243 lines
9.3 KiB
Vue
243 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
defineProps<{ canWrite: boolean; canDelete: boolean }>();
|
|
|
|
type DocRow = { id: string; slug: string; title: string; section: string; order: number; status: string; updatedAt: string };
|
|
type ListResponse = { items: DocRow[]; total: number; page: number; pageSize: number; pages: number };
|
|
type Form = { title: string; slug: string; section: string; order: number; body: string; status: string };
|
|
type DocDetail = Form & { id: string };
|
|
|
|
const STATUSES = ['all', 'published', 'draft'];
|
|
const statusTone = (status: string) => (status === 'published' ? 'success' : 'warning');
|
|
|
|
const items = ref<DocRow[]>([]);
|
|
const meta = ref({ page: 1, pages: 1, total: 0 });
|
|
const status = ref('all');
|
|
const page = ref(1);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
|
|
const form = ref<Form | null>(null);
|
|
const editingId = ref<string | null>(null);
|
|
const formError = ref<string | null>(null);
|
|
const busy = ref(false);
|
|
const showPreview = ref(false);
|
|
const slugTouched = ref(false);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
const params = new URLSearchParams({ page: String(page.value), pageSize: '20' });
|
|
if (status.value !== 'all') params.set('status', status.value);
|
|
try {
|
|
const data = await $fetch<ListResponse>(`/api/admin/docs?${params.toString()}`);
|
|
items.value = data.items;
|
|
meta.value = { page: data.page, pages: data.pages, total: data.total };
|
|
} catch {
|
|
error.value = t('common.error');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
watch([page, status], load);
|
|
onMounted(load);
|
|
|
|
watch(
|
|
() => form.value?.title,
|
|
(title) => {
|
|
if (form.value && !slugTouched.value) form.value.slug = slugify(title ?? '');
|
|
},
|
|
);
|
|
|
|
function openCreate() {
|
|
editingId.value = null;
|
|
formError.value = null;
|
|
showPreview.value = false;
|
|
slugTouched.value = false;
|
|
form.value = { title: '', slug: '', section: '', order: items.value.length + 1, body: '', status: 'published' };
|
|
}
|
|
|
|
async function openEdit(row: DocRow) {
|
|
formError.value = null;
|
|
showPreview.value = false;
|
|
try {
|
|
const data = await $fetch<{ doc: DocDetail }>(`/api/admin/docs/${row.id}`);
|
|
editingId.value = row.id;
|
|
slugTouched.value = true;
|
|
form.value = {
|
|
title: data.doc.title,
|
|
slug: data.doc.slug,
|
|
section: data.doc.section,
|
|
order: data.doc.order,
|
|
body: data.doc.body,
|
|
status: data.doc.status,
|
|
};
|
|
} catch {
|
|
error.value = t('common.error');
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
if (!form.value) return;
|
|
busy.value = true;
|
|
formError.value = null;
|
|
try {
|
|
await $fetch(editingId.value ? `/api/admin/docs/${editingId.value}` : '/api/admin/docs', {
|
|
method: editingId.value ? 'PATCH' : 'POST',
|
|
body: form.value,
|
|
});
|
|
form.value = null;
|
|
editingId.value = null;
|
|
await load();
|
|
} catch (e) {
|
|
const err = e as { data?: { statusMessage?: string }; statusMessage?: string };
|
|
formError.value = err.data?.statusMessage || err.statusMessage || t('common.error');
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function toggleStatus(row: DocRow) {
|
|
busy.value = true;
|
|
try {
|
|
const data = await $fetch<{ doc: DocDetail }>(`/api/admin/docs/${row.id}`);
|
|
await $fetch(`/api/admin/docs/${row.id}`, {
|
|
method: 'PATCH',
|
|
body: { ...data.doc, status: row.status === 'published' ? 'draft' : 'published' },
|
|
});
|
|
await load();
|
|
} catch {
|
|
error.value = t('common.error');
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove(row: DocRow) {
|
|
if (!window.confirm(`${t('admin.delete')} “${row.title}”? This cannot be undone.`)) return;
|
|
busy.value = true;
|
|
await $fetch(`/api/admin/docs/${row.id}`, { method: 'DELETE' }).catch(() => undefined);
|
|
busy.value = false;
|
|
await load();
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-2xl font-bold">{{ t('admin.docs') }}</h1>
|
|
<p class="muted mt-1 text-sm">{{ meta.total }} {{ t('admin.docs') }}</p>
|
|
</div>
|
|
<button v-if="canWrite" type="button" class="btn-primary" @click="openCreate">{{ t('admin.newDoc') }}</button>
|
|
</header>
|
|
|
|
<form class="card flex flex-wrap items-end gap-3" @submit.prevent>
|
|
<label class="text-sm">
|
|
{{ t('admin.status') }}
|
|
<select v-model="status" class="input mt-1" @change="page = 1">
|
|
<option v-for="s in STATUSES" :key="s" :value="s">{{ t(`admin.filter_${s}`) }}</option>
|
|
</select>
|
|
</label>
|
|
</form>
|
|
|
|
<form v-if="form" class="card space-y-3" @submit.prevent="submit">
|
|
<h2 class="text-lg font-semibold">{{ editingId ? t('admin.editDoc') : t('admin.newDoc') }}</h2>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<label class="text-sm sm:col-span-2">
|
|
{{ t('admin.title') }}
|
|
<input v-model="form.title" class="input mt-1" required >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.slug') }}
|
|
<input v-model="form.slug" class="input mt-1" required @input="slugTouched = true" >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.section') }}
|
|
<input v-model="form.section" class="input mt-1" required >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.order') }}
|
|
<input v-model.number="form.order" type="number" min="0" class="input mt-1" >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.status') }}
|
|
<select v-model="form.status" class="input mt-1">
|
|
<option value="published">{{ t('admin.published') }}</option>
|
|
<option value="draft">{{ t('admin.draft') }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="text-sm sm:col-span-2">
|
|
{{ t('admin.body') }} <span class="muted">({{ t('admin.markdownHint') }})</span>
|
|
<textarea v-model="form.body" class="input mt-1 font-mono" rows="10" required />
|
|
</label>
|
|
</div>
|
|
<p v-if="formError" class="text-sm text-red-600">{{ formError }}</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="submit" class="btn-primary" :disabled="busy">{{ t('admin.save') }}</button>
|
|
<button type="button" class="btn-secondary" @click="showPreview = !showPreview">{{ t('admin.preview') }}</button>
|
|
<button
|
|
type="button"
|
|
class="btn-secondary"
|
|
@click="
|
|
form = null;
|
|
editingId = null;
|
|
"
|
|
>
|
|
{{ t('admin.cancel') }}
|
|
</button>
|
|
</div>
|
|
<div v-if="showPreview" class="mt-4 rounded-xl border p-5">
|
|
<p class="muted mb-3 text-xs uppercase tracking-wide">{{ t('admin.preview') }}</p>
|
|
<h3 class="text-xl font-bold">{{ form.title || t('admin.untitled') }}</h3>
|
|
<MarkdownBody :body="form.body" />
|
|
</div>
|
|
</form>
|
|
|
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
|
<p v-if="loading" class="muted text-sm">{{ t('common.loading') }}</p>
|
|
<p v-else-if="items.length === 0" class="muted py-6 text-center text-sm">{{ t('admin.empty') }}</p>
|
|
<template v-else>
|
|
<div class="overflow-x-auto rounded-xl border">
|
|
<table class="w-full text-sm">
|
|
<thead class="muted text-left text-xs uppercase">
|
|
<tr>
|
|
<th class="px-4 py-3">{{ t('admin.title') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.section') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.order') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.status') }}</th>
|
|
<th class="px-4 py-3 text-right">{{ t('admin.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="row in items" :key="row.id" class="border-t">
|
|
<td class="px-4 py-3">
|
|
<NuxtLink v-if="row.status === 'published'" :to="`/docs/${row.slug}`" class="font-medium hover:text-brand-600">{{ row.title }}</NuxtLink>
|
|
<span v-else class="font-medium">{{ row.title }}</span>
|
|
<span class="muted mt-0.5 block text-xs">{{ row.slug }}</span>
|
|
</td>
|
|
<td class="px-4 py-3">{{ row.section }}</td>
|
|
<td class="px-4 py-3">{{ row.order }}</td>
|
|
<td class="px-4 py-3"><UiBadge :tone="statusTone(row.status)">{{ t(`admin.${row.status}`) }}</UiBadge></td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex flex-wrap justify-end gap-2">
|
|
<button v-if="canWrite" type="button" class="btn-secondary" @click="openEdit(row)">{{ t('admin.edit') }}</button>
|
|
<button v-if="canWrite" type="button" class="btn-secondary" :disabled="busy" @click="toggleStatus(row)">
|
|
{{ row.status === 'published' ? t('admin.unpublish') : t('admin.publish') }}
|
|
</button>
|
|
<button v-if="canDelete" type="button" class="btn-danger" :disabled="busy" @click="remove(row)">{{ t('admin.delete') }}</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<button type="button" class="btn-secondary" :disabled="page <= 1" @click="page--">{{ t('admin.prev') }}</button>
|
|
<span class="muted">{{ t('admin.pageOf', { page: meta.page, pages: meta.pages }) }}</span>
|
|
<button type="button" class="btn-secondary" :disabled="page >= meta.pages" @click="page++">{{ t('admin.next') }}</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|