1<script lang="ts">
2 import type { Favorite, Folder, Tag } from '../types';
3
4 interface Props {
5 favorite: Favorite;
6 folders: Folder[];
7 tags: Tag[];
8 onSave: (updates: Partial<Favorite>) => void;
9 onDelete: (id: number) => void;
10 onClose: () => void;
11 onAssignTag: (favoriteId: number, tagId: number) => void;
12 onRemoveTag: (favoriteId: number, tagId: number) => void;
13 }
14
15 const props: Props = $props();
16 const { folders, tags, onSave, onDelete, onClose, onAssignTag, onRemoveTag } = props;
17 // Capture initial values for form state (component remounts for different favorites)
18 const initialFavorite = props.favorite;
19
20 // Form state
21 let title = $state(initialFavorite.title || '');
22 let folderId = $state<number | null>(initialFavorite.folder_id);
23 let shortcutKey = $state<number | null>(initialFavorite.shortcut_key);
24 let confirmDelete = $state(false);
25
26 // Track current favorite for reactive operations
27 const favorite = $derived(props.favorite);
28
29 // Get favorite's current tags
30 const favoriteTags = $derived(favorite.tags || []);
31 const hasTag = (tagId: number) => favoriteTags.some(t => t.id === tagId);
32
33 const handleSave = () => {
34 onSave({
35 id: favorite.id,
36 title: title.trim() || null,
37 folder_id: folderId,
38 shortcut_key: shortcutKey,
39 });
40 onClose();
41 };
42
43 const handleDelete = () => {
44 if (!confirmDelete) {
45 confirmDelete = true;
46 return;
47 }
48 onDelete(favorite.id);
49 onClose();
50 };
51
52 const handleTagClick = (tagId: number) => {
53 if (hasTag(tagId)) {
54 onRemoveTag(favorite.id, tagId);
55 } else {
56 onAssignTag(favorite.id, tagId);
57 }
58 };
59
60 const handleKeyDown = (e: KeyboardEvent) => {
61 switch (e.key) {
62 case 'Escape':
63 e.preventDefault();
64 if (confirmDelete) {
65 confirmDelete = false;
66 } else {
67 onClose();
68 }
69 break;
70 case 'Enter':
71 if (e.ctrlKey || e.metaKey) {
72 e.preventDefault();
73 handleSave();
74 }
75 break;
76 }
77 };
78
79 const handleOverlayClick = (e: Event) => {
80 if (e.target === e.currentTarget) {
81 onClose();
82 }
83 };
84</script>
85
86<svelte:window onkeydown={handleKeyDown} />
87
88<div
89 class="editor-overlay"
90 onclick={handleOverlayClick}
91 onkeydown={(e) => { if (e.key === 'Escape') handleOverlayClick(e); }}
92 role="button"
93 tabindex="0"
94 aria-label="Close editor"
95>
96 <div class="editor-modal">
97 <div class="modal-header">
98 <span class="modal-icon"></span>
99 <span class="modal-title">EDIT FAVORITE</span>
100 <button class="close-btn" type="button" onclick={onClose}>
101 <kbd>Esc</kbd>
102 </button>
103 </div>
104
105 <div class="modal-body">
106 <!-- URL (readonly) -->
107 <div class="form-group">
108 <span class="form-label" id="url-label">URL</span>
109 <div class="url-display" aria-labelledby="url-label">{favorite.url}</div>
110 </div>
111
112 <!-- Title -->
113 <div class="form-group">
114 <label class="form-label" for="fav-title">TITLE</label>
115 <input
116 id="fav-title"
117 type="text"
118 class="form-input"
119 bind:value={title}
120 placeholder="Custom title (optional)"
121 />
122 </div>
123
124 <!-- Folder -->
125 <div class="form-group">
126 <label class="form-label" for="fav-folder">FOLDER</label>
127 <select id="fav-folder" class="form-select" bind:value={folderId}>
128 <option value={null}>— No folder —</option>
129 {#each folders as folder (folder.id)}
130 <option value={folder.id}>
131 {folder.icon || ''} {folder.name}
132 </option>
133 {/each}
134 </select>
135 </div>
136
137 <!-- Shortcut Key -->
138 <div class="form-group">
139 <label class="form-label" for="fav-shortcut">QUICK ACCESS KEY</label>
140 <select id="fav-shortcut" class="form-select" bind:value={shortcutKey}>
141 <option value={null}>— None —</option>
142 {#each [1, 2, 3, 4, 5, 6, 7, 8, 9] as key (key)}
143 <option value={key}>{key}</option>
144 {/each}
145 </select>
146 <span class="form-hint">Press 1-9 on homepage to open directly</span>
147 </div>
148
149 <!-- Tags -->
150 <div class="form-group">
151 <span class="form-label" id="tags-label">TAGS</span>
152 <div class="tag-selector" role="group" aria-labelledby="tags-label">
153 {#if tags.length === 0}
154 <span class="no-tags">No tags available</span>
155 {:else}
156 {#each tags as tag (tag.id)}
157 <button
158 class="tag-chip"
159 class:selected={hasTag(tag.id)}
160 type="button"
161 onclick={() => handleTagClick(tag.id)}
162 style="--tag-color: {tag.color}"
163 >
164 <span class="tag-dot"></span>
165 <span class="tag-name">{tag.name}</span>
166 </button>
167 {/each}
168 {/if}
169 </div>
170 </div>
171 </div>
172
173 <div class="modal-footer">
174 {#if confirmDelete}
175 <div class="confirm-delete">
176 <span class="confirm-text">Delete this favorite?</span>
177 <button class="btn btn-cancel" type="button" onclick={() => confirmDelete = false}>
178 CANCEL
179 </button>
180 <button class="btn btn-danger" type="button" onclick={handleDelete}>
181 DELETE
182 </button>
183 </div>
184 {:else}
185 <button class="btn btn-danger-outline" type="button" onclick={handleDelete}>
186 DELETE
187 </button>
188 <div class="footer-right">
189 <button class="btn btn-secondary" type="button" onclick={onClose}>
190 CANCEL
191 </button>
192 <button class="btn btn-primary" type="button" onclick={handleSave}>
193 SAVE
194 <kbd>Ctrl+Enter</kbd>
195 </button>
196 </div>
197 {/if}
198 </div>
199 </div>
200</div>
201
202<style>
203 .editor-overlay {
204 position: fixed;
205 inset: 0;
206 z-index: 100;
207 display: flex;
208 align-items: center;
209 justify-content: center;
210 background: rgb(0 0 0 / 0.75);
211 backdrop-filter: blur(4px);
212 animation: fade-in 150ms ease;
213 }
214
215 @keyframes fade-in {
216 from { opacity: 0; }
217 }
218
219 .editor-modal {
220 width: 100%;
221 max-width: 480px;
222 margin: 1rem;
223 background: var(--card);
224 border-width: 1px;
225 border-style: solid;
226 border-color: var(--border);
227 box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.6);
228 animation: modal-in 150ms ease;
229 }
230
231 @keyframes modal-in {
232 from {
233 opacity: 0;
234 transform: scale(0.96) translateY(-8px);
235 }
236 }
237
238 .modal-header {
239 display: flex;
240 align-items: center;
241 gap: 0.6rem;
242 padding: 0.75rem 1rem;
243 border-bottom: 1px solid var(--border);
244 background: color-mix(in srgb, var(--background) 80%, transparent);
245 }
246
247 .modal-icon::before {
248 content: '';
249 font-size: 0.9rem;
250 color: var(--primary, #4ade80);
251 }
252
253 .modal-title {
254 flex: 1;
255 font-size: 0.72rem;
256 font-weight: 600;
257 letter-spacing: 0.1em;
258 color: var(--foreground);
259 }
260
261 .close-btn {
262 padding: 0.25rem 0.5rem;
263 background: transparent;
264 border-width: 1px;
265 border-style: solid;
266 border-color: var(--border);
267 cursor: pointer;
268 }
269
270 .close-btn kbd {
271 font-size: 0.6rem;
272 color: var(--muted-foreground);
273 }
274
275 .close-btn:hover {
276 border-color: var(--foreground);
277 }
278
279 .close-btn:hover kbd {
280 color: var(--foreground);
281 }
282
283 .modal-body {
284 padding: 1rem;
285 display: flex;
286 flex-direction: column;
287 gap: 1rem;
288 }
289
290 .form-group {
291 display: flex;
292 flex-direction: column;
293 gap: 0.35rem;
294 }
295
296 .form-label {
297 font-size: 0.6rem;
298 font-weight: 600;
299 letter-spacing: 0.12em;
300 color: var(--muted-foreground);
301 }
302
303 .url-display {
304 font-size: 0.72rem;
305 color: var(--foreground);
306 padding: 0.5rem 0.65rem;
307 border-width: 1px;
308 border-style: solid;
309 border-color: var(--border);
310 background: var(--background);
311 white-space: nowrap;
312 overflow: hidden;
313 text-overflow: ellipsis;
314 }
315
316 .form-input,
317 .form-select {
318 font-family: inherit;
319 font-size: 0.75rem;
320 padding: 0.5rem 0.65rem;
321 border-width: 1px;
322 border-style: solid;
323 border-color: var(--border);
324 background: var(--background);
325 color: var(--foreground);
326 outline: none;
327 transition: border-color 100ms ease;
328 }
329
330 .form-input:focus,
331 .form-select:focus {
332 border-color: var(--primary, #4ade80);
333 }
334
335 .form-input::placeholder {
336 color: var(--muted-foreground);
337 }
338
339 .form-hint {
340 font-size: 0.6rem;
341 color: var(--muted-foreground);
342 letter-spacing: 0.05em;
343 }
344
345 .tag-selector {
346 display: flex;
347 flex-wrap: wrap;
348 gap: 0.35rem;
349 }
350
351 .no-tags {
352 font-size: 0.65rem;
353 color: var(--muted-foreground);
354 font-style: italic;
355 }
356
357 .tag-chip {
358 display: inline-flex;
359 align-items: center;
360 gap: 0.35rem;
361 padding: 0.25rem 0.5rem;
362 font-size: 0.62rem;
363 font-weight: 500;
364 letter-spacing: 0.05em;
365 border-width: 1px;
366 border-style: solid;
367 border-color: var(--tag-color);
368 background: transparent;
369 color: var(--foreground);
370 cursor: pointer;
371 transition: all 100ms ease;
372 }
373
374 .tag-chip:hover {
375 background: color-mix(in srgb, var(--tag-color) 15%, transparent);
376 }
377
378 .tag-chip.selected {
379 background: color-mix(in srgb, var(--tag-color) 25%, transparent);
380 border-width: 2px;
381 padding: calc(0.25rem - 1px) calc(0.5rem - 1px);
382 }
383
384 .tag-dot {
385 width: 6px;
386 height: 6px;
387 background: var(--tag-color);
388 border-radius: 50%;
389 }
390
391 .tag-name {
392 text-transform: lowercase;
393 }
394
395 .modal-footer {
396 display: flex;
397 align-items: center;
398 justify-content: space-between;
399 gap: 0.5rem;
400 padding: 0.75rem 1rem;
401 border-top: 1px solid var(--border);
402 background: color-mix(in srgb, var(--background) 60%, transparent);
403 }
404
405 .footer-right {
406 display: flex;
407 gap: 0.5rem;
408 }
409
410 .btn {
411 display: inline-flex;
412 align-items: center;
413 gap: 0.5rem;
414 padding: 0.5rem 0.85rem;
415 font-size: 0.68rem;
416 font-weight: 500;
417 letter-spacing: 0.08em;
418 border-width: 1px;
419 border-style: solid;
420 border-color: var(--border);
421 cursor: pointer;
422 transition: all 100ms ease;
423 }
424
425 .btn kbd {
426 font-size: 0.55rem;
427 padding: 0.1rem 0.3rem;
428 border: 1px solid;
429 opacity: 0.6;
430 }
431
432 .btn-secondary {
433 background: transparent;
434 color: var(--muted-foreground);
435 }
436
437 .btn-secondary:hover {
438 color: var(--foreground);
439 border-color: var(--foreground);
440 }
441
442 .btn-primary {
443 background: var(--primary, #4ade80);
444 color: var(--background);
445 border-color: var(--primary, #4ade80);
446 }
447
448 .btn-primary:hover {
449 filter: brightness(1.1);
450 }
451
452 .btn-danger-outline {
453 background: transparent;
454 color: var(--muted-foreground);
455 }
456
457 .btn-danger-outline:hover {
458 color: var(--destructive);
459 border-color: var(--destructive);
460 }
461
462 .btn-danger {
463 background: var(--destructive);
464 color: var(--destructive-foreground);
465 border-color: color-mix(in srgb, var(--destructive) 80%, black 20%);
466 }
467
468 .btn-danger:hover {
469 background: color-mix(in srgb, var(--destructive) 85%, white 15%);
470 }
471
472 .btn-cancel {
473 background: transparent;
474 color: var(--muted-foreground);
475 }
476
477 .btn-cancel:hover {
478 color: var(--foreground);
479 border-color: var(--foreground);
480 }
481
482 .confirm-delete {
483 display: flex;
484 align-items: center;
485 gap: 0.75rem;
486 width: 100%;
487 }
488
489 .confirm-text {
490 flex: 1;
491 font-size: 0.72rem;
492 color: var(--destructive);
493 font-weight: 500;
494 }
495</style>