diff --git a/.agents/skills/ordering-with-dnd-kit/SKILL.md b/.agents/skills/ordering-with-dnd-kit/SKILL.md new file mode 100644 index 0000000..026dd90 --- /dev/null +++ b/.agents/skills/ordering-with-dnd-kit/SKILL.md @@ -0,0 +1,16 @@ +--- +name: ordering-with-dnd-kit +description: Use the project's installed dnd-kit packages for UI requests involving ordering, reordering, sorting, dragging, or moving items. Applies when a user asks for an orderable or sortable interface, including similar wording. +--- + +# Ordering with dnd-kit + +Implement interactive ordering and reordering with the project's installed dnd-kit libraries: + +- Use `@dnd-kit/core` for drag-and-drop context, sensors, and events. +- Use `@dnd-kit/sortable` for sortable collections and item movement. +- Use `@dnd-kit/utilities` for dnd-kit transform helpers when needed. +- Prefer these library APIs over custom pointer handling or native HTML drag-and-drop. +- Preserve the project's existing UI patterns and persist the resulting order through the data flow already used by the feature. + +Interpret requests mentioning ordering, reordering, sorting, rearranging, dragging, moving, ranking, or similar item-placement behavior as triggers for this guidance when the request concerns an interactive UI. diff --git a/.agents/skills/ordering-with-dnd-kit/agents/openai.yaml b/.agents/skills/ordering-with-dnd-kit/agents/openai.yaml new file mode 100644 index 0000000..8d048b3 --- /dev/null +++ b/.agents/skills/ordering-with-dnd-kit/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Ordering with Dnd Kit" + short_description: "Build sortable interfaces with dnd-kit" diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 14d9902..d9ff124 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -49,6 +49,9 @@ export default async function StructuredEditorPage({ const editorSection = fixedSection ? page as (typeof sections)[number] : "extra" as const; + const sectionTitle = extraSection?.title + ?? guide.sections.find((section) => section.slug === page)?.title + ?? page; const [catalog, content] = await Promise.all([ getActiveCatalog(), getAdminGuideContent(guide.slug), @@ -77,9 +80,9 @@ export default async function StructuredEditorPage({

{guide.name}

- {extraSection?.title ?? guide.sections.find((section) => section.slug === page)?.title ?? page} + {sectionTitle}

- +

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

{guide.name}

- + diff --git a/app/api/presence/admin/route.ts b/app/api/presence/admin/route.ts index f2137b4..786a121 100644 --- a/app/api/presence/admin/route.ts +++ b/app/api/presence/admin/route.ts @@ -7,6 +7,7 @@ import { updateAdminPresence } from "@/lib/presence"; const inputSchema = z.strictObject({ tabId: z.string().uuid(), guideId: z.string().uuid().nullable(), + section: z.string().trim().min(1).max(100).nullable(), active: z.boolean().default(true), }); diff --git a/components/admin/editor-presence.tsx b/components/admin/editor-presence.tsx index dad118b..d5bc424 100644 --- a/components/admin/editor-presence.tsx +++ b/components/admin/editor-presence.tsx @@ -5,8 +5,8 @@ import { UsersIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { useAdminPresence } from "@/hooks/use-presence"; -export function EditorPresence({ guideId }: { guideId: string }) { - const presence = useAdminPresence(guideId); +export function EditorPresence({ guideId, section }: { guideId: string; section: string }) { + const presence = useAdminPresence(guideId, section); const editors = presence?.editorsByGuide[guideId] ?? []; return (
@@ -15,7 +15,10 @@ export function EditorPresence({ guideId }: { guideId: string }) { Editing {editors.length || (presence ? 0 : "")} {editors.map((editor) => ( - {editor.name} + + {editor.name} + {editor.sections.length ? ` · ${editor.sections.join(", ")}` : ""} + ))}
); diff --git a/components/admin/structured-dashboard.tsx b/components/admin/structured-dashboard.tsx index 07ed339..bf65721 100644 --- a/components/admin/structured-dashboard.tsx +++ b/components/admin/structured-dashboard.tsx @@ -217,7 +217,10 @@ export function StructuredDashboard({
v{guide.version} {(presence?.editorsByGuide[guide.id] ?? []).map((editor) => ( - {editor.name} + + {editor.name} + {editor.sections.length ? ` · ${editor.sections.join(", ")}` : ""} + ))}
diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx index 5bb5b2b..700cf4a 100644 --- a/components/public/structured-directory.tsx +++ b/components/public/structured-directory.tsx @@ -2,15 +2,17 @@ import Image from "next/image"; import Link from "next/link"; -import { SearchIcon } from "lucide-react"; +import { SearchIcon, UsersIcon } from "lucide-react"; import { useState } from "react"; import { Card, + CardAction, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { Empty, EmptyDescription, @@ -22,6 +24,7 @@ import { InputGroupAddon, InputGroupInput, } from "@/components/ui/input-group"; +import { useVisitorPresence } from "@/hooks/use-presence"; type GuideCard = { id: string; @@ -35,6 +38,7 @@ type GuideCard = { export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { const [query, setQuery] = useState(""); + const presence = useVisitorPresence(null); const normalized = query.trim().toLowerCase(); const filtered = guides.filter((guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`.toLowerCase().includes(normalized), @@ -71,6 +75,14 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { {guide.name} + {(presence?.byGuide[guide.id] ?? 0) > 0 ? ( + + + + {presence?.byGuide[guide.id]} คน + + + ) : null} {guide.overview} diff --git a/hooks/use-presence.ts b/hooks/use-presence.ts index 2929f1f..13626f7 100644 --- a/hooks/use-presence.ts +++ b/hooks/use-presence.ts @@ -4,8 +4,15 @@ import { useEffect, useEffectEvent, useState } from "react"; import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants"; -type VisitorCounts = { global: number; guide: number | null }; -export type EditorsByGuide = Record>; +type VisitorCounts = { + global: number; + guide: number | null; + byGuide: Record; +}; +export type EditorsByGuide = Record< + string, + Array<{ userId: string; name: string; sections: string[] }> +>; function tabId(storageKey: string): string { const existing = window.sessionStorage.getItem(storageKey); @@ -18,9 +25,11 @@ function tabId(storageKey: string): string { function useHeartbeat({ endpoint, guideId, + section, }: { endpoint: string; guideId: string | null; + section?: string | null; }) { const [value, setValue] = useState(null); const send = useEffectEvent(async (active = true) => { @@ -28,7 +37,12 @@ function useHeartbeat({ const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tabId: tabId(`buzz-sheet:${endpoint}:tab`), guideId, active }), + body: JSON.stringify({ + tabId: tabId(`buzz-sheet:${endpoint}:tab`), + guideId, + ...(section !== undefined ? { section } : {}), + active, + }), cache: "no-store", keepalive: !active, }); @@ -56,7 +70,7 @@ function useHeartbeat({ window.removeEventListener("pagehide", leave); document.removeEventListener("visibilitychange", refresh); }; - }, [endpoint, guideId]); + }, [endpoint, guideId, section]); return value; } @@ -65,6 +79,10 @@ export function useVisitorPresence(guideId: string | null) { return useHeartbeat({ endpoint: "/api/presence/visitors", guideId }); } -export function useAdminPresence(guideId: string | null) { - return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ endpoint: "/api/presence/admin", guideId }); +export function useAdminPresence(guideId: string | null, section: string | null = null) { + return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ + endpoint: "/api/presence/admin", + guideId, + section, + }); } diff --git a/lib/presence.ts b/lib/presence.ts index 60597f1..097adff 100644 --- a/lib/presence.ts +++ b/lib/presence.ts @@ -10,6 +10,7 @@ type AdminPresence = { name: string; email: string; guideId: string; + section: string | null; }; function prefix(): string { @@ -56,6 +57,10 @@ export async function updateVisitorPresence({ guide: guideId ? scopes.filter((scope) => scope === guideId).length : null, + byGuide: scopes.reduce>((counts, scope) => { + if (scope) counts[scope] = (counts[scope] ?? 0) + 1; + return counts; + }, {}), }; } @@ -63,11 +68,13 @@ export async function updateAdminPresence({ memberId, user, guideId, + section, active, }: { memberId: string; user: { id: string; name?: string | null; email: string }; guideId: string | null; + section: string | null; active: boolean; }) { const redis = await getRedisClient(); @@ -80,6 +87,7 @@ export async function updateAdminPresence({ name: user.name?.trim() || user.email, email: user.email, guideId, + section, }; await redis.multi() .zadd(activeKey, now + PRESENCE_TTL_MS, memberId) @@ -91,14 +99,21 @@ export async function updateAdminPresence({ await purge(redis, activeKey, detailKey, now); const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf"); const serialized = ids.length ? await redis.hmget(detailKey, ...ids) : []; - const editorsByGuide: Record> = {}; + const editorsByGuide: Record> = {}; for (const value of serialized) { if (!value) continue; try { const detail = JSON.parse(value) as AdminPresence; const editors = editorsByGuide[detail.guideId] ?? []; - if (!editors.some((editor) => editor.userId === detail.userId)) { - editors.push({ userId: detail.userId, name: detail.name || detail.email }); + const existing = editors.find((editor) => editor.userId === detail.userId); + if (existing && detail.section && !existing.sections.includes(detail.section)) { + existing.sections.push(detail.section); + } else if (!existing) { + editors.push({ + userId: detail.userId, + name: detail.name || detail.email, + sections: detail.section ? [detail.section] : [], + }); } editorsByGuide[detail.guideId] = editors; } catch {