Add admin panel with password protection and JSON editing

This commit is contained in:
Michael Winter 2026-02-19 15:56:20 +01:00
parent 57014401ff
commit 5202d5f8e1
7 changed files with 448 additions and 0 deletions

49
admin/schemas/index.js Normal file
View file

@ -0,0 +1,49 @@
export const schemas = {
works: {
title: { type: 'text', label: 'Title' },
date: { type: 'text', label: 'Date' },
type: { type: 'text', label: 'Type' },
score: { type: 'text', label: 'Score URL' },
gallery: { type: 'text', label: 'Gallery' },
soundcloud_trackid: { type: 'text', label: 'SoundCloud Track ID' },
vimeo_trackid: { type: 'text', label: 'Vimeo Track ID' },
instrument_tags: { type: 'text', label: 'Instrument Tags' },
priority: { type: 'number', label: 'Priority' }
},
publications: {
citationKey: { type: 'text', label: 'Citation Key' },
entryType: { type: 'text', label: 'Entry Type' },
entryTags: { type: 'textarea', label: 'Entry Tags (JSON)' }
},
events: {
title: { type: 'text', label: 'Title' },
start_date: { type: 'text', label: 'Start Date' },
end_date: { type: 'text', label: 'End Date' },
location: { type: 'text', label: 'Location' },
type: { type: 'text', label: 'Type' },
program: { type: 'textarea', label: 'Program' },
works_list: { type: 'textarea', label: 'Works List' }
},
releases: {
title: { type: 'text', label: 'Title' },
year: { type: 'text', label: 'Year' },
album_art: { type: 'text', label: 'Album Art' },
discogs_id: { type: 'text', label: 'Discogs ID' },
buy_link: { type: 'text', label: 'Buy Link' },
spotify_id: { type: 'text', label: 'Spotify ID' }
},
talks: {
title: { type: 'text', label: 'Title' },
date: { type: 'text', label: 'Date' },
location: { type: 'text', label: 'Location' },
type: { type: 'text', label: 'Type' }
}
}
export const collections = [
{ key: 'works', label: 'Works', file: 'works.json' },
{ key: 'publications', label: 'Publications', file: 'publications.json' },
{ key: 'events', label: 'Events', file: 'events.json' },
{ key: 'releases', label: 'Releases', file: 'releases.json' },
{ key: 'talks', label: 'Talks', file: 'talks.json' }
]

109
package-lock.json generated
View file

@ -7,6 +7,8 @@
"name": "nuxt-app", "name": "nuxt-app",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@formkit/themes": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"mongodb": "^7.1.0", "mongodb": "^7.1.0",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",
@ -1050,6 +1052,113 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@formkit/core": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/core/-/core-1.7.2.tgz",
"integrity": "sha512-XDRVqkDtOziU3z44hdvgWEqGpiv6nTNeCmP8cnESKkr24a4keQuEwmfDVvibYV0ywyTMg6ylp2lyklCYeRHykQ==",
"license": "MIT",
"dependencies": {
"@formkit/utils": "^1.7.2"
}
},
"node_modules/@formkit/dev": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/dev/-/dev-1.7.2.tgz",
"integrity": "sha512-W38xbbFS4h4LTV22kUC6ZbPHBmlM2lVbAz5PSYU3SPaNc4FeXp4GTe4GzLEmcS82B+5L1zbSOgGB43kWZts7wA==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/utils": "^1.7.2"
}
},
"node_modules/@formkit/i18n": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/i18n/-/i18n-1.7.2.tgz",
"integrity": "sha512-Zs6f+rtP2j2Nnt1HkNrL85WU6rtS1l1Q0BAQjCMGQPsbrppiL7/TZ5bNIrVm0DTntLgG3firDS7bNWAxPSwerQ==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/utils": "^1.7.2",
"@formkit/validation": "^1.7.2"
}
},
"node_modules/@formkit/inputs": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/inputs/-/inputs-1.7.2.tgz",
"integrity": "sha512-4xs7RJN8EFGctTCNRXBTvor2O8RvuEEK3q2Hl/RMU5lhhn5tmck90fkkeAr9o+rWRoSNiV8XgbLQArQ/B1IYPw==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/utils": "^1.7.2"
}
},
"node_modules/@formkit/observer": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/observer/-/observer-1.7.2.tgz",
"integrity": "sha512-KUr5mcu2SAeHOOK1FaMyFigtA8hkHLsUkaPFWpTC81j79o1AUjJppmfPaX6PbtlHLeMs2Zq8Qhp6VAhi7D2WJg==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/utils": "^1.7.2"
}
},
"node_modules/@formkit/rules": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/rules/-/rules-1.7.2.tgz",
"integrity": "sha512-2e5qKlXzmL9LAetncFp7xkHEX/UAJsU3bo1iL3idloH3HALf5fIdg3lgOKwCNDMdLjbfiKJ6lVwFaeFfFpcDKw==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/utils": "^1.7.2",
"@formkit/validation": "^1.7.2"
}
},
"node_modules/@formkit/themes": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/themes/-/themes-1.7.2.tgz",
"integrity": "sha512-AaZHy7l9D44Ya3cQRqZ8RIpaTTsCjTB1gX13NlpqYSs6yG0yBTmJ7L7kW60bOGdhs/vmIwSel578EBwz98nybQ==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2"
}
},
"node_modules/@formkit/utils": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/utils/-/utils-1.7.2.tgz",
"integrity": "sha512-bBF6alUBOqFfJHjVB95Vck0hp36vlw4QfFJxGfTO6BX68AEaFzzzabtpwfy0DbcHtwHh4Yn7l/rOWGxXEve+QQ==",
"license": "MIT"
},
"node_modules/@formkit/validation": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/validation/-/validation-1.7.2.tgz",
"integrity": "sha512-JJsLP7AI/++VujdwtBeUcPhCqY3FXSKZ7hajT7Ow5Feab7JLqVPjxVMFbXDgEOLjq0ex1NX/I+EQHfm5sqMRsA==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/observer": "^1.7.2",
"@formkit/utils": "^1.7.2"
}
},
"node_modules/@formkit/vue": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/@formkit/vue/-/vue-1.7.2.tgz",
"integrity": "sha512-jrvXl2ZhS6X5klTbP4N5zc0Dg37AjbAf8SCVoz7mfligVkmwrrF4SN3waMBcH+n9gqT+vrkOYb6bQXdvjUdnCQ==",
"license": "MIT",
"dependencies": {
"@formkit/core": "^1.7.2",
"@formkit/dev": "^1.7.2",
"@formkit/i18n": "^1.7.2",
"@formkit/inputs": "^1.7.2",
"@formkit/observer": "^1.7.2",
"@formkit/rules": "^1.7.2",
"@formkit/themes": "^1.7.2",
"@formkit/utils": "^1.7.2",
"@formkit/validation": "^1.7.2"
},
"peerDependencies": {
"vue": "^3.4.0"
}
},
"node_modules/@headlessui/vue": { "node_modules/@headlessui/vue": {
"version": "1.7.23", "version": "1.7.23",
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",

View file

@ -24,6 +24,8 @@
"nuxt-icon": "^1.0.0-beta.7" "nuxt-icon": "^1.0.0-beta.7"
}, },
"dependencies": { "dependencies": {
"@formkit/themes": "^1.7.2",
"@formkit/vue": "^1.7.2",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"mongodb": "^7.1.0", "mongodb": "^7.1.0",
"nuxt": "^4.3.1", "nuxt": "^4.3.1",

151
pages/admin.vue Normal file
View file

@ -0,0 +1,151 @@
<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>
<nav class="space-y-2">
<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'"
>
{{ col.label }}
</button>
</nav>
<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">
<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>
<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>
<tr v-for="item in items" :key="item.id" class="border-t hover:bg-gray-50">
<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>
<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>
import { ref, watch } from 'vue'
import { collections } from '@/admin/schemas'
const password = ref('')
const authenticated = ref(false)
const selectedCollection = ref('works')
const items = ref([])
const rawJsonItem = ref(null)
const rawJsonContent = ref('')
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) {
return item.title || item.citationKey || item.name || item.id
}
async function loadItems() {
const { data } = await useFetch(`/api/admin/${selectedCollection.value}`)
items.value = data.value || []
}
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()
}
}
watch(selectedCollection, () => {
loadItems()
})
</script>

View file

@ -0,0 +1,5 @@
import { defaultConfig, plugin } from '@formkit/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(plugin, defaultConfig)
})

View file

@ -0,0 +1,80 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const dataDir = './server/data'
function getFilePath(collection) {
const fileMap = {
works: 'works.json',
publications: 'publications.json',
events: 'events.json',
releases: 'releases.json',
talks: 'talks.json'
}
return join(dataDir, fileMap[collection] || `${collection}.json`)
}
function generateId() {
return Math.random().toString(36).substring(2, 15)
}
export default defineEventHandler(async (event) => {
const method = event.method
const collection = event.context.params?.collection
const id = event.context.params?.id
if (!collection) {
throw createError({ statusCode: 400, message: 'Collection required' })
}
const filePath = getFilePath(collection)
if (!existsSync(filePath)) {
throw createError({ statusCode: 404, message: 'Collection not found' })
}
const readData = () => {
try {
return JSON.parse(readFileSync(filePath, 'utf-8'))
} catch {
return []
}
}
const writeData = (data) => {
writeFileSync(filePath, JSON.stringify(data, null, 2))
}
if (method === 'GET') {
return readData()
}
if (method === 'POST') {
const body = await readBody(event)
const data = readData()
body.id = generateId()
data.push(body)
writeData(data)
return body
}
if (method === 'PUT') {
const body = await readBody(event)
const data = readData()
const index = data.findIndex(item => item.id === body.id)
if (index !== -1) {
data[index] = body
writeData(data)
}
return body
}
if (method === 'DELETE' && id) {
const data = readData()
const filtered = data.filter(item => item.id !== id)
writeData(filtered)
return { success: true }
}
throw createError({ statusCode: 405, message: 'Method not allowed' })
})

View file

@ -0,0 +1,52 @@
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
const dataDir = './server/data'
function getFilePath(collection) {
const fileMap = {
works: 'works.json',
publications: 'publications.json',
events: 'events.json',
releases: 'releases.json',
talks: 'talks.json'
}
return join(dataDir, fileMap[collection] || `${collection}.json`)
}
export default defineEventHandler(async (event) => {
const method = event.method
const collection = event.context.params?.collection
const id = event.context.params?.id
if (!collection || !id) {
throw createError({ statusCode: 400, message: 'Collection and ID required' })
}
const filePath = getFilePath(collection)
if (!existsSync(filePath)) {
throw createError({ statusCode: 404, message: 'Collection not found' })
}
const readData = () => {
try {
return JSON.parse(readFileSync(filePath, 'utf-8'))
} catch {
return []
}
}
const writeData = (data) => {
writeFileSync(filePath, JSON.stringify(data, null, 2))
}
if (method === 'DELETE') {
const data = readData()
const filtered = data.filter(item => item.id !== id)
writeData(filtered)
return { success: true }
}
throw createError({ statusCode: 405, message: 'Method not allowed' })
})