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
|
||||
? 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user