a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 251 lines 5.7 kB view raw
1<script lang="ts"> 2 import { onMount } from "svelte"; 3 4 type Item = { 5 label?: string; 6 action?: () => void; 7 items?: Item[]; 8 disabled?: boolean; 9 type?: "separator"; 10 }; 11 12 let { x, y, items, onclose } = $props<{ 13 x: number; 14 y: number; 15 items: Item[]; 16 onclose: () => void; 17 }>(); 18 19 let active = $state<number | null>(null), 20 subActive = $state<number | null>(null), 21 w = $state(0), 22 h = $state(0), 23 el = $state<HTMLElement>(); 24 25 onMount(() => { 26 const prevFocus = document.activeElement as HTMLElement | null; 27 el?.focus(); 28 return () => prevFocus?.focus(); 29 }); 30 31 const exec = (a?: () => void) => { 32 if (a) { 33 a(); 34 onclose(); 35 } 36 }; 37 38 const left = $derived(x + w > window.innerWidth ? Math.max(0, x - w) : x); 39 const top = $derived(y + h > window.innerHeight ? Math.max(0, y - h) : y); 40 41 function onKey(e: KeyboardEvent) { 42 e.stopPropagation(); 43 const k = e.key; 44 if (k === "Escape") { 45 onclose(); 46 } else if (k === "ArrowDown") { 47 e.preventDefault(); 48 if (subActive !== null && active !== null && items[active].items) { 49 const s = items[active].items!; 50 let next = (subActive + 1) % s.length; 51 while (s[next]?.type === "separator") next = (next + 1) % s.length; 52 subActive = next; 53 } else { 54 let next = active === null ? 0 : (active + 1) % items.length; 55 while (items[next]?.type === "separator") 56 next = (next + 1) % items.length; 57 active = next; 58 subActive = null; 59 } 60 } else if (k === "ArrowUp") { 61 e.preventDefault(); 62 if (subActive !== null && active !== null && items[active].items) { 63 const s = items[active].items!; 64 let next = (subActive - 1 + s.length) % s.length; 65 while (s[next]?.type === "separator") 66 next = (next - 1 + s.length) % s.length; 67 subActive = next; 68 } else { 69 let next = 70 active === null 71 ? items.length - 1 72 : (active - 1 + items.length) % items.length; 73 while (items[next]?.type === "separator") 74 next = (next - 1 + items.length) % items.length; 75 active = next; 76 subActive = null; 77 } 78 } else if (k === "ArrowRight") { 79 if (active !== null && items[active].items) { 80 e.preventDefault(); 81 subActive = 0; 82 } 83 } else if (k === "ArrowLeft") { 84 if (subActive !== null) { 85 e.preventDefault(); 86 subActive = null; 87 } 88 } else if (k === "Enter") { 89 e.preventDefault(); 90 if (active !== null) { 91 const item = items[active]; 92 if (item.items) { 93 if (subActive === null) subActive = 0; 94 else exec(item.items[subActive].action); 95 } else exec(item.action); 96 } 97 } 98 } 99</script> 100 101{#snippet menuButton(item: Item, isActive: boolean, isSub: boolean)} 102 <button 103 class:active={isActive} 104 disabled={item.disabled} 105 onmouseenter={() => { 106 if (isSub) { 107 if (active !== null && items[active].items) { 108 subActive = items[active].items.indexOf(item); 109 } 110 } else { 111 active = items.indexOf(item); 112 subActive = null; 113 } 114 }} 115 onclick={(e) => { 116 e.stopPropagation(); 117 if (item.items) { 118 active = items.indexOf(item); 119 subActive = null; 120 } else { 121 exec(item.action); 122 } 123 }} 124 > 125 {item.label} 126 {#if item.items}<span class="arrow"></span>{/if} 127 </button> 128{/snippet} 129 130<!-- svelte-ignore a11y_no_static_element_interactions --> 131<!-- svelte-ignore a11y_no_noninteractive_tabindex --> 132<!-- svelte-ignore a11y_click_events_have_key_events --> 133<div 134 class="overlay" 135 onclick={onclose} 136 oncontextmenu={(e) => { 137 e.preventDefault(); 138 onclose(); 139 }} 140> 141 <div 142 bind:this={el} 143 class="menu" 144 style="top: {top}px; left: {left}px;" 145 onclick={(e) => e.stopPropagation()} 146 onkeydown={onKey} 147 bind:clientWidth={w} 148 bind:clientHeight={h} 149 tabindex="0" 150 > 151 {#each items as item, i} 152 {#if item.type === "separator"} 153 <div class="separator"></div> 154 {:else} 155 <div 156 class="row" 157 onmouseleave={() => { 158 if (subActive === null) active = null; 159 }} 160 > 161 {@render menuButton(item, active === i && subActive === null, false)} 162 {#if item.items && active === i} 163 <div 164 class="submenu" 165 class:flip-x={left + w + 160 > window.innerWidth} 166 class:flip-y={top + i * 32 + item.items.length * 32 > 167 window.innerHeight} 168 > 169 {#each item.items as sub, si} 170 {#if sub.type === "separator"} 171 <div class="separator"></div> 172 {:else} 173 {@render menuButton(sub, subActive === si, true)} 174 {/if} 175 {/each} 176 </div> 177 {/if} 178 </div> 179 {/if} 180 {/each} 181 </div> 182</div> 183 184<style> 185 .overlay { 186 position: fixed; 187 inset: 0; 188 z-index: 1000; 189 } 190 .menu { 191 position: absolute; 192 background: Canvas; 193 color: CanvasText; 194 border: 1px solid var(--border); 195 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 196 min-inline-size: 160px; 197 display: flex; 198 flex-direction: column; 199 outline: none; 200 } 201 .row { 202 position: relative; 203 } 204 button { 205 background: none; 206 border: none; 207 text-align: left; 208 padding: 0.375rem 0.75rem; 209 inline-size: 100%; 210 display: flex; 211 justify-content: space-between; 212 align-items: center; 213 white-space: nowrap; 214 gap: 1rem; 215 color: inherit; 216 } 217 button:hover, 218 button.active { 219 background: Highlight; 220 color: HighlightText; 221 } 222 button:disabled { 223 opacity: 0.5; 224 } 225 .separator { 226 border-block-start: 1px solid var(--border-subtle); 227 } 228 .arrow { 229 font-size: 0.7em; 230 opacity: 0.5; 231 } 232 .submenu { 233 position: absolute; 234 inset-inline-start: 100%; 235 inset-block-start: 0; 236 background: Canvas; 237 border: 1px solid var(--border); 238 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 239 min-inline-size: 160px; 240 display: flex; 241 flex-direction: column; 242 } 243 .submenu.flip-x { 244 inset-inline-start: auto; 245 inset-inline-end: 100%; 246 } 247 .submenu.flip-y { 248 inset-block-start: auto; 249 inset-block-end: 0; 250 } 251</style>