Skip to content

Commit b792758

Browse files
committed
✨ Highlights admin page
1 parent af11918 commit b792758

14 files changed

Lines changed: 521 additions & 21 deletions

File tree

app/actions/remove-featured.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use server";
2+
3+
import { MUTATIONS } from "@/lib/supabase/repository";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
export type RemoveFeaturedState = { ok: boolean; error: string };
7+
8+
export async function removeFeatured(
9+
translationId: string,
10+
): Promise<RemoveFeaturedState> {
11+
const supabase = await createClient();
12+
13+
// Verify admin role
14+
const {
15+
data: { user },
16+
} = await supabase.auth.getUser();
17+
if (!user) {
18+
return { ok: false, error: "로그인이 필요해요" };
19+
}
20+
if (user.app_metadata?.userrole !== "admin") {
21+
return { ok: false, error: "관리자 권한이 필요해요" };
22+
}
23+
24+
const { error } = await MUTATIONS.removeFeatured(supabase, translationId);
25+
if (error) {
26+
return { ok: false, error: "하이라이트를 삭제하는 중 문제가 생겼어요" };
27+
}
28+
29+
return { ok: true, error: "" };
30+
}

app/admin/highlights/page.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { redirect } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { QUERIES } from "@/lib/supabase/repository";
4+
import HighlightsManager, {
5+
type FeaturedTranslation,
6+
} from "@/components/admin/highlights-manager";
7+
8+
export default async function AdminHighlightsPage() {
9+
const supabase = await createClient();
10+
11+
// Verify admin role
12+
const {
13+
data: { user },
14+
} = await supabase.auth.getUser();
15+
if (!user || user.app_metadata?.userrole !== "admin") {
16+
redirect("/");
17+
}
18+
19+
const { data, error } = await QUERIES.listAllFeaturedTranslations(supabase);
20+
if (error) throw error;
21+
22+
const featuredTranslations: FeaturedTranslation[] = (data ?? []).map(
23+
(item) => ({
24+
id: item.id,
25+
name: item.name,
26+
featured: item.featured!,
27+
jargonName: (item.jargon as { name: string })?.name ?? "",
28+
jargonSlug: (item.jargon as { slug: string })?.slug ?? "",
29+
}),
30+
);
31+
32+
return (
33+
<div className="mx-auto max-w-3xl py-8">
34+
<h1 className="mb-6 text-2xl font-bold">하이라이트 관리</h1>
35+
<p className="text-muted-foreground mb-8">
36+
홈 화면에 표시될 하이라이트 번역어의 순서를 변경하거나 삭제할 수
37+
있습니다. 드래그하여 순서를 변경하세요.
38+
</p>
39+
<HighlightsManager initialData={featuredTranslations} />
40+
</div>
41+
);
42+
}

bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"use client";
2+
3+
import { useState, useTransition, useSyncExternalStore } from "react";
4+
import Link from "next/link";
5+
import {
6+
DndContext,
7+
closestCenter,
8+
KeyboardSensor,
9+
PointerSensor,
10+
useSensor,
11+
useSensors,
12+
type DragEndEvent,
13+
} from "@dnd-kit/core";
14+
import {
15+
arrayMove,
16+
SortableContext,
17+
sortableKeyboardCoordinates,
18+
useSortable,
19+
verticalListSortingStrategy,
20+
} from "@dnd-kit/sortable";
21+
import { CSS } from "@dnd-kit/utilities";
22+
import { GripVertical, Trash2, ExternalLink } from "lucide-react";
23+
import { Button } from "@/components/ui/button";
24+
import { Card } from "@/components/ui/card";
25+
import {
26+
AlertDialog,
27+
AlertDialogAction,
28+
AlertDialogCancel,
29+
AlertDialogContent,
30+
AlertDialogDescription,
31+
AlertDialogFooter,
32+
AlertDialogHeader,
33+
AlertDialogTitle,
34+
AlertDialogTrigger,
35+
} from "@/components/ui/alert-dialog";
36+
import { updateFeaturedOrder } from "@/app/actions/update-featured-order";
37+
import { removeFeatured } from "@/app/actions/remove-featured";
38+
39+
// Hook to detect if we're on the client (prevents hydration mismatch from dnd-kit)
40+
const emptySubscribe = () => () => {};
41+
function useIsClient() {
42+
return useSyncExternalStore(
43+
emptySubscribe,
44+
() => true,
45+
() => false,
46+
);
47+
}
48+
49+
export type FeaturedTranslation = {
50+
id: string;
51+
name: string;
52+
featured: number;
53+
jargonName: string;
54+
jargonSlug: string;
55+
};
56+
57+
function SortableItem({
58+
item,
59+
onRemove,
60+
isRemoving,
61+
}: {
62+
item: FeaturedTranslation;
63+
onRemove: (id: string) => void;
64+
isRemoving: boolean;
65+
}) {
66+
const {
67+
attributes,
68+
listeners,
69+
setNodeRef,
70+
transform,
71+
transition,
72+
isDragging,
73+
} = useSortable({ id: item.id });
74+
75+
const style = {
76+
transform: CSS.Transform.toString(transform),
77+
transition,
78+
};
79+
80+
return (
81+
<Card
82+
ref={setNodeRef}
83+
style={style}
84+
className={`flex flex-row items-center gap-3 p-4 ${isDragging ? "opacity-50 shadow-lg" : ""}`}
85+
>
86+
<button
87+
className="text-muted-foreground hover:text-foreground cursor-grab touch-none active:cursor-grabbing"
88+
{...attributes}
89+
{...listeners}
90+
>
91+
<GripVertical className="size-5" />
92+
</button>
93+
<span className="bg-muted text-muted-foreground shrink-0 rounded px-2 py-0.5 text-xs font-medium">
94+
#{item.featured}
95+
</span>
96+
<div className="flex min-w-0 flex-1 items-center gap-2">
97+
<span className="truncate font-medium">{item.name}</span>
98+
<span className="text-muted-foreground"></span>
99+
<Link
100+
href={`/jargon/${item.jargonSlug}`}
101+
className="text-muted-foreground hover:text-foreground flex shrink-0 items-center gap-1 text-sm"
102+
>
103+
{item.jargonName}
104+
<ExternalLink className="size-3" />
105+
</Link>
106+
</div>
107+
<AlertDialog>
108+
<AlertDialogTrigger asChild>
109+
<Button
110+
variant="ghost"
111+
size="icon"
112+
className="text-muted-foreground hover:text-destructive shrink-0"
113+
disabled={isRemoving}
114+
>
115+
<Trash2 className="size-4" />
116+
</Button>
117+
</AlertDialogTrigger>
118+
<AlertDialogContent>
119+
<AlertDialogHeader>
120+
<AlertDialogTitle>하이라이트 삭제</AlertDialogTitle>
121+
<AlertDialogDescription>
122+
&quot;{item.name}&quot;을(를) 하이라이트에서 삭제하시겠어요?
123+
번역어 자체는 삭제되지 않습니다.
124+
</AlertDialogDescription>
125+
</AlertDialogHeader>
126+
<AlertDialogFooter>
127+
<AlertDialogCancel>취소</AlertDialogCancel>
128+
<AlertDialogAction
129+
onClick={() => onRemove(item.id)}
130+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
131+
>
132+
삭제
133+
</AlertDialogAction>
134+
</AlertDialogFooter>
135+
</AlertDialogContent>
136+
</AlertDialog>
137+
</Card>
138+
);
139+
}
140+
141+
export default function HighlightsManager({
142+
initialData,
143+
}: {
144+
initialData: FeaturedTranslation[];
145+
}) {
146+
const [items, setItems] = useState(initialData);
147+
const [isPending, startTransition] = useTransition();
148+
const isClient = useIsClient();
149+
150+
const sensors = useSensors(
151+
useSensor(PointerSensor),
152+
useSensor(KeyboardSensor, {
153+
coordinateGetter: sortableKeyboardCoordinates,
154+
}),
155+
);
156+
157+
function handleDragEnd(event: DragEndEvent) {
158+
const { active, over } = event;
159+
160+
if (over && active.id !== over.id) {
161+
const oldIndex = items.findIndex((item) => item.id === active.id);
162+
const newIndex = items.findIndex((item) => item.id === over.id);
163+
const newItems = arrayMove(items, oldIndex, newIndex);
164+
165+
// Update featured ranks
166+
const updatedItems = newItems.map((item, index) => ({
167+
...item,
168+
featured: index + 1,
169+
}));
170+
171+
// Optimistic update
172+
setItems(updatedItems);
173+
174+
// Persist to server
175+
startTransition(async () => {
176+
const result = await updateFeaturedOrder(
177+
updatedItems.map((item) => item.id),
178+
);
179+
if (!result.ok) {
180+
// Revert on error
181+
setItems(items);
182+
}
183+
});
184+
}
185+
}
186+
187+
function handleRemove(id: string) {
188+
startTransition(async () => {
189+
const result = await removeFeatured(id);
190+
if (result.ok) {
191+
setItems((prev) => {
192+
const filtered = prev.filter((item) => item.id !== id);
193+
// Re-number the remaining items
194+
return filtered.map((item, index) => ({
195+
...item,
196+
featured: index + 1,
197+
}));
198+
});
199+
// Update the server with new order
200+
const remaining = items
201+
.filter((item) => item.id !== id)
202+
.map((item) => item.id);
203+
if (remaining.length > 0) {
204+
await updateFeaturedOrder(remaining);
205+
}
206+
}
207+
});
208+
}
209+
210+
if (items.length === 0) {
211+
return (
212+
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
213+
하이라이트로 지정된 번역어가 없습니다.
214+
</div>
215+
);
216+
}
217+
218+
// Render static list on server, interactive list on client
219+
if (!isClient) {
220+
return (
221+
<div className="flex flex-col gap-2">
222+
{items.map((item) => (
223+
<Card key={item.id} className="flex flex-row items-center gap-3 p-4">
224+
<GripVertical className="text-muted-foreground size-5" />
225+
<span className="bg-muted text-muted-foreground shrink-0 rounded px-2 py-0.5 text-xs font-medium">
226+
#{item.featured}
227+
</span>
228+
<div className="flex min-w-0 flex-1 items-center gap-2">
229+
<span className="truncate font-medium">{item.name}</span>
230+
<span className="text-muted-foreground"></span>
231+
<span className="text-muted-foreground text-sm">
232+
{item.jargonName}
233+
</span>
234+
</div>
235+
</Card>
236+
))}
237+
</div>
238+
);
239+
}
240+
241+
return (
242+
<DndContext
243+
sensors={sensors}
244+
collisionDetection={closestCenter}
245+
onDragEnd={handleDragEnd}
246+
>
247+
<SortableContext items={items} strategy={verticalListSortingStrategy}>
248+
<div className="flex flex-col gap-2">
249+
{items.map((item) => (
250+
<SortableItem
251+
key={item.id}
252+
item={item}
253+
onRemove={handleRemove}
254+
isRemoving={isPending}
255+
/>
256+
))}
257+
</div>
258+
</SortableContext>
259+
{isPending && (
260+
<div className="text-muted-foreground mt-4 text-center text-sm">
261+
저장 중...
262+
</div>
263+
)}
264+
</DndContext>
265+
);
266+
}

components/comment/comment-item.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
Minus,
1111
PencilIcon,
1212
Plus,
13-
TrashIcon,
1413
} from "lucide-react";
1514
import Form from "next/form";
1615
import { useQueryClient } from "@tanstack/react-query";

components/comment/comment-thread.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useMemo } from "react";
44
import { useQuery } from "@tanstack/react-query";
5+
import { MessagesSquareIcon } from "lucide-react";
56
import { getClient } from "@/lib/supabase/client";
67
import { QUERIES } from "@/lib/supabase/repository";
78
import { Comment, CommentTree } from "@/types/comment";
@@ -11,7 +12,6 @@ import { useUserQuery } from "@/hooks/use-user-query";
1112
import { Button } from "@/components/ui/button";
1213
import { Skeleton } from "@/components/ui/skeleton";
1314
import { useLoginDialog } from "@/components/auth/login-dialog-provider";
14-
import { MessagesSquareIcon } from "lucide-react";
1515

1616
function buildCommentTree(comments: Omit<Comment, "replies">[]): CommentTree[] {
1717
const commentMap = new Map<string, CommentTree>();

0 commit comments

Comments
 (0)