From 8cef79b2b4cb36d1b900fa7e47e505b57598b991 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Sat, 23 May 2026 02:18:36 -0400 Subject: [PATCH] Implement phase 1 --- .gitignore | 1 + src/app.css | 263 +----------------- src/hooks.client.ts | 115 ++++---- src/lib/core/image.ts | 30 ++ src/lib/core/keybinds/keybindEngine.ts | 18 +- src/lib/core/persistence/persist.ts | 77 ++++++ src/lib/core/theme.ts | 41 +++ src/lib/design/base/animations.css | 48 ++++ src/lib/design/base/index.css | 4 + src/lib/design/base/reset.css | 48 ++++ src/lib/design/base/scrollbars.css | 22 ++ src/lib/design/base/typography.css | 9 + src/lib/design/index.css | 3 + src/lib/design/themes/dark.css | 25 ++ src/lib/design/themes/index.css | 6 + src/lib/design/themes/light-contrast.css | 29 ++ src/lib/design/themes/light.css | 29 ++ src/lib/design/themes/midnight.css | 25 ++ src/lib/design/themes/original.css | 31 +++ src/lib/design/themes/warm.css | 25 ++ src/lib/design/tokens/colors.css | 35 +++ src/lib/design/tokens/index.css | 7 + src/lib/design/tokens/motion.css | 5 + src/lib/design/tokens/radius.css | 8 + src/lib/design/tokens/shadows.css | 2 + src/lib/design/tokens/spacing.css | 13 + src/lib/design/tokens/typography.css | 28 ++ src/lib/design/tokens/zindex.css | 5 + src/lib/state/history.svelte.ts | 168 ++++++++++++ src/lib/state/manga-prefs.svelte.ts | 40 +++ src/lib/state/settings.svelte.ts | 140 ++++++++++ src/lib/ui/chrome/ContextMenu.svelte | 333 +++++++++++++++++++++++ src/lib/ui/manga/MangaCard.svelte | 137 ++++++++++ src/lib/ui/manga/ThreeDCard.svelte | 102 +++++++ src/lib/ui/manga/Thumbnail.svelte | 98 +++++++ src/lib/ui/primitives/Button.svelte | 84 ++++++ src/lib/ui/primitives/Dropdown.svelte | 78 ++++++ src/lib/ui/primitives/Input.svelte | 73 +++++ src/lib/ui/primitives/Modal.svelte | 130 +++++++++ src/lib/ui/primitives/Select.svelte | 84 ++++++ 40 files changed, 2118 insertions(+), 301 deletions(-) create mode 100644 src/lib/core/image.ts create mode 100644 src/lib/core/persistence/persist.ts create mode 100644 src/lib/core/theme.ts create mode 100644 src/lib/design/base/animations.css create mode 100644 src/lib/design/base/index.css create mode 100644 src/lib/design/base/reset.css create mode 100644 src/lib/design/base/scrollbars.css create mode 100644 src/lib/design/base/typography.css create mode 100644 src/lib/design/index.css create mode 100644 src/lib/design/themes/dark.css create mode 100644 src/lib/design/themes/index.css create mode 100644 src/lib/design/themes/light-contrast.css create mode 100644 src/lib/design/themes/light.css create mode 100644 src/lib/design/themes/midnight.css create mode 100644 src/lib/design/themes/original.css create mode 100644 src/lib/design/themes/warm.css create mode 100644 src/lib/design/tokens/colors.css create mode 100644 src/lib/design/tokens/index.css create mode 100644 src/lib/design/tokens/motion.css create mode 100644 src/lib/design/tokens/radius.css create mode 100644 src/lib/design/tokens/shadows.css create mode 100644 src/lib/design/tokens/spacing.css create mode 100644 src/lib/design/tokens/typography.css create mode 100644 src/lib/design/tokens/zindex.css create mode 100644 src/lib/state/history.svelte.ts create mode 100644 src/lib/state/manga-prefs.svelte.ts create mode 100644 src/lib/state/settings.svelte.ts create mode 100644 src/lib/ui/chrome/ContextMenu.svelte create mode 100644 src/lib/ui/manga/MangaCard.svelte create mode 100644 src/lib/ui/manga/ThreeDCard.svelte create mode 100644 src/lib/ui/manga/Thumbnail.svelte create mode 100644 src/lib/ui/primitives/Button.svelte create mode 100644 src/lib/ui/primitives/Dropdown.svelte create mode 100644 src/lib/ui/primitives/Input.svelte create mode 100644 src/lib/ui/primitives/Modal.svelte create mode 100644 src/lib/ui/primitives/Select.svelte diff --git a/.gitignore b/.gitignore index 234f3cb..d60e748 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist-tauri/ target/ bin/ out/ +notes/ .direnv/ result diff --git a/src/app.css b/src/app.css index 027fdb2..466da2e 100644 --- a/src/app.css +++ b/src/app.css @@ -1,262 +1,23 @@ +@import './lib/design/index.css'; + :root { - --bg-void: #080808; - --bg-base: #0c0c0c; - --bg-surface: #101010; - --bg-raised: #151515; - --bg-overlay: #1a1a1a; - --bg-subtle: #202020; - - --border-dim: #1c1c1c; - --border-base: #242424; - --border-strong: #2e2e2e; - --border-focus: #4a5c4a; - - --text-primary: #f0efec; - --text-secondary: #c8c6c0; - --text-muted: #8a8880; - --text-faint: #4e4d4a; - --text-disabled: #2a2a28; - - --accent: #6b8f6b; - --accent-dim: #2a3d2a; - --accent-muted: #1a251a; - --accent-fg: #a8c4a8; - --accent-bright: #8fb88f; - - --color-error: #c47a7a; - --color-error-bg: #1f1212; - --color-success: #7aab7a; - --color-info: #7a9ec4; - --color-info-bg: #121a1f; - --color-read: #2e2e2c; - - --dot-active: var(--accent); - --dot-inactive: var(--text-faint); - - --t-fast: 0.08s ease; - --t-base: 0.14s ease; - --t-slow: 0.22s ease; - - --radius-sm: 3px; - --radius-md: 5px; - --radius-lg: 7px; - --radius-xl: 10px; - --radius-2xl: 14px; - --radius-full: 9999px; - - --sp-1: 4px; - --sp-2: 8px; - --sp-3: 12px; - --sp-4: 16px; - --sp-5: 20px; - --sp-6: 24px; - --sp-8: 32px; - --sp-10: 40px; - - --sidebar-width: 52px; - --titlebar-height: 36px; - - --font-ui: "DM Mono", "Fira Mono", ui-monospace, monospace; - --font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; - - --text-2xs: 10px; - --text-xs: 11px; - --text-sm: 12px; - --text-base: 13px; - --text-md: 14px; - --text-lg: 15px; - --text-xl: 17px; - --text-2xl: 20px; - --text-3xl: 24px; - - --weight-normal: 400; - --weight-medium: 500; - --weight-semi: 600; - - --leading-none: 1; - --leading-tight: 1.3; - --leading-snug: 1.45; - --leading-base: 1.6; - - --tracking-tight: -0.02em; - --tracking-normal: 0; - --tracking-wide: 0.06em; - --tracking-wider: 0.1em; - - --z-reader: 50; - --z-modal: 100; - --z-settings: 150; + --ui-zoom: 1; + --ui-scale: 1; + --visual-vh: 100vh; } -[data-theme="dark"] { - --bg-void: #000000; - --bg-base: #080808; - --bg-surface: #0d0d0d; - --bg-raised: #111111; - --bg-overlay: #171717; - --bg-subtle: #1e1e1e; - - --border-dim: #252525; - --border-base: #303030; - --border-strong: #3e3e3e; - --border-focus: #5a7a5a; - - --text-primary: #ffffff; - --text-secondary: #e8e6e0; - --text-muted: #b0aea8; - --text-faint: #6e6c68; - --text-disabled: #303030; - - --accent: #7aaa7a; - --accent-dim: #2e4a2e; - --accent-muted: #1e2e1e; - --accent-fg: #bcd8bc; - --accent-bright: #9fcf9f; +html, +body, +#svelte { + width: 100%; } -[data-theme="light"] { - --bg-void: #d8d4ce; - --bg-base: #e2deda; - --bg-surface: #ece8e2; - --bg-raised: #f5f2ec; - --bg-overlay: #ffffff; - --bg-subtle: #e4e0d8; - - --border-dim: #c4c0b8; - --border-base: #b0aca4; - --border-strong: #989490; - --border-focus: #3a5a3a; - - --text-primary: #080806; - --text-secondary: #181612; - --text-muted: #38342e; - --text-faint: #706c64; - --text-disabled: #b0aca4; - - --accent: #2a5a2a; - --accent-dim: #b0ccb0; - --accent-muted: #c8dcc8; - --accent-fg: #183818; - --accent-bright: #1e4e1e; - - --color-error: #8a1a1a; - --color-error-bg: #f8e0e0; - --color-read: #e0dcd4; -} - -[data-theme="midnight"] { - --bg-void: #050810; - --bg-base: #080c18; - --bg-surface: #0c1020; - --bg-raised: #101428; - --bg-overlay: #151a30; - --bg-subtle: #1a2038; - - --border-dim: #1a2035; - --border-base: #222840; - --border-strong: #2c3450; - --border-focus: #4a5c8a; - - --text-primary: #eeeef8; - --text-secondary: #c0c4d8; - --text-muted: #808498; - --text-faint: #404860; - --text-disabled: #202840; - - --accent: #6a7ab8; - --accent-dim: #252d50; - --accent-muted: #181e38; - --accent-fg: #a8b4e8; - --accent-bright: #8896d0; -} - -[data-theme="original"] { - --bg-void: #080808; - --bg-base: #0c0c0c; - --bg-surface: #101010; - --bg-raised: #151515; - --bg-overlay: #1a1a1a; - --bg-subtle: #202020; - - --border-dim: #1c1c1c; - --border-base: #242424; - --border-strong: #2e2e2e; - --border-focus: #4a5c4a; - - --text-primary: #f0efec; - --text-secondary: #c8c6c0; - --text-muted: #8a8880; - --text-faint: #4e4d4a; - --text-disabled: #2a2a28; - - --accent: #6b8f6b; - --accent-dim: #2a3d2a; - --accent-muted: #1a251a; - --accent-fg: #a8c4a8; - --accent-bright: #8fb88f; - - --color-error: #c47a7a; - --color-error-bg: #1f1212; - --color-success: #7aab7a; - --color-info: #7a9ec4; - --color-info-bg: #121a1f; -} - -[data-theme="warm"] { - --bg-void: #0c0a06; - --bg-base: #100e08; - --bg-surface: #16130c; - --bg-raised: #1c1810; - --bg-overlay: #221e14; - --bg-subtle: #28241a; - - --border-dim: #201c10; - --border-base: #2c2818; - --border-strong: #3a3420; - --border-focus: #6a5a30; - - --text-primary: #f5f0e0; - --text-secondary: #d8d0b0; - --text-muted: #988c60; - --text-faint: #584e30; - --text-disabled: #302a18; - - --accent: #c0902a; - --accent-dim: #3a2c10; - --accent-muted: #261e0c; - --accent-fg: #e0b860; - --accent-bright: #d0a040; -} - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html, body { - height: 100%; - overflow: hidden; - background: var(--bg-void); - color: var(--text-primary); +body { + overscroll-behavior: none; } #svelte { - height: 100%; -} - -button { - cursor: pointer; - font: inherit; - color: inherit; - background: none; - border: none; - padding: 0; -} - -input, textarea, select { - font: inherit; - color: inherit; + isolation: isolate; } a { diff --git a/src/hooks.client.ts b/src/hooks.client.ts index f1b4f69..168dc85 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,53 +1,59 @@ -import { initRequestManager } from '$lib/request-manager' -import { initPlatformService } from '$lib/platform-service' -import { appState } from '$lib/state/app.svelte' -import { configureAuth, probeServer } from '$lib/core/auth' +import {initRequestManager} from '$lib/request-manager'; +import {initPlatformService} from '$lib/platform-service'; +import {appState} from '$lib/state/app.svelte'; +import {configureAuth, probeServer} from '$lib/core/auth'; +import {initHistoryState} from '$lib/state/history.svelte'; +import {initSettingsState, settingsState, updateSettings} from '$lib/state/settings.svelte'; -const SAVED_URL_KEY = 'moku_server_url' -const SAVED_AUTH_KEY = 'moku_auth_config' +const SAVED_URL_KEY = 'moku_server_url'; +const SAVED_AUTH_KEY = 'moku_auth_config'; interface SavedAuth { - mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' - user?: string - pass?: string + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'; + user?: string; + pass?: string; +} + +function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' { + return mode === 'BASIC_AUTH' ? 'BASIC_AUTH' : mode === 'UI_LOGIN' || mode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : 'NONE'; } function isTauri(): boolean { - return '__TAURI_INTERNALS__' in window + return '__TAURI_INTERNALS__' in window; } function isCapacitor(): boolean { - return 'Capacitor' in window + return 'Capacitor' in window; } function loadSavedServerUrl(): string { - return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567' + return localStorage.getItem(SAVED_URL_KEY) ?? 'http://127.0.0.1:4567'; } function loadSavedAuth(): SavedAuth { try { - return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' } + return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'}; } catch { - return { mode: 'NONE' } + return {mode: 'NONE'}; } } async function resolvePlatformAdapter() { if (isTauri()) { - const { TauriAdapter } = await import('$lib/platform-adapters/tauri') - return new TauriAdapter() + const {TauriAdapter} = await import('$lib/platform-adapters/tauri'); + return new TauriAdapter(); } if (isCapacitor()) { - const { CapacitorAdapter } = await import('$lib/platform-adapters/capacitor') - return new CapacitorAdapter() + const {CapacitorAdapter} = await import('$lib/platform-adapters/capacitor'); + return new CapacitorAdapter(); } - const { WebAdapter } = await import('$lib/platform-adapters/web') - return new WebAdapter() + const {WebAdapter} = await import('$lib/platform-adapters/web'); + return new WebAdapter(); } async function resolveServerAdapter() { - const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi') - return new SuwayomiAdapter() + const {SuwayomiAdapter} = await import('$lib/server-adapters/suwayomi'); + return new SuwayomiAdapter(); } async function boot() { @@ -55,46 +61,61 @@ async function boot() { const [serverAdapter, platformAdapter] = await Promise.all([ resolveServerAdapter(), resolvePlatformAdapter(), - ]) + ]); - initRequestManager(serverAdapter) - initPlatformService(platformAdapter) + await platformAdapter.init(); - appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web' - appState.version = await platformAdapter.getVersion() + initRequestManager(serverAdapter); + initPlatformService(platformAdapter); - const savedUrl = loadSavedServerUrl() - const savedAuth = loadSavedAuth() + await Promise.all([ + initSettingsState(), + initHistoryState(), + ]); - appState.serverUrl = savedUrl - appState.authMode = savedAuth.mode + appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'; + appState.version = await platformAdapter.getVersion(); - if (isTauri() && platformAdapter.isSupported('server-management')) { - await platformAdapter.launchServer({ url: savedUrl }).catch(() => {}) - } + const legacyAuth = loadSavedAuth(); + const savedUrl = settingsState.serverUrl || loadSavedServerUrl(); + const savedAuth: SavedAuth = { + mode: normalizeAuthMode(settingsState.serverAuthMode || legacyAuth.mode), + user: settingsState.serverAuthUser || legacyAuth.user, + pass: settingsState.serverAuthPass || legacyAuth.pass, + }; - configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) - await serverAdapter.connect({ baseUrl: savedUrl }) + updateSettings({ + serverUrl: savedUrl, + serverAuthMode: savedAuth.mode, + serverAuthUser: savedAuth.user ?? '', + serverAuthPass: savedAuth.pass ?? '', + }); - const probe = await probeServer() + appState.serverUrl = savedUrl; + appState.authMode = savedAuth.mode; + + configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass); + await serverAdapter.connect({baseUrl: savedUrl}); + + const probe = await probeServer(); if (probe === 'auth_required') { - appState.status = 'auth' - return + appState.status = 'auth'; + return; } if (probe === 'unreachable') { - appState.error = `Could not reach server at ${savedUrl}` - appState.status = 'error' - return + appState.error = `Could not reach server at ${savedUrl}`; + appState.status = 'error'; + return; } - appState.authenticated = true - appState.status = 'ready' + appState.authenticated = true; + appState.status = 'ready'; } catch (e) { - appState.error = String(e) - appState.status = 'error' + appState.error = String(e); + appState.status = 'error'; } } -boot() \ No newline at end of file +boot(); \ No newline at end of file diff --git a/src/lib/core/image.ts b/src/lib/core/image.ts new file mode 100644 index 0000000..6b5ca71 --- /dev/null +++ b/src/lib/core/image.ts @@ -0,0 +1,30 @@ +import {fetchAuthenticated, getAuthMode, getServerBase} from '$lib/core/auth'; + +function isAbsoluteUrl(value: string): boolean { + return /^https?:\/\//.test(value); +} + +export function resolveImageUrl(path: string | null | undefined): string | undefined { + if (!path) return undefined; + if (isAbsoluteUrl(path)) return path; + + const normalizedBase = getServerBase().replace(/\/$/, ''); + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${normalizedBase}${normalizedPath}`; +} + +export async function loadImageObjectUrl(path: string, signal?: AbortSignal): Promise { + const resolved = resolveImageUrl(path); + if (!resolved) throw new Error('Image URL is missing'); + + if (getAuthMode() === 'NONE') { + return resolved; + } + + const response = await fetchAuthenticated(resolved, {}, signal); + if (!response.ok) { + throw new Error(`Failed to load image: ${response.status}`); + } + + return URL.createObjectURL(await response.blob()); +} \ No newline at end of file diff --git a/src/lib/core/keybinds/keybindEngine.ts b/src/lib/core/keybinds/keybindEngine.ts index 5c1487e..ead8c24 100644 --- a/src/lib/core/keybinds/keybindEngine.ts +++ b/src/lib/core/keybinds/keybindEngine.ts @@ -1,10 +1,10 @@ export function eventToKeybind(e: KeyboardEvent): string { if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return ""; const parts: string[] = []; - if (e.ctrlKey) parts.push("ctrl"); - if (e.altKey) parts.push("alt"); + if (e.ctrlKey) parts.push("ctrl"); + if (e.altKey) parts.push("alt"); if (e.shiftKey) parts.push("shift"); - if (e.metaKey) parts.push("meta"); + if (e.metaKey) parts.push("meta"); parts.push(e.key); return parts.join("+"); } @@ -12,3 +12,15 @@ export function eventToKeybind(e: KeyboardEvent): string { export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { return eventToKeybind(e) === bind; } + +export async function toggleFullscreen(): Promise { + if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return; + + try { + const {getCurrentWindow} = await import('@tauri-apps/api/window'); + const currentWindow = getCurrentWindow(); + await currentWindow.setFullscreen(!await currentWindow.isFullscreen()); + } catch (error) { + console.warn('toggleFullscreen unavailable:', error); + } +} diff --git a/src/lib/core/persistence/persist.ts b/src/lib/core/persistence/persist.ts new file mode 100644 index 0000000..df919d7 --- /dev/null +++ b/src/lib/core/persistence/persist.ts @@ -0,0 +1,77 @@ +import {isSupported, readFile, writeFile} from '$lib/platform-service'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const STORAGE_PREFIX = 'moku:'; + +function localStorageKey(key: string): string { + return `${STORAGE_PREFIX}${key}`; +} + +function fileName(key: string): string { + return `moku.${key}.json`; +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined'; +} + +function canUseFilesystem(): boolean { + try { + return isSupported('filesystem'); + } catch { + return false; + } +} + +export async function loadPersistentState(key: string): Promise { + if (canUseFilesystem()) { + try { + const data = await readFile(fileName(key)); + if (data.length > 0) { + return JSON.parse(decoder.decode(data)) as T; + } + } catch { + // Fall back to localStorage when the file does not exist or the adapter cannot read it yet. + } + } + + if (!canUseLocalStorage()) return null; + + try { + const raw = localStorage.getItem(localStorageKey(key)); + return raw ? JSON.parse(raw) as T : null; + } catch { + return null; + } +} + +export async function savePersistentState(key: string, value: T): Promise { + const json = JSON.stringify(value); + + if (canUseLocalStorage()) { + localStorage.setItem(localStorageKey(key), json); + } + + if (!canUseFilesystem()) return; + + try { + await writeFile(fileName(key), encoder.encode(json)); + } catch { + // LocalStorage remains the fallback when a platform adapter cannot persist to files. + } +} + +export async function clearPersistentState(key: string): Promise { + if (canUseLocalStorage()) { + localStorage.removeItem(localStorageKey(key)); + } + + if (!canUseFilesystem()) return; + + try { + await writeFile(fileName(key), encoder.encode('null')); + } catch { + // Ignore native persistence failures during cleanup. + } +} \ No newline at end of file diff --git a/src/lib/core/theme.ts b/src/lib/core/theme.ts new file mode 100644 index 0000000..746d462 --- /dev/null +++ b/src/lib/core/theme.ts @@ -0,0 +1,41 @@ +import type {CustomTheme, Theme} from '$lib/types/settings'; + +let themeStyleEl: HTMLStyleElement | null = null; + +function ensureThemeStyleEl(): HTMLStyleElement { + if (themeStyleEl) return themeStyleEl; + + themeStyleEl = document.createElement('style'); + themeStyleEl.id = 'moku-custom-theme'; + document.head.appendChild(themeStyleEl); + return themeStyleEl; +} + +function removeCustomThemeCss() { + themeStyleEl?.remove(); + themeStyleEl = null; +} + +function resolveBuiltinTheme(theme: Theme): string { + if (theme === 'light-contrast') return 'light'; + return theme || 'dark'; +} + +export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) { + const activeTheme = theme || 'dark'; + const customThemeId = activeTheme.startsWith('custom:') ? activeTheme.slice(7) : activeTheme; + const customTheme = customThemes.find(entry => entry.id === customThemeId); + + if (!customTheme) { + removeCustomThemeCss(); + document.documentElement.setAttribute('data-theme', resolveBuiltinTheme(activeTheme)); + return; + } + + const css = Object.entries(customTheme.tokens) + .map(([token, value]) => ` --${token}: ${value};`) + .join('\n'); + + ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`; + document.documentElement.setAttribute('data-theme', 'custom'); +} \ No newline at end of file diff --git a/src/lib/design/base/animations.css b/src/lib/design/base/animations.css new file mode 100644 index 0000000..64a23d8 --- /dev/null +++ b/src/lib/design/base/animations.css @@ -0,0 +1,48 @@ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes fadeDown { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.97); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +@keyframes shimmer { + from { background-position: -200% 0; } + to { background-position: 200% 0; } +} + +.anim-fade-in { animation: fadeIn 0.14s ease both; } +.anim-fade-up { animation: fadeUp 0.18s ease both; } +.anim-fade-down { animation: fadeDown 0.18s ease both; } +.anim-scale-in { animation: scaleIn 0.14s ease both; } +.anim-pulse { animation: pulse 1.6s ease infinite; } +.anim-spin { animation: spin 0.7s linear infinite; } + +.skeleton { + background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay) 50%, var(--bg-raised) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s ease infinite; + border-radius: var(--radius-sm); +} \ No newline at end of file diff --git a/src/lib/design/base/index.css b/src/lib/design/base/index.css new file mode 100644 index 0000000..3352c6a --- /dev/null +++ b/src/lib/design/base/index.css @@ -0,0 +1,4 @@ +@import './reset.css'; +@import './animations.css'; +@import './scrollbars.css'; +@import './typography.css'; \ No newline at end of file diff --git a/src/lib/design/base/reset.css b/src/lib/design/base/reset.css new file mode 100644 index 0000000..80d9538 --- /dev/null +++ b/src/lib/design/base/reset.css @@ -0,0 +1,48 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-void); + color: var(--text-primary); +} + +#svelte { + height: 100%; +} + +button { + cursor: pointer; + font: inherit; + color: inherit; + background: none; + border: none; + padding: 0; +} + +input, textarea, select { + font: inherit; + color: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, ol { + list-style: none; +} + +img, svg { + display: block; + max-width: 100%; +} + +p { + margin: 0; +} \ No newline at end of file diff --git a/src/lib/design/base/scrollbars.css b/src/lib/design/base/scrollbars.css new file mode 100644 index 0000000..45bb3d4 --- /dev/null +++ b/src/lib/design/base/scrollbars.css @@ -0,0 +1,22 @@ +* { + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} + +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 99px; +} + +*::-webkit-scrollbar-thumb:hover { + background: transparent; +} \ No newline at end of file diff --git a/src/lib/design/base/typography.css b/src/lib/design/base/typography.css new file mode 100644 index 0000000..0a52eb1 --- /dev/null +++ b/src/lib/design/base/typography.css @@ -0,0 +1,9 @@ +body { + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: var(--weight-normal); + line-height: var(--leading-base); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} \ No newline at end of file diff --git a/src/lib/design/index.css b/src/lib/design/index.css new file mode 100644 index 0000000..7a3c9db --- /dev/null +++ b/src/lib/design/index.css @@ -0,0 +1,3 @@ +@import './base/index.css'; +@import './tokens/index.css'; +@import './themes/index.css'; \ No newline at end of file diff --git a/src/lib/design/themes/dark.css b/src/lib/design/themes/dark.css new file mode 100644 index 0000000..f9d1aa7 --- /dev/null +++ b/src/lib/design/themes/dark.css @@ -0,0 +1,25 @@ +[data-theme='dark'] { + --bg-void: #000000; + --bg-base: #080808; + --bg-surface: #0d0d0d; + --bg-raised: #111111; + --bg-overlay: #171717; + --bg-subtle: #1e1e1e; + + --border-dim: #252525; + --border-base: #303030; + --border-strong: #3e3e3e; + --border-focus: #5a7a5a; + + --text-primary: #ffffff; + --text-secondary: #e8e6e0; + --text-muted: #b0aea8; + --text-faint: #6e6c68; + --text-disabled: #303030; + + --accent: #7aaa7a; + --accent-dim: #2e4a2e; + --accent-muted: #1e2e1e; + --accent-fg: #bcd8bc; + --accent-bright: #9fcf9f; +} \ No newline at end of file diff --git a/src/lib/design/themes/index.css b/src/lib/design/themes/index.css new file mode 100644 index 0000000..923d6ea --- /dev/null +++ b/src/lib/design/themes/index.css @@ -0,0 +1,6 @@ +@import './original.css'; +@import './dark.css'; +@import './light.css'; +@import './light-contrast.css'; +@import './midnight.css'; +@import './warm.css'; \ No newline at end of file diff --git a/src/lib/design/themes/light-contrast.css b/src/lib/design/themes/light-contrast.css new file mode 100644 index 0000000..fba452b --- /dev/null +++ b/src/lib/design/themes/light-contrast.css @@ -0,0 +1,29 @@ +[data-theme='light-contrast'] { + --bg-void: #f3efe7; + --bg-base: #fbf7f1; + --bg-surface: #ffffff; + --bg-raised: #fffdfa; + --bg-overlay: #ffffff; + --bg-subtle: #efe8dc; + + --border-dim: #c0b8ab; + --border-base: #8f8677; + --border-strong: #60594f; + --border-focus: #234c23; + + --text-primary: #050402; + --text-secondary: #14110b; + --text-muted: #3e3529; + --text-faint: #655c50; + --text-disabled: #a39b8f; + + --accent: #244f24; + --accent-dim: #b7d2b7; + --accent-muted: #d5e6d5; + --accent-fg: #173717; + --accent-bright: #173f17; + + --color-error: #7f1010; + --color-error-bg: #fdeaea; + --color-read: #ece5da; +} \ No newline at end of file diff --git a/src/lib/design/themes/light.css b/src/lib/design/themes/light.css new file mode 100644 index 0000000..9b52338 --- /dev/null +++ b/src/lib/design/themes/light.css @@ -0,0 +1,29 @@ +[data-theme='light'] { + --bg-void: #d8d4ce; + --bg-base: #e2deda; + --bg-surface: #ece8e2; + --bg-raised: #f5f2ec; + --bg-overlay: #ffffff; + --bg-subtle: #e4e0d8; + + --border-dim: #c4c0b8; + --border-base: #b0aca4; + --border-strong: #989490; + --border-focus: #3a5a3a; + + --text-primary: #080806; + --text-secondary: #181612; + --text-muted: #38342e; + --text-faint: #706c64; + --text-disabled: #b0aca4; + + --accent: #2a5a2a; + --accent-dim: #b0ccb0; + --accent-muted: #c8dcc8; + --accent-fg: #183818; + --accent-bright: #1e4e1e; + + --color-error: #8a1a1a; + --color-error-bg: #f8e0e0; + --color-read: #e0dcd4; +} \ No newline at end of file diff --git a/src/lib/design/themes/midnight.css b/src/lib/design/themes/midnight.css new file mode 100644 index 0000000..f3d44d6 --- /dev/null +++ b/src/lib/design/themes/midnight.css @@ -0,0 +1,25 @@ +[data-theme='midnight'] { + --bg-void: #050810; + --bg-base: #080c18; + --bg-surface: #0c1020; + --bg-raised: #101428; + --bg-overlay: #151a30; + --bg-subtle: #1a2038; + + --border-dim: #1a2035; + --border-base: #222840; + --border-strong: #2c3450; + --border-focus: #4a5c8a; + + --text-primary: #eeeef8; + --text-secondary: #c0c4d8; + --text-muted: #808498; + --text-faint: #404860; + --text-disabled: #202840; + + --accent: #6a7ab8; + --accent-dim: #252d50; + --accent-muted: #181e38; + --accent-fg: #a8b4e8; + --accent-bright: #8896d0; +} \ No newline at end of file diff --git a/src/lib/design/themes/original.css b/src/lib/design/themes/original.css new file mode 100644 index 0000000..aa92b02 --- /dev/null +++ b/src/lib/design/themes/original.css @@ -0,0 +1,31 @@ +[data-theme='original'] { + --bg-void: #080808; + --bg-base: #0c0c0c; + --bg-surface: #101010; + --bg-raised: #151515; + --bg-overlay: #1a1a1a; + --bg-subtle: #202020; + + --border-dim: #1c1c1c; + --border-base: #242424; + --border-strong: #2e2e2e; + --border-focus: #4a5c4a; + + --text-primary: #f0efec; + --text-secondary: #c8c6c0; + --text-muted: #8a8880; + --text-faint: #4e4d4a; + --text-disabled: #2a2a28; + + --accent: #6b8f6b; + --accent-dim: #2a3d2a; + --accent-muted: #1a251a; + --accent-fg: #a8c4a8; + --accent-bright: #8fb88f; + + --color-error: #c47a7a; + --color-error-bg: #1f1212; + --color-success: #7aab7a; + --color-info: #7a9ec4; + --color-info-bg: #121a1f; +} \ No newline at end of file diff --git a/src/lib/design/themes/warm.css b/src/lib/design/themes/warm.css new file mode 100644 index 0000000..263be41 --- /dev/null +++ b/src/lib/design/themes/warm.css @@ -0,0 +1,25 @@ +[data-theme='warm'] { + --bg-void: #0c0a06; + --bg-base: #100e08; + --bg-surface: #16130c; + --bg-raised: #1c1810; + --bg-overlay: #221e14; + --bg-subtle: #28241a; + + --border-dim: #201c10; + --border-base: #2c2818; + --border-strong: #3a3420; + --border-focus: #6a5a30; + + --text-primary: #f5f0e0; + --text-secondary: #d8d0b0; + --text-muted: #988c60; + --text-faint: #584e30; + --text-disabled: #302a18; + + --accent: #c0902a; + --accent-dim: #3a2c10; + --accent-muted: #261e0c; + --accent-fg: #e0b860; + --accent-bright: #d0a040; +} \ No newline at end of file diff --git a/src/lib/design/tokens/colors.css b/src/lib/design/tokens/colors.css new file mode 100644 index 0000000..8334711 --- /dev/null +++ b/src/lib/design/tokens/colors.css @@ -0,0 +1,35 @@ +:root { + --bg-void: #080808; + --bg-base: #0c0c0c; + --bg-surface: #101010; + --bg-raised: #151515; + --bg-overlay: #1a1a1a; + --bg-subtle: #202020; + + --border-dim: #1c1c1c; + --border-base: #242424; + --border-strong: #2e2e2e; + --border-focus: #4a5c4a; + + --text-primary: #f0efec; + --text-secondary: #c8c6c0; + --text-muted: #8a8880; + --text-faint: #4e4d4a; + --text-disabled: #2a2a28; + + --accent: #6b8f6b; + --accent-dim: #2a3d2a; + --accent-muted: #1a251a; + --accent-fg: #a8c4a8; + --accent-bright: #8fb88f; + + --color-error: #c47a7a; + --color-error-bg: #1f1212; + --color-success: #7aab7a; + --color-info: #7a9ec4; + --color-info-bg: #121a1f; + --color-read: #2e2e2c; + + --dot-active: var(--accent); + --dot-inactive: var(--text-faint); +} \ No newline at end of file diff --git a/src/lib/design/tokens/index.css b/src/lib/design/tokens/index.css new file mode 100644 index 0000000..71e2ff5 --- /dev/null +++ b/src/lib/design/tokens/index.css @@ -0,0 +1,7 @@ +@import './colors.css'; +@import './typography.css'; +@import './spacing.css'; +@import './radius.css'; +@import './motion.css'; +@import './shadows.css'; +@import './zindex.css'; \ No newline at end of file diff --git a/src/lib/design/tokens/motion.css b/src/lib/design/tokens/motion.css new file mode 100644 index 0000000..f621969 --- /dev/null +++ b/src/lib/design/tokens/motion.css @@ -0,0 +1,5 @@ +:root { + --t-fast: 0.08s ease; + --t-base: 0.14s ease; + --t-slow: 0.22s ease; +} \ No newline at end of file diff --git a/src/lib/design/tokens/radius.css b/src/lib/design/tokens/radius.css new file mode 100644 index 0000000..5ea0b98 --- /dev/null +++ b/src/lib/design/tokens/radius.css @@ -0,0 +1,8 @@ +:root { + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 7px; + --radius-xl: 10px; + --radius-2xl: 14px; + --radius-full: 9999px; +} \ No newline at end of file diff --git a/src/lib/design/tokens/shadows.css b/src/lib/design/tokens/shadows.css new file mode 100644 index 0000000..fbcb648 --- /dev/null +++ b/src/lib/design/tokens/shadows.css @@ -0,0 +1,2 @@ +:root { +} \ No newline at end of file diff --git a/src/lib/design/tokens/spacing.css b/src/lib/design/tokens/spacing.css new file mode 100644 index 0000000..a7113e0 --- /dev/null +++ b/src/lib/design/tokens/spacing.css @@ -0,0 +1,13 @@ +:root { + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + + --sidebar-width: 52px; + --titlebar-height: 36px; +} \ No newline at end of file diff --git a/src/lib/design/tokens/typography.css b/src/lib/design/tokens/typography.css new file mode 100644 index 0000000..166d105 --- /dev/null +++ b/src/lib/design/tokens/typography.css @@ -0,0 +1,28 @@ +:root { + --font-ui: 'DM Mono', 'Fira Mono', ui-monospace, monospace; + --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif; + + --text-2xs: 10px; + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --text-lg: 15px; + --text-xl: 17px; + --text-2xl: 20px; + --text-3xl: 24px; + + --weight-normal: 400; + --weight-medium: 500; + --weight-semi: 600; + + --leading-none: 1; + --leading-tight: 1.3; + --leading-snug: 1.45; + --leading-base: 1.6; + + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.06em; + --tracking-wider: 0.1em; +} \ No newline at end of file diff --git a/src/lib/design/tokens/zindex.css b/src/lib/design/tokens/zindex.css new file mode 100644 index 0000000..cfb7720 --- /dev/null +++ b/src/lib/design/tokens/zindex.css @@ -0,0 +1,5 @@ +:root { + --z-reader: 50; + --z-modal: 100; + --z-settings: 150; +} \ No newline at end of file diff --git a/src/lib/state/history.svelte.ts b/src/lib/state/history.svelte.ts new file mode 100644 index 0000000..df76563 --- /dev/null +++ b/src/lib/state/history.svelte.ts @@ -0,0 +1,168 @@ +import {untrack} from 'svelte'; +import { + DEFAULT_READING_STATS, + type BookmarkEntry, + type HistoryEntry, + type MarkerEntry, + type ReadLogEntry, + type ReadingStats, +} from '$lib/types/history'; +import {loadPersistentState, savePersistentState} from '$lib/core/persistence/persist'; + +const HISTORY_STORAGE_KEY = 'history'; +const AVG_MIN_PER_CHAPTER = 5; + +interface PersistedHistory { + history: HistoryEntry[]; + bookmarks: BookmarkEntry[]; + markers: MarkerEntry[]; + readLog: ReadLogEntry[]; + readingStats: ReadingStats; + dailyReadCounts: Record; +} + +function localDateString(value: Date): string { + return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; +} + +function emptyHistoryState(): PersistedHistory { + return { + history: [], + bookmarks: [], + markers: [], + readLog: [], + readingStats: {...DEFAULT_READING_STATS}, + dailyReadCounts: {}, + }; +} + +export const historyState = $state(emptyHistoryState()); + +export const historyStatus = $state({ + ready: false, + loading: false, + error: null as string | null, +}); + +let initialized = false; +let persistQueued = false; + +function queueHistoryPersist() { + if (!historyStatus.ready || historyStatus.loading || persistQueued) return; + + persistQueued = true; + + queueMicrotask(() => { + persistQueued = false; + + if (!historyStatus.ready || historyStatus.loading) return; + + const snapshot = JSON.stringify(historyState); + void savePersistentState(HISTORY_STORAGE_KEY, JSON.parse(snapshot) as PersistedHistory); + }); +} + +export async function initHistoryState() { + if (initialized || historyStatus.loading) return; + + historyStatus.loading = true; + + try { + const persisted = await loadPersistentState(HISTORY_STORAGE_KEY); + + untrack(() => { + Object.assign(historyState, { + ...emptyHistoryState(), + ...persisted, + readingStats: persisted?.readingStats ?? {...DEFAULT_READING_STATS}, + dailyReadCounts: persisted?.dailyReadCounts ?? {}, + }); + }); + + initialized = true; + historyStatus.ready = true; + historyStatus.error = null; + } catch (error) { + historyStatus.ready = true; + historyStatus.error = String(error); + } finally { + historyStatus.loading = false; + } +} + +export function addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) { + historyState.history = [entry, ...historyState.history.filter(item => item.chapterId !== entry.chapterId)].slice(0, 500); + + if (!completed || historyState.readLog.some(item => item.chapterId === entry.chapterId)) { + queueHistoryPersist(); + return; + } + + historyState.readLog = [ + ...historyState.readLog, + {mangaId: entry.mangaId, chapterId: entry.chapterId, readAt: entry.readAt, minutes}, + ]; + + const totalMinutes = historyState.readLog.reduce((sum, item) => sum + item.minutes, 0); + const uniqueChapters = new Set(historyState.readLog.map(item => item.chapterId)); + const uniqueManga = new Set(historyState.readLog.map(item => item.mangaId)); + const today = localDateString(new Date()); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayKey = localDateString(yesterday); + const previousStreakDate = historyState.readingStats.lastStreakDate; + const streak = previousStreakDate === today + ? historyState.readingStats.currentStreakDays + : previousStreakDate === yesterdayKey + ? historyState.readingStats.currentStreakDays + 1 + : 1; + + historyState.readingStats = { + totalChaptersRead: uniqueChapters.size, + totalMangaRead: uniqueManga.size, + totalMinutesRead: totalMinutes, + firstReadAt: historyState.readingStats.firstReadAt || entry.readAt, + lastReadAt: entry.readAt, + currentStreakDays: streak, + longestStreakDays: Math.max(historyState.readingStats.longestStreakDays, streak), + lastStreakDate: today, + }; + + historyState.dailyReadCounts = { + ...historyState.dailyReadCounts, + [today]: (historyState.dailyReadCounts[today] ?? 0) + 1, + }; + + queueHistoryPersist(); +} + +export function addBookmark(entry: Omit, label?: string) { + historyState.bookmarks = [ + {...entry, savedAt: Date.now(), label}, + ...historyState.bookmarks.filter(item => item.chapterId !== entry.chapterId), + ].slice(0, 200); + + queueHistoryPersist(); +} + +export function removeBookmark(chapterId: number) { + historyState.bookmarks = historyState.bookmarks.filter(item => item.chapterId !== chapterId); + queueHistoryPersist(); +} + +export function getBookmark(chapterId: number): BookmarkEntry | undefined { + return historyState.bookmarks.find(item => item.chapterId === chapterId); +} + +export function clearBookmarks() { + historyState.bookmarks = []; + queueHistoryPersist(); +} + +export function clearHistory() { + historyState.history = []; + historyState.readLog = []; + historyState.dailyReadCounts = {}; + historyState.readingStats = {...DEFAULT_READING_STATS}; + queueHistoryPersist(); +} \ No newline at end of file diff --git a/src/lib/state/manga-prefs.svelte.ts b/src/lib/state/manga-prefs.svelte.ts new file mode 100644 index 0000000..4417df6 --- /dev/null +++ b/src/lib/state/manga-prefs.svelte.ts @@ -0,0 +1,40 @@ +import {DEFAULT_MANGA_PREFS, type MangaPrefs} from '$lib/types/settings'; +import {settingsState} from '$lib/state/settings.svelte'; + +export function getMangaPref(mangaId: number, key: K): MangaPrefs[K] { + const prefs = settingsState.mangaPrefs[mangaId] ?? {}; + return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]; +} + +export function getMangaPrefs(mangaId: number): MangaPrefs { + return { + ...DEFAULT_MANGA_PREFS, + ...(settingsState.mangaPrefs[mangaId] ?? {}), + }; +} + +export function setMangaPref(mangaId: number, key: K, value: MangaPrefs[K]) { + settingsState.mangaPrefs = { + ...settingsState.mangaPrefs, + [mangaId]: { + ...(settingsState.mangaPrefs[mangaId] ?? {}), + [key]: value, + }, + }; +} + +export function replaceMangaPrefs(mangaId: number, prefs: Partial) { + settingsState.mangaPrefs = { + ...settingsState.mangaPrefs, + [mangaId]: { + ...(settingsState.mangaPrefs[mangaId] ?? {}), + ...prefs, + }, + }; +} + +export function clearMangaPrefs(mangaId: number) { + const next = {...settingsState.mangaPrefs}; + delete next[mangaId]; + settingsState.mangaPrefs = next; +} \ No newline at end of file diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts new file mode 100644 index 0000000..e46dd86 --- /dev/null +++ b/src/lib/state/settings.svelte.ts @@ -0,0 +1,140 @@ +import {untrack} from 'svelte'; +import {DEFAULT_KEYBINDS} from '$lib/core/keybinds/defaultBinds'; +import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist'; +import {applyTheme} from '$lib/core/theme'; +import {applyZoom} from '$lib/core/ui/zoom'; +import {DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, type MangaPrefs, type Settings} from '$lib/types/settings'; + +const SETTINGS_STORAGE_KEY = 'settings'; +const SETTINGS_STORE_VERSION = 1; + +interface PersistedSettings { + settings: Partial | null; + storeVersion: number | null; +} + +function mergeSettings(saved: Partial | null | undefined): Settings { + return { + ...DEFAULT_SETTINGS, + ...saved, + keybinds: {...DEFAULT_KEYBINDS, ...(saved?.keybinds ?? {})}, + heroSlots: saved?.heroSlots ?? [null, null, null, null], + mangaLinks: saved?.mangaLinks ?? {}, + mangaPrefs: saved?.mangaPrefs ?? {}, + customThemes: saved?.customThemes ?? [], + hiddenCategoryIds: saved?.hiddenCategoryIds ?? [], + nsfwAllowedSourceIds: saved?.nsfwAllowedSourceIds ?? [], + nsfwBlockedSourceIds: saved?.nsfwBlockedSourceIds ?? [], + libraryTabSort: saved?.libraryTabSort ?? {}, + libraryTabStatus: saved?.libraryTabStatus ?? {}, + libraryTabFilters: saved?.libraryTabFilters ?? {}, + extraScanDirs: saved?.extraScanDirs ?? [], + pinnedSourceIds: saved?.pinnedSourceIds ?? [], + readerPresets: saved?.readerPresets ?? [], + mangaReaderSettings: saved?.mangaReaderSettings ?? {}, + hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [], + libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [], + }; +} + +export const settingsState = $state(mergeSettings(null)); + +export const settingsStatus = $state({ + ready: false, + loading: false, + error: null as string | null, +}); + +let initialized = false; +let persistQueued = false; + +function persistSettings() { + const snapshot = JSON.stringify(settingsState); + + void savePersistentState(SETTINGS_STORAGE_KEY, { + settings: JSON.parse(snapshot) as Settings, + storeVersion: SETTINGS_STORE_VERSION, + } satisfies PersistedSettings); +} + +function applySettingsVisuals() { + if (!settingsStatus.ready || typeof document === 'undefined') return; + + applyTheme(settingsState.theme, settingsState.customThemes); + applyZoom(settingsState.uiZoom); +} + +function queueSettingsSync() { + applySettingsVisuals(); + + if (!settingsStatus.ready || settingsStatus.loading || persistQueued) return; + + persistQueued = true; + + queueMicrotask(() => { + persistQueued = false; + + if (!settingsStatus.ready || settingsStatus.loading) return; + + persistSettings(); + }); +} + +export async function initSettingsState() { + if (initialized || settingsStatus.loading) return; + + settingsStatus.loading = true; + + try { + const persisted = await loadPersistentState(SETTINGS_STORAGE_KEY); + + untrack(() => { + Object.assign(settingsState, mergeSettings(persisted?.settings)); + }); + + initialized = true; + settingsStatus.ready = true; + settingsStatus.error = null; + applySettingsVisuals(); + } catch (error) { + settingsStatus.ready = true; + settingsStatus.error = String(error); + } finally { + settingsStatus.loading = false; + } +} + +export function updateSettings(patch: Partial) { + Object.assign(settingsState, patch); + queueSettingsSync(); +} + +export function resetSettings() { + Object.assign(settingsState, mergeSettings(null)); + queueSettingsSync(); +} + +export function getMangaPrefs(mangaId: number): MangaPrefs { + return { + ...DEFAULT_MANGA_PREFS, + ...(settingsState.mangaPrefs[mangaId] ?? {}), + }; +} + +export function updateMangaPrefs(mangaId: number, patch: Partial) { + settingsState.mangaPrefs = { + ...settingsState.mangaPrefs, + [mangaId]: { + ...(settingsState.mangaPrefs[mangaId] ?? {}), + ...patch, + }, + }; + queueSettingsSync(); +} + +export function clearMangaPrefs(mangaId: number) { + const next = {...settingsState.mangaPrefs}; + delete next[mangaId]; + settingsState.mangaPrefs = next; + queueSettingsSync(); +} \ No newline at end of file diff --git a/src/lib/ui/chrome/ContextMenu.svelte b/src/lib/ui/chrome/ContextMenu.svelte new file mode 100644 index 0000000..ec19664 --- /dev/null +++ b/src/lib/ui/chrome/ContextMenu.svelte @@ -0,0 +1,333 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ui/manga/MangaCard.svelte b/src/lib/ui/manga/MangaCard.svelte new file mode 100644 index 0000000..06a8355 --- /dev/null +++ b/src/lib/ui/manga/MangaCard.svelte @@ -0,0 +1,137 @@ + + +{#if href} + + +
+

{manga.title}

+ {#if showMeta} +
+ {unreadCount} + {downloadCount} + {bookmarkCount} +
+ {/if} + {#if manga.source?.displayName} +

{manga.source.displayName}

+ {/if} +
+
+{:else} +
+ +
+

{manga.title}

+ {#if showMeta} +
+ {unreadCount} + {downloadCount} + {bookmarkCount} +
+ {/if} + {#if manga.source?.displayName} +

{manga.source.displayName}

+ {/if} +
+
+{/if} + + \ No newline at end of file diff --git a/src/lib/ui/manga/ThreeDCard.svelte b/src/lib/ui/manga/ThreeDCard.svelte new file mode 100644 index 0000000..0bbd3c7 --- /dev/null +++ b/src/lib/ui/manga/ThreeDCard.svelte @@ -0,0 +1,102 @@ + + +
+
+ {@render children?.()} +
+
+
+
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/src/lib/ui/manga/Thumbnail.svelte b/src/lib/ui/manga/Thumbnail.svelte new file mode 100644 index 0000000..6939bc3 --- /dev/null +++ b/src/lib/ui/manga/Thumbnail.svelte @@ -0,0 +1,98 @@ + + +{#if resolvedSrc && !failed} + { failed = true }} /> +{:else} + +{/if} + + \ No newline at end of file diff --git a/src/lib/ui/primitives/Button.svelte b/src/lib/ui/primitives/Button.svelte new file mode 100644 index 0000000..d5d0ab6 --- /dev/null +++ b/src/lib/ui/primitives/Button.svelte @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ui/primitives/Dropdown.svelte b/src/lib/ui/primitives/Dropdown.svelte new file mode 100644 index 0000000..918ea02 --- /dev/null +++ b/src/lib/ui/primitives/Dropdown.svelte @@ -0,0 +1,78 @@ + + + { + if (!open || !(event.target instanceof Node) || root?.contains(event.target)) return + close() + }} + onkeydown={(event) => event.key === 'Escape' && close()} +/> + + + + \ No newline at end of file diff --git a/src/lib/ui/primitives/Input.svelte b/src/lib/ui/primitives/Input.svelte new file mode 100644 index 0000000..f738348 --- /dev/null +++ b/src/lib/ui/primitives/Input.svelte @@ -0,0 +1,73 @@ + + + + + \ No newline at end of file diff --git a/src/lib/ui/primitives/Modal.svelte b/src/lib/ui/primitives/Modal.svelte new file mode 100644 index 0000000..c7bfc77 --- /dev/null +++ b/src/lib/ui/primitives/Modal.svelte @@ -0,0 +1,130 @@ + + +{#if open} + +{/if} + + \ No newline at end of file diff --git a/src/lib/ui/primitives/Select.svelte b/src/lib/ui/primitives/Select.svelte new file mode 100644 index 0000000..a0ea640 --- /dev/null +++ b/src/lib/ui/primitives/Select.svelte @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file