feat: instant feedback button — creates Gitea issues with auto-labels
iOS: - Subtle floating feedback button (bottom-left, speech bubble icon) - Quick sheet: type → send → auto-creates Gitea issue - Shows checkmark on success, auto-dismisses - No friction — tap, type, done Gateway: - POST /api/feedback endpoint - Auto-labels: bug/feature/enhancement + ios/web + fitness/brain/reader/podcasts - Keyword detection for label assignment - Creates issue via Gitea API with user name and source Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import DownloadStatusDock, { type DockItem } from '$lib/components/media/DownloadStatusDock.svelte';
|
||||
|
||||
interface MusicTrack {
|
||||
id: string; name: string; artists: { name: string }[];
|
||||
@@ -21,12 +22,34 @@
|
||||
let searching = $state(false);
|
||||
let searched = $state(false);
|
||||
let downloading = $state<Set<string>>(new Set());
|
||||
let activeView = $state<'search' | 'downloads'>('search');
|
||||
let playingId = $state<string | null>(null);
|
||||
let playingEmbed = $state<string | null>(null);
|
||||
let poll: ReturnType<typeof setInterval> | null = null;
|
||||
let statusEvents = $state<DockItem[]>([]);
|
||||
|
||||
const activeTasks = $derived(tasks.filter(t => ['downloading', 'queued'].includes(t.status)));
|
||||
const dockItems = $derived.by(() => {
|
||||
const liveItems = activeTasks.map((task) => ({
|
||||
id: `music-live-${task.task_id}`,
|
||||
label: task.name || task.task_id,
|
||||
message: task.status === 'queued' ? 'Waiting in queue' : 'Downloading now',
|
||||
tone: 'progress' as const,
|
||||
progress: task.progress ?? null,
|
||||
meta: [task.artist, task.speed, task.eta ? `ETA ${task.eta}` : null].filter(Boolean).join(' · '),
|
||||
actionLabel: 'Cancel',
|
||||
actionKind: 'cancel' as const
|
||||
}));
|
||||
|
||||
return [...liveItems, ...statusEvents].slice(0, 4);
|
||||
});
|
||||
|
||||
function pushStatusEvent(item: DockItem, ttl = 5200) {
|
||||
statusEvents = [item, ...statusEvents.filter((entry) => entry.id !== item.id)].slice(0, 6);
|
||||
const timer = setTimeout(() => {
|
||||
statusEvents = statusEvents.filter((entry) => entry.id !== item.id);
|
||||
}, ttl);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
function artistNames(t: MusicTrack): string { return t.artists?.map(a => a.name).join(', ') || ''; }
|
||||
function albumArt(t: MusicTrack): string | null { return t.album?.images?.[0]?.url || t.images?.[0]?.url || null; }
|
||||
@@ -46,6 +69,13 @@
|
||||
downloading = new Set([...downloading, id]);
|
||||
try {
|
||||
await fetch(`/api/music/api/${type}/download/${id}`, { credentials: 'include' });
|
||||
pushStatusEvent({
|
||||
id: `music-queued-${id}`,
|
||||
label: 'Music download',
|
||||
message: 'Added to the queue',
|
||||
tone: 'progress',
|
||||
meta: type
|
||||
});
|
||||
fetchTasks();
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
@@ -53,7 +83,36 @@
|
||||
async function fetchTasks() {
|
||||
try {
|
||||
const res = await fetch('/api/music/api/prgs/list', { credentials: 'include' });
|
||||
if (res.ok) { const data = await res.json(); tasks = Array.isArray(data) ? data : (data.tasks || []); }
|
||||
if (res.ok) {
|
||||
const previous = tasks;
|
||||
const data = await res.json();
|
||||
tasks = Array.isArray(data) ? data : (data.tasks || []);
|
||||
for (const task of tasks) {
|
||||
const prev = previous.find((entry) => entry.task_id === task.task_id);
|
||||
if (prev && prev.status !== task.status) {
|
||||
if (task.status === 'completed') {
|
||||
pushStatusEvent({
|
||||
id: `music-complete-${task.task_id}`,
|
||||
label: task.name || task.task_id,
|
||||
message: 'Download finished',
|
||||
tone: 'success',
|
||||
meta: [task.artist, task.download_type].filter(Boolean).join(' · ')
|
||||
});
|
||||
}
|
||||
if (task.status === 'error') {
|
||||
pushStatusEvent({
|
||||
id: `music-error-${task.task_id}`,
|
||||
label: task.name || task.task_id,
|
||||
message: task.error_message || 'Download failed',
|
||||
tone: 'error',
|
||||
meta: task.artist || task.download_type,
|
||||
actionLabel: 'Cancel',
|
||||
actionKind: 'cancel'
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -66,49 +125,44 @@
|
||||
else { playingId = id; playingEmbed = `https://open.spotify.com/embed/track/${id}?utm_source=generator&theme=0`; }
|
||||
}
|
||||
|
||||
function handleDockAction(event: CustomEvent<{ id: string; actionKind: 'cancel' | 'retry' | 'remove' }>) {
|
||||
const taskId = event.detail.id.replace(/^music-(live|queued|complete|error)-/, '');
|
||||
cancelTask(taskId);
|
||||
}
|
||||
|
||||
onMount(() => { fetchTasks(); poll = setInterval(fetchTasks, 3000); });
|
||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||
</script>
|
||||
|
||||
<!-- Sub-tabs -->
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab" class:active={activeView === 'search'} onclick={() => activeView = 'search'}>Search</button>
|
||||
<button class="sub-tab" class:active={activeView === 'downloads'} onclick={() => activeView = 'downloads'}>
|
||||
Downloads
|
||||
{#if activeTasks.length > 0}<span class="sub-badge">{activeTasks.length}</span>{/if}
|
||||
<div class="search-bar">
|
||||
<select class="type-select" bind:value={searchType}>
|
||||
<option value="track">Tracks</option>
|
||||
<option value="album">Albums</option>
|
||||
<option value="artist">Artists</option>
|
||||
<option value="playlist">Playlists</option>
|
||||
</select>
|
||||
<div class="search-wrap">
|
||||
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input class="s-input" type="text" placeholder="Search songs, albums, artists..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
|
||||
</div>
|
||||
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
|
||||
{#if searching}<span class="spinner"></span>{:else}Search{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeView === 'search'}
|
||||
<div class="search-bar">
|
||||
<select class="type-select" bind:value={searchType}>
|
||||
<option value="track">Tracks</option>
|
||||
<option value="album">Albums</option>
|
||||
<option value="artist">Artists</option>
|
||||
<option value="playlist">Playlists</option>
|
||||
</select>
|
||||
<div class="search-wrap">
|
||||
<svg class="s-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
<input class="s-input" type="text" placeholder="Search songs, albums, artists..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
|
||||
</div>
|
||||
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
|
||||
{#if searching}<span class="spinner"></span>{:else}Search{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if searching}
|
||||
<div class="empty">Searching Spotify...</div>
|
||||
{:else if searched && results.length === 0}
|
||||
<div class="empty">No results for "{query}"</div>
|
||||
{:else if results.length > 0}
|
||||
{#if searchType === 'track'}
|
||||
{#if playingEmbed}
|
||||
<div class="player-wrap">
|
||||
<iframe src={playingEmbed} width="100%" height="80" frameborder="0" allow="autoplay; clipboard-write; encrypted-media" title="Spotify player"></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="track-list">
|
||||
{#each results as track (track.id)}
|
||||
{#if searching}
|
||||
<div class="empty">Searching Spotify...</div>
|
||||
{:else if searched && results.length === 0}
|
||||
<div class="empty">No results for "{query}"</div>
|
||||
{:else if results.length > 0}
|
||||
{#if searchType === 'track'}
|
||||
{#if playingEmbed}
|
||||
<div class="player-wrap">
|
||||
<iframe src={playingEmbed} width="100%" height="80" frameborder="0" allow="autoplay; clipboard-write; encrypted-media" title="Spotify player"></iframe>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="track-list">
|
||||
{#each results as track (track.id)}
|
||||
{@const art = albumArt(track)}
|
||||
<div class="track-row">
|
||||
<button class="play-btn" onclick={() => togglePlay(track.id)}>
|
||||
@@ -132,11 +186,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each results as item (item.id)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-grid">
|
||||
{#each results as item (item.id)}
|
||||
{@const art = albumArt(item)}
|
||||
<div class="m-card">
|
||||
<div class="m-card-img" class:round={searchType === 'artist'}>
|
||||
@@ -152,124 +206,78 @@
|
||||
{downloading.has(item.id) ? 'Queued' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<div>Search for music</div>
|
||||
<div class="empty-sub">Spotify tracks, albums, playlists</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Download Tasks -->
|
||||
{#if tasks.length === 0}
|
||||
<div class="empty">No music downloads</div>
|
||||
{:else}
|
||||
<div class="task-list">
|
||||
{#each tasks as task (task.task_id)}
|
||||
<div class="task-row">
|
||||
<div class="task-info">
|
||||
<div class="task-name">{task.name || task.task_id}</div>
|
||||
{#if task.artist}<div class="task-artist">{task.artist}</div>{/if}
|
||||
<div class="task-meta">
|
||||
<span class="dl-badge {task.status === 'downloading' ? 'accent' : task.status === 'completed' ? 'success' : task.status === 'error' ? 'error' : 'muted'}">{task.status}</span>
|
||||
{#if task.completed_items != null && task.total_items}<span class="task-progress-text">{task.completed_items}/{task.total_items} tracks</span>{/if}
|
||||
{#if task.speed}<span class="task-speed">{task.speed}</span>{/if}
|
||||
{#if task.eta}<span class="task-eta">ETA {task.eta}</span>{/if}
|
||||
{#if task.error_message}<span class="task-error">{task.error_message}</span>{/if}
|
||||
</div>
|
||||
{#if task.progress != null && task.progress > 0}
|
||||
<div class="dl-bar full"><div class="dl-fill" style="width:{task.progress}%"></div></div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ['downloading', 'queued'].includes(task.status)}
|
||||
<button class="action-btn danger" onclick={() => cancelTask(task.task_id)}>Cancel</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<div>Search for music</div>
|
||||
<div class="empty-sub">Spotify tracks, albums, playlists</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
||||
.sub-tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); display: flex; align-items: center; gap: var(--sp-1.5); }
|
||||
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
|
||||
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
|
||||
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
|
||||
<DownloadStatusDock heading="Download queue" items={dockItems} on:action={handleDockAction} />
|
||||
|
||||
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
|
||||
.type-select { height: 42px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font); cursor: pointer; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
|
||||
.type-select:focus { outline: none; border-color: var(--accent); }
|
||||
<style>
|
||||
.search-bar { display: grid; grid-template-columns: 180px minmax(0, 1fr) auto; gap: 12px; margin-bottom: 22px; align-items: stretch; }
|
||||
.type-select { height: 50px; padding: 0 34px 0 14px; border-radius: 20px; border: 1px solid rgba(35, 26, 17, 0.1); background: rgba(255, 252, 248, 0.86); color: #1e1812; font-size: 0.9rem; font-weight: 600; font-family: var(--font); cursor: pointer; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
|
||||
.type-select:focus { outline: none; border-color: rgba(47, 106, 82, 0.34); box-shadow: 0 0 0 4px rgba(47, 106, 82, 0.08); }
|
||||
.search-wrap { flex: 1; position: relative; }
|
||||
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
|
||||
.s-input { width: 100%; height: 42px; padding: 0 14px 0 40px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); box-sizing: border-box; }
|
||||
.s-input:focus { outline: none; border-color: var(--accent); }
|
||||
.s-input::placeholder { color: var(--text-4); }
|
||||
.s-btn { height: 42px; padding: 0 var(--sp-5); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); white-space: nowrap; }
|
||||
.s-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: #8a7d70; pointer-events: none; }
|
||||
.s-input { width: 100%; height: 50px; padding: 0 14px 0 44px; border-radius: 20px; border: 1px solid rgba(35, 26, 17, 0.1); background: rgba(255, 252, 248, 0.86); color: #1e1812; font-size: 0.98rem; font-family: var(--font); box-sizing: border-box; }
|
||||
.s-input:focus { outline: none; border-color: rgba(47, 106, 82, 0.34); box-shadow: 0 0 0 4px rgba(47, 106, 82, 0.08); background: rgba(255, 253, 250, 0.98); }
|
||||
.s-input::placeholder { color: #8a7d70; }
|
||||
.s-btn { height: 50px; padding: 0 20px; border-radius: 20px; background: #1f1a15; color: #fffaf5; border: none; font-size: 0.88rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer; font-family: var(--font); white-space: nowrap; box-shadow: 0 14px 28px rgba(35, 24, 15, 0.12); transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease; }
|
||||
.s-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.s-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 18px 34px rgba(35, 24, 15, 0.16); }
|
||||
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
|
||||
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
|
||||
.empty { padding: 52px 24px; text-align: center; color: #675b4f; font-size: 0.98rem; display: flex; flex-direction: column; align-items: center; gap: 8px; border-radius: 28px; border: 1px dashed rgba(35, 26, 17, 0.12); background: linear-gradient(180deg, rgba(255, 251, 246, 0.78), rgba(246, 240, 232, 0.58)); }
|
||||
.empty-sub { font-size: 0.88rem; color: #8a7d70; }
|
||||
|
||||
.player-wrap { margin-bottom: var(--sp-3); border-radius: var(--radius); overflow: hidden; }
|
||||
.player-wrap { margin-bottom: 12px; border-radius: 22px; overflow: hidden; box-shadow: 0 18px 36px rgba(35, 24, 15, 0.08); }
|
||||
|
||||
.track-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.track-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); transition: background var(--transition); }
|
||||
.track-list { background: rgba(255, 252, 248, 0.9); border-radius: 28px; border: 1px solid rgba(35, 26, 17, 0.08); box-shadow: 0 20px 40px rgba(35, 24, 15, 0.06); overflow: hidden; }
|
||||
.track-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid rgba(35, 26, 17, 0.08); transition: background var(--transition); }
|
||||
.track-row:last-child { border-bottom: none; }
|
||||
.track-row:hover { background: var(--card-hover); }
|
||||
.play-btn { width: 32px; height: 32px; border-radius: var(--radius-full); background: none; border: none; cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color var(--transition); }
|
||||
.track-row:hover { background: rgba(248, 242, 235, 0.78); }
|
||||
.play-btn { width: 34px; height: 34px; border-radius: 999px; background: none; border: none; cursor: pointer; color: #675b4f; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color var(--transition), background 160ms ease; }
|
||||
.play-btn:hover { color: var(--accent); }
|
||||
.play-btn svg { width: 16px; height: 16px; }
|
||||
.track-art { width: 40px; height: 40px; border-radius: var(--radius-xs); object-fit: cover; flex-shrink: 0; }
|
||||
.track-art { width: 44px; height: 44px; border-radius: 14px; object-fit: cover; flex-shrink: 0; }
|
||||
.track-info { flex: 1; min-width: 0; }
|
||||
.track-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: var(--text-sm); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-dur { font-size: var(--text-sm); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; }
|
||||
.dl-sm-btn { width: 32px; height: 32px; border-radius: var(--radius-md); background: none; border: 1px solid var(--border); cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all var(--transition); }
|
||||
.track-name { font-size: 0.98rem; font-weight: 600; color: #1e1812; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: 0.88rem; color: #675b4f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-dur { font-size: 0.82rem; font-family: var(--mono); color: #8a7d70; flex-shrink: 0; }
|
||||
.dl-sm-btn { width: 34px; height: 34px; border-radius: 14px; background: rgba(249, 244, 237, 0.92); border: 1px solid rgba(35, 26, 17, 0.1); cursor: pointer; color: #675b4f; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all var(--transition); }
|
||||
.dl-sm-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.dl-sm-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--sp-4); }
|
||||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; }
|
||||
.m-card { text-align: center; }
|
||||
.m-card-img { width: 100%; aspect-ratio: 1; border-radius: var(--radius); overflow: hidden; background: var(--surface-secondary); margin-bottom: var(--sp-2); }
|
||||
.m-card-img { width: 100%; aspect-ratio: 1; border-radius: 24px; overflow: hidden; background: linear-gradient(180deg, rgba(239, 231, 221, 0.9), rgba(247, 241, 233, 0.72)); margin-bottom: 10px; box-shadow: 0 18px 36px rgba(35, 24, 15, 0.06); }
|
||||
.m-card-img.round { border-radius: var(--radius-full); }
|
||||
.m-card-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.m-card-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); color: var(--text-4); }
|
||||
.m-card-name { font-size: var(--text-sm); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.m-card-sub { font-size: var(--text-xs); color: var(--text-3); margin-top: 2px; }
|
||||
.dl-card-btn { margin-top: var(--sp-2); padding: var(--sp-1) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-xs); font-weight: 600; cursor: pointer; font-family: var(--font); }
|
||||
.m-card-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); color: #8a7d70; }
|
||||
.m-card-name { font-size: 0.9rem; font-weight: 600; color: #1e1812; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.m-card-sub { font-size: 0.76rem; color: #675b4f; margin-top: 4px; }
|
||||
.dl-card-btn { margin-top: 10px; padding: 9px 14px; border-radius: 14px; background: #2f6a52; color: white; border: none; font-size: 0.76rem; font-weight: 700; cursor: pointer; font-family: var(--font); box-shadow: 0 12px 24px rgba(47, 106, 82, 0.18); }
|
||||
.dl-card-btn:disabled { opacity: 0.4; }
|
||||
|
||||
.dl-badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
|
||||
.dl-badge { font-size: 11px; font-weight: 700; padding: 4px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.dl-badge.success { background: var(--success-dim); color: var(--success); }
|
||||
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
||||
.dl-badge.error { background: var(--error-dim); color: var(--error); }
|
||||
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
||||
|
||||
.task-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.task-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-bottom: 1px solid var(--border); }
|
||||
.task-row:last-child { border-bottom: none; }
|
||||
.task-info { flex: 1; min-width: 0; }
|
||||
.task-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
|
||||
.task-artist { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
|
||||
.task-meta { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
|
||||
.task-progress-text { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-3); }
|
||||
.task-speed { font-size: var(--text-xs); color: var(--text-4); }
|
||||
.task-eta { font-size: var(--text-xs); color: var(--text-4); }
|
||||
.task-error { font-size: var(--text-xs); color: var(--error); }
|
||||
.dl-bar { width: 100%; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: var(--sp-2); }
|
||||
.dl-bar { width: 100%; height: 4px; background: rgba(35, 26, 17, 0.1); border-radius: 999px; overflow: hidden; margin-top: 10px; }
|
||||
.dl-bar.full { width: 100%; }
|
||||
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
|
||||
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); flex-shrink: 0; }
|
||||
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-bar { flex-wrap: wrap; }
|
||||
.search-bar { grid-template-columns: 1fr; }
|
||||
.type-select { width: 100%; }
|
||||
.card-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user