feat : update
This commit is contained in:
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user