diff --git a/Frontend/src/app/components/ui/card.tsx b/Frontend/src/app/components/ui/card.tsx new file mode 100644 index 0000000..5f9d58a --- /dev/null +++ b/Frontend/src/app/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "./utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/Frontend/src/app/components/ui/carousel.tsx b/Frontend/src/app/components/ui/carousel.tsx new file mode 100644 index 0000000..bb5ab13 --- /dev/null +++ b/Frontend/src/app/components/ui/carousel.tsx @@ -0,0 +1,241 @@ +"use client"; + +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { cn } from "./utils"; +import { Button } from "./button"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/Frontend/src/app/components/ui/chart.tsx b/Frontend/src/app/components/ui/chart.tsx new file mode 100644 index 0000000..b49bc36 --- /dev/null +++ b/Frontend/src/app/components/ui/chart.tsx @@ -0,0 +1,353 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "./utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +} + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( + + + +${body} + + +` +} \ No newline at end of file diff --git a/Frontend/src/app/page-seo.ts b/Frontend/src/app/page-seo.ts new file mode 100644 index 0000000..f284faa --- /dev/null +++ b/Frontend/src/app/page-seo.ts @@ -0,0 +1,104 @@ +import type { PageSEO } from './types' + +const DEFAULT_SEO: Required> & Omit = { + title: '', + description: '', + canonicalUrl: '', + openGraph: { + title: '', + description: '', + image: '', + url: '', + type: 'website', + }, + robots: { + index: true, + follow: true, + noarchive: false, + nosnippet: false, + }, +} + +export function normalizePageSlug(value: string | undefined): string { + return value?.trim() ?? '' +} + +export function createEmptyPageSEO(): PageSEO { + return structuredClone(DEFAULT_SEO) +} + +export function normalizePageSEO(value: PageSEO | undefined): PageSEO { + return { + title: value?.title ?? DEFAULT_SEO.title, + description: value?.description ?? DEFAULT_SEO.description, + canonicalUrl: value?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl, + openGraph: { + title: value?.openGraph?.title ?? DEFAULT_SEO.openGraph.title, + description: value?.openGraph?.description ?? DEFAULT_SEO.openGraph.description, + image: value?.openGraph?.image ?? DEFAULT_SEO.openGraph.image, + url: value?.openGraph?.url ?? DEFAULT_SEO.openGraph.url, + type: value?.openGraph?.type ?? DEFAULT_SEO.openGraph.type, + }, + robots: { + index: value?.robots?.index ?? DEFAULT_SEO.robots.index, + follow: value?.robots?.follow ?? DEFAULT_SEO.robots.follow, + noarchive: value?.robots?.noarchive ?? DEFAULT_SEO.robots.noarchive, + nosnippet: value?.robots?.nosnippet ?? DEFAULT_SEO.robots.nosnippet, + }, + } +} + +export function sanitizePageSEO(value: PageSEO | undefined): PageSEO | undefined { + if (!value) { + return undefined + } + + const title = value.title?.trim() ?? '' + const description = value.description?.trim() ?? '' + const canonicalUrl = value.canonicalUrl?.trim() ?? '' + const openGraph = { + title: value.openGraph?.title?.trim() ?? '', + description: value.openGraph?.description?.trim() ?? '', + image: value.openGraph?.image?.trim() ?? '', + url: value.openGraph?.url?.trim() ?? '', + type: value.openGraph?.type?.trim() ?? DEFAULT_SEO.openGraph.type, + } + const robots = { + index: value.robots?.index ?? DEFAULT_SEO.robots.index, + follow: value.robots?.follow ?? DEFAULT_SEO.robots.follow, + noarchive: value.robots?.noarchive ?? DEFAULT_SEO.robots.noarchive, + nosnippet: value.robots?.nosnippet ?? DEFAULT_SEO.robots.nosnippet, + } + + const sanitized: PageSEO = {} + + if (title) { + sanitized.title = title + } + if (description) { + sanitized.description = description + } + if (canonicalUrl) { + sanitized.canonicalUrl = canonicalUrl + } + + if (openGraph.title || openGraph.description || openGraph.image || openGraph.url || openGraph.type !== DEFAULT_SEO.openGraph.type) { + sanitized.openGraph = {} + if (openGraph.title) sanitized.openGraph.title = openGraph.title + if (openGraph.description) sanitized.openGraph.description = openGraph.description + if (openGraph.image) sanitized.openGraph.image = openGraph.image + if (openGraph.url) sanitized.openGraph.url = openGraph.url + if (openGraph.type) sanitized.openGraph.type = openGraph.type + } + + if ( + robots.index !== DEFAULT_SEO.robots.index || + robots.follow !== DEFAULT_SEO.robots.follow || + robots.noarchive !== DEFAULT_SEO.robots.noarchive || + robots.nosnippet !== DEFAULT_SEO.robots.nosnippet + ) { + sanitized.robots = robots + } + + return Object.keys(sanitized).length > 0 ? sanitized : undefined +} \ No newline at end of file diff --git a/Frontend/src/app/pages/AdminAssetsPage.tsx b/Frontend/src/app/pages/AdminAssetsPage.tsx new file mode 100644 index 0000000..3f024a6 --- /dev/null +++ b/Frontend/src/app/pages/AdminAssetsPage.tsx @@ -0,0 +1,335 @@ +import { useEffect, useMemo, useState } from 'react' + +import { + filterAssets, + loadAdminAssets, + loadAssetAccessEvents, + loadAssetClaims, + updateAssetStatus, + type AssetAccessEvent, + type AssetClaim, + type AssetRecord, + type AssetStatus, +} from '../admin-assets' + +type LoadState = 'loading' | 'ready' | 'error' + +const STATUS_FILTERS: Array<{ value: 'all' | AssetStatus; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'archived', label: 'Archived' }, + { value: 'quarantined', label: 'Quarantined' }, + { value: 'disabled', label: 'Disabled' }, + { value: 'deleted', label: 'Deleted' }, +] + +function statusClassName(status: AssetStatus): string { + if (status === 'active') return 'border-emerald-200 bg-emerald-50 text-emerald-700' + if (status === 'quarantined') return 'border-amber-200 bg-amber-50 text-amber-700' + if (status === 'disabled' || status === 'deleted') return 'border-rose-200 bg-rose-50 text-rose-700' + if (status === 'archived') return 'border-zinc-200 bg-zinc-100 text-zinc-700' + return 'border-zinc-200 bg-zinc-100 text-zinc-600' +} + +function formatDate(value: number | null): string { + if (value === null) return 'Unknown' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) +} + +function claimStatusClassName(status: string): string { + const normalized = status.toLowerCase() + if (normalized.includes('resolved')) return 'text-emerald-700' + if (normalized.includes('rejected')) return 'text-zinc-700' + if (normalized.includes('review') || normalized.includes('received')) return 'text-amber-700' + if (normalized.includes('disabled')) return 'text-rose-700' + return 'text-zinc-600' +} + +export function AdminAssetsPage() { + const [state, setState] = useState('loading') + const [error, setError] = useState('') + const [assets, setAssets] = useState([]) + const [statusFilter, setStatusFilter] = useState<'all' | AssetStatus>('all') + const [query, setQuery] = useState('') + const [selectedAssetID, setSelectedAssetID] = useState(null) + const [accessEvents, setAccessEvents] = useState([]) + const [claims, setClaims] = useState([]) + const [claimsPlaceholder, setClaimsPlaceholder] = useState('') + const [loadingDetail, setLoadingDetail] = useState(false) + + const loadAssets = async () => { + try { + setState('loading') + setError('') + const rows = await loadAdminAssets() + setAssets(rows) + setSelectedAssetID(current => current ?? rows[0]?.id ?? null) + setState('ready') + } catch (err) { + setAssets([]) + setSelectedAssetID(null) + setState('error') + setError(err instanceof Error ? err.message : 'Failed to load asset library') + } + } + + const selectedAsset = useMemo(() => { + return assets.find(asset => asset.id === selectedAssetID) ?? null + }, [assets, selectedAssetID]) + + const filteredAssets = useMemo(() => { + return filterAssets(assets, statusFilter, query) + }, [assets, query, statusFilter]) + + useEffect(() => { + void loadAssets() + }, []) + + useEffect(() => { + const assetID = selectedAsset?.id + if (!assetID) { + setAccessEvents([]) + setClaims([]) + setClaimsPlaceholder('') + return + } + + const loadDetails = async () => { + setLoadingDetail(true) + try { + const [events, claimRows] = await Promise.all([ + loadAssetAccessEvents(assetID), + loadAssetClaims(assetID), + ]) + setAccessEvents(events) + setClaims(claimRows) + setClaimsPlaceholder(claimRows.length === 0 ? 'No copyright claims are recorded for this asset yet.' : '') + } catch { + setAccessEvents([]) + setClaims([]) + setClaimsPlaceholder('Copyright claims are currently unavailable for this asset.') + } finally { + setLoadingDetail(false) + } + } + + void loadDetails() + }, [selectedAsset?.id]) + + const onStatusAction = async (asset: AssetRecord, action: 'quarantine' | 'disable' | 'restore') => { + try { + const updated = await updateAssetStatus(asset.id, action) + setAssets(current => current.map(row => row.id === updated.id ? updated : row)) + setSelectedAssetID(updated.id) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update asset status') + setState('error') + } + } + + if (state === 'loading') { + return ( +
+

Asset Library

+

Loading protected assets and copyright queue...

+
+ ) + } + + if (state === 'error') { + return ( +
+

Asset Library

+

{error}

+ +
+ ) + } + + return ( +
+
+

Asset Library

+

+ Review asset protection posture, copyright metadata, access events, and enforcement actions. +

+
+ +
+
+ {STATUS_FILTERS.map(filter => ( + + ))} +
+ + +
+ +
+
+ + + + + + + + + + + + + + + {filteredAssets.map(asset => ( + setSelectedAssetID(asset.id)} + className={`cursor-pointer border-t border-zinc-100 ${selectedAsset?.id === asset.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`} + > + + + + + + + + + + ))} + +
AssetStatusVisibilityOwnerLicenceProtectionRelatedActions
+

{asset.title}

+

{asset.id}

+
+ + {asset.status} + + {asset.visibility}{asset.owner}{asset.licence}{asset.protectionLevel}{asset.relatedResource} +
event.stopPropagation()}> + {asset.status !== 'quarantined' ? ( + + ) : null} + {asset.status !== 'disabled' ? ( + + ) : null} + {asset.status !== 'active' ? ( + + ) : null} +
+
+ + {filteredAssets.length === 0 ? ( +
+ No assets matched this filter. +
+ ) : null} +
+ + +
+
+ ) +} diff --git a/Frontend/src/app/pages/AdminAuditPage.tsx b/Frontend/src/app/pages/AdminAuditPage.tsx new file mode 100644 index 0000000..4fd7069 --- /dev/null +++ b/Frontend/src/app/pages/AdminAuditPage.tsx @@ -0,0 +1,312 @@ +import { useEffect, useMemo, useState } from 'react' + +import { filterAuditEvents, loadAuditEvents, type AuditEvent, type AuditFilters } from '../admin-audit' + +type LoadState = 'loading' | 'ready' | 'error' + +const INITIAL_FILTERS: AuditFilters = { + action: '', + resource: '', + actor: '', + result: '', + since: '', + until: '', +} + +function formatTimestamp(value: number | null): string { + if (value === null) return 'Unknown' + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) +} + +function resultClassName(result: AuditEvent['result']): string { + if (result === 'success') return 'border-emerald-200 bg-emerald-50 text-emerald-700' + if (result === 'failed') return 'border-rose-200 bg-rose-50 text-rose-700' + return 'border-zinc-200 bg-zinc-100 text-zinc-600' +} + +function toDateTimeLocalInput(value: string): string { + if (!value) return '' + const parsed = Date.parse(value) + if (Number.isNaN(parsed)) return '' + + const date = new Date(parsed) + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const hh = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${yyyy}-${mm}-${dd}T${hh}:${min}` +} + +function fromDateTimeLocalInput(value: string): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toISOString() +} + +export function AdminAuditPage() { + const [state, setState] = useState('loading') + const [error, setError] = useState('') + const [events, setEvents] = useState([]) + const [filters, setFilters] = useState(INITIAL_FILTERS) + const [search, setSearch] = useState('') + const [selectedID, setSelectedID] = useState(null) + + const load = async (nextFilters: AuditFilters) => { + try { + setState('loading') + setError('') + const rows = await loadAuditEvents(nextFilters) + setEvents(rows) + setSelectedID(current => current ?? rows[0]?.id ?? null) + setState('ready') + } catch (err) { + setEvents([]) + setSelectedID(null) + setState('error') + setError(err instanceof Error ? err.message : 'Failed to load audit events') + } + } + + useEffect(() => { + void load(filters) + }, []) + + const filteredEvents = useMemo(() => filterAuditEvents(events, search), [events, search]) + + const selectedEvent = useMemo(() => { + const active = filteredEvents.find(event => event.id === selectedID) + if (active) return active + return filteredEvents[0] ?? null + }, [filteredEvents, selectedID]) + + const onApplyFilters = () => { + void load(filters) + } + + const onResetFilters = () => { + setFilters(INITIAL_FILTERS) + void load(INITIAL_FILTERS) + } + + if (state === 'loading') { + return ( +
+

Audit Log

+

Loading platform events...

+
+ ) + } + + if (state === 'error') { + return ( +
+

Audit Log

+

{error}

+ +
+ ) + } + + return ( +
+
+

Audit Log

+

+ Review key platform events with actor, action, resource, result, timestamp, and redacted metadata. +

+
+ +
+
+ + + + + + + + + + + +
+ +
+ + + +
+
+ +
+
+ + + + + + + + + + + + {filteredEvents.map(event => ( + setSelectedID(event.id)} + className={`cursor-pointer border-t border-zinc-100 ${selectedEvent?.id === event.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`} + > + + + + + + + ))} + +
TimestampActorActionResourceResult
{formatTimestamp(event.timestamp)}{event.actor}{event.action}{event.resource} + + {event.result} + +
+ + {filteredEvents.length === 0 ? ( +
+ No audit events matched these filters. +
+ ) : null} +
+ + +
+
+ ) +} diff --git a/Frontend/src/app/pages/AdminBlogPage.tsx b/Frontend/src/app/pages/AdminBlogPage.tsx new file mode 100644 index 0000000..0f519ef --- /dev/null +++ b/Frontend/src/app/pages/AdminBlogPage.tsx @@ -0,0 +1,420 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { BookText, Plus, Save, Trash2 } from 'lucide-react' + +import { + createAdminBlogPost, + deleteAdminBlogPost, + fetchAdminBlogAuthors, + fetchAdminBlogCategories, + fetchAdminBlogPosts, + fetchAdminBlogTags, + type BlogPost, + type BlogStatus, + type BlogTaxonomy, + updateAdminBlogPost, +} from '../blog' + +type EditorState = { + id?: string + title: string + slug: string + excerpt: string + content: string + contentPagePath: string + status: BlogStatus + publishedAt: string + categories: string + tags: string + authorId: string +} + +const EMPTY_EDITOR: EditorState = { + title: '', + slug: '', + excerpt: '', + content: '', + contentPagePath: '', + status: 'draft', + publishedAt: '', + categories: '', + tags: '', + authorId: '', +} + +function toDateTimeInput(value?: number): string { + if (!value || !Number.isFinite(value)) { + return '' + } + const date = new Date(value) + const offsetMs = date.getTimezoneOffset() * 60_000 + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16) +} + +function fromDateTimeInput(value: string): number | undefined { + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + const timestamp = Date.parse(trimmed) + return Number.isFinite(timestamp) ? timestamp : undefined +} + +function parseTokenList(value: string): string[] { + return value + .split(',') + .map(token => token.trim()) + .filter(Boolean) +} + +function toEditor(post: BlogPost): EditorState { + return { + id: post.id, + title: post.title, + slug: post.slug, + excerpt: post.excerpt ?? '', + content: post.content ?? '', + contentPagePath: post.contentPagePath ?? '', + status: post.status ?? 'draft', + publishedAt: toDateTimeInput(post.publishedAt), + categories: (post.categories ?? []).join(', '), + tags: (post.tags ?? []).join(', '), + authorId: post.authorId ?? '', + } +} + +function formatDate(value?: number): string { + if (!value) { + return '-' + } + return new Date(value).toLocaleString() +} + +export function AdminBlogPage() { + const [posts, setPosts] = useState([]) + const [categories, setCategories] = useState([]) + const [tags, setTags] = useState([]) + const [authors, setAuthors] = useState([]) + const [selectedID, setSelectedID] = useState(null) + const [editor, setEditor] = useState(EMPTY_EDITOR) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + + const sortedPosts = useMemo( + () => [...posts].sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)), + [posts], + ) + + const loadAll = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const [postsResult, categoriesResult, tagsResult, authorsResult] = await Promise.all([ + fetchAdminBlogPosts(), + fetchAdminBlogCategories(), + fetchAdminBlogTags(), + fetchAdminBlogAuthors(), + ]) + + setPosts(postsResult) + setCategories(categoriesResult) + setTags(tagsResult) + setAuthors(authorsResult) + + if (selectedID) { + const current = postsResult.find(post => post.id === selectedID) + if (current) { + setEditor(toEditor(current)) + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load blog administration data') + } finally { + setLoading(false) + } + }, [selectedID]) + + useEffect(() => { + void loadAll() + }, [loadAll]) + + const selectPost = (post: BlogPost) => { + setSelectedID(post.id) + setEditor(toEditor(post)) + setNotice(null) + setError(null) + } + + const newPost = () => { + setSelectedID(null) + setEditor(EMPTY_EDITOR) + setNotice(null) + setError(null) + } + + const savePost = async () => { + const title = editor.title.trim() + if (!title) { + setError('Title is required') + return + } + + setSaving(true) + setError(null) + setNotice(null) + + const payload = { + title, + slug: editor.slug.trim() || undefined, + excerpt: editor.excerpt.trim() || undefined, + content: editor.content.trim() || undefined, + contentPagePath: editor.contentPagePath.trim() || undefined, + status: editor.status, + publishedAt: fromDateTimeInput(editor.publishedAt), + categories: parseTokenList(editor.categories), + tags: parseTokenList(editor.tags), + authorId: editor.authorId.trim() || undefined, + } + + try { + if (editor.id) { + const updated = await updateAdminBlogPost(editor.id, payload) + setNotice('Post updated') + setSelectedID(updated.id) + } else { + const created = await createAdminBlogPost(payload) + setNotice('Post created') + setSelectedID(created.id) + } + await loadAll() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save blog post') + } finally { + setSaving(false) + } + } + + const removePost = async () => { + if (!selectedID) { + return + } + + setSaving(true) + setError(null) + setNotice(null) + + try { + await deleteAdminBlogPost(selectedID) + setNotice('Post deleted') + setSelectedID(null) + setEditor(EMPTY_EDITOR) + await loadAll() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete blog post') + } finally { + setSaving(false) + } + } + + return ( +
+
+ + +
+
+

Blog Administration

+
+ {selectedID ? ( + + ) : null} + +
+
+ +
+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + +
+ + + + + + + +