Clear button, toolbar row, admin format defaults, cyberpunk background

Queue toolbar:
- Filter tabs (All/Active/Completed/Failed) and action buttons (Download
  All/Clear) share one row — filters left, actions right
- Download All moved from DownloadTable to DownloadQueue toolbar
- Clear button: muted style → red border on hover → 'Sure?' red confirm
  state → executes on second click, auto-resets after 3s
- Clear removes all completed and failed jobs (leaves active untouched)

Admin format defaults:
- Settings tab has Video/Audio default format dropdowns
- Stored in settings_overrides (same as welcome_message)
- Public config returns default_video_format and default_audio_format
- UrlInput resolves Auto format against admin defaults — if admin sets
  audio default to MP3, 'Auto' chip shows 'Auto (.mp3)' and downloads
  convert accordingly

Cyberpunk animated background:
- Diagonal crossing lines (blue 45° + orange -45°) drift slowly (60s cycle)
- Subtle radial gradient pulse (8s breathing effect)
- Layered on top of the existing grid pattern
- All CSS-only, no JS — zero performance cost
- Only active on cyberpunk theme (scoped to [data-theme=cyberpunk])
This commit is contained in:
xpltd 2026-03-19 04:13:17 -05:00
parent 41c79bdfb2
commit 44eb8c758a
8 changed files with 288 additions and 63 deletions

View file

@ -32,4 +32,6 @@ async def public_config(request: Request) -> dict:
), ),
"purge_enabled": config.purge.enabled, "purge_enabled": config.purge.enabled,
"max_concurrent_downloads": config.downloads.max_concurrent, "max_concurrent_downloads": config.downloads.max_concurrent,
"default_video_format": overrides.get("default_video_format", "auto"),
"default_audio_format": overrides.get("default_audio_format", "auto"),
} }

View file

@ -73,6 +73,8 @@ export interface PublicConfig {
welcome_message: string welcome_message: string
purge_enabled: boolean purge_enabled: boolean
max_concurrent_downloads: number max_concurrent_downloads: number
default_video_format: string
default_audio_format: string
} }
export interface HealthStatus { export interface HealthStatus {

View file

@ -16,6 +16,8 @@ const loadingJobs = ref<Set<string>>(new Set())
// Settings state // Settings state
const welcomeMessage = ref('') const welcomeMessage = ref('')
const defaultVideoFormat = ref('auto')
const defaultAudioFormat = ref('auto')
const settingsSaved = ref(false) const settingsSaved = ref(false)
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@ -34,6 +36,8 @@ async function switchTab(tab: typeof activeTab.value) {
try { try {
const config = await api.getPublicConfig() const config = await api.getPublicConfig()
welcomeMessage.value = config.welcome_message welcomeMessage.value = config.welcome_message
defaultVideoFormat.value = config.default_video_format || 'auto'
defaultAudioFormat.value = config.default_audio_format || 'auto'
} catch { } catch {
// Keep current value // Keep current value
} }
@ -42,7 +46,11 @@ async function switchTab(tab: typeof activeTab.value) {
async function saveSettings() { async function saveSettings() {
settingsSaved.value = false settingsSaved.value = false
const ok = await store.updateSettings({ welcome_message: welcomeMessage.value }) const ok = await store.updateSettings({
welcome_message: welcomeMessage.value,
default_video_format: defaultVideoFormat.value,
default_audio_format: defaultAudioFormat.value,
})
if (ok) { if (ok) {
settingsSaved.value = true settingsSaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000) setTimeout(() => { settingsSaved.value = false }, 3000)
@ -204,6 +212,33 @@ function formatFilesize(bytes: number | null): string {
placeholder="Enter a welcome message…" placeholder="Enter a welcome message…"
></textarea> ></textarea>
</div> </div>
<div class="settings-field" style="margin-top: var(--space-lg);">
<label>Default Output Formats</label>
<p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
<div class="format-defaults">
<div class="format-default-row">
<span class="format-default-label">Video</span>
<select v-model="defaultVideoFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div class="format-default-row">
<span class="format-default-label">Audio</span>
<select v-model="defaultAudioFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp3">MP3</option>
<option value="m4a">M4A (AAC)</option>
<option value="flac">FLAC</option>
<option value="wav">WAV</option>
<option value="opus">Opus</option>
</select>
</div>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<button <button
@click="saveSettings" @click="saveSettings"
@ -413,6 +448,41 @@ h3 {
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.format-defaults {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.format-default-row {
display: flex;
align-items: center;
gap: var(--space-md);
}
.format-default-label {
min-width: 50px;
font-weight: 500;
color: var(--color-text);
}
.settings-select {
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: var(--font-ui);
font-size: var(--font-size-sm);
min-width: 200px;
}
.settings-select:focus {
outline: none;
border-color: var(--color-accent);
}
/* Expandable session rows */ /* Expandable session rows */
.session-row.clickable { .session-row.clickable {
cursor: pointer; cursor: pointer;

View file

@ -31,10 +31,52 @@ const filterCounts = computed(() => ({
function setFilter(f: Filter): void { function setFilter(f: Filter): void {
activeFilter.value = f activeFilter.value = f
} }
// Download All
const completedWithFiles = computed(() =>
store.completedJobs.filter(j => j.filename)
)
function downloadAll(): void {
const jobs = completedWithFiles.value
if (!jobs.length) return
jobs.forEach((job, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/downloads/${encodeURIComponent(job.filename!)}`
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}, i * 300)
})
}
// Clear completed + failed
const clearState = ref<'idle' | 'confirm'>('idle')
let clearTimer: ReturnType<typeof setTimeout> | null = null
const clearableJobs = computed(() =>
store.jobList.filter(j => j.status === 'completed' || j.status === 'failed')
)
function handleClear(): void {
if (clearState.value === 'idle') {
clearState.value = 'confirm'
clearTimer = setTimeout(() => { clearState.value = 'idle' }, 3000)
} else {
if (clearTimer) clearTimeout(clearTimer)
clearState.value = 'idle'
for (const job of clearableJobs.value) {
store.cancelDownload(job.id)
}
}
}
</script> </script>
<template> <template>
<div class="download-queue"> <div class="download-queue">
<div class="queue-toolbar">
<div class="queue-filters"> <div class="queue-filters">
<button <button
v-for="f in (['all', 'active', 'completed', 'failed'] as Filter[])" v-for="f in (['all', 'active', 'completed', 'failed'] as Filter[])"
@ -47,6 +89,26 @@ function setFilter(f: Filter): void {
<span class="filter-count" v-if="filterCounts[f] > 0">({{ filterCounts[f] }})</span> <span class="filter-count" v-if="filterCounts[f] > 0">({{ filterCounts[f] }})</span>
</button> </button>
</div> </div>
<div class="queue-actions" v-if="store.jobList.length > 0">
<button
v-if="completedWithFiles.length > 1"
class="btn-download-all"
@click="downloadAll"
title="Download all completed files"
>
Download All ({{ completedWithFiles.length }})
</button>
<button
v-if="clearableJobs.length > 0"
class="btn-clear"
:class="{ confirming: clearState === 'confirm' }"
@click="handleClear"
:title="clearState === 'confirm' ? 'Click again to clear' : 'Clear completed and failed downloads'"
>
{{ clearState === 'confirm' ? 'Sure?' : 'Clear' }}
</button>
</div>
</div>
<div v-if="filteredJobs.length === 0" class="queue-empty"> <div v-if="filteredJobs.length === 0" class="queue-empty">
<template v-if="activeFilter === 'all'"> <template v-if="activeFilter === 'all'">
@ -68,12 +130,26 @@ function setFilter(f: Filter): void {
gap: var(--space-md); gap: var(--space-md);
} }
.queue-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
}
.queue-filters { .queue-filters {
display: flex; display: flex;
gap: var(--space-xs); gap: var(--space-xs);
flex-wrap: wrap; flex-wrap: wrap;
} }
.queue-actions {
display: flex;
gap: var(--space-xs);
align-items: center;
}
.filter-btn { .filter-btn {
padding: var(--space-xs) var(--space-md); padding: var(--space-xs) var(--space-md);
min-height: 36px; min-height: 36px;
@ -100,6 +176,52 @@ function setFilter(f: Filter): void {
opacity: 0.7; opacity: 0.7;
} }
.btn-download-all {
min-height: 36px;
font-size: var(--font-size-sm);
padding: var(--space-xs) var(--space-md);
background: var(--color-accent);
color: var(--color-bg);
border: 1px solid var(--color-accent);
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: background-color 0.15s;
}
.btn-download-all:hover {
background: var(--color-accent-hover);
}
.btn-clear {
min-height: 36px;
font-size: var(--font-size-sm);
padding: var(--space-xs) var(--space-md);
background: var(--color-surface);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.btn-clear:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.btn-clear.confirming {
background: var(--color-error);
border-color: var(--color-error);
color: var(--color-bg);
font-weight: 600;
}
.btn-clear.confirming:hover {
background: color-mix(in srgb, var(--color-error) 85%, black);
}
.queue-empty { .queue-empty {
padding: var(--space-xl); padding: var(--space-xl);
text-align: center; text-align: center;
@ -109,12 +231,21 @@ function setFilter(f: Filter): void {
/* Mobile: full-width filters */ /* Mobile: full-width filters */
@media (max-width: 767px) { @media (max-width: 767px) {
.queue-toolbar {
flex-direction: column;
align-items: stretch;
}
.queue-filters { .queue-filters {
overflow-x: auto; overflow-x: auto;
flex-wrap: nowrap; flex-wrap: nowrap;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.queue-actions {
justify-content: flex-end;
}
.filter-btn { .filter-btn {
min-height: var(--touch-min); min-height: var(--touch-min);
flex-shrink: 0; flex-shrink: 0;

View file

@ -20,28 +20,6 @@ function showToast(message: string): void {
toastTimer = setTimeout(() => { toast.value = null }, 2000) toastTimer = setTimeout(() => { toast.value = null }, 2000)
} }
// Completed jobs with downloadable files
const completedWithFiles = computed(() =>
props.jobs.filter(j => j.status === 'completed' && j.filename)
)
function downloadAll(): void {
const jobs = completedWithFiles.value
if (!jobs.length) return
// Stagger downloads slightly to avoid browser blocking
jobs.forEach((job, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = downloadUrl(job)
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}, i * 300)
})
showToast(`Downloading ${jobs.length} file${jobs.length > 1 ? 's' : ''}`)
}
// Sort state // Sort state
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta' type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
const sortBy = ref<SortKey>('name') const sortBy = ref<SortKey>('name')
@ -191,11 +169,6 @@ async function clearJob(jobId: string): Promise<void> {
<template> <template>
<div class="download-table-wrap"> <div class="download-table-wrap">
<div v-if="completedWithFiles.length > 1" class="table-toolbar">
<button class="btn-download-all" @click="downloadAll" title="Download all completed files">
Download All ({{ completedWithFiles.length }})
</button>
</div>
<table class="download-table"> <table class="download-table">
<thead> <thead>
<tr> <tr>
@ -320,28 +293,6 @@ async function clearJob(jobId: string): Promise<void> {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.table-toolbar {
display: flex;
justify-content: flex-end;
padding: var(--space-xs) 0;
}
.btn-download-all {
font-size: var(--font-size-sm);
padding: var(--space-xs) var(--space-md);
background: var(--color-accent);
color: var(--color-bg);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
transition: background-color 0.15s;
}
.btn-download-all:hover {
background: var(--color-accent-hover);
}
.download-table { .download-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;

View file

@ -2,10 +2,12 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { api } from '@/api/client' import { api } from '@/api/client'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import { useConfigStore } from '@/stores/config'
import FormatPicker from './FormatPicker.vue' import FormatPicker from './FormatPicker.vue'
import type { FormatInfo, UrlInfo } from '@/api/types' import type { FormatInfo, UrlInfo } from '@/api/types'
const store = useDownloadsStore() const store = useDownloadsStore()
const configStore = useConfigStore()
const url = ref('') const url = ref('')
const formats = ref<FormatInfo[]>([]) const formats = ref<FormatInfo[]>([])
@ -34,6 +36,19 @@ const availableFormats = computed(() =>
mediaType.value === 'audio' ? audioFormats : videoFormats mediaType.value === 'audio' ? audioFormats : videoFormats
) )
/** Resolve the actual output format to send to the backend.
* If 'auto' and admin has set a default, use that instead of null.
*/
const effectiveOutputFormat = computed(() => {
if (outputFormat.value !== 'auto') return outputFormat.value
const cfg = configStore.config
if (!cfg) return null
const adminDefault = mediaType.value === 'audio'
? cfg.default_audio_format
: cfg.default_video_format
return adminDefault && adminDefault !== 'auto' ? adminDefault : null
})
// Persist preferences and reset output format when switching media type // Persist preferences and reset output format when switching media type
watch(mediaType, (val) => { watch(mediaType, (val) => {
localStorage.setItem('mediarip:mediaType', val) localStorage.setItem('mediarip:mediaType', val)
@ -105,7 +120,7 @@ async function submitDownload(): Promise<void> {
format_id: selectedFormatId.value, format_id: selectedFormatId.value,
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null, quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
media_type: mediaType.value, media_type: mediaType.value,
output_format: outputFormat.value === 'auto' ? null : outputFormat.value, output_format: effectiveOutputFormat.value,
}) })
} }
} else { } else {
@ -114,7 +129,7 @@ async function submitDownload(): Promise<void> {
format_id: selectedFormatId.value, format_id: selectedFormatId.value,
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null, quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
media_type: mediaType.value, media_type: mediaType.value,
output_format: outputFormat.value === 'auto' ? null : outputFormat.value, output_format: effectiveOutputFormat.value,
}) })
} }
// Reset form on success // Reset form on success
@ -214,6 +229,11 @@ function toggleOptions(): void {
function formatLabel(fmt: string): string { function formatLabel(fmt: string): string {
if (fmt === 'auto') { if (fmt === 'auto') {
// Show what format will actually be used
const effective = effectiveOutputFormat.value
if (effective) {
return `Auto (.${effective})`
}
if (urlInfo.value?.default_ext) { if (urlInfo.value?.default_ext) {
return `Auto (.${urlInfo.value.default_ext})` return `Auto (.${urlInfo.value.default_ext})`
} }

View file

@ -31,6 +31,8 @@ describe('config store', () => {
welcome_message: 'Test welcome', welcome_message: 'Test welcome',
purge_enabled: false, purge_enabled: false,
max_concurrent_downloads: 3, max_concurrent_downloads: 3,
default_video_format: 'auto',
default_audio_format: 'auto',
} }
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig) vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)

View file

@ -79,3 +79,50 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15); --shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15);
} }
/* Animated geometric background — cyberpunk only */
:root[data-theme="cyberpunk"] body::after {
background:
/* Diagonal lines moving slowly */
repeating-linear-gradient(
45deg,
transparent,
transparent 60px,
rgba(0, 168, 255, 0.015) 60px,
rgba(0, 168, 255, 0.015) 61px
),
repeating-linear-gradient(
-45deg,
transparent,
transparent 80px,
rgba(255, 107, 43, 0.01) 80px,
rgba(255, 107, 43, 0.01) 81px
),
/* Base grid */
linear-gradient(rgba(0, 168, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 168, 255, 0.025) 1px, transparent 1px);
background-size: 200px 200px, 240px 240px, 32px 32px, 32px 32px;
animation: grid-drift 60s linear infinite;
}
@keyframes grid-drift {
0% {
background-position: 0 0, 0 0, 0 0, 0 0;
}
100% {
background-position: 200px 200px, -240px 240px, 32px 32px, 32px 32px;
}
}
/* Subtle pulsing glow dot at intersections — extra depth */
:root[data-theme="cyberpunk"] body {
background-image:
radial-gradient(circle at 50% 50%, rgba(0, 168, 255, 0.04) 0%, transparent 70%);
background-size: 100% 100%;
animation: bg-pulse 8s ease-in-out infinite alternate;
}
@keyframes bg-pulse {
0% { background-size: 100% 100%; }
100% { background-size: 120% 120%; }
}