forked from Keelrleek/SoulStream
import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the window, the picker, the audition pen, apply, and the review that stands between them. The fold is the new part. A release filed as a container of parts arrived as N albums, because the scan groups audio by its immediate parent and the container holds none of its own: Analord's eleven volumes were eleven records, and six more releases split the same way. Records now fold into the release above them, and apply is handed an explicit part list — never a recursive walk, which is what let one stray file swallow 288 descendants and duplicate 9 GB. Where releases live is read off the collection rather than assumed, so a genre-nested library moves the answer instead of breaking; below a dominant majority at one depth the pass refuses entirely, because no folding beats wrong folding. Three vetoes independent of depth catch a misread level. Every fold is listed in the review with a way out. research/import/fold-audit.mjs imports the shipping module, so the audited code is the running code: 9 folds, 0 swallows across four library shapes, and none of the 148 artist folders touched. It was that harness that killed the tempting version of this rule — matching a part against its container folded 40 artist folders, Mac Demarco's 8 albums into one, because album folders carry the artist's name. The regression test found a live bug on the way: parts number from 01 each, so flattening two discs collided, and "same name, same size" read the second file as already-imported and dropped it while still counting it. That reuse now also requires that this run has not already written the path. Also here: the picker's accent crossfades between records instead of cutting through the app's blue (ownership moved to the window, which survives the per-record re-key), the fullscreen viewer wears the album's accent instead of the one it left behind in the portal, Skip defers a record without deciding it, and Keep mine is gone — with the cards picked field by field it was Next under another name.
This commit is contained in:
1 parent
4b30c5e573
commit
e7d0971de2
23 files changed
+6498
-258
No files matched your search
+264
-203
@@ -36,6 +36,8 @@
|
||||
openCatalogAlbum,
|
||||
RECENTS_FLOOR_SECS,
|
||||
recentsStore,
|
||||
importResume,
|
||||
goToView,
|
||||
} from "../lib/state.svelte.js";
|
||||
import {
|
||||
fileRelease,
|
||||
@@ -1378,6 +1380,10 @@
|
||||
if (rescanning) return;
|
||||
rescanning = true;
|
||||
try {
|
||||
// Prune deletions + index additions on the backend FIRST — the
|
||||
// refetch below otherwise re-reads the same stale rows, which is
|
||||
// exactly what this button used to do.
|
||||
await api.rescanLibrary().catch((e) => console.warn("rescan:", e));
|
||||
await refreshLibrary();
|
||||
await loadStats();
|
||||
} finally {
|
||||
@@ -1849,212 +1855,251 @@
|
||||
<div class="lib-ambience-glow" style={highlightAccent}></div>
|
||||
{/if}
|
||||
|
||||
{#if libraryStore.albums.length === 0}
|
||||
<div class="lib-empty-lib">
|
||||
<p class="big">Your music will live here</p>
|
||||
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<header class="lib-hero">
|
||||
<div class="lib-hero-top">
|
||||
<h1 class="lib-h1">Library</h1>
|
||||
<div class="lib-tools">
|
||||
<label class="lib-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input type="text" placeholder="Filter your library" bind:value={q} />
|
||||
{#if q}
|
||||
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
|
||||
{/if}
|
||||
</label>
|
||||
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
|
||||
<Icon name="refresh" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-stats">
|
||||
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
|
||||
{#if totals.secs}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
|
||||
{/if}
|
||||
{#if totals.bytes}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
|
||||
{/if}
|
||||
{#if totals.never}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
|
||||
<div class="disco-tabs" role="tablist">
|
||||
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
|
||||
<button
|
||||
class="disco-tab"
|
||||
class:on={view === k}
|
||||
role="tab"
|
||||
aria-selected={view === k}
|
||||
onclick={() => setView(k)}
|
||||
>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="lib-controls">
|
||||
<span class="lib-ctl-lab">Group</span>
|
||||
<div class="lib-seg">
|
||||
{#each Object.entries(GROUPS) as [k, g] (k)}
|
||||
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="lib-ctl-lab">Sort</span>
|
||||
<div class="sortwrap">
|
||||
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
|
||||
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<div class="mgroup">Sort by</div>
|
||||
{#each Object.entries(SORTS) as [k, s] (k)}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
if (sortKey === k) desc = !desc;
|
||||
else { sortKey = k; desc = s.desc !== false; }
|
||||
menuOpen = false;
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
{#if sortKey === k}
|
||||
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- There is no separate "nothing here" screen. An empty library is the
|
||||
library page with nothing in it: same hero, same toolbar, same Import
|
||||
button in the place it always lives. The takeover this replaced
|
||||
REMOVED the header, so the one moment importing matters most was the
|
||||
one moment there was no way to do it. -->
|
||||
<header class="lib-hero">
|
||||
<div class="lib-hero-top">
|
||||
<h1 class="lib-h1">Library</h1>
|
||||
<div class="lib-tools">
|
||||
<label class="lib-search">
|
||||
<Icon name="search" size={15} />
|
||||
<input type="text" placeholder="Filter your library" bind:value={q} />
|
||||
{#if q}
|
||||
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
|
||||
{/if}
|
||||
</label>
|
||||
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
|
||||
<Icon name="refresh" size={15} />
|
||||
</button>
|
||||
<!-- Importing is not a first-run event: another folder or
|
||||
someone else's drive can arrive any day, so the way in
|
||||
lives here, beside the library it feeds. -->
|
||||
<button class="lib-tool"
|
||||
title={importResume.active
|
||||
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
|
||||
: "Import music from a folder or drive"}
|
||||
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
|
||||
<Icon name="plus" size={15} />
|
||||
Import
|
||||
{#if importResume.active}
|
||||
<span class="lib-import-pct">{importResume.pct}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{#if view === "list"}
|
||||
<!-- The column header is the bar's own last row: one box, one
|
||||
pin — it cannot drift from the bar or hide behind it at
|
||||
any width (handoff §4b.3, Kyle's pick). -->
|
||||
<div class="lb-tr lb-th">
|
||||
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
|
||||
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
|
||||
</div>
|
||||
<div class="lib-stats">
|
||||
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
|
||||
{#if totals.secs}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
|
||||
{/if}
|
||||
{#if totals.bytes}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
|
||||
{/if}
|
||||
{#if totals.never}
|
||||
<span class="lib-stat-div"></span>
|
||||
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
|
||||
<div class="disco-tabs" role="tablist">
|
||||
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
|
||||
<button
|
||||
class="disco-tab"
|
||||
class:on={view === k}
|
||||
role="tab"
|
||||
aria-selected={view === k}
|
||||
onclick={() => setView(k)}
|
||||
>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="lib-controls">
|
||||
<span class="lib-ctl-lab">Group</span>
|
||||
<div class="lib-seg">
|
||||
{#each Object.entries(GROUPS) as [k, g] (k)}
|
||||
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="lib-ctl-lab">Sort</span>
|
||||
<div class="sortwrap">
|
||||
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
|
||||
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div class="menu" role="menu">
|
||||
<div class="mgroup">Sort by</div>
|
||||
{#each Object.entries(SORTS) as [k, s] (k)}
|
||||
<button
|
||||
role="menuitem"
|
||||
onclick={() => {
|
||||
if (sortKey === k) desc = !desc;
|
||||
else { sortKey = k; desc = s.desc !== false; }
|
||||
menuOpen = false;
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
{#if sortKey === k}
|
||||
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
|
||||
<Icon name="chevron-down" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{#if view === "list"}
|
||||
<!-- The column header is the bar's own last row: one box, one
|
||||
pin — it cannot drift from the bar or hide behind it at
|
||||
any width (handoff §4b.3, Kyle's pick). -->
|
||||
<div class="lb-tr lb-th">
|
||||
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
|
||||
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if railItems.length}
|
||||
<div class="year-rail"><div class="yr-col">
|
||||
{#each railItems as [l, k] (k)}
|
||||
<button onclick={() => jumpTo(k)}>{l}</button>
|
||||
{/each}
|
||||
</div></div>
|
||||
{/if}
|
||||
|
||||
{#if corpus.length === 0}
|
||||
<div class="lib-empty">
|
||||
{#if q}
|
||||
<span class="icon"><Icon name="search" size={26} /></span>
|
||||
<p>Nothing matches <b>{q}</b></p>
|
||||
{:else}
|
||||
<!-- Nothing in the library at all. Both ways in sit here, but
|
||||
the toolbar's Import is still up there too — this is an
|
||||
invitation, not the only door. -->
|
||||
<span class="icon"><Icon name="library" size={26} /></span>
|
||||
<p>Your music will live here</p>
|
||||
<small>Bring in a folder you already have, or download an album from
|
||||
Search — either way it lands here, tagged with art.</small>
|
||||
<div class="lib-empty-acts">
|
||||
<button class="lib-tool primary"
|
||||
title={importResume.active
|
||||
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
|
||||
: "Import music from a folder or drive"}
|
||||
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
|
||||
<Icon name="plus" size={15} />
|
||||
{importResume.active ? "Continue importing" : "Import from a folder"}
|
||||
{#if importResume.active}
|
||||
<span class="lib-import-pct">{importResume.pct}%</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="lib-tool" onclick={() => goToView("search")}>
|
||||
<Icon name="search" size={15} />
|
||||
Find music to download
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if railItems.length}
|
||||
<div class="year-rail"><div class="yr-col">
|
||||
{#each railItems as [l, k] (k)}
|
||||
<button onclick={() => jumpTo(k)}>{l}</button>
|
||||
{/each}
|
||||
</div></div>
|
||||
{/if}
|
||||
|
||||
{#if corpus.length === 0}
|
||||
<div class="lib-empty">
|
||||
<span class="icon"><Icon name="search" size={26} /></span>
|
||||
<p>Nothing matches <b>{q}</b></p>
|
||||
</div>
|
||||
{:else if view === "shelves"}
|
||||
{#each grouped as [name, list] (name)}
|
||||
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
|
||||
<section class="lib-shelf">
|
||||
{#if name}{@render shelfHead(name, list)}{/if}
|
||||
{#if group === "artist"}
|
||||
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
|
||||
{#if label}
|
||||
<div class="lib-subhead">{label}<span>{part.length}</span></div>
|
||||
{/if}
|
||||
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
|
||||
{/each}
|
||||
{:else}
|
||||
{@render cardGrid(shown, name || "flat")}
|
||||
{/if}
|
||||
{#if list.length > shown.length}
|
||||
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
|
||||
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if group === "artist"}
|
||||
{@render gapSection(name, list)}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{:else if view === "grid"}
|
||||
{:else if view === "shelves"}
|
||||
{#each grouped as [name, list] (name)}
|
||||
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
|
||||
<section class="lib-shelf">
|
||||
{#if name}{@render shelfHead(name, list)}{/if}
|
||||
{#if group === "artist"}
|
||||
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
|
||||
{#if label}
|
||||
<div class="lib-subhead">{label}<span>{part.length}</span></div>
|
||||
{/if}
|
||||
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
|
||||
{/each}
|
||||
{:else}
|
||||
{@render cardGrid(shown, name || "flat")}
|
||||
{/if}
|
||||
{#if list.length > shown.length}
|
||||
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
|
||||
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if group === "artist"}
|
||||
{@render gapSection(name, list)}
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{:else if view === "grid"}
|
||||
{#each grouped as [name, list] (name)}
|
||||
<section class="lib-sec">
|
||||
{#if name}
|
||||
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
|
||||
{/if}
|
||||
{@render cardGrid(list, name || "flat")}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="lib-table">
|
||||
{#each grouped as [name, list] (name)}
|
||||
<section class="lib-sec">
|
||||
{#if name}
|
||||
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
|
||||
{/if}
|
||||
{@render cardGrid(list, name || "flat")}
|
||||
{#each list as a (a.id)}
|
||||
<div
|
||||
class="lb-tr"
|
||||
class:active={openId === a.id}
|
||||
style={accentFor(a)}
|
||||
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:loadCover={a}
|
||||
onpointerenter={() => (hoverId = a.id)}
|
||||
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
|
||||
onclick={() => toggleExpander(a)}
|
||||
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
|
||||
>
|
||||
<span class="lb-cell lb-thumb">
|
||||
{#if a.art_path && coverUrls[a.id]}
|
||||
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||||
{:else if !a.art_path}
|
||||
{@const g = genFor(a)}
|
||||
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
||||
{:else}
|
||||
<Icon name="disc" size={15} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="lb-cell lb-strong">{a.title}</span>
|
||||
<span class="lb-cell">{a.artist}</span>
|
||||
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
|
||||
<span class="lb-cell num">{a.year ?? "—"}</span>
|
||||
<span class="lb-cell num">{a.track_count || "—"}</span>
|
||||
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
|
||||
<span class="lb-cell">
|
||||
{#if a.formats[0]}
|
||||
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
|
||||
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
|
||||
</span>
|
||||
{:else}—{/if}
|
||||
</span>
|
||||
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
|
||||
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
|
||||
<span class="lb-cell num">{since(a.added_at)}</span>
|
||||
</div>
|
||||
{#if openId === a.id}
|
||||
{@render expander([a], "list")}
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="lib-table">
|
||||
{#each grouped as [name, list] (name)}
|
||||
<section class="lib-sec">
|
||||
{#if name}
|
||||
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
|
||||
{/if}
|
||||
{#each list as a (a.id)}
|
||||
<div
|
||||
class="lb-tr"
|
||||
class:active={openId === a.id}
|
||||
style={accentFor(a)}
|
||||
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:loadCover={a}
|
||||
onpointerenter={() => (hoverId = a.id)}
|
||||
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
|
||||
onclick={() => toggleExpander(a)}
|
||||
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
|
||||
>
|
||||
<span class="lb-cell lb-thumb">
|
||||
{#if a.art_path && coverUrls[a.id]}
|
||||
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
|
||||
{:else if !a.art_path}
|
||||
{@const g = genFor(a)}
|
||||
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
|
||||
{:else}
|
||||
<Icon name="disc" size={15} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="lb-cell lb-strong">{a.title}</span>
|
||||
<span class="lb-cell">{a.artist}</span>
|
||||
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
|
||||
<span class="lb-cell num">{a.year ?? "—"}</span>
|
||||
<span class="lb-cell num">{a.track_count || "—"}</span>
|
||||
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
|
||||
<span class="lb-cell">
|
||||
{#if a.formats[0]}
|
||||
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
|
||||
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
|
||||
</span>
|
||||
{:else}—{/if}
|
||||
</span>
|
||||
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
|
||||
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
|
||||
<span class="lb-cell num">{since(a.added_at)}</span>
|
||||
</div>
|
||||
{#if openId === a.id}
|
||||
{@render expander([a], "list")}
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2181,6 +2226,17 @@
|
||||
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
|
||||
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
|
||||
.lib-clear:hover { color: var(--text-primary); }
|
||||
/* The ongoing-import badge on the Import tool: quiet accent pill, the
|
||||
percentage the review has covered. */
|
||||
.lib-import-pct {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
color: var(--accent-hover, var(--accent));
|
||||
}
|
||||
.lib-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3327,16 +3383,21 @@
|
||||
place-items: center;
|
||||
}
|
||||
.lib-empty b { color: var(--text-primary); }
|
||||
.lib-empty-lib {
|
||||
padding: 18vh 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
.lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; }
|
||||
.lib-empty-acts {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.lib-empty-lib .big {
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
/* The one action that starts something. Same object as the toolbar's
|
||||
tools, wearing the accent — nothing new invented for an empty page. */
|
||||
.lib-tool.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
.lib-empty-lib .sub { margin: 0; font-size: 13px; }
|
||||
.lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; }
|
||||
.lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }
|
||||
</style>
|
||||
Reference in new issue
Block a user