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">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import DownloadStatusDock, { type DockItem } from '$lib/components/media/DownloadStatusDock.svelte';
|
||||||
|
|
||||||
interface Release {
|
interface Release {
|
||||||
title: string; content_type: string; format: string; size: string;
|
title: string; content_type: string; format: string; size: string;
|
||||||
@@ -26,8 +27,8 @@
|
|||||||
let importedIds = $state<Set<string>>(new Set());
|
let importedIds = $state<Set<string>>(new Set());
|
||||||
let perBookLibrary = $state<Record<string, number>>({});
|
let perBookLibrary = $state<Record<string, number>>({});
|
||||||
let prevCompleted = $state<Set<string>>(new Set());
|
let prevCompleted = $state<Set<string>>(new Set());
|
||||||
let activeView = $state<'search' | 'downloads'>('search');
|
|
||||||
let poll: ReturnType<typeof setInterval> | null = null;
|
let poll: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let statusEvents = $state<DockItem[]>([]);
|
||||||
|
|
||||||
// Kindle auto-send
|
// Kindle auto-send
|
||||||
interface KindleTarget { id: string; label: string; email: string; }
|
interface KindleTarget { id: string; label: string; email: string; }
|
||||||
@@ -37,10 +38,55 @@
|
|||||||
let kindleSending = $state<Set<string>>(new Set());
|
let kindleSending = $state<Set<string>>(new Set());
|
||||||
let kindleSent = $state<Set<string>>(new Set());
|
let kindleSent = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
const downloadCount = $derived(Object.keys(downloads).length);
|
const dockItems = $derived.by(() => {
|
||||||
const activeDownloads = $derived(
|
const activeItems: DockItem[] = [];
|
||||||
Object.values(downloads).filter(d => ['queued', 'downloading', 'locating', 'resolving'].includes(d.status))
|
for (const dl of Object.values(downloads)) {
|
||||||
);
|
if (['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)) {
|
||||||
|
activeItems.push({
|
||||||
|
id: `book-live-${dl.id}`,
|
||||||
|
label: dl.title || 'Book download',
|
||||||
|
message: dl.status_message || dl.status,
|
||||||
|
tone: 'progress',
|
||||||
|
progress: dl.progress > 0 ? dl.progress : null,
|
||||||
|
meta: [dl.author, dl.format?.toUpperCase()].filter(Boolean).join(' · '),
|
||||||
|
actionLabel: 'Cancel',
|
||||||
|
actionKind: 'cancel'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of importingIds) {
|
||||||
|
const dl = downloads[id];
|
||||||
|
activeItems.push({
|
||||||
|
id: `book-import-${id}`,
|
||||||
|
label: dl?.title || 'Library import',
|
||||||
|
message: 'Sending into your library',
|
||||||
|
tone: 'progress',
|
||||||
|
meta: dl?.author || 'Booklore import'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of kindleSending) {
|
||||||
|
const dl = downloads[id];
|
||||||
|
activeItems.push({
|
||||||
|
id: `book-kindle-${id}`,
|
||||||
|
label: dl?.title || 'Kindle delivery',
|
||||||
|
message: 'Sending to Kindle',
|
||||||
|
tone: 'progress',
|
||||||
|
meta: dl?.author || 'Kindle'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...activeItems, ...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);
|
||||||
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
if (!query.trim()) return;
|
if (!query.trim()) return;
|
||||||
@@ -54,6 +100,13 @@
|
|||||||
|
|
||||||
async function download(release: Release) {
|
async function download(release: Release) {
|
||||||
downloadingIds = new Set([...downloadingIds, release.source_id]);
|
downloadingIds = new Set([...downloadingIds, release.source_id]);
|
||||||
|
pushStatusEvent({
|
||||||
|
id: `book-queued-${release.source_id}`,
|
||||||
|
label: release.title,
|
||||||
|
message: 'Added to the queue',
|
||||||
|
tone: 'progress',
|
||||||
|
meta: [release.extra?.author, release.format?.toUpperCase()].filter(Boolean).join(' · ')
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/books/releases/download', {
|
const res = await fetch('/api/books/releases/download', {
|
||||||
method: 'POST', credentials: 'include',
|
method: 'POST', credentials: 'include',
|
||||||
@@ -91,6 +144,13 @@
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
importedIds = new Set([...importedIds, dl.id]);
|
importedIds = new Set([...importedIds, dl.id]);
|
||||||
|
pushStatusEvent({
|
||||||
|
id: `book-imported-${dl.id}`,
|
||||||
|
label: dl.title || fileName,
|
||||||
|
message: 'Imported into your library',
|
||||||
|
tone: 'success',
|
||||||
|
meta: lib.name
|
||||||
|
});
|
||||||
// Auto-send to Kindle if configured
|
// Auto-send to Kindle if configured
|
||||||
if (autoKindleTarget !== 'none' && fileName && !kindleSent.has(dl.id)) {
|
if (autoKindleTarget !== 'none' && fileName && !kindleSent.has(dl.id)) {
|
||||||
sendFileToKindle(dl.id, fileName, dl.title || fileName, autoKindleTarget);
|
sendFileToKindle(dl.id, fileName, dl.title || fileName, autoKindleTarget);
|
||||||
@@ -109,7 +169,16 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ filename, title, target }),
|
body: JSON.stringify({ filename, title, target }),
|
||||||
});
|
});
|
||||||
if (res.ok) { kindleSent = new Set([...kindleSent, dlId]); }
|
if (res.ok) {
|
||||||
|
kindleSent = new Set([...kindleSent, dlId]);
|
||||||
|
pushStatusEvent({
|
||||||
|
id: `book-kindle-sent-${dlId}`,
|
||||||
|
label: title,
|
||||||
|
message: 'Sent to Kindle',
|
||||||
|
tone: 'success',
|
||||||
|
meta: 'Kindle delivery'
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
const next = new Set(kindleSending); next.delete(dlId); kindleSending = next;
|
const next = new Set(kindleSending); next.delete(dlId); kindleSending = next;
|
||||||
}
|
}
|
||||||
@@ -118,6 +187,7 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/books/status', { credentials: 'include' });
|
const res = await fetch('/api/books/status', { credentials: 'include' });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const previous = downloads;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const all: Record<string, Download> = {};
|
const all: Record<string, Download> = {};
|
||||||
for (const [groupName, group] of Object.entries(data) as [string, Record<string, Download>][]) {
|
for (const [groupName, group] of Object.entries(data) as [string, Record<string, Download>][]) {
|
||||||
@@ -137,11 +207,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevCompleted = new Set(Object.entries(all).filter(([_, d]) => d.status === 'complete').map(([id]) => id));
|
prevCompleted = new Set(Object.entries(all).filter(([_, d]) => d.status === 'complete').map(([id]) => id));
|
||||||
|
for (const [id, dl] of Object.entries(all)) {
|
||||||
|
const prev = previous[id];
|
||||||
|
if (prev && prev.status !== dl.status) {
|
||||||
|
if (dl.status === 'complete') {
|
||||||
|
pushStatusEvent({
|
||||||
|
id: `book-complete-${id}`,
|
||||||
|
label: dl.title || id,
|
||||||
|
message: 'Download finished',
|
||||||
|
tone: 'success',
|
||||||
|
meta: [dl.author, dl.format?.toUpperCase()].filter(Boolean).join(' · ')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dl.status === 'error') {
|
||||||
|
pushStatusEvent({
|
||||||
|
id: `book-error-${id}`,
|
||||||
|
label: dl.title || id,
|
||||||
|
message: dl.status_message || 'Download failed',
|
||||||
|
tone: 'error',
|
||||||
|
meta: dl.author || 'Book download',
|
||||||
|
actionLabel: 'Retry',
|
||||||
|
actionKind: 'retry'
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
downloads = all;
|
downloads = all;
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDockAction(event: CustomEvent<{ id: string; actionKind: 'cancel' | 'retry' | 'remove' }>) {
|
||||||
|
const { id, actionKind } = event.detail;
|
||||||
|
const sourceId = id.replace(/^book-(live|import|kindle|complete|error|queued|imported|kindle-sent)-/, '');
|
||||||
|
if (actionKind === 'cancel') {
|
||||||
|
cancelDownload(sourceId);
|
||||||
|
} else if (actionKind === 'retry') {
|
||||||
|
retryDownload(sourceId);
|
||||||
|
} else {
|
||||||
|
cancelDownload(sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchLibraries() {
|
async function fetchLibraries() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/booklore/libraries', { credentials: 'include' });
|
const res = await fetch('/api/booklore/libraries', { credentials: 'include' });
|
||||||
@@ -194,15 +301,6 @@
|
|||||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||||
</script>
|
</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 activeDownloads.length > 0}<span class="sub-badge">{activeDownloads.length}</span>{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if kindleConfigured && kindleTargets.length > 0}
|
{#if kindleConfigured && kindleTargets.length > 0}
|
||||||
<div class="kindle-auto-row">
|
<div class="kindle-auto-row">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
@@ -216,8 +314,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if activeView === 'search'}
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<div class="search-wrap">
|
<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>
|
<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>
|
||||||
@@ -292,144 +388,176 @@
|
|||||||
<div class="empty-sub">Anna's Archive, Libgen, Z-Library</div>
|
<div class="empty-sub">Anna's Archive, Libgen, Z-Library</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
|
||||||
<!-- Downloads -->
|
<DownloadStatusDock heading="Download queue" items={dockItems} on:action={handleDockAction} />
|
||||||
{#if downloadCount === 0}
|
|
||||||
<div class="empty">No downloads yet</div>
|
|
||||||
{:else}
|
|
||||||
<div class="dl-list">
|
|
||||||
{#each Object.entries(downloads) as [id, dl] (id)}
|
|
||||||
<div class="dl-row">
|
|
||||||
<div class="dl-info">
|
|
||||||
<div class="dl-title">{dl.title || id}</div>
|
|
||||||
{#if dl.author}<div class="dl-author">{dl.author}</div>{/if}
|
|
||||||
<div class="dl-meta-row">
|
|
||||||
{#if dl.format}<span class="fmt-badge sm {fmtBadge(dl.format)}">{dl.format.toUpperCase()}</span>{/if}
|
|
||||||
<span class="dl-badge {statusClass(dl.status)}">{dl.status}</span>
|
|
||||||
{#if dl.status_message}<span class="dl-msg">{dl.status_message}</span>{/if}
|
|
||||||
</div>
|
|
||||||
{#if dl.status === 'downloading' && dl.progress > 0}
|
|
||||||
<div class="dl-bar full"><div class="dl-fill" style="width:{dl.progress}%"></div></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="dl-actions">
|
|
||||||
{#if dl.status === 'complete'}
|
|
||||||
{#if kindleSent.has(id)}
|
|
||||||
<span class="dl-badge success">Sent to Kindle ✓</span>
|
|
||||||
{:else if kindleSending.has(id)}
|
|
||||||
<span class="dl-badge accent">Sending to Kindle...</span>
|
|
||||||
{/if}
|
|
||||||
{#if importedIds.has(id)}
|
|
||||||
<span class="dl-badge success">Imported ✓</span>
|
|
||||||
{:else if importingIds.has(id)}
|
|
||||||
<span class="dl-badge accent">Importing...</span>
|
|
||||||
{:else}
|
|
||||||
{#if libraries.length > 0}
|
|
||||||
<select class="lib-select" value={perBookLibrary[id] ?? defaultLibraryId} onchange={(e) => { perBookLibrary[id] = Number((e.currentTarget as HTMLSelectElement).value); perBookLibrary = {...perBookLibrary}; }}>
|
|
||||||
{#each libraries as lib}<option value={lib.id}>{lib.name}</option>{/each}
|
|
||||||
</select>
|
|
||||||
{/if}
|
|
||||||
<button class="action-btn" onclick={() => importToBooklore(dl, getLibraryId(id))}>Import</button>
|
|
||||||
{/if}
|
|
||||||
{:else if dl.status === 'error'}
|
|
||||||
<button class="action-btn" onclick={() => retryDownload(id)}>Retry</button>
|
|
||||||
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
|
|
||||||
{:else if ['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)}
|
|
||||||
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Cancel</button>
|
|
||||||
{:else}
|
|
||||||
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
.search-bar {
|
||||||
.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); }
|
display: grid;
|
||||||
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
|
gap: 12px;
|
||||||
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
|
margin-bottom: 22px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
|
|
||||||
.search-wrap { flex: 1; position: relative; }
|
.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 36px 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); transition: all var(--transition); box-sizing: border-box; }
|
.s-icon {
|
||||||
.s-input:focus { outline: none; border-color: var(--accent); }
|
position: absolute;
|
||||||
.s-input::placeholder { color: var(--text-4); }
|
left: 16px;
|
||||||
.s-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); }
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: #8a7d70;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0 44px 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);
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
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-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #8a7d70;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.s-clear svg { width: 14px; height: 14px; }
|
.s-clear svg { width: 14px; height: 14px; }
|
||||||
.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); transition: opacity var(--transition); white-space: nowrap; }
|
|
||||||
.s-btn:disabled { opacity: 0.4; cursor: default; }
|
.s-btn {
|
||||||
.s-btn:hover:not(:disabled) { opacity: 0.9; }
|
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);
|
||||||
|
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 14px 28px rgba(35, 24, 15, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-btn:disabled { opacity: 0.42; 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; }
|
.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); } }
|
@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 {
|
||||||
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
|
padding: 52px 24px;
|
||||||
.result-count { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
|
.empty-sub { font-size: 0.88rem; color: #8a7d70; }
|
||||||
.r-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; display: flex; flex-direction: column; }
|
.result-count { font-size: 0.88rem; color: #675b4f; margin-bottom: 12px; }
|
||||||
.r-cover { position: relative; width: 100%; aspect-ratio: 3/2; background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
|
||||||
|
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
.r-card {
|
||||||
|
background: rgba(255, 252, 248, 0.9);
|
||||||
|
border-radius: 26px;
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.08);
|
||||||
|
box-shadow: 0 20px 40px rgba(35, 24, 15, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.r-cover {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/2;
|
||||||
|
background: linear-gradient(180deg, rgba(239, 231, 221, 0.9), rgba(247, 241, 233, 0.72));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.r-cover img { width: 100%; height: 100%; object-fit: cover; }
|
.r-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.fmt-badge { position: absolute; top: var(--sp-2); right: var(--sp-2); font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
|
.fmt-badge { position: absolute; top: 12px; right: 12px; font-size: 11px; font-weight: 700; padding: 4px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.fmt-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
.fmt-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
||||||
.fmt-badge.error { background: var(--error-dim); color: var(--error); }
|
.fmt-badge.error { background: var(--error-dim); color: var(--error); }
|
||||||
.fmt-badge.warning { background: var(--warning-bg); color: var(--warning); }
|
.fmt-badge.warning { background: var(--warning-bg); color: var(--warning); }
|
||||||
.fmt-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
.fmt-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
||||||
.fmt-badge.sm { position: static; }
|
.fmt-badge.sm { position: static; }
|
||||||
.r-info { padding: var(--sp-3) var(--sp-4); flex: 1; }
|
.r-info { padding: 16px 18px 10px; flex: 1; }
|
||||||
.r-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
.r-title { font-size: 1rem; font-weight: 600; color: #1e1812; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.r-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
|
.r-author { font-size: 0.9rem; color: #675b4f; margin-top: 3px; }
|
||||||
.r-meta { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-4); }
|
.r-meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 11px; color: #8a7d70; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.r-actions { padding: var(--sp-2) var(--sp-4) var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
.r-actions { padding: 0 18px 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
.lib-select { padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--card); color: var(--text-2); font-size: var(--text-xs); font-family: var(--font); cursor: pointer; max-width: 120px; }
|
.lib-select { padding: 9px 30px 9px 12px; border-radius: 14px; border: 1px solid rgba(35, 26, 17, 0.1); background: rgba(249, 244, 237, 0.92); color: #4a4036; font-size: 12px; font-family: var(--font); cursor: pointer; max-width: 140px; }
|
||||||
.dl-btn { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1.5) var(--sp-3); 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); transition: opacity var(--transition); }
|
.dl-btn { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-radius: 14px; background: #2f6a52; color: white; border: none; font-size: 0.84rem; font-weight: 700; cursor: pointer; font-family: var(--font); transition: transform 160ms ease, box-shadow 160ms ease; box-shadow: 0 12px 24px rgba(47, 106, 82, 0.2); }
|
||||||
.dl-btn:hover { opacity: 0.9; }
|
.dl-btn:hover { transform: translateY(-1px); box-shadow: 0 16px 28px rgba(47, 106, 82, 0.24); }
|
||||||
.dl-btn svg { width: 14px; height: 14px; }
|
.dl-btn svg { width: 14px; height: 14px; }
|
||||||
.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.success { background: var(--success-dim); color: var(--success); }
|
||||||
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
||||||
.dl-badge.error { background: var(--error-dim); color: var(--error); }
|
.dl-badge.error { background: var(--error-dim); color: var(--error); }
|
||||||
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
||||||
.dl-bar { width: 80px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
.dl-bar { width: 80px; height: 4px; background: rgba(35, 26, 17, 0.1); border-radius: 999px; overflow: hidden; }
|
||||||
.dl-bar.full { width: 100%; margin-top: var(--sp-2); }
|
.dl-bar.full { width: 100%; margin-top: 10px; }
|
||||||
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
|
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
|
||||||
|
|
||||||
.dl-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
|
|
||||||
.dl-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); }
|
|
||||||
.dl-row:last-child { border-bottom: none; }
|
|
||||||
.dl-info { flex: 1; min-width: 0; }
|
|
||||||
.dl-title { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
|
|
||||||
.dl-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
|
|
||||||
.dl-meta-row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
|
|
||||||
.dl-msg { font-size: var(--text-xs); color: var(--text-4); }
|
|
||||||
.dl-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
|
|
||||||
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
|
|
||||||
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
|
|
||||||
.action-btn.danger:hover { background: var(--error-dim); }
|
|
||||||
|
|
||||||
.kindle-auto-row {
|
.kindle-auto-row {
|
||||||
display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4);
|
display: flex; align-items: center; gap: 10px; margin-bottom: 16px;
|
||||||
padding: var(--sp-2) var(--sp-3); background: var(--surface-secondary); border-radius: var(--radius-md); border: 1px solid var(--border);
|
padding: 12px 14px; background: rgba(247, 241, 233, 0.82); border-radius: 20px; border: 1px solid rgba(35, 26, 17, 0.08);
|
||||||
flex-wrap: wrap; max-width: 100%; overflow: hidden;
|
flex-wrap: wrap; max-width: 100%; overflow: hidden;
|
||||||
}
|
}
|
||||||
.kindle-auto-row svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
|
.kindle-auto-row svg { width: 14px; height: 14px; color: #675b4f; flex-shrink: 0; }
|
||||||
.kindle-auto-label { font-size: var(--text-sm); color: var(--text-2); white-space: nowrap; flex-shrink: 1; min-width: 0; }
|
.kindle-auto-label { font-size: 0.9rem; color: #4a4036; white-space: nowrap; flex-shrink: 1; min-width: 0; }
|
||||||
.kindle-auto-select {
|
.kindle-auto-select {
|
||||||
height: 32px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
|
height: 36px; padding: 0 30px 0 12px; border-radius: 14px; border: 1px solid rgba(35, 26, 17, 0.1);
|
||||||
background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font);
|
background: rgba(255, 252, 248, 0.94); color: #1e1812; font-size: 0.88rem; font-weight: 500; font-family: var(--font);
|
||||||
cursor: pointer; -webkit-appearance: none; appearance: none;
|
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-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 8px center; padding-right: 24px;
|
background-repeat: no-repeat; background-position: right 10px center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.search-bar { grid-template-columns: 1fr; }
|
||||||
.results-grid { grid-template-columns: 1fr; }
|
.results-grid { grid-template-columns: 1fr; }
|
||||||
.dl-row { flex-direction: column; }
|
|
||||||
.dl-actions { width: 100%; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
245
frontend-v2/src/lib/components/media/DownloadStatusDock.svelte
Normal file
245
frontend-v2/src/lib/components/media/DownloadStatusDock.svelte
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export type DockTone = 'progress' | 'success' | 'error';
|
||||||
|
|
||||||
|
export type DockItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
message: string;
|
||||||
|
tone: DockTone;
|
||||||
|
progress?: number | null;
|
||||||
|
meta?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionKind?: 'cancel' | 'retry' | 'remove';
|
||||||
|
};
|
||||||
|
|
||||||
|
let { heading = 'Queue', items = [] as DockItem[] } = $props();
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
action: { id: string; actionKind: 'cancel' | 'retry' | 'remove' };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function toneLabel(tone: DockTone): string {
|
||||||
|
if (tone === 'error') return 'Issue';
|
||||||
|
if (tone === 'success') return 'Done';
|
||||||
|
return 'Live';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if items.length > 0}
|
||||||
|
<aside class="status-dock">
|
||||||
|
<div class="dock-head">
|
||||||
|
<div class="dock-kicker">{heading}</div>
|
||||||
|
<div class="dock-count">{items.length}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dock-list">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<section class="dock-card" data-tone={item.tone}>
|
||||||
|
<div class="dock-card-head">
|
||||||
|
<div>
|
||||||
|
<div class="dock-label">{item.label}</div>
|
||||||
|
<div class="dock-message">{item.message}</div>
|
||||||
|
</div>
|
||||||
|
<span class="dock-tone">{toneLabel(item.tone)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if item.meta}
|
||||||
|
<div class="dock-meta">{item.meta}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.progress != null}
|
||||||
|
<div class="dock-progress">
|
||||||
|
<div class="dock-progress-fill" style={`width:${Math.max(3, Math.min(item.progress, 100))}%`}></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if item.actionLabel && item.actionKind}
|
||||||
|
<button
|
||||||
|
class="dock-action"
|
||||||
|
type="button"
|
||||||
|
onclick={() => dispatch('action', { id: item.id, actionKind: item.actionKind! })}
|
||||||
|
>
|
||||||
|
{item.actionLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.status-dock {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 60;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-head,
|
||||||
|
.dock-card {
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.1);
|
||||||
|
box-shadow: 0 24px 40px rgba(35, 24, 15, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 251, 246, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-kicker,
|
||||||
|
.dock-count,
|
||||||
|
.dock-tone {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-kicker { color: #6a5f53; }
|
||||||
|
.dock-count { color: #1f1a15; }
|
||||||
|
|
||||||
|
.dock-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 251, 246, 0.94);
|
||||||
|
animation: dockRise 240ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='progress'] {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 251, 246, 0.96), rgba(241, 247, 243, 0.88)),
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(47, 106, 82, 0.12), transparent 26%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='success'] {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(251, 255, 251, 0.96), rgba(239, 247, 242, 0.9)),
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(47, 106, 82, 0.14), transparent 26%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='error'] {
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 250, 249, 0.96), rgba(252, 239, 236, 0.9)),
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(178, 61, 46, 0.16), transparent 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #1f1a15;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-message {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: #655a4e;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-meta {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #86796c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-tone {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='progress'] .dock-tone {
|
||||||
|
background: rgba(47, 106, 82, 0.12);
|
||||||
|
color: #2f6a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='success'] .dock-tone {
|
||||||
|
background: rgba(47, 106, 82, 0.12);
|
||||||
|
color: #2f6a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='error'] .dock-tone {
|
||||||
|
background: rgba(178, 61, 46, 0.12);
|
||||||
|
color: #b23d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-progress {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(35, 26, 17, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2f6a52;
|
||||||
|
transition: width 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-card[data-tone='error'] .dock-progress-fill {
|
||||||
|
background: #b23d2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dock-action {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #1f1a15;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dockRise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.status-dock {
|
||||||
|
right: 12px;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import DownloadStatusDock, { type DockItem } from '$lib/components/media/DownloadStatusDock.svelte';
|
||||||
|
|
||||||
interface MusicTrack {
|
interface MusicTrack {
|
||||||
id: string; name: string; artists: { name: string }[];
|
id: string; name: string; artists: { name: string }[];
|
||||||
@@ -21,12 +22,34 @@
|
|||||||
let searching = $state(false);
|
let searching = $state(false);
|
||||||
let searched = $state(false);
|
let searched = $state(false);
|
||||||
let downloading = $state<Set<string>>(new Set());
|
let downloading = $state<Set<string>>(new Set());
|
||||||
let activeView = $state<'search' | 'downloads'>('search');
|
|
||||||
let playingId = $state<string | null>(null);
|
let playingId = $state<string | null>(null);
|
||||||
let playingEmbed = $state<string | null>(null);
|
let playingEmbed = $state<string | null>(null);
|
||||||
let poll: ReturnType<typeof setInterval> | 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 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 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; }
|
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]);
|
downloading = new Set([...downloading, id]);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/music/api/${type}/download/${id}`, { credentials: 'include' });
|
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();
|
fetchTasks();
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -53,7 +83,36 @@
|
|||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/music/api/prgs/list', { credentials: 'include' });
|
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 */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,20 +125,15 @@
|
|||||||
else { playingId = id; playingEmbed = `https://open.spotify.com/embed/track/${id}?utm_source=generator&theme=0`; }
|
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); });
|
onMount(() => { fetchTasks(); poll = setInterval(fetchTasks, 3000); });
|
||||||
onDestroy(() => { if (poll) clearInterval(poll); });
|
onDestroy(() => { if (poll) clearInterval(poll); });
|
||||||
</script>
|
</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}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activeView === 'search'}
|
|
||||||
<div class="search-bar">
|
<div class="search-bar">
|
||||||
<select class="type-select" bind:value={searchType}>
|
<select class="type-select" bind:value={searchType}>
|
||||||
<option value="track">Tracks</option>
|
<option value="track">Tracks</option>
|
||||||
@@ -162,114 +216,68 @@
|
|||||||
<div class="empty-sub">Spotify tracks, albums, playlists</div>
|
<div class="empty-sub">Spotify tracks, albums, playlists</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
|
||||||
<!-- Download Tasks -->
|
<DownloadStatusDock heading="Download queue" items={dockItems} on:action={handleDockAction} />
|
||||||
{#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}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
.search-bar { display: grid; grid-template-columns: 180px minmax(0, 1fr) auto; gap: 12px; margin-bottom: 22px; align-items: stretch; }
|
||||||
.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); }
|
.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; }
|
||||||
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
|
.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); }
|
||||||
.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); }
|
|
||||||
|
|
||||||
.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); }
|
|
||||||
.search-wrap { flex: 1; position: relative; }
|
.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-icon { position: absolute; left: 16px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: #8a7d70; 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 { 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: var(--accent); }
|
.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: var(--text-4); }
|
.s-input::placeholder { color: #8a7d70; }
|
||||||
.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-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: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; }
|
.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); } }
|
@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 { 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: var(--text-sm); color: var(--text-4); }
|
.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-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: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); transition: background var(--transition); }
|
.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:last-child { border-bottom: none; }
|
||||||
.track-row:hover { background: var(--card-hover); }
|
.track-row:hover { background: rgba(248, 242, 235, 0.78); }
|
||||||
.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); }
|
.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:hover { color: var(--accent); }
|
||||||
.play-btn svg { width: 16px; height: 16px; }
|
.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-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-name { font-size: 0.98rem; font-weight: 600; color: #1e1812; 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-artist { font-size: 0.88rem; color: #675b4f; 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; }
|
.track-dur { font-size: 0.82rem; font-family: var(--mono); color: #8a7d70; 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); }
|
.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:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
.dl-sm-btn svg { width: 14px; height: 14px; }
|
.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 { 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.round { border-radius: var(--radius-full); }
|
||||||
.m-card-img img { width: 100%; height: 100%; object-fit: cover; }
|
.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-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: 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-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: var(--text-xs); color: var(--text-3); margin-top: 2px; }
|
.m-card-sub { font-size: 0.76rem; color: #675b4f; margin-top: 4px; }
|
||||||
.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); }
|
.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-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.success { background: var(--success-dim); color: var(--success); }
|
||||||
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
|
||||||
.dl-badge.error { background: var(--error-dim); color: var(--error); }
|
.dl-badge.error { background: var(--error-dim); color: var(--error); }
|
||||||
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
|
.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; }
|
.dl-bar { width: 100%; height: 4px; background: rgba(35, 26, 17, 0.1); border-radius: 999px; overflow: hidden; margin-top: 10px; }
|
||||||
.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.full { width: 100%; }
|
.dl-bar.full { width: 100%; }
|
||||||
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
|
.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) {
|
@media (max-width: 768px) {
|
||||||
.search-bar { flex-wrap: wrap; }
|
.search-bar { grid-template-columns: 1fr; }
|
||||||
.type-select { width: 100%; }
|
.type-select { width: 100%; }
|
||||||
.card-grid { grid-template-columns: repeat(2, 1fr); }
|
.card-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
}
|
}
|
||||||
|
|||||||
339
frontend-v2/src/lib/pages/downloads/AtelierDownloadsPage.svelte
Normal file
339
frontend-v2/src/lib/pages/downloads/AtelierDownloadsPage.svelte
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { Download } from '@lucide/svelte';
|
||||||
|
import BookSearch from '$lib/components/media/BookSearch.svelte';
|
||||||
|
import MusicSearch from '$lib/components/media/MusicSearch.svelte';
|
||||||
|
import BookLibrary from '$lib/components/media/BookLibrary.svelte';
|
||||||
|
|
||||||
|
type MediaTab = 'books' | 'music' | 'library';
|
||||||
|
|
||||||
|
type TabMeta = {
|
||||||
|
id: MediaTab;
|
||||||
|
label: string;
|
||||||
|
kicker: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
statLabel: string;
|
||||||
|
statValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlMode = page.url.searchParams.get('mode');
|
||||||
|
let activeTab = $state<MediaTab>(urlMode === 'music' ? 'music' : urlMode === 'library' ? 'library' : 'books');
|
||||||
|
|
||||||
|
const tabs: TabMeta[] = [
|
||||||
|
{
|
||||||
|
id: 'books',
|
||||||
|
label: 'Books',
|
||||||
|
kicker: 'Direct releases',
|
||||||
|
title: 'Source, queue, and route ebook drops.',
|
||||||
|
description: 'Search releases, watch active transfers, and hand finished files into the right Booklore library without leaving the page.',
|
||||||
|
statLabel: 'Flow',
|
||||||
|
statValue: 'Search -> Download -> Import'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
kicker: 'Spotify intake',
|
||||||
|
title: 'Pull tracks, albums, artists, and playlists into one queue.',
|
||||||
|
description: 'Keep discovery and active download tasks in the same lane so new pulls do not get buried under search results.',
|
||||||
|
statLabel: 'Coverage',
|
||||||
|
statValue: 'Tracks · Albums · Artists'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'library',
|
||||||
|
label: 'Library',
|
||||||
|
kicker: 'Shelves',
|
||||||
|
title: 'Review the catalog after the download work is done.',
|
||||||
|
description: 'Filter across libraries, search titles quickly, and open book details without switching to a separate management view.',
|
||||||
|
statLabel: 'Output',
|
||||||
|
statValue: 'Booklore shelves'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeMeta = $derived(tabs.find((tab) => tab.id === activeTab) ?? tabs[0]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="downloads-page">
|
||||||
|
<header class="downloads-header intro-sequence">
|
||||||
|
<div class="downloads-heading">
|
||||||
|
<h1>Downloads</h1>
|
||||||
|
<p class="downloads-subcopy">Book pulls, music grabs, and the library handoff in one workspace.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="top-tabs intro-sequence">
|
||||||
|
{#each tabs as tab}
|
||||||
|
<button class="top-tab" class:active={activeTab === tab.id} onclick={() => activeTab = tab.id}>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="workspace-frame intro-sequence">
|
||||||
|
<div class="workspace-shell panel">
|
||||||
|
<div class="workspace-label-row">
|
||||||
|
<div>
|
||||||
|
<div class="section-label">{activeMeta.kicker}</div>
|
||||||
|
<h2>{activeMeta.title}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="workspace-chip">
|
||||||
|
<Download size={14} strokeWidth={2} />
|
||||||
|
<span>{activeMeta.statLabel}</span>
|
||||||
|
<strong>{activeMeta.statValue}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="workspace-copy">{activeMeta.description}</p>
|
||||||
|
|
||||||
|
<section class="workspace-panel">
|
||||||
|
<div class="workspace-body">
|
||||||
|
{#if activeTab === 'books'}
|
||||||
|
<BookSearch />
|
||||||
|
{:else if activeTab === 'music'}
|
||||||
|
<MusicSearch />
|
||||||
|
{:else}
|
||||||
|
<BookLibrary />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.downloads-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 24px 24px 120px;
|
||||||
|
max-width: 1460px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.6rem, 4.6vw, 4.25rem);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
color: #1e1812;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-subcopy {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
max-width: 42rem;
|
||||||
|
color: #5c5247;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: rgba(255, 252, 248, 0.82);
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.12);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
box-shadow: 0 18px 40px rgba(35, 24, 15, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-shell,
|
||||||
|
.workspace-panel {
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-copy {
|
||||||
|
color: #5c5247;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: #7b6d5f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.48);
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.1);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tab {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b5f52;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tab:hover {
|
||||||
|
color: #1e1812;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tab.active {
|
||||||
|
background: rgba(255, 252, 248, 0.96);
|
||||||
|
color: #1e1812;
|
||||||
|
box-shadow: 0 10px 24px rgba(33, 22, 14, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label-row h2 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
color: #1e1812;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-frame {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.64);
|
||||||
|
border: 1px solid rgba(35, 26, 17, 0.08);
|
||||||
|
color: #6b6256;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip strong {
|
||||||
|
color: #1e1812;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-copy {
|
||||||
|
max-width: 72ch;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-panel {
|
||||||
|
padding: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 251, 246, 0.96), rgba(241, 247, 243, 0.82)),
|
||||||
|
radial-gradient(circle at 88% 14%, rgba(47, 106, 82, 0.1), transparent 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-sequence {
|
||||||
|
animation: riseIn 720ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-sequence:nth-child(1) { animation-delay: 20ms; }
|
||||||
|
.intro-sequence:nth-child(2) { animation-delay: 110ms; }
|
||||||
|
.intro-sequence:nth-child(3) { animation-delay: 180ms; }
|
||||||
|
|
||||||
|
.reveal-item {
|
||||||
|
opacity: 0;
|
||||||
|
animation: revealLine 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
animation-delay: var(--delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes riseIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes revealLine {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.top-tabs {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
overflow: visible;
|
||||||
|
padding: 4px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tab {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.downloads-page {
|
||||||
|
padding: 20px 16px 120px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-shell,
|
||||||
|
.workspace-panel {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-heading h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-header,
|
||||||
|
.workspace-label-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tab {
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-chip {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,75 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import AtelierDownloadsPage from '$lib/pages/downloads/AtelierDownloadsPage.svelte';
|
||||||
import BookSearch from '$lib/components/media/BookSearch.svelte';
|
|
||||||
import MusicSearch from '$lib/components/media/MusicSearch.svelte';
|
|
||||||
import BookLibrary from '$lib/components/media/BookLibrary.svelte';
|
|
||||||
|
|
||||||
type MediaTab = 'books' | 'music' | 'library';
|
|
||||||
|
|
||||||
const urlMode = page.url.searchParams.get('mode');
|
|
||||||
let activeTab = $state<MediaTab>(urlMode === 'music' ? 'music' : urlMode === 'library' ? 'library' : 'books');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<AtelierDownloadsPage />
|
||||||
<div class="app-surface">
|
|
||||||
<div class="page-header">
|
|
||||||
<div class="page-title">DOWNLOADS</div>
|
|
||||||
<div class="page-subtitle">
|
|
||||||
{#if activeTab === 'books'}Book Downloads
|
|
||||||
{:else if activeTab === 'music'}Music Downloads
|
|
||||||
{:else}Book Library{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="media-tabs">
|
|
||||||
<button class="tab" class:active={activeTab === 'books'} onclick={() => activeTab = 'books'}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
|
||||||
Books
|
|
||||||
</button>
|
|
||||||
<button class="tab" class:active={activeTab === 'music'} onclick={() => activeTab = 'music'}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
||||||
Music
|
|
||||||
</button>
|
|
||||||
<button class="tab" class:active={activeTab === 'library'} onclick={() => activeTab = 'library'}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
||||||
Library
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if activeTab === 'books'}
|
|
||||||
<BookSearch />
|
|
||||||
{:else if activeTab === 'music'}
|
|
||||||
<MusicSearch />
|
|
||||||
{:else}
|
|
||||||
<BookLibrary />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: var(--text-2xl);
|
|
||||||
font-weight: 300;
|
|
||||||
color: var(--text-1);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-tabs {
|
|
||||||
display: flex; gap: var(--sp-1); margin-bottom: var(--sp-5);
|
|
||||||
border-bottom: 1px solid var(--border); padding-bottom: 0;
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-2);
|
|
||||||
flex: 1; padding: 10px var(--sp-2) 12px; font-size: var(--text-base); font-weight: 500;
|
|
||||||
color: var(--text-3); background: none; border: none; border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer; font-family: var(--font); transition: all var(--transition);
|
|
||||||
text-align: center; justify-content: center; margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
.tab:hover { color: var(--text-2); }
|
|
||||||
.tab.active { color: var(--text-1); border-bottom-color: var(--accent); font-weight: 600; }
|
|
||||||
.tab svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.page-subtitle { font-size: var(--text-xl); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
5
frontend-v2/src/routes/mockup/downloads/+page.svelte
Normal file
5
frontend-v2/src/routes/mockup/downloads/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AtelierDownloadsPage from '$lib/pages/downloads/AtelierDownloadsPage.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AtelierDownloadsPage />
|
||||||
@@ -3,7 +3,7 @@ WORKDIR /app
|
|||||||
RUN pip install --no-cache-dir bcrypt
|
RUN pip install --no-cache-dir bcrypt
|
||||||
RUN adduser --disabled-password --no-create-home appuser
|
RUN adduser --disabled-password --no-create-home appuser
|
||||||
RUN mkdir -p /app/data && chown -R appuser /app/data
|
RUN mkdir -p /app/data && chown -R appuser /app/data
|
||||||
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py ./
|
COPY --chown=appuser server.py config.py database.py sessions.py proxy.py responses.py auth.py dashboard.py command.py assistant.py feedback.py ./
|
||||||
COPY --chown=appuser integrations/ ./integrations/
|
COPY --chown=appuser integrations/ ./integrations/
|
||||||
EXPOSE 8100
|
EXPOSE 8100
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import sqlite3
|
|||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
|
from config import SESSION_COOKIE_SECURE
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from sessions import create_session, delete_session
|
from sessions import create_session, delete_session
|
||||||
|
|
||||||
@@ -58,7 +59,10 @@ def handle_logout(handler):
|
|||||||
delete_session(token)
|
delete_session(token)
|
||||||
handler.send_response(200)
|
handler.send_response(200)
|
||||||
handler.send_header("Content-Type", "application/json")
|
handler.send_header("Content-Type", "application/json")
|
||||||
handler.send_header("Set-Cookie", "platform_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0")
|
cookie = "platform_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
|
||||||
|
if SESSION_COOKIE_SECURE:
|
||||||
|
cookie += "; Secure"
|
||||||
|
handler.send_header("Set-Cookie", cookie)
|
||||||
resp = b'{"success": true}'
|
resp = b'{"success": true}'
|
||||||
handler.send_header("Content-Length", len(resp))
|
handler.send_header("Content-Length", len(resp))
|
||||||
handler.end_headers()
|
handler.end_headers()
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.2")
|
|||||||
|
|
||||||
# ── Session config ──
|
# ── Session config ──
|
||||||
SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days
|
SESSION_MAX_AGE = int(os.environ.get("SESSION_MAX_AGE", 30 * 86400)) # 30 days
|
||||||
|
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "").lower() in {"1", "true", "yes", "on"}
|
||||||
DEV_AUTO_LOGIN = os.environ.get("DEV_AUTO_LOGIN", "").lower() in {"1", "true", "yes", "on"}
|
DEV_AUTO_LOGIN = os.environ.get("DEV_AUTO_LOGIN", "").lower() in {"1", "true", "yes", "on"}
|
||||||
DEV_AUTO_LOGIN_USERNAME = os.environ.get("DEV_AUTO_LOGIN_USERNAME", "dev")
|
DEV_AUTO_LOGIN_USERNAME = os.environ.get("DEV_AUTO_LOGIN_USERNAME", "dev")
|
||||||
DEV_AUTO_LOGIN_DISPLAY_NAME = os.environ.get("DEV_AUTO_LOGIN_DISPLAY_NAME", "Dev User")
|
DEV_AUTO_LOGIN_DISPLAY_NAME = os.environ.get("DEV_AUTO_LOGIN_DISPLAY_NAME", "Dev User")
|
||||||
|
|||||||
108
gateway/feedback.py
Normal file
108
gateway/feedback.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Platform Gateway — Feedback handler.
|
||||||
|
Creates Gitea issues from user feedback with auto-labeling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
GITEA_URL = "http://localhost:3300"
|
||||||
|
GITEA_TOKEN = "7abae0245e49e130e1b6752b7fa381a57607d8c7"
|
||||||
|
REPO = "yusiboyz/platform"
|
||||||
|
|
||||||
|
# Label IDs from Gitea
|
||||||
|
LABELS = {
|
||||||
|
"bug": 11,
|
||||||
|
"feature": 12,
|
||||||
|
"ios": 13,
|
||||||
|
"web": 14,
|
||||||
|
"fitness": 15,
|
||||||
|
"brain": 16,
|
||||||
|
"reader": 17,
|
||||||
|
"podcasts": 18,
|
||||||
|
"enhancement": 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def auto_label(text: str, source: str = "ios") -> list[int]:
|
||||||
|
"""Detect labels from feedback text."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
label_ids = []
|
||||||
|
|
||||||
|
# Source platform
|
||||||
|
if source == "ios":
|
||||||
|
label_ids.append(LABELS["ios"])
|
||||||
|
elif source == "web":
|
||||||
|
label_ids.append(LABELS["web"])
|
||||||
|
|
||||||
|
# Bug or feature
|
||||||
|
bug_words = ["bug", "broken", "crash", "error", "wrong", "fix", "issue", "doesn't work", "not working", "can't"]
|
||||||
|
feature_words = ["want", "add", "wish", "would be nice", "can we", "can you", "feature", "idea", "suggest"]
|
||||||
|
|
||||||
|
if any(w in text_lower for w in bug_words):
|
||||||
|
label_ids.append(LABELS["bug"])
|
||||||
|
elif any(w in text_lower for w in feature_words):
|
||||||
|
label_ids.append(LABELS["feature"])
|
||||||
|
else:
|
||||||
|
label_ids.append(LABELS["enhancement"])
|
||||||
|
|
||||||
|
# App detection
|
||||||
|
if any(w in text_lower for w in ["fitness", "calorie", "food", "meal", "macro", "protein"]):
|
||||||
|
label_ids.append(LABELS["fitness"])
|
||||||
|
elif any(w in text_lower for w in ["brain", "note", "save", "bookmark"]):
|
||||||
|
label_ids.append(LABELS["brain"])
|
||||||
|
elif any(w in text_lower for w in ["reader", "rss", "feed", "article"]):
|
||||||
|
label_ids.append(LABELS["reader"])
|
||||||
|
elif any(w in text_lower for w in ["podcast", "episode", "listen", "player"]):
|
||||||
|
label_ids.append(LABELS["podcasts"])
|
||||||
|
|
||||||
|
return label_ids
|
||||||
|
|
||||||
|
|
||||||
|
def handle_feedback(handler, body, user):
|
||||||
|
"""Create a Gitea issue from user feedback."""
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
handler._send_json({"error": "Invalid JSON"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
source = data.get("source", "ios")
|
||||||
|
|
||||||
|
if not text:
|
||||||
|
handler._send_json({"error": "Feedback text is required"}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
user_name = user.get("display_name") or user.get("username", "Unknown")
|
||||||
|
label_ids = auto_label(text, source)
|
||||||
|
|
||||||
|
# Create Gitea issue
|
||||||
|
issue_body = {
|
||||||
|
"title": text[:80] + ("..." if len(text) > 80 else ""),
|
||||||
|
"body": f"**From:** {user_name} ({source})\n\n{text}",
|
||||||
|
"labels": label_ids,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GITEA_URL}/api/v1/repos/{REPO}/issues",
|
||||||
|
data=json.dumps(issue_body).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"token {GITEA_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
|
||||||
|
handler._send_json({
|
||||||
|
"ok": True,
|
||||||
|
"issue_number": result.get("number"),
|
||||||
|
"url": result.get("html_url"),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
handler._send_json({"error": f"Failed to create issue: {e}"}, 500)
|
||||||
@@ -5,7 +5,7 @@ Platform Gateway — Response helpers mixed into GatewayHandler.
|
|||||||
import json
|
import json
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
|
|
||||||
from config import SESSION_MAX_AGE, DEV_AUTO_LOGIN
|
from config import SESSION_MAX_AGE, SESSION_COOKIE_SECURE, DEV_AUTO_LOGIN
|
||||||
from sessions import get_session_user, get_or_create_dev_user
|
from sessions import get_session_user, get_or_create_dev_user
|
||||||
|
|
||||||
|
|
||||||
@@ -43,8 +43,10 @@ class ResponseMixin:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
def _set_session_cookie(self, token):
|
def _set_session_cookie(self, token):
|
||||||
self.send_header("Set-Cookie",
|
cookie = f"platform_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_MAX_AGE}"
|
||||||
f"platform_session={token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={SESSION_MAX_AGE}")
|
if SESSION_COOKIE_SECURE:
|
||||||
|
cookie += "; Secure"
|
||||||
|
self.send_header("Set-Cookie", cookie)
|
||||||
|
|
||||||
def _read_body(self):
|
def _read_body(self):
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from dashboard import (
|
|||||||
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
handle_set_connection, handle_pin, handle_unpin, handle_get_pinned,
|
||||||
)
|
)
|
||||||
from command import handle_command
|
from command import handle_command
|
||||||
|
from feedback import handle_feedback
|
||||||
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
|
from assistant import handle_assistant, handle_fitness_assistant, handle_brain_assistant
|
||||||
from integrations.booklore import (
|
from integrations.booklore import (
|
||||||
handle_booklore_libraries, handle_booklore_import,
|
handle_booklore_libraries, handle_booklore_import,
|
||||||
@@ -243,6 +244,12 @@ class GatewayHandler(ResponseMixin, BaseHTTPRequestHandler):
|
|||||||
handle_command(self, user, body)
|
handle_command(self, user, body)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if path == "/api/feedback":
|
||||||
|
user = self._require_auth()
|
||||||
|
if user:
|
||||||
|
handle_feedback(self, body, user)
|
||||||
|
return
|
||||||
|
|
||||||
if path == "/api/assistant":
|
if path == "/api/assistant":
|
||||||
user = self._require_auth()
|
user = self._require_auth()
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; };
|
A10030 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10030; };
|
||||||
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
|
A10031 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10031; };
|
||||||
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
|
A10032 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10001; };
|
||||||
|
A10033 /* FeedbackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10034; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
B10030 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
B10031 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
B10033 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
B10034 /* FeedbackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackView.swift; sourceTree = "<group>"; };
|
||||||
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
C10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
D10001 /* Platform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Platform.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -128,6 +130,7 @@
|
|||||||
F10006 /* Home */,
|
F10006 /* Home */,
|
||||||
F10007 /* Fitness */,
|
F10007 /* Fitness */,
|
||||||
F10014 /* Assistant */,
|
F10014 /* Assistant */,
|
||||||
|
F10020 /* Feedback */,
|
||||||
);
|
);
|
||||||
path = Features;
|
path = Features;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -221,6 +224,14 @@
|
|||||||
path = Assistant;
|
path = Assistant;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F10020 /* Feedback */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
B10034 /* FeedbackView.swift */,
|
||||||
|
);
|
||||||
|
path = Feedback;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F10015 /* Shared */ = {
|
F10015 /* Shared */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -357,6 +368,7 @@
|
|||||||
A10029 /* LoadingView.swift in Sources */,
|
A10029 /* LoadingView.swift in Sources */,
|
||||||
A10030 /* Color+Extensions.swift in Sources */,
|
A10030 /* Color+Extensions.swift in Sources */,
|
||||||
A10031 /* Date+Extensions.swift in Sources */,
|
A10031 /* Date+Extensions.swift in Sources */,
|
||||||
|
A10033 /* FeedbackView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,11 +40,17 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
|
|
||||||
// Floating + button
|
// Floating buttons
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack {
|
HStack(alignment: .bottom) {
|
||||||
|
// Feedback button (subtle, bottom-left)
|
||||||
|
FeedbackButton()
|
||||||
|
.padding(.leading, 20)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Add food button (prominent, bottom-right)
|
||||||
Button { showAssistant = true } label: {
|
Button { showAssistant = true } label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.title2.weight(.semibold))
|
.font(.title2.weight(.semibold))
|
||||||
|
|||||||
120
ios/Platform/Platform/Features/Feedback/FeedbackView.swift
Normal file
120
ios/Platform/Platform/Features/Feedback/FeedbackView.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FeedbackButton: View {
|
||||||
|
@State private var showSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { showSheet = true } label: {
|
||||||
|
Image(systemName: "exclamationmark.bubble.fill")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Color.surfaceCard.opacity(0.8))
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSheet) {
|
||||||
|
FeedbackSheet()
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FeedbackSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var text = ""
|
||||||
|
@State private var isSending = false
|
||||||
|
@State private var sent = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.textTertiary.opacity(0.3))
|
||||||
|
.frame(width: 36, height: 5)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Text("Feedback")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
|
||||||
|
Text("Bug report, feature request, or anything else")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.textTertiary)
|
||||||
|
|
||||||
|
if sent {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(Color.emerald)
|
||||||
|
Text("Sent! We'll look into it.")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(Color.textPrimary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
TextEditor(text: $text)
|
||||||
|
.font(.body)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.canvas)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
sendFeedback()
|
||||||
|
} label: {
|
||||||
|
if isSending {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Send")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 48)
|
||||||
|
.background(text.trimmingCharacters(in: .whitespaces).isEmpty ? Color.textTertiary : Color.accentWarm)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty || isSending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.surfaceCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendFeedback() {
|
||||||
|
isSending = true
|
||||||
|
error = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let body: [String: String] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
let (data, response) = try await APIClient.shared.rawPost("/api/feedback", body: jsonData)
|
||||||
|
|
||||||
|
if let httpResp = response as? HTTPURLResponse, httpResp.statusCode < 300 {
|
||||||
|
withAnimation { sent = true }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let respBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
error = (respBody?["error"] as? String) ?? "Failed to send"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isSending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
screenshots/ScreenRecording_04-03-2026 10-45-15_1.mp4
Normal file
BIN
screenshots/ScreenRecording_04-03-2026 10-45-15_1.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user