feat: add album autocomplete to prevent typo duplicates (#246)

* feat: add album autocomplete to prevent typo duplicates

- add datalist to upload form album field
- add datalist to edit form album field
- show 'similar albums' hint when typing non-exact match
- helps prevent accidental album duplicates (e.g., 'Potato' vs 'Potatoes')
- autocomplete off to prioritize datalist suggestions

closes #TBD

* refactor: replace datalist with custom AlbumSelect component

- created AlbumSelect component matching HandleSearch styling
- consistent dropdown appearance across forms
- added custom dark scrollbar styling (thin, dark gray)
- removed ugly white scrollbar from HandleSearch
- shows album title and track count in dropdown
- highlights exact matches with blue accent
- displays 'similar albums' hint for near matches
- same UX as featured artists dropdown

fixes inconsistent form styling between album and featured artists fields

* fix: add font-family inheritance to all form elements

- portal page: inputs, textareas, file inputs, buttons inherit font
- AlbumSelect: input and dropdown buttons inherit font
- HandleSearch: input, dropdown buttons, chip remove buttons inherit font
- all form elements now use global monospace font stack
- consistent typography throughout portal forms

* refactor: remove redundant onSelect callback

- removed onSelect prop from AlbumSelect component
- rely solely on bind:value for two-way binding
- removed redundant albumTitle = title assignments in portal
- cleaner component API with proper Svelte 5 runes pattern

addresses copilot review feedback

* docs: add example for checking inline review comments

- added gh api example to consider-review command
- shows how to get inline PR review comments via gh cli
- makes it easier to remember the correct command syntax

authored by zzstoatzz.io and committed by GitHub 27c54536 2937326e

Changed files
+279 -8
.claude
frontend
src
lib
routes
portal
+7 -1
.claude/commands/consider-review.md
··· 1 - Check the current PR for comments, reviews, AND inline review comments via the gh cli. 1 + Check the current PR for comments, reviews, AND inline review comments via the gh cli. 2 + 3 + For example, to get the review comments for PR #246: 4 + 5 + ```bash 6 + gh api repos/zzstoatzz/plyr.fm/pulls/246/comments --jq '.[] | {path: .path, line: .line, body: .body}' 7 + ```
+235
frontend/src/lib/components/AlbumSelect.svelte
··· 1 + <script lang="ts"> 2 + import type { AlbumSummary } from '$lib/types'; 3 + 4 + interface Props { 5 + albums: AlbumSummary[]; 6 + value: string; 7 + placeholder?: string; 8 + disabled?: boolean; 9 + } 10 + 11 + let { albums = [], value = $bindable(''), placeholder = 'album name', disabled = false }: Props = $props(); 12 + 13 + let showResults = $state(false); 14 + let filteredAlbums = $derived.by(() => { 15 + if (!value || value.length === 0) { 16 + return albums; 17 + } 18 + return albums.filter(album => 19 + album.title.toLowerCase().includes(value.toLowerCase()) 20 + ); 21 + }); 22 + 23 + let exactMatch = $derived.by(() => { 24 + return albums.find(a => a.title.toLowerCase() === value.toLowerCase()); 25 + }); 26 + 27 + let similarAlbums = $derived.by(() => { 28 + if (exactMatch || !value) return []; 29 + return albums.filter(a => 30 + a.title.toLowerCase() !== value.toLowerCase() && 31 + a.title.toLowerCase().includes(value.toLowerCase()) 32 + ); 33 + }); 34 + 35 + function selectAlbum(albumTitle: string) { 36 + value = albumTitle; 37 + showResults = false; 38 + } 39 + 40 + function handleClickOutside(e: MouseEvent) { 41 + const target = e.target as HTMLElement; 42 + if (!target.closest('.album-select-container')) { 43 + showResults = false; 44 + } 45 + } 46 + </script> 47 + 48 + <svelte:window onclick={handleClickOutside} /> 49 + 50 + <div class="album-select-container"> 51 + <div class="input-wrapper"> 52 + <input 53 + type="text" 54 + bind:value 55 + placeholder={placeholder} 56 + {disabled} 57 + class="album-input" 58 + onfocus={() => { if (albums.length > 0) showResults = true; }} 59 + oninput={() => { showResults = albums.length > 0; }} 60 + autocomplete="off" 61 + /> 62 + 63 + {#if showResults && filteredAlbums.length > 0} 64 + <div class="album-results"> 65 + {#each filteredAlbums as album} 66 + <button 67 + type="button" 68 + class="album-result-item" 69 + class:exact-match={album.title.toLowerCase() === value.toLowerCase()} 70 + onclick={() => selectAlbum(album.title)} 71 + > 72 + <div class="album-info"> 73 + <div class="album-title">{album.title}</div> 74 + <div class="album-stats"> 75 + {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} 76 + </div> 77 + </div> 78 + </button> 79 + {/each} 80 + </div> 81 + {/if} 82 + </div> 83 + 84 + {#if !exactMatch && similarAlbums.length > 0} 85 + <p class="similar-hint"> 86 + similar: {similarAlbums.map(a => a.title).join(', ')} 87 + </p> 88 + {/if} 89 + </div> 90 + 91 + <style> 92 + .album-select-container { 93 + width: 100%; 94 + } 95 + 96 + .input-wrapper { 97 + position: relative; 98 + } 99 + 100 + .album-input { 101 + width: 100%; 102 + padding: 0.75rem; 103 + background: #0a0a0a; 104 + border: 1px solid #333; 105 + border-radius: 4px; 106 + color: white; 107 + font-size: 1rem; 108 + font-family: inherit; 109 + transition: all 0.2s; 110 + } 111 + 112 + .album-input:focus { 113 + outline: none; 114 + border-color: #3a7dff; 115 + } 116 + 117 + .album-input:disabled { 118 + opacity: 0.5; 119 + cursor: not-allowed; 120 + } 121 + 122 + .album-results { 123 + position: absolute; 124 + z-index: 100; 125 + width: 100%; 126 + max-height: 300px; 127 + overflow-y: auto; 128 + background: #1a1a1a; 129 + border: 1px solid #333; 130 + border-radius: 4px; 131 + margin-top: 0.25rem; 132 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 + } 134 + 135 + /* custom scrollbar styling */ 136 + .album-results::-webkit-scrollbar { 137 + width: 8px; 138 + } 139 + 140 + .album-results::-webkit-scrollbar-track { 141 + background: #0a0a0a; 142 + border-radius: 4px; 143 + } 144 + 145 + .album-results::-webkit-scrollbar-thumb { 146 + background: #333; 147 + border-radius: 4px; 148 + } 149 + 150 + .album-results::-webkit-scrollbar-thumb:hover { 151 + background: #444; 152 + } 153 + 154 + /* firefox scrollbar */ 155 + .album-results { 156 + scrollbar-width: thin; 157 + scrollbar-color: #333 #0a0a0a; 158 + } 159 + 160 + .album-result-item { 161 + width: 100%; 162 + display: flex; 163 + align-items: center; 164 + gap: 0.75rem; 165 + padding: 0.75rem; 166 + background: transparent; 167 + border: none; 168 + border-bottom: 1px solid #2a2a2a; 169 + color: white; 170 + text-align: left; 171 + font-family: inherit; 172 + cursor: pointer; 173 + transition: all 0.15s; 174 + min-width: 0; 175 + } 176 + 177 + .album-result-item:last-child { 178 + border-bottom: none; 179 + } 180 + 181 + .album-result-item:hover { 182 + background: #222; 183 + } 184 + 185 + .album-result-item.exact-match { 186 + background: rgba(58, 125, 255, 0.1); 187 + border-left: 3px solid #3a7dff; 188 + } 189 + 190 + .album-info { 191 + flex: 1; 192 + min-width: 0; 193 + overflow: hidden; 194 + } 195 + 196 + .album-title { 197 + font-weight: 500; 198 + color: #e8e8e8; 199 + margin-bottom: 0.125rem; 200 + overflow: hidden; 201 + text-overflow: ellipsis; 202 + white-space: nowrap; 203 + } 204 + 205 + .album-stats { 206 + font-size: 0.85rem; 207 + color: #888; 208 + overflow: hidden; 209 + text-overflow: ellipsis; 210 + white-space: nowrap; 211 + } 212 + 213 + .similar-hint { 214 + margin-top: 0.5rem; 215 + font-size: 0.85rem; 216 + color: #ff9800; 217 + font-style: italic; 218 + margin-bottom: 0; 219 + } 220 + 221 + /* mobile styles */ 222 + @media (max-width: 768px) { 223 + .album-input { 224 + font-size: 16px; /* prevents zoom on iOS */ 225 + } 226 + 227 + .album-results { 228 + max-height: 200px; 229 + } 230 + 231 + .album-result-item { 232 + padding: 0.625rem; 233 + } 234 + } 235 + </style>
+28
frontend/src/lib/components/HandleSearch.svelte
··· 158 158 border-radius: 4px; 159 159 color: white; 160 160 font-size: 1rem; 161 + font-family: inherit; 161 162 transition: all 0.2s; 162 163 } 163 164 ··· 193 194 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 194 195 } 195 196 197 + /* custom scrollbar styling */ 198 + .search-results::-webkit-scrollbar { 199 + width: 8px; 200 + } 201 + 202 + .search-results::-webkit-scrollbar-track { 203 + background: #0a0a0a; 204 + border-radius: 4px; 205 + } 206 + 207 + .search-results::-webkit-scrollbar-thumb { 208 + background: #333; 209 + border-radius: 4px; 210 + } 211 + 212 + .search-results::-webkit-scrollbar-thumb:hover { 213 + background: #444; 214 + } 215 + 216 + /* firefox scrollbar */ 217 + .search-results { 218 + scrollbar-width: thin; 219 + scrollbar-color: #333 #0a0a0a; 220 + } 221 + 196 222 .search-result-item { 197 223 width: 100%; 198 224 display: flex; ··· 204 230 border-bottom: 1px solid #2a2a2a; 205 231 color: white; 206 232 text-align: left; 233 + font-family: inherit; 207 234 cursor: pointer; 208 235 transition: all 0.15s; 209 236 min-width: 0; /* allow flex child to shrink */ ··· 291 318 border: none; 292 319 color: #888; 293 320 font-size: 1.3rem; 321 + font-family: inherit; 294 322 line-height: 1; 295 323 cursor: pointer; 296 324 padding: 0;
+9 -7
frontend/src/routes/portal/+page.svelte
··· 3 3 import { replaceState } from '$app/navigation'; 4 4 import Header from '$lib/components/Header.svelte'; 5 5 import HandleSearch from '$lib/components/HandleSearch.svelte'; 6 + import AlbumSelect from '$lib/components/AlbumSelect.svelte'; 6 7 import LoadingSpinner from '$lib/components/LoadingSpinner.svelte'; 7 8 import MigrationBanner from '$lib/components/MigrationBanner.svelte'; 8 9 import BrokenTracks from '$lib/components/BrokenTracks.svelte'; ··· 503 504 504 505 <div class="form-group"> 505 506 <label for="album">album (optional)</label> 506 - <input 507 - id="album" 508 - type="text" 507 + <AlbumSelect 508 + {albums} 509 509 bind:value={albumTitle} 510 - placeholder="album name" 511 510 /> 512 511 </div> 513 512 ··· 583 582 </div> 584 583 <div class="edit-field-group"> 585 584 <label for="edit-album" class="edit-label">album (optional)</label> 586 - <input id="edit-album" 587 - type="text" 585 + <AlbumSelect 586 + {albums} 588 587 bind:value={editAlbum} 589 588 placeholder="album (optional)" 590 - class="edit-input" 591 589 /> 592 590 </div> 593 591 <div class="edit-field-group"> ··· 907 905 border-radius: 4px; 908 906 color: white; 909 907 font-size: 1rem; 908 + font-family: inherit; 910 909 transition: all 0.2s; 911 910 } 912 911 ··· 991 990 border-radius: 4px; 992 991 color: white; 993 992 font-size: 0.9rem; 993 + font-family: inherit; 994 994 cursor: pointer; 995 995 } 996 996 ··· 1020 1020 border-radius: 4px; 1021 1021 font-size: 1rem; 1022 1022 font-weight: 600; 1023 + font-family: inherit; 1023 1024 cursor: pointer; 1024 1025 transition: all 0.2s; 1025 1026 } ··· 1246 1247 border-radius: 4px; 1247 1248 color: white; 1248 1249 font-size: 0.9rem; 1250 + font-family: inherit; 1249 1251 } 1250 1252 1251 1253 .current-image-preview {