From e2dbb665253db5689ee4802e510a615e5523727c Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 21 Jun 2026 19:46:26 -0700 Subject: [PATCH] feat: web UI + HTTP server + Electron shell - serve.go: embedded web UI (embed.FS), REST API endpoints for search, list, download (SSE progress), and WAV extraction - main.go: added serve subcommand and flag registration - web/ui: vanilla JS frontend with search cards, download queue, extract controls; dark theme, zero dependencies - electron/: Electron wrapper that spawns fetch serve as sidecar, reads port from stdout, creates BrowserWindow; electron-builder config for macOS .app bundle with akaiutil + script as resources Refs #1, #9 --- electron/main.js | 122 ++++++++++++++++ electron/package.json | 66 +++++++++ electron/preload.js | 5 + main.go | 9 +- serve.go | 322 ++++++++++++++++++++++++++++++++++++++++++ web/ui/app.js | 206 +++++++++++++++++++++++++++ web/ui/index.html | 45 ++++++ web/ui/style.css | 208 +++++++++++++++++++++++++++ 8 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 electron/main.js create mode 100644 electron/package.json create mode 100644 electron/preload.js create mode 100644 serve.go create mode 100644 web/ui/app.js create mode 100644 web/ui/index.html create mode 100644 web/ui/style.css diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..a7fb24b --- /dev/null +++ b/electron/main.js @@ -0,0 +1,122 @@ +const { app, BrowserWindow, dialog } = require('electron'); +const path = require('path'); +const { spawn } = require('child_process'); + +let mainWindow; +let serverProcess; +let serverPort = 0; + +function findServerBinary() { + const candidates = [ + path.join(__dirname, '..', 'fetch'), + path.join(__dirname, '..', 'akai-fetch'), + path.join(process.resourcesPath, 'fetch'), + path.join(process.resourcesPath, 'akai-fetch'), + ]; + for (const p of candidates) { + try { + require('fs').accessSync(p, require('fs').constants.X_OK); + return p; + } catch (_) {} + } + return null; +} + +async function startServer() { + const bin = findServerBinary(); + if (!bin) { + dialog.showErrorBox('Missing Binary', + 'Could not find fetch/akai-fetch binary. Place it next to the app or in resources.'); + app.quit(); + return; + } + + const usePort = process.env.AKAI_PORT ? parseInt(process.env.AKAI_PORT) : 0; + + return new Promise((resolve, reject) => { + serverProcess = spawn(bin, ['serve', '-p', String(usePort), '--no-browser'], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let output = ''; + serverProcess.stdout.on('data', (data) => { + output += data.toString(); + const m = output.match(/Local:\s+http:\/\/localhost:(\d+)/); + if (m) { + serverPort = parseInt(m[1]); + resolve(); + } + }); + + serverProcess.stderr.on('data', (data) => { + console.error('[server]', data.toString().trim()); + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + reject(err); + }); + + serverProcess.on('close', (code) => { + if (serverPort === 0) { + reject(new Error(`Server exited with code ${code}`)); + } + }); + }); +} + +async function createWindow() { + try { + await startServer(); + } catch (err) { + dialog.showErrorBox('Server Error', String(err)); + app.quit(); + return; + } + + mainWindow = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + title: 'AKAI Utils', + backgroundColor: '#1a1a2e', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.loadURL(`http://localhost:${serverPort}`); + mainWindow.setMenuBarVisibility(false); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (serverProcess) { + serverProcess.kill(); + serverProcess = null; + } + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (mainWindow === null) { + createWindow(); + } +}); + +app.on('before-quit', () => { + if (serverProcess) { + serverProcess.kill(); + serverProcess = null; + } +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..925b3c1 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,66 @@ +{ + "name": "akai-utils", + "version": "0.2.0", + "description": "AKAI Sampler ISO Downloader, WAV Extractor & Library Manager", + "main": "main.js", + "build": { + "appId": "dev.akai.utils", + "productName": "AKAI Utils", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "package.json" + ], + "extraResources": [ + { + "from": "../fetch", + "to": "fetch" + }, + { + "from": "../scripts/extract_wavs.sh", + "to": "extract_wavs.sh" + } + ], + "mac": { + "category": "public.app-category.music", + "target": [ + { + "target": "dir", + "arch": ["x64", "arm64"] + } + ], + "artifactName": "${name}-${version}-${arch}.${ext}", + "extraResources": [ + { + "from": "../fetch", + "to": "fetch" + }, + { + "from": "../scripts/extract_wavs.sh", + "to": "extract_wavs.sh" + }, + { + "from": "../third_party/akaiutil/akaiutil", + "to": "akaiutil" + } + ] + }, + "linux": { + "target": ["dir"], + "category": "Audio" + } + }, + "scripts": { + "start": "electron .", + "build": "electron-builder", + "build:mac": "electron-builder --mac", + "build:linux": "electron-builder --linux" + }, + "devDependencies": { + "electron": "^33.0.0", + "electron-builder": "^25.0.0" + } +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..0fa8dd0 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,5 @@ +const { contextBridge } = require('electron'); + +contextBridge.exposeInMainWorld('electronAPI', { + platform: process.platform, +}); diff --git a/main.go b/main.go index f9525af..1b79be7 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,8 @@ func main() { cmdExtract(args) case "pipeline": cmdPipeline(args) + case "serve": + cmdServe(args) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd) printUsage() @@ -84,6 +86,7 @@ Usage: akai-fetch download [flags] download ISOs (from file or by identifier) akai-fetch extract [flags] extract WAVs from downloaded ISOs akai-fetch pipeline [flags] full search → download → extract pipeline + akai-fetch serve [flags] start HTTP server with web UI Search flags: -query "akai sampler" search query (default: "subject:akai AND subject:sampler") @@ -109,7 +112,11 @@ Pipeline flags: -limit 30 max results -dir "./output" base output directory -download-jobs 2 concurrent downloads - -extract-jobs 2 concurrent extractions`) + -extract-jobs 2 concurrent extractions + +Serve flags: + -p, --port 0 HTTP port (0 = auto-assign) + --no-browser don't open browser automatically`) } // ─── Search ──────────────────────────────────────────────────────────── diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..56c06b4 --- /dev/null +++ b/serve.go @@ -0,0 +1,322 @@ +package main + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed web/ui/* +var webUI embed.FS + +func cmdServe(args []string) { + port := 0 + openBrowser := true + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-p", "--port": + if i+1 < len(args) { + port, _ = strconv.Atoi(args[i+1]) + i++ + } + case "--no-browser": + openBrowser = false + } + } + + mux := http.NewServeMux() + + uiFS, err := fs.Sub(webUI, "web/ui") + if err != nil { + fatal("embed web/ui: %v", err) + } + mux.Handle("/", http.FileServer(http.FS(uiFS))) + + mux.HandleFunc("/api/search", handleSearch) + mux.HandleFunc("/api/list", handleList) + mux.HandleFunc("/api/download", handleAPIDownload) + mux.HandleFunc("/api/extract", handleAPIExtract) + mux.HandleFunc("/api/progress", handleProgress) + + addr := fmt.Sprintf("127.0.0.1:%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + fatal("listen: %v", err) + } + + realPort := listener.Addr().(*net.TCPAddr).Port + + fmt.Printf("\n AKAI Utils Server\n") + fmt.Printf(" =================\n\n") + fmt.Printf(" Local: http://localhost:%d\n", realPort) + fmt.Printf(" API: http://localhost:%d/api/\n", realPort) + fmt.Printf(" Quit: Ctrl+C\n\n") + + if openBrowser { + go func() { + time.Sleep(500 * time.Millisecond) + openURL(fmt.Sprintf("http://localhost:%d", realPort)) + }() + } + + server := &http.Server{Handler: mux} + log.Fatal(server.Serve(listener)) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + filter := r.URL.Query().Get("filter") + limitStr := r.URL.Query().Get("limit") + + if q == "" { + q = "subject:akai AND subject:sampler" + } + if filter != "" { + q = "(" + q + ") AND (" + filter + ")" + } + + limit := 30 + if limitStr != "" { + limit, _ = strconv.Atoi(limitStr) + } + + results := searchArchive(q, limit) + sort.Slice(results, func(i, j int) bool { + return results[i].Downloads > results[j].Downloads + }) + + writeJSON(w, results) +} + +func handleList(w http.ResponseWriter, r *http.Request) { + identifier := r.URL.Query().Get("identifier") + if identifier == "" { + http.Error(w, "missing identifier", http.StatusBadRequest) + return + } + files := getItemFiles(identifier) + writeJSON(w, files) +} + +var ( + downloadJobs = make(map[string]*downloadState) + downloadJobsMu sync.Mutex +) + +type downloadState struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + FileName string `json:"file_name"` + TotalSize int64 `json:"total_size"` + Downloaded int64 `json:"downloaded"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` + Speed float64 `json:"speed"` + lastCheck time.Time + lastBytes int64 +} + +func handleAPIDownload(w http.ResponseWriter, r *http.Request) { + identifier := r.URL.Query().Get("identifier") + outDir := r.URL.Query().Get("dir") + if identifier == "" { + http.Error(w, "missing identifier", http.StatusBadRequest) + return + } + if outDir == "" { + outDir = "." + } + + os.MkdirAll(outDir, 0755) + + files := getItemFiles(identifier) + if len(files) == 0 { + writeJSON(w, map[string]any{"error": "no ISO files found"}) + return + } + + var jobIDs []string + for _, f := range files { + size, _ := strconv.ParseInt(f.Size, 10, 64) + jobID := identifier + "/" + f.Name + state := &downloadState{ + ID: jobID, + Identifier: identifier, + FileName: f.Name, + TotalSize: size, + lastCheck: time.Now(), + } + downloadJobsMu.Lock() + downloadJobs[jobID] = state + downloadJobsMu.Unlock() + jobIDs = append(jobIDs, jobID) + + go func(j downloadJob, s *downloadState) { + client := &http.Client{Timeout: 0} + req, _ := http.NewRequest("GET", j.URL, nil) + req.Header.Set("User-Agent", userAgent) + + if fi, err := os.Stat(j.OutPath); err == nil && j.Size > 0 && fi.Size() == j.Size { + s.Downloaded = j.Size + s.Done = true + return + } + + resp, err := client.Do(req) + if err != nil { + s.Error = err.Error() + return + } + defer resp.Body.Close() + + outFile, err := os.Create(j.OutPath) + if err != nil { + s.Error = err.Error() + return + } + defer outFile.Close() + + buf := make([]byte, 32*1024) + for { + n, readErr := resp.Body.Read(buf) + if n > 0 { + outFile.Write(buf[:n]) + s.Downloaded += int64(n) + + now := time.Now() + elapsed := now.Sub(s.lastCheck).Seconds() + if elapsed >= 1.0 { + s.Speed = float64(s.Downloaded-s.lastBytes) / elapsed + s.lastCheck = now + s.lastBytes = s.Downloaded + } + } + if readErr != nil { + break + } + } + s.Done = true + s.Speed = 0 + }(downloadJob{ + Identifier: identifier, + Name: f.Name, + URL: fmt.Sprintf("%s/download/%s/%s", iaBaseURL, url.PathEscape(identifier), url.PathEscape(f.Name)), + OutPath: filepath.Join(outDir, f.Name), + Size: size, + }, state) + } + + writeJSON(w, map[string]any{"jobs": jobIDs}) +} + +func handleProgress(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for range ticker.C { + downloadJobsMu.Lock() + data, _ := json.Marshal(downloadJobs) + downloadJobsMu.Unlock() + + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + allDone := true + downloadJobsMu.Lock() + for _, s := range downloadJobs { + if !s.Done && s.Error == "" { + allDone = false + break + } + } + downloadJobsMu.Unlock() + if allDone && len(downloadJobs) > 0 { + break + } + } +} + +func handleAPIExtract(w http.ResponseWriter, r *http.Request) { + isoDir := r.URL.Query().Get("dir") + outDir := r.URL.Query().Get("out") + if isoDir == "" { + isoDir = "." + } + if outDir == "" { + outDir = "./wavs" + } + + toolPath := findScript("extract_wavs.sh") + + entries, err := os.ReadDir(isoDir) + if err != nil { + writeJSON(w, map[string]any{"error": err.Error()}) + return + } + + var isos []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".iso") { + isos = append(isos, filepath.Join(isoDir, e.Name())) + } + } + + if len(isos) == 0 { + writeJSON(w, map[string]any{"error": "no ISO files found in " + isoDir}) + return + } + + var messages []string + for _, isoPath := range isos { + result, err := extractISO(toolPath, isoPath, outDir) + if err != nil { + messages = append(messages, fmt.Sprintf("FAIL %s: %v", filepath.Base(isoPath), err)) + } else { + messages = append(messages, fmt.Sprintf("OK %s: %s", filepath.Base(isoPath), result)) + } + } + + writeJSON(w, map[string]any{ + "message": fmt.Sprintf("Extracted %d ISOs: %s", len(isos), strings.Join(messages, "; ")), + }) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + json.NewEncoder(w).Encode(v) +} + +func openURL(rawURL string) { + exec.Command("xdg-open", rawURL).Start() + exec.Command("open", rawURL).Start() + exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL).Start() +} + +func init() { + log.SetFlags(0) +} diff --git a/web/ui/app.js b/web/ui/app.js new file mode 100644 index 0000000..b08d0a3 --- /dev/null +++ b/web/ui/app.js @@ -0,0 +1,206 @@ +// AKAI Utils — Web UI +const API = '/api'; + +let eventSource = null; + +// ─── Search ──────────────────────────────────────────────────────── + +document.getElementById('search-btn').addEventListener('click', doSearch); +document.getElementById('search-query').addEventListener('keydown', e => { + if (e.key === 'Enter') doSearch(); +}); +document.getElementById('search-dl-all').addEventListener('click', downloadTop10); + +async function doSearch() { + const q = document.getElementById('search-query').value; + const filter = document.getElementById('search-filter').value; + const status = document.getElementById('search-status'); + const results = document.getElementById('search-results'); + + status.textContent = 'Searching...'; + results.innerHTML = ''; + + try { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (filter) params.set('filter', filter); + params.set('limit', '50'); + + const resp = await fetch(`${API}/search?${params}`); + const data = await resp.json(); + status.textContent = `Found ${data.length} items`; + renderResults(data); + } catch (e) { + status.textContent = 'Search failed: ' + e.message; + } +} + +function renderResults(items) { + const container = document.getElementById('search-results'); + container.innerHTML = items.map(r => ` +
+
${esc(r.title)}
+
+ ${r.identifier} + ${r.downloads.toLocaleString()} downloads +
+
+ + +
+
+
+ `).join(''); +} + +async function downloadTop10() { + const q = document.getElementById('search-query').value; + const filter = document.getElementById('search-filter').value; + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (filter) params.set('filter', filter); + params.set('limit', '10'); + + try { + const resp = await fetch(`${API}/search?${params}`); + const data = await resp.json(); + for (const item of data) { + await downloadItem(item.identifier); + } + } catch (e) { + alert('Bulk download failed: ' + e.message); + } +} + +// ─── List ────────────────────────────────────────────────────────── + +async function listItem(identifier) { + const el = document.getElementById('list-' + sanitize(identifier)); + el.innerHTML = 'Loading...'; + + try { + const resp = await fetch(`${API}/list?identifier=${encodeURIComponent(identifier)}`); + const files = await resp.json(); + if (!Array.isArray(files) || files.length === 0) { + el.innerHTML = 'No ISO files'; + return; + } + el.innerHTML = files.map(f => + `
${esc(f.name)} ${f.size || '?'}
` + ).join(''); + } catch (e) { + el.innerHTML = 'Failed: ' + e.message; + } +} + +// ─── Download ────────────────────────────────────────────────────── + +async function downloadItem(identifier) { + try { + const resp = await fetch(`${API}/download?identifier=${encodeURIComponent(identifier)}&dir=./output/isos`); + const data = await resp.json(); + if (data.error) { + alert(data.error); + return; + } + startProgressStream(); + } catch (e) { + alert('Download failed: ' + e.message); + } +} + +function startProgressStream() { + if (eventSource) eventSource.close(); + eventSource = new EventSource(`${API}/progress`); + + eventSource.onmessage = function(e) { + const jobs = JSON.parse(e.data); + renderDownloadQueue(jobs); + + const allDone = Object.values(jobs).every(j => j.done || j.error); + if (allDone && Object.keys(jobs).length > 0) { + eventSource.close(); + eventSource = null; + } + }; + + eventSource.onerror = function() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + }; +} + +function renderDownloadQueue(jobs) { + const container = document.getElementById('download-queue'); + const count = document.getElementById('dl-count'); + const entries = Object.values(jobs); + count.textContent = `(${entries.length})`; + + container.innerHTML = entries.map(j => { + const pct = j.total_size > 0 ? Math.round(j.downloaded / j.total_size * 100) : 0; + let cls = ''; + if (j.error) cls = 'error'; + else if (j.done) cls = 'done'; + + let info = ''; + if (j.error) info = j.error; + else if (j.done) info = formatBytes(j.total_size) + ' — complete'; + else info = formatBytes(j.downloaded) + ' / ' + formatBytes(j.total_size) + + (j.speed > 0 ? ' — ' + formatBytes(j.speed) + '/s' : ''); + + return ` +
+
${esc(j.file_name)}
+
+
${info}${j.done && !j.error ? '✓' : pct + '%'}
+
`; + }).join(''); +} + +// ─── Extract ─────────────────────────────────────────────────────── + +document.getElementById('extract-btn').addEventListener('click', doExtract); + +async function doExtract() { + const isoDir = document.getElementById('extract-iso-dir').value; + const wavDir = document.getElementById('extract-wav-dir').value; + const status = document.getElementById('extract-status'); + + status.textContent = 'Extracting... (check server console for output)'; + status.style.color = 'var(--yellow)'; + + try { + const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`); + const data = await resp.json(); + status.textContent = data.message || 'Extraction complete'; + status.style.color = 'var(--green)'; + } catch (e) { + status.textContent = 'Extraction failed: ' + e.message; + status.style.color = 'var(--accent)'; + } +} + +// ─── Helpers ────────────────────────────────────────────────────── + +function esc(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +function sanitize(s) { + return s.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +// Initial load +doSearch(); diff --git a/web/ui/index.html b/web/ui/index.html new file mode 100644 index 0000000..fe4b4ad --- /dev/null +++ b/web/ui/index.html @@ -0,0 +1,45 @@ + + + + + +AKAI Utils + + + +
+
+

AKAI Utils

+ Sampler ISO Downloader & WAV Extractor +
+ +
+ +
+
+
+ +
+

Downloads

+
+
+ +
+

WAV Extraction

+
+ + + +
+
+
+
+ + + diff --git a/web/ui/style.css b/web/ui/style.css new file mode 100644 index 0000000..82bdc98 --- /dev/null +++ b/web/ui/style.css @@ -0,0 +1,208 @@ +:root { + --bg: #1a1a2e; + --bg2: #16213e; + --bg3: #0f3460; + --text: #e0e0e0; + --text2: #a0a0b0; + --accent: #e94560; + --accent2: #533483; + --green: #4ecca3; + --yellow: #f0c040; + --radius: 6px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.5; + min-height: 100vh; +} + +#app { + max-width: 1100px; + margin: 0 auto; + padding: 20px; +} + +header { + text-align: center; + padding: 30px 0 20px; + border-bottom: 1px solid var(--bg3); + margin-bottom: 24px; +} +header h1 { + font-size: 28px; + color: var(--accent); + letter-spacing: 1px; +} +header .subtitle { + color: var(--text2); + font-size: 14px; +} + +section { + background: var(--bg2); + border-radius: var(--radius); + padding: 20px; + margin-bottom: 20px; +} + +h2 { + font-size: 16px; + color: var(--text2); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.search-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.search-bar input { + flex: 1; + min-width: 200px; + padding: 10px 14px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.search-bar input:focus { + outline: none; + border-color: var(--accent); +} +.search-bar button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: white; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} +.search-bar button:hover { opacity: 0.9; } +#search-dl-all { + background: var(--green); + color: #111; +} + +#search-status { + margin-top: 10px; + color: var(--text2); + font-size: 13px; +} + +#search-results { + margin-top: 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 10px; +} + +.result-card { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; +} +.result-card .title { + font-weight: 600; + font-size: 14px; + word-break: break-word; +} +.result-card .meta { + font-size: 12px; + color: var(--text2); + display: flex; + justify-content: space-between; +} +.result-card .actions { + display: flex; + gap: 6px; +} +.result-card button { + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} +.btn-dl { background: var(--accent); color: white; } +.btn-list { background: var(--bg3); color: var(--text); } +.btn-dl:hover, .btn-list:hover { opacity: 0.85; } + +.dl-item { + background: var(--bg); + border: 1px solid var(--bg3); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 8px; +} +.dl-item .dl-name { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; + word-break: break-all; +} +.dl-item .dl-bar { + height: 6px; + background: var(--bg3); + border-radius: 3px; + overflow: hidden; + margin-bottom: 4px; +} +.dl-item .dl-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s; +} +.dl-item .dl-bar-fill.done { background: var(--green); } +.dl-item .dl-bar-fill.error { background: var(--yellow); } +.dl-item .dl-info { + font-size: 11px; + color: var(--text2); + display: flex; + justify-content: space-between; +} + +.extract-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.extract-controls input { + flex: 1; + min-width: 150px; + padding: 10px 14px; + border: 1px solid var(--bg3); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.extract-controls button { + padding: 10px 20px; + border: none; + border-radius: var(--radius); + background: var(--accent2); + color: white; + font-size: 14px; + cursor: pointer; +} +#extract-status { + margin-top: 10px; + font-size: 13px; + color: var(--text2); +} \ No newline at end of file