mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
41c79bdfb2
commit
44eb8c758a
8 changed files with 288 additions and 63 deletions
|
|
@ -32,4 +32,6 @@ async def public_config(request: Request) -> dict:
|
|||
),
|
||||
"purge_enabled": config.purge.enabled,
|
||||
"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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ export interface PublicConfig {
|
|||
welcome_message: string
|
||||
purge_enabled: boolean
|
||||
max_concurrent_downloads: number
|
||||
default_video_format: string
|
||||
default_audio_format: string
|
||||
}
|
||||
|
||||
export interface HealthStatus {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const loadingJobs = ref<Set<string>>(new Set())
|
|||
|
||||
// Settings state
|
||||
const welcomeMessage = ref('')
|
||||
const defaultVideoFormat = ref('auto')
|
||||
const defaultAudioFormat = ref('auto')
|
||||
const settingsSaved = ref(false)
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
|
|
@ -34,6 +36,8 @@ async function switchTab(tab: typeof activeTab.value) {
|
|||
try {
|
||||
const config = await api.getPublicConfig()
|
||||
welcomeMessage.value = config.welcome_message
|
||||
defaultVideoFormat.value = config.default_video_format || 'auto'
|
||||
defaultAudioFormat.value = config.default_audio_format || 'auto'
|
||||
} catch {
|
||||
// Keep current value
|
||||
}
|
||||
|
|
@ -42,7 +46,11 @@ async function switchTab(tab: typeof activeTab.value) {
|
|||
|
||||
async function saveSettings() {
|
||||
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) {
|
||||
settingsSaved.value = true
|
||||
setTimeout(() => { settingsSaved.value = false }, 3000)
|
||||
|
|
@ -204,6 +212,33 @@ function formatFilesize(bytes: number | null): string {
|
|||
placeholder="Enter a welcome message…"
|
||||
></textarea>
|
||||
</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">
|
||||
<button
|
||||
@click="saveSettings"
|
||||
|
|
@ -413,6 +448,41 @@ h3 {
|
|||
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 */
|
||||
.session-row.clickable {
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -31,21 +31,83 @@ const filterCounts = computed(() => ({
|
|||
function setFilter(f: Filter): void {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="download-queue">
|
||||
<div class="queue-filters">
|
||||
<button
|
||||
v-for="f in (['all', 'active', 'completed', 'failed'] as Filter[])"
|
||||
:key="f"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeFilter === f }"
|
||||
@click="setFilter(f)"
|
||||
>
|
||||
{{ f }}
|
||||
<span class="filter-count" v-if="filterCounts[f] > 0">({{ filterCounts[f] }})</span>
|
||||
</button>
|
||||
<div class="queue-toolbar">
|
||||
<div class="queue-filters">
|
||||
<button
|
||||
v-for="f in (['all', 'active', 'completed', 'failed'] as Filter[])"
|
||||
:key="f"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeFilter === f }"
|
||||
@click="setFilter(f)"
|
||||
>
|
||||
{{ f }}
|
||||
<span class="filter-count" v-if="filterCounts[f] > 0">({{ filterCounts[f] }})</span>
|
||||
</button>
|
||||
</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">
|
||||
|
|
@ -68,12 +130,26 @@ function setFilter(f: Filter): void {
|
|||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.queue-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-filters {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
min-height: 36px;
|
||||
|
|
@ -100,6 +176,52 @@ function setFilter(f: Filter): void {
|
|||
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 {
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
|
|
@ -109,12 +231,21 @@ function setFilter(f: Filter): void {
|
|||
|
||||
/* Mobile: full-width filters */
|
||||
@media (max-width: 767px) {
|
||||
.queue-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.queue-filters {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.queue-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-height: var(--touch-min);
|
||||
flex-shrink: 0;
|
||||
|
|
|
|||
|
|
@ -20,28 +20,6 @@ function showToast(message: string): void {
|
|||
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
|
||||
type SortKey = 'name' | 'status' | 'progress' | 'speed' | 'eta'
|
||||
const sortBy = ref<SortKey>('name')
|
||||
|
|
@ -191,11 +169,6 @@ async function clearJob(jobId: string): Promise<void> {
|
|||
|
||||
<template>
|
||||
<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">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -320,28 +293,6 @@ async function clearJob(jobId: string): Promise<void> {
|
|||
-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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import FormatPicker from './FormatPicker.vue'
|
||||
import type { FormatInfo, UrlInfo } from '@/api/types'
|
||||
|
||||
const store = useDownloadsStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const url = ref('')
|
||||
const formats = ref<FormatInfo[]>([])
|
||||
|
|
@ -34,6 +36,19 @@ const availableFormats = computed(() =>
|
|||
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
|
||||
watch(mediaType, (val) => {
|
||||
localStorage.setItem('mediarip:mediaType', val)
|
||||
|
|
@ -105,7 +120,7 @@ async function submitDownload(): Promise<void> {
|
|||
format_id: selectedFormatId.value,
|
||||
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
|
||||
media_type: mediaType.value,
|
||||
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
|
||||
output_format: effectiveOutputFormat.value,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
|
@ -114,7 +129,7 @@ async function submitDownload(): Promise<void> {
|
|||
format_id: selectedFormatId.value,
|
||||
quality: mediaType.value === 'audio' && !selectedFormatId.value ? 'bestaudio' : null,
|
||||
media_type: mediaType.value,
|
||||
output_format: outputFormat.value === 'auto' ? null : outputFormat.value,
|
||||
output_format: effectiveOutputFormat.value,
|
||||
})
|
||||
}
|
||||
// Reset form on success
|
||||
|
|
@ -214,6 +229,11 @@ function toggleOptions(): void {
|
|||
|
||||
function formatLabel(fmt: string): string {
|
||||
if (fmt === 'auto') {
|
||||
// Show what format will actually be used
|
||||
const effective = effectiveOutputFormat.value
|
||||
if (effective) {
|
||||
return `Auto (.${effective})`
|
||||
}
|
||||
if (urlInfo.value?.default_ext) {
|
||||
return `Auto (.${urlInfo.value.default_ext})`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ describe('config store', () => {
|
|||
welcome_message: 'Test welcome',
|
||||
purge_enabled: false,
|
||||
max_concurrent_downloads: 3,
|
||||
default_video_format: 'auto',
|
||||
default_audio_format: 'auto',
|
||||
}
|
||||
vi.mocked(api.getPublicConfig).mockResolvedValue(mockConfig)
|
||||
|
||||
|
|
|
|||
|
|
@ -79,3 +79,50 @@
|
|||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--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%; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue