diff --git a/admin/src/App.tsx b/admin/src/App.tsx index d733d3f..00f4ed2 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -21,8 +21,12 @@ import { Toaster, toast } from 'sonner' import { AppShell } from '@/components/app-shell' import { adminApi, ApiError } from '@/lib/api' import type { AdminSessionResponse } from '@/lib/types' +import { CommentsPage } from '@/pages/comments-page' import { DashboardPage } from '@/pages/dashboard-page' +import { FriendLinksPage } from '@/pages/friend-links-page' import { LoginPage } from '@/pages/login-page' +import { PostsPage } from '@/pages/posts-page' +import { ReviewsPage } from '@/pages/reviews-page' import { SiteSettingsPage } from '@/pages/site-settings-page' type SessionContextValue = { @@ -146,6 +150,11 @@ function AppRoutes() { }> }> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/admin/src/components/app-shell.tsx b/admin/src/components/app-shell.tsx index b32edeb..681dcd5 100644 --- a/admin/src/components/app-shell.tsx +++ b/admin/src/components/app-shell.tsx @@ -1,4 +1,15 @@ -import { ExternalLink, LayoutDashboard, LogOut, Orbit, Settings, Sparkles } from 'lucide-react' +import { + BookOpenText, + ExternalLink, + LayoutDashboard, + Link2, + LogOut, + MessageSquareText, + Orbit, + ScrollText, + Settings, + Sparkles, +} from 'lucide-react' import type { ReactNode } from 'react' import { NavLink } from 'react-router-dom' @@ -14,6 +25,30 @@ const primaryNav = [ description: 'Live operational dashboard', icon: LayoutDashboard, }, + { + to: '/posts', + label: 'Posts', + description: 'Markdown content workspace', + icon: ScrollText, + }, + { + to: '/comments', + label: 'Comments', + description: 'Moderation and paragraph replies', + icon: MessageSquareText, + }, + { + to: '/friend-links', + label: 'Friend links', + description: 'Partner queue and reciprocity', + icon: Link2, + }, + { + to: '/reviews', + label: 'Reviews', + description: 'Curated review library', + icon: BookOpenText, + }, { to: '/settings', label: 'Site settings', @@ -22,8 +57,6 @@ const primaryNav = [ }, ] -const nextNav = ['Posts editor', 'Comments moderation', 'Friend links queue', 'Review library'] - export function AppShell({ children, username, @@ -51,8 +84,8 @@ export function AppShell({ Control room for the blog system

- A separate React workspace for operations, moderation, and AI-related site - controls. + A dedicated React workspace for publishing, moderation, operations, and + AI-related site controls.

@@ -104,28 +137,25 @@ export function AppShell({ -
-
+
+

- Migration queue + Workspace status

- Legacy Tera screens that move here next. + Core admin flows are now available in the standalone app.

- phase 1 + live
-
- {nextNav.map((item) => ( -
- {item} - next -
- ))} +
+
+ Public site and admin stay decoupled. +
+
+ Backend remains the shared auth and data layer. +
@@ -148,6 +178,26 @@ export function AppShell({
+
+ {primaryNav.map((item) => ( + + cn( + 'rounded-full border px-3 py-2 text-sm whitespace-nowrap transition-colors', + isActive + ? 'border-primary/30 bg-primary/10 text-primary' + : 'border-border/70 bg-background/60 text-muted-foreground', + ) + } + > + {item.label} + + ))} +
+
+ + {loading ? ( + + ) : ( + + + + Comment + Status + Context + Actions + + + + {filteredComments.map((comment) => ( + + +
+
+ {comment.author ?? 'Anonymous'} + {comment.scope} + + {formatDateTime(comment.created_at)} + +
+

+ {comment.content ?? 'No content provided.'} +

+ {comment.scope === 'paragraph' ? ( +
+

{comment.paragraph_key ?? 'missing-key'}

+

+ {comment.paragraph_excerpt ?? 'No paragraph excerpt stored.'} +

+
+ ) : null} +
+
+ + + {comment.approved ? 'Approved' : 'Pending'} + + + +
+

{comment.post_slug ?? 'unknown-post'}

+ {comment.reply_to_comment_id ? ( +

Replying to #{comment.reply_to_comment_id}

+ ) : ( +

Top-level comment

+ )} +
+
+ +
+ + + +
+
+
+ ))} + {!filteredComments.length ? ( + + +
+ +

No comments match the current moderation filters.

+
+
+
+ ) : null} +
+
+ )} + + + + ) +} diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx index 7e22ad6..2828711 100644 --- a/admin/src/pages/dashboard-page.tsx +++ b/admin/src/pages/dashboard-page.tsx @@ -135,8 +135,8 @@ export function DashboardPage() {

Operations overview

- This screen pulls the operational signals the old Tera dashboard used to summarize, - but now from a standalone React app ready for gradual module migration. + This screen brings the most important publishing, moderation, and AI signals into the + new standalone admin so the day-to-day control loop stays in one place.

diff --git a/admin/src/pages/friend-links-page.tsx b/admin/src/pages/friend-links-page.tsx new file mode 100644 index 0000000..fb28a7f --- /dev/null +++ b/admin/src/pages/friend-links-page.tsx @@ -0,0 +1,411 @@ +import { ExternalLink, Link2, RefreshCcw, Save, Trash2 } from 'lucide-react' +import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { FormField } from '@/components/form-field' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Select } from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { adminApi, ApiError } from '@/lib/api' +import { emptyToNull, formatDateTime } from '@/lib/admin-format' +import type { FriendLinkPayload, FriendLinkRecord } from '@/lib/types' + +type FriendLinkFormState = { + siteName: string + siteUrl: string + avatarUrl: string + description: string + category: string + status: string +} + +const defaultFriendLinkForm: FriendLinkFormState = { + siteName: '', + siteUrl: '', + avatarUrl: '', + description: '', + category: '', + status: 'pending', +} + +function toFormState(link: FriendLinkRecord): FriendLinkFormState { + return { + siteName: link.site_name ?? '', + siteUrl: link.site_url, + avatarUrl: link.avatar_url ?? '', + description: link.description ?? '', + category: link.category ?? '', + status: link.status ?? 'pending', + } +} + +function toPayload(form: FriendLinkFormState): FriendLinkPayload { + return { + siteName: emptyToNull(form.siteName), + siteUrl: form.siteUrl.trim(), + avatarUrl: emptyToNull(form.avatarUrl), + description: emptyToNull(form.description), + category: emptyToNull(form.category), + status: emptyToNull(form.status) ?? 'pending', + } +} + +function statusBadgeVariant(status: string | null) { + switch (status) { + case 'approved': + return 'success' as const + case 'rejected': + return 'danger' as const + default: + return 'warning' as const + } +} + +export function FriendLinksPage() { + const [links, setLinks] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [form, setForm] = useState(defaultFriendLinkForm) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + + const loadLinks = useCallback(async (showToast = false) => { + try { + if (showToast) { + setRefreshing(true) + } + + const next = await adminApi.listFriendLinks() + startTransition(() => { + setLinks(next) + }) + + if (showToast) { + toast.success('Friend links refreshed.') + } + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return + } + toast.error(error instanceof ApiError ? error.message : 'Unable to load friend links.') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + void loadLinks(false) + }, [loadLinks]) + + const filteredLinks = useMemo(() => { + return links.filter((link) => { + const matchesSearch = + !searchTerm || + [ + link.site_name ?? '', + link.site_url, + link.category ?? '', + link.description ?? '', + link.status ?? '', + ] + .join('\n') + .toLowerCase() + .includes(searchTerm.toLowerCase()) + + const matchesStatus = statusFilter === 'all' || (link.status ?? 'pending') === statusFilter + + return matchesSearch && matchesStatus + }) + }, [links, searchTerm, statusFilter]) + + const selectedLink = useMemo( + () => links.find((link) => link.id === selectedId) ?? null, + [links, selectedId], + ) + + return ( +
+
+
+ Friend links +
+

Partner site queue

+

+ Review inbound link exchanges, keep metadata accurate, and move requests through + pending, approved, or rejected states in one dedicated workspace. +

+
+
+ +
+ + +
+
+ +
+ + + Link inventory + + Pick an item to edit it, or start a new record from the right-hand form. + + + +
+ setSearchTerm(event.target.value)} + /> + +
+ + {loading ? ( + + ) : ( +
+ {filteredLinks.map((link) => ( + + ))} + + {!filteredLinks.length ? ( +
+ +

No friend links match the current filters.

+
+ ) : null} +
+ )} +
+
+ + + +
+ {selectedLink ? 'Edit friend link' : 'Create friend link'} + + Capture the reciprocal URL, classification, and moderation status the public link + page depends on. + +
+
+ {selectedLink ? ( + + ) : null} + + {selectedLink ? ( + + ) : null} +
+ + + {selectedLink ? ( +
+
+
+

+ Selected record +

+

+ Created {formatDateTime(selectedLink.created_at)} +

+
+ + {selectedLink.status ?? 'pending'} + +
+
+ ) : null} + +
+ + + setForm((current) => ({ ...current, siteName: event.target.value })) + } + /> + + + + setForm((current) => ({ ...current, siteUrl: event.target.value })) + } + /> + + + + setForm((current) => ({ ...current, avatarUrl: event.target.value })) + } + /> + + + + setForm((current) => ({ ...current, category: event.target.value })) + } + /> + +
+ + + +
+
+ +