From 57aaa96a59e6fb57719cfbcb95136ed6bda8dd82 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Thu, 19 Feb 2026 16:20:02 +0100 Subject: [PATCH] Add file manager to admin panel with upload, list, and delete functionality --- pages/admin.vue | 203 +++++++++++++++++++++++++------ server/api/admin/files.js | 52 ++++++++ server/api/admin/files.upload.js | 60 +++++++++ 3 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 server/api/admin/files.js create mode 100644 server/api/admin/files.upload.js diff --git a/pages/admin.vue b/pages/admin.vue index 85f998d..729f168 100644 --- a/pages/admin.vue +++ b/pages/admin.vue @@ -14,49 +14,105 @@

Admin

- + +
+

Collections

+ +
+ +
+

Files

+ +
+
-
-

{{ collections.find(c => c.key === selectedCollection)?.label }}

- -
+ + + + +
@@ -83,10 +139,22 @@ import { collections } from '@/admin/schemas' const password = ref('') const authenticated = ref(false) +const selectedView = ref('collections') const selectedCollection = ref('works') +const selectedFolder = ref('scores') const items = ref([]) +const files = ref([]) const rawJsonItem = ref(null) const rawJsonContent = ref('') +const isUploading = ref(false) + +const fileFolders = [ + { key: 'scores', label: 'Scores' }, + { key: 'pubs', label: 'Publications' }, + { key: 'album_art', label: 'Album Art' }, + { key: 'images', label: 'Images' }, + { key: 'hdp_images', label: 'HDP Images' } +] function checkPassword(data) { if (data.password === 'admin123') { @@ -103,6 +171,16 @@ function logout() { } function getTitle(item) { + if (selectedCollection.value === 'publications' && item.entryTags?.title) { + return item.entryTags.title + } + if (selectedCollection.value === 'events' && item.start_date) { + const v = item.venue + return `${item.start_date}: ${v?.name} - ${v?.city}, ${v?.state}` + } + if (selectedCollection.value === 'talks' && item.date) { + return `${item.date}: ${item.location}` + } return item.title || item.citationKey || item.name || item.id } @@ -111,6 +189,11 @@ async function loadItems() { items.value = data.value || [] } +async function loadFiles() { + const { data } = await useFetch(`/api/admin/files?folder=${selectedFolder.value}`) + files.value = data.value || [] +} + function createNew() { rawJsonItem.value = { id: null } rawJsonContent.value = '{\n \n}' @@ -145,7 +228,55 @@ async function deleteItem(item) { } } +async function handleFileUpload(event) { + const file = event.target.files?.[0] + if (!file) return + + isUploading.value = true + const formData = new FormData() + formData.append('file', file) + + try { + await $fetch(`/api/admin/files/upload?folder=${selectedFolder.value}`, { + method: 'POST', + body: formData + }) + loadFiles() + } catch (e) { + alert('Upload failed: ' + e.message) + } finally { + isUploading.value = false + event.target.value = '' + } +} + +async function deleteFile(file) { + if (confirm(`Delete ${file.name}?`)) { + await $fetch(`/api/admin/files?folder=${selectedFolder.value}&file=${file.name}`, { + method: 'DELETE' + }) + loadFiles() + } +} + +function copyUrl(url) { + navigator.clipboard.writeText(window.location.origin + url) + alert('URL copied to clipboard!') +} + watch(selectedCollection, () => { loadItems() }) + +watch(selectedFolder, () => { + loadFiles() +}) + +watch(selectedView, (newView) => { + if (newView === 'collections') { + loadItems() + } else if (newView === 'files') { + loadFiles() + } +}) diff --git a/server/api/admin/files.js b/server/api/admin/files.js new file mode 100644 index 0000000..0bcb1fc --- /dev/null +++ b/server/api/admin/files.js @@ -0,0 +1,52 @@ +import { readdirSync, statSync, unlinkSync, writeFileSync, existsSync } from 'node:fs' +import { join, extname } from 'node:path' + +const publicDir = './public' + +const allowedFolders = ['scores', 'pubs', 'album_art', 'images', 'hdp_images'] + +function getFolderPath(folder) { + return join(publicDir, folder) +} + +export default defineEventHandler(async (event) => { + const method = event.method + const query = getQuery(event) + const folder = query.folder + + if (!folder || !allowedFolders.includes(folder)) { + throw createError({ statusCode: 400, message: 'Invalid folder' }) + } + + const folderPath = getFolderPath(folder) + + if (!existsSync(folderPath)) { + throw createError({ statusCode: 404, message: 'Folder not found' }) + } + + if (method === 'GET') { + const files = readdirSync(folderPath) + .filter(f => statSync(join(folderPath, f)).isFile()) + .map(f => ({ + name: f, + size: statSync(join(folderPath, f)).size, + url: `/${folder}/${f}` + })) + return files + } + + if (method === 'DELETE') { + const filename = query.file + if (!filename) { + throw createError({ statusCode: 400, message: 'Filename required' }) + } + const filePath = join(folderPath, filename) + if (existsSync(filePath)) { + unlinkSync(filePath) + return { success: true } + } + throw createError({ statusCode: 404, message: 'File not found' }) + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/server/api/admin/files.upload.js b/server/api/admin/files.upload.js new file mode 100644 index 0000000..11ed733 --- /dev/null +++ b/server/api/admin/files.upload.js @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { readMultipartFormData } from 'h3' + +const publicDir = './public' + +const allowedFolders = ['scores', 'pubs', 'album_art', 'images', 'hdp_images'] +const allowedExtensions = ['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.mp3', '.wav', '.ogg'] + +function getFolderPath(folder) { + return join(publicDir, folder) +} + +function sanitizeFilename(filename) { + return filename.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +export default defineEventHandler(async (event) => { + const query = getQuery(event) + const folder = query.folder + + if (!folder || !allowedFolders.includes(folder)) { + throw createError({ statusCode: 400, message: 'Invalid folder' }) + } + + const folderPath = getFolderPath(folder) + + if (!existsSync(folderPath)) { + mkdirSync(folderPath, { recursive: true }) + } + + const formData = await readMultipartFormData(event) + + if (!formData || formData.length === 0) { + throw createError({ statusCode: 400, message: 'No file uploaded' }) + } + + const fileField = formData.find(f => f.name === 'file') + + if (!fileField || !fileField.data) { + throw createError({ statusCode: 400, message: 'No file data' }) + } + + const ext = fileField.filename ? fileField.filename.toLowerCase().slice(fileField.filename.lastIndexOf('.')) : '' + + if (!allowedExtensions.includes(ext)) { + throw createError({ statusCode: 400, message: `File type not allowed. Allowed: ${allowedExtensions.join(', ')}` }) + } + + const filename = fileField.filename ? sanitizeFilename(fileField.filename) : `upload${ext}` + const filePath = join(folderPath, filename) + + writeFileSync(filePath, fileField.data) + + return { + success: true, + filename, + url: `/${folder}/${filename}` + } +})