Add file manager to admin panel with upload, list, and delete functionality
This commit is contained in:
parent
5202d5f8e1
commit
57aaa96a59
139
pages/admin.vue
139
pages/admin.vue
|
|
@ -14,23 +14,45 @@
|
|||
<div v-else class="flex min-h-screen">
|
||||
<div class="w-64 bg-white border-r p-4">
|
||||
<h2 class="text-xl font-bold mb-4">Admin</h2>
|
||||
<nav class="space-y-2">
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Collections</h3>
|
||||
<nav class="space-y-1">
|
||||
<button
|
||||
v-for="col in collections"
|
||||
:key="col.key"
|
||||
@click="selectedCollection = col.key"
|
||||
class="w-full text-left px-4 py-2 rounded"
|
||||
:class="selectedCollection === col.key ? 'bg-black text-white' : 'hover:bg-gray-100'"
|
||||
@click="selectedView = 'collections'; selectedCollection = col.key"
|
||||
class="w-full text-left px-4 py-2 rounded text-sm"
|
||||
:class="selectedView === 'collections' && selectedCollection === col.key ? 'bg-black text-white' : 'hover:bg-gray-100'"
|
||||
>
|
||||
{{ col.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Files</h3>
|
||||
<nav class="space-y-1">
|
||||
<button
|
||||
v-for="folder in fileFolders"
|
||||
:key="folder.key"
|
||||
@click="selectedView = 'files'; selectedFolder = folder.key"
|
||||
class="w-full text-left px-4 py-2 rounded text-sm"
|
||||
:class="selectedView === 'files' && selectedFolder === folder.key ? 'bg-black text-white' : 'hover:bg-gray-100'"
|
||||
>
|
||||
{{ folder.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<button @click="logout" class="mt-8 w-full px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-8 overflow-auto">
|
||||
<!-- Collections View -->
|
||||
<template v-if="selectedView === 'collections'">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ collections.find(c => c.key === selectedCollection)?.label }}</h1>
|
||||
<button @click="createNew" class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800">
|
||||
|
|
@ -57,6 +79,40 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Files View -->
|
||||
<template v-else-if="selectedView === 'files'">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ fileFolders.find(f => f.key === selectedFolder)?.label }}</h1>
|
||||
<label class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800 cursor-pointer">
|
||||
{{ isUploading ? 'Uploading...' : 'Upload File' }}
|
||||
<input type="file" class="hidden" @change="handleFileUpload" :disabled="isUploading" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">File</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Size</th>
|
||||
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files" :key="file.name" class="border-t hover:bg-gray-50">
|
||||
<td class="px-4 py-3">{{ file.name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500">{{ (file.size / 1024).toFixed(1) }} KB</td>
|
||||
<td class="px-4 py-3 space-x-2">
|
||||
<button @click="copyUrl(file.url)" class="text-blue-600 hover:text-blue-800">Copy URL</button>
|
||||
<button @click="deleteFile(file)" class="text-red-600 hover:text-red-800">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="rawJsonItem" class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col p-6">
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
52
server/api/admin/files.js
Normal file
52
server/api/admin/files.js
Normal file
|
|
@ -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' })
|
||||
})
|
||||
60
server/api/admin/files.upload.js
Normal file
60
server/api/admin/files.upload.js
Normal file
|
|
@ -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}`
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue