The Appview for the kipclip.com atproto bookmarking service
at main 181 lines 5.6 kB view raw
1import { useState } from "react"; 2import type { EnrichedBookmark, EnrichedTag } from "../../shared/types.ts"; 3import { getBaseUrl } from "../../shared/url-utils.ts"; 4import { useApp } from "../context/AppContext.tsx"; 5import { DuplicateWarning } from "./DuplicateWarning.tsx"; 6import { TagInput } from "./TagInput.tsx"; 7 8interface AddBookmarkProps { 9 onClose: () => void; 10 onBookmarkAdded: (bookmark: EnrichedBookmark) => void; 11 availableTags: EnrichedTag[]; 12 onTagsChanged?: () => void; 13} 14 15export function AddBookmark({ 16 onClose, 17 onBookmarkAdded, 18 availableTags, 19 onTagsChanged, 20}: AddBookmarkProps) { 21 const { bookmarks } = useApp(); 22 const [url, setUrl] = useState(""); 23 const [tags, setTags] = useState<string[]>([]); 24 const [loading, setLoading] = useState(false); 25 const [error, setError] = useState<string | null>(null); 26 const [duplicates, setDuplicates] = useState<EnrichedBookmark[] | null>(null); 27 28 function findDuplicates(inputUrl: string): EnrichedBookmark[] { 29 const inputBase = getBaseUrl(inputUrl); 30 if (!inputBase) return []; 31 return bookmarks.filter((b) => getBaseUrl(b.subject) === inputBase); 32 } 33 34 async function saveBookmark() { 35 setLoading(true); 36 setError(null); 37 38 try { 39 const response = await fetch("/api/bookmarks", { 40 method: "POST", 41 headers: { "Content-Type": "application/json" }, 42 body: JSON.stringify({ url: url.trim(), tags }), 43 }); 44 45 if (!response.ok) { 46 const data = await response.json(); 47 throw new Error(data.error || "Failed to add bookmark"); 48 } 49 50 const data = await response.json(); 51 if (tags.length > 0 && onTagsChanged) { 52 onTagsChanged(); 53 } 54 onBookmarkAdded(data.bookmark); 55 } catch (err: any) { 56 setError(err.message); 57 setLoading(false); 58 } 59 } 60 61 async function handleSubmit(e: React.FormEvent) { 62 e.preventDefault(); 63 if (!url.trim()) return; 64 65 const matches = findDuplicates(url.trim()); 66 if (matches.length > 0) { 67 setDuplicates(matches); 68 return; 69 } 70 71 await saveBookmark(); 72 } 73 74 function handleCancelDuplicate() { 75 setDuplicates(null); 76 } 77 78 async function handleSaveAnyway() { 79 await saveBookmark(); 80 } 81 82 return ( 83 <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> 84 <div className="bg-white rounded-lg max-w-md w-full p-6 fade-in"> 85 <div className="flex items-center justify-between mb-4"> 86 <h3 className="text-xl font-bold text-gray-800">Add Bookmark</h3> 87 <button 88 type="button" 89 onClick={onClose} 90 className="text-gray-400 hover:text-gray-600 text-2xl" 91 disabled={loading} 92 > 93 × 94 </button> 95 </div> 96 97 {duplicates 98 ? ( 99 <DuplicateWarning 100 duplicates={duplicates} 101 onCancel={handleCancelDuplicate} 102 onContinue={handleSaveAnyway} 103 loading={loading} 104 /> 105 ) 106 : ( 107 <form onSubmit={handleSubmit} className="space-y-4"> 108 <div> 109 <label 110 htmlFor="url" 111 className="block text-sm font-medium text-gray-700 mb-2" 112 > 113 URL 114 </label> 115 <input 116 type="url" 117 id="url" 118 value={url} 119 onChange={(e) => setUrl(e.target.value)} 120 placeholder="https://example.com" 121 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" 122 disabled={loading} 123 autoFocus 124 required 125 /> 126 </div> 127 128 <div> 129 <label className="block text-sm font-medium text-gray-700 mb-2"> 130 Tags (optional) 131 </label> 132 <TagInput 133 tags={tags} 134 onTagsChange={setTags} 135 availableTags={availableTags} 136 disabled={loading} 137 compact 138 /> 139 </div> 140 141 {error && ( 142 <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm"> 143 {error} 144 </div> 145 )} 146 147 <div className="flex gap-3"> 148 <button 149 type="button" 150 onClick={onClose} 151 className="flex-1 px-4 py-3 rounded-lg border border-gray-300 text-gray-700 font-medium hover:bg-gray-50 transition" 152 disabled={loading} 153 > 154 Cancel 155 </button> 156 <button 157 type="submit" 158 className="flex-1 btn-primary disabled:opacity-50" 159 disabled={loading || !url.trim()} 160 > 161 {loading 162 ? ( 163 <span className="flex items-center justify-center gap-2"> 164 <div className="spinner w-5 h-5 border-2"></div> 165 Adding... 166 </span> 167 ) 168 : "Add Bookmark"} 169 </button> 170 </div> 171 </form> 172 )} 173 174 <p className="text-xs text-gray-500 mt-4 text-center"> 175 The page title will be automatically fetched and saved with your 176 bookmark 177 </p> 178 </div> 179 </div> 180 ); 181}