templates2.Content.Nuxt/app/components/DocsManager.vue
w4c-templates 940313f8c5
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Content.Nuxt
2026-10-11 23:26:40 +03:00

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>