mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
4eec024750
commit
9b62d50461
8 changed files with 221 additions and 288 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
52
.gsd/milestones/M002/slices/S03/S03-PLAN.md
Normal file
52
.gsd/milestones/M002/slices/S03/S03-PLAN.md
Normal 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)
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue