2026-02-19 15:56:20 +01:00
|
|
|
<template>
|
|
|
|
|
<div class="min-h-screen bg-gray-100">
|
|
|
|
|
<div v-if="!authenticated" class="flex items-center justify-center min-h-screen">
|
|
|
|
|
<FormKit type="form" @submit="checkPassword" submit-label="Login">
|
|
|
|
|
<FormKit
|
|
|
|
|
type="password"
|
|
|
|
|
name="password"
|
|
|
|
|
label="Password"
|
|
|
|
|
validation="required"
|
|
|
|
|
/>
|
|
|
|
|
</FormKit>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-02-19 16:20:02 +01:00
|
|
|
|
|
|
|
|
<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="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>
|
|
|
|
|
|
2026-02-19 15:56:20 +01:00
|
|
|
<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">
|
2026-02-19 16:20:02 +01:00
|
|
|
<!-- 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">
|
|
|
|
|
Add New
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-19 15:56:20 +01:00
|
|
|
|
2026-02-19 16:29:41 +01:00
|
|
|
<input
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search..."
|
|
|
|
|
class="w-full mb-4 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-black"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-02-19 16:20:02 +01:00
|
|
|
<div class="bg-white rounded-lg shadow overflow-hidden mb-8">
|
|
|
|
|
<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">Title</th>
|
|
|
|
|
<th class="px-4 py-2 text-left text-sm font-medium text-gray-500">Actions</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-02-19 16:29:41 +01:00
|
|
|
<tr v-for="item in filteredItems" :key="item.id" class="border-t hover:bg-gray-50">
|
2026-02-19 16:20:02 +01:00
|
|
|
<td class="px-4 py-3">{{ getTitle(item) }}</td>
|
|
|
|
|
<td class="px-4 py-3 space-x-2">
|
|
|
|
|
<button @click="viewRawJson(item)" class="text-green-600 hover:text-green-800">JSON</button>
|
|
|
|
|
<button @click="deleteItem(item)" class="text-red-600 hover:text-red-800">Delete</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</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>
|
|
|
|
|
|
2026-02-19 16:29:41 +01:00
|
|
|
<input
|
|
|
|
|
v-model="fileSearchQuery"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="Search files..."
|
|
|
|
|
class="w-full mb-4 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-black"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-02-19 16:20:02 +01:00
|
|
|
<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>
|
2026-02-19 16:29:41 +01:00
|
|
|
<tr v-for="file in filteredFiles" :key="file.name" class="border-t hover:bg-gray-50">
|
2026-02-19 16:20:02 +01:00
|
|
|
<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>
|
2026-02-19 15:56:20 +01:00
|
|
|
|
|
|
|
|
<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">
|
|
|
|
|
<h2 class="text-xl font-bold mb-4">Raw JSON</h2>
|
|
|
|
|
<textarea
|
|
|
|
|
v-model="rawJsonContent"
|
|
|
|
|
class="flex-1 w-full font-mono text-sm border rounded p-4 resize-none"
|
|
|
|
|
spellcheck="false"
|
|
|
|
|
></textarea>
|
|
|
|
|
<div class="flex justify-end gap-2 mt-4">
|
|
|
|
|
<button @click="rawJsonItem = null" class="px-4 py-2 border rounded hover:bg-gray-50">Cancel</button>
|
|
|
|
|
<button @click="saveRawJson" class="px-4 py-2 bg-black text-white rounded hover:bg-gray-800">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
2026-02-19 16:29:41 +01:00
|
|
|
import { ref, watch, computed } from 'vue'
|
2026-02-19 15:56:20 +01:00
|
|
|
import { collections } from '@/admin/schemas'
|
|
|
|
|
|
|
|
|
|
const password = ref('')
|
|
|
|
|
const authenticated = ref(false)
|
2026-02-19 16:20:02 +01:00
|
|
|
const selectedView = ref('collections')
|
2026-02-19 15:56:20 +01:00
|
|
|
const selectedCollection = ref('works')
|
2026-02-19 16:20:02 +01:00
|
|
|
const selectedFolder = ref('scores')
|
2026-02-19 15:56:20 +01:00
|
|
|
const items = ref([])
|
2026-02-19 16:20:02 +01:00
|
|
|
const files = ref([])
|
2026-02-19 15:56:20 +01:00
|
|
|
const rawJsonItem = ref(null)
|
|
|
|
|
const rawJsonContent = ref('')
|
2026-02-19 16:20:02 +01:00
|
|
|
const isUploading = ref(false)
|
2026-02-19 16:29:41 +01:00
|
|
|
const searchQuery = ref('')
|
|
|
|
|
const fileSearchQuery = ref('')
|
|
|
|
|
|
|
|
|
|
const searchFields = {
|
|
|
|
|
works: ['title', 'type', 'instrument_tags'],
|
|
|
|
|
publications: ['entryTags.title', 'entryTags.year', 'citationKey'],
|
|
|
|
|
events: ['venue.name', 'venue.city', 'start_date'],
|
|
|
|
|
releases: ['title', 'year'],
|
|
|
|
|
talks: ['title', 'location', 'date']
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filteredItems = computed(() => {
|
|
|
|
|
if (!searchQuery.value.trim()) return items.value
|
|
|
|
|
const query = searchQuery.value.toLowerCase()
|
|
|
|
|
const fields = searchFields[selectedCollection.value] || []
|
|
|
|
|
|
|
|
|
|
return items.value.filter(item => {
|
|
|
|
|
for (const field of fields) {
|
|
|
|
|
const value = field.split('.').reduce((obj, key) => obj?.[key], item)
|
|
|
|
|
if (value && String(value).toLowerCase().includes(query)) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const filteredFiles = computed(() => {
|
|
|
|
|
if (!fileSearchQuery.value.trim()) return files.value
|
|
|
|
|
const normalize = (str) => str.toLowerCase().replace(/[\s_-]+/g, '')
|
|
|
|
|
const query = normalize(fileSearchQuery.value)
|
|
|
|
|
|
|
|
|
|
return files.value.filter(file => normalize(file.name).includes(query))
|
|
|
|
|
})
|
2026-02-19 16:20:02 +01:00
|
|
|
|
|
|
|
|
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' }
|
|
|
|
|
]
|
2026-02-19 15:56:20 +01:00
|
|
|
|
|
|
|
|
function checkPassword(data) {
|
|
|
|
|
if (data.password === 'admin123') {
|
|
|
|
|
authenticated.value = true
|
|
|
|
|
loadItems()
|
|
|
|
|
} else {
|
|
|
|
|
alert('Incorrect password')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logout() {
|
|
|
|
|
authenticated.value = false
|
|
|
|
|
password.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTitle(item) {
|
2026-02-19 16:20:02 +01:00
|
|
|
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}`
|
|
|
|
|
}
|
2026-02-19 15:56:20 +01:00
|
|
|
return item.title || item.citationKey || item.name || item.id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadItems() {
|
|
|
|
|
const { data } = await useFetch(`/api/admin/${selectedCollection.value}`)
|
|
|
|
|
items.value = data.value || []
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 16:20:02 +01:00
|
|
|
async function loadFiles() {
|
|
|
|
|
const { data } = await useFetch(`/api/admin/files?folder=${selectedFolder.value}`)
|
|
|
|
|
files.value = data.value || []
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:56:20 +01:00
|
|
|
function createNew() {
|
|
|
|
|
rawJsonItem.value = { id: null }
|
|
|
|
|
rawJsonContent.value = '{\n \n}'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function viewRawJson(item) {
|
|
|
|
|
rawJsonItem.value = item
|
|
|
|
|
rawJsonContent.value = JSON.stringify(item, null, 2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveRawJson() {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(rawJsonContent.value)
|
|
|
|
|
const method = parsed.id ? 'PUT' : 'POST'
|
|
|
|
|
await useFetch(`/api/admin/${selectedCollection.value}`, {
|
|
|
|
|
method,
|
|
|
|
|
body: parsed
|
|
|
|
|
})
|
|
|
|
|
rawJsonItem.value = null
|
|
|
|
|
loadItems()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
alert('Invalid JSON')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteItem(item) {
|
|
|
|
|
if (confirm('Are you sure you want to delete this item?')) {
|
|
|
|
|
await useFetch(`/api/admin/${selectedCollection.value}/${item.id}`, {
|
|
|
|
|
method: 'DELETE'
|
|
|
|
|
})
|
|
|
|
|
loadItems()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 16:20:02 +01:00
|
|
|
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!')
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:56:20 +01:00
|
|
|
watch(selectedCollection, () => {
|
|
|
|
|
loadItems()
|
|
|
|
|
})
|
2026-02-19 16:20:02 +01:00
|
|
|
|
|
|
|
|
watch(selectedFolder, () => {
|
|
|
|
|
loadFiles()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(selectedView, (newView) => {
|
|
|
|
|
if (newView === 'collections') {
|
|
|
|
|
loadItems()
|
|
|
|
|
} else if (newView === 'files') {
|
|
|
|
|
loadFiles()
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-02-19 15:56:20 +01:00
|
|
|
</script>
|