Download button gating, format defaults fix, layout/UX polish

Download button:
- Disabled until URL analysis confirms downloadable content
- Shows error message for invalid URLs or pages with no media
- analyzeError state resets when URL is cleared or changed

Admin format defaults fix:
- AdminPanel now reloads configStore after saving settings
- Previously the main page kept stale config until full page refresh
- Config store import added to AdminPanel

Re-download same URL:
- Added overwrites: true to yt-dlp opts so re-downloading the
  same URL with different format options works correctly
- Previously yt-dlp would skip if intermediate file existed

UI polish:
- Clear button fixed-width (min-width: 70px) — no shift between
  'Clear' and 'Sure?' states
- Action buttons uniform sizing (inline-flex, min-width/height,
  box-sizing: border-box) — download/copy/clear all same size
- Footer no longer pushes below viewport — App.vue uses flex
  column layout, AppLayout uses flex:1 instead of min-height:100vh
- Page only scrolls when content exceeds viewport
This commit is contained in:
xpltd 2026-03-19 04:50:52 -05:00
parent 635da2be82
commit 87f7996d5d
7 changed files with 45 additions and 6 deletions

View file

@ -159,6 +159,7 @@ class DownloadService:
"no_warnings": True, "no_warnings": True,
"noprogress": True, "noprogress": True,
"noplaylist": True, # Individual jobs — don't re-expand playlists "noplaylist": True, # Individual jobs — don't re-expand playlists
"overwrites": True, # Allow re-downloading same URL with different format
} }
if job_create.format_id: if job_create.format_id:
opts["format"] = job_create.format_id opts["format"] = job_create.format_id

View file

@ -22,7 +22,17 @@ onMounted(async () => {
</script> </script>
<template> <template>
<AppHeader /> <div class="app-root">
<router-view /> <AppHeader />
<AppFooter /> <router-view />
<AppFooter />
</div>
</template> </template>
<style>
.app-root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
</style>

View file

@ -2,10 +2,12 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useConfigStore } from '@/stores/config'
import { api } from '@/api/client' import { api } from '@/api/client'
import AdminLogin from './AdminLogin.vue' import AdminLogin from './AdminLogin.vue'
const store = useAdminStore() const store = useAdminStore()
const configStore = useConfigStore()
const router = useRouter() const router = useRouter()
const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions') const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
@ -52,6 +54,8 @@ async function saveSettings() {
default_audio_format: defaultAudioFormat.value, default_audio_format: defaultAudioFormat.value,
}) })
if (ok) { if (ok) {
// Reload public config so main page picks up new defaults
await configStore.loadConfig()
settingsSaved.value = true settingsSaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000) setTimeout(() => { settingsSaved.value = false }, 3000)
} }

View file

@ -52,7 +52,7 @@ const activeTab = ref<MobileTab>('submit')
.app-layout { .app-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: calc(100vh - var(--header-height)); flex: 1;
} }
.layout-main { .layout-main {

View file

@ -195,6 +195,7 @@ function handleClear(): void {
.btn-clear { .btn-clear {
min-height: 36px; min-height: 36px;
min-width: 70px;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
padding: var(--space-xs) var(--space-md); padding: var(--space-xs) var(--space-md);
background: var(--color-surface); background: var(--color-surface);

View file

@ -441,11 +441,13 @@ async function clearJob(jobId: string): Promise<void> {
} }
.action-btn { .action-btn {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 30px; width: 30px;
height: 30px; height: 30px;
min-width: 30px;
min-height: 30px;
padding: 0; padding: 0;
background: transparent; background: transparent;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -454,6 +456,8 @@ async function clearJob(jobId: string): Promise<void> {
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
text-decoration: none; text-decoration: none;
box-sizing: border-box;
line-height: 1;
} }
.action-btn:hover { .action-btn:hover {

View file

@ -22,6 +22,7 @@ const audioLocked = ref(false) // true when source is audio-only
// Unified loading state for URL check + format extraction // Unified loading state for URL check + format extraction
const isAnalyzing = ref(false) const isAnalyzing = ref(false)
const analyzePhase = ref<string>('') const analyzePhase = ref<string>('')
const analyzeError = ref<string | null>(null)
const phaseMessages = [ const phaseMessages = [
'Peeking at the URL…', 'Peeking at the URL…',
'Interrogating the server…', 'Interrogating the server…',
@ -95,6 +96,7 @@ watch(url, (newUrl) => {
formats.value = [] formats.value = []
selectedFormatId.value = null selectedFormatId.value = null
extractError.value = null extractError.value = null
analyzeError.value = null
audioLocked.value = false audioLocked.value = false
showOptions.value = false showOptions.value = false
selectedEntries.value = new Set() selectedEntries.value = new Set()
@ -104,6 +106,7 @@ watch(url, (newUrl) => {
formats.value = [] formats.value = []
selectedFormatId.value = null selectedFormatId.value = null
extractError.value = null extractError.value = null
analyzeError.value = null
audioLocked.value = false audioLocked.value = false
selectedEntries.value = new Set() selectedEntries.value = new Set()
} }
@ -115,6 +118,11 @@ const selectedEntries = ref<Set<number>>(new Set())
/** Whether formats have been fetched for the current URL. */ /** Whether formats have been fetched for the current URL. */
const formatsReady = computed(() => formats.value.length > 0) const formatsReady = computed(() => formats.value.length > 0)
/** URL has been analyzed and content was found. */
const urlReady = computed(() =>
!!urlInfo.value && urlInfo.value.type !== 'unknown'
)
async function extractFormats(): Promise<void> { async function extractFormats(): Promise<void> {
const trimmed = url.value.trim() const trimmed = url.value.trim()
if (!trimmed) return if (!trimmed) return
@ -180,9 +188,16 @@ function handlePaste(): void {
setTimeout(async () => { setTimeout(async () => {
if (url.value.trim()) { if (url.value.trim()) {
isAnalyzing.value = true isAnalyzing.value = true
analyzeError.value = null
startAnalyzePhase() startAnalyzePhase()
try { try {
await Promise.all([extractFormats(), fetchUrlInfo()]) await Promise.all([extractFormats(), fetchUrlInfo()])
// Check if URL yielded anything useful
if (urlInfo.value?.type === 'unknown') {
analyzeError.value = 'No downloadable media found at this URL.'
} else if (!urlInfo.value && !extractError.value) {
analyzeError.value = 'Could not reach this URL. Check the address and try again.'
}
} finally { } finally {
isAnalyzing.value = false isAnalyzing.value = false
stopAnalyzePhase() stopAnalyzePhase()
@ -325,7 +340,7 @@ function formatTooltip(fmt: string): string {
<button <button
class="btn-download" class="btn-download"
@click="submitDownload" @click="submitDownload"
:disabled="!url.trim() || store.isSubmitting" :disabled="!urlReady || isAnalyzing || store.isSubmitting"
> >
{{ store.isSubmitting ? 'Submitting…' : 'Download' }} {{ store.isSubmitting ? 'Submitting…' : 'Download' }}
</button> </button>
@ -378,6 +393,10 @@ function formatTooltip(fmt: string): string {
</div> </div>
</div> </div>
<div v-if="analyzeError" class="extract-error">
{{ analyzeError }}
</div>
<div v-if="extractError" class="extract-error"> <div v-if="extractError" class="extract-error">
{{ extractError }} {{ extractError }}
</div> </div>