956 lines
29 KiB
Svelte
956 lines
29 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { DEFAULT_CONFIG, TIMING, API } from '../constants.js';
|
|
import { handleError } from '../errorHandler.js';
|
|
import EyeIndicator from './visual/EyeIndicator.svelte';
|
|
import AlignmentIndicator from './visual/AlignmentIndicator.svelte';
|
|
import BrightnessIndicator from './visual/BrightnessIndicator.svelte';
|
|
|
|
let { disabled = false } = $props();
|
|
|
|
let isOpen = $state(false);
|
|
let loading = $state(true);
|
|
let saving = $state(false);
|
|
let error = $state(null);
|
|
let saveMessage = $state(null);
|
|
let activeTab = $state('face');
|
|
|
|
// Config state - initialized from DEFAULT_CONFIG, then loaded from API
|
|
let config = $state(JSON.parse(JSON.stringify(DEFAULT_CONFIG)));
|
|
|
|
async function loadConfig() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const res = await fetch(API.config);
|
|
if (!res.ok) throw res;
|
|
config = await res.json();
|
|
} catch (e) {
|
|
error = await handleError('Failed to load config', e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function saveConfig() {
|
|
saving = true;
|
|
error = null;
|
|
saveMessage = null;
|
|
try {
|
|
// Ensure all numeric values are proper numbers (not strings from range inputs)
|
|
const configToSend = {
|
|
processing: {
|
|
max_workers: Number(config.processing.max_workers),
|
|
use_preview: config.processing.use_preview,
|
|
face_resolution: {
|
|
enabled: config.processing.face_resolution.enabled,
|
|
min_size: Number(config.processing.face_resolution.min_size),
|
|
},
|
|
brightness: {
|
|
enabled: config.processing.brightness.enabled,
|
|
min_brightness: Number(config.processing.brightness.min_brightness),
|
|
max_brightness: Number(config.processing.brightness.max_brightness),
|
|
},
|
|
blur: {
|
|
enabled: config.processing.blur.enabled,
|
|
min_sharpness: Number(config.processing.blur.min_sharpness),
|
|
},
|
|
head_pose: {
|
|
enabled: config.processing.head_pose.enabled,
|
|
max_yaw: Number(config.processing.head_pose.max_yaw),
|
|
max_pitch: Number(config.processing.head_pose.max_pitch),
|
|
},
|
|
eye_filter: {
|
|
enabled: config.processing.eye_filter.enabled,
|
|
min_ear: Number(config.processing.eye_filter.min_ear),
|
|
},
|
|
output: {
|
|
size: Math.max(64, Math.min(4096, Number(config.processing.output.size) || 512)),
|
|
keep_intermediates: config.processing.output.keep_intermediates,
|
|
},
|
|
alignment: {
|
|
eye_distance: Number(config.processing.alignment.eye_distance),
|
|
},
|
|
timestamp: {
|
|
enabled: config.processing.timestamp.enabled,
|
|
position: config.processing.timestamp.position,
|
|
year: config.processing.timestamp.year,
|
|
month: config.processing.timestamp.month,
|
|
day: config.processing.timestamp.day,
|
|
},
|
|
time_interval: {
|
|
enabled: config.processing.time_interval.enabled,
|
|
max_photos: Number(config.processing.time_interval.max_photos),
|
|
time_range: config.processing.time_interval.time_range,
|
|
},
|
|
},
|
|
video: {
|
|
enabled: config.video.enabled,
|
|
framerate: Number(config.video.framerate),
|
|
codec: config.video.codec,
|
|
crf: Number(config.video.crf),
|
|
},
|
|
};
|
|
|
|
const res = await fetch(API.config, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(configToSend),
|
|
});
|
|
if (!res.ok) throw res;
|
|
config = await res.json();
|
|
saveMessage = 'Settings saved';
|
|
setTimeout(() => (saveMessage = null), TIMING.messageDisplayDuration);
|
|
} catch (e) {
|
|
error = await handleError('Failed to save config', e);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function resetToDefaults() {
|
|
config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
|
|
}
|
|
|
|
function toggle() {
|
|
isOpen = !isOpen;
|
|
}
|
|
|
|
onMount(() => {
|
|
loadConfig();
|
|
});
|
|
</script>
|
|
|
|
<div class="settings-panel">
|
|
<button type="button" class="toggle-btn" onclick={toggle}>
|
|
<span class="icon">{isOpen ? '▼' : '▶'}</span>
|
|
Settings
|
|
</button>
|
|
|
|
{#if isOpen}
|
|
<div class="settings-content">
|
|
{#if loading}
|
|
<p class="loading">Loading settings...</p>
|
|
{:else if error}
|
|
<p class="error">{error}</p>
|
|
<button type="button" class="retry-btn" onclick={loadConfig}>Retry</button>
|
|
{:else}
|
|
<!-- Tab navigation -->
|
|
<div class="tabs">
|
|
<button
|
|
type="button"
|
|
class="tab"
|
|
class:active={activeTab === 'face'}
|
|
onclick={() => (activeTab = 'face')}
|
|
>
|
|
Face
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="tab"
|
|
class:active={activeTab === 'output'}
|
|
onclick={() => (activeTab = 'output')}
|
|
>
|
|
Output
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="tab"
|
|
class:active={activeTab === 'video'}
|
|
onclick={() => (activeTab = 'video')}
|
|
>
|
|
Video
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tab content -->
|
|
<div class="tab-content">
|
|
{#if activeTab === 'face'}
|
|
<fieldset disabled={disabled || saving}>
|
|
<!-- Face Resolution Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Face Resolution</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.face_resolution.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.face_resolution.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<label for="min-face-size">
|
|
<span class="setting-label">Min Face Size</span>
|
|
<span class="setting-hint">Minimum face resolution in pixels</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="min-face-size"
|
|
type="range"
|
|
bind:value={config.processing.face_resolution.min_size}
|
|
min="20"
|
|
max="200"
|
|
step="10"
|
|
/>
|
|
<span class="value">{config.processing.face_resolution.min_size}px</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Brightness Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Brightness Filter</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.brightness.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.brightness.enabled}
|
|
<div class="brightness-visual-control sub-setting">
|
|
<div class="brightness-hint">
|
|
Discard photos if the face is under/over exposed
|
|
</div>
|
|
<div class="brightness-indicator-container">
|
|
<BrightnessIndicator
|
|
bind:minBrightness={config.processing.brightness.min_brightness}
|
|
bind:maxBrightness={config.processing.brightness.max_brightness}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Blur Detection Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Blur Filter</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.blur.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.blur.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<label for="min-sharpness">
|
|
<span class="setting-label">Min Sharpness</span>
|
|
<span class="setting-hint">Discard blurry faces</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="min-sharpness"
|
|
type="range"
|
|
bind:value={config.processing.blur.min_sharpness}
|
|
min="10"
|
|
max="50"
|
|
step="1"
|
|
/>
|
|
<span class="value">{Number(config.processing.blur.min_sharpness).toFixed(0)}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Head Pose Filter Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Head Pose Filter</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.head_pose.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.head_pose.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<label for="max-yaw">
|
|
<span class="setting-label">Max Yaw</span>
|
|
<span class="setting-hint">Maximum left/right turn angle</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="max-yaw"
|
|
type="range"
|
|
bind:value={config.processing.head_pose.max_yaw}
|
|
min="5"
|
|
max="90"
|
|
step="5"
|
|
/>
|
|
<span class="value">{config.processing.head_pose.max_yaw.toFixed(0)}°</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row sub-setting">
|
|
<label for="max-pitch">
|
|
<span class="setting-label">Max Pitch</span>
|
|
<span class="setting-hint">Maximum up/down tilt angle</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="max-pitch"
|
|
type="range"
|
|
bind:value={config.processing.head_pose.max_pitch}
|
|
min="5"
|
|
max="90"
|
|
step="5"
|
|
/>
|
|
<span class="value">{config.processing.head_pose.max_pitch.toFixed(0)}°</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Eye Filter Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Eye Filter (Blink Detection)</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.eye_filter.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.eye_filter.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<label for="min-ear">
|
|
<span class="setting-label">Minimum eye opening</span>
|
|
<span class="setting-hint">Discard image if Eye Aspect Ratio is below this value</span>
|
|
</label>
|
|
<div class="setting-control-with-visual">
|
|
<input
|
|
id="min-ear"
|
|
type="range"
|
|
bind:value={config.processing.eye_filter.min_ear}
|
|
min="0.1"
|
|
max="0.4"
|
|
step="0.02"
|
|
/>
|
|
<span class="value">{config.processing.eye_filter.min_ear.toFixed(2)}</span>
|
|
<div class="inline-visual-indicator">
|
|
<EyeIndicator ear={config.processing.eye_filter.min_ear} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="setting-row checkbox-row">
|
|
<label for="keep-intermediates">
|
|
<span class="setting-label">Keep Debug Images</span>
|
|
<span class="setting-hint">Save intermediate processing visualizations</span>
|
|
</label>
|
|
<input id="keep-intermediates" type="checkbox" bind:checked={config.processing.output.keep_intermediates} />
|
|
</div>
|
|
|
|
</fieldset>
|
|
{:else if activeTab === 'output'}
|
|
<fieldset disabled={disabled || saving}>
|
|
<div class="setting-section">
|
|
<div class="setting-row">
|
|
<label for="output-size">
|
|
<span class="setting-label">Output Size</span>
|
|
<span class="setting-hint">Final image dimensions (square)</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="output-size"
|
|
type="number"
|
|
class="inline-number"
|
|
bind:value={config.processing.output.size}
|
|
min="64"
|
|
max="4096"
|
|
/>
|
|
<span class="setting-label">px</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label for="max-workers">
|
|
<span class="setting-label">Parallel Workers</span>
|
|
<span class="setting-hint">Concurrent image processing tasks</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="max-workers"
|
|
type="range"
|
|
bind:value={config.processing.max_workers}
|
|
min="1"
|
|
max="16"
|
|
step="1"
|
|
/>
|
|
<span class="value">{config.processing.max_workers}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row checkbox-row">
|
|
<label for="use-preview">
|
|
<span class="setting-label">Use Preview Images</span>
|
|
<span class="setting-hint">Download Immich 1440p previews instead of originals (much faster)</span>
|
|
</label>
|
|
<input id="use-preview" type="checkbox" bind:checked={config.processing.use_preview} />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Time Interval Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Time Interval</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.time_interval.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.time_interval.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<span class="setting-label">Keep at most</span>
|
|
<div class="setting-control">
|
|
<input
|
|
id="max-photos"
|
|
type="number"
|
|
class="inline-number"
|
|
bind:value={config.processing.time_interval.max_photos}
|
|
min="1"
|
|
max="10"
|
|
/>
|
|
<span class="setting-label">photos per</span>
|
|
<div class="toggle-group">
|
|
{#each [['day', 'day'], ['week', 'week'], ['month', 'month']] as [value, label]}
|
|
<button
|
|
type="button"
|
|
class="toggle-btn-option"
|
|
class:active={config.processing.time_interval.time_range === value}
|
|
onclick={() => config.processing.time_interval.time_range = value}
|
|
>{label}</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Timestamp Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Timestamp Overlay</span>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={config.processing.timestamp.enabled}
|
|
/>
|
|
</div>
|
|
|
|
{#if config.processing.timestamp.enabled}
|
|
<div class="setting-row sub-setting">
|
|
<label for="timestamp-position">
|
|
<span class="setting-label">Position</span>
|
|
<span class="setting-hint">Where to place the timestamp</span>
|
|
</label>
|
|
<select id="timestamp-position" bind:value={config.processing.timestamp.position}>
|
|
<option value="top_left">Top Left</option>
|
|
<option value="top_right">Top Right</option>
|
|
<option value="bottom_left">Bottom Left</option>
|
|
<option value="bottom_right">Bottom Right</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row sub-setting">
|
|
<span class="setting-label">Date Components</span>
|
|
<div class="toggle-group">
|
|
<button
|
|
type="button"
|
|
class="toggle-btn-option"
|
|
class:active={config.processing.timestamp.year}
|
|
onclick={() => config.processing.timestamp.year = !config.processing.timestamp.year}
|
|
>Year</button>
|
|
<button
|
|
type="button"
|
|
class="toggle-btn-option"
|
|
class:active={config.processing.timestamp.month}
|
|
onclick={() => config.processing.timestamp.month = !config.processing.timestamp.month}
|
|
>Month</button>
|
|
<button
|
|
type="button"
|
|
class="toggle-btn-option"
|
|
class:active={config.processing.timestamp.day}
|
|
onclick={() => config.processing.timestamp.day = !config.processing.timestamp.day}
|
|
>Day</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Alignment Section -->
|
|
<div class="setting-section">
|
|
<div class="section-header">
|
|
<span class="section-title">Final Image Composition</span>
|
|
</div>
|
|
<div class="setting-row sub-setting">
|
|
<label for="eye-distance">
|
|
<span class="setting-label">Eye Distance</span>
|
|
<span class="setting-hint">Distance between eyes as % of image width (larger = zoom in)</span>
|
|
</label>
|
|
<div class="setting-control-with-visual">
|
|
<input
|
|
id="eye-distance"
|
|
type="range"
|
|
bind:value={config.processing.alignment.eye_distance}
|
|
min="0.05"
|
|
max="0.4"
|
|
step="0.01"
|
|
/>
|
|
<span class="value">{(config.processing.alignment.eye_distance * 100).toFixed(0)}%</span>
|
|
<div class="inline-visual-indicator">
|
|
<AlignmentIndicator eyeDistance={config.processing.alignment.eye_distance} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
{:else if activeTab === 'video'}
|
|
<fieldset disabled={disabled || saving}>
|
|
<div class="setting-row">
|
|
<label for="video-framerate">
|
|
<span class="setting-label">Framerate</span>
|
|
<span class="setting-hint">Video frames per second</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="video-framerate"
|
|
type="range"
|
|
bind:value={config.video.framerate}
|
|
min="1"
|
|
max="60"
|
|
step="1"
|
|
/>
|
|
<span class="value">{config.video.framerate} fps</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label for="video-crf">
|
|
<span class="setting-label">Quality (CRF)</span>
|
|
<span class="setting-hint">Lower = better quality, larger file</span>
|
|
</label>
|
|
<div class="setting-control">
|
|
<input
|
|
id="video-crf"
|
|
type="range"
|
|
bind:value={config.video.crf}
|
|
min="15"
|
|
max="40"
|
|
step="1"
|
|
/>
|
|
<span class="value">{config.video.crf}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<label for="video-codec">
|
|
<span class="setting-label">Codec</span>
|
|
<span class="setting-hint">Video encoding format</span>
|
|
</label>
|
|
<select id="video-codec" bind:value={config.video.codec}>
|
|
<option value="libx264">H.264 (libx264)</option>
|
|
<option value="libx265">H.265 (libx265)</option>
|
|
<option value="libvpx-vp9">VP9 (libvpx-vp9)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="setting-row checkbox-row">
|
|
<label for="video-enabled">
|
|
<span class="setting-label">Auto-compile Video</span>
|
|
<span class="setting-hint">Automatically create video after processing</span>
|
|
</label>
|
|
<input id="video-enabled" type="checkbox" bind:checked={config.video.enabled} />
|
|
</div>
|
|
</fieldset>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button type="button" class="save-btn" onclick={saveConfig} disabled={disabled || saving}>
|
|
{saving ? 'Saving...' : 'Save Settings'}
|
|
</button>
|
|
<button type="button" class="reset-btn" onclick={resetToDefaults} disabled={disabled || saving}>
|
|
Reset to Defaults
|
|
</button>
|
|
{#if saveMessage}
|
|
<span class="save-message">{saveMessage}</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.settings-panel {
|
|
background: #1a1a1a;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.toggle-btn {
|
|
width: 100%;
|
|
padding: 1rem 1.5rem;
|
|
background: none;
|
|
border: none;
|
|
color: #e0e0e0;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
text-align: left;
|
|
}
|
|
|
|
.toggle-btn:hover {
|
|
background: #252525;
|
|
}
|
|
|
|
.icon {
|
|
font-size: 0.75rem;
|
|
color: #888;
|
|
}
|
|
|
|
.settings-content {
|
|
padding: 0 1.5rem 1.5rem;
|
|
}
|
|
|
|
.loading,
|
|
.error {
|
|
font-size: 0.875rem;
|
|
padding: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.error {
|
|
color: #dc2626;
|
|
}
|
|
|
|
.retry-btn {
|
|
display: block;
|
|
margin: 0 auto;
|
|
padding: 0.5rem 1rem;
|
|
background: #333;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: #e0e0e0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Tabs */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
margin-bottom: 1rem;
|
|
border-bottom: 1px solid #333;
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.5rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 4px 4px 0 0;
|
|
color: #888;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.tab:hover {
|
|
color: #e0e0e0;
|
|
background: #252525;
|
|
}
|
|
|
|
.tab.active {
|
|
color: #e0e0e0;
|
|
background: #333;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Tab content */
|
|
.tab-content {
|
|
min-height: 200px;
|
|
}
|
|
|
|
fieldset {
|
|
border: none;
|
|
padding: 0;
|
|
}
|
|
|
|
fieldset:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.setting-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid #252525;
|
|
}
|
|
|
|
.setting-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.setting-row label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.125rem;
|
|
}
|
|
|
|
.setting-label {
|
|
font-size: 0.875rem;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.setting-hint {
|
|
font-size: 0.75rem;
|
|
color: #666;
|
|
}
|
|
|
|
.setting-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
input[type='range'] {
|
|
width: 120px;
|
|
accent-color: #4f46e5;
|
|
}
|
|
|
|
.value {
|
|
font-size: 0.875rem;
|
|
color: #4f46e5;
|
|
font-weight: 600;
|
|
min-width: 60px;
|
|
text-align: right;
|
|
}
|
|
|
|
select {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
background: #0f0f0f;
|
|
color: #e0e0e0;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
select:focus {
|
|
outline: none;
|
|
border-color: #4f46e5;
|
|
}
|
|
|
|
.checkbox-row {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.checkbox-row input[type='checkbox'] {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
accent-color: #4f46e5;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Settings section with header */
|
|
.setting-section {
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
.setting-section:last-child {
|
|
margin-bottom: 0;
|
|
padding-bottom: 0;
|
|
border-bottom: none;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.section-header input[type='checkbox'] {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
accent-color: #4f46e5;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sub-setting {
|
|
padding-left: 1.5rem;
|
|
border-left: 2px solid #333;
|
|
margin-left: 0.75rem;
|
|
}
|
|
|
|
.actions {
|
|
margin-top: 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.save-btn {
|
|
padding: 0.75rem 1.5rem;
|
|
background: #4f46e5;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.save-btn:hover:not(:disabled) {
|
|
background: #4338ca;
|
|
}
|
|
|
|
.save-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.reset-btn {
|
|
padding: 0.75rem 1.5rem;
|
|
background: #333;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #e0e0e0;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.reset-btn:hover:not(:disabled) {
|
|
background: #444;
|
|
}
|
|
|
|
.reset-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.save-message {
|
|
font-size: 0.875rem;
|
|
color: #22c55e;
|
|
}
|
|
|
|
/* Visual indicator layout */
|
|
.setting-control-with-visual {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.inline-visual-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0.5rem;
|
|
background: #0f0f0f;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
width: 96px;
|
|
height: 96px;
|
|
}
|
|
|
|
.inline-number {
|
|
width: 3rem;
|
|
padding: 0.3rem 0.4rem;
|
|
background: #0f0f0f;
|
|
border: 1px solid #333;
|
|
border-radius: 4px;
|
|
color: #e0e0e0;
|
|
font-size: 0.875rem;
|
|
text-align: center;
|
|
-moz-appearance: textfield;
|
|
}
|
|
|
|
.inline-number::-webkit-inner-spin-button,
|
|
.inline-number::-webkit-outer-spin-button {
|
|
-webkit-appearance: none;
|
|
margin: 0;
|
|
}
|
|
|
|
.inline-number:focus {
|
|
outline: none;
|
|
border-color: #4f46e5;
|
|
}
|
|
|
|
/* Toggle button group */
|
|
.toggle-group {
|
|
display: flex;
|
|
gap: 0;
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
border: 1px solid #333;
|
|
}
|
|
|
|
.toggle-btn-option {
|
|
padding: 0.4rem 0.75rem;
|
|
background: #0f0f0f;
|
|
border: none;
|
|
border-right: 1px solid #333;
|
|
color: #888;
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.toggle-btn-option:last-child {
|
|
border-right: none;
|
|
}
|
|
|
|
.toggle-btn-option:hover {
|
|
background: #252525;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.toggle-btn-option.active {
|
|
background: #4f46e5;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Brightness visual control */
|
|
.brightness-visual-control {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.brightness-hint {
|
|
font-size: 0.75rem;
|
|
color: #666;
|
|
text-align: center;
|
|
}
|
|
|
|
.brightness-indicator-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 1rem;
|
|
background: #0f0f0f;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
}
|
|
</style>
|