mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 10:54:00 -06:00
Compare commits
No commits in common. "master" and "v1.2.2" have entirely different histories.
9 changed files with 32 additions and 298 deletions
28
README.md
28
README.md
|
|
@ -11,7 +11,7 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
|
||||||
- **Real-time progress** — Server-Sent Events stream download progress to the browser instantly.
|
- **Real-time progress** — Server-Sent Events stream download progress to the browser instantly.
|
||||||
- **Session isolation** — Each browser gets its own download queue. No cross-talk.
|
- **Session isolation** — Each browser gets its own download queue. No cross-talk.
|
||||||
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
|
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
|
||||||
- **9 built-in themes** — 5 dark (Cyberpunk, Dark, Midnight, Hacker, Neon) + 4 light (Light, Paper, Arctic, Solarized). Admin picks the pair, visitors toggle dark/light.
|
- **Three built-in themes** — Cyberpunk (default), Dark, Light. Switch in the header.
|
||||||
- **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed.
|
- **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed.
|
||||||
- **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth.
|
- **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth.
|
||||||
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
|
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
|
||||||
|
|
@ -21,32 +21,8 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
The Docker image is published to GitHub Container Registry:
|
|
||||||
|
|
||||||
```
|
|
||||||
ghcr.io/xpltdco/media-rip:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Pull and run with Docker Compose (recommended):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download the compose file
|
docker compose up
|
||||||
curl -O https://raw.githubusercontent.com/xpltdco/media-rip/master/docker-compose.yml
|
|
||||||
|
|
||||||
# Start the container
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Or pull and run directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mediarip \
|
|
||||||
-p 8080:8000 \
|
|
||||||
-v ./downloads:/downloads \
|
|
||||||
-v mediarip-data:/data \
|
|
||||||
--restart unless-stopped \
|
|
||||||
ghcr.io/xpltdco/media-rip:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password.
|
Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""SQLite database layer with async CRUD operations.
|
"""SQLite database layer with WAL mode and async CRUD operations.
|
||||||
|
|
||||||
Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs
|
Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs
|
||||||
(busy_timeout, journal_mode, synchronous) *before* creating any tables so
|
(busy_timeout, WAL, synchronous) *before* creating any tables so that
|
||||||
that concurrent download workers never hit ``SQLITE_BUSY``. WAL mode is
|
concurrent download workers never hit ``SQLITE_BUSY``.
|
||||||
preferred on local filesystems; DELETE mode is used automatically when a
|
|
||||||
network filesystem (CIFS, NFS) is detected.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -92,31 +90,19 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
|
||||||
|
|
||||||
PRAGMA order matters:
|
PRAGMA order matters:
|
||||||
1. ``busy_timeout`` — prevents immediate ``SQLITE_BUSY`` on lock contention
|
1. ``busy_timeout`` — prevents immediate ``SQLITE_BUSY`` on lock contention
|
||||||
2. ``journal_mode`` — WAL for local filesystems, DELETE for network mounts
|
2. ``journal_mode=WAL`` — enables concurrent readers + single writer
|
||||||
(CIFS/NFS lack the shared-memory primitives WAL requires)
|
3. ``synchronous=NORMAL`` — safe durability level for WAL mode
|
||||||
3. ``synchronous=NORMAL`` — safe durability level
|
|
||||||
|
|
||||||
Returns the ready-to-use connection.
|
Returns the ready-to-use connection.
|
||||||
"""
|
"""
|
||||||
# Detect network filesystem *before* opening the DB so we never attempt
|
|
||||||
# WAL on CIFS/NFS (which creates broken SHM files that persist).
|
|
||||||
use_wal = not _is_network_filesystem(db_path)
|
|
||||||
|
|
||||||
db = await aiosqlite.connect(db_path)
|
db = await aiosqlite.connect(db_path)
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
# --- PRAGMAs (before any DDL) ---
|
# --- PRAGMAs (before any DDL) ---
|
||||||
await db.execute("PRAGMA busy_timeout = 5000")
|
await db.execute("PRAGMA busy_timeout = 5000")
|
||||||
|
result = await db.execute("PRAGMA journal_mode = WAL")
|
||||||
if use_wal:
|
row = await result.fetchone()
|
||||||
journal_mode = await _try_journal_mode(db, "wal")
|
journal_mode = row[0] if row else "unknown"
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Network filesystem detected for %s — using DELETE journal mode",
|
|
||||||
db_path,
|
|
||||||
)
|
|
||||||
journal_mode = await _try_journal_mode(db, "delete")
|
|
||||||
|
|
||||||
logger.info("journal_mode set to %s", journal_mode)
|
logger.info("journal_mode set to %s", journal_mode)
|
||||||
|
|
||||||
await db.execute("PRAGMA synchronous = NORMAL")
|
await db.execute("PRAGMA synchronous = NORMAL")
|
||||||
|
|
@ -129,54 +115,6 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
def _is_network_filesystem(db_path: str) -> bool:
|
|
||||||
"""Return True if *db_path* resides on a network filesystem (CIFS, NFS, etc.).
|
|
||||||
|
|
||||||
Parses ``/proc/mounts`` (Linux) to find the filesystem type of the
|
|
||||||
longest-prefix mount matching the database directory. Returns False
|
|
||||||
on non-Linux hosts or if detection fails.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
network_fs_types = {"cifs", "nfs", "nfs4", "smb", "smbfs", "9p", "fuse.sshfs"}
|
|
||||||
try:
|
|
||||||
db_dir = os.path.dirname(os.path.abspath(db_path))
|
|
||||||
with open("/proc/mounts", "r") as f:
|
|
||||||
mounts = f.readlines()
|
|
||||||
best_match = ""
|
|
||||||
best_fstype = ""
|
|
||||||
for line in mounts:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) < 3:
|
|
||||||
continue
|
|
||||||
mountpoint, fstype = parts[1], parts[2]
|
|
||||||
if db_dir.startswith(mountpoint) and len(mountpoint) > len(best_match):
|
|
||||||
best_match = mountpoint
|
|
||||||
best_fstype = fstype
|
|
||||||
is_net = best_fstype in network_fs_types
|
|
||||||
if is_net:
|
|
||||||
logger.info(
|
|
||||||
"Detected %s filesystem at %s for database %s",
|
|
||||||
best_fstype, best_match, db_path,
|
|
||||||
)
|
|
||||||
return is_net
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _try_journal_mode(
|
|
||||||
db: aiosqlite.Connection, mode: str,
|
|
||||||
) -> str:
|
|
||||||
"""Try setting *mode* and return the actual journal mode string."""
|
|
||||||
try:
|
|
||||||
result = await db.execute(f"PRAGMA journal_mode = {mode}")
|
|
||||||
row = await result.fetchone()
|
|
||||||
return (row[0] if row else "unknown").lower()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("PRAGMA journal_mode=%s failed: %s", mode, exc)
|
|
||||||
return "error"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CRUD helpers
|
# CRUD helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -561,31 +561,6 @@ class DownloadService:
|
||||||
url_lower = url.lower()
|
url_lower = url.lower()
|
||||||
return any(domain in url_lower for domain in audio_domains)
|
return any(domain in url_lower for domain in audio_domains)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _url_or_ext_implies_video(url: str, ext: str | None) -> bool:
|
|
||||||
"""Return True if the URL path or reported extension is a known video container.
|
|
||||||
|
|
||||||
This acts as a fallback when yt-dlp's extract_flat mode strips codec
|
|
||||||
metadata (common for archive.org, direct-file URLs, etc.), which would
|
|
||||||
otherwise cause the UI to wrongly label the source as "audio only".
|
|
||||||
"""
|
|
||||||
video_extensions = {
|
|
||||||
"mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "mpg",
|
|
||||||
"mpeg", "m4v", "ts", "3gp", "ogv",
|
|
||||||
}
|
|
||||||
# Check the extension reported by yt-dlp
|
|
||||||
if ext and ext.lower() in video_extensions:
|
|
||||||
return True
|
|
||||||
# Check the URL path for a video file extension
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
path = urlparse(url).path.lower()
|
|
||||||
# Strip any trailing slashes / query residue
|
|
||||||
path = path.rstrip("/")
|
|
||||||
for vext in video_extensions:
|
|
||||||
if path.endswith(f".{vext}"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_auth_hint(url: str) -> str | None:
|
def _get_auth_hint(url: str) -> str | None:
|
||||||
"""Return a user-facing hint for sites that commonly need auth."""
|
"""Return a user-facing hint for sites that commonly need auth."""
|
||||||
|
|
@ -670,27 +645,13 @@ class DownloadService:
|
||||||
"url": e.get("url") or e.get("webpage_url", ""),
|
"url": e.get("url") or e.get("webpage_url", ""),
|
||||||
"duration": e.get("duration"),
|
"duration": e.get("duration"),
|
||||||
})
|
})
|
||||||
# Domain-based detection may miss video playlists on generic
|
|
||||||
# hosting sites (e.g. archive.org). If any entry URL looks like
|
|
||||||
# a video file, override domain_audio for the whole playlist.
|
|
||||||
playlist_audio = domain_audio
|
|
||||||
if playlist_audio:
|
|
||||||
for e_check in entries:
|
|
||||||
entry_url = e_check.get("url", "")
|
|
||||||
if self._url_or_ext_implies_video(entry_url, None):
|
|
||||||
playlist_audio = False
|
|
||||||
break
|
|
||||||
if not playlist_audio and not domain_audio:
|
|
||||||
# Also check the top-level URL itself
|
|
||||||
if self._url_or_ext_implies_video(url, info.get("ext")):
|
|
||||||
playlist_audio = False
|
|
||||||
result = {
|
result = {
|
||||||
"type": "playlist",
|
"type": "playlist",
|
||||||
"title": info.get("title", "Playlist"),
|
"title": info.get("title", "Playlist"),
|
||||||
"count": len(entries),
|
"count": len(entries),
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
"is_audio_only": playlist_audio,
|
"is_audio_only": domain_audio,
|
||||||
"default_ext": self._guess_ext_from_url(url, playlist_audio),
|
"default_ext": self._guess_ext_from_url(url, domain_audio),
|
||||||
}
|
}
|
||||||
if unavailable_count > 0:
|
if unavailable_count > 0:
|
||||||
result["unavailable_count"] = unavailable_count
|
result["unavailable_count"] = unavailable_count
|
||||||
|
|
@ -698,11 +659,6 @@ class DownloadService:
|
||||||
else:
|
else:
|
||||||
# Single video/track
|
# Single video/track
|
||||||
has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
|
has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
|
||||||
# extract_flat mode often strips codec info, so also check the
|
|
||||||
# URL extension and the reported ext — if either is a known video
|
|
||||||
# container we should NOT mark it as audio-only.
|
|
||||||
if not has_video:
|
|
||||||
has_video = self._url_or_ext_implies_video(url, info.get("ext"))
|
|
||||||
is_audio_only = domain_audio or not has_video
|
is_audio_only = domain_audio or not has_video
|
||||||
# Detect likely file extension
|
# Detect likely file extension
|
||||||
ext = info.get("ext")
|
ext = info.get("ext")
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,6 @@ onMounted(async () => {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.app-footer {
|
|
||||||
padding-bottom: calc(var(--mobile-nav-height) + var(--space-md));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sep {
|
.sep {
|
||||||
margin: 0 var(--space-sm);
|
margin: 0 var(--space-sm);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ function goHome(): void {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
width: 100%;
|
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
@ -45,12 +44,6 @@ function goHome(): void {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.header-content {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useDownloadsStore } from '@/stores/downloads'
|
|
||||||
import WireframeBackground from './WireframeBackground.vue'
|
import WireframeBackground from './WireframeBackground.vue'
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const downloadsStore = useDownloadsStore()
|
|
||||||
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
|
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
|
||||||
|
|
||||||
type MobileTab = 'submit' | 'queue'
|
type MobileTab = 'submit' | 'queue'
|
||||||
const activeTab = ref<MobileTab>('submit')
|
const activeTab = ref<MobileTab>('submit')
|
||||||
|
|
||||||
/** Number of active (non-terminal) jobs — shown as badge on Queue tab */
|
|
||||||
const queueBadge = computed(() => {
|
|
||||||
let count = 0
|
|
||||||
for (const job of downloadsStore.jobs.values()) {
|
|
||||||
if (job.status === 'queued' || job.status === 'downloading' || job.status === 'extracting') {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -54,10 +41,7 @@ const queueBadge = computed(() => {
|
||||||
:class="{ active: activeTab === 'queue' }"
|
:class="{ active: activeTab === 'queue' }"
|
||||||
@click="activeTab = 'queue'"
|
@click="activeTab = 'queue'"
|
||||||
>
|
>
|
||||||
<span class="nav-icon-wrap">
|
<span class="nav-icon">☰</span>
|
||||||
<span class="nav-icon">☰</span>
|
|
||||||
<span v-if="queueBadge > 0 && activeTab !== 'queue'" class="nav-badge">{{ queueBadge > 9 ? '9+' : queueBadge }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="nav-label">Queue</span>
|
<span class="nav-label">Queue</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -152,26 +136,5 @@ const queueBadge = computed(() => {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -6px;
|
|
||||||
right: -10px;
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-bg);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
line-height: 16px;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
padding: 0 3px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,6 @@ function selectFormat(id: string | null): void {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
min-height: var(--touch-min);
|
min-height: var(--touch-min);
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-option:hover {
|
.format-option:hover {
|
||||||
|
|
@ -158,10 +157,6 @@ function selectFormat(id: string | null): void {
|
||||||
|
|
||||||
.format-label {
|
.format-label {
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-hint {
|
.format-hint {
|
||||||
|
|
@ -170,31 +165,9 @@ function selectFormat(id: string | null): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-codecs {
|
.format-codecs {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.format-option {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-label {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.format-codecs {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-empty {
|
.format-empty {
|
||||||
|
|
|
||||||
|
|
@ -184,12 +184,10 @@ function onFormatSelect(formatId: string | null): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(): void {
|
function handlePaste(): void {
|
||||||
// Immediately signal that analysis is starting — prevents the Download
|
|
||||||
// button from being briefly clickable between paste and analysis.
|
|
||||||
isAnalyzing.value = true
|
|
||||||
// Auto-extract on paste — unified loading state
|
// Auto-extract on paste — unified loading state
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (url.value.trim()) {
|
if (url.value.trim()) {
|
||||||
|
isAnalyzing.value = true
|
||||||
analyzeError.value = null
|
analyzeError.value = null
|
||||||
startAnalyzePhase()
|
startAnalyzePhase()
|
||||||
try {
|
try {
|
||||||
|
|
@ -206,9 +204,6 @@ function handlePaste(): void {
|
||||||
isAnalyzing.value = false
|
isAnalyzing.value = false
|
||||||
stopAnalyzePhase()
|
stopAnalyzePhase()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// URL was cleared before timeout — cancel analysis state
|
|
||||||
isAnalyzing.value = false
|
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
@ -299,27 +294,16 @@ function formatTooltip(fmt: string): string {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="url-input">
|
<div class="url-input">
|
||||||
<!-- URL field with clear button -->
|
<!-- URL field -->
|
||||||
<div class="url-field-wrap">
|
<input
|
||||||
<input
|
v-model="url"
|
||||||
v-model="url"
|
type="url"
|
||||||
type="url"
|
placeholder="Paste a URL to download…"
|
||||||
placeholder="Paste a URL to download…"
|
class="url-field"
|
||||||
class="url-field"
|
@paste="handlePaste"
|
||||||
@paste="handlePaste"
|
@keydown.enter="submitDownload"
|
||||||
@keydown.enter="submitDownload"
|
:disabled="isAnalyzing || store.isSubmitting"
|
||||||
:disabled="store.isSubmitting"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="url.trim()"
|
|
||||||
class="url-clear"
|
|
||||||
@click="url = ''"
|
|
||||||
title="Clear URL"
|
|
||||||
aria-label="Clear URL"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action row: gear, media toggle, download button -->
|
<!-- Action row: gear, media toggle, download button -->
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
|
|
@ -467,46 +451,15 @@ function formatTooltip(fmt: string): string {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-field-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-field {
|
.url-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
padding-right: 40px; /* room for clear button */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-clear {
|
/* Action row: gear | toggle | download */
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.url-clear:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action row: gear | toggle | download — all 42px height */
|
|
||||||
.action-row {
|
.action-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -514,8 +467,8 @@ function formatTooltip(fmt: string): string {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 42px;
|
width: 38px;
|
||||||
min-height: 42px;
|
height: 38px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
|
@ -555,7 +508,7 @@ function formatTooltip(fmt: string): string {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
min-height: 42px;
|
min-height: 38px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,7 +535,7 @@ function formatTooltip(fmt: string): string {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: var(--space-sm) var(--space-lg);
|
padding: var(--space-sm) var(--space-lg);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-height: 42px;
|
min-height: 38px;
|
||||||
background: var(--color-accent);
|
background: var(--color-accent);
|
||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
@ -874,8 +827,6 @@ button:disabled {
|
||||||
|
|
||||||
.toggle-pill {
|
.toggle-pill {
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
min-width: 42px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,22 +115,12 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update admin theme config and apply if current mode matches.
|
* Update admin theme config (called after saving settings).
|
||||||
* Called after saving settings — gives live preview without page reload.
|
|
||||||
*/
|
*/
|
||||||
function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void {
|
function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void {
|
||||||
adminDarkTheme.value = darkTheme
|
adminDarkTheme.value = darkTheme
|
||||||
adminLightTheme.value = lightTheme
|
adminLightTheme.value = lightTheme
|
||||||
adminDefaultMode.value = defaultMode
|
adminDefaultMode.value = defaultMode
|
||||||
|
|
||||||
// Apply the new theme if user's current mode matches the changed side
|
|
||||||
if (currentMode.value === 'dark') {
|
|
||||||
currentTheme.value = darkTheme
|
|
||||||
_apply(darkTheme)
|
|
||||||
} else {
|
|
||||||
currentTheme.value = lightTheme
|
|
||||||
_apply(lightTheme)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue