mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Implement phase 1
This commit is contained in:
@@ -6,6 +6,7 @@ dist-tauri/
|
|||||||
target/
|
target/
|
||||||
bin/
|
bin/
|
||||||
out/
|
out/
|
||||||
|
notes/
|
||||||
|
|
||||||
.direnv/
|
.direnv/
|
||||||
result
|
result
|
||||||
|
|||||||
+12
-251
@@ -1,262 +1,23 @@
|
|||||||
|
@import './lib/design/index.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-void: #080808;
|
--ui-zoom: 1;
|
||||||
--bg-base: #0c0c0c;
|
--ui-scale: 1;
|
||||||
--bg-surface: #101010;
|
--visual-vh: 100vh;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
html,
|
||||||
--bg-void: #000000;
|
body,
|
||||||
--bg-base: #080808;
|
#svelte {
|
||||||
--bg-surface: #0d0d0d;
|
width: 100%;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="light"] {
|
body {
|
||||||
--bg-void: #d8d4ce;
|
overscroll-behavior: none;
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#svelte {
|
#svelte {
|
||||||
height: 100%;
|
isolation: isolate;
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, select {
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
+68
-47
@@ -1,53 +1,59 @@
|
|||||||
import { initRequestManager } from '$lib/request-manager'
|
import {initRequestManager} from '$lib/request-manager';
|
||||||
import { initPlatformService } from '$lib/platform-service'
|
import {initPlatformService} from '$lib/platform-service';
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import {appState} from '$lib/state/app.svelte';
|
||||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
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_URL_KEY = 'moku_server_url';
|
||||||
const SAVED_AUTH_KEY = 'moku_auth_config'
|
const SAVED_AUTH_KEY = 'moku_auth_config';
|
||||||
|
|
||||||
interface SavedAuth {
|
interface SavedAuth {
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN';
|
||||||
user?: string
|
user?: string;
|
||||||
pass?: 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 {
|
function isTauri(): boolean {
|
||||||
return '__TAURI_INTERNALS__' in window
|
return '__TAURI_INTERNALS__' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCapacitor(): boolean {
|
function isCapacitor(): boolean {
|
||||||
return 'Capacitor' in window
|
return 'Capacitor' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSavedServerUrl(): string {
|
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 {
|
function loadSavedAuth(): SavedAuth {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? { mode: 'NONE' }
|
return JSON.parse(localStorage.getItem(SAVED_AUTH_KEY) ?? 'null') ?? {mode: 'NONE'};
|
||||||
} catch {
|
} catch {
|
||||||
return { mode: 'NONE' }
|
return {mode: 'NONE'};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePlatformAdapter() {
|
async function resolvePlatformAdapter() {
|
||||||
if (isTauri()) {
|
if (isTauri()) {
|
||||||
const { TauriAdapter } = await import('$lib/platform-adapters/tauri')
|
const {TauriAdapter} = await import('$lib/platform-adapters/tauri');
|
||||||
return new TauriAdapter()
|
return new TauriAdapter();
|
||||||
}
|
}
|
||||||
if (isCapacitor()) {
|
if (isCapacitor()) {
|
||||||
const { CapacitorAdapter } = await import('$lib/platform-adapters/capacitor')
|
const {CapacitorAdapter} = await import('$lib/platform-adapters/capacitor');
|
||||||
return new CapacitorAdapter()
|
return new CapacitorAdapter();
|
||||||
}
|
}
|
||||||
const { WebAdapter } = await import('$lib/platform-adapters/web')
|
const {WebAdapter} = await import('$lib/platform-adapters/web');
|
||||||
return new WebAdapter()
|
return new WebAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveServerAdapter() {
|
async function resolveServerAdapter() {
|
||||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
const {SuwayomiAdapter} = await import('$lib/server-adapters/suwayomi');
|
||||||
return new SuwayomiAdapter()
|
return new SuwayomiAdapter();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
@@ -55,46 +61,61 @@ async function boot() {
|
|||||||
const [serverAdapter, platformAdapter] = await Promise.all([
|
const [serverAdapter, platformAdapter] = await Promise.all([
|
||||||
resolveServerAdapter(),
|
resolveServerAdapter(),
|
||||||
resolvePlatformAdapter(),
|
resolvePlatformAdapter(),
|
||||||
])
|
]);
|
||||||
|
|
||||||
initRequestManager(serverAdapter)
|
await platformAdapter.init();
|
||||||
initPlatformService(platformAdapter)
|
|
||||||
|
|
||||||
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web'
|
initRequestManager(serverAdapter);
|
||||||
appState.version = await platformAdapter.getVersion()
|
initPlatformService(platformAdapter);
|
||||||
|
|
||||||
const savedUrl = loadSavedServerUrl()
|
await Promise.all([
|
||||||
const savedAuth = loadSavedAuth()
|
initSettingsState(),
|
||||||
|
initHistoryState(),
|
||||||
|
]);
|
||||||
|
|
||||||
appState.serverUrl = savedUrl
|
appState.platform = isTauri() ? 'tauri' : isCapacitor() ? 'capacitor' : 'web';
|
||||||
appState.authMode = savedAuth.mode
|
appState.version = await platformAdapter.getVersion();
|
||||||
|
|
||||||
if (isTauri() && platformAdapter.isSupported('server-management')) {
|
const legacyAuth = loadSavedAuth();
|
||||||
await platformAdapter.launchServer({ url: savedUrl }).catch(() => {})
|
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)
|
updateSettings({
|
||||||
await serverAdapter.connect({ baseUrl: savedUrl })
|
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') {
|
if (probe === 'auth_required') {
|
||||||
appState.status = 'auth'
|
appState.status = 'auth';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (probe === 'unreachable') {
|
if (probe === 'unreachable') {
|
||||||
appState.error = `Could not reach server at ${savedUrl}`
|
appState.error = `Could not reach server at ${savedUrl}`;
|
||||||
appState.status = 'error'
|
appState.status = 'error';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appState.authenticated = true
|
appState.authenticated = true;
|
||||||
appState.status = 'ready'
|
appState.status = 'ready';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appState.error = String(e)
|
appState.error = String(e);
|
||||||
appState.status = 'error'
|
appState.status = 'error';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
boot()
|
boot();
|
||||||
@@ -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<string> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -12,3 +12,15 @@ export function eventToKeybind(e: KeyboardEvent): string {
|
|||||||
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||||
return eventToKeybind(e) === bind;
|
return eventToKeybind(e) === bind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleFullscreen(): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<T>(key: string): Promise<T | null> {
|
||||||
|
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<T>(key: string, value: T): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (canUseLocalStorage()) {
|
||||||
|
localStorage.removeItem(localStorageKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canUseFilesystem()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(fileName(key), encoder.encode('null'));
|
||||||
|
} catch {
|
||||||
|
// Ignore native persistence failures during cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@import './reset.css';
|
||||||
|
@import './animations.css';
|
||||||
|
@import './scrollbars.css';
|
||||||
|
@import './typography.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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@import './base/index.css';
|
||||||
|
@import './tokens/index.css';
|
||||||
|
@import './themes/index.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@import './original.css';
|
||||||
|
@import './dark.css';
|
||||||
|
@import './light.css';
|
||||||
|
@import './light-contrast.css';
|
||||||
|
@import './midnight.css';
|
||||||
|
@import './warm.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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--t-fast: 0.08s ease;
|
||||||
|
--t-base: 0.14s ease;
|
||||||
|
--t-slow: 0.22s ease;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
:root {
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--radius-md: 5px;
|
||||||
|
--radius-lg: 7px;
|
||||||
|
--radius-xl: 10px;
|
||||||
|
--radius-2xl: 14px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
:root {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:root {
|
||||||
|
--z-reader: 50;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-settings: 150;
|
||||||
|
}
|
||||||
@@ -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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PersistedHistory>(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<BookmarkEntry, 'savedAt'>, 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {DEFAULT_MANGA_PREFS, type MangaPrefs} from '$lib/types/settings';
|
||||||
|
import {settingsState} from '$lib/state/settings.svelte';
|
||||||
|
|
||||||
|
export function getMangaPref<K extends keyof MangaPrefs>(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<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
||||||
|
settingsState.mangaPrefs = {
|
||||||
|
...settingsState.mangaPrefs,
|
||||||
|
[mangaId]: {
|
||||||
|
...(settingsState.mangaPrefs[mangaId] ?? {}),
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceMangaPrefs(mangaId: number, prefs: Partial<MangaPrefs>) {
|
||||||
|
settingsState.mangaPrefs = {
|
||||||
|
...settingsState.mangaPrefs,
|
||||||
|
[mangaId]: {
|
||||||
|
...(settingsState.mangaPrefs[mangaId] ?? {}),
|
||||||
|
...prefs,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMangaPrefs(mangaId: number) {
|
||||||
|
const next = {...settingsState.mangaPrefs};
|
||||||
|
delete next[mangaId];
|
||||||
|
settingsState.mangaPrefs = next;
|
||||||
|
}
|
||||||
@@ -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<Settings> | null;
|
||||||
|
storeVersion: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSettings(saved: Partial<Settings> | 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<Settings>(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<PersistedSettings>(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<Settings>) {
|
||||||
|
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<MangaPrefs>) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string
|
||||||
|
icon?: any
|
||||||
|
onClick: () => void
|
||||||
|
danger?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
separator?: never
|
||||||
|
children?: MenuEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuSeparator {
|
||||||
|
separator: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuEntry = MenuItem | MenuSeparator
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
items: MenuEntry[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { x, y, items, onClose }: Props = $props()
|
||||||
|
|
||||||
|
let focused = $state(-1)
|
||||||
|
let el = $state<HTMLDivElement | undefined>(undefined)
|
||||||
|
let measured = $state(false)
|
||||||
|
let pos = $state({ left: 0, top: 0 })
|
||||||
|
let subOpen = $state(-1)
|
||||||
|
let subEls = $state<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
const actionable = $derived(
|
||||||
|
items
|
||||||
|
.map((_, index) => index)
|
||||||
|
.filter((index) => !('separator' in items[index]) && !(items[index] as MenuItem).disabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (actionable.length && focused === -1) focused = actionable[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
function getZoom(): number {
|
||||||
|
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
|
||||||
|
return raw > 10 ? raw / 100 : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const zoom = getZoom()
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
const sidebarWidth = parseFloat(style.getPropertyValue('--sidebar-width')) || 52
|
||||||
|
const titlebarHeight = parseFloat(style.getPropertyValue('--titlebar-height')) || 36
|
||||||
|
const viewportWidth = window.innerWidth / zoom
|
||||||
|
const viewportHeight = window.innerHeight / zoom
|
||||||
|
const screenX = x / zoom - sidebarWidth / zoom
|
||||||
|
const screenY = y / zoom - titlebarHeight / zoom
|
||||||
|
const menuWidth = el.offsetWidth
|
||||||
|
const menuHeight = el.offsetHeight
|
||||||
|
|
||||||
|
pos = {
|
||||||
|
left: Math.max(4, screenX + menuWidth > viewportWidth ? screenX - menuWidth : screenX),
|
||||||
|
top: Math.max(4, screenY + menuHeight > viewportHeight ? screenY - menuHeight : screenY),
|
||||||
|
}
|
||||||
|
|
||||||
|
measured = true
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (subOpen < 0) return
|
||||||
|
|
||||||
|
const submenu = subEls[subOpen]
|
||||||
|
if (!submenu) return
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const zoom = getZoom()
|
||||||
|
const viewportWidth = window.innerWidth / zoom
|
||||||
|
const rect = submenu.getBoundingClientRect()
|
||||||
|
if (rect.right / zoom > viewportWidth) submenu.classList.add('sub-flip')
|
||||||
|
else submenu.classList.remove('sub-flip')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePointerOutside(target: EventTarget | null) {
|
||||||
|
const inMain = el?.contains(target as Node)
|
||||||
|
const inSubmenu = subOpen >= 0 && subEls[subOpen]?.contains(target as Node)
|
||||||
|
|
||||||
|
if (!inMain && !inSubmenu) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (subOpen >= 0) subOpen = -1
|
||||||
|
else onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
const current = actionable.indexOf(focused)
|
||||||
|
focused = actionable[(current + 1) % actionable.length] ?? actionable[0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
const current = actionable.indexOf(focused)
|
||||||
|
focused = actionable[(current - 1 + actionable.length) % actionable.length] ?? actionable[0]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowRight' && focused >= 0) {
|
||||||
|
const item = items[focused] as MenuItem
|
||||||
|
if (item.children?.length) subOpen = focused
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowLeft') {
|
||||||
|
subOpen = -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && focused >= 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
const item = items[focused] as MenuItem
|
||||||
|
if (item.children?.length) {
|
||||||
|
subOpen = focused
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.disabled) {
|
||||||
|
item.onClick()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const onMouseDown = (event: MouseEvent) => handlePointerOutside(event.target)
|
||||||
|
const onTouchStart = (event: TouchEvent) => handlePointerOutside(event.target)
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', onMouseDown, true)
|
||||||
|
document.addEventListener('touchstart', onTouchStart, true)
|
||||||
|
document.addEventListener('keydown', onKey, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onMouseDown, true)
|
||||||
|
document.removeEventListener('touchstart', onTouchStart, true)
|
||||||
|
document.removeEventListener('keydown', onKey, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={el} class="menu" role="menu" tabindex="-1" style={`left:${pos.left}px;top:${pos.top}px;visibility:${measured ? 'visible' : 'hidden'}`} oncontextmenu={(event) => event.preventDefault()}>
|
||||||
|
{#each items as item, index (index)}
|
||||||
|
{#if 'separator' in item}
|
||||||
|
<div class="sep"></div>
|
||||||
|
{:else}
|
||||||
|
{@const menuItem = item as MenuItem}
|
||||||
|
{@const hasSubmenu = !!menuItem.children?.length}
|
||||||
|
<div class="item-wrap">
|
||||||
|
<button
|
||||||
|
class="item"
|
||||||
|
class:danger={menuItem.danger}
|
||||||
|
class:disabled={menuItem.disabled}
|
||||||
|
class:focused={focused === index}
|
||||||
|
class:has-sub={hasSubmenu}
|
||||||
|
disabled={menuItem.disabled}
|
||||||
|
onclick={() => {
|
||||||
|
if (menuItem.disabled) return
|
||||||
|
if (hasSubmenu) {
|
||||||
|
subOpen = subOpen === index ? -1 : index
|
||||||
|
return
|
||||||
|
}
|
||||||
|
menuItem.onClick()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
onmouseenter={() => {
|
||||||
|
if (menuItem.disabled) return
|
||||||
|
focused = index
|
||||||
|
subOpen = hasSubmenu ? index : -1
|
||||||
|
}}
|
||||||
|
onmouseleave={() => {
|
||||||
|
focused = -1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="icon" class:icon-danger={menuItem.danger}>
|
||||||
|
{#if menuItem.icon}
|
||||||
|
<menuItem.icon size={13} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{menuItem.label}</span>
|
||||||
|
{#if hasSubmenu}
|
||||||
|
<span class="sub-arrow">›</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if hasSubmenu && subOpen === index}
|
||||||
|
<div bind:this={subEls[index]} class="menu submenu" role="menu" tabindex="-1" onmouseenter={() => { subOpen = index }}>
|
||||||
|
{#each menuItem.children as child, childIndex (childIndex)}
|
||||||
|
{#if 'separator' in child}
|
||||||
|
<div class="sep"></div>
|
||||||
|
{:else}
|
||||||
|
{@const childItem = child as MenuItem}
|
||||||
|
<button class="item" class:danger={childItem.danger} class:disabled={childItem.disabled} disabled={childItem.disabled} onclick={() => {
|
||||||
|
if (childItem.disabled) return
|
||||||
|
childItem.onClick()
|
||||||
|
onClose()
|
||||||
|
}}>
|
||||||
|
<span class="icon" class:icon-danger={childItem.danger}>
|
||||||
|
{#if childItem.icon}
|
||||||
|
<childItem.icon size={13} weight="light" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{childItem.label}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 190px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.35), 0 16px 40px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: scaleIn 0.1s ease both;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 0;
|
||||||
|
z-index: 201;
|
||||||
|
animation: scaleIn 0.08s ease both;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.submenu.sub-flip) {
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover:not(.disabled),
|
||||||
|
.item.focused:not(.disabled) {
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger:hover:not(.disabled),
|
||||||
|
.item.danger.focused:not(.disabled) {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-faint);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-arrow {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-dim);
|
||||||
|
margin: 3px var(--sp-1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookmarkSimple, BookOpen, DownloadSimple } from 'phosphor-svelte'
|
||||||
|
import type { Manga } from '$lib/types/manga'
|
||||||
|
import Thumbnail from '$lib/ui/manga/Thumbnail.svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
manga: Manga
|
||||||
|
href?: string
|
||||||
|
compact?: boolean
|
||||||
|
showMeta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
manga,
|
||||||
|
href,
|
||||||
|
compact = false,
|
||||||
|
showMeta = true,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const unreadCount = $derived(manga.unreadCount ?? 0)
|
||||||
|
const downloadCount = $derived(manga.downloadCount ?? 0)
|
||||||
|
const bookmarkCount = $derived(manga.bookmarkCount ?? 0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a class:compact class="card" {href} aria-label={manga.title}>
|
||||||
|
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{manga.title}</p>
|
||||||
|
{#if showMeta}
|
||||||
|
<div class="meta">
|
||||||
|
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
|
||||||
|
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
|
||||||
|
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if manga.source?.displayName}
|
||||||
|
<p class="source">{manga.source.displayName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class:compact class="card">
|
||||||
|
<Thumbnail class="manga-card-cover" src={manga.thumbnailUrl} alt={manga.title} />
|
||||||
|
<div class="body">
|
||||||
|
<p class="title">{manga.title}</p>
|
||||||
|
{#if showMeta}
|
||||||
|
<div class="meta">
|
||||||
|
<span><BookOpen size={12} weight="light" /> {unreadCount}</span>
|
||||||
|
<span><DownloadSimple size={12} weight="light" /> {downloadCount}</span>
|
||||||
|
<span><BookmarkSimple size={12} weight="light" /> {bookmarkCount}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if manga.source?.displayName}
|
||||||
|
<p class="source">{manga.source.displayName}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--sp-3);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--bg-overlay) 75%, transparent), transparent),
|
||||||
|
var(--bg-raised);
|
||||||
|
transition: border-color var(--t-base), transform var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.compact {
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.manga-card-cover) {
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--bg-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className = '' }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={`hover-3d ${className}`.trim()}>
|
||||||
|
<div class="hover-3d-content">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hover-3d {
|
||||||
|
display: inline-grid;
|
||||||
|
perspective: 75rem;
|
||||||
|
--transform: 0, 0;
|
||||||
|
--shine: 100% 100%;
|
||||||
|
--shadow: 0rem 0rem 0rem;
|
||||||
|
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
|
||||||
|
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
|
||||||
|
filter:
|
||||||
|
drop-shadow(var(--shadow) 0.1rem #00000020)
|
||||||
|
drop-shadow(var(--shadow) 0.2rem #00000015)
|
||||||
|
drop-shadow(var(--shadow) 0.3rem #00000010);
|
||||||
|
transition: filter ease-out 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d > :nth-child(n + 2) {
|
||||||
|
isolation: isolate;
|
||||||
|
z-index: 1;
|
||||||
|
scale: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d > :nth-child(2) { grid-area: 1 / 1 / 2 / 2; }
|
||||||
|
.hover-3d > :nth-child(3) { grid-area: 1 / 2 / 2 / 3; }
|
||||||
|
.hover-3d > :nth-child(4) { grid-area: 1 / 3 / 2 / 4; }
|
||||||
|
.hover-3d > :nth-child(5) { grid-area: 2 / 1 / 3 / 2; }
|
||||||
|
.hover-3d > :nth-child(6) { grid-area: 2 / 3 / 3 / 4; }
|
||||||
|
.hover-3d > :nth-child(7) { grid-area: 3 / 1 / 4 / 2; }
|
||||||
|
.hover-3d > :nth-child(8) { grid-area: 3 / 2 / 4 / 3; }
|
||||||
|
.hover-3d > :nth-child(9) { grid-area: 3 / 3 / 4 / 4; }
|
||||||
|
|
||||||
|
.hover-3d-content {
|
||||||
|
grid-area: 1 / 1 / 4 / 4;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: relative;
|
||||||
|
transform: rotate3d(var(--transform), 0, 10deg);
|
||||||
|
transition: transform var(--ease-out) 500ms, scale var(--ease-out) 500ms, outline-color ease-out 500ms;
|
||||||
|
outline: 0.5px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d-content::before {
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(0.75rem);
|
||||||
|
background-image: radial-gradient(circle at 50%, rgba(255, 255, 255, 0.18) 10%, transparent 50%);
|
||||||
|
translate: var(--shine);
|
||||||
|
transition: translate ease-out 400ms, opacity ease-out 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d:hover {
|
||||||
|
--ease-out: var(--ease-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d:hover > .hover-3d-content {
|
||||||
|
scale: 1.05;
|
||||||
|
outline-color: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d:hover > .hover-3d-content::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-3d:has(> :nth-child(2):hover) { --transform: -1, 1; --shine: 0% 0%; --shadow: -0.5rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(3):hover) { --transform: -1, 0; --shine: 100% 0%; --shadow: 0rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(4):hover) { --transform: -1, -1; --shine: 200% 0%; --shadow: 0.5rem -0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(5):hover) { --transform: 0, 1; --shine: 0% 100%; --shadow: -0.5rem 0rem; }
|
||||||
|
.hover-3d:has(> :nth-child(6):hover) { --transform: 0, -1; --shine: 200% 100%; --shadow: 0.5rem 0rem; }
|
||||||
|
.hover-3d:has(> :nth-child(7):hover) { --transform: 1, 1; --shine: 0% 200%; --shadow: -0.5rem 0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(8):hover) { --transform: 1, 0; --shine: 100% 200%; --shadow: 0rem 0.5rem; }
|
||||||
|
.hover-3d:has(> :nth-child(9):hover) { --transform: 1, -1; --shine: 200% 200%; --shadow: 0.5rem 0.5rem; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getAuthMode } from '$lib/core/auth'
|
||||||
|
import { loadImageObjectUrl, resolveImageUrl } from '$lib/core/image'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string | null | undefined
|
||||||
|
alt?: string
|
||||||
|
class?: string
|
||||||
|
loading?: 'lazy' | 'eager'
|
||||||
|
decoding?: 'sync' | 'async' | 'auto'
|
||||||
|
draggable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
class: className = '',
|
||||||
|
loading = 'lazy',
|
||||||
|
decoding = 'async',
|
||||||
|
draggable = false,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let objectUrl = $state<string | null>(null)
|
||||||
|
let failed = $state(false)
|
||||||
|
|
||||||
|
const resolvedSrc = $derived(objectUrl ?? resolveImageUrl(src) ?? '')
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const source = src
|
||||||
|
failed = false
|
||||||
|
|
||||||
|
if (!source || getAuthMode() === 'NONE') {
|
||||||
|
if (objectUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
objectUrl = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
const controller = new AbortController()
|
||||||
|
const previousUrl = objectUrl
|
||||||
|
|
||||||
|
void loadImageObjectUrl(source, controller.signal)
|
||||||
|
.then((nextUrl) => {
|
||||||
|
if (!active) {
|
||||||
|
if (nextUrl.startsWith('blob:')) URL.revokeObjectURL(nextUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousUrl?.startsWith('blob:') && previousUrl !== nextUrl) {
|
||||||
|
URL.revokeObjectURL(previousUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
objectUrl = nextUrl
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!active) return
|
||||||
|
objectUrl = null
|
||||||
|
failed = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
controller.abort()
|
||||||
|
if (objectUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if resolvedSrc && !failed}
|
||||||
|
<img src={resolvedSrc} {alt} class={className} {loading} {decoding} {draggable} onerror={() => { failed = true }} />
|
||||||
|
{:else}
|
||||||
|
<div class={`placeholder ${className}`.trim()} aria-label={alt || 'Thumbnail unavailable'} role="img">
|
||||||
|
<span>no cover</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background:
|
||||||
|
linear-gradient(160deg, color-mix(in srgb, var(--accent-muted) 60%, transparent), transparent 55%),
|
||||||
|
linear-gradient(180deg, var(--bg-raised), var(--bg-overlay));
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder span {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
import type { HTMLButtonAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
|
interface Props extends Omit<HTMLButtonAttributes, 'children'> {
|
||||||
|
children?: Snippet
|
||||||
|
variant?: 'solid' | 'ghost' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
block?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
class: className = '',
|
||||||
|
variant = 'solid',
|
||||||
|
size = 'md',
|
||||||
|
block = false,
|
||||||
|
type = 'button',
|
||||||
|
...rest
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class={`button ${variant} ${size} ${block ? 'block' : ''} ${className}`.trim()} {type} {...rest}>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
transition: background var(--t-base), border-color var(--t-base), color var(--t-base), opacity var(--t-base), transform var(--t-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.sm { min-height: 30px; padding: 0 var(--sp-3); font-size: var(--text-2xs); }
|
||||||
|
.button.md { min-height: 36px; padding: 0 var(--sp-4); font-size: var(--text-xs); }
|
||||||
|
.button.lg { min-height: 42px; padding: 0 var(--sp-5); font-size: var(--text-sm); }
|
||||||
|
|
||||||
|
.button.solid {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.ghost {
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-color: var(--border-dim);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.block {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus-visible {
|
||||||
|
outline: 2px solid var(--border-focus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trigger?: Snippet
|
||||||
|
children?: Snippet
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
trigger,
|
||||||
|
children,
|
||||||
|
align = 'left',
|
||||||
|
width = '220px',
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let root = $state<HTMLElement | null>(null)
|
||||||
|
let open = $state(false)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open = !open
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
open = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:document
|
||||||
|
onclick={(event) => {
|
||||||
|
if (!open || !(event.target instanceof Node) || root?.contains(event.target)) return
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
onkeydown={(event) => event.key === 'Escape' && close()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div bind:this={root} class="dropdown">
|
||||||
|
<button class="trigger" type="button" onclick={toggle} aria-expanded={open}>
|
||||||
|
{@render trigger?.()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class={`panel ${align}`.trim()} role="menu" style={`width: ${width}`}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--sp-2));
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
|
||||||
|
padding: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
|
interface Props extends Omit<HTMLInputAttributes, 'value'> {
|
||||||
|
value?: string
|
||||||
|
label?: string
|
||||||
|
error?: string | null
|
||||||
|
inputClass?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
label = '',
|
||||||
|
error = null,
|
||||||
|
class: className = '',
|
||||||
|
inputClass = '',
|
||||||
|
...rest
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class={`field ${className}`.trim()}>
|
||||||
|
{#if label}
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{/if}
|
||||||
|
<input class={`control ${inputClass}`.trim()} bind:value {...rest} />
|
||||||
|
{#if error}
|
||||||
|
<span class="error">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
transition: border-color var(--t-base), box-shadow var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
children?: Snippet
|
||||||
|
actions?: Snippet
|
||||||
|
onClose?: () => void
|
||||||
|
closeOnBackdrop?: boolean
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
open,
|
||||||
|
title = '',
|
||||||
|
description = '',
|
||||||
|
children,
|
||||||
|
actions,
|
||||||
|
onClose,
|
||||||
|
closeOnBackdrop = true,
|
||||||
|
width = 'min(520px, calc(100vw - 32px))',
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class="backdrop"
|
||||||
|
role="presentation"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={() => closeOnBackdrop && close()}
|
||||||
|
onkeydown={(event) => event.key === 'Escape' && closeOnBackdrop && close()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="panel anim-scale-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
style={`width: ${width}`}
|
||||||
|
onclick={(event) => event.stopPropagation()}
|
||||||
|
onkeydown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
{#if title || description}
|
||||||
|
<header class="header">
|
||||||
|
{#if title}
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{/if}
|
||||||
|
{#if description}
|
||||||
|
<p>{description}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
{@render children?.()}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if actions}
|
||||||
|
<footer class="actions">
|
||||||
|
{@render actions()}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-4);
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.content,
|
||||||
|
.actions {
|
||||||
|
padding-inline: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-top: var(--sp-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--weight-semi);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
overflow: auto;
|
||||||
|
padding-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Option {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string
|
||||||
|
options: Option[]
|
||||||
|
label?: string
|
||||||
|
disabled?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
options,
|
||||||
|
label = '',
|
||||||
|
disabled = false,
|
||||||
|
class: className = '',
|
||||||
|
}: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class={`field ${className}`.trim()}>
|
||||||
|
{#if label}
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="frame">
|
||||||
|
<select bind:value {disabled}>
|
||||||
|
{#each options as option (option.value)}
|
||||||
|
<option value={option.value} disabled={option.disabled}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-2xs);
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame::after {
|
||||||
|
content: '▾';
|
||||||
|
position: absolute;
|
||||||
|
right: var(--sp-3);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-faint);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 calc(var(--sp-5) + var(--sp-2)) 0 var(--sp-3);
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user