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
? 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({
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{guide.name}</p>
<h1 className="whitespace-nowrap font-heading text-3xl font-semibold">
{extraSection?.title ?? guide.sections.find((section) => section.slug === page)?.title ?? page}
{sectionTitle}
</h1>
<EditorPresence guideId={guide.id} />
<EditorPresence guideId={guide.id} section={sectionTitle} />
</div>
</header>
<GuideSectionNav
+1 -1
View File
@@ -47,7 +47,7 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{
<div className="min-w-0 flex-1">
<p className="text-sm text-muted-foreground">เลือกส่วนที่ต้องการแก้ไข</p>
<h1 className="font-heading text-3xl font-semibold">{guide.name}</h1>
<EditorPresence guideId={guide.id} />
<EditorPresence guideId={guide.id} section="Overview" />
</div>
<GuideVisibilityControl guideId={guide.id} guideName={guide.name} isPublic={guide.isPublic} />
</header>
+1
View File
@@ -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),
});
+6 -3
View File
@@ -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 (
<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 : "")}
</Badge>
{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>
);
+4 -1
View File
@@ -217,7 +217,10 @@ export function StructuredDashboard({
<div className="flex flex-col items-end gap-1">
<Badge variant="secondary">v{guide.version}</Badge>
{(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>
</CardAction>
+13 -1
View File
@@ -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[] }) {
</div>
<CardHeader className="pt-5">
<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">
{guide.overview}
</CardDescription>
+24 -6
View File
@@ -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<string, Array<{ userId: string; name: string }>>;
type VisitorCounts = {
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 {
const existing = window.sessionStorage.getItem(storageKey);
@@ -18,9 +25,11 @@ function tabId(storageKey: string): string {
function useHeartbeat<T>({
endpoint,
guideId,
section,
}: {
endpoint: string;
guideId: string | null;
section?: string | null;
}) {
const [value, setValue] = useState<T | null>(null);
const send = useEffectEvent(async (active = true) => {
@@ -28,7 +37,12 @@ function useHeartbeat<T>({
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<T>({
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<VisitorCounts>({ 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,
});
}
+18 -3
View File
@@ -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<Record<string, number>>((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<string, Array<{ userId: string; name: string }>> = {};
const editorsByGuide: Record<string, Array<{ userId: string; name: string; sections: string[] }>> = {};
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 {