1"use client";
2
3
16import { useMemo, useState } from "react";
17import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
18import { Check, Copy, Link2, Loader2, Lock, Mail } from "lucide-react";
19import { apiFetch, pageApi } from "@/lib/api";
20import { toast } from "@/stores/ui-store";
21import { User, WorkspaceRole } from "@/lib/types";
22import { Dialog } from "@/components/dialogs/dialog";
23
24const LEVELS: { value: "view" | "edit" | "public"; label: string; description: string }[] = [
25 { value: "view", label: "Can view", description: "Read-only access to this page." },
26 { value: "edit", label: "Can edit", description: "Can edit this page and its sub-pages." },
27 { value: "public", label: "Anyone with the link", description: "No sign-in required." }
28];
29
30const ROLES: { value: WorkspaceRole; label: string }[] = [
31 { value: "member", label: "Member" },
32 { value: "guest", label: "Guest (read-only)" }
33];
34
35export interface ShareDialogProps {
36 open: boolean;
37 pageId: string;
38 pageTitle?: string;
39
40 members?: User[];
41 onClose: () => void;
42}
43
44export function ShareDialog({ open, pageId, pageTitle, members, onClose }: ShareDialogProps) {
45 const qc = useQueryClient();
46 const [level, setLevel] = useState<"view" | "edit" | "public">("view");
47 const [password, setPassword] = useState("");
48 const [copied, setCopied] = useState(false);
49 const [email, setEmail] = useState("");
50 const [role, setRole] = useState<WorkspaceRole>("member");
51
52 const share = useQuery({
53 queryKey: ["pages", pageId, "share"],
54 queryFn: async () => {
55 try {
56 return await pageApi.share(pageId, { level: "view" });
57 } catch {
58 return null;
59 }
60 },
61 enabled: open && !!pageId,
62 retry: false
63 });
64
65 const saveShare = useMutation({
66 mutationFn: (input: { level: "view" | "edit" | "public"; password?: string | null }) =>
67 pageApi.share(pageId, input),
68 onSuccess: (data) => {
69 qc.setQueryData(["pages", pageId, "share"], data);
70 setLevel(data.level);
71 toast("success", "Share settings saved");
72 },
73 onError: (e) => toast("error", "Save failed", (e as Error).message)
74 });
75
76 const invite = useMutation({
77 mutationFn: (input: { email: string; role: WorkspaceRole }) =>
78 apiFetch<{ id: string }>(`/pages/${pageId}/invites`, { method: "POST", body: input }),
79 onSuccess: () => {
80 toast("success", "Invitation sent", email);
81 setEmail("");
82 },
83 onError: (e) => toast("error", "Invite failed", (e as Error).message)
84 });
85
86 const shareUrl = useMemo(() => {
87 if (share.data?.url) return share.data.url;
88 if (typeof window === "undefined" || !pageId) return "";
89 return `${window.location.origin}/share/${pageId}`;
90 }, [share.data, pageId]);
91
92 const copyLink = async () => {
93 try {
94 await navigator.clipboard.writeText(shareUrl);
95 setCopied(true);
96 setTimeout(() => setCopied(false), 2000);
97 } catch {
98 toast("error", "Copy failed", "Select the link text and copy manually.");
99 }
100 };
101
102 const levelChanged = share.data ? share.data.level !== level : true;
103 const passwordChanged = share.data ? (share.data.password ?? "") !== password : !!password;
104
105 return (
106 <Dialog open={open} title="Share" description={pageTitle ? `Share “${pageTitle}”` : "Share this page"} onClose={onClose} width="max-w-md">
107 <div className="space-y-5">
108 {}
109 <div>
110 <label className="mb-1.5 block text-xs font-medium text-muted-foreground">Share link</label>
111 <div className="flex items-center gap-2">
112 <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-input bg-muted/30 px-2.5 py-1.5 text-sm">
113 <Link2 className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
114 <span className="truncate">{shareUrl || "No link yet"}</span>
115 </div>
116 <button
117 type="button"
118 className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-sm hover:bg-muted"
119 onClick={copyLink}
120 disabled={!shareUrl}
121 >
122 {copied ? <Check className="size-3.5 text-emerald-600" /> : <Copy className="size-3.5" />}
123 {copied ? "Copied" : "Copy"}
124 </button>
125 </div>
126 </div>
127
128 {}
129 <div>
130 <label className="mb-1.5 block text-xs font-medium text-muted-foreground">Who can access</label>
131 <div className="space-y-1.5">
132 {LEVELS.map((l) => (
133 <label
134 key={l.value}
135 className={
136 "flex cursor-pointer items-start gap-2.5 rounded-md border p-2.5 " +
137 (level === l.value ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50")
138 }
139 >
140 <input
141 type="radio"
142 name="share-level"
143 className="mt-0.5 size-3.5 accent-[oklch(var(--primary))]"
144 checked={level === l.value}
145 onChange={() => setLevel(l.value)}
146 />
147 <span>
148 <span className="block text-sm font-medium">{l.label}</span>
149 <span className="block text-xs text-muted-foreground">{l.description}</span>
150 </span>
151 </label>
152 ))}
153 </div>
154 </div>
155
156 {}
157 <div>
158 <label className="mb-1.5 flex items-center gap-1 text-xs font-medium text-muted-foreground">
159 <Lock className="size-3" aria-hidden /> Password (optional)
160 </label>
161 <input
162 className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
163 placeholder="No password"
164 value={password}
165 onChange={(e) => setPassword(e.target.value)}
166 />
167 </div>
168
169 <button
170 type="button"
171 className="w-full rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
172 disabled={saveShare.isPending || (!levelChanged && !passwordChanged)}
173 onClick={() =>
174 saveShare.mutate({ level, password: password || null })
175 }
176 >
177 {saveShare.isPending ? (
178 <span className="inline-flex items-center gap-1.5">
179 <Loader2 className="size-3.5 animate-spin" /> Saving…
180 </span>
181 ) : (
182 "Save share settings"
183 )}
184 </button>
185
186 {}
187 <div className="border-t border-border pt-4">
188 <label className="mb-1.5 flex items-center gap-1 text-xs font-medium text-muted-foreground">
189 <Mail className="size-3" aria-hidden /> Invite by email
190 </label>
191 <div className="flex items-center gap-1.5">
192 <input
193 type="email"
194 className="min-w-0 flex-1 rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
195 placeholder="name@example.com"
196 value={email}
197 onChange={(e) => setEmail(e.target.value)}
198 onKeyDown={(e) => {
199 if (e.key === "Enter" && email.trim()) invite.mutate({ email: email.trim(), role });
200 }}
201 />
202 <select
203 className="rounded-md border border-input bg-background px-1.5 py-1.5 text-sm outline-none"
204 value={role}
205 onChange={(e) => setRole(e.target.value as WorkspaceRole)}
206 aria-label="Invite role"
207 >
208 {ROLES.map((r) => (
209 <option key={r.value} value={r.value}>
210 {r.label}
211 </option>
212 ))}
213 </select>
214 <button
215 type="button"
216 className="rounded-md border px-2.5 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
217 disabled={!email.trim() || invite.isPending}
218 onClick={() => invite.mutate({ email: email.trim(), role })}
219 >
220 {invite.isPending ? <Loader2 className="size-3.5 animate-spin" /> : "Invite"}
221 </button>
222 </div>
223 {members && members.length > 0 && (
224 <p className="mt-1.5 text-[11px] text-muted-foreground">
225 Already a member: {members.slice(0, 4).map((m) => m.name).join(", ")}
226 {members.length > 4 ? ` +${members.length - 4} more` : ""}
227 </p>
228 )}
229 </div>
230 </div>
231 </Dialog>
232 );
233}
234