diff --git a/.env.example b/.env.example index 7177f8c..e7a7452 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 REDIS_URL=rediss://default:replace-me@redis.example.internal:6379 REDIS_CACHE_PREFIX=buzz:next-cache REDIS_EVENT_PREFIX=buzz:events +REDIS_PRESENCE_PREFIX=buzz:presence REDIS_CACHE_MAX_ENTRY_BYTES=5242880 OUTBOX_BATCH_SIZE=20 OUTBOX_POLL_MS=1000 diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 8d8f841..5b58451 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import Image from "next/image"; import { ArrowLeftIcon } from "lucide-react"; import { notFound, redirect } from "next/navigation"; import { connection } from "next/server"; @@ -6,6 +7,7 @@ import { connection } from "next/server"; import { GuideSectionEditor } from "@/components/admin/guide-section-editor"; import { GuideSectionNav } from "@/components/admin/guide-section-nav"; import { AdminHeader } from "@/components/admin/admin-header"; +import { EditorPresence } from "@/components/admin/editor-presence"; import { Button } from "@/components/ui/button"; import { getAdminSession } from "@/lib/auth/server"; import { @@ -55,12 +57,16 @@ export default async function StructuredEditorPage({ > +
+ {guide.name} +

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

+
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; + return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; } diff --git a/app/admin/actions.ts b/app/admin/actions.ts index 2dc82d4..7add01d 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -4,7 +4,11 @@ import { updateTag } from "next/cache"; import * as z from "zod"; import type { AutosaveResponse } from "@/lib/editor/autosave"; -import { pageCacheTag } from "@/lib/cache/tags"; +import { GLOSSARY_CACHE_TAG, pageCacheTag } from "@/lib/cache/tags"; +import { + createGlossaryAlias, + deleteGlossaryAlias, +} from "@/lib/glossary-repository"; import { createNamedRevision, saveDataSourceVersion, @@ -301,3 +305,35 @@ export async function startCatalogSync() { return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" }; } } + +export async function addGlossaryAlias(input: unknown) { + await requireAdmin(); + try { + const alias = await createGlossaryAlias(input); + updateTag(GLOSSARY_CACHE_TAG); + return { status: "saved" as const, alias }; + } catch (cause) { + const message = cause instanceof Error && cause.message.includes("unique") + ? "Shortcut นี้ถูกใช้งานแล้ว" + : cause instanceof z.ZodError + ? cause.issues.map((issue) => issue.message).join(" • ") + : cause instanceof Error + ? cause.message + : "เพิ่ม Shortcut ไม่สำเร็จ"; + return { status: "error" as const, message }; + } +} + +export async function removeGlossaryAlias(id: string) { + await requireAdmin(); + try { + await deleteGlossaryAlias(z.string().uuid().parse(id)); + updateTag(GLOSSARY_CACHE_TAG); + return { status: "saved" as const }; + } catch (cause) { + return { + status: "error" as const, + message: cause instanceof Error ? cause.message : "ลบ Shortcut ไม่สำเร็จ", + }; + } +} diff --git a/app/admin/glossary/page.tsx b/app/admin/glossary/page.tsx new file mode 100644 index 0000000..0c75055 --- /dev/null +++ b/app/admin/glossary/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminHeader } from "@/components/admin/admin-header"; +import { GlossaryManager } from "@/components/admin/glossary-manager"; +import { getAdminSession } from "@/lib/auth/server"; +import { listGlossaryAliases } from "@/lib/glossary-repository"; +import { getActiveCatalog } from "@/lib/guides/queries"; + +export default async function GlossaryPage() { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + const [catalog, aliases] = await Promise.all([ + getActiveCatalog(), + listGlossaryAliases(), + ]); + if (!catalog) redirect("/admin"); + return ( +
+ +
+
+

Search shortcuts

+

Glossary Manager

+
+ +
+
+ ); +} diff --git a/app/api/presence/admin/route.ts b/app/api/presence/admin/route.ts new file mode 100644 index 0000000..f2137b4 --- /dev/null +++ b/app/api/presence/admin/route.ts @@ -0,0 +1,33 @@ +import { connection } from "next/server"; +import * as z from "zod"; + +import { AdminAuthorizationError, requireAdmin } from "@/lib/auth/server"; +import { updateAdminPresence } from "@/lib/presence"; + +const inputSchema = z.strictObject({ + tabId: z.string().uuid(), + guideId: z.string().uuid().nullable(), + active: z.boolean().default(true), +}); + +export async function POST(request: Request) { + await connection(); + try { + const admin = await requireAdmin(); + const parsed = inputSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return Response.json({ error: "invalid-presence" }, { status: 400 }); + return Response.json(await updateAdminPresence({ + ...parsed.data, + memberId: `${admin.session.id}:${parsed.data.tabId}`, + user: admin.user, + }), { headers: { "Cache-Control": "no-store" } }); + } catch (cause) { + if (cause instanceof AdminAuthorizationError) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + return Response.json({ error: "presence-unavailable" }, { + status: 503, + headers: { "Cache-Control": "no-store" }, + }); + } +} diff --git a/app/api/presence/visitors/route.ts b/app/api/presence/visitors/route.ts new file mode 100644 index 0000000..ddfe4c3 --- /dev/null +++ b/app/api/presence/visitors/route.ts @@ -0,0 +1,26 @@ +import { connection } from "next/server"; +import * as z from "zod"; + +import { updateVisitorPresence } from "@/lib/presence"; + +const inputSchema = z.strictObject({ + tabId: z.string().uuid(), + guideId: z.string().uuid().nullable(), + active: z.boolean().default(true), +}); + +export async function POST(request: Request) { + await connection(); + const parsed = inputSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return Response.json({ error: "invalid-presence" }, { status: 400 }); + try { + return Response.json(await updateVisitorPresence(parsed.data), { + headers: { "Cache-Control": "no-store" }, + }); + } catch { + return Response.json({ error: "presence-unavailable" }, { + status: 503, + headers: { "Cache-Control": "no-store" }, + }); + } +} diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 15e4d82..5957447 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation"; import { ExternalLinkIcon, LayoutDashboardIcon, + BookOpenIcon, LogOutIcon, PlusIcon, UserPlusIcon, @@ -29,6 +30,15 @@ export function AdminHeader() { ไกด์ช่องเกนชินไม่ใช่เกมมือถือ