templates2.Content.Nuxt/app/components/PostsManager.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

299 lines
11 KiB
Vue

<script setup lang="ts">
defineProps<{ canWrite: boolean; canDelete: boolean }>();
type PostRow = {
id: string;
slug: string;
title: string;
excerpt: string;
status: string;
publishedAt: string | null;
readingMinutes: number;
featured: boolean;
author: { name: string; slug: string; avatarColor?: string };
tags: { name: string; slug: string }[];
};
type ListResponse = { items: PostRow[]; total: number; page: number; pageSize: number; pages: number };
type MetaResponse = { authors: { id: string; name: string; slug: string }[]; tags: { id: string; name: string; slug: string }[] };
type Form = {
title: string;
slug: string;
excerpt: string;
body: string;
authorId: string;
tagIds: string[];
status: string;
featured: boolean;
};
const STATUSES = ['all', 'published', 'draft'];
const statusTone = (status: string) => (status === 'published' ? 'success' : 'warning');
const items = ref<PostRow[]>([]);
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 authors = ref<MetaResponse['authors']>([]);
const tags = ref<MetaResponse['tags']>([]);
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: '10' });
if (status.value !== 'all') params.set('status', status.value);
try {
const data = await $fetch<ListResponse>(`/api/admin/posts?${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;
}
}
async function loadMeta() {
try {
const data = await $fetch<MetaResponse>('/api/admin/posts/meta');
authors.value = data.authors;
tags.value = data.tags;
} catch {
/* leave empty; the form validates server-side */
}
}
watch([page, status], load);
onMounted(async () => {
await Promise.all([load(), loadMeta()]);
});
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: '', excerpt: '', body: '', authorId: authors.value[0]?.id ?? '', tagIds: [], status: 'draft', featured: false };
}
async function openEdit(row: PostRow) {
formError.value = null;
showPreview.value = false;
try {
const data = await $fetch<{ post: Form & { id: string } }>('/api/admin/posts/' + row.id);
editingId.value = row.id;
slugTouched.value = true;
form.value = {
title: data.post.title,
slug: data.post.slug,
excerpt: data.post.excerpt,
body: data.post.body,
authorId: data.post.authorId,
tagIds: data.post.tagIds ?? [],
status: data.post.status,
featured: data.post.featured,
};
} 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/posts/' + editingId.value : '/api/admin/posts', {
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: PostRow) {
busy.value = true;
try {
const data = await $fetch<{ post: Form }>('/api/admin/posts/' + row.id);
await $fetch('/api/admin/posts/' + row.id, {
method: 'PATCH',
body: { ...data.post, status: row.status === 'published' ? 'draft' : 'published' },
});
await load();
} catch {
error.value = t('common.error');
} finally {
busy.value = false;
}
}
async function remove(row: PostRow) {
if (!window.confirm(`${t('admin.delete')} “${row.title}”? This cannot be undone.`)) return;
busy.value = true;
await $fetch('/api/admin/posts/' + 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.posts') }}</h1>
<p class="muted mt-1 text-sm">{{ meta.total }} {{ t('admin.posts') }}</p>
</div>
<button v-if="canWrite" type="button" class="btn-primary" @click="openCreate">{{ t('admin.newPost') }}</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.editPost') : t('admin.newPost') }}</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.author') }}
<select v-model="form.authorId" class="input mt-1" required>
<option v-for="a in authors" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</label>
<label class="text-sm sm:col-span-2">
{{ t('admin.excerpt') }}
<input v-model="form.excerpt" class="input mt-1" :placeholder="t('admin.excerptHint')" >
</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="12" required />
</label>
<fieldset class="text-sm sm:col-span-2">
<legend class="mb-2">{{ t('admin.tags') }}</legend>
<div class="flex flex-wrap gap-3">
<label v-for="tag in tags" :key="tag.id" class="flex items-center gap-2">
<input v-model="form.tagIds" type="checkbox" :value="tag.id" >
{{ tag.name }}
</label>
<span v-if="tags.length === 0" class="muted">{{ t('admin.none') }}</span>
</div>
</fieldset>
<label class="text-sm">
{{ t('admin.status') }}
<select v-model="form.status" class="input mt-1">
<option value="draft">{{ t('admin.draft') }}</option>
<option value="published">{{ t('admin.published') }}</option>
</select>
</label>
<label class="flex items-center gap-2 self-end text-sm">
<input v-model="form.featured" type="checkbox" >
{{ t('admin.featured') }}
</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.author') }}</th>
<th class="px-4 py-3">{{ t('admin.status') }}</th>
<th class="px-4 py-3">{{ t('admin.published') }}</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="`/blog/${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.author.name }}</td>
<td class="px-4 py-3">
<UiBadge :tone="statusTone(row.status)">{{ t(`admin.${row.status}`) }}</UiBadge>
<UiBadge v-if="row.featured" tone="brand" class="ms-1">{{ t('admin.featured') }}</UiBadge>
</td>
<td class="px-4 py-3">{{ row.publishedAt ? formatDate(row.publishedAt) : t('admin.none') }}</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>