feat : update
CI / Verify (push) Successful in 1m14s
CI / Build immutable images and deploy (push) Successful in 4m51s

This commit is contained in:
2026-09-06 17:41:16 +07:00 Unverified
parent fcf6a2c187
commit 770c60c4a2
10 changed files with 91 additions and 17 deletions
@@ -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.
@@ -0,0 +1,3 @@
interface:
display_name: "Ordering with Dnd Kit"
short_description: "Build sortable interfaces with dnd-kit"
+5 -2
View File
@@ -49,6 +49,9 @@ export default async function StructuredEditorPage({
const editorSection = fixedSection const editorSection = fixedSection
? page as (typeof sections)[number] ? page as (typeof sections)[number]
: "extra" as const; : "extra" as const;
const sectionTitle = extraSection?.title
?? guide.sections.find((section) => section.slug === page)?.title
?? page;
const [catalog, content] = await Promise.all([ const [catalog, content] = await Promise.all([
getActiveCatalog(), getActiveCatalog(),
getAdminGuideContent(guide.slug), getAdminGuideContent(guide.slug),
@@ -77,9 +80,9 @@ export default async function StructuredEditorPage({
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-muted-foreground">{guide.name}</p> <p className="text-sm text-muted-foreground">{guide.name}</p>
<h1 className="whitespace-nowrap font-heading text-3xl font-semibold"> <h1 className="whitespace-nowrap font-heading text-3xl font-semibold">
{extraSection?.title ?? guide.sections.find((section) => section.slug === page)?.title ?? page} {sectionTitle}
</h1> </h1>
<EditorPresence guideId={guide.id} /> <EditorPresence guideId={guide.id} section={sectionTitle} />
</div> </div>
</header> </header>
<GuideSectionNav <GuideSectionNav
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p> <p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p>
<h1 className="font-heading text-3xl font-semibold">{guide.name}</h1> <h1 className="font-heading text-3xl font-semibold">{guide.name}</h1>
<EditorPresence guideId={guide.id} /> <EditorPresence guideId={guide.id} section="Overview" />
</div> </div>
<GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} /> <GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} />
</header> </header>
+1
View File
@@ -7,6 +7,7 @@ import { updateAdminPresence } from "@/lib/presence";
const inputSchema = z.strictObject({ const inputSchema = z.strictObject({
tabId: z.string().uuid(), tabId: z.string().uuid(),
guideId: z.string().uuid().nullable(), guideId: z.string().uuid().nullable(),
section: z.string().trim().min(1).max(100).nullable(),
active: z.boolean().default(true), active: z.boolean().default(true),
}); });
+6 -3
View File
@@ -5,8 +5,8 @@ import { UsersIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useAdminPresence } from "@/hooks/use-presence"; import { useAdminPresence } from "@/hooks/use-presence";
export function EditorPresence({ guideId }: { guideId: string }) { export function EditorPresence({ guideId, section }: { guideId: string; section: string }) {
const presence = useAdminPresence(guideId); const presence = useAdminPresence(guideId, section);
const editors = presence?.editorsByGuide[guideId] ?? []; const editors = presence?.editorsByGuide[guideId] ?? [];
return ( return (
<div className="flex flex-wrap items-center gap-1" aria-live="polite"> <div className="flex flex-wrap items-center gap-1" aria-live="polite">
@@ -15,7 +15,10 @@ export function EditorPresence({ guideId }: { guideId: string }) {
Editing {editors.length || (presence ? 0 : "")} Editing {editors.length || (presence ? 0 : "")}
</Badge> </Badge>
{editors.map((editor) => ( {editors.map((editor) => (
<Badge key={editor.userId} variant="outline">{editor.name}</Badge> <Badge key={editor.userId} variant="outline">
{editor.name}
{editor.sections.length ? ` · ${editor.sections.join(", ")}` : ""}
</Badge>
))} ))}
</div> </div>
); );
+4 -1
View File
@@ -217,7 +217,10 @@ export function StructuredDashboard({
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<Badge variant="secondary">v{guide.version}</Badge> <Badge variant="secondary">v{guide.version}</Badge>
{(presence?.editorsByGuide[guide.id] ?? []).map((editor) => ( {(presence?.editorsByGuide[guide.id] ?? []).map((editor) => (
<Badge key={editor.userId} variant="outline">{editor.name}</Badge> <Badge key={editor.userId} variant="outline">
{editor.name}
{editor.sections.length ? ` · ${editor.sections.join(", ")}` : ""}
</Badge>
))} ))}
</div> </div>
</CardAction> </CardAction>
+13 -1
View File
@@ -2,15 +2,17 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { SearchIcon } from "lucide-react"; import { SearchIcon, UsersIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import {
Card, Card,
CardAction,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
@@ -22,6 +24,7 @@ import {
InputGroupAddon, InputGroupAddon,
InputGroupInput, InputGroupInput,
} from "@/components/ui/input-group"; } from "@/components/ui/input-group";
import { useVisitorPresence } from "@/hooks/use-presence";
type GuideCard = { type GuideCard = {
id: string; id: string;
@@ -35,6 +38,7 @@ type GuideCard = {
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) { export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const presence = useVisitorPresence(null);
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
const filtered = guides.filter((guide) => const filtered = guides.filter((guide) =>
`${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`.toLowerCase().includes(normalized), `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`.toLowerCase().includes(normalized),
@@ -71,6 +75,14 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
</div> </div>
<CardHeader className="pt-5"> <CardHeader className="pt-5">
<CardTitle>{guide.name}</CardTitle> <CardTitle>{guide.name}</CardTitle>
{(presence?.byGuide[guide.id] ?? 0) > 0 ? (
<CardAction>
<Badge variant="secondary">
<UsersIcon data-icon="inline-start" />
{presence?.byGuide[guide.id]} คน
</Badge>
</CardAction>
) : null}
<CardDescription className="line-clamp-3"> <CardDescription className="line-clamp-3">
{guide.overview} {guide.overview}
</CardDescription> </CardDescription>
+24 -6
View File
@@ -4,8 +4,15 @@ import { useEffect, useEffectEvent, useState } from "react";
import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants"; import { PRESENCE_HEARTBEAT_MS } from "@/lib/presence-constants";
type VisitorCounts = { global: number; guide: number | null }; type VisitorCounts = {
export type EditorsByGuide = Record<string, Array<{ userId: string; name: string }>>; global: number;
guide: number | null;
byGuide: Record<string, number>;
};
export type EditorsByGuide = Record<
string,
Array<{ userId: string; name: string; sections: string[] }>
>;
function tabId(storageKey: string): string { function tabId(storageKey: string): string {
const existing = window.sessionStorage.getItem(storageKey); const existing = window.sessionStorage.getItem(storageKey);
@@ -18,9 +25,11 @@ function tabId(storageKey: string): string {
function useHeartbeat<T>({ function useHeartbeat<T>({
endpoint, endpoint,
guideId, guideId,
section,
}: { }: {
endpoint: string; endpoint: string;
guideId: string | null; guideId: string | null;
section?: string | null;
}) { }) {
const [value, setValue] = useState<T | null>(null); const [value, setValue] = useState<T | null>(null);
const send = useEffectEvent(async (active = true) => { const send = useEffectEvent(async (active = true) => {
@@ -28,7 +37,12 @@ function useHeartbeat<T>({
const response = await fetch(endpoint, { const response = await fetch(endpoint, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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", cache: "no-store",
keepalive: !active, keepalive: !active,
}); });
@@ -56,7 +70,7 @@ function useHeartbeat<T>({
window.removeEventListener("pagehide", leave); window.removeEventListener("pagehide", leave);
document.removeEventListener("visibilitychange", refresh); document.removeEventListener("visibilitychange", refresh);
}; };
}, [endpoint, guideId]); }, [endpoint, guideId, section]);
return value; return value;
} }
@@ -65,6 +79,10 @@ export function useVisitorPresence(guideId: string | null) {
return useHeartbeat<VisitorCounts>({ endpoint: "/api/presence/visitors", guideId }); return useHeartbeat<VisitorCounts>({ endpoint: "/api/presence/visitors", guideId });
} }
export function useAdminPresence(guideId: string | null) { export function useAdminPresence(guideId: string | null, section: string | null = null) {
return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({ endpoint: "/api/presence/admin", guideId }); return useHeartbeat<{ editorsByGuide: EditorsByGuide }>({
endpoint: "/api/presence/admin",
guideId,
section,
});
} }
+18 -3
View File
@@ -10,6 +10,7 @@ type AdminPresence = {
name: string; name: string;
email: string; email: string;
guideId: string; guideId: string;
section: string | null;
}; };
function prefix(): string { function prefix(): string {
@@ -56,6 +57,10 @@ export async function updateVisitorPresence({
guide: guideId guide: guideId
? scopes.filter((scope) => scope === guideId).length ? scopes.filter((scope) => scope === guideId).length
: null, : null,
byGuide: scopes.reduce<Record<string, number>>((counts, scope) => {
if (scope) counts[scope] = (counts[scope] ?? 0) + 1;
return counts;
}, {}),
}; };
} }
@@ -63,11 +68,13 @@ export async function updateAdminPresence({
memberId, memberId,
user, user,
guideId, guideId,
section,
active, active,
}: { }: {
memberId: string; memberId: string;
user: { id: string; name?: string | null; email: string }; user: { id: string; name?: string | null; email: string };
guideId: string | null; guideId: string | null;
section: string | null;
active: boolean; active: boolean;
}) { }) {
const redis = await getRedisClient(); const redis = await getRedisClient();
@@ -80,6 +87,7 @@ export async function updateAdminPresence({
name: user.name?.trim() || user.email, name: user.name?.trim() || user.email,
email: user.email, email: user.email,
guideId, guideId,
section,
}; };
await redis.multi() await redis.multi()
.zadd(activeKey, now + PRESENCE_TTL_MS, memberId) .zadd(activeKey, now + PRESENCE_TTL_MS, memberId)
@@ -91,14 +99,21 @@ export async function updateAdminPresence({
await purge(redis, activeKey, detailKey, now); await purge(redis, activeKey, detailKey, now);
const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf"); const ids = await redis.zrangebyscore(activeKey, now + 1, "+inf");
const serialized = ids.length ? await redis.hmget(detailKey, ...ids) : []; const serialized = ids.length ? await redis.hmget(detailKey, ...ids) : [];
const editorsByGuide: Record<string, Array<{ userId: string; name: string }>> = {}; const editorsByGuide: Record<string, Array<{ userId: string; name: string; sections: string[] }>> = {};
for (const value of serialized) { for (const value of serialized) {
if (!value) continue; if (!value) continue;
try { try {
const detail = JSON.parse(value) as AdminPresence; const detail = JSON.parse(value) as AdminPresence;
const editors = editorsByGuide[detail.guideId] ?? []; const editors = editorsByGuide[detail.guideId] ?? [];
if (!editors.some((editor) => editor.userId === detail.userId)) { const existing = editors.find((editor) => editor.userId === detail.userId);
editors.push({ userId: detail.userId, name: detail.name || detail.email }); 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; editorsByGuide[detail.guideId] = editors;
} catch { } catch {