[READ-ONLY] Mirror of https://github.com/mrgnw/dlna-rs. Single-binary DLNA media server in Rust. Auto-discovered by VLC on Apple TV.
0

Configure Feed

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

web: BrowseView with filename/path sort and collapsible subdirs

+162
+162
web/src/BrowseView.svelte
··· 1 + <script lang="ts"> 2 + import { listFiles, type FileMeta } from './api'; 3 + import { formatSize } from './format'; 4 + 5 + let { folder, onback }: { folder: string; onback: () => void } = $props(); 6 + 7 + let recursive = $state(false); 8 + let files = $state<FileMeta[]>([]); 9 + let loading = $state(true); 10 + let error = $state<string | null>(null); 11 + let sortMode = $state<'filename' | 'path'>('filename'); 12 + let collapsed = $state<Record<string, boolean>>({}); 13 + 14 + async function load() { 15 + loading = true; 16 + try { 17 + const r = await listFiles(folder); 18 + files = r.files; 19 + recursive = r.recursive; 20 + error = null; 21 + } catch (e) { 22 + error = String(e); 23 + } finally { 24 + loading = false; 25 + } 26 + } 27 + load(); 28 + 29 + function basename(p: string): string { 30 + const parts = p.split('/').filter(Boolean); 31 + return parts[parts.length - 1] ?? p; 32 + } 33 + 34 + function parentDir(rel: string): string { 35 + const i = rel.lastIndexOf('/'); 36 + return i < 0 ? '' : rel.slice(0, i + 1); 37 + } 38 + 39 + const sortedByFilename = $derived( 40 + [...files].sort((a, b) => basename(a.rel).localeCompare(basename(b.rel))) 41 + ); 42 + 43 + type Group = { dir: string; files: FileMeta[] }; 44 + const groupedByPath = $derived.by<Group[]>(() => { 45 + const map = new Map<string, FileMeta[]>(); 46 + for (const f of [...files].sort((a, b) => a.rel.localeCompare(b.rel))) { 47 + const dir = parentDir(f.rel); 48 + if (!map.has(dir)) map.set(dir, []); 49 + map.get(dir)!.push(f); 50 + } 51 + const dirs = [...map.keys()].sort(); 52 + return dirs.map(dir => ({ dir, files: map.get(dir)! })); 53 + }); 54 + 55 + function toggle(dir: string) { 56 + collapsed = { ...collapsed, [dir]: !collapsed[dir] }; 57 + } 58 + </script> 59 + 60 + <header class="page"> 61 + <button class="back" onclick={onback}>← dlna-rs</button> 62 + </header> 63 + 64 + <section class="hero"> 65 + <div class="row"> 66 + <h2>{basename(folder)}</h2> 67 + {#if recursive}<span class="tag">recursive</span>{/if} 68 + <span class="count">{files.length} files</span> 69 + </div> 70 + <div class="path">{folder}</div> 71 + </section> 72 + 73 + <nav class="sort"> 74 + <span class="label">sort:</span> 75 + <label><input type="radio" name="sort" value="filename" bind:group={sortMode} /> filename</label> 76 + <label><input type="radio" name="sort" value="path" bind:group={sortMode} /> path</label> 77 + </nav> 78 + 79 + {#if loading} 80 + <p>Loading…</p> 81 + {:else if error} 82 + <p class="err">{error}</p> 83 + {:else if sortMode === 'filename'} 84 + <ul class="files"> 85 + {#each sortedByFilename as f (f.rel)} 86 + <li> 87 + <span class="name">{basename(f.rel)}</span> 88 + <span class="size">{formatSize(f.size)}</span> 89 + </li> 90 + {/each} 91 + </ul> 92 + {:else} 93 + {#each groupedByPath as g (g.dir)} 94 + {#if g.dir === ''} 95 + <ul class="files"> 96 + {#each g.files as f (f.rel)} 97 + <li> 98 + <span class="name">{f.rel}</span> 99 + <span class="size">{formatSize(f.size)}</span> 100 + </li> 101 + {/each} 102 + </ul> 103 + {:else} 104 + <button class="group-head" onclick={() => toggle(g.dir)}> 105 + <span class="chev">{collapsed[g.dir] ? '▸' : '▾'}</span> 106 + <code>{g.dir}</code> 107 + <span class="count">{g.files.length} files</span> 108 + </button> 109 + {#if !collapsed[g.dir]} 110 + <ul class="files indent"> 111 + {#each g.files as f (f.rel)} 112 + <li> 113 + <span class="name">{f.rel.slice(g.dir.length)}</span> 114 + <span class="size">{formatSize(f.size)}</span> 115 + </li> 116 + {/each} 117 + </ul> 118 + {/if} 119 + {/if} 120 + {/each} 121 + {/if} 122 + 123 + <style> 124 + .page { margin-bottom: 1rem; } 125 + .back { background: none; border: none; padding: 0; font: inherit; color: #555; cursor: pointer; } 126 + .back:hover { color: #000; } 127 + 128 + .hero { padding: 0.5rem 0 1rem; border-bottom: 1px solid #eee; } 129 + .hero .row { display: flex; align-items: baseline; gap: 0.75rem; } 130 + .hero h2 { font-size: 1.05rem; margin: 0; } 131 + .tag { font-size: 0.7rem; color: #888; background: #f3f3f3; padding: 0.05rem 0.4rem; border-radius: 3px; } 132 + .count { font-variant-numeric: tabular-nums; color: #666; font-size: 0.85rem; margin-left: auto; } 133 + .path { color: #999; font-family: ui-monospace, monospace; font-size: 0.8rem; margin-top: 0.2rem; } 134 + 135 + .sort { display: flex; gap: 1rem; align-items: center; padding: 0.6rem 0; font-size: 0.9rem; } 136 + .sort .label { color: #888; } 137 + 138 + .files { list-style: none; padding: 0; margin: 0; } 139 + .files li { 140 + display: grid; 141 + grid-template-columns: 1fr auto; 142 + gap: 1rem; 143 + padding: 0.2rem 0; 144 + font-size: 0.9rem; 145 + } 146 + .files .name { font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 147 + .files .size { color: #777; font-variant-numeric: tabular-nums; } 148 + .indent { padding-left: 1rem; } 149 + 150 + .group-head { 151 + display: flex; align-items: baseline; gap: 0.5rem; 152 + background: none; border: none; padding: 0.5rem 0 0.2rem; 153 + font: inherit; cursor: pointer; width: 100%; text-align: left; 154 + color: #444; 155 + } 156 + .group-head:hover { color: #000; } 157 + .group-head .chev { width: 1rem; color: #999; } 158 + .group-head code { font-size: 0.85rem; } 159 + .group-head .count { margin-left: 0; color: #999; font-size: 0.8rem; } 160 + 161 + .err { color: #c33; } 162 + </style>