From 5aa0ae80c1c5ee7aec3ac78ff3a84482cb7413ee Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 5 Sep 2026 18:15:06 +0700 Subject: [PATCH] feat : update --- .env.example | 1 + app/admin/[character]/[page]/page.tsx | 6 + app/admin/[character]/page.tsx | 3 +- app/admin/actions.ts | 38 +- app/admin/glossary/page.tsx | 34 + app/api/presence/admin/route.ts | 33 + app/api/presence/visitors/route.ts | 26 + components/admin/admin-header.tsx | 10 + components/admin/create-guide-flow.tsx | 9 +- components/admin/editor-presence.tsx | 22 + components/admin/extra-sheet-editor.tsx | 106 +- components/admin/glossary-manager.tsx | 165 + components/admin/guide-section-editor.tsx | 68 +- components/admin/structured-dashboard.tsx | 9 +- components/public/site-header.tsx | 4 +- components/public/structured-directory.tsx | 5 +- components/public/structured-guide.tsx | 8 +- components/public/team-search.tsx | 29 +- components/public/visitor-presence.tsx | 23 + db/schema.ts | 21 +- drizzle/0030_third_maddog.sql | 13 + drizzle/meta/0030_snapshot.json | 3557 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + hooks/use-autosave.ts | 35 +- hooks/use-presence.ts | 70 + k8s/base/configmap.yaml | 1 + lib/cache/public.ts | 5 + lib/cache/tags.ts | 1 + lib/glossary-repository.ts | 62 + lib/glossary.test.ts | 20 + lib/glossary.ts | 32 + lib/guides/extra-sheet.test.ts | 15 + lib/guides/extra-sheet.ts | 32 + lib/guides/mutations.ts | 2 +- lib/guides/queries.ts | 31 +- lib/guides/schemas.test.ts | 8 +- lib/guides/schemas.ts | 10 +- lib/guides/team-search.test.ts | 12 +- lib/guides/team-search.ts | 26 + lib/presence-constants.test.ts | 10 + lib/presence-constants.ts | 2 + lib/presence.ts | 109 + 42 files changed, 4613 insertions(+), 67 deletions(-) create mode 100644 app/admin/glossary/page.tsx create mode 100644 app/api/presence/admin/route.ts create mode 100644 app/api/presence/visitors/route.ts create mode 100644 components/admin/editor-presence.tsx create mode 100644 components/admin/glossary-manager.tsx create mode 100644 components/public/visitor-presence.tsx create mode 100644 drizzle/0030_third_maddog.sql create mode 100644 drizzle/meta/0030_snapshot.json create mode 100644 hooks/use-presence.ts create mode 100644 lib/glossary-repository.ts create mode 100644 lib/glossary.test.ts create mode 100644 lib/glossary.ts create mode 100644 lib/presence-constants.test.ts create mode 100644 lib/presence-constants.ts create mode 100644 lib/presence.ts 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() { ไกด์ช่องเกนชินไม่ใช่เกมมือถือ