Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,718 @@
|
||||
<script>
|
||||
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let settings = $state(null);
|
||||
let saving = $state(false);
|
||||
let savedFlash = $state(false);
|
||||
let saveError = $state("");
|
||||
let refreshingArtists = $state(false);
|
||||
let artistRefreshResult = $state("");
|
||||
let refreshingAlbums = $state(false);
|
||||
let albumRefreshResult = $state("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
settings = await api.getSettings();
|
||||
} catch {
|
||||
settings = null;
|
||||
}
|
||||
}
|
||||
load();
|
||||
|
||||
async function commit(patch) {
|
||||
saving = true;
|
||||
saveError = "";
|
||||
try {
|
||||
await api.setSettings(patch);
|
||||
await load();
|
||||
savedFlash = true;
|
||||
setTimeout(() => (savedFlash = false), 1500);
|
||||
return true;
|
||||
} catch (e) {
|
||||
saveError = `Could not save: ${e}`;
|
||||
return false;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseDownloadDir() {
|
||||
const dir = await api.pickFolder();
|
||||
if (dir) await commit({ downloadDir: dir });
|
||||
}
|
||||
|
||||
async function addSharedFolder() {
|
||||
const dir = await api.pickFolder();
|
||||
if (dir && !settings.shared_folders.includes(dir)) {
|
||||
await commit({ sharedFolders: [...settings.shared_folders, dir] });
|
||||
}
|
||||
}
|
||||
|
||||
async function setLibraryLayout(value) {
|
||||
if (value !== settings.library_layout) await commit({ libraryLayout: value });
|
||||
}
|
||||
|
||||
async function removeSharedFolder(folder) {
|
||||
await commit({
|
||||
sharedFolders: settings.shared_folders.filter((f) => f !== folder),
|
||||
});
|
||||
}
|
||||
|
||||
async function setSlots(n) {
|
||||
await commit({ uploadSlots: Math.max(1, Math.min(10, n)) });
|
||||
}
|
||||
|
||||
async function setAudioOutput(device) {
|
||||
if (device !== (settings.audio_output_device ?? "")) {
|
||||
await commit({ audioOutputDevice: device });
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE_PREF_DEFAULTS = { max: "any", min: "any", priority: "quality" };
|
||||
function sourcePrefsFor(mode) {
|
||||
const stored = mode === "stream"
|
||||
? settings.stream_source_prefs
|
||||
: settings.download_source_prefs;
|
||||
return { ...SOURCE_PREF_DEFAULTS, ...(stored ?? {}) };
|
||||
}
|
||||
|
||||
async function setSourcePref(mode, field, value) {
|
||||
const next = { ...sourcePrefsFor(mode), [field]: value };
|
||||
const patch = mode === "stream"
|
||||
? { streamSourcePrefs: next }
|
||||
: { downloadSourcePrefs: next };
|
||||
if (await commit(patch)) await refreshSourcePrefs();
|
||||
}
|
||||
|
||||
async function setArtistHeroUpscaler(upscaler) {
|
||||
if (upscaler === settings.artist_hero_upscaler) return;
|
||||
if (await commit({ artistHeroUpscaler: upscaler })) {
|
||||
clearCatalogHeroArtCache();
|
||||
}
|
||||
}
|
||||
|
||||
async function forget() {
|
||||
await api.forgetCredentials();
|
||||
app.username = "";
|
||||
await load();
|
||||
}
|
||||
|
||||
async function setStreamRetention(value) {
|
||||
if (value === "forever") {
|
||||
if (!settings.stream_keep_forever) {
|
||||
await commit({ streamKeepForever: true });
|
||||
}
|
||||
} else {
|
||||
const days = Number(value);
|
||||
if (settings.stream_keep_forever || days !== settings.stream_retention_days) {
|
||||
await commit({ streamKeepForever: false, streamRetentionDays: days });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setStreamCacheMax(value) {
|
||||
const mb = Number(value);
|
||||
if (mb !== settings.stream_cache_max_mb) {
|
||||
await commit({ streamCacheMaxMb: mb });
|
||||
}
|
||||
}
|
||||
|
||||
async function setReplaceEmbeddedArt(value) {
|
||||
const next = value === "replace";
|
||||
if (next !== settings.replace_embedded_art) {
|
||||
await commit({ replaceEmbeddedArt: next });
|
||||
}
|
||||
}
|
||||
|
||||
async function setEmbedArtMaxEdge(value) {
|
||||
const next = Number(value);
|
||||
if (next !== settings.embed_art_max_edge) {
|
||||
await commit({ embedArtMaxEdge: next });
|
||||
}
|
||||
}
|
||||
|
||||
async function setWriteFolderCover(value) {
|
||||
const next = value === "write";
|
||||
if (next !== settings.write_folder_cover) {
|
||||
await commit({ writeFolderCover: next });
|
||||
}
|
||||
}
|
||||
|
||||
// Sequential (one album ≈ one second: MusicBrainz is throttled), so a big
|
||||
// library takes a while — the button stays busy until the sweep finishes.
|
||||
async function refreshAllAlbumArt() {
|
||||
if (refreshingAlbums) return;
|
||||
refreshingAlbums = true;
|
||||
albumRefreshResult = "";
|
||||
try {
|
||||
const count = await api.refreshAllAlbumArt();
|
||||
albumRefreshResult = `Upgraded ${count} album ${count === 1 ? "cover" : "covers"}.`;
|
||||
} catch (e) {
|
||||
albumRefreshResult = `Refresh failed: ${e}`;
|
||||
} finally {
|
||||
refreshingAlbums = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshArtistArt() {
|
||||
if (refreshingArtists) return;
|
||||
refreshingArtists = true;
|
||||
artistRefreshResult = "";
|
||||
try {
|
||||
const count = await api.refreshArtistArt();
|
||||
artistRefreshResult = `Refreshed ${count} artist ${count === 1 ? "portrait" : "portraits"}.`;
|
||||
} catch (e) {
|
||||
artistRefreshResult = `Refresh failed: ${e}`;
|
||||
} finally {
|
||||
refreshingArtists = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
<h1>Settings</h1>
|
||||
|
||||
{#if !settings}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else}
|
||||
<section>
|
||||
<h2>Account</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Soulseek account</div>
|
||||
<div class="hint">
|
||||
{settings.username
|
||||
? `Signed in as ${settings.username}`
|
||||
: "Not signed in"}
|
||||
{#if settings.connected}<span class="online"> · online</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
{#if app.connection !== "online"}
|
||||
<button class="btn" onclick={() => (app.showLogin = true)}>Connect</button>
|
||||
{/if}
|
||||
{#if settings.has_password}
|
||||
<button class="btn ghost" onclick={forget}>Forget credentials</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="stored-note">
|
||||
<Icon name="lock" size={13} />
|
||||
Your password is stored in the Windows Credential Manager, not in a plain file.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Playback</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Audio output</div>
|
||||
<div class="hint">System default follows Windows when headphones or speakers change.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.audio_output_device ?? ""}
|
||||
disabled={saving}
|
||||
onchange={(e) => setAudioOutput(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">System default (recommended)</option>
|
||||
{#if settings.audio_output_device && !settings.audio_output_devices.includes(settings.audio_output_device)}
|
||||
<option value={settings.audio_output_device}>{settings.audio_output_device} (unavailable)</option>
|
||||
{/if}
|
||||
{#each settings.audio_output_devices as device (device)}
|
||||
<option value={device}>{device}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Streaming</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Keep streamed tracks</div>
|
||||
<div class="hint">Streamed songs stay in a private cache for instant replay — not in your library. Choose Forever to add everything you stream to the library instead.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.stream_keep_forever ? "forever" : String(settings.stream_retention_days)}
|
||||
disabled={saving}
|
||||
onchange={(e) => setStreamRetention(e.currentTarget.value)}
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7">1 week</option>
|
||||
<option value="14">2 weeks</option>
|
||||
<option value="30">1 month (recommended)</option>
|
||||
<option value="90">3 months</option>
|
||||
<option value="forever">Forever — save to library</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Cache size limit</div>
|
||||
<div class="hint">Oldest streamed tracks are removed first once the cache passes this size. Saved and downloaded music is never touched.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={String(settings.stream_cache_max_mb)}
|
||||
disabled={saving}
|
||||
onchange={(e) => setStreamCacheMax(e.currentTarget.value)}
|
||||
>
|
||||
<option value="512">512 MB</option>
|
||||
<option value="1024">1 GB</option>
|
||||
<option value="2048">2 GB (recommended)</option>
|
||||
<option value="5120">5 GB</option>
|
||||
<option value="10240">10 GB</option>
|
||||
<option value="20480">20 GB</option>
|
||||
<option value="0">Unlimited</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="muted small">Retention counts from the last time a track was played. Tracks you save stay in your library regardless.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Library & downloads</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Library folder</div>
|
||||
<div class="hint path">{settings.download_dir}</div>
|
||||
</div>
|
||||
<button class="btn" onclick={chooseDownloadDir}>Change…</button>
|
||||
</div>
|
||||
<p class="muted small">This is your downloaded-music library and is always shared. Folder changes apply after reconnecting.</p>
|
||||
<div class="layout-row">
|
||||
<div class="label">
|
||||
<div class="name">Album folder format</div>
|
||||
<div class="hint">Use {"{artist}"}, {"{album}"}, {"{date}"}, or {"{year}"}; "\" starts a subfolder. Full dates display as DD-MM-YYYY.</div>
|
||||
</div>
|
||||
<input
|
||||
class="layout-input"
|
||||
value={settings.library_layout}
|
||||
onkeydown={(e) => e.key === "Enter" && e.currentTarget.blur()}
|
||||
onchange={(e) => setLibraryLayout(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<p class="muted small">New downloads are organized into this format; existing files are not moved.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Sharing</h2>
|
||||
<p class="muted small">
|
||||
Sharing keeps you a good citizen — leechers get banned. Your download
|
||||
folder is always shared ({settings.share_files} files indexed).
|
||||
Add extra folders below.
|
||||
</p>
|
||||
{#if settings.shared_folders.length > 0}
|
||||
<div class="folder-list">
|
||||
{#each settings.shared_folders as folder (folder)}
|
||||
<div class="folder">
|
||||
<span class="path">{folder}</span>
|
||||
<button
|
||||
class="remove"
|
||||
title="Stop sharing"
|
||||
onclick={() => removeSharedFolder(folder)}
|
||||
><Icon name="x" size={15} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="btn" onclick={addSharedFolder}>Add folder…</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Album artwork</h2>
|
||||
<p class="muted small">Covers are resolved from Deezer, Apple, and the Cover Art Archive; the artwork shown in the catalog wins, at the highest resolution any source serves. One canonical image is used for the library, the embedded tags, and the album folder.</p>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Art inside downloaded files</div>
|
||||
<div class="hint">Replace whatever picture the uploader embedded with the canonical cover, or keep theirs when present.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.replace_embedded_art ? "replace" : "keep"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setReplaceEmbeddedArt(e.currentTarget.value)}
|
||||
>
|
||||
<option value="replace">Replace with canonical (recommended)</option>
|
||||
<option value="keep">Keep uploader's art</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Embedded cover size</div>
|
||||
<div class="hint">Longest edge of the copy written into each audio file. The full-resolution original is always kept in the album folder and art cache.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={String(settings.embed_art_max_edge)}
|
||||
disabled={saving}
|
||||
onchange={(e) => setEmbedArtMaxEdge(e.currentTarget.value)}
|
||||
>
|
||||
<option value="0">Original (largest files)</option>
|
||||
<option value="1500">1500 px (recommended)</option>
|
||||
<option value="1000">1000 px</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Album folder cover</div>
|
||||
<div class="hint">Write the canonical cover as cover.jpg and folder.jpg so Explorer and other players show the same artwork.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.write_folder_cover ? "write" : "skip"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setWriteFolderCover(e.currentTarget.value)}
|
||||
>
|
||||
<option value="write">Write cover.jpg + folder.jpg</option>
|
||||
<option value="skip">Leave folders untouched</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Re-fetch all album covers</div>
|
||||
<div class="hint">Upgrade every library album to the canonical high-resolution cover — cache, embedded tags, and folder files. Roughly one album per second.</div>
|
||||
</div>
|
||||
<button class="btn" onclick={refreshAllAlbumArt} disabled={refreshingAlbums}>
|
||||
{refreshingAlbums ? "Upgrading…" : "Re-fetch covers"}
|
||||
</button>
|
||||
</div>
|
||||
{#if albumRefreshResult}<p class="muted small refresh-result">{albumRefreshResult}</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Artist artwork</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Hero renderer</div>
|
||||
<div class="hint">Live GPU creates one native-4K master, then mip-filters it cleanly for every window size.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.artist_hero_upscaler ?? "live_gpu"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setArtistHeroUpscaler(e.currentTarget.value)}
|
||||
>
|
||||
<option value="live_gpu">Live GPU 4K (recommended)</option>
|
||||
<option value="fsr_legacy">Original baked FSR 1</option>
|
||||
<option value="lanczos3">Baked Lanczos3</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="muted small">Live GPU applies crop ? EASU ? RCAS ? color/fade/grain once at 3840?2160. Resizing only re-samples that fixed master, preserving grain scale and avoiding baked-file latency.</p>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Refresh artist portraits</div>
|
||||
<div class="hint">Fetch portraits for artists already in your library.</div>
|
||||
</div>
|
||||
<button class="btn" onclick={refreshArtistArt} disabled={refreshingArtists}>
|
||||
{refreshingArtists ? "Refreshing…" : "Refresh artwork"}
|
||||
</button>
|
||||
</div>
|
||||
{#if artistRefreshResult}<p class="muted small refresh-result">{artistRefreshResult}</p>{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Source quality</h2>
|
||||
<p class="muted small">
|
||||
Independent preferences for streaming and downloading. The ceiling and floor
|
||||
bound which peer files qualify; priority decides whether the resolver picks
|
||||
the best format or the fastest transfer among them.
|
||||
</p>
|
||||
{#each [
|
||||
{ key: "stream", label: "Streaming" },
|
||||
{ key: "download", label: "Downloads" },
|
||||
] as mode (mode.key)}
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">{mode.label} — maximum</div>
|
||||
<div class="hint">Skip files above this ceiling (lossy-only avoids huge lossless transfers).</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={sourcePrefsFor(mode.key).max}
|
||||
disabled={saving}
|
||||
onchange={(e) => setSourcePref(mode.key, "max", e.currentTarget.value)}
|
||||
>
|
||||
<option value="any">No limit (lossless preferred)</option>
|
||||
<option value="lossy_only">Lossy only (≤ 320 kbps)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">{mode.label} — minimum</div>
|
||||
<div class="hint">Reject files below this floor. Lossy files must report a bitrate to qualify.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={sourcePrefsFor(mode.key).min}
|
||||
disabled={saving}
|
||||
onchange={(e) => setSourcePref(mode.key, "min", e.currentTarget.value)}
|
||||
>
|
||||
<option value="any">Any quality</option>
|
||||
<option value="kbps192">192 kbps and up</option>
|
||||
<option value="kbps256">256 kbps and up</option>
|
||||
<option value="kbps320">320 kbps and up</option>
|
||||
<option value="lossless">Lossless only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">{mode.label} — priority</div>
|
||||
<div class="hint">
|
||||
Fastest transfer estimates arrival time from file size ÷ peer speed
|
||||
(plus queue wait) and picks whatever lands soonest — a large FLAC from a
|
||||
fast peer can win. Only files inside your min/max window compete.
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={sourcePrefsFor(mode.key).priority}
|
||||
disabled={saving}
|
||||
onchange={(e) => setSourcePref(mode.key, "priority", e.currentTarget.value)}
|
||||
>
|
||||
<option value="quality">Best quality</option>
|
||||
<option value="speed">Fastest transfer (est. time)</option>
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Uploads</h2>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Upload slots</div>
|
||||
<div class="hint">How many peers can download from you at once.</div>
|
||||
</div>
|
||||
<div class="stepper">
|
||||
<button onclick={() => setSlots(settings.upload_slots - 1)}>
|
||||
<Icon name="minus" size={16} />
|
||||
</button>
|
||||
<span>{settings.upload_slots}</span>
|
||||
<button onclick={() => setSlots(settings.upload_slots + 1)}>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if saving}
|
||||
<p class="muted small">Saving…</p>
|
||||
{:else if savedFlash}
|
||||
<p class="saved small">✓ Saved</p>
|
||||
{:else if saveError}
|
||||
<p class="save-error small">{saveError}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-family: "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.online {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
background: var(--text-primary);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn.ghost:hover {
|
||||
color: var(--text-primary);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.stored-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.layout-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 10px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.layout-input {
|
||||
width: 270px;
|
||||
max-width: 48%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: "Cascadia Code", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.layout-input:focus { border-color: var(--text-secondary); }
|
||||
|
||||
.device-select {
|
||||
width: 310px;
|
||||
max-width: 52%;
|
||||
padding: 9px 34px 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.device-select:focus { border-color: var(--text-secondary); }
|
||||
.device-select:disabled { opacity: 0.6; }
|
||||
|
||||
.folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stepper button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.stepper button:hover {
|
||||
background: var(--bg-press);
|
||||
}
|
||||
|
||||
.stepper span {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.saved {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.save-error {
|
||||
color: #f15e6c;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user