The Appview for the kipclip.com atproto bookmarking service
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 373 lines 11 kB view raw
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}