Skip to content

Commit 5379b40

Browse files
committed
✨ Jargon categories are now editable!
1 parent 04732b4 commit 5379b40

File tree

7 files changed

+318
-11
lines changed

7 files changed

+318
-11
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use server";
2+
3+
import { MUTATIONS } from "@/lib/supabase/repository";
4+
import { createClient } from "@/lib/supabase/server";
5+
6+
export type UpdateJargonCategoriesState =
7+
| { ok: false; error: string }
8+
| { ok: true; error: null };
9+
10+
export type UpdateJargonCategoriesAction = (
11+
prevState: UpdateJargonCategoriesState,
12+
formData: FormData,
13+
) => Promise<UpdateJargonCategoriesState>;
14+
15+
export async function updateJargonCategories(
16+
jargonId: string,
17+
_prevState: UpdateJargonCategoriesState,
18+
formData: FormData,
19+
): Promise<UpdateJargonCategoriesState> {
20+
const categoryIds = (formData.getAll("categoryIds") as string[])
21+
.map((id) => Number(id))
22+
.filter((n) => !Number.isNaN(n));
23+
24+
const supabase = await createClient();
25+
const { error } = await MUTATIONS.updateJargonCategories(
26+
supabase,
27+
jargonId,
28+
categoryIds,
29+
);
30+
31+
if (error) {
32+
switch (error.code) {
33+
case "28000":
34+
return { ok: false, error: "로그인이 필요해요" };
35+
case "22023":
36+
return { ok: false, error: "잘못된 요청이에요" };
37+
case "NO_JARGON":
38+
return { ok: false, error: "용어를 찾을 수 없어요" };
39+
case "23503":
40+
return { ok: false, error: "존재하지 않는 분야가 있어요" };
41+
default:
42+
return { ok: false, error: "분야를 업데이트하는 중 문제가 생겼어요" };
43+
}
44+
}
45+
46+
return { ok: true, error: null };
47+
}

app/jargon/[slug]/page.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ShareButton from "@/components/share-button";
77
import { Comment } from "@/types/comment";
88
import JargonActions from "@/components/jargon/jargon-actions";
99
import TranslationActions from "@/components/jargon/translation-actions";
10+
import UpdateJargonCategoriesDialog from "@/components/jargon/update-jargon-categories-dialog";
1011

1112
export default async function JargonDetailPage({
1213
params,
@@ -39,8 +40,8 @@ export default async function JargonDetailPage({
3940
<div className="bg-card rounded-lg p-3">
4041
<div className="flex flex-col gap-2">
4142
{/* categories */}
42-
{jargon.categories.length > 0 ? (
43-
<div className="flex flex-wrap items-center justify-between gap-2">
43+
<div className="flex flex-wrap items-center justify-between">
44+
<div className="flex flex-wrap items-center gap-2">
4445
<div className="flex flex-wrap gap-2">
4546
{jargon.categories.map((cat) => (
4647
<span
@@ -51,19 +52,21 @@ export default async function JargonDetailPage({
5152
</span>
5253
))}
5354
</div>
54-
</div>
55-
) : null}
56-
<div className="flex items-center justify-between gap-2">
57-
<div className="flex items-center gap-2">
58-
<h1 className="text-2xl font-bold">{jargon.name}</h1>
59-
<JargonActions
55+
<UpdateJargonCategoriesDialog
6056
jargonId={jargon.id}
61-
authorId={jargon.author_id}
62-
name={jargon.name}
57+
initialCategoryIds={jargon.categories.map((c) => c.category.id)}
6358
/>
6459
</div>
6560
<ShareButton label="공유" />
6661
</div>
62+
<div className="flex items-center gap-2">
63+
<h1 className="text-2xl font-bold">{jargon.name}</h1>
64+
<JargonActions
65+
jargonId={jargon.id}
66+
authorId={jargon.author_id}
67+
name={jargon.name}
68+
/>
69+
</div>
6770
{jargon.translations.length > 0 ? (
6871
<div className="text-foreground text-base">
6972
<ul className="list-disc pl-5">
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use client";
2+
3+
import {
4+
useActionState,
5+
useCallback,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from "react";
11+
import { useQuery } from "@tanstack/react-query";
12+
import Form from "next/form";
13+
import { useFormStatus } from "react-dom";
14+
import { Pencil } from "lucide-react";
15+
import { Button } from "@/components/ui/button";
16+
import { Label } from "@/components/ui/label";
17+
import { MultiSelect } from "@/components/ui/multi-select";
18+
import {
19+
Dialog,
20+
DialogContent,
21+
DialogDescription,
22+
DialogFooter,
23+
DialogHeader,
24+
DialogTitle,
25+
DialogTrigger,
26+
} from "@/components/ui/dialog";
27+
import { getClient } from "@/lib/supabase/client";
28+
import { useLoginDialog } from "@/components/auth/login-dialog-provider";
29+
import { QUERIES } from "@/lib/supabase/repository";
30+
import {
31+
updateJargonCategories,
32+
type UpdateJargonCategoriesState,
33+
} from "@/app/actions/update-jargon-categories";
34+
35+
function Submit({ label }: { label: string }) {
36+
const { pending } = useFormStatus();
37+
return (
38+
<Button type="submit" disabled={pending}>
39+
{pending ? "저장 중..." : label}
40+
</Button>
41+
);
42+
}
43+
44+
export default function UpdateJargonCategoriesDialog({
45+
jargonId,
46+
initialCategoryIds,
47+
}: {
48+
jargonId: string;
49+
initialCategoryIds: number[];
50+
}) {
51+
const supabase = getClient();
52+
const { openLogin } = useLoginDialog();
53+
54+
const [open, setOpen] = useState(false);
55+
const formRef = useRef<HTMLFormElement>(null);
56+
const [selected, setSelected] = useState<string[]>(
57+
initialCategoryIds.map((id) => String(id)),
58+
);
59+
60+
const { data: categories, isLoading } = useQuery({
61+
queryKey: ["categories"],
62+
enabled: open,
63+
queryFn: async ({ signal }) => {
64+
const { data, error } = await QUERIES.listCategories(supabase, {
65+
signal,
66+
});
67+
if (error) throw error;
68+
return data;
69+
},
70+
});
71+
72+
const options = useMemo(
73+
() =>
74+
(categories ?? []).map((c) => ({
75+
value: String(c.id),
76+
label: `${c.acronym} (${c.name})`,
77+
shortLabel: c.acronym,
78+
})),
79+
[categories],
80+
);
81+
82+
const resetForm = useCallback(() => {
83+
setSelected(initialCategoryIds.map((id) => String(id)));
84+
formRef.current?.reset();
85+
}, [initialCategoryIds]);
86+
87+
const [state, action] = useActionState(
88+
updateJargonCategories.bind(null, jargonId),
89+
{
90+
ok: false,
91+
error: "",
92+
} as UpdateJargonCategoriesState,
93+
);
94+
95+
const handleOpenChange = useCallback(
96+
async (nextOpen: boolean) => {
97+
if (nextOpen) {
98+
const {
99+
data: { user },
100+
} = await supabase.auth.getUser();
101+
if (!user) {
102+
openLogin();
103+
return;
104+
}
105+
// Ensure defaults reflect current props each time dialog opens
106+
setSelected(initialCategoryIds.map((id) => String(id)));
107+
}
108+
setOpen(nextOpen);
109+
},
110+
[openLogin, supabase, initialCategoryIds],
111+
);
112+
113+
useEffect(() => {
114+
if (state && state.ok) {
115+
setOpen(false);
116+
resetForm();
117+
// Refresh the page to reflect updated categories on SSR
118+
window.location.reload();
119+
}
120+
}, [state, resetForm]);
121+
122+
return (
123+
<Dialog open={open} onOpenChange={handleOpenChange}>
124+
<DialogTrigger asChild>
125+
<button className="bg-background text-foreground border-accent flex rounded-full border px-1.5 py-1.5 font-mono text-sm hover:cursor-pointer">
126+
<Pencil className="size-3" />
127+
</button>
128+
</DialogTrigger>
129+
<DialogContent className="sm:max-w-[520px]">
130+
<DialogHeader>
131+
<DialogTitle>분야 고치기</DialogTitle>
132+
<DialogDescription>
133+
이 용어에 해당하는 분야를 선택해 주세요
134+
</DialogDescription>
135+
</DialogHeader>
136+
<Form ref={formRef} action={action} className="flex flex-col gap-3">
137+
<div className="flex flex-col gap-1">
138+
<Label className="text-sm font-medium">분야</Label>
139+
<MultiSelect
140+
variant="outline"
141+
options={options}
142+
defaultValue={selected}
143+
onValueChange={setSelected}
144+
closeOnSelect={true}
145+
hideSelectAll={true}
146+
placeholder={isLoading ? "불러오는 중..." : "분야 선택"}
147+
disabled={isLoading}
148+
popoverClassName="w-full"
149+
/>
150+
{selected.map((cid) => (
151+
<input key={cid} type="hidden" name="categoryIds" value={cid} />
152+
))}
153+
</div>
154+
155+
{state && !state.ok ? (
156+
<p className="text-sm text-red-600">{state.error}</p>
157+
) : null}
158+
159+
<DialogFooter>
160+
<Button
161+
type="button"
162+
variant="outline"
163+
onClick={() => {
164+
setOpen(false);
165+
resetForm();
166+
}}
167+
>
168+
닫기
169+
</Button>
170+
<Submit label="저장" />
171+
</DialogFooter>
172+
</Form>
173+
</DialogContent>
174+
</Dialog>
175+
);
176+
}

lib/supabase/repository.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const QUERIES = {
3535
return supabase
3636
.from("jargon")
3737
.select(
38-
"id, name, slug, created_at, author_id, translations:translation(id, name, author_id), categories:jargon_category(category:category(acronym))",
38+
"id, name, slug, created_at, author_id, translations:translation(id, name, author_id), categories:jargon_category(category:category(id, name, acronym))",
3939
)
4040
.eq("slug", slug)
4141
.limit(1)
@@ -247,4 +247,15 @@ export const MUTATIONS = {
247247
p_translation_id: translationId,
248248
});
249249
},
250+
251+
updateJargonCategories: function (
252+
supabase: SupabaseClient<Database>,
253+
jargonId: string,
254+
categoryIds: number[],
255+
) {
256+
return supabase.rpc("update_jargon_categories", {
257+
p_jargon_id: jargonId,
258+
p_category_ids: categoryIds,
259+
});
260+
},
250261
};

lib/supabase/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,10 @@ export type Database = {
569569
jargon_slug: string
570570
}[]
571571
}
572+
update_jargon_categories: {
573+
Args: { p_category_ids: number[]; p_jargon_id: string }
574+
Returns: boolean
575+
}
572576
update_translation: {
573577
Args: { p_name: string; p_translation_id: string }
574578
Returns: boolean
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
CREATE OR REPLACE FUNCTION public.update_jargon_categories(
2+
p_jargon_id uuid,
3+
p_category_ids int[]
4+
) RETURNS boolean
5+
LANGUAGE plpgsql SECURITY DEFINER
6+
SET search_path TO 'public'
7+
AS $$
8+
declare
9+
v_actor_id uuid := auth.uid();
10+
v_exists boolean;
11+
begin
12+
-- Auth check (any authenticated user can update)
13+
if v_actor_id is null then
14+
raise exception 'Not authenticated' using errcode = '28000';
15+
end if;
16+
17+
-- Input validation
18+
if p_jargon_id is null then
19+
raise exception 'Jargon ID is required' using errcode = '22023';
20+
end if;
21+
22+
-- Ensure jargon exists for clearer error reporting
23+
select true into v_exists from public.jargon where id = p_jargon_id;
24+
if not found then
25+
raise exception 'Jargon not found' using errcode = 'NO_JARGON';
26+
end if;
27+
28+
-- Replace category mappings
29+
delete from public.jargon_category where jargon_id = p_jargon_id;
30+
31+
if p_category_ids is not null and array_length(p_category_ids, 1) > 0 then
32+
insert into public.jargon_category (jargon_id, category_id)
33+
select p_jargon_id, unnest(p_category_ids);
34+
end if;
35+
36+
return true;
37+
exception
38+
when foreign_key_violation then
39+
-- invalid category id
40+
raise exception using errcode = '23503', message = 'Invalid category id';
41+
end;
42+
$$;
43+
44+
GRANT ALL ON FUNCTION public.update_jargon_categories(p_jargon_id uuid, p_category_ids int[]) TO anon;
45+
GRANT ALL ON FUNCTION public.update_jargon_categories(p_jargon_id uuid, p_category_ids int[]) TO authenticated;
46+
GRANT ALL ON FUNCTION public.update_jargon_categories(p_jargon_id uuid, p_category_ids int[]) TO service_role;
47+
48+
NOTIFY pgrst, 'reload schema';

supabase/seed.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,30 @@ INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encryp
33
('00000000-0000-0000-0000-000000000000', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', 'authenticated', 'authenticated', '[email protected]', NULL, '2025-08-28 04:27:30.258836+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-08-28 07:58:16.594996+00', '{"provider": "github", "userrole": "admin", "providers": ["github"]}', '{"iss": "https://api.github.com", "sub": "9553691", "name": "Jay Lee", "email": "[email protected]", "full_name": "Jay Lee the Tester", "user_name": "Zeta611", "avatar_url": "https://avatars.githubusercontent.com/u/9553691?v=4", "provider_id": "9553691", "email_verified": true, "phone_verified": false, "preferred_username": "Zeta611"}', NULL, '2025-08-28 04:27:30.253169+00', '2025-08-28 07:58:16.597091+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
44
('00000000-0000-0000-0000-000000000000', '98e607fc-ef95-4272-9b66-62526b576c84', 'authenticated', 'authenticated', '[email protected]', NULL, '2025-08-28 04:27:30.258836+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-08-28 04:29:30.162166+00', '{"provider": "github", "providers": ["github"]}', '{"iss": "https://api.github.com", "sub": "1000000", "name": "User A", "email": "[email protected]", "full_name": "User A", "user_name": "usera", "avatar_url": "", "provider_id": "1000000", "email_verified": true, "phone_verified": false, "preferred_username": "usera"}', NULL, '2025-08-28 04:27:30.253169+00', '2025-08-28 04:29:30.164132+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false);
55

6+
-- category
7+
INSERT INTO "public"."category" ("id", "name", "acronym") VALUES
8+
(1, 'programming languages', 'PL'),
9+
(2, 'artificial intelligence', 'AI'),
10+
(3, 'algorithms', 'AL'),
11+
(4, 'software engineering', 'SE'),
12+
(5, 'databases', 'DB'),
13+
(6, 'operating systems', 'OS'),
14+
(7, 'network', 'NW'),
15+
(8, 'graphics & HCI', 'GH'),
16+
(9, 'architecture', 'AR'),
17+
(10, 'security', 'SC');
18+
619
-- jargon
720
INSERT INTO "public"."jargon" ("name", "author_id", "created_at", "updated_at", "id", "slug") VALUES
821
('user A test', '98e607fc-ef95-4272-9b66-62526b576c84', '2025-08-28 07:17:26.730353+00', '2025-08-28 07:17:26.730353+00', '6af4329d-49e6-4110-8938-f43f3cf30194', 'user-a-test'),
922
('test', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:26.730353+00', '2025-08-28 07:17:26.730353+00', 'd4ed1ffb-896f-47c5-a94e-0fc7cd7ec1f6', 'test'),
1023
('test 2', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:41.53785+00', '2025-08-28 07:17:41.53785+00', 'fed56078-e4f7-41cf-b608-56fa173a82e6', 'test-2');
1124

25+
-- jargon_category: set categories for "test" (PL, DB)
26+
INSERT INTO "public"."jargon_category" ("jargon_id", "category_id") VALUES
27+
('d4ed1ffb-896f-47c5-a94e-0fc7cd7ec1f6', 1),
28+
('d4ed1ffb-896f-47c5-a94e-0fc7cd7ec1f6', 5);
29+
1230
-- translation
1331
INSERT INTO "public"."translation" ("name", "author_id", "created_at", "updated_at", "id", "jargon_id", "comment_id") VALUES
1432
('테스트 2', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:19:24.414112+00', '2025-08-28 07:19:24.414112+00', '7b1c9053-d772-4e13-bf43-1940ec66534e', 'fed56078-e4f7-41cf-b608-56fa173a82e6', '02c0cd00-3ccc-444b-bdfb-c7c25b444f7e');

0 commit comments

Comments
 (0)