diff --git a/controllers/fileController.js b/controllers/fileController.js new file mode 100644 index 0000000..aecd48d --- /dev/null +++ b/controllers/fileController.js @@ -0,0 +1,144 @@ +const fs = require('fs'); +const path = require('path'); +const { File, Page } = require('../models'); +const { v4: uuidv4 } = require('uuid'); + +const uploadDir = path.join(__dirname, '..', 'files', 'uploads'); + +const uploadFile = (req, res, next) => { + if (!req.file) { + return res.status(400).send('No file uploaded.'); + } + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + const file = req.file; + const hash = uuidv4(); + const filePath = path.join(uploadDir, hash + path.extname(file.originalname)); + try { + fs.writeFileSync(filePath, file.buffer); + req.fileData = { + hash: hash, + title: req.body.title || file.originalname, + originalName: file.originalname, + path: filePath + }; + next(); + } catch (error) { + return res.status(500).send('File upload failed.'); + } +}; + +const saveFileDetails = async (req, res) => { + try { + const { hash, title, originalName, path } = req.fileData; + const newFile = await File.create({ + hash, + title, + originalName, + path + }); + res.status(201).json(newFile); + } catch (error) { + console.log(error); + res.status(500).json({ error: 'Failed to save file details.' }); + } +}; + +const getFiles = async (req, res) => { + try { + const files = await File.findAll(); + res.status(200).json(files); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch files.' }); + } +}; + +const getFilesByPage = async (req, res) => { + try { + const { pageId } = req.params; + const page = await Page.findByPk(pageId, { + include: [File] + }); + if (!page) { + return res.status(404).json({ error: 'Page not found.' }); + } + res.status(200).json(page.Files); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch files for the page.' }); + } +}; + +const getFileById = async (req, res) => { + try { + const { id } = req.params; + const file = await File.findByPk(id); + if (!file) { + return res.status(404).json({ error: 'File not found.' }); + } + res.status(200).json(file); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch file.' }); + } +}; + +const getFileByHash = async (req, res) => { + try { + const { hash } = req.params; + const file = await File.findOne({ where: { hash } }); + if (!file) { + return res.status(404).json({ error: 'File not found.' }); + } + res.status(200).json(file); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch file.' }); + } +}; + +const downloadFile = async (req, res) => { + try { + const { hash } = req.params; + const file = await File.findOne({ where: { hash } }); + if (!file) { + return res.status(404).json({ error: 'File not found.' }); + } + const filePath = path.join(__dirname, '..', 'files', 'uploads', hash + path.extname(file.originalName)); + res.download(filePath, file.originalName, (err) => { + if (err) { + res.status(500).send({ + message: "Could not download the file. " + err, + }); + } + }); + } catch (error) { + console.log(error); + res.status(500).json({ error: 'Failed to download file.' }); + } +}; + +const updateFile = async (req, res) => { + try { + const { id } = req.params; + const { title } = req.body; + const file = await File.findByPk(id); + if (!file) { + return res.status(404).json({ error: 'File not found.' }); + } + file.title = title || file.title; + await file.save(); + res.status(200).json(file); + } catch (error) { + res.status500.json({ error: 'Failed to update file.' }); + } +}; + +module.exports = { + uploadFile, + saveFileDetails, + getFiles, + getFilesByPage, + getFileById, + getFileByHash, + downloadFile, + updateFile, +}; diff --git a/files/uploads/1e221880-7626-406d-bed6-01692eb71e48.pdf b/files/uploads/1e221880-7626-406d-bed6-01692eb71e48.pdf new file mode 100644 index 0000000..986e880 Binary files /dev/null and b/files/uploads/1e221880-7626-406d-bed6-01692eb71e48.pdf differ diff --git a/files/uploads/30e723d1-bde0-4459-8d0e-06b820f1ccab.pdf b/files/uploads/30e723d1-bde0-4459-8d0e-06b820f1ccab.pdf new file mode 100644 index 0000000..986e880 Binary files /dev/null and b/files/uploads/30e723d1-bde0-4459-8d0e-06b820f1ccab.pdf differ diff --git a/files/uploads/c6f81d13-955b-4ab5-bee8-ca260dfad1c5.pdf b/files/uploads/c6f81d13-955b-4ab5-bee8-ca260dfad1c5.pdf new file mode 100644 index 0000000..986e880 Binary files /dev/null and b/files/uploads/c6f81d13-955b-4ab5-bee8-ca260dfad1c5.pdf differ diff --git a/migrations/20240621071724-create-files-table.js b/migrations/20240621071724-create-files-table.js new file mode 100644 index 0000000..f90e375 --- /dev/null +++ b/migrations/20240621071724-create-files-table.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('files', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER + }, + hash: { + type: Sequelize.STRING, + unique: true, + allowNull: false + }, + title: { + type: Sequelize.STRING, + allowNull: false + }, + originalName: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('files'); + } +}; diff --git a/migrations/20240621071827-create-page-files-table.js b/migrations/20240621071827-create-page-files-table.js new file mode 100644 index 0000000..b7e062b --- /dev/null +++ b/migrations/20240621071827-create-page-files-table.js @@ -0,0 +1,38 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('page_files', { + pageId: { + type: Sequelize.INTEGER, + references: { + model: 'pages', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + fileId: { + type: Sequelize.INTEGER, + references: { + model: 'files', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE + } + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('page_files'); + } +}; diff --git a/migrations/20240621073053-alter-files-table.js b/migrations/20240621073053-alter-files-table.js new file mode 100644 index 0000000..79c2714 --- /dev/null +++ b/migrations/20240621073053-alter-files-table.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('Files', 'originalName', { + type: Sequelize.STRING, + allowNull: false, + after: 'title' // optional: to place the column after 'title' + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('Files', 'originalName'); + } +}; diff --git a/models/File.js b/models/File.js new file mode 100644 index 0000000..cd42faa --- /dev/null +++ b/models/File.js @@ -0,0 +1,28 @@ +const { DataTypes } = require('sequelize'); + +module.exports = (sequelize) => { + const File = sequelize.define('File', { + hash: { + type: DataTypes.STRING, + allowNull: false, + unique: true + }, + title: { + type: DataTypes.STRING, + allowNull: false + }, + originalName: { + type: DataTypes.STRING, + allowNull: false + } + }, { + tableName: 'files', + timestamps: true + }); + + File.associate = (models) => { + File.belongsToMany(models.Page, { through: 'PageFiles' }); + }; + + return File; +}; diff --git a/models/Page.js b/models/Page.js index 918016d..dcad30e 100644 --- a/models/Page.js +++ b/models/Page.js @@ -21,5 +21,9 @@ module.exports = (sequelize) => { timestamps: true }); + Page.associate = (models) => { + Page.belongsToMany(models.File, { through: 'page_files', foreignKey: 'pageId' }); + }; + return Page; }; diff --git a/package-lock.json b/package-lock.json index 445d541..a272345 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "date-fns": "^3.6.0", "dotenv": "^16.4.5", "express": "^4.19.2", + "file-saver": "^2.0.5", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "multer": "^1.4.5-lts.1", @@ -7379,6 +7380,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/package.json b/package.json index 1407370..7a40bb1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "date-fns": "^3.6.0", "dotenv": "^16.4.5", "express": "^4.19.2", + "file-saver": "^2.0.5", "jsonwebtoken": "^9.0.2", "moment": "^2.30.1", "multer": "^1.4.5-lts.1", diff --git a/routes/files.js b/routes/files.js new file mode 100644 index 0000000..702cefe --- /dev/null +++ b/routes/files.js @@ -0,0 +1,17 @@ +const express = require('express'); +const router = express.Router(); +const multer = require('multer'); +const upload = multer({ storage: multer.memoryStorage() }); + +const { uploadFile, saveFileDetails, getFiles, getFilesByPage, getFileById, getFileByHash, downloadFile, updateFile } = require('../controllers/fileController'); +const authMiddleware = require('../middleware/authMiddleware'); + +router.post('/', authMiddleware, upload.single('file'), uploadFile, saveFileDetails); +router.get('/', authMiddleware, getFiles); +router.get('/page/:pageId', getFilesByPage); +router.get('/:id', getFileById); +router.get('/hash/:hash', getFileByHash); +router.get('/download/:hash', downloadFile); +router.put('/:id', authMiddleware, updateFile); + +module.exports = router; diff --git a/server.js b/server.js index 1fbfd5f..00c6ed4 100644 --- a/server.js +++ b/server.js @@ -2,18 +2,19 @@ const express = require('express'); const bodyParser = require('body-parser'); const cors = require('cors'); const sequelize = require('./config/database'); -const authRoutes = require('./routes/auth'); +const authRouter = require('./routes/auth'); const eventTypesRouter = require('./routes/eventtypes'); const eventPlacesRouter = require('./routes/eventPlaces'); const contactPersonsRouter = require('./routes/contactPerson'); const positionsRouter = require('./routes/positions'); -const institutionRoutes = require('./routes/institutions'); +const institutionRouter = require('./routes/institutions'); const eventRouter = require('./routes/event'); const menuDataRouter = require('./routes/menuData'); const worshipRouter = require('./routes/worships'); -const pageRoutes = require('./routes/pages'); -const userRoutes = require('./routes/users'); -const imageRoutes = require('./routes/image'); +const pageRouter = require('./routes/pages'); +const userRouter = require('./routes/users'); +const imageRouter = require('./routes/image'); +const filesRouter = require('./routes/files'); const app = express(); const PORT = 3000; @@ -21,18 +22,19 @@ const PORT = 3000; app.use(cors()); app.use(bodyParser.json()); -app.use('/api/auth', authRoutes); +app.use('/api/auth', authRouter); app.use('/api/event-types', eventTypesRouter); app.use('/api/event-places', eventPlacesRouter); app.use('/api/contact-persons', contactPersonsRouter); app.use('/api/positions', positionsRouter); -app.use('/api/institutions', institutionRoutes); +app.use('/api/institutions', institutionRouter); app.use('/api/events', eventRouter); app.use('/api/menu-data', menuDataRouter); app.use('/api/worships', worshipRouter); -app.use('/api/page-content', pageRoutes); -app.use('/api/users', userRoutes); -app.use('/api/image', imageRoutes); +app.use('/api/page-content', pageRouter); +app.use('/api/users', userRouter); +app.use('/api/image', imageRouter); +app.use('/api/files', filesRouter); sequelize.sync().then(() => { app.listen(PORT, () => { console.log(`Server läuft auf Port ${PORT}`); diff --git a/src/components/AddDownloadDialog.vue b/src/components/AddDownloadDialog.vue new file mode 100644 index 0000000..f307206 --- /dev/null +++ b/src/components/AddDownloadDialog.vue @@ -0,0 +1,98 @@ + + + + + + \ No newline at end of file diff --git a/src/components/ComponentsLink.vue b/src/components/ComponentsLink.vue new file mode 100644 index 0000000..f5c53d5 --- /dev/null +++ b/src/components/ComponentsLink.vue @@ -0,0 +1,43 @@ + + + + + + \ No newline at end of file diff --git a/src/components/DownloadLink.vue b/src/components/DownloadLink.vue new file mode 100644 index 0000000..503f86e --- /dev/null +++ b/src/components/DownloadLink.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/src/components/RenderContentComponent.vue b/src/components/RenderContentComponent.vue index c5e3da0..9ddf846 100644 --- a/src/components/RenderContentComponent.vue +++ b/src/components/RenderContentComponent.vue @@ -7,6 +7,7 @@ import { createApp, h, ref, watch } from 'vue'; import WorshipRender from './WorshipRender.vue'; import ImageRender from './ImageRender.vue'; import EventRender from './EventRender.vue'; +import DownloadLink from './DownloadLink.vue'; export default { name: 'RenderContentComponent', @@ -21,8 +22,9 @@ export default { const renderContent = (content) => { let result = renderWorship(content); - result = renderImage(result); // Use result here - result = renderEvent(result); // Use result here + result = renderImage(result); + result = renderEvent(result); + result = renderDownload(result); return result; }; @@ -58,7 +60,6 @@ export default { if (placeholder) { const app = createApp({ render() { - console.log(config); return h(ImageRender, { id: config }); }, }); @@ -74,9 +75,7 @@ export default { const eventsPattern = /{{ events:(.*?) }}/g; let result = content; result = result.replace(eventsPattern, (match, config) => { - console.log(config); const props = parseConfig(config); - console.log(props); const placeholderId = `event-render-placeholder-${Math.random().toString(36).substr(2, 9)}`; setTimeout(() => { const placeholder = document.getElementById(placeholderId); @@ -94,6 +93,27 @@ export default { return result; }; + const renderDownload = (content) => { + const downloadPattern = /{{ download title="(.*?)" hash="(.*?)" extension="(.*?)" }}/g; + let result = content; + result = result.replace(downloadPattern, (match, title, hash, extension) => { + const placeholderId = `download-render-placeholder-${Math.random().toString(36).substr(2, 9)}`; + setTimeout(() => { + const placeholder = document.getElementById(placeholderId); + if (placeholder) { + const app = createApp({ + render() { + return h(DownloadLink, { title, hash, extension }); + }, + }); + app.mount(placeholder); + } + }, 0); + return `
`; + }); + return result; + }; + const parseConfig = (configString) => { const config = {}; const configArray = configString.split(','); diff --git a/src/content/admin/EditPagesComponent.vue b/src/content/admin/EditPagesComponent.vue index 1f73938..82c6e2b 100644 --- a/src/content/admin/EditPagesComponent.vue +++ b/src/content/admin/EditPagesComponent.vue @@ -8,28 +8,28 @@
- - - - + + + - - - - - - +
- - - - - - - -
@@ -86,6 +89,7 @@ + @@ -113,9 +117,11 @@ import WorshipDialog from '@/components/WorshipDialog.vue'; import AddImageDialog from '@/components/AddImageDialog.vue'; import AddEventDialog from '@/components/AddEventDialog.vue'; import AddLinkDialog from '@/components/AddLinkDialog.vue'; +import AddDownloadDialog from '@/components/AddDownloadDialog.vue'; import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, ListIcon, NumberedListLeftIcon, TableIcon, - Table2ColumnsIcon, ArrowDownIcon, ArrowRightIcon, TableRowsIcon, AlignTopBoxIcon, AlignLeftBoxIcon, StatsReportIcon + Table2ColumnsIcon, ArrowDownIcon, ArrowRightIcon, TableRowsIcon, AlignTopBoxIcon, AlignLeftBoxIcon, StatsReportIcon, + OpenInWindowIcon, DownloadIcon } from '@/icons'; export default { @@ -140,6 +146,9 @@ export default { StatsReportIcon, AddEventDialog, AddLinkDialog, + AddDownloadDialog, + OpenInWindowIcon, + DownloadIcon, }, setup() { const store = useStore(); @@ -150,6 +159,7 @@ export default { const addImageDialog = ref(null); const addEventDialog = ref(null); const addLinkDialog = ref(null); + const addDownloadDialog = ref(null); const colorPicker = ref(null); const editor = useEditor({ @@ -297,6 +307,17 @@ export default { } }; + const openAddDownloadDialog = () => { + addDownloadDialog.value.openAddDownloadDialog(); + }; + + const insertDownload = ({ title, hash, extension }) => { + if (title && hash && extension && editor.value) { + const url = `/files/download/${hash}`; + editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).insertContent(title).run(); + } + }; + const openColorPicker = () => { colorPicker.value.click(); }; @@ -308,6 +329,38 @@ export default { } }; + const toggleHeading = (level) => { + editor.value.chain().focus().toggleHeading({level: level}).run(); + }; + + const toggleItalic = () => { + editor.value.chain().focus().toggleItalic().run(); + }; + + const toggleBold = () => { + editor.value.chain().focus().toggleBold().run(); + }; + + const toggleUnderline = () => { + editor.value.chain().focus().toggleUnderline().run(); + }; + + const toggleStrike = () => { + editor.value.chain().focus().toggleStrike().run(); + }; + + const insertTable = () => { + editor.value.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }; + + const toggleBulletList = () => { + editor.value.chain().focus().toggleBulletList().run(); + }; + + const toggleOrderedList = () => { + editor.value.chain().focus().toggleOrderedList().run(); + }; + return { pages, sortedPages, @@ -328,9 +381,20 @@ export default { addLinkDialog, openAddLinkDialog, insertLink, + addDownloadDialog, + openAddDownloadDialog, + insertDownload, colorPicker, openColorPicker, setColor, + toggleHeading, + toggleBold, + toggleItalic, + toggleUnderline, + toggleStrike, + insertTable, + toggleBulletList, + toggleOrderedList, }; }, }; diff --git a/src/content/admin/UploadFileManagement.vue b/src/content/admin/UploadFileManagement.vue new file mode 100644 index 0000000..b429384 --- /dev/null +++ b/src/content/admin/UploadFileManagement.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/icons.js b/src/icons.js index 90413cb..4d9f546 100644 --- a/src/icons.js +++ b/src/icons.js @@ -1,6 +1,6 @@ // src/icons.js import { Bold, Italic, Underline, Strikethrough, List, NumberedListLeft, Table, Table2Columns, ArrowRight, ArrowDown, TableRows, - AlignTopBox, AlignLeftBox, StatsReport, OpenInWindow + AlignTopBox, AlignLeftBox, StatsReport, OpenInWindow, Download } from '@iconoir/vue'; export { @@ -19,4 +19,5 @@ export { AlignLeftBox as AlignLeftBoxIcon, StatsReport as StatsReportIcon, OpenInWindow as OpenInWindowIcon, + Download as DownloadIcon, };