|
|
|
@@ -0,0 +1,774 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { config } from '$lib/stores/config.svelte';
|
|
|
|
|
import { themeStore } from '$lib/stores/theme.svelte';
|
|
|
|
|
import { app } from '$lib/stores/app.svelte';
|
|
|
|
|
import { checkFfmpeg } from '$lib/utils/tauri';
|
|
|
|
|
import { toasts } from '$lib/stores/toast.svelte';
|
|
|
|
|
import CustomSelect from '$lib/components/CustomSelect.svelte';
|
|
|
|
|
import Slider from '$lib/components/Slider.svelte';
|
|
|
|
|
import Toggle from '$lib/components/Toggle.svelte';
|
|
|
|
|
import type { Theme, CompressSettings } from '$lib/types';
|
|
|
|
|
|
|
|
|
|
let detecting = $state(false);
|
|
|
|
|
|
|
|
|
|
const themeChoices: { value: Theme; icon: string; label: string }[] = [
|
|
|
|
|
{ value: 'light', icon: 'ti-sun', label: 'Light' },
|
|
|
|
|
{ value: 'dark', icon: 'ti-moon', label: 'Dark' },
|
|
|
|
|
{ value: 'system', icon: 'ti-device-desktop', label: 'System' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function handleThemeChange(t: Theme) {
|
|
|
|
|
themeStore.setTheme(t);
|
|
|
|
|
config.update({ theme: t });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleZoom(val: number) {
|
|
|
|
|
const zoom = Math.round(val);
|
|
|
|
|
config.update({ ui_zoom: zoom });
|
|
|
|
|
document.documentElement.style.fontSize = `${(zoom / 100) * 16}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetOptions = [
|
|
|
|
|
{ value: '8', label: '8 MB', icon: 'ti-file-zip' },
|
|
|
|
|
{ value: '25', label: '25 MB', icon: 'ti-file-zip' },
|
|
|
|
|
{ value: '50', label: '50 MB', icon: 'ti-file-zip' },
|
|
|
|
|
{ value: '100', label: '100 MB', icon: 'ti-file-zip' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const codecOptions = [
|
|
|
|
|
{ value: 'H264', label: 'H.264', icon: 'ti-badge-hd' },
|
|
|
|
|
{ value: 'HEVC', label: 'HEVC', icon: 'ti-badge-4k' },
|
|
|
|
|
{ value: 'AV1', label: 'AV1', icon: 'ti-atom' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const containerOptions = [
|
|
|
|
|
{ value: 'MP4', label: 'MP4', icon: 'ti-box' },
|
|
|
|
|
{ value: 'MKV', label: 'MKV', icon: 'ti-package' },
|
|
|
|
|
{ value: 'WebM', label: 'WebM', icon: 'ti-world' },
|
|
|
|
|
{ value: 'MOV', label: 'MOV', icon: 'ti-movie' },
|
|
|
|
|
{ value: 'AVI', label: 'AVI', icon: 'ti-archive' },
|
|
|
|
|
{ value: 'TS', label: 'TS', icon: 'ti-broadcast' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const audioCodecOptions = [
|
|
|
|
|
{ value: 'AAC', label: 'AAC', icon: 'ti-headphones' },
|
|
|
|
|
{ value: 'Opus', label: 'Opus', icon: 'ti-vinyl' },
|
|
|
|
|
{ value: 'None', label: 'None (strip)', icon: 'ti-volume-off' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const audioBitrateOptions = [
|
|
|
|
|
{ value: '64', label: '64 kbps', icon: 'ti-antenna-bars-1' },
|
|
|
|
|
{ value: '96', label: '96 kbps', icon: 'ti-antenna-bars-2' },
|
|
|
|
|
{ value: '128', label: '128 kbps', icon: 'ti-antenna-bars-3' },
|
|
|
|
|
{ value: '192', label: '192 kbps', icon: 'ti-antenna-bars-4' },
|
|
|
|
|
{ value: '256', label: '256 kbps', icon: 'ti-antenna-bars-5' },
|
|
|
|
|
{ value: '320', label: '320 kbps', icon: 'ti-antenna-bars-5' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const retryThresholdOptions = [
|
|
|
|
|
{ value: '1', label: '1%', icon: 'ti-target' },
|
|
|
|
|
{ value: '2', label: '2%', icon: 'ti-target' },
|
|
|
|
|
{ value: '3', label: '3%', icon: 'ti-target' },
|
|
|
|
|
{ value: '5', label: '5%', icon: 'ti-target' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const maxRetryOptions = [
|
|
|
|
|
{ value: '1', label: '1 attempt', icon: 'ti-refresh' },
|
|
|
|
|
{ value: '2', label: '2 attempts', icon: 'ti-refresh' },
|
|
|
|
|
{ value: '3', label: '3 attempts', icon: 'ti-refresh' },
|
|
|
|
|
{ value: '5', label: '5 attempts', icon: 'ti-refresh' },
|
|
|
|
|
{ value: '10', label: '10 attempts', icon: 'ti-refresh' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const namingOptions = [
|
|
|
|
|
{ value: '{name}_{mode}_{timestamp}', label: 'Name + mode + time', icon: 'ti-file-text' },
|
|
|
|
|
{ value: '{name}_{mode}', label: 'Name + mode', icon: 'ti-file-text' },
|
|
|
|
|
{ value: '{name}_cinch', label: 'Name + cinch', icon: 'ti-file-text' }
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let defaultCodec = $derived(config.current.last_compress_settings?.video_codec ?? 'H264');
|
|
|
|
|
let defaultContainer = $derived(config.current.last_compress_settings?.container ?? 'MP4');
|
|
|
|
|
let defaultAudioCodec = $derived(config.current.last_compress_settings?.audio_codec ?? 'AAC');
|
|
|
|
|
let defaultAudioBitrate = $derived(String(config.current.last_compress_settings?.audio_bitrate ?? 128));
|
|
|
|
|
|
|
|
|
|
const defaultCompressSettings: CompressSettings = {
|
|
|
|
|
strategy: { type: 'TargetSize', mb: 8 },
|
|
|
|
|
video_codec: 'H264',
|
|
|
|
|
audio_codec: 'AAC',
|
|
|
|
|
audio_bitrate: 128,
|
|
|
|
|
container: 'MP4',
|
|
|
|
|
resolution: { type: 'Original' },
|
|
|
|
|
speed_preset: 'medium',
|
|
|
|
|
hw_accel: 'Auto'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function updateLastSetting<K extends keyof CompressSettings>(key: K, value: CompressSettings[K]) {
|
|
|
|
|
config.update({
|
|
|
|
|
last_compress_settings: { ...defaultCompressSettings, ...config.current.last_compress_settings, [key]: value }
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function detectFfmpeg() {
|
|
|
|
|
detecting = true;
|
|
|
|
|
try {
|
|
|
|
|
const status = await checkFfmpeg();
|
|
|
|
|
app.ffmpegStatus = status;
|
|
|
|
|
if (status.found) {
|
|
|
|
|
config.update({ ffmpeg_path: status.path });
|
|
|
|
|
toasts.add('success', `FFmpeg ${status.version} detected.`);
|
|
|
|
|
} else {
|
|
|
|
|
toasts.add('warning', 'FFmpeg not found on this system.');
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
toasts.add('error', 'Detection failed.');
|
|
|
|
|
}
|
|
|
|
|
detecting = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function browseFfmpeg() {
|
|
|
|
|
try {
|
|
|
|
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
|
|
|
const result = await open({ multiple: false, filters: [{ name: 'FFmpeg', extensions: ['exe'] }] });
|
|
|
|
|
if (result) {
|
|
|
|
|
const path = typeof result === 'string' ? result : result.path;
|
|
|
|
|
if (path) {
|
|
|
|
|
config.update({ ffmpeg_path: path });
|
|
|
|
|
app.ffmpegStatus = { ...app.ffmpegStatus, path };
|
|
|
|
|
toasts.add('success', 'FFmpeg path updated.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
toasts.add('error', 'Could not open file browser.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function browseOutputDir() {
|
|
|
|
|
try {
|
|
|
|
|
const { open } = await import('@tauri-apps/plugin-dialog');
|
|
|
|
|
const result = await open({ directory: true });
|
|
|
|
|
if (result) {
|
|
|
|
|
const path = typeof result === 'string' ? result : result.path;
|
|
|
|
|
if (path) config.update({ default_output_dir: path });
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
toasts.add('error', 'Could not open folder browser.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resetDefaults() {
|
|
|
|
|
await config.reset();
|
|
|
|
|
themeStore.setTheme(config.current.theme);
|
|
|
|
|
document.documentElement.style.fontSize = `${(config.current.ui_zoom / 100) * 16}px`;
|
|
|
|
|
toasts.add('success', 'Settings reset to defaults.');
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="settings-page">
|
|
|
|
|
<div class="settings-header">
|
|
|
|
|
<h1 class="settings-title">Settings</h1>
|
|
|
|
|
<p class="settings-subtitle">Configure how Cinch works</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="settings-grid">
|
|
|
|
|
<!-- Appearance -->
|
|
|
|
|
<div class="settings-card settings-card--small">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-compress)">
|
|
|
|
|
<i class="ti ti-palette card-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="card-title">Appearance</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Theme</span>
|
|
|
|
|
<div class="theme-pills">
|
|
|
|
|
{#each themeChoices as t}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
class="theme-pill"
|
|
|
|
|
class:theme-pill--active={themeStore.theme === t.value}
|
|
|
|
|
onclick={() => handleThemeChange(t.value)}
|
|
|
|
|
>
|
|
|
|
|
<i class="ti {t.icon}"></i>
|
|
|
|
|
{t.label}
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">UI Zoom</span>
|
|
|
|
|
<div class="zoom-row">
|
|
|
|
|
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.max(75, config.current.ui_zoom - 5))} aria-label="Decrease zoom">
|
|
|
|
|
<i class="ti ti-minus"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<div class="zoom-slider"><Slider min={75} max={200} step={5} value={config.current.ui_zoom} onChange={handleZoom} /></div>
|
|
|
|
|
<button type="button" class="zoom-btn" onclick={() => handleZoom(Math.min(200, config.current.ui_zoom + 5))} aria-label="Increase zoom">
|
|
|
|
|
<i class="ti ti-plus"></i>
|
|
|
|
|
</button>
|
|
|
|
|
<span class="zoom-value">{config.current.ui_zoom}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Defaults -->
|
|
|
|
|
<div class="settings-card settings-card--large">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-trim)">
|
|
|
|
|
<i class="ti ti-settings card-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="card-title">Default settings</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="selects-grid">
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Target size</span>
|
|
|
|
|
<CustomSelect options={targetOptions} value={String(config.current.default_target_size)} onChange={(v) => config.update({ default_target_size: parseInt(v) })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Video codec</span>
|
|
|
|
|
<CustomSelect options={codecOptions} value={defaultCodec} onChange={(v) => updateLastSetting('video_codec', v as any)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Container</span>
|
|
|
|
|
<CustomSelect options={containerOptions} value={defaultContainer} onChange={(v) => updateLastSetting('container', v as any)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Audio codec</span>
|
|
|
|
|
<CustomSelect options={audioCodecOptions} value={defaultAudioCodec} onChange={(v) => updateLastSetting('audio_codec', v as any)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Audio bitrate</span>
|
|
|
|
|
<CustomSelect options={audioBitrateOptions} value={defaultAudioBitrate} onChange={(v) => updateLastSetting('audio_bitrate', parseInt(v))} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="field-row" style="margin-top: 12px;">
|
|
|
|
|
<div class="field-row-left">
|
|
|
|
|
<i class="ti ti-cut"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<span>Smart cut by default</span>
|
|
|
|
|
<span class="field-hint">Use smart re-encode instead of keyframe snap</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Toggle checked={config.current.default_smart_cut} onChange={(v) => config.update({ default_smart_cut: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Processing -->
|
|
|
|
|
<div class="settings-card settings-card--medium">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div class="card-icon-wrap" style="--card-icon-color: var(--color-accent-warning)">
|
|
|
|
|
<i class="ti ti-bolt card-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="card-title">Processing</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="field-row">
|
|
|
|
|
<div class="field-row-left">
|
|
|
|
|
<i class="ti ti-refresh"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<span>Auto-retry when over target</span>
|
|
|
|
|
<span class="field-hint">Automatically re-encode if output exceeds target</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Toggle checked={config.current.auto_retry} onChange={(v) => config.update({ auto_retry: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if config.current.auto_retry}
|
|
|
|
|
<div class="selects-grid selects-grid--2">
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Threshold</span>
|
|
|
|
|
<CustomSelect options={retryThresholdOptions} value={String(config.current.retry_threshold_percent)} onChange={(v) => config.update({ retry_threshold_percent: parseFloat(v) })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Max retries</span>
|
|
|
|
|
<CustomSelect options={maxRetryOptions} value={String(config.current.max_retry_attempts)} onChange={(v) => config.update({ max_retry_attempts: parseInt(v) })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="field-row" style="margin-top: 8px;">
|
|
|
|
|
<div class="field-row-left">
|
|
|
|
|
<i class="ti ti-terminal-2"></i>
|
|
|
|
|
<div>
|
|
|
|
|
<span>Show FFmpeg log</span>
|
|
|
|
|
<span class="field-hint">Display raw FFmpeg output during encoding</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Toggle checked={config.current.show_ffmpeg_log} onChange={(v) => config.update({ show_ffmpeg_log: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- FFmpeg -->
|
|
|
|
|
<div class="settings-card settings-card--medium">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div class="card-icon-wrap" style="--card-icon-color: #a78bfa">
|
|
|
|
|
<i class="ti ti-terminal-2 card-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="card-title">FFmpeg</h2>
|
|
|
|
|
{#if app.ffmpegStatus.found}
|
|
|
|
|
<span class="card-badge card-badge--ok"><i class="ti ti-circle-check"></i> Ready</span>
|
|
|
|
|
{:else}
|
|
|
|
|
<span class="card-badge card-badge--warn"><i class="ti ti-alert-circle"></i> Missing</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Binary path</span>
|
|
|
|
|
<div class="path-bar">
|
|
|
|
|
<i class="ti ti-folder-code path-bar-icon"></i>
|
|
|
|
|
<span class="path-bar-text" class:path-bar-text--empty={!config.current.ffmpeg_path}>
|
|
|
|
|
{config.current.ffmpeg_path ?? 'Not configured - will search system PATH'}
|
|
|
|
|
</span>
|
|
|
|
|
<div class="path-bar-actions">
|
|
|
|
|
<button type="button" class="path-bar-btn" onclick={browseFfmpeg} title="Browse for ffmpeg.exe">
|
|
|
|
|
<i class="ti ti-folder-open"></i> Browse
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="path-bar-btn" class:path-bar-btn--spinning={detecting} disabled={detecting} onclick={detectFfmpeg} title="Auto-detect FFmpeg">
|
|
|
|
|
<i class="ti ti-search"></i> Detect
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{#if app.ffmpegStatus.version}
|
|
|
|
|
<div class="version-pill">
|
|
|
|
|
<i class="ti ti-circle-check"></i>
|
|
|
|
|
Version {app.ffmpegStatus.version} detected
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Output -->
|
|
|
|
|
<div class="settings-card settings-card--wide">
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
<div class="card-icon-wrap" style="--card-icon-color: #f472b6">
|
|
|
|
|
<i class="ti ti-download card-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h2 class="card-title">Output</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
<span class="field-label">Default folder</span>
|
|
|
|
|
<div class="path-bar">
|
|
|
|
|
<i class="ti ti-folder path-bar-icon"></i>
|
|
|
|
|
<span class="path-bar-text" class:path-bar-text--empty={!config.current.default_output_dir}>
|
|
|
|
|
{config.current.default_output_dir ?? 'Same folder as source file'}
|
|
|
|
|
</span>
|
|
|
|
|
<div class="path-bar-actions">
|
|
|
|
|
<button type="button" class="path-bar-btn" onclick={browseOutputDir} title="Choose output folder">
|
|
|
|
|
<i class="ti ti-folder-open"></i> Browse
|
|
|
|
|
</button>
|
|
|
|
|
{#if config.current.default_output_dir}
|
|
|
|
|
<button type="button" class="path-bar-btn path-bar-btn--danger" onclick={() => config.update({ default_output_dir: null })} title="Reset to source folder">
|
|
|
|
|
<i class="ti ti-rotate"></i> Reset
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="field-group" style="margin-top: 12px;">
|
|
|
|
|
<span class="field-label">Naming pattern</span>
|
|
|
|
|
<CustomSelect options={namingOptions} value={config.current.naming_pattern} onChange={(v) => config.update({ naming_pattern: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="settings-footer">
|
|
|
|
|
<button type="button" class="reset-btn" onclick={resetDefaults}>
|
|
|
|
|
<i class="ti ti-rotate"></i>
|
|
|
|
|
Reset all to defaults
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
/* ── Page shell ── */
|
|
|
|
|
.settings-page {
|
|
|
|
|
flex: 1;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
scrollbar-width: none;
|
|
|
|
|
padding: 28px 32px 48px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.settings-header {
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
}
|
|
|
|
|
.settings-title {
|
|
|
|
|
font-family: var(--font-display);
|
|
|
|
|
font-size: var(--text-2xl);
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
}
|
|
|
|
|
.settings-subtitle {
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Bento grid ── */
|
|
|
|
|
.settings-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(12, 1fr);
|
|
|
|
|
gap: 16px;
|
|
|
|
|
align-items: start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.settings-card {
|
|
|
|
|
background: var(--color-bg-elevated);
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
padding: 20px;
|
|
|
|
|
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
}
|
|
|
|
|
.settings-card:hover {
|
|
|
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.14);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.settings-card--small { grid-column: span 4; }
|
|
|
|
|
.settings-card--large { grid-column: span 8; }
|
|
|
|
|
.settings-card--medium { grid-column: span 6; }
|
|
|
|
|
.settings-card--wide { grid-column: span 12; }
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
.settings-card--small,
|
|
|
|
|
.settings-card--large,
|
|
|
|
|
.settings-card--medium,
|
|
|
|
|
.settings-card--wide {
|
|
|
|
|
grid-column: span 12;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Card header ── */
|
|
|
|
|
.card-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
padding-bottom: 14px;
|
|
|
|
|
border-bottom: 1px solid var(--color-border);
|
|
|
|
|
}
|
|
|
|
|
.card-icon-wrap {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 32px;
|
|
|
|
|
height: 32px;
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
background: color-mix(in srgb, var(--card-icon-color) 12%, transparent);
|
|
|
|
|
color: var(--card-icon-color);
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.card-icon {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
}
|
|
|
|
|
.card-title {
|
|
|
|
|
font-family: var(--font-display);
|
|
|
|
|
font-size: var(--text-base);
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
flex: 1;
|
|
|
|
|
}
|
|
|
|
|
.card-badge {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
padding: 3px 8px;
|
|
|
|
|
border-radius: var(--radius-pill);
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.card-badge--ok {
|
|
|
|
|
background: rgba(5, 150, 105, 0.12);
|
|
|
|
|
color: var(--color-accent-compress);
|
|
|
|
|
}
|
|
|
|
|
.card-badge--warn {
|
|
|
|
|
background: rgba(245, 158, 11, 0.12);
|
|
|
|
|
color: var(--color-accent-warning);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Card body ── */
|
|
|
|
|
.card-body {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Field groups ── */
|
|
|
|
|
.field-group {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
}
|
|
|
|
|
.field-label {
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
color: var(--color-text-disabled);
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
}
|
|
|
|
|
.field-hint {
|
|
|
|
|
display: block;
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--color-text-disabled);
|
|
|
|
|
margin-top: 1px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Field rows (toggle rows) ── */
|
|
|
|
|
.field-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 4px 0;
|
|
|
|
|
}
|
|
|
|
|
.field-row-left {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
.field-row-left > i {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: var(--color-text-disabled);
|
|
|
|
|
margin-top: 1px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.field-row-left > div {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Selects grid ── */
|
|
|
|
|
.selects-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
.selects-grid--2 {
|
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
.selects-grid {
|
|
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Theme pills ── */
|
|
|
|
|
.theme-pills {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 2px;
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
border-radius: var(--radius-pill);
|
|
|
|
|
padding: 3px;
|
|
|
|
|
}
|
|
|
|
|
.theme-pill {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 6px;
|
|
|
|
|
padding: 5px 12px;
|
|
|
|
|
border-radius: var(--radius-pill);
|
|
|
|
|
border: none;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
|
|
|
flex: 1;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
.theme-pill:hover {
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
}
|
|
|
|
|
.theme-pill--active {
|
|
|
|
|
background: var(--color-accent-compress);
|
|
|
|
|
color: white;
|
|
|
|
|
}
|
|
|
|
|
.theme-pill--active:hover {
|
|
|
|
|
color: white;
|
|
|
|
|
}
|
|
|
|
|
.theme-pill > i {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Zoom row ── */
|
|
|
|
|
.zoom-row {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.zoom-btn {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 28px;
|
|
|
|
|
height: 28px;
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
|
|
|
padding: 0;
|
|
|
|
|
}
|
|
|
|
|
.zoom-btn:hover {
|
|
|
|
|
background: var(--color-bg-elevated);
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
}
|
|
|
|
|
.zoom-btn > i {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
.zoom-slider {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.zoom-value {
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
min-width: 36px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Path bar ── */
|
|
|
|
|
.path-bar {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
min-height: 38px;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-icon {
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
color: var(--color-text-disabled);
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-text {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-text--empty {
|
|
|
|
|
color: var(--color-text-disabled);
|
|
|
|
|
font-style: italic;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-btn {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 5px;
|
|
|
|
|
padding: 8px 14px;
|
|
|
|
|
background: none;
|
|
|
|
|
border: none;
|
|
|
|
|
border-left: 1px solid var(--color-border);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
transition: background var(--transition-fast), color var(--transition-fast);
|
|
|
|
|
}
|
|
|
|
|
.path-bar-btn:hover {
|
|
|
|
|
background: var(--color-bg-elevated);
|
|
|
|
|
color: var(--color-text-primary);
|
|
|
|
|
}
|
|
|
|
|
.path-bar-btn--danger:hover {
|
|
|
|
|
color: var(--color-accent-error);
|
|
|
|
|
background: rgba(239, 68, 68, 0.06);
|
|
|
|
|
}
|
|
|
|
|
.path-bar-btn--spinning {
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
|
|
|
|
.path-bar-btn > i {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.version-pill {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 5px;
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--color-accent-compress);
|
|
|
|
|
}
|
|
|
|
|
.version-pill > i {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Footer ── */
|
|
|
|
|
.settings-footer {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
margin-top: 32px;
|
|
|
|
|
}
|
|
|
|
|
.reset-btn {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 7px;
|
|
|
|
|
padding: 10px 20px;
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
background: var(--color-bg-elevated);
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
font-family: var(--font-body);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
|
|
|
}
|
|
|
|
|
.reset-btn:hover {
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
color: var(--color-accent-error);
|
|
|
|
|
border-color: rgba(239, 68, 68, 0.3);
|
|
|
|
|
box-shadow: 0 0 12px rgba(239, 68, 68, 0.08);
|
|
|
|
|
}
|
|
|
|
|
.reset-btn > i {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
</style>
|