feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
Settings page refactor plus supporting docs (requirements, ux-flows, traceability) and the frontend-domain-model spec with implementation-status banner. Removes SkeletonLoader and StorageManagement components (no remaining references).
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
type?: "card" | "text" | "circle" | "banner" | "row";
|
||||
count?: number;
|
||||
width?: string;
|
||||
height?: string;
|
||||
aspectRatio?: "square" | "video" | "portrait";
|
||||
}
|
||||
|
||||
let {
|
||||
type = "card",
|
||||
count = 1,
|
||||
width = "100%",
|
||||
height = "auto",
|
||||
aspectRatio = "square",
|
||||
}: Props = $props();
|
||||
|
||||
const aspectClasses = {
|
||||
square: "aspect-square",
|
||||
video: "aspect-video",
|
||||
portrait: "aspect-[2/3]",
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if type === "card"}
|
||||
<div class="flex gap-4 overflow-hidden">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex-shrink-0 w-36 animate-pulse">
|
||||
<div class="w-full {aspectClasses[aspectRatio]} bg-[var(--color-surface)] rounded-lg shimmer"></div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 80%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "banner"}
|
||||
<div class="animate-pulse">
|
||||
<div class="h-[500px] bg-[var(--color-surface)] rounded-xl shimmer"></div>
|
||||
</div>
|
||||
{:else if type === "circle"}
|
||||
<div class="flex gap-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex flex-col items-center animate-pulse">
|
||||
<div class="w-20 h-20 rounded-full bg-[var(--color-surface)] shimmer"></div>
|
||||
<div class="mt-2 h-3 w-16 bg-[var(--color-surface)] rounded shimmer"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "row"}
|
||||
<div class="space-y-4">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="flex gap-4 animate-pulse">
|
||||
<div class="w-16 h-16 rounded bg-[var(--color-surface)] shimmer flex-shrink-0"></div>
|
||||
<div class="flex-1 space-y-2 py-2">
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: 70%"></div>
|
||||
<div class="h-3 bg-[var(--color-surface)] rounded shimmer" style="width: 50%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if type === "text"}
|
||||
<div class="space-y-2 animate-pulse">
|
||||
{#each Array(count) as _, i (i)}
|
||||
<div class="h-4 bg-[var(--color-surface)] rounded shimmer" style="width: {width}; height: {height}"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--color-surface) 0%,
|
||||
rgba(255, 255, 255, 0.05) 20%,
|
||||
var(--color-surface) 40%,
|
||||
var(--color-surface) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface AlbumStorageInfo {
|
||||
album_id: string;
|
||||
album_name: string;
|
||||
artist_name: string | null;
|
||||
bytes_used: number;
|
||||
track_count: number;
|
||||
}
|
||||
|
||||
interface StorageStats {
|
||||
total_bytes: number;
|
||||
total_items: number;
|
||||
albums: AlbumStorageInfo[];
|
||||
}
|
||||
|
||||
let stats = $state<StorageStats | null>(null);
|
||||
let loading = $state(true);
|
||||
let deleting = $state(false);
|
||||
let deletingAlbum = $state<string | null>(null);
|
||||
let showDeleteAllConfirm = $state(false);
|
||||
let showBreakdown = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
loading = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
stats = await commands.getDownloadStorageStats(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load storage stats:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
async function deleteAllDownloads() {
|
||||
try {
|
||||
deleting = true;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAllDownloads(userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all downloads:", error);
|
||||
} finally {
|
||||
deleting = false;
|
||||
showDeleteAllConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlbumDownloads(albumId: string) {
|
||||
try {
|
||||
deletingAlbum = albumId;
|
||||
const userId = $auth.user?.id;
|
||||
if (userId) {
|
||||
await commands.deleteAlbumDownloads(albumId, userId);
|
||||
await downloads.refresh(userId);
|
||||
await loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete album downloads:", error);
|
||||
} finally {
|
||||
deletingAlbum = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAlbumClick(albumId: string) {
|
||||
if (albumId !== "unknown") {
|
||||
goto(`/library/${albumId}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-[var(--color-surface)] rounded-xl p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Storage</h2>
|
||||
{#if stats && stats.total_items > 0}
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = true)}
|
||||
class="px-4 py-2 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="w-6 h-6 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
{:else if stats}
|
||||
<!-- Storage Summary -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-16 h-16 rounded-full bg-[var(--color-jellyfin)]/20 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-[var(--color-jellyfin)]" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold text-white">{formatBytes(stats.total_bytes)}</p>
|
||||
<p class="text-sm text-gray-400">
|
||||
{stats.total_items} {stats.total_items === 1 ? "item" : "items"} downloaded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Breakdown Toggle -->
|
||||
{#if stats.albums.length > 0}
|
||||
<button
|
||||
onclick={() => (showBreakdown = !showBreakdown)}
|
||||
class="w-full flex items-center justify-between py-3 px-4 bg-white/5 rounded-lg hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<span class="text-sm text-gray-300">Storage by album</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-gray-400 transition-transform {showBreakdown ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Album Breakdown -->
|
||||
{#if showBreakdown}
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
{#each stats.albums as album (album.album_id)}
|
||||
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg group hover:bg-white/10 transition-colors">
|
||||
<button
|
||||
onclick={() => handleAlbumClick(album.album_id)}
|
||||
class="flex-1 min-w-0 text-left"
|
||||
>
|
||||
<p class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors">
|
||||
{truncateMiddle(album.album_name, 40)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 truncate">
|
||||
{album.artist_name || "Unknown Artist"} • {album.track_count} {album.track_count === 1 ? "track" : "tracks"}
|
||||
</p>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="text-sm text-gray-400">{formatBytes(album.bytes_used)}</span>
|
||||
<button
|
||||
onclick={() => deleteAlbumDownloads(album.album_id)}
|
||||
disabled={deletingAlbum === album.album_id}
|
||||
class="p-1.5 rounded-full text-gray-400 hover:text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
title="Delete album downloads"
|
||||
>
|
||||
{#if deletingAlbum === album.album_id}
|
||||
<div class="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Empty State -->
|
||||
{#if stats.total_items === 0}
|
||||
<div class="text-center py-4">
|
||||
<p class="text-gray-400 text-sm">No downloads yet</p>
|
||||
<p class="text-gray-500 text-xs mt-1">Downloaded media will appear here</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete All Confirmation Modal -->
|
||||
{#if showDeleteAllConfirm}
|
||||
<div class="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-[var(--color-surface)] rounded-2xl w-full max-w-sm shadow-2xl">
|
||||
<div class="p-6 text-center">
|
||||
<div class="mx-auto w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Delete All Downloads?</h3>
|
||||
<p class="text-sm text-gray-400 mb-6">
|
||||
This will remove {stats?.total_items || 0} downloaded items and free up {formatBytes(stats?.total_bytes || 0)} of storage. This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
onclick={() => (showDeleteAllConfirm = false)}
|
||||
class="flex-1 px-4 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={deleteAllDownloads}
|
||||
disabled={deleting}
|
||||
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if deleting}
|
||||
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Deleting...
|
||||
{:else}
|
||||
Delete All
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-023, UR-029 | DR-048, DR-077 -->
|
||||
<!-- TRACES: UR-023, UR-029, UR-057 | DR-048, DR-077, DR-086 -->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
@@ -63,8 +63,6 @@
|
||||
let networkDetectionSupported = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let saveMessage = $state("");
|
||||
|
||||
// Image cache state
|
||||
let cacheStats = $state<ImageCacheStats | null>(null);
|
||||
@@ -153,55 +151,99 @@
|
||||
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
// Settings apply the moment the user changes a control — there is no Save
|
||||
// button. Each helper writes just the settings group it owns so a single
|
||||
// toggle doesn't re-push unrelated state.
|
||||
async function persistAudio() {
|
||||
try {
|
||||
saving = true;
|
||||
saveMessage = "";
|
||||
await Promise.all([
|
||||
commands.playerSetAudioSettings(settings),
|
||||
commands.playerSetVideoSettings(videoSettings),
|
||||
updateCacheConfig(cacheConfig),
|
||||
]);
|
||||
await commands.playerSetAudioSettings(settings);
|
||||
} catch (e) {
|
||||
console.error("Failed to save audio settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistVideo() {
|
||||
try {
|
||||
await commands.playerSetVideoSettings(videoSettings);
|
||||
} catch (e) {
|
||||
console.error("Failed to save video settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCache() {
|
||||
try {
|
||||
await updateCacheConfig(cacheConfig);
|
||||
// Re-report the network so the backend re-evaluates the gate against the
|
||||
// just-saved wifi-only preference, releasing or holding the queue now
|
||||
// just-changed wifi-only preference, releasing or holding the queue now
|
||||
// rather than at the next network change.
|
||||
await reportNetworkState();
|
||||
saveMessage = "Settings saved successfully!";
|
||||
setTimeout(() => {
|
||||
saveMessage = "";
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
console.error("Failed to save settings:", e);
|
||||
saveMessage = "Failed to save settings";
|
||||
} finally {
|
||||
saving = false;
|
||||
console.error("Failed to save download settings:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Slider drags fire `input` on every tick; update the live display there but
|
||||
// only persist on `change` (pointer release) so we don't spam the backend.
|
||||
function handleCrossfadeInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
settings.crossfadeDuration = parseFloat(target.value);
|
||||
}
|
||||
|
||||
function handleCrossfadeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
settings.crossfadeDuration = parseFloat(target.value);
|
||||
persistAudio();
|
||||
}
|
||||
|
||||
function handleGaplessToggle() {
|
||||
settings.gaplessPlayback = !settings.gaplessPlayback;
|
||||
persistAudio();
|
||||
}
|
||||
|
||||
function handleNormalizeToggle() {
|
||||
settings.normalizeVolume = !settings.normalizeVolume;
|
||||
persistAudio();
|
||||
}
|
||||
|
||||
function handleVolumeLevelChange(level: VolumeLevel) {
|
||||
settings.volumeLevel = level;
|
||||
persistAudio();
|
||||
}
|
||||
|
||||
function handleAutoPlayToggle() {
|
||||
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
||||
persistVideo();
|
||||
}
|
||||
|
||||
function handleCountdownInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
||||
}
|
||||
|
||||
function handleCountdownChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
||||
persistVideo();
|
||||
}
|
||||
|
||||
function handleEpisodeLimitChange(value: number) {
|
||||
videoSettings.autoPlayMaxEpisodes = value;
|
||||
persistVideo();
|
||||
}
|
||||
|
||||
function handleSmartCachingToggle() {
|
||||
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
|
||||
persistCache();
|
||||
}
|
||||
|
||||
function handleQueuePrecacheToggle() {
|
||||
cacheConfig.queuePrecacheEnabled = !cacheConfig.queuePrecacheEnabled;
|
||||
persistCache();
|
||||
}
|
||||
|
||||
function handleWifiOnlyToggle() {
|
||||
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
|
||||
persistCache();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -279,7 +321,8 @@
|
||||
max="12"
|
||||
step="0.5"
|
||||
value={settings.crossfadeDuration}
|
||||
oninput={handleCrossfadeChange}
|
||||
oninput={handleCrossfadeInput}
|
||||
onchange={handleCrossfadeChange}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
@@ -425,7 +468,8 @@
|
||||
max="30"
|
||||
step="5"
|
||||
value={videoSettings.autoPlayCountdownSeconds}
|
||||
oninput={handleCountdownChange}
|
||||
oninput={handleCountdownInput}
|
||||
onchange={handleCountdownChange}
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
||||
@@ -445,7 +489,7 @@
|
||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{#each episodeLimitOptions as option}
|
||||
<button
|
||||
onclick={() => { videoSettings.autoPlayMaxEpisodes = option.value; }}
|
||||
onclick={() => handleEpisodeLimitChange(option.value)}
|
||||
class="py-3 px-3 rounded-lg transition-all text-sm
|
||||
{videoSettings.autoPlayMaxEpisodes === option.value
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
@@ -597,9 +641,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() =>
|
||||
(cacheConfig.albumAffinityEnabled =
|
||||
!cacheConfig.albumAffinityEnabled)}
|
||||
onclick={handleSmartCachingToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'}"
|
||||
@@ -626,9 +668,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() =>
|
||||
(cacheConfig.queuePrecacheEnabled =
|
||||
!cacheConfig.queuePrecacheEnabled)}
|
||||
onclick={handleQueuePrecacheToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'}"
|
||||
@@ -660,7 +700,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (cacheConfig.wifiOnly = !cacheConfig.wifiOnly)}
|
||||
onclick={handleWifiOnlyToggle}
|
||||
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
|
||||
? 'bg-[var(--color-jellyfin)]'
|
||||
: 'bg-gray-600'} {networkDetectionSupported
|
||||
@@ -680,28 +720,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm">
|
||||
{#if saveMessage}
|
||||
<span
|
||||
class="text-{saveMessage.includes('success')
|
||||
? 'green'
|
||||
: 'red'}-400"
|
||||
>
|
||||
{saveMessage}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={saveSettings}
|
||||
disabled={saving}
|
||||
class="px-6 py-3 bg-[var(--color-jellyfin)] text-white rounded-lg font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
||||
<div class="flex gap-3">
|
||||
|
||||
Reference in New Issue
Block a user