Baseline: full tree snapshot after git repository loss

The previous .git directory was found gutted (only info/ remained), so
history is unrecoverable locally. This commit restores version control
with the tree exactly as it stands: the working app, the preview-tracks
launcher on the artist hero, the scrollable-track-previews design bundle
(docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
<script>
import {
catalog,
libraryStore,
streamSong,
saveSong,
streamCatalogAlbum,
saveCatalogAlbum,
resolver,
player,
openArtistByName,
artBroken,
coverUpgrades,
resolveCovers,
coverLoadError,
} from "../lib/state.svelte.js";
import Icon from "../components/Icon.svelte";
const page = $derived(catalog.albumPage); // { album, tracks }
const album = $derived(page?.album ?? null);
// Upgrade this album's cover to the highest verified-same-artwork source.
let coveredId = null;
$effect(() => {
if (album?.cover && album.id !== coveredId) {
coveredId = album.id;
resolveCovers([album], album.artist);
}
});
const nowPlaying = (kind, id) =>
player.status !== "idle" && player.catalogRef[kind] === id;
function handleCoverError(node) {
const fallback = coverLoadError(
album?.cover,
node?.currentSrc || node?.src || null,
album?.artist ?? "",
album?.title ?? "",
);
if (fallback && node?.src !== fallback) node.src = fallback;
}
const norm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const inLibrary = $derived.by(() => {
if (!album) return false;
const a = norm(album.artist);
const t = norm(album.title);
return libraryStore.albums.some((x) => norm(x.artist) === a && norm(x.title) === t);
});
// An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
// Multi-disc releases (e.g. anniversary box sets) get "Disc N" headers, and
// each disc's tracks are numbered from 1 (Deezer's per-disc track_position).
const multiDisc = $derived(
new Set((page?.tracks ?? []).map((t) => t.disk_number ?? 1)).size > 1,
);
let saved = $state({});
function flagSaveError(key) {
saved[key] = "err";
setTimeout(() => saved[key] === "err" && (saved[key] = ""), 3000);
}
async function saveAlbumAction() {
if (!album || saved.album) return;
saved.album = "…";
if (await saveCatalogAlbum(album)) saved.album = "ok";
else flagSaveError("album");
}
async function saveTrackAction(t) {
if (saved[`t${t.id}`]) return;
saved[`t${t.id}`] = "…";
if (await saveSong(album.artist, t.title)) saved[`t${t.id}`] = "ok";
else flagSaveError(`t${t.id}`);
}
const playTrack = (t) =>
streamSong(album.artist, t.title, {
cover: t.cover ?? album.cover,
trackId: t.id,
albumId: album.id,
album: album.title || null,
expectSecs: t.duration_secs ?? null,
});
function year(a) {
return a?.release_date?.slice(0, 4) ?? "";
}
function fmtDuration(s) {
if (s == null) return "";
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
// Scroll to (and briefly flash) the track the now-playing song link targets.
let flashId = $state(null);
$effect(() => {
const tid = catalog.pendingTrackId;
if (tid == null || !page) return;
requestAnimationFrame(() => {
const el = document.getElementById(`catrack-${tid}`);
if (el) el.scrollIntoView({ block: "center", behavior: "smooth" });
});
flashId = tid;
const t = setTimeout(() => (flashId === tid) && (flashId = null), 1800);
return () => clearTimeout(t);
});
</script>
<div class="album-page">
<button class="back" onclick={() => openArtistByName(album?.artist)}>
<Icon name="chevron-left" size={16} /> {album?.artist ?? "Back"}
</button>
{#if resolver.active}
<p class="resolving"><span class="pulse">●</span> {resolver.label} — finding the best source…</p>
{:else if resolver.error}
<p class="resolving error">{resolver.error}</p>
{/if}
{#if catalog.albumLoading}
<p class="hint">Loading album…</p>
{:else if !page}
<p class="hint">{catalog.error || "Album not found."}</p>
{:else}
<header class="hero">
{#if album.cover && !artBroken[album.cover]}
<img class="cover" src={coverUpgrades[album.cover] ?? album.cover} alt="" draggable="false" onerror={(event) => handleCoverError(event.currentTarget)} />
{:else}
<div class="cover placeholder"><Icon name="disc" size={40} /></div>
{/if}
<div class="hero-info">
<p class="kind">{(album.record_type ?? "album").toUpperCase()}</p>
<h1>{album.title}</h1>
<p class="meta">
<button class="artist-link" onclick={() => openArtistByName(album.artist)}>{album.artist}</button>
{#if year(album)}· {year(album)}{/if}
{#if album.track_count}· {album.track_count} tracks{/if}
{#if inLibrary}· <span class="owned">in your library</span>{/if}
</p>
<div class="actions">
<button
class="pill"
disabled={hasUnreleased}
title={hasUnreleased ? "Not released yet" : ""}
onclick={() => streamCatalogAlbum(album)}
>▶ Play</button>
<button class="pill ghost" disabled={hasUnreleased} onclick={saveAlbumAction}>
{saved.album === "ok" ? "✓ Saved" : saved.album === "err" ? "✗ No sources" : "⤓ Save"}
</button>
</div>
</div>
</header>
<div class="tracklist">
{#each page.tracks as t, i (t.id)}
{#if multiDisc && t.disk_number !== (page.tracks[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
{/if}
{@const locked = t.unreleased}
{@const play = () => { if (!locked) playTrack(t); }}
<div
id="catrack-{t.id}"
class="trow"
class:now={nowPlaying("trackId", t.id)}
class:flash={flashId === t.id}
class:locked
role="button"
tabindex="0"
title={locked ? "Not released yet" : "Play — streams from the best source"}
onclick={play}
onkeydown={(e) => e.key === "Enter" && play()}
>
<span class="tno">{nowPlaying("trackId", t.id) ? "♫" : (t.track_no ?? "·")}</span>
<span class="ttitle">{t.title}</span>
{#if locked}
<span class="tag-unreleased">Unreleased</span>
{:else}
<span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if}
<button class="mini-btn play" disabled={locked} title={locked ? "Not released yet" : "Play"} onclick={(e) => { e.stopPropagation(); play(); }}>
<Icon name="play" size={13} />
</button>
<button class="mini-get" disabled={locked} title={locked ? "Not released yet" : "Save to library"} onclick={(e) => { e.stopPropagation(); saveTrackAction(t); }}>
<Icon name={saved[`t${t.id}`] === "ok" ? "check" : saved[`t${t.id}`] === "err" ? "x" : "download"} size={13} />
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.hint {
color: var(--text-secondary);
padding-top: 10vh;
text-align: center;
}
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-weight: 600;
margin-bottom: 16px;
}
.back:hover { color: var(--text-primary); }
.hero {
display: flex;
align-items: flex-end;
gap: 20px;
background: var(--bg-elevated);
border-radius: 10px;
padding: 20px;
}
.cover {
width: 150px;
height: 150px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.7);
}
.placeholder {
display: grid;
place-items: center;
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
color: var(--text-muted);
}
.hero-info { min-width: 0; }
.kind {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
h1 {
font-size: 30px;
font-weight: 800;
margin: 4px 0 6px;
}
.meta {
color: var(--text-secondary);
font-size: 12px;
}
.artist-link {
color: var(--text-primary);
font-weight: 600;
}
.artist-link:hover { text-decoration: underline; }
.owned { color: var(--accent-hover); }
.actions {
display: flex;
gap: 8px;
margin-top: 14px;
}
.pill {
background: var(--accent);
color: #000;
font-size: 11px;
font-weight: 700;
padding: 7px 16px;
border-radius: 14px;
white-space: nowrap;
}
.pill:hover { background: var(--accent-hover); }
.pill.ghost {
background: var(--bg-highlight);
color: var(--text-primary);
}
.pill.ghost:hover { background: var(--bg-press, #333); }
.pill:disabled {
background: var(--bg-highlight);
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.pill:disabled:hover { background: var(--bg-highlight); }
.tracklist {
margin-top: 18px;
display: flex;
flex-direction: column;
}
.disc-head {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
padding: 14px 8px 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 4px;
}
.disc-head:first-child { padding-top: 4px; }
.trow {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 8px;
border-radius: 5px;
cursor: pointer;
}
.trow:hover { background: var(--bg-highlight); }
.trow.now .ttitle,
.trow.now .tno { color: var(--accent-hover); }
.trow.locked { cursor: default; }
.trow.locked:hover { background: transparent; }
.trow.locked .ttitle { color: var(--text-muted); }
.tag-unreleased {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
border: 1px solid var(--border, #3a3a3a);
border-radius: 4px;
padding: 1px 6px;
flex: 0 0 auto;
}
.mini-btn:disabled,
.mini-get:disabled {
color: var(--text-muted);
opacity: 0.4;
cursor: default;
}
.mini-btn:disabled:hover,
.mini-get:disabled:hover { color: var(--text-muted); }
.trow.flash { animation: flash 1.8s ease; }
@keyframes flash {
0%, 30% { background: color-mix(in srgb, var(--accent) 22%, transparent); }
100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
.trow.flash { animation: none; }
}
.tno {
width: 22px;
text-align: right;
color: var(--text-muted);
font-size: 12px;
flex: 0 0 auto;
}
.ttitle {
flex: 1;
min-width: 0;
font-size: 13.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tdur {
color: var(--text-muted);
font-size: 11px;
flex: 0 0 auto;
}
.mini-btn.play { color: var(--accent-hover); }
.mini-btn,
.mini-get { color: var(--text-secondary); flex: 0 0 auto; }
.mini-btn:hover,
.mini-get:hover { color: var(--accent-hover); }
.resolving {
color: var(--text-secondary);
font-size: 12px;
margin-bottom: 12px;
}
.resolving .pulse { color: var(--accent); }
.resolving.error { color: #f15e6c; }
</style>