1"use client";
2
3
24import { useEffect, useState } from "react";
25import { ChevronRight, FolderClosed, FolderOpen, Home, Inbox } from "lucide-react";
26import { Dialog, DialogFooter } from "./dialog";
27import { isApiError } from "@/lib/api";
28import { usePageTree, useUpdatePage } from "@/hooks/use-pages";
29import { useUiStore } from "@/stores/ui-store";
30import { cn } from "@/lib/utils";
31import type { PageTreeEntry } from "@/lib/types";
32
33export function MoveToDialog() {
34 const open = useUiStore((s) => s.moveOpen);
35 const context = useUiStore((s) => s.moveContext);
36 const setMove = useUiStore((s) => s.setMove);
37 const pushToast = useUiStore((s) => s.pushToast);
38
39 const pageId = context?.pageId ?? null;
40 const workspaceId = context?.workspaceId ?? "";
41
42
43 const [selected, setSelected] = useState<string | null | undefined>(undefined);
44 const [busy, setBusy] = useState(false);
45
46 const updatePage = useUpdatePage(workspaceId);
47
48 const rootQuery = usePageTree(workspaceId, null);
49
50
51 useEffect(() => {
52 if (open) setSelected(undefined);
53 }, [open, pageId]);
54
55 if (!open || !context || !pageId) return null;
56
57 const title = context.title || "This page";
58
59 const onAction = () => {
60 if (selected === undefined) return;
61 setBusy(true);
62 updatePage.mutate(
63 { id: pageId, parentId: selected ?? null },
64 {
65 onSuccess: () => {
66 setBusy(false);
67 setMove(false, null);
68 pushToast({
69 kind: "success",
70 title: "Moved",
71 description:
72 selected === null ? `“${title}” is now at the top level` : `“${title}” moved into its new folder`
73 });
74 },
75 onError: (e: Error) => {
76 setBusy(false);
77 const msg = isApiError(e) && e.message && e.message.length < 140 ? e.message : e.message || "Move failed";
78 pushToast({ kind: "error", title: "Move failed", description: msg });
79 }
80 }
81 );
82 };
83
84 const entries = (rootQuery.data ?? []).filter((e) => e.id !== pageId);
85
86 return (
87 <Dialog
88 open={open}
89 title={`Move “${title}” to…`}
90 description="Pick a page to use as the new folder, or move back to the top level."
91 onClose={() => !busy && setMove(false, null)}
92 testId="move-dialog"
93 footer={
94 <DialogFooter
95 onCancel={() => setMove(false, null)}
96 onAction={onAction}
97 actionLabel="Move here"
98 busy={busy}
99 disabled={selected === undefined}
100 testId="move-dialog-confirm"
101 />
102 }
103 >
104 <div className="space-y-3">
105 <RootOption selected={selected === null} onPick={() => setSelected(null)} />
106 <div className="flex items-center gap-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
107 <span className="h-px flex-1 bg-border" aria-hidden />
108 or into a page
109 <span className="h-px flex-1 bg-border" aria-hidden />
110 </div>
111 {rootQuery.isLoading ? (
112 <p className="px-2 py-1 text-sm text-muted-foreground">Loading pages…</p>
113 ) : (
114 <MoveTreePicker
115 entries={entries}
116 workspaceId={workspaceId}
117 selected={selected}
118 onSelect={setSelected}
119 />
120 )}
121 </div>
122 </Dialog>
123 );
124}
125
126
127function RootOption({ selected, onPick }: { selected: boolean; onPick: () => void }) {
128 return (
129 <button
130 type="button"
131 onClick={onPick}
132 className={cn(
133 "flex w-full items-center gap-2 rounded-md border px-3 py-2 text-sm",
134 selected ? "border-primary bg-accent font-medium" : "border-border hover:bg-accent"
135 )}
136 >
137 <Home className="size-4 text-muted-foreground" aria-hidden />
138 Root (top level)
139 {selected ? <PickMark /> : null}
140 </button>
141 );
142}
143
144
145function MoveTreePicker({
146 entries,
147 workspaceId,
148 selected,
149 onSelect,
150 depth = 0
151}: {
152 entries: PageTreeEntry[];
153 workspaceId: string;
154 selected: string | null | undefined;
155 onSelect: (id: string | null) => void;
156 depth?: number;
157}) {
158 if (entries.length === 0) {
159 return <p className="px-2 py-1 text-sm text-muted-foreground">No pages here yet.</p>;
160 }
161 return (
162 <ul className="space-y-px">
163 {entries.map((entry) => (
164 <MovePickerNode
165 key={entry.id}
166 entry={entry}
167 workspaceId={workspaceId}
168 selected={selected}
169 onSelect={onSelect}
170 depth={depth}
171 />
172 ))}
173 </ul>
174 );
175}
176
177function MovePickerNode({
178 entry,
179 workspaceId,
180 selected,
181 onSelect,
182 depth
183}: {
184 entry: PageTreeEntry;
185 workspaceId: string;
186 selected: string | null | undefined;
187 onSelect: (id: string | null) => void;
188 depth: number;
189}) {
190 const [expanded, setExpanded] = useState(false);
191 const childrenQuery = usePageTree(workspaceId, entry.id);
192 const children = entry.children ?? childrenQuery.data ?? [];
193 const hasChildren = Boolean(entry.hasMore || (entry.children?.length ?? 0) > 0);
194 const isSelected = selected === entry.id;
195
196 return (
197 <li>
198 <div
199 className={cn(
200 "flex items-center gap-1 rounded-md py-1 pr-2 text-sm hover:bg-accent",
201 isSelected && "bg-accent"
202 )}
203 style={{ paddingLeft: `${depth * 14 + 4}px` }}
204 >
205 <button
206 type="button"
207 onClick={() => setExpanded((v) => !v)}
208 className={cn(
209 "flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground",
210 !hasChildren && "invisible"
211 )}
212 aria-label={expanded ? "Collapse" : "Expand"}
213 aria-expanded={expanded}
214 >
215 <ChevronRight className={cn("size-3.5 transition-transform", expanded && "rotate-90")} aria-hidden />
216 </button>
217 {hasChildren ? (
218 expanded ? (
219 <FolderOpen className="size-4 shrink-0 text-amber-500/80" aria-hidden />
220 ) : (
221 <FolderClosed className="size-4 shrink-0 text-amber-500/80" aria-hidden />
222 )
223 ) : (
224 <Inbox className="size-4 shrink-0 text-muted-foreground" aria-hidden />
225 )}
226 <button
227 type="button"
228 onClick={() => onSelect(entry.id)}
229 className={cn(
230 "min-w-0 flex-1 truncate text-left text-foreground/90 hover:text-foreground",
231 isSelected && "font-medium"
232 )}
233 title={entry.title || "Untitled"}
234 >
235 {entry.title || "Untitled"}
236 </button>
237 {isSelected ? <PickMark /> : null}
238 </div>
239 {expanded ? (
240 children.length > 0 || childrenQuery.isLoading ? (
241 <MoveTreePicker
242 entries={children}
243 workspaceId={workspaceId}
244 selected={selected}
245 onSelect={onSelect}
246 depth={depth + 1}
247 />
248 ) : (
249 <p className="py-1 text-xs text-muted-foreground" style={{ paddingLeft: `${(depth + 1) * 14 + 20}px` }}>
250 {childrenQuery.isLoading ? "Loading…" : "Empty"}
251 </p>
252 )
253 ) : null}
254 </li>
255 );
256}
257
258function PickMark() {
259 return (
260 <span className="text-primary" aria-hidden>
261 ✓
262 </span>
263 );
264}
265