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 {