The Appview for the kipclip.com atproto bookmarking service
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}