v0.27.2 5960dd8

FavoriteEditor.svelte RawHistoryFolder

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>

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close