The Appview for the kipclip.com atproto bookmarking service
1import { useEffect, useState } from "react";
2import type {
3 CheckDuplicatesResponse,
4 EnrichedBookmark,
5 EnrichedTag,
6} from "../../shared/types.ts";
7import { DuplicateWarning } from "./DuplicateWarning.tsx";
8import { TagInput } from "./TagInput.tsx";
9import { Button } from "./Button.tsx";
10
11export function Save() {
12 const [session, setSession] = useState<
13 { did: string; handle: string } | null
14 >(null);
15 const [loading, setLoading] = useState(true);
16 const [saving, setSaving] = useState(false);
17 const [saved, setSaved] = useState(false);
18 const [error, setError] = useState<string | null>(null);
19 const [url, setUrl] = useState("");
20 const [tags, setTags] = useState<string[]>([]);
21 const [availableTags, setAvailableTags] = useState<EnrichedTag[]>([]);
22 const [duplicates, setDuplicates] = useState<EnrichedBookmark[] | null>(null);
23
24 useEffect(() => {
25 // Get URL from query params
26 const params = new URLSearchParams(globalThis.location.search);
27 const urlParam = params.get("url");
28 if (urlParam) {
29 setUrl(urlParam);
30 }
31
32 // Check session
33 checkSession();
34 }, []);
35
36 async function checkSession() {
37 try {
38 const response = await fetch("/api/auth/session", {
39 credentials: "include",
40 });
41 if (response.ok) {
42 const data = await response.json();
43 setSession({
44 did: data.did,
45 handle: data.handle,
46 });
47
48 // Fetch available tags for autocomplete (non-fatal)
49 try {
50 const tagsRes = await fetch("/api/tags", {
51 credentials: "include",
52 });
53 if (tagsRes.ok) {
54 const tagsData = await tagsRes.json();
55 setAvailableTags(tagsData.tags || []);
56 }
57 } catch {
58 // Autocomplete won't work, but that's fine
59 }
60 } else {
61 // Session check failed - log for debugging
62 console.warn("Session check failed:", {
63 status: response.status,
64 statusText: response.statusText,
65 });
66
67 // Try to get error details
68 try {
69 const errorData = await response.json();
70 console.warn("Session error details:", errorData);
71 } catch {
72 // Ignore JSON parse errors
73 }
74 }
75 } catch (error) {
76 console.error("Failed to check session:", error);
77 } finally {
78 setLoading(false);
79 }
80 }
81
82 async function checkDuplicates(
83 urlToCheck: string,
84 ): Promise<EnrichedBookmark[]> {
85 try {
86 const response = await fetch("/api/bookmarks/check-duplicates", {
87 method: "POST",
88 headers: { "Content-Type": "application/json" },
89 credentials: "include",
90 body: JSON.stringify({ url: urlToCheck }),
91 });
92 if (response.ok) {
93 const data: CheckDuplicatesResponse = await response.json();
94 return data.duplicates;
95 }
96 } catch {
97 // Duplicate check is advisory — never block saving
98 }
99 return [];
100 }
101
102 async function saveBookmark() {
103 setSaving(true);
104 setError(null);
105
106 try {
107 const response = await fetch("/api/bookmarks", {
108 method: "POST",
109 headers: { "Content-Type": "application/json" },
110 credentials: "include",
111 body: JSON.stringify({ url: url.trim(), tags }),
112 });
113
114 // If session expired, redirect to login with current page
115 if (response.status === 401) {
116 try {
117 const errorData = await response.json();
118 console.warn("Authentication error during save:", errorData);
119 } catch {
120 // Ignore JSON parse errors
121 }
122
123 const loginUrl = `/login?redirect=${
124 encodeURIComponent(
125 globalThis.location.pathname + globalThis.location.search,
126 )
127 }`;
128 globalThis.location.href = loginUrl;
129 return;
130 }
131
132 if (!response.ok) {
133 const data = await response.json();
134 console.error("Failed to save bookmark:", data);
135 throw new Error(data.message || data.error || "Failed to add bookmark");
136 }
137
138 setSaved(true);
139 } catch (err: any) {
140 setError(err.message);
141 setSaving(false);
142 }
143 }
144
145 async function handleSave(e: React.FormEvent) {
146 e.preventDefault();
147 if (!url.trim()) return;
148
149 setSaving(true);
150 setError(null);
151
152 const matches = await checkDuplicates(url.trim());
153 if (matches.length > 0) {
154 setDuplicates(matches);
155 setSaving(false);
156 return;
157 }
158
159 await saveBookmark();
160 }
161
162 function handleCancelDuplicate() {
163 setDuplicates(null);
164 }
165
166 async function handleSaveAnyway() {
167 await saveBookmark();
168 }
169
170 function handleClose() {
171 globalThis.close();
172 }
173
174 if (loading) {
175 return (
176 <div
177 className="flex items-center justify-center min-h-screen"
178 style={{
179 background: "linear-gradient(135deg, var(--cream) 0%, #e8f4f5 100%)",
180 }}
181 >
182 <div className="spinner"></div>
183 </div>
184 );
185 }
186
187 if (!session) {
188 const loginUrl = `/?redirect=${
189 encodeURIComponent(
190 globalThis.location.pathname + globalThis.location.search,
191 )
192 }`;
193 return (
194 <div
195 className="flex items-center justify-center min-h-screen p-4"
196 style={{
197 background: "linear-gradient(135deg, var(--cream) 0%, #e8f4f5 100%)",
198 }}
199 >
200 <div className="bg-white rounded-lg max-w-md w-full p-8 shadow-lg text-center">
201 <div className="mb-6">
202 <img
203 src="https://res.cloudinary.com/dru3aznlk/image/upload/v1760692589/kip-vignette_h2jwct.png"
204 alt="Kip logo"
205 className="w-16 h-16 mx-auto mb-4"
206 />
207 <h1 className="text-2xl font-bold text-gray-800 mb-2">
208 Sign in required
209 </h1>
210 <p className="text-gray-600">
211 Sign in to save bookmarks to kipclip
212 </p>
213 </div>
214
215 <Button href={loginUrl} variant="primary" fullWidth>
216 Sign in
217 </Button>
218 </div>
219 </div>
220 );
221 }
222
223 if (saved) {
224 return (
225 <div
226 className="flex items-center justify-center min-h-screen p-4"
227 style={{
228 background: "linear-gradient(135deg, var(--cream) 0%, #e8f4f5 100%)",
229 }}
230 >
231 <div className="bg-white rounded-lg max-w-md w-full p-8 shadow-lg text-center">
232 <div className="mb-6">
233 <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
234 <svg
235 className="w-8 h-8 text-green-600"
236 fill="none"
237 stroke="currentColor"
238 viewBox="0 0 24 24"
239 >
240 <path
241 strokeLinecap="round"
242 strokeLinejoin="round"
243 strokeWidth={2}
244 d="M5 13l4 4L19 7"
245 />
246 </svg>
247 </div>
248 <h2 className="text-2xl font-bold text-gray-800 mb-2">
249 Bookmark Saved!
250 </h2>
251 <p className="text-gray-600 mb-6">
252 Your bookmark has been saved to kipclip
253 </p>
254 </div>
255
256 <div className="space-y-3">
257 <Button
258 type="button"
259 variant="secondary"
260 onClick={handleClose}
261 fullWidth
262 >
263 Close Window
264 </Button>
265 <Button href="/" variant="primary" fullWidth>
266 View All Bookmarks
267 </Button>
268 </div>
269 </div>
270 </div>
271 );
272 }
273
274 return (
275 <div
276 className="flex items-center justify-center min-h-screen p-4"
277 style={{
278 background: "linear-gradient(135deg, var(--cream) 0%, #e8f4f5 100%)",
279 }}
280 >
281 <div className="bg-white rounded-lg max-w-md w-full p-6 shadow-lg">
282 <div className="flex items-center justify-between mb-6">
283 <div className="flex items-center gap-2">
284 <img
285 src="https://res.cloudinary.com/dru3aznlk/image/upload/v1760692589/kip-vignette_h2jwct.png"
286 alt="Kip logo"
287 className="w-8 h-8"
288 />
289 <h2 className="text-xl font-bold text-gray-800">Save Bookmark</h2>
290 </div>
291 <button
292 type="button"
293 onClick={handleClose}
294 className="text-gray-400 hover:text-gray-600 text-2xl"
295 disabled={saving}
296 >
297 ×
298 </button>
299 </div>
300
301 <div className="mb-4 text-sm text-gray-600">
302 Signed in as <span className="font-medium">@{session.handle}</span>
303 </div>
304
305 {duplicates
306 ? (
307 <DuplicateWarning
308 duplicates={duplicates}
309 onCancel={handleCancelDuplicate}
310 onContinue={handleSaveAnyway}
311 loading={saving}
312 />
313 )
314 : (
315 <form onSubmit={handleSave} className="space-y-4">
316 <div>
317 <label
318 htmlFor="url"
319 className="block text-sm font-medium text-gray-700 mb-2"
320 >
321 URL
322 </label>
323 <input
324 type="url"
325 id="url"
326 value={url}
327 onChange={(e) => setUrl(e.target.value)}
328 placeholder="https://example.com"
329 className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-coral focus:border-transparent outline-none transition"
330 disabled={saving}
331 autoFocus
332 required
333 />
334 </div>
335
336 <div>
337 <label className="block text-sm font-medium text-gray-700 mb-2">
338 Tags (optional)
339 </label>
340 <TagInput
341 tags={tags}
342 onTagsChange={setTags}
343 availableTags={availableTags}
344 disabled={saving}
345 compact
346 />
347 </div>
348
349 {error && (
350 <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
351 {error}
352 </div>
353 )}
354
355 <Button
356 type="submit"
357 variant="primary"
358 loading={saving}
359 disabled={!url.trim()}
360 fullWidth
361 >
362 {saving ? "Saving..." : "Save Bookmark"}
363 </Button>
364 </form>
365 )}
366
367 <p className="text-xs text-gray-500 mt-4 text-center">
368 The page title and description will be automatically fetched
369 </p>
370 </div>
371 </div>
372 );
373}