fix: persist GlitchTip GzipChunk patch and prune old releases in CI
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 4m36s
Release / Docker / ui (push) Successful in 4m38s
Release / Gitea Release (push) Successful in 4m46s
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Upload source maps (push) Successful in 4m36s
Release / Docker / ui (push) Successful in 4m38s
Release / Gitea Release (push) Successful in 4m46s
- Add homelab/glitchtip/files_api.py with GzipChunk fallback for sentry-cli 3.x raw zip uploads; bind-mount into both glitchtip-web and glitchtip-worker - Add release.yaml prune step to delete all but the 10 newest GlitchTip releases - Reader page: remove dead code and simplify layout
This commit is contained in:
@@ -202,6 +202,31 @@ jobs:
|
|||||||
SENTRY_ORG: libnovel
|
SENTRY_ORG: libnovel
|
||||||
SENTRY_PROJECT: ui
|
SENTRY_PROJECT: ui
|
||||||
|
|
||||||
|
- name: Prune old GlitchTip releases (keep latest 10)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
KEEP=10
|
||||||
|
# Fetch releases sorted newest-first, extract versions after the first $KEEP
|
||||||
|
OLD=$(curl -sf \
|
||||||
|
-H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
|
||||||
|
"$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
|
||||||
|
| python3 -c "
|
||||||
|
import sys, json
|
||||||
|
releases = json.load(sys.stdin)
|
||||||
|
# Skip beyond the first KEEP entries
|
||||||
|
for r in releases[$KEEP:]:
|
||||||
|
print(r['version'])
|
||||||
|
" KEEP=$KEEP)
|
||||||
|
for ver in $OLD; do
|
||||||
|
echo "Deleting old release: $ver"
|
||||||
|
glitchtip-cli releases delete "$ver" || true
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
SENTRY_URL: https://errors.libnovel.cc
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||||
|
SENTRY_ORG: libnovel
|
||||||
|
SENTRY_PROJECT: ui
|
||||||
|
|
||||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||||
docker-ui:
|
docker-ui:
|
||||||
name: Docker / ui
|
name: Docker / ui
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ services:
|
|||||||
MEDIA_ROOT: "/code/uploads"
|
MEDIA_ROOT: "/code/uploads"
|
||||||
volumes:
|
volumes:
|
||||||
- glitchtip_uploads:/code/uploads
|
- glitchtip_uploads:/code/uploads
|
||||||
|
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
|
||||||
|
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -196,6 +198,8 @@ services:
|
|||||||
MEDIA_ROOT: "/code/uploads"
|
MEDIA_ROOT: "/code/uploads"
|
||||||
volumes:
|
volumes:
|
||||||
- glitchtip_uploads:/code/uploads
|
- glitchtip_uploads:/code/uploads
|
||||||
|
# Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug)
|
||||||
|
- ./glitchtip/files_api.py:/code/apps/files/api.py:ro
|
||||||
|
|
||||||
# ── Umami ───────────────────────────────────────────────────────────────────
|
# ── Umami ───────────────────────────────────────────────────────────────────
|
||||||
umami:
|
umami:
|
||||||
|
|||||||
127
homelab/glitchtip/files_api.py
Normal file
127
homelab/glitchtip/files_api.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Port of sentry.api.endpoints.chunk.ChunkUploadEndpoint"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from gzip import GzipFile
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import aget_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
|
from ninja import File, Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from ninja.files import UploadedFile
|
||||||
|
|
||||||
|
from apps.organizations_ext.models import Organization
|
||||||
|
from glitchtip.api.authentication import AuthHttpRequest
|
||||||
|
from glitchtip.api.decorators import optional_slash
|
||||||
|
from glitchtip.api.permissions import has_permission
|
||||||
|
|
||||||
|
from .models import FileBlob
|
||||||
|
|
||||||
|
# Force just one blob
|
||||||
|
CHUNK_UPLOAD_BLOB_SIZE = 32 * 1024 * 1024 # 32MB
|
||||||
|
MAX_CHUNKS_PER_REQUEST = 1
|
||||||
|
MAX_REQUEST_SIZE = CHUNK_UPLOAD_BLOB_SIZE
|
||||||
|
MAX_CONCURRENCY = 1
|
||||||
|
HASH_ALGORITHM = "sha1"
|
||||||
|
|
||||||
|
CHUNK_UPLOAD_ACCEPT = (
|
||||||
|
"debug_files", # DIF assemble
|
||||||
|
"release_files", # Release files assemble
|
||||||
|
"pdbs", # PDB upload and debug id override
|
||||||
|
"sources", # Source artifact bundle upload
|
||||||
|
"artifact_bundles", # Artifact bundles contain debug ids to link source to sourcemaps
|
||||||
|
"proguard",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GzipChunk(BytesIO):
|
||||||
|
def __init__(self, file):
|
||||||
|
raw = file.read()
|
||||||
|
try:
|
||||||
|
data = GzipFile(fileobj=BytesIO(raw), mode="rb").read()
|
||||||
|
except Exception:
|
||||||
|
# sentry-cli 3.x sends raw (uncompressed) zip data despite gzip being
|
||||||
|
# advertised by the server — fall back to using the raw bytes as-is.
|
||||||
|
data = raw
|
||||||
|
self.size = len(data)
|
||||||
|
self.name = file.name
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@optional_slash(router, "get", "organizations/{slug:organization_slug}/chunk-upload/")
|
||||||
|
async def get_chunk_upload_info(request: AuthHttpRequest, organization_slug: str):
|
||||||
|
"""Get server settings for chunk file upload"""
|
||||||
|
path = reverse("api:get_chunk_upload_info", args=[organization_slug])
|
||||||
|
url = (
|
||||||
|
path
|
||||||
|
if settings.GLITCHTIP_CHUNK_UPLOAD_USE_RELATIVE_URL
|
||||||
|
else settings.GLITCHTIP_URL.geturl() + path
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"chunkSize": CHUNK_UPLOAD_BLOB_SIZE,
|
||||||
|
"chunksPerRequest": MAX_CHUNKS_PER_REQUEST,
|
||||||
|
"maxFileSize": 2147483648,
|
||||||
|
"maxRequestSize": MAX_REQUEST_SIZE,
|
||||||
|
"concurrency": MAX_CONCURRENCY,
|
||||||
|
"hashAlgorithm": HASH_ALGORITHM,
|
||||||
|
"compression": ["gzip"],
|
||||||
|
"accept": CHUNK_UPLOAD_ACCEPT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@optional_slash(router, "post", "organizations/{slug:organization_slug}/chunk-upload/")
|
||||||
|
@has_permission(["project:write", "project:admin", "project:releases"])
|
||||||
|
async def chunk_upload(
|
||||||
|
request: AuthHttpRequest,
|
||||||
|
organization_slug: str,
|
||||||
|
file_gzip: list[UploadedFile] = File(...),
|
||||||
|
):
|
||||||
|
"""Upload one more more gzipped files to save"""
|
||||||
|
logger = logging.getLogger("glitchtip.files")
|
||||||
|
logger.info("chunkupload.start")
|
||||||
|
|
||||||
|
organization = await aget_object_or_404(
|
||||||
|
Organization, slug=organization_slug.lower(), users=request.auth.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
files = [GzipChunk(chunk) for chunk in file_gzip]
|
||||||
|
|
||||||
|
if len(files) == 0:
|
||||||
|
# No files uploaded is ok
|
||||||
|
logger.info("chunkupload.end", extra={"status": 200})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("chunkupload.post.files", extra={"len": len(files)})
|
||||||
|
|
||||||
|
# Validate file size
|
||||||
|
checksums = []
|
||||||
|
size = 0
|
||||||
|
for chunk in files:
|
||||||
|
size += chunk.size
|
||||||
|
if chunk.size > CHUNK_UPLOAD_BLOB_SIZE:
|
||||||
|
logger.info("chunkupload.end", extra={"status": 400})
|
||||||
|
raise HttpError(400, "Chunk size too large")
|
||||||
|
checksums.append(chunk.name)
|
||||||
|
|
||||||
|
if size > MAX_REQUEST_SIZE:
|
||||||
|
logger.info("chunkupload.end", extra={"status": 400})
|
||||||
|
raise HttpError(400, "Request too large")
|
||||||
|
|
||||||
|
if len(files) > MAX_CHUNKS_PER_REQUEST:
|
||||||
|
logger.info("chunkupload.end", extra={"status": 400})
|
||||||
|
raise HttpError(400, "Too many chunks")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await FileBlob.from_files(
|
||||||
|
zip(files, checksums), organization=organization, logger=logger
|
||||||
|
)
|
||||||
|
except IOError as err:
|
||||||
|
logger.info("chunkupload.end", extra={"status": 400})
|
||||||
|
raise HttpError(400, str(err)) from err
|
||||||
|
|
||||||
|
logger.info("chunkupload.end", extra={"status": 200})
|
||||||
@@ -22,14 +22,6 @@
|
|||||||
let settingsPanelOpen = $state(false);
|
let settingsPanelOpen = $state(false);
|
||||||
let settingsTab = $state<'reading' | 'listening'>('reading');
|
let settingsTab = $state<'reading' | 'listening'>('reading');
|
||||||
|
|
||||||
const READER_THEMES = [
|
|
||||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
|
||||||
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
|
|
||||||
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
|
|
||||||
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
|
|
||||||
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
|
|
||||||
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
|
|
||||||
] as const;
|
|
||||||
const READER_FONTS = [
|
const READER_FONTS = [
|
||||||
{ id: 'system', label: 'System' },
|
{ id: 'system', label: 'System' },
|
||||||
{ id: 'serif', label: 'Serif' },
|
{ id: 'serif', label: 'Serif' },
|
||||||
@@ -43,14 +35,9 @@
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Mirror context values into local reactive state so the panel shows current values
|
// Mirror context values into local reactive state so the panel shows current values
|
||||||
let panelTheme = $state(settingsCtx?.current ?? 'amber');
|
|
||||||
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
|
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
|
||||||
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
|
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
|
||||||
|
|
||||||
function applyTheme(id: string) {
|
|
||||||
panelTheme = id;
|
|
||||||
if (settingsCtx) settingsCtx.current = id;
|
|
||||||
}
|
|
||||||
function applyFont(id: string) {
|
function applyFont(id: string) {
|
||||||
panelFont = id;
|
panelFont = id;
|
||||||
if (settingsCtx) settingsCtx.fontFamily = id;
|
if (settingsCtx) settingsCtx.fontFamily = id;
|
||||||
@@ -97,34 +84,6 @@
|
|||||||
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Listening settings helpers ───────────────────────────────────────────────
|
|
||||||
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
|
|
||||||
const sleepSettingsLabel = $derived(
|
|
||||||
audioStore.sleepAfterChapter
|
|
||||||
? 'End Ch.'
|
|
||||||
: audioStore.sleepUntil > Date.now()
|
|
||||||
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
|
|
||||||
: 'Off'
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleSleepFromSettings() {
|
|
||||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
|
||||||
audioStore.sleepAfterChapter = true;
|
|
||||||
} else if (audioStore.sleepAfterChapter) {
|
|
||||||
audioStore.sleepAfterChapter = false;
|
|
||||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
|
|
||||||
} else {
|
|
||||||
const remaining = audioStore.sleepUntil - Date.now();
|
|
||||||
const currentMin = Math.round(remaining / 60000);
|
|
||||||
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
|
||||||
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
|
|
||||||
audioStore.sleepUntil = 0;
|
|
||||||
} else {
|
|
||||||
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply reading CSS vars whenever layout changes
|
// Apply reading CSS vars whenever layout changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -338,7 +297,8 @@
|
|||||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Top nav -->
|
<!-- Top nav (hidden in focus mode) -->
|
||||||
|
{#if !layout.focusMode}
|
||||||
<div class="flex items-center justify-between mb-6 gap-4">
|
<div class="flex items-center justify-between mb-6 gap-4">
|
||||||
<a
|
<a
|
||||||
href="/books/{data.book.slug}/chapters"
|
href="/books/{data.book.slug}/chapters"
|
||||||
@@ -383,6 +343,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
||||||
{#if !data.isPreview && !layout.focusMode}
|
{#if !data.isPreview && !layout.focusMode}
|
||||||
@@ -562,8 +523,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
|
<!-- Bottom nav + comments (hidden in focus mode) -->
|
||||||
{#if !(layout.focusMode && layout.readMode === 'paginated')}
|
{#if !layout.focusMode}
|
||||||
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
||||||
{#if data.prev}
|
{#if data.prev}
|
||||||
<a
|
<a
|
||||||
@@ -595,6 +556,45 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Focus mode floating nav (shown only in focus mode) -->
|
||||||
|
{#if layout.focusMode}
|
||||||
|
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
|
||||||
|
{#if data.prev}
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
{m.reader_chapter_n({ n: String(data.prev) })}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setLayout('focusMode', false)}
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-brand) text-xs transition-colors shadow-md"
|
||||||
|
aria-label="Exit focus mode"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
Exit focus
|
||||||
|
</button>
|
||||||
|
{#if data.next}
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border-(--color-border) text-(--color-muted) hover:text-(--color-text) text-xs transition-colors shadow-md"
|
||||||
|
>
|
||||||
|
{m.reader_chapter_n({ n: String(data.next) })}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
||||||
{#if settingsCtx}
|
{#if settingsCtx}
|
||||||
|
|
||||||
@@ -653,27 +653,6 @@
|
|||||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
||||||
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
||||||
|
|
||||||
<!-- Theme -->
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
||||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Theme</span>
|
|
||||||
<div class="flex flex-wrap gap-1.5 flex-1">
|
|
||||||
{#each READER_THEMES as t}
|
|
||||||
<button
|
|
||||||
onclick={() => applyTheme(t.id)}
|
|
||||||
title={t.label}
|
|
||||||
class="flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
|
|
||||||
{panelTheme === t.id
|
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
|
||||||
aria-pressed={panelTheme === t.id}
|
|
||||||
>
|
|
||||||
<span class="w-2 h-2 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
|
|
||||||
{t.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Font -->
|
<!-- Font -->
|
||||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||||
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
||||||
@@ -828,51 +807,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if page.data.user}
|
|
||||||
|
|
||||||
<!-- Speed -->
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
||||||
<span class="text-xs text-(--color-muted) w-14 shrink-0">Speed</span>
|
|
||||||
<div class="flex gap-1 flex-1">
|
|
||||||
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { audioStore.speed = s; }}
|
|
||||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
|
||||||
{audioStore.speed === s
|
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
|
||||||
aria-pressed={audioStore.speed === s}
|
|
||||||
>{s}×</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto-next -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
|
||||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
|
||||||
{audioStore.autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
|
||||||
aria-pressed={audioStore.autoNext}
|
|
||||||
>
|
|
||||||
<span>Auto-next chapter</span>
|
|
||||||
<span class="text-(--color-muted) text-[11px]">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Sleep timer -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggleSleepFromSettings}
|
|
||||||
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
|
||||||
{audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
|
||||||
>
|
|
||||||
<span>Sleep timer</span>
|
|
||||||
<span class="text-(--color-muted) text-[11px]">{sleepSettingsLabel}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user