GSD: M002/S03 complete — Mobile + integration polish

- Admin panel: Settings tab with welcome message editor (runtime override)
- Backend: PUT /api/admin/settings endpoint for runtime config
- Backend: public config reads runtime overrides (settings_overrides on app.state)
- Removed unused ThemePicker.vue (replaced by DarkModeToggle in S01)
- Removed unused DownloadItem.vue (replaced by DownloadTable in S02)
- All 34 frontend + 179 backend tests passing
- M002 COMPLETE — all 3 slices done
This commit is contained in:
xpltd 2026-03-18 21:34:46 -05:00
parent 4eec024750
commit 9b62d50461
8 changed files with 221 additions and 288 deletions

View file

@ -59,7 +59,7 @@ This milestone is complete only when all are true:
- [x] **S02: Download Flow + Queue Redesign** `risk:medium` `depends:[S01]` - [x] **S02: Download Flow + Queue Redesign** `risk:medium` `depends:[S01]`
> After this: Single "Download" button with optional format picker, audio/video toggle, queue displays as styled table with sorting, completed items show download/copy/clear glyphs > After this: Single "Download" button with optional format picker, audio/video toggle, queue displays as styled table with sorting, completed items show download/copy/clear glyphs
- [ ] **S03: Mobile + Integration Polish** `risk:low` `depends:[S02]` - [x] **S03: Mobile + Integration Polish** `risk:low` `depends:[S02]`
> After this: Mobile layout works with new table design, admin welcome message editor functional, all flows verified end-to-end > After this: Mobile layout works with new table design, admin welcome message editor functional, all flows verified end-to-end
## Boundary Map ## Boundary Map

View file

@ -0,0 +1,52 @@
# S03: Mobile + Integration Polish
**Goal:** Ensure mobile view works cleanly with the new table-based queue, add a welcome message editor in the admin panel, and verify all flows end-to-end.
**Demo:** Mobile user can submit downloads and view the queue table. Admin can edit the welcome message text from the admin panel Settings tab. All navigation flows work.
## Must-Haves
- Mobile queue table is usable (tested at 390px viewport)
- Admin panel has a "Settings" tab with welcome message text editor
- Admin settings tab saves welcome_message via backend API
- All end-to-end flows verified in browser (desktop + mobile)
- No test regressions
## Verification
- `cd frontend && npx vitest run` — all tests pass
- `cd backend && source .venv/Scripts/activate && python -m pytest tests/ -q -m "not integration"` — no regressions
- Browser (desktop): full download lifecycle works
- Browser (mobile 390px): submit + queue table renders, actions work
- Browser: admin panel Settings tab → edit welcome message → saves
## Tasks
- [x] **T01: Admin welcome message editor** `est:30m`
- Why: Operators need to customize the welcome message without editing config files
- Files: `frontend/src/components/AdminPanel.vue`, `frontend/src/stores/admin.ts`, `backend/app/routers/admin.py`
- Do: Add a "Settings" tab to admin panel with a textarea for welcome message. Load current value from `/api/config/public`. Add PUT `/api/admin/settings` endpoint that updates the config's welcome_message in memory (runtime override — persisting to YAML is out of scope for this milestone). Add `updateSettings(data)` to admin store. Show save confirmation.
- Verify: Login to admin → Settings tab → edit message → save → reload main page → new message visible
- Done when: Welcome message is editable from admin panel
- [x] **T02: Mobile polish and end-to-end verification** `est:30m`
- Why: Table-based queue may have issues at narrow viewports. Need to verify all flows.
- Files: Various frontend components (fixes only as needed)
- Do: Test at 390px viewport width. Fix any overflow, truncation, or touch target issues. Verify: submit download, view queue, cancel download, completed actions, dark/light toggle, footer. Fix any issues found.
- Verify: All flows work at mobile viewport. No horizontal overflow on queue table.
- Done when: Mobile experience is functional and clean
- [x] **T03: Final test run and cleanup** `est:15m`
- Why: Ensure no regressions across the full M002 milestone
- Files: Test files, cleanup any unused components
- Do: Run full test suites. Remove ThemePicker.vue if no longer imported. Remove DownloadItem.vue if no longer imported. Clean up any dead imports.
- Verify: All tests pass, no unused component files, no dead imports
- Done when: Clean codebase, all tests green
## Files Likely Touched
- `frontend/src/components/AdminPanel.vue` (add Settings tab)
- `frontend/src/stores/admin.ts` (add updateSettings)
- `backend/app/routers/admin.py` (add PUT /admin/settings)
- Various frontend components (mobile fixes as needed)
- `frontend/src/components/ThemePicker.vue` (remove if unused)
- `frontend/src/components/DownloadItem.vue` (remove if unused)

View file

@ -122,3 +122,35 @@ async def manual_purge(
db = request.app.state.db db = request.app.state.db
result = await run_purge(db, config) result = await run_purge(db, config)
return result return result
@router.put("/settings")
async def update_settings(
request: Request,
_admin: str = Depends(require_admin),
) -> dict:
"""Update runtime settings (in-memory only — resets on restart).
Accepts a JSON body with optional fields:
- welcome_message: str
"""
body = await request.json()
if not hasattr(request.app.state, "settings_overrides"):
request.app.state.settings_overrides = {}
updated = []
if "welcome_message" in body:
msg = body["welcome_message"]
if not isinstance(msg, str):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=422,
content={"detail": "welcome_message must be a string"},
)
request.app.state.settings_overrides["welcome_message"] = msg
updated.append("welcome_message")
logger.info("Admin updated welcome_message to: %s", msg[:80])
return {"updated": updated, "status": "ok"}

View file

@ -20,10 +20,16 @@ async def public_config(request: Request) -> dict:
is fragile when new sensitive fields are added later. is fragile when new sensitive fields are added later.
""" """
config = request.app.state.config config = request.app.state.config
# Runtime overrides (set via admin settings endpoint) take precedence
overrides = getattr(request.app.state, "settings_overrides", {})
return { return {
"session_mode": config.session.mode, "session_mode": config.session.mode,
"default_theme": config.ui.default_theme, "default_theme": config.ui.default_theme,
"welcome_message": config.ui.welcome_message, "welcome_message": overrides.get(
"welcome_message", config.ui.welcome_message
),
"purge_enabled": config.purge.enabled, "purge_enabled": config.purge.enabled,
"max_concurrent_downloads": config.downloads.max_concurrent, "max_concurrent_downloads": config.downloads.max_concurrent,
} }

View file

@ -1,10 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { api } from '@/api/client'
import AdminLogin from './AdminLogin.vue' import AdminLogin from './AdminLogin.vue'
const store = useAdminStore() const store = useAdminStore()
const activeTab = ref<'sessions' | 'storage' | 'purge'>('sessions') const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
// Settings state
const welcomeMessage = ref('')
const settingsSaved = ref(false)
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
@ -15,8 +20,26 @@ function formatBytes(bytes: number): string {
async function switchTab(tab: typeof activeTab.value) { async function switchTab(tab: typeof activeTab.value) {
activeTab.value = tab activeTab.value = tab
settingsSaved.value = false
if (tab === 'sessions') await store.loadSessions() if (tab === 'sessions') await store.loadSessions()
if (tab === 'storage') await store.loadStorage() if (tab === 'storage') await store.loadStorage()
if (tab === 'settings') {
try {
const config = await api.getPublicConfig()
welcomeMessage.value = config.welcome_message
} catch {
// Keep current value
}
}
}
async function saveSettings() {
settingsSaved.value = false
const ok = await store.updateSettings({ welcome_message: welcomeMessage.value })
if (ok) {
settingsSaved.value = true
setTimeout(() => { settingsSaved.value = false }, 3000)
}
} }
</script> </script>
@ -32,7 +55,7 @@ async function switchTab(tab: typeof activeTab.value) {
<div class="admin-tabs"> <div class="admin-tabs">
<button <button
v-for="tab in (['sessions', 'storage', 'purge'] as const)" v-for="tab in (['sessions', 'storage', 'purge', 'settings'] as const)"
:key="tab" :key="tab"
:class="{ active: activeTab === tab }" :class="{ active: activeTab === tab }"
@click="switchTab(tab)" @click="switchTab(tab)"
@ -103,6 +126,34 @@ async function switchTab(tab: typeof activeTab.value) {
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p> <p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p>
</div> </div>
</div> </div>
<!-- Settings tab -->
<div v-if="activeTab === 'settings'" class="tab-content">
<div class="settings-field">
<label for="welcome-msg">Welcome Message</label>
<p class="field-hint">Displayed above the URL input on the main page. Leave empty to hide.</p>
<textarea
id="welcome-msg"
v-model="welcomeMessage"
rows="3"
class="settings-textarea"
placeholder="Enter a welcome message…"
></textarea>
</div>
<div class="settings-actions">
<button
@click="saveSettings"
:disabled="store.isLoading"
class="btn-save"
>
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="settingsSaved" class="save-confirm"> Saved</span>
</div>
<p class="field-hint" style="margin-top: var(--space-md);">
Changes are applied immediately but reset on server restart.
</p>
</div>
</template> </template>
</div> </div>
</template> </template>
@ -239,4 +290,62 @@ h3 {
color: var(--color-text-muted); color: var(--color-text-muted);
text-align: center; text-align: center;
} }
.settings-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-field label {
font-weight: 600;
color: var(--color-text);
}
.field-hint {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
.settings-textarea {
width: 100%;
padding: 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-base);
resize: vertical;
}
.settings-textarea:focus {
outline: none;
border-color: var(--color-accent);
}
.settings-actions {
display: flex;
align-items: center;
gap: var(--space-md);
margin-top: var(--space-md);
}
.btn-save {
background: var(--color-accent);
color: var(--color-bg);
font-weight: 600;
padding: var(--space-sm) var(--space-lg);
}
.btn-save:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.save-confirm {
color: var(--color-success);
font-weight: 500;
font-size: var(--font-size-sm);
}
</style> </style>

View file

@ -1,179 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDownloadsStore } from '@/stores/downloads'
import ProgressBar from './ProgressBar.vue'
import type { Job, JobStatus } from '@/api/types'
const props = defineProps<{
job: Job
}>()
const store = useDownloadsStore()
const isActive = computed(() => !store.isTerminal(props.job.status))
const statusClass = computed(() => {
const map: Record<string, string> = {
queued: 'status-queued',
extracting: 'status-extracting',
downloading: 'status-downloading',
completed: 'status-completed',
failed: 'status-failed',
expired: 'status-expired',
}
return map[props.job.status] || ''
})
const displayName = computed(() => {
if (props.job.filename) {
// Show just the filename, not the full path
const parts = props.job.filename.replace(/\\/g, '/').split('/')
return parts[parts.length - 1]
}
// Truncate URL for display
try {
const u = new URL(props.job.url)
return `${u.hostname}${u.pathname}`.slice(0, 60)
} catch {
return props.job.url.slice(0, 60)
}
})
const showProgress = computed(() =>
props.job.status === 'downloading' || props.job.status === 'extracting',
)
const cancelling = ref(false)
async function cancel(): Promise<void> {
if (cancelling.value) return
cancelling.value = true
try {
await store.cancelDownload(props.job.id)
} catch (err) {
console.error('[DownloadItem] Cancel failed:', err)
} finally {
cancelling.value = false
}
}
</script>
<template>
<div class="download-item" :class="statusClass">
<div class="item-header">
<span class="item-name" :title="job.url">{{ displayName }}</span>
<span class="item-status">{{ job.status }}</span>
</div>
<ProgressBar
v-if="showProgress"
:percent="job.progress_percent"
/>
<div class="item-details">
<span v-if="job.speed" class="detail-speed">{{ job.speed }}</span>
<span v-if="job.eta" class="detail-eta">ETA: {{ job.eta }}</span>
<span v-if="job.error_message" class="detail-error">{{ job.error_message }}</span>
</div>
<div class="item-actions">
<button
v-if="isActive"
class="btn-cancel"
:class="{ cancelling }"
:disabled="cancelling"
@click.stop="cancel"
title="Cancel download"
>
{{ cancelling ? '…' : '✕' }}
</button>
</div>
</div>
</template>
<style scoped>
.download-item {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto auto;
gap: var(--space-xs) var(--space-sm);
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
border-left: 3px solid var(--color-border);
}
.download-item.status-queued { border-left-color: var(--color-text-muted); }
.download-item.status-extracting { border-left-color: var(--color-warning); }
.download-item.status-downloading { border-left-color: var(--color-accent); }
.download-item.status-completed { border-left-color: var(--color-success); }
.download-item.status-failed { border-left-color: var(--color-error); }
.download-item.status-expired { border-left-color: var(--color-text-muted); }
.item-header {
grid-column: 1 / -1;
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
}
.item-name {
font-size: var(--font-size-base);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.item-status {
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
white-space: nowrap;
}
.item-details {
grid-column: 1;
display: flex;
gap: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.detail-error {
color: var(--color-error);
font-family: var(--font-ui);
}
.item-actions {
grid-column: 2;
grid-row: 2 / -1;
display: flex;
align-items: center;
}
.btn-cancel {
width: var(--touch-min);
height: var(--touch-min);
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--font-size-lg);
padding: 0;
}
.btn-cancel:hover {
color: var(--color-error);
border-color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
}
</style>

View file

@ -1,105 +0,0 @@
<script setup lang="ts">
import { useThemeStore } from '@/stores/theme'
const theme = useThemeStore()
</script>
<template>
<div class="theme-picker">
<button
v-for="t in theme.allThemes"
:key="t.id"
:class="['theme-btn', { active: theme.currentTheme === t.id }]"
:title="t.description || t.name"
@click="theme.setTheme(t.id)"
>
<span class="theme-dot" :data-preview="t.id"></span>
<span class="theme-name">{{ t.name }}</span>
</button>
</div>
</template>
<style scoped>
.theme-picker {
display: flex;
gap: var(--space-xs);
}
.theme-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
min-height: 32px;
background: transparent;
color: var(--color-text-muted);
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-normal);
}
.theme-btn:hover {
color: var(--color-text);
border-color: var(--color-border);
}
.theme-btn.active {
color: var(--color-accent);
border-color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.theme-dot {
width: 12px;
height: 12px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Preview dots show the theme's accent color */
.theme-dot[data-preview="cyberpunk"] {
background: #00a8ff;
border-color: #00a8ff;
}
.theme-dot[data-preview="dark"] {
background: #a78bfa;
border-color: #a78bfa;
}
.theme-dot[data-preview="light"] {
background: #2563eb;
border-color: #2563eb;
}
/* Custom theme dots fall back to a generic style */
.theme-dot:not([data-preview="cyberpunk"]):not([data-preview="dark"]):not([data-preview="light"]) {
background: var(--color-text-muted);
}
.theme-name {
white-space: nowrap;
}
/* On mobile, hide theme names — show only dots */
@media (max-width: 768px) {
.theme-name {
display: none;
}
.theme-btn {
padding: var(--space-xs);
min-height: var(--touch-min);
min-width: var(--touch-min);
justify-content: center;
}
.theme-dot {
width: 16px;
height: 16px;
}
}
</style>

View file

@ -110,6 +110,23 @@ export const useAdminStore = defineStore('admin', () => {
} }
} }
async function updateSettings(data: Record<string, string>): Promise<boolean> {
isLoading.value = true
try {
const res = await fetch('/api/admin/settings', {
method: 'PUT',
headers: {
..._authHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
return res.ok
} finally {
isLoading.value = false
}
}
return { return {
username, username,
isAuthenticated, isAuthenticated,
@ -123,5 +140,6 @@ export const useAdminStore = defineStore('admin', () => {
loadSessions, loadSessions,
loadStorage, loadStorage,
triggerPurge, triggerPurge,
updateSettings,
} }
}) })