music on atproto
plyr.fm
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: var(--bg-primary);
104 border: 1px solid var(--border-default);
105 border-radius: var(--radius-sm);
106 color: var(--text-primary);
107 font-size: var(--text-lg);
108 font-family: inherit;
109 transition: all 0.2s;
110 }
111
112 .album-input:focus {
113 outline: none;
114 border-color: var(--accent);
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: var(--bg-tertiary);
129 border: 1px solid var(--border-default);
130 border-radius: var(--radius-sm);
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: var(--bg-primary);
142 border-radius: var(--radius-sm);
143 }
144
145 .album-results::-webkit-scrollbar-thumb {
146 background: var(--border-default);
147 border-radius: var(--radius-sm);
148 }
149
150 .album-results::-webkit-scrollbar-thumb:hover {
151 background: var(--border-emphasis);
152 }
153
154 /* firefox scrollbar */
155 .album-results {
156 scrollbar-width: thin;
157 scrollbar-color: var(--border-default) var(--bg-primary);
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 var(--border-subtle);
169 color: var(--text-primary);
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: var(--bg-hover);
183 }
184
185 .album-result-item.exact-match {
186 background: color-mix(in srgb, var(--accent) 10%, transparent);
187 border-left: 3px solid var(--accent);
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: var(--text-primary);
199 margin-bottom: 0.125rem;
200 overflow: hidden;
201 text-overflow: ellipsis;
202 white-space: nowrap;
203 }
204
205 .album-stats {
206 font-size: var(--text-sm);
207 color: var(--text-tertiary);
208 overflow: hidden;
209 text-overflow: ellipsis;
210 white-space: nowrap;
211 }
212
213 .similar-hint {
214 margin-top: 0.5rem;
215 font-size: var(--text-sm);
216 color: var(--warning);
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>