mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
M002/S04: URL preview, playlist support, admin improvements, UX polish
URL preview & playlist support:
- POST /url-info endpoint extracts metadata (title, type, entry count)
- Preview box shows playlist contents before downloading (up to 10 items)
- Auto-detect audio-only sources (SoundCloud, etc) and switch to Audio mode
- Video toggle grayed out for audio-only sources
- Enable playlist downloading (noplaylist=False)
Admin panel improvements:
- Expandable session rows show per-session job list with filename, size,
status, timestamp, and source URL link
- GET /admin/sessions/{id}/jobs endpoint for session job details
- Logout now redirects to home page instead of staying on login form
- Logo in header is clickable → navigates to home
UX polish:
- Tooltips on output format chips (explains Auto vs specific formats)
- Format tooltips change based on video/audio mode
This commit is contained in:
parent
6e27f8e424
commit
0d9e6b18ac
9 changed files with 495 additions and 13 deletions
|
|
@ -43,6 +43,41 @@ async def list_sessions(
|
|||
return {"sessions": sessions, "total": len(sessions)}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/jobs")
|
||||
async def session_jobs(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
_admin: str = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""List jobs for a specific session with file details."""
|
||||
db = request.app.state.db
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
SELECT id, url, status, filename, filesize,
|
||||
created_at, started_at, completed_at
|
||||
FROM jobs
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
jobs = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"url": row["url"],
|
||||
"status": row["status"],
|
||||
"filename": row["filename"],
|
||||
"filesize": row["filesize"],
|
||||
"created_at": row["created_at"],
|
||||
"started_at": row["started_at"],
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {"jobs": jobs}
|
||||
|
||||
|
||||
@router.get("/storage")
|
||||
async def storage_info(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -68,3 +68,16 @@ async def cancel_download(
|
|||
)
|
||||
|
||||
return {"status": "cancelled"}
|
||||
|
||||
|
||||
@router.post("/url-info")
|
||||
async def url_info(
|
||||
request: Request,
|
||||
body: dict,
|
||||
) -> dict:
|
||||
"""Extract metadata about a URL (title, playlist detection, audio-only detection)."""
|
||||
url = body.get("url", "").strip()
|
||||
if not url:
|
||||
return JSONResponse(status_code=400, content={"detail": "URL required"})
|
||||
download_service = request.app.state.download_service
|
||||
return await download_service.get_url_info(url)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ class DownloadService:
|
|||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": True,
|
||||
"noplaylist": False,
|
||||
}
|
||||
if job_create.format_id:
|
||||
opts["format"] = job_create.format_id
|
||||
|
|
@ -378,6 +379,66 @@ class DownloadService:
|
|||
logger.exception("Format extraction failed for %s", url)
|
||||
return None
|
||||
|
||||
def _extract_url_info(self, url: str) -> dict | None:
|
||||
"""Extract URL metadata including playlist detection."""
|
||||
opts = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"extract_flat": "in_playlist",
|
||||
"noplaylist": False,
|
||||
}
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
return ydl.extract_info(url, download=False)
|
||||
except Exception:
|
||||
logger.exception("URL info extraction failed for %s", url)
|
||||
return None
|
||||
|
||||
async def get_url_info(self, url: str) -> dict:
|
||||
"""Get URL metadata: title, type (single/playlist), entries."""
|
||||
info = await self._loop.run_in_executor(
|
||||
self._executor,
|
||||
self._extract_url_info,
|
||||
url,
|
||||
)
|
||||
if not info:
|
||||
return {"type": "unknown", "title": None, "entries": []}
|
||||
|
||||
result_type = info.get("_type", "video")
|
||||
if result_type == "playlist" or "entries" in info:
|
||||
entries_raw = info.get("entries") or []
|
||||
entries = []
|
||||
for e in entries_raw:
|
||||
if isinstance(e, dict):
|
||||
entries.append({
|
||||
"title": e.get("title") or e.get("id", "Unknown"),
|
||||
"url": e.get("url") or e.get("webpage_url", ""),
|
||||
"duration": e.get("duration"),
|
||||
})
|
||||
# Detect audio-only source (no video formats)
|
||||
is_audio_only = False
|
||||
if info.get("categories"):
|
||||
is_audio_only = "Music" in info["categories"]
|
||||
return {
|
||||
"type": "playlist",
|
||||
"title": info.get("title", "Playlist"),
|
||||
"count": len(entries),
|
||||
"entries": entries,
|
||||
"is_audio_only": is_audio_only,
|
||||
}
|
||||
else:
|
||||
# Single video/track
|
||||
has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
|
||||
is_audio_only = not has_video
|
||||
return {
|
||||
"type": "single",
|
||||
"title": info.get("title"),
|
||||
"duration": info.get("duration"),
|
||||
"is_audio_only": is_audio_only,
|
||||
"entries": [],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* relative paths work without configuration.
|
||||
*/
|
||||
|
||||
import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus } from './types'
|
||||
import type { Job, JobCreate, FormatInfo, PublicConfig, HealthStatus, UrlInfo } from './types'
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
|
|
@ -77,6 +77,14 @@ export const api = {
|
|||
async getHealth(): Promise<HealthStatus> {
|
||||
return request<HealthStatus>('/api/health')
|
||||
},
|
||||
|
||||
/** Get URL metadata (title, playlist detection, audio-only detection). */
|
||||
async getUrlInfo(url: string): Promise<UrlInfo> {
|
||||
return request<UrlInfo>('/api/url-info', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export { ApiError }
|
||||
|
|
|
|||
|
|
@ -82,6 +82,21 @@ export interface HealthStatus {
|
|||
queue_depth: number
|
||||
}
|
||||
|
||||
export interface UrlInfoEntry {
|
||||
title: string
|
||||
url: string
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
export interface UrlInfo {
|
||||
type: 'single' | 'playlist' | 'unknown'
|
||||
title: string | null
|
||||
count?: number
|
||||
entries: UrlInfoEntry[]
|
||||
duration?: number | null
|
||||
is_audio_only: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE event types received from GET /api/events.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import { api } from '@/api/client'
|
||||
import AdminLogin from './AdminLogin.vue'
|
||||
|
||||
const store = useAdminStore()
|
||||
const router = useRouter()
|
||||
const activeTab = ref<'sessions' | 'storage' | 'purge' | 'settings'>('sessions')
|
||||
|
||||
// Session expansion state
|
||||
const expandedSessions = ref<Set<string>>(new Set())
|
||||
const sessionJobs = ref<Record<string, any[]>>({})
|
||||
const loadingJobs = ref<Set<string>>(new Set())
|
||||
|
||||
// Settings state
|
||||
const welcomeMessage = ref('')
|
||||
const settingsSaved = ref(false)
|
||||
|
|
@ -41,6 +48,38 @@ async function saveSettings() {
|
|||
setTimeout(() => { settingsSaved.value = false }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSession(sessionId: string) {
|
||||
if (expandedSessions.value.has(sessionId)) {
|
||||
expandedSessions.value.delete(sessionId)
|
||||
return
|
||||
}
|
||||
expandedSessions.value.add(sessionId)
|
||||
if (!sessionJobs.value[sessionId]) {
|
||||
loadingJobs.value.add(sessionId)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sessions/${sessionId}/jobs`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${store.username}:${store.password}`)}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
sessionJobs.value[sessionId] = data.jobs
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
loadingJobs.value.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
function jobFilename(job: any): string {
|
||||
if (!job.filename) return '—'
|
||||
const parts = job.filename.replace(/\\/g, '/').split('/')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
function formatFilesize(bytes: number | null): string {
|
||||
if (!bytes) return '—'
|
||||
return formatBytes(bytes)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -50,7 +89,7 @@ async function saveSettings() {
|
|||
<template v-else>
|
||||
<div class="admin-header">
|
||||
<h2>Admin Panel</h2>
|
||||
<button class="btn-logout" @click="store.logout()">Logout</button>
|
||||
<button class="btn-logout" @click="store.logout(); router.push('/')">Logout</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
|
|
@ -69,17 +108,42 @@ async function saveSettings() {
|
|||
<table class="admin-table" v-if="store.sessions.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Session ID</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Jobs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in store.sessions" :key="s.id">
|
||||
<template v-for="s in store.sessions" :key="s.id">
|
||||
<tr
|
||||
class="session-row"
|
||||
:class="{ expanded: expandedSessions.has(s.id), clickable: s.job_count > 0 }"
|
||||
@click="s.job_count > 0 && toggleSession(s.id)"
|
||||
>
|
||||
<td class="col-expand">
|
||||
<span v-if="s.job_count > 0" class="expand-icon">{{ expandedSessions.has(s.id) ? '▼' : '▶' }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ s.id.slice(0, 8) }}…</td>
|
||||
<td>{{ new Date(s.last_seen).toLocaleString() }}</td>
|
||||
<td>{{ s.job_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="expandedSessions.has(s.id)" class="jobs-detail-row">
|
||||
<td colspan="4">
|
||||
<div v-if="loadingJobs.has(s.id)" class="jobs-loading">Loading…</div>
|
||||
<div v-else-if="sessionJobs[s.id]?.length" class="jobs-detail">
|
||||
<div v-for="job in sessionJobs[s.id]" :key="job.id" class="job-item">
|
||||
<span class="job-filename">{{ jobFilename(job) }}</span>
|
||||
<span class="job-size">{{ formatFilesize(job.filesize) }}</span>
|
||||
<span class="job-status badge-sm" :class="'badge-' + job.status">{{ job.status }}</span>
|
||||
<span class="job-time">{{ new Date(job.created_at).toLocaleString() }}</span>
|
||||
<a v-if="job.url" class="job-url" :href="job.url" target="_blank" rel="noopener" :title="job.url">↗</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="jobs-empty">No jobs found.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty">No sessions found.</p>
|
||||
|
|
@ -348,4 +412,102 @@ h3 {
|
|||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Expandable session rows */
|
||||
.session-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-row.clickable:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.session-row.expanded {
|
||||
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.col-expand {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.jobs-detail-row td {
|
||||
padding: 0 var(--space-md) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.jobs-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.job-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.job-filename {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.job-size {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
.badge-failed { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
|
||||
.badge-downloading { background: color-mix(in srgb, var(--color-accent) 15%, transparent); color: var(--color-accent); }
|
||||
.badge-queued { background: color-mix(in srgb, var(--color-text-muted) 15%, transparent); color: var(--color-text-muted); }
|
||||
|
||||
.job-time {
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.job-url {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.job-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.jobs-loading, .jobs-empty {
|
||||
padding: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goHome(): void {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1 class="header-title">
|
||||
<h1 class="header-title" @click="goHome" role="link" tabindex="0" @keydown.enter="goHome" title="Back to downloads">
|
||||
<span class="title-media">media</span><span class="title-dot">.</span><span class="title-rip">rip</span><span class="title-parens">()</span>
|
||||
</h1>
|
||||
<div class="header-right">
|
||||
|
|
@ -42,6 +49,8 @@ import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
|||
font-weight: 700;
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: -0.02em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.title-media { color: var(--color-text); }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ref, computed, watch } from 'vue'
|
|||
import { api } from '@/api/client'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import FormatPicker from './FormatPicker.vue'
|
||||
import type { FormatInfo } from '@/api/types'
|
||||
import type { FormatInfo, UrlInfo } from '@/api/types'
|
||||
|
||||
const store = useDownloadsStore()
|
||||
|
||||
|
|
@ -14,6 +14,11 @@ const isExtracting = ref(false)
|
|||
const extractError = ref<string | null>(null)
|
||||
const showOptions = ref(false)
|
||||
|
||||
// URL preview state
|
||||
const urlInfo = ref<UrlInfo | null>(null)
|
||||
const isLoadingInfo = ref(false)
|
||||
const audioLocked = ref(false) // true when source is audio-only
|
||||
|
||||
type MediaType = 'video' | 'audio'
|
||||
const mediaType = ref<MediaType>('video')
|
||||
const outputFormat = ref<string>('auto')
|
||||
|
|
@ -71,6 +76,8 @@ async function submitDownload(): Promise<void> {
|
|||
extractError.value = null
|
||||
mediaType.value = 'video'
|
||||
outputFormat.value = 'auto'
|
||||
urlInfo.value = null
|
||||
audioLocked.value = false
|
||||
} catch {
|
||||
// Error already in store.submitError
|
||||
}
|
||||
|
|
@ -81,14 +88,43 @@ function onFormatSelect(formatId: string | null): void {
|
|||
}
|
||||
|
||||
function handlePaste(): void {
|
||||
// Auto-extract on paste (populate formats silently in background)
|
||||
// Auto-extract on paste (populate formats + URL info silently in background)
|
||||
setTimeout(() => {
|
||||
if (url.value.trim()) {
|
||||
extractFormats()
|
||||
fetchUrlInfo()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
async function fetchUrlInfo(): Promise<void> {
|
||||
const trimmed = url.value.trim()
|
||||
if (!trimmed) return
|
||||
isLoadingInfo.value = true
|
||||
urlInfo.value = null
|
||||
audioLocked.value = false
|
||||
try {
|
||||
const info = await api.getUrlInfo(trimmed)
|
||||
urlInfo.value = info
|
||||
// Auto-switch to audio if the source is audio-only
|
||||
if (info.is_audio_only) {
|
||||
mediaType.value = 'audio'
|
||||
audioLocked.value = true
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — preview is optional
|
||||
} finally {
|
||||
isLoadingInfo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return ''
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function toggleOptions(): void {
|
||||
showOptions.value = !showOptions.value
|
||||
// Extract formats when opening options if we haven't yet
|
||||
|
|
@ -101,6 +137,14 @@ function formatLabel(fmt: string): string {
|
|||
if (fmt === 'auto') return 'Auto'
|
||||
return fmt.toUpperCase()
|
||||
}
|
||||
|
||||
function formatTooltip(fmt: string): string {
|
||||
if (fmt !== 'auto') return `Convert to ${fmt.toUpperCase()}`
|
||||
if (mediaType.value === 'audio') {
|
||||
return 'Best quality audio in its native format (usually Opus/WebM)'
|
||||
}
|
||||
return 'Best quality video in its native format (usually WebM/MKV)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -131,9 +175,10 @@ function formatLabel(fmt: string): string {
|
|||
<div class="media-toggle">
|
||||
<button
|
||||
class="toggle-pill"
|
||||
:class="{ active: mediaType === 'video' }"
|
||||
@click="mediaType = 'video'"
|
||||
:title="'Video'"
|
||||
:class="{ active: mediaType === 'video', disabled: audioLocked }"
|
||||
@click="!audioLocked && (mediaType = 'video')"
|
||||
:title="audioLocked ? 'Source contains audio only' : 'Video'"
|
||||
:disabled="audioLocked"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
<span class="toggle-label">Video</span>
|
||||
|
|
@ -158,6 +203,35 @@ function formatLabel(fmt: string): string {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingInfo" class="url-preview loading">
|
||||
<span class="spinner"></span>
|
||||
Checking URL…
|
||||
</div>
|
||||
|
||||
<!-- URL preview: show what will be downloaded -->
|
||||
<div v-else-if="urlInfo" class="url-preview">
|
||||
<div class="preview-header">
|
||||
<span v-if="urlInfo.type === 'playlist'" class="preview-badge playlist">Playlist · {{ urlInfo.count }} items</span>
|
||||
<span v-else class="preview-badge single">Single {{ audioLocked ? 'track' : 'video' }}</span>
|
||||
<span v-if="audioLocked" class="preview-badge audio-only">Audio only</span>
|
||||
<span v-if="urlInfo.title" class="preview-title">{{ urlInfo.title }}</span>
|
||||
</div>
|
||||
<div v-if="urlInfo.type === 'playlist' && urlInfo.entries.length" class="preview-entries">
|
||||
<div
|
||||
v-for="(entry, i) in urlInfo.entries.slice(0, 10)"
|
||||
:key="i"
|
||||
class="preview-entry"
|
||||
>
|
||||
<span class="entry-num">{{ i + 1 }}.</span>
|
||||
<span class="entry-title">{{ entry.title }}</span>
|
||||
<span v-if="entry.duration" class="entry-duration">{{ formatDuration(entry.duration) }}</span>
|
||||
</div>
|
||||
<div v-if="urlInfo.entries.length > 10" class="preview-more">
|
||||
…and {{ urlInfo.entries.length - 10 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isExtracting" class="extract-loading">
|
||||
<span class="spinner"></span>
|
||||
Extracting available formats…
|
||||
|
|
@ -183,6 +257,7 @@ function formatLabel(fmt: string): string {
|
|||
:key="fmt"
|
||||
class="format-chip"
|
||||
:class="{ active: outputFormat === fmt }"
|
||||
:title="formatTooltip(fmt)"
|
||||
@click="outputFormat = fmt"
|
||||
>
|
||||
{{ formatLabel(fmt) }}
|
||||
|
|
@ -415,6 +490,109 @@ button:disabled {
|
|||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* URL preview */
|
||||
.url-preview {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.url-preview.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-badge.playlist {
|
||||
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.preview-badge.single {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.preview-badge.audio-only {
|
||||
background: color-mix(in srgb, var(--color-warning) 15%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-entries {
|
||||
margin-top: var(--space-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 2px 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entry-num {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-duration {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.preview-more {
|
||||
padding: var(--space-xs) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toggle-pill.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Narrow viewports: hide toggle labels, keep icons only */
|
||||
@media (max-width: 540px) {
|
||||
.toggle-label {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export const useAdminStore = defineStore('admin', () => {
|
|||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
isAuthenticated,
|
||||
authError,
|
||||
sessions,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue