Implement phase 1

This commit is contained in:
Zerebos
2026-05-23 02:18:36 -04:00
parent 6c39ef538f
commit 8cef79b2b4
40 changed files with 2118 additions and 301 deletions
+1
View File
@@ -6,6 +6,7 @@ dist-tauri/
target/
bin/
out/
notes/
.direnv/
result
+12 -251
View File
@@ -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 {
+68 -47
View File
@@ -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()
boot();
+30
View File
@@ -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());
}
+15 -3
View File
@@ -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<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);
}
}
+77
View File
@@ -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.
}
}
+41
View File
@@ -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');
}
+48
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
@import './reset.css';
@import './animations.css';
@import './scrollbars.css';
@import './typography.css';
+48
View File
@@ -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;
}
+22
View File
@@ -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;
}
+9
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
@import './base/index.css';
@import './tokens/index.css';
@import './themes/index.css';
+25
View File
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
@import './original.css';
@import './dark.css';
@import './light.css';
@import './light-contrast.css';
@import './midnight.css';
@import './warm.css';
+29
View File
@@ -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;
}
+29
View File
@@ -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;
}
+25
View File
@@ -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;
}
+31
View File
@@ -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;
}
+25
View File
@@ -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;
}
+35
View File
@@ -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);
}
+7
View File
@@ -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';
+5
View File
@@ -0,0 +1,5 @@
:root {
--t-fast: 0.08s ease;
--t-base: 0.14s ease;
--t-slow: 0.22s ease;
}
+8
View File
@@ -0,0 +1,8 @@
:root {
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 7px;
--radius-xl: 10px;
--radius-2xl: 14px;
--radius-full: 9999px;
}
+2
View File
@@ -0,0 +1,2 @@
:root {
}
+13
View File
@@ -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;
}
+28
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
:root {
--z-reader: 50;
--z-modal: 100;
--z-settings: 150;
}
+168
View File
@@ -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();
}
+40
View File
@@ -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;
}
+140
View File
@@ -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();
}
+333
View File
@@ -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>
+137
View File
@@ -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>
+102
View File
@@ -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>
+98
View File
@@ -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>
+84
View File
@@ -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>
+78
View File
@@ -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>
+73
View File
@@ -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>
+130
View File
@@ -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>
+84
View File
@@ -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>