text representation and disambiguation to releases

This commit is contained in:
Gabe Farrell 2026-04-10 10:18:19 -04:00
parent a937f4a38e
commit b910e32d6c
3 changed files with 552 additions and 391 deletions

View file

@ -30,6 +30,10 @@ type mbRelease struct {
Title string `json:"title"` Title string `json:"title"`
Date string `json:"date"` Date string `json:"date"`
Country string `json:"country"` Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
TextRepresentation struct {
Language string `json:"language"`
} `json:"text-representation"`
Media []mbMedia `json:"media"` Media []mbMedia `json:"media"`
ArtistCredit []mbArtistCredit `json:"artist-credit"` ArtistCredit []mbArtistCredit `json:"artist-credit"`
ReleaseGroup struct { ReleaseGroup struct {

View file

@ -1,9 +1,9 @@
'use strict'; "use strict";
// IDs of fetch cards we've already created, so we don't duplicate them. // IDs of fetch cards we've already created, so we don't duplicate them.
const knownFetchIds = new Set(); const knownFetchIds = new Set();
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
initTabs(); initTabs();
initSearch(); initSearch();
initFetchList(); initFetchList();
@ -12,105 +12,124 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Tabs ─────────────────────────────────────────────────────────────────────── // ── Tabs ───────────────────────────────────────────────────────────────────────
function initTabs() { function initTabs() {
document.querySelector('.tabs').addEventListener('click', e => { document.querySelector(".tabs").addEventListener("click", (e) => {
const btn = e.target.closest('.tab-btn'); const btn = e.target.closest(".tab-btn");
if (!btn) return; if (!btn) return;
showTab(btn.dataset.tab); showTab(btn.dataset.tab);
}); });
} }
function showTab(name) { function showTab(name) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); document
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); .querySelectorAll(".tab-pane")
document.getElementById('tab-' + name).classList.add('active'); .forEach((p) => p.classList.remove("active"));
document.querySelector(`.tab-btn[data-tab="${name}"]`).classList.add('active'); document
.querySelectorAll(".tab-btn")
.forEach((b) => b.classList.remove("active"));
document.getElementById("tab-" + name).classList.add("active");
document
.querySelector(`.tab-btn[data-tab="${name}"]`)
.classList.add("active");
} }
// ── Search ───────────────────────────────────────────────────────────────────── // ── Search ─────────────────────────────────────────────────────────────────────
let searchType = 'release'; let searchType = "release";
function initSearch() { function initSearch() {
document.querySelector('.type-toggle').addEventListener('click', e => { document.querySelector(".type-toggle").addEventListener("click", (e) => {
const btn = e.target.closest('.type-btn'); const btn = e.target.closest(".type-btn");
if (btn) setSearchType(btn.dataset.type); if (btn) setSearchType(btn.dataset.type);
}); });
const searchBtn = document.getElementById('search-btn'); const searchBtn = document.getElementById("search-btn");
const searchInput = document.getElementById('search-q'); const searchInput = document.getElementById("search-q");
searchBtn.addEventListener('click', doSearch); searchBtn.addEventListener("click", doSearch);
searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") doSearch();
});
// Event delegation for dynamically rendered result buttons // Event delegation for dynamically rendered result buttons
document.getElementById('search-results').addEventListener('click', e => { document.getElementById("search-results").addEventListener("click", (e) => {
const btn = e.target.closest('.fetch-btn'); const btn = e.target.closest(".fetch-btn");
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
if (btn.dataset.fetchType === 'artist') startArtistFetch(btn); if (btn.dataset.fetchType === "artist") startArtistFetch(btn);
else startReleaseFetch(btn); else startReleaseFetch(btn);
}); });
} }
function setSearchType(type) { function setSearchType(type) {
searchType = type; searchType = type;
document.querySelectorAll('.type-btn').forEach(b => { document.querySelectorAll(".type-btn").forEach((b) => {
b.classList.toggle('active', b.dataset.type === type); b.classList.toggle("active", b.dataset.type === type);
}); });
} }
function doSearch() { function doSearch() {
const q = document.getElementById('search-q').value.trim(); const q = document.getElementById("search-q").value.trim();
if (!q) return; if (!q) return;
const btn = document.getElementById('search-btn'); const btn = document.getElementById("search-btn");
const resultsEl = document.getElementById('search-results'); const resultsEl = document.getElementById("search-results");
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Searching\u2026'; btn.textContent = "Searching\u2026";
resultsEl.innerHTML = '<p class="search-msg">Searching MusicBrainz\u2026</p>'; resultsEl.innerHTML = '<p class="search-msg">Searching MusicBrainz\u2026</p>';
fetch(`/discover/search?q=${encodeURIComponent(q)}&type=${searchType}`) fetch(`/discover/search?q=${encodeURIComponent(q)}&type=${searchType}`)
.then(r => { .then((r) => {
if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); if (!r.ok)
return r.text().then((t) => {
throw new Error(t || r.statusText);
});
return r.json(); return r.json();
}) })
.then(data => renderResults(data)) .then((data) => renderResults(data))
.catch(err => { .catch((err) => {
resultsEl.innerHTML = `<p class="search-msg error">Error: ${esc(err.message)}</p>`; resultsEl.innerHTML = `<p class="search-msg error">Error: ${esc(err.message)}</p>`;
}) })
.finally(() => { .finally(() => {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Search'; btn.textContent = "Search";
}); });
} }
// ── Results rendering ────────────────────────────────────────────────────────── // ── Results rendering ──────────────────────────────────────────────────────────
function renderResults(data) { function renderResults(data) {
const el = document.getElementById('search-results'); const el = document.getElementById("search-results");
if (!data || data.length === 0) { if (!data || data.length === 0) {
el.innerHTML = '<p class="search-msg">No results found.</p>'; el.innerHTML = '<p class="search-msg">No results found.</p>';
return; return;
} }
const renderer = searchType === 'artist' ? renderArtist : renderRelease; const renderer = searchType === "artist" ? renderArtist : renderRelease;
el.innerHTML = data.map(renderer).join(''); el.innerHTML = data.map(renderer).join("");
} }
function renderRelease(r) { function renderRelease(r) {
const credits = r['artist-credit'] ?? []; const credits = r["artist-credit"] ?? [];
const artist = credits.map(c => c.name || c.artist?.name || '').join('') || 'Unknown Artist'; const artist =
const year = r.date?.substring(0, 4) ?? ''; credits.map((c) => c.name || c.artist?.name || "").join("") ||
const type = r['release-group']?.['primary-type'] ?? ''; "Unknown Artist";
const country = r.country ?? ''; const year = r.date?.substring(0, 4) ?? "";
const formats = [...new Set((r.media ?? []).map(m => m.format).filter(Boolean))].join('+'); const type = r["release-group"]?.["primary-type"] ?? "";
const meta = [year, type, formats, country].filter(Boolean).join(' \u00b7 '); const country = r.country ?? "";
const formats = [
...new Set((r.media ?? []).map((m) => m.format).filter(Boolean)),
].join("+");
const lang = r["text-representation"]?.language ?? "";
const meta = [year, type, formats, country, lang]
.filter(Boolean)
.join(" \u00b7 ");
const dis = r.disambiguation ? ` (${esc(r.disambiguation)})` : "";
const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`; const coverUrl = `https://coverartarchive.org/release/${r.id}/front-250`;
return ` return `
<div class="result-row"> <div class="result-row">
<img class="result-cover" src="${coverUrl}" onerror="this.style.display='none'" loading="lazy" alt=""> <img class="result-cover" src="${coverUrl}" onerror="this.style.display='none'" loading="lazy" alt="">
<div class="result-info"> <div class="result-info">
<span class="result-title">${esc(artist)} \u2014 ${esc(r.title)}</span> <span class="result-title">${esc(artist)} \u2014 ${esc(r.title)}<span class="result-dis">${dis}</span></span>
${meta ? `<span class="result-meta">${esc(meta)}</span>` : ''} ${meta ? `<span class="result-meta">${esc(meta)}</span>` : ""}
</div> </div>
<button class="fetch-btn" <button class="fetch-btn"
data-fetch-type="release" data-fetch-type="release"
@ -121,12 +140,12 @@ function renderRelease(r) {
} }
function renderArtist(a) { function renderArtist(a) {
const dis = a.disambiguation ? ` (${esc(a.disambiguation)})` : ''; const dis = a.disambiguation ? ` (${esc(a.disambiguation)})` : "";
return ` return `
<div class="result-row"> <div class="result-row">
<div class="result-info"> <div class="result-info">
<span class="result-title">${esc(a.name)}${dis}</span> <span class="result-title">${esc(a.name)}${dis}</span>
${a.country ? `<span class="result-meta">${esc(a.country)}</span>` : ''} ${a.country ? `<span class="result-meta">${esc(a.country)}</span>` : ""}
</div> </div>
<button class="fetch-btn" <button class="fetch-btn"
data-fetch-type="artist" data-fetch-type="artist"
@ -140,24 +159,27 @@ function renderArtist(a) {
function startReleaseFetch(btn) { function startReleaseFetch(btn) {
const { id, artist, album } = btn.dataset; const { id, artist, album } = btn.dataset;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Fetching\u2026'; btn.textContent = "Fetching\u2026";
fetch('/discover/fetch', { fetch("/discover/fetch", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, artist, album }), body: JSON.stringify({ id, artist, album }),
}) })
.then(r => { .then((r) => {
if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); if (!r.ok)
return r.text().then((t) => {
throw new Error(t || r.statusText);
});
return r.json(); return r.json();
}) })
.then(() => { .then(() => {
addFetchCard(id, `${artist} \u2014 ${album}`); addFetchCard(id, `${artist} \u2014 ${album}`);
pollFetch(id); pollFetch(id);
}) })
.catch(err => { .catch((err) => {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Fetch'; btn.textContent = "Fetch";
showFetchError(err.message); showFetchError(err.message);
}); });
} }
@ -165,24 +187,27 @@ function startReleaseFetch(btn) {
function startArtistFetch(btn) { function startArtistFetch(btn) {
const { id, name } = btn.dataset; const { id, name } = btn.dataset;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Fetching\u2026'; btn.textContent = "Fetching\u2026";
fetch('/discover/fetch/artist', { fetch("/discover/fetch/artist", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name }), body: JSON.stringify({ id, name }),
}) })
.then(r => { .then((r) => {
if (!r.ok) return r.text().then(t => { throw new Error(t || r.statusText); }); if (!r.ok)
return r.text().then((t) => {
throw new Error(t || r.statusText);
});
return r.json(); return r.json();
}) })
.then(() => { .then(() => {
addFetchCard(id, `${name} \u2014 full discography`); addFetchCard(id, `${name} \u2014 full discography`);
pollFetch(id); pollFetch(id);
}) })
.catch(err => { .catch((err) => {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Fetch All'; btn.textContent = "Fetch All";
showFetchError(err.message); showFetchError(err.message);
}); });
} }
@ -191,9 +216,9 @@ function startArtistFetch(btn) {
function addFetchCard(id, title) { function addFetchCard(id, title) {
knownFetchIds.add(id); knownFetchIds.add(id);
const list = document.getElementById('fetch-list'); const list = document.getElementById("fetch-list");
const card = document.createElement('div'); const card = document.createElement("div");
card.className = 'fetch-card'; card.className = "fetch-card";
card.id = `fetch-${id}`; card.id = `fetch-${id}`;
card.innerHTML = ` card.innerHTML = `
<div class="fetch-header"> <div class="fetch-header">
@ -206,28 +231,28 @@ function addFetchCard(id, title) {
function pollFetch(id) { function pollFetch(id) {
fetch(`/discover/fetch/status?id=${encodeURIComponent(id)}`) fetch(`/discover/fetch/status?id=${encodeURIComponent(id)}`)
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
const logEl = document.getElementById(`flog-${id}`); const logEl = document.getElementById(`flog-${id}`);
const statusEl = document.getElementById(`fstatus-${id}`); const statusEl = document.getElementById(`fstatus-${id}`);
const card = document.getElementById(`fetch-${id}`); const card = document.getElementById(`fetch-${id}`);
if (logEl && data.log) { if (logEl && data.log) {
logEl.innerHTML = data.log logEl.innerHTML = data.log
.map(l => `<div class="log-line">${esc(l)}</div>`) .map((l) => `<div class="log-line">${esc(l)}</div>`)
.join(''); .join("");
logEl.scrollTop = logEl.scrollHeight; logEl.scrollTop = logEl.scrollHeight;
} }
if (data.done) { if (data.done) {
if (data.success) { if (data.success) {
statusEl?.setAttribute('class', 'fetch-status fetch-status-ok'); statusEl?.setAttribute("class", "fetch-status fetch-status-ok");
if (statusEl) statusEl.textContent = '\u2713 done'; if (statusEl) statusEl.textContent = "\u2713 done";
card?.classList.add('fetch-card-ok'); card?.classList.add("fetch-card-ok");
} else { } else {
statusEl?.setAttribute('class', 'fetch-status fetch-status-err'); statusEl?.setAttribute("class", "fetch-status fetch-status-err");
if (statusEl) statusEl.textContent = '\u2717 failed'; if (statusEl) statusEl.textContent = "\u2717 failed";
card?.classList.add('fetch-card-err'); card?.classList.add("fetch-card-err");
if (data.error && logEl) { if (data.error && logEl) {
logEl.innerHTML += `<div class="log-line log-line-err">${esc(data.error)}</div>`; logEl.innerHTML += `<div class="log-line log-line-err">${esc(data.error)}</div>`;
logEl.scrollTop = logEl.scrollHeight; logEl.scrollTop = logEl.scrollHeight;
@ -249,9 +274,9 @@ function initFetchList() {
} }
function pollFetchList() { function pollFetchList() {
fetch('/discover/fetch/list') fetch("/discover/fetch/list")
.then(r => r.ok ? r.json() : null) .then((r) => (r.ok ? r.json() : null))
.then(items => { .then((items) => {
if (!items) return; if (!items) return;
for (const item of items) { for (const item of items) {
if (!knownFetchIds.has(item.id)) { if (!knownFetchIds.has(item.id)) {
@ -268,9 +293,9 @@ function pollFetchList() {
// ── Utilities ────────────────────────────────────────────────────────────────── // ── Utilities ──────────────────────────────────────────────────────────────────
function showFetchError(msg) { function showFetchError(msg) {
const list = document.getElementById('fetch-list'); const list = document.getElementById("fetch-list");
const el = document.createElement('div'); const el = document.createElement("div");
el.className = 'fetch-card fetch-card-err'; el.className = "fetch-card fetch-card-err";
el.innerHTML = `<div class="fetch-header"> el.innerHTML = `<div class="fetch-header">
<span class="fetch-title">Fetch failed</span> <span class="fetch-title">Fetch failed</span>
<span class="fetch-status fetch-status-err">\u2717 error</span> <span class="fetch-status fetch-status-err">\u2717 error</span>
@ -280,9 +305,9 @@ function showFetchError(msg) {
} }
function esc(s) { function esc(s) {
return String(s ?? '') return String(s ?? "")
.replace(/&/g, '&amp;') .replace(/&/g, "&amp;")
.replace(/</g, '&lt;') .replace(/</g, "&lt;")
.replace(/>/g, '&gt;') .replace(/>/g, "&gt;")
.replace(/"/g, '&quot;'); .replace(/"/g, "&quot;");
} }

View file

@ -37,10 +37,17 @@
/* ── Reset & base ─────────────────────────────────────────────────────────── */ /* ── Reset & base ─────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; } *,
*::before,
*::after {
box-sizing: border-box;
}
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family:
system-ui,
-apple-system,
sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
margin: 0; margin: 0;
@ -74,15 +81,21 @@ h1 {
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
transition: background 0.15s, color 0.15s; transition:
background 0.15s,
color 0.15s;
} }
.tab-btn.active { .tab-btn.active {
background: var(--surface-hi); background: var(--surface-hi);
color: var(--text); color: var(--text);
} }
.tab-pane { display: none; } .tab-pane {
.tab-pane.active { display: block; } display: none;
}
.tab-pane.active {
display: block;
}
/* ── Shared card / content container ─────────────────────────────────────── */ /* ── Shared card / content container ─────────────────────────────────────── */
@ -104,7 +117,9 @@ h1 {
color: #fff; color: #fff;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.run-btn:hover:not(:disabled) { opacity: 0.88; } .run-btn:hover:not(:disabled) {
opacity: 0.88;
}
.run-btn:disabled { .run-btn:disabled {
background: #555; background: #555;
cursor: not-allowed; cursor: not-allowed;
@ -112,7 +127,9 @@ h1 {
/* ── Import tab — session summary ────────────────────────────────────────── */ /* ── Import tab — session summary ────────────────────────────────────────── */
.session { margin-top: 48px; } .session {
margin-top: 48px;
}
.session-header { .session-header {
display: flex; display: flex;
@ -124,8 +141,15 @@ h1 {
padding-bottom: 8px; padding-bottom: 8px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.session-header h2 { margin: 0; font-size: 16px; color: var(--text-secondary); } .session-header h2 {
.session-header .duration { font-size: 13px; color: var(--text-dim); } margin: 0;
font-size: 16px;
color: var(--text-secondary);
}
.session-header .duration {
font-size: 13px;
color: var(--text-dim);
}
/* ── Album card ───────────────────────────────────────────────────────────── */ /* ── Album card ───────────────────────────────────────────────────────────── */
@ -162,9 +186,18 @@ h1 {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
.badge-ok { background: var(--green-bg); color: var(--green); } .badge-ok {
.badge-warn { background: var(--amber-bg); color: var(--amber); } background: var(--green-bg);
.badge-fatal { background: var(--red-bg); color: var(--red); } color: var(--green);
}
.badge-warn {
background: var(--amber-bg);
color: var(--amber);
}
.badge-fatal {
background: var(--red-bg);
color: var(--red);
}
/* ── Metadata row ─────────────────────────────────────────────────────────── */ /* ── Metadata row ─────────────────────────────────────────────────────────── */
@ -177,7 +210,10 @@ h1 {
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 12px; margin-bottom: 12px;
} }
.metadata-title { color: var(--text-secondary); font-size: 13px; } .metadata-title {
color: var(--text-secondary);
font-size: 13px;
}
.metadata-pill { .metadata-pill {
display: inline-flex; display: inline-flex;
@ -188,11 +224,21 @@ h1 {
padding: 2px 7px; padding: 2px 7px;
font-size: 11px; font-size: 11px;
} }
.pill-label { color: var(--text-dim); } .pill-label {
.pill-beets { color: var(--pill-beets); } color: var(--text-dim);
.pill-musicbrainz { color: var(--pill-mb); } }
.pill-file_tags { color: var(--pill-tags); } .pill-beets {
.pill-unknown { color: #888; } color: var(--pill-beets);
}
.pill-musicbrainz {
color: var(--pill-mb);
}
.pill-file_tags {
color: var(--pill-tags);
}
.pill-unknown {
color: #888;
}
/* ── Info grid ────────────────────────────────────────────────────────────── */ /* ── Info grid ────────────────────────────────────────────────────────────── */
@ -215,12 +261,27 @@ h1 {
color: var(--text-dim); color: var(--text-dim);
margin-bottom: 4px; margin-bottom: 4px;
} }
.info-card-value { color: var(--text-secondary); font-size: 13px; font-weight: 600; } .info-card-value {
.info-card-sub { margin-top: 3px; color: var(--text-dim); font-size: 11px; line-height: 1.4; } color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
}
.info-card-sub {
margin-top: 3px;
color: var(--text-dim);
font-size: 11px;
line-height: 1.4;
}
.info-ok { color: var(--green); } .info-ok {
.info-warn { color: var(--amber); } color: var(--green);
.info-dim { color: var(--text-dim); } }
.info-warn {
color: var(--amber);
}
.info-dim {
color: var(--text-dim);
}
/* ── Pipeline steps ───────────────────────────────────────────────────────── */ /* ── Pipeline steps ───────────────────────────────────────────────────────── */
@ -245,11 +306,24 @@ h1 {
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
} }
.step-label { color: #888; } .step-label {
.step-ok { color: var(--green); } color: #888;
.step-warn { color: var(--amber); } }
.step-fatal { color: var(--red); } .step-ok {
.step-err { font-size: 11px; color: var(--red-text); margin-top: 2px; word-break: break-word; } color: var(--green);
}
.step-warn {
color: var(--amber);
}
.step-fatal {
color: var(--red);
}
.step-err {
font-size: 11px;
color: var(--red-text);
margin-top: 2px;
word-break: break-word;
}
/* ── Discover tab — search form ───────────────────────────────────────────── */ /* ── Discover tab — search form ───────────────────────────────────────────── */
@ -274,7 +348,9 @@ h1 {
background: var(--surface); background: var(--surface);
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s; transition:
background 0.15s,
color 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.type-btn.active { .type-btn.active {
@ -295,7 +371,9 @@ h1 {
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.search-input:focus { border-color: var(--border-focus); } .search-input:focus {
border-color: var(--border-focus);
}
.search-btn { .search-btn {
font-size: 14px; font-size: 14px;
@ -309,8 +387,13 @@ h1 {
flex-shrink: 0; flex-shrink: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.search-btn:hover:not(:disabled) { opacity: 0.88; } .search-btn:hover:not(:disabled) {
.search-btn:disabled { background: #555; cursor: not-allowed; } opacity: 0.88;
}
.search-btn:disabled {
background: #555;
cursor: not-allowed;
}
/* ── Discover tab — search results ───────────────────────────────────────── */ /* ── Discover tab — search results ───────────────────────────────────────── */
@ -320,7 +403,9 @@ h1 {
font-size: 14px; font-size: 14px;
padding: 32px 0; padding: 32px 0;
} }
.search-msg.error { color: var(--red); } .search-msg.error {
color: var(--red);
}
.result-row { .result-row {
display: flex; display: flex;
@ -340,7 +425,10 @@ h1 {
flex-shrink: 0; flex-shrink: 0;
background: var(--surface-hi); background: var(--surface-hi);
} }
.result-info { flex: 1; min-width: 0; } .result-info {
flex: 1;
min-width: 0;
}
.result-title { .result-title {
display: block; display: block;
font-size: 14px; font-size: 14px;
@ -349,6 +437,9 @@ h1 {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.result-dis {
color: var(--text-dim);
}
.result-meta { .result-meta {
display: block; display: block;
font-size: 12px; font-size: 12px;
@ -368,12 +459,20 @@ h1 {
white-space: nowrap; white-space: nowrap;
transition: background 0.15s; transition: background 0.15s;
} }
.fetch-btn:hover:not(:disabled) { background: var(--green-hover); } .fetch-btn:hover:not(:disabled) {
.fetch-btn:disabled { border-color: #333; color: var(--text-dim); cursor: not-allowed; } background: var(--green-hover);
}
.fetch-btn:disabled {
border-color: #333;
color: var(--text-dim);
cursor: not-allowed;
}
/* ── Discover tab — fetch log cards ───────────────────────────────────────── */ /* ── Discover tab — fetch log cards ───────────────────────────────────────── */
.fetch-list { margin-top: 32px; } .fetch-list {
margin-top: 32px;
}
.fetch-card { .fetch-card {
background: var(--surface); background: var(--surface);
@ -383,8 +482,12 @@ h1 {
margin-bottom: 10px; margin-bottom: 10px;
transition: border-color 0.3s; transition: border-color 0.3s;
} }
.fetch-card-ok { border-color: var(--green-bg); } .fetch-card-ok {
.fetch-card-err { border-color: var(--red-bg); } border-color: var(--green-bg);
}
.fetch-card-err {
border-color: var(--red-bg);
}
.fetch-header { .fetch-header {
display: flex; display: flex;
@ -408,8 +511,12 @@ h1 {
color: var(--text-dim); color: var(--text-dim);
flex-shrink: 0; flex-shrink: 0;
} }
.fetch-status-ok { color: var(--green); } .fetch-status-ok {
.fetch-status-err { color: var(--red); } color: var(--green);
}
.fetch-status-err {
color: var(--red);
}
.fetch-log { .fetch-log {
font-size: 12px; font-size: 12px;
@ -420,8 +527,13 @@ h1 {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #333 transparent; scrollbar-color: #333 transparent;
} }
.log-line { padding: 1px 0; line-height: 1.5; } .log-line {
.log-line-err { color: var(--red-text); } padding: 1px 0;
line-height: 1.5;
}
.log-line-err {
color: var(--red-text);
}
/* ── Footer ───────────────────────────────────────────────────────────────── */ /* ── Footer ───────────────────────────────────────────────────────────────── */
@ -439,15 +551,35 @@ footer {
/* ── Responsive ───────────────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 600px) { @media (max-width: 600px) {
body { padding: 32px 16px 72px; } body {
padding: 32px 16px 72px;
.tabs { display: flex; width: 100%; } }
.tab-btn { flex: 1; padding: 0; min-height: 40px; }
.tabs {
.search-form { flex-wrap: wrap; } display: flex;
.type-toggle { width: 100%; } width: 100%;
.type-btn { flex: 1; min-height: 38px; } }
.search-btn { width: 100%; } .tab-btn {
flex: 1;
.result-title { white-space: normal; } padding: 0;
min-height: 40px;
}
.search-form {
flex-wrap: wrap;
}
.type-toggle {
width: 100%;
}
.type-btn {
flex: 1;
min-height: 38px;
}
.search-btn {
width: 100%;
}
.result-title {
white-space: normal;
}
} }