forked from Keelrleek/SoulStream
the soulseek hub: the whole Qt client as one area, wired to the wire
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.
THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.
THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.
soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.
THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).
And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.
Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
This commit is contained in:
1 parent
913289a297
commit
63a0357651
17 files changed
+2843
-773
No files matched your search
@@ -1,343 +0,0 @@
|
||||
<script>
|
||||
import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import PlaylistMenu from "../components/PlaylistMenu.svelte";
|
||||
|
||||
const uploads = $derived(
|
||||
Object.values(network.uploads)
|
||||
.sort((a, b) => b.id - a.id)
|
||||
.slice(0, 20),
|
||||
);
|
||||
|
||||
const items = $derived(
|
||||
Object.values(downloads.items).sort((a, b) => b.id - a.id),
|
||||
);
|
||||
|
||||
const active = $derived(
|
||||
items.filter((d) => !["complete", "failed", "cancelled"].includes(d.state)),
|
||||
);
|
||||
const finished = $derived(
|
||||
items.filter((d) => ["complete", "failed", "cancelled"].includes(d.state)),
|
||||
);
|
||||
|
||||
function pct(d) {
|
||||
return d.total > 0 ? Math.min(100, (d.received / d.total) * 100) : 0;
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
|
||||
return (bytes / 1e6).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function stateLabel(d) {
|
||||
switch (d.state) {
|
||||
case "connecting": return "connecting…";
|
||||
case "requested": return "requested…";
|
||||
case "queued":
|
||||
return d.queue_place != null ? `in queue (#${d.queue_place})` : "in queue";
|
||||
case "transferring":
|
||||
return `${fmtSize(d.received)} / ${fmtSize(d.total)} · ${(d.speed_bps / 1e6).toFixed(1)} MB/s`;
|
||||
case "complete": return "done";
|
||||
case "failed": return d.error || "failed";
|
||||
case "cancelled": return "cancelled";
|
||||
default: return d.state;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="downloads">
|
||||
<h1>Downloads</h1>
|
||||
|
||||
{#if network.shareFiles > 0}
|
||||
<div class="sharing-banner">
|
||||
<span class="dot"></span>
|
||||
Sharing {network.shareFiles} files with the network
|
||||
{#if network.respondedCount > 0}
|
||||
· answered {network.respondedCount} searches
|
||||
{/if}
|
||||
{#if uploads.length > 0}
|
||||
· {uploads.filter((u) => u.state === "transferring").length} uploading
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploads.length > 0}
|
||||
<h2>Uploads</h2>
|
||||
<div class="list">
|
||||
{#each uploads as u (u.id)}
|
||||
<div class="row">
|
||||
<div class="info">
|
||||
<div class="name">{u.name}</div>
|
||||
<div class="sub">
|
||||
to {u.username} ·
|
||||
<span class:ok={u.state === "complete"} class:err={u.state === "failed"}>
|
||||
{u.state === "transferring" && u.total > 0
|
||||
? `${((u.sent / u.total) * 100).toFixed(0)}%`
|
||||
: u.state}
|
||||
</span>
|
||||
</div>
|
||||
{#if u.state === "transferring"}
|
||||
<div class="bar">
|
||||
<div class="fill up" style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if items.length === 0}
|
||||
{#if uploads.length === 0}
|
||||
<div class="empty">
|
||||
<p class="big">No downloads yet</p>
|
||||
<p class="sub">Grab something from Search — files land in your library folder.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if active.length > 0}
|
||||
<h2>Active</h2>
|
||||
<div class="list">
|
||||
{#each active as d (d.id)}
|
||||
<div class="row">
|
||||
<div class="info">
|
||||
<div class="name">{d.name}</div>
|
||||
<div class="sub">{d.username} · {stateLabel(d)}</div>
|
||||
<div class="bar">
|
||||
<div
|
||||
class="fill"
|
||||
class:indeterminate={d.state !== "transferring"}
|
||||
style="width: {d.state === 'transferring' ? pct(d) : 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="cancel" title="Cancel" onclick={() => api.cancelDownload(d.id)}>
|
||||
<Icon name="x" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if finished.length > 0}
|
||||
<h2>Finished</h2>
|
||||
<div class="list">
|
||||
{#each finished as d (d.id)}
|
||||
<div class="row done-row">
|
||||
<div class="info">
|
||||
<div class="name" class:dim={d.state !== "complete"}>{d.name}</div>
|
||||
<div class="sub">
|
||||
{d.username} ·
|
||||
<span class:ok={d.state === "complete"} class:err={d.state === "failed"}>
|
||||
{stateLabel(d)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if d.state === "complete" && d.local_path}
|
||||
<button
|
||||
class="reveal"
|
||||
title="Play"
|
||||
onclick={() =>
|
||||
playLocalFile(
|
||||
d.local_path,
|
||||
d.name.replace(/\.[^.]+$/, ""),
|
||||
d.username,
|
||||
)}
|
||||
><Icon name="play" size={15} /></button>
|
||||
<button
|
||||
class="reveal"
|
||||
title="Show in folder"
|
||||
onclick={() => api.showInFolder(d.local_path)}
|
||||
><Icon name="folder-open" size={16} /></button>
|
||||
<PlaylistMenu
|
||||
compact
|
||||
iconSize={15}
|
||||
align="right"
|
||||
tracks={() => [{
|
||||
title: d.name.replace(/\.[^.]+$/, ""),
|
||||
path: d.local_path,
|
||||
}]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 15vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.row.done-row {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.row.done-row:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name.dim {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bar {
|
||||
margin-top: 8px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: hsla(0, 0%, 100%, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
transition: width 0.25s linear;
|
||||
}
|
||||
|
||||
.fill.indeterminate {
|
||||
opacity: 0.25;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.fill.up {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sharing-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sharing-banner .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.err {
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.cancel:hover {
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
font-size: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.reveal:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row :global(.plm-trigger) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0.8;
|
||||
color: inherit;
|
||||
}
|
||||
.row :global(.plm-trigger:hover) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. An active transfer row is a card on the pane; a
|
||||
finished one is bare until hovered, and keeps that shape. */
|
||||
:global(:root.glass) .row {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .row.done-row {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
:global(:root.glass) .row.done-row:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -1,408 +0,0 @@
|
||||
<script>
|
||||
import {
|
||||
app,
|
||||
search,
|
||||
runSearch,
|
||||
downloadFile,
|
||||
downloadFolder,
|
||||
streamFile,
|
||||
filesForGroup,
|
||||
} from "../lib/state.svelte.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let queuedKeys = $state({});
|
||||
|
||||
// Seeded from the store so "get on soulseek" hand-offs show their query.
|
||||
let query = $state(search.query);
|
||||
let expanded = $state({});
|
||||
|
||||
const groups = $derived(
|
||||
Object.values(search.groups).sort(
|
||||
(a, b) => (b.hasFreeSlot - a.hasFreeSlot) || (b.speed - a.speed),
|
||||
),
|
||||
);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
if (app.connection !== "online") {
|
||||
app.showLogin = true;
|
||||
return;
|
||||
}
|
||||
expanded = {};
|
||||
try {
|
||||
await runSearch(q);
|
||||
} catch (err) {
|
||||
app.loginError = String(err);
|
||||
app.showLogin = true;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSize(bytes) {
|
||||
if (bytes >= 1e9) return (bytes / 1e9).toFixed(2) + " GB";
|
||||
return (bytes / 1e6).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function fmtDuration(s) {
|
||||
if (s == null) return "";
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtSpeed(bps) {
|
||||
if (!bps) return "";
|
||||
return (bps / 1e6).toFixed(1) + " MB/s";
|
||||
}
|
||||
|
||||
// "FLAC", "MP3 320"… from the group's incrementally-kept summary fields.
|
||||
function quality(g) {
|
||||
if (g.topExt === "flac") return "FLAC";
|
||||
const label = g.topExt.toUpperCase();
|
||||
return g.maxBitrate ? `${label} ${g.maxBitrate}` : label;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search">
|
||||
<form onsubmit={submit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What do you want to listen to?"
|
||||
bind:value={query}
|
||||
spellcheck="false"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{#if search.token == null}
|
||||
<div class="empty">
|
||||
<p class="big">Search the Soulseek network</p>
|
||||
<p class="sub">
|
||||
{app.connection === "online"
|
||||
? `Connected as ${app.username} — search for an artist or album.`
|
||||
: "You'll be asked to log in on your first search."}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="meta">
|
||||
<span>
|
||||
{groups.length} folders · {search.responses} users
|
||||
{#if search.searching}<span class="pulse">· searching…</span>{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
{#each groups.slice(0, 120) as g (g.key)}
|
||||
<div class="group">
|
||||
<button
|
||||
class="group-head"
|
||||
onclick={() => (expanded[g.key] = !expanded[g.key])}
|
||||
>
|
||||
<div class="art-mini"><Icon name="music" size={20} /></div>
|
||||
<div class="ginfo">
|
||||
<div class="gtitle" title={g.folder}>{g.folderName}</div>
|
||||
<div class="gsub">
|
||||
{g.username} · {g.audioCount} tracks · {fmtSize(g.totalSize)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="badges">
|
||||
{#if quality(g)}
|
||||
<span class="badge quality">{quality(g)}</span>
|
||||
{/if}
|
||||
{#if g.hasFreeSlot}
|
||||
<span class="badge slot">open slot</span>
|
||||
{:else}
|
||||
<span class="badge queue">queue {g.queue}</span>
|
||||
{/if}
|
||||
{#if g.speed}
|
||||
<span class="badge">{fmtSpeed(g.speed)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="dl-all"
|
||||
class:queued={queuedKeys[g.key]}
|
||||
title="Download folder"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (queuedKeys[g.key]) return;
|
||||
queuedKeys[g.key] = true;
|
||||
downloadFolder(g);
|
||||
}}
|
||||
onkeydown={(e) => e.key === "Enter" && e.currentTarget.click()}
|
||||
>
|
||||
{#if queuedKeys[g.key]}
|
||||
<Icon name="check" size={13} /> queued
|
||||
{:else}
|
||||
<Icon name="download" size={13} /> all
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded[g.key]}
|
||||
<div class="files">
|
||||
<!-- g.fileCount in the key keeps this list refreshing
|
||||
as new (non-reactive) files arrive for the group -->
|
||||
{#each filesForGroup(g.key, g.fileCount) as f (f.path)}
|
||||
<div class="file">
|
||||
<span class="fname" title={f.name}>{f.name}</span>
|
||||
<span class="fmeta">
|
||||
{f.bitrate ? `${f.bitrate} kbps` : ""}
|
||||
{f.duration != null ? " · " + fmtDuration(f.duration) : ""}
|
||||
· {fmtSize(f.size)}
|
||||
</span>
|
||||
<button
|
||||
class="mini live"
|
||||
title="Play (streams while downloading)"
|
||||
onclick={() => streamFile(g.username, f, g.folderName)}
|
||||
><Icon name="play" size={13} /></button>
|
||||
<button
|
||||
class="mini live"
|
||||
title="Download"
|
||||
onclick={() => {
|
||||
queuedKeys[f.path] = true;
|
||||
downloadFile(g.username, f);
|
||||
}}
|
||||
>
|
||||
<Icon name={queuedKeys[f.path] ? "check" : "download"} size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: min(100%, 420px);
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
box-shadow: 0 0 0 2px var(--text-primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 15vh;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.big {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.group {
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-head:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.art-mini {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ginfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gtitle {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gsub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.quality {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.badge.slot {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.badge.queue {
|
||||
color: #e8a33d;
|
||||
}
|
||||
|
||||
.files {
|
||||
padding: 2px 10px 8px 66px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.fname {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fmeta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mini {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mini.live {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mini.live:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.dl-all {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dl-all:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.dl-all.queued {
|
||||
background: var(--bg-highlight);
|
||||
color: var(--accent-hover);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── dark glass ────────────────────────────────────────────────────────
|
||||
Ladder in global.css. The query field is the page's whole first
|
||||
impression and was a 420px opaque grey lozenge on the frost. */
|
||||
:global(:root.glass) input {
|
||||
background: var(--glass-well);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
/* The focus ring is a 2px --text-primary halo in both modes and reads
|
||||
fine against the well; left alone deliberately. */
|
||||
|
||||
:global(:root.glass) .group-head:hover,
|
||||
:global(:root.glass) .file:hover {
|
||||
background: var(--glass-hover);
|
||||
}
|
||||
:global(:root.glass) .badge,
|
||||
:global(:root.glass) .dl-all.queued {
|
||||
background: var(--glass-surface);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .art-mini {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.11));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user