New SiteContent.
This commit is contained in:
parent
02222a5c16
commit
7e7672d52b
5 changed files with 285 additions and 0 deletions
225
app/pages/admin/site-content.vue
Normal file
225
app/pages/admin/site-content.vue
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
<template>
|
||||
<div class="admin-site-content">
|
||||
<div class="page-header">
|
||||
<h1>Site Content</h1>
|
||||
<p>Editable copy rendered on the public site. Leave fields blank to use defaults.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="loading-state">Loading…</div>
|
||||
<div v-else class="content-blocks">
|
||||
<section v-for="entry in entries" :key="entry.key" class="content-block">
|
||||
<div class="block-header">
|
||||
<div>
|
||||
<div class="block-key">{{ entry.key }}</div>
|
||||
<div class="block-label">{{ KEY_LABELS[entry.key] || entry.key }}</div>
|
||||
</div>
|
||||
<div v-if="entry.updatedAt" class="block-meta">
|
||||
Updated {{ formatTime(entry.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Title</label>
|
||||
<input v-model="entry.title" type="text" maxlength="300" >
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Body</label>
|
||||
<textarea v-model="entry.body" rows="8" maxlength="5000" />
|
||||
<p class="help-text">Paragraphs separated by blank lines. Plain text only.</p>
|
||||
</div>
|
||||
|
||||
<div class="block-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="entry.saving"
|
||||
@click="save(entry)"
|
||||
>
|
||||
{{ entry.saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin',
|
||||
middleware: 'admin',
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const KEY_LABELS = {
|
||||
'homepage.wiki_feature': 'Homepage: From the Wiki',
|
||||
}
|
||||
|
||||
const { data: keysData } = await useFetch('/api/site-content/keys')
|
||||
const knownKeys = computed(() => keysData.value?.keys || [])
|
||||
|
||||
const entries = ref([])
|
||||
const pending = ref(true)
|
||||
|
||||
const load = async () => {
|
||||
pending.value = true
|
||||
const results = await Promise.all(
|
||||
knownKeys.value.map((key) => $fetch(`/api/site-content/${key}`))
|
||||
)
|
||||
entries.value = results.map((r) => ({
|
||||
key: r.key,
|
||||
title: r.title || '',
|
||||
body: r.body || '',
|
||||
updatedAt: r.updatedAt || null,
|
||||
saving: false,
|
||||
}))
|
||||
pending.value = false
|
||||
}
|
||||
|
||||
await load()
|
||||
|
||||
const save = async (entry) => {
|
||||
entry.saving = true
|
||||
try {
|
||||
const updated = await $fetch(`/api/admin/site-content/${entry.key}`, {
|
||||
method: 'PUT',
|
||||
body: { title: entry.title, body: entry.body },
|
||||
})
|
||||
entry.updatedAt = updated.updatedAt
|
||||
toast.add({ title: 'Saved', color: 'green' })
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
title: 'Save failed',
|
||||
description: err.data?.statusMessage || err.message,
|
||||
color: 'red',
|
||||
})
|
||||
} finally {
|
||||
entry.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (iso) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-site-content {
|
||||
padding: 24px;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-family: 'Brygada 1918', serif;
|
||||
font-size: 28px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
color: var(--text-faint);
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.content-blocks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
border: 1px dashed var(--border);
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.block-key {
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
.block-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
resize: vertical;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--candle);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.block-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue