mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: App Pin & Downloads (Filesystem Changes)
This commit is contained in:
@@ -43,6 +43,8 @@
|
|||||||
"discord-rpc:allow-disconnect",
|
"discord-rpc:allow-disconnect",
|
||||||
"discord-rpc:allow-set-activity",
|
"discord-rpc:allow-set-activity",
|
||||||
"discord-rpc:allow-clear-activity",
|
"discord-rpc:allow-clear-activity",
|
||||||
"discord-rpc:allow-is-running"
|
"discord-rpc:allow-is-running",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,58 @@ use serde::Serialize;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use sysinfo::Disks;
|
use sysinfo::Disks;
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::server::resolve::suwayomi_data_dir;
|
use crate::server::resolve::suwayomi_data_dir;
|
||||||
|
|
||||||
|
// ── Key-value store (used by the frontend via platformService) ────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn load_store(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let value = store.get(&key);
|
||||||
|
Ok(value.map(|v| v.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn save_store(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store(format!("{}.json", key))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&value).map_err(|e| e.to_string())?;
|
||||||
|
store.set(key, parsed);
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Credential store (PIN-encrypted vault, auth tokens) ──────────────────────
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn store_credential(app: tauri::AppHandle, key: String, value: String) -> Result<(), String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if value.is_empty() {
|
||||||
|
store.delete(&key);
|
||||||
|
} else {
|
||||||
|
store.set(&key, serde_json::Value::String(value));
|
||||||
|
}
|
||||||
|
store.save().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_credential(app: tauri::AppHandle, key: String) -> Result<Option<String>, String> {
|
||||||
|
let store = app
|
||||||
|
.store("credentials.json")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(store.get(&key).and_then(|v| v.as_str().map(|s| s.to_owned())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disk / downloads storage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct StorageInfo {
|
pub struct StorageInfo {
|
||||||
pub manga_bytes: u64,
|
pub manga_bytes: u64,
|
||||||
@@ -127,4 +175,4 @@ pub async fn migrate_downloads(
|
|||||||
|
|
||||||
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
fs::remove_dir_all(&src_path).map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,10 @@ pub fn run() {
|
|||||||
commands::backup::auto_backup_app_data,
|
commands::backup::auto_backup_app_data,
|
||||||
commands::backup::get_auto_backup_dir,
|
commands::backup::get_auto_backup_dir,
|
||||||
commands::backup::read_store_files,
|
commands::backup::read_store_files,
|
||||||
|
commands::storage::load_store,
|
||||||
|
commands::storage::save_store,
|
||||||
|
commands::storage::store_credential,
|
||||||
|
commands::storage::get_credential,
|
||||||
commands::updater::list_releases,
|
commands::updater::list_releases,
|
||||||
commands::updater::download_and_install_update,
|
commands::updater::download_and_install_update,
|
||||||
commands::biometric::windows_hello_authenticate,
|
commands::biometric::windows_hello_authenticate,
|
||||||
|
|||||||
+4
-8
@@ -17,21 +17,17 @@ interface SavedAuth {
|
|||||||
pass?: string
|
pass?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveServerAdapter() {
|
|
||||||
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
|
||||||
return new SuwayomiAdapter()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
try {
|
try {
|
||||||
const platformAdapter = detectAdapter()
|
const platformAdapter = detectAdapter()
|
||||||
initPlatformService(platformAdapter)
|
initPlatformService(platformAdapter)
|
||||||
|
|
||||||
await platformAdapter.init()
|
const { SuwayomiAdapter } = await import('$lib/server-adapters/suwayomi')
|
||||||
|
const serverAdapter = new SuwayomiAdapter()
|
||||||
const serverAdapter = await resolveServerAdapter()
|
|
||||||
initRequestManager(serverAdapter)
|
initRequestManager(serverAdapter)
|
||||||
|
|
||||||
|
await platformAdapter.init()
|
||||||
|
|
||||||
appState.platform = platformAdapter.platform
|
appState.platform = platformAdapter.platform
|
||||||
appState.version = await platformAdapter.getVersion()
|
appState.version = await platformAdapter.getVersion()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||||
|
|
||||||
function handleBypass() {
|
function handleBypass() {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if appState.status === 'auth'}
|
{#if appState.status === 'auth'}
|
||||||
<div class="overlay">
|
<div class="overlay overlay--clear">
|
||||||
<div class="card anim-scale-in">
|
<div class="card anim-scale-in">
|
||||||
<img src={logoUrl} alt="Moku" class="logo" />
|
<img src={logoUrl} alt="Moku" class="logo" />
|
||||||
<p class="title">moku</p>
|
<p class="title">moku</p>
|
||||||
@@ -56,10 +56,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; pointer-events:none; }
|
.overlay { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.7); backdrop-filter:blur(6px); animation:overlayIn 0.28s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.card { pointer-events:auto; width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; }
|
.overlay--clear { background:transparent; backdrop-filter:none; pointer-events:none; }
|
||||||
|
.overlay--clear .card { pointer-events:auto; }
|
||||||
|
|
||||||
|
.card { width:min(280px, calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); text-align:center; animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||||
|
|
||||||
|
.logo { width:56px; height:56px; border-radius:14px; display:block; position:relative; }
|
||||||
|
|
||||||
.logo { width:56px; height:56px; border-radius:14px; display:block; }
|
|
||||||
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
.title { font-family:var(--font-ui); font-size:11px; font-weight:500; letter-spacing:0.26em; text-transform:uppercase; color:var(--text-secondary); margin:-6px 0 0; user-select:none; }
|
||||||
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
.mode-badge { font-family:var(--font-ui); font-size:var(--text-2xs); letter-spacing:var(--tracking-wider); text-transform:uppercase; color:var(--accent-fg); background:var(--accent-muted); border:1px solid var(--accent-dim); border-radius:var(--radius-full); padding:2px 10px; }
|
||||||
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
.host { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); margin:-4px 0 0; }
|
||||||
@@ -70,9 +74,14 @@
|
|||||||
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
.input:focus { border-color:var(--border-focus); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); }
|
||||||
.input:disabled { opacity:0.5; }
|
.input:disabled { opacity:0.5; }
|
||||||
|
|
||||||
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
.btn { width:100%; padding:9px; border-radius:var(--radius-md); background:var(--accent); border:1px solid var(--accent); color:var(--accent-fg); font-size:var(--text-sm); font-family:var(--font-ui); letter-spacing:var(--tracking-wide); cursor:pointer; transition:opacity var(--t-base); }
|
||||||
.btn:hover:not(:disabled) { opacity:0.85; }
|
.btn:hover:not(:disabled) { opacity:0.85; }
|
||||||
.btn:disabled { opacity:0.35; cursor:default; }
|
.btn:disabled { opacity:0.35; cursor:default; }
|
||||||
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
.btn--ghost { background:none; border-color:transparent; color:var(--text-faint); font-size:var(--text-xs); padding:4px; }
|
||||||
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
.btn--ghost:hover:not(:disabled) { color:var(--text-muted); opacity:1; }
|
||||||
|
|
||||||
|
@keyframes overlayIn { from { opacity:0 } to { opacity:1 } }
|
||||||
|
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||||
|
@keyframes anim-scale-in { from { opacity:0; transform:scale(0.96) } to { opacity:1; transform:scale(1) } }
|
||||||
|
.anim-scale-in { animation:anim-scale-in 0.2s cubic-bezier(0,0,0.2,1) both; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,384 +1,336 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from 'svelte'
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||||
import logoUrl from "$lib/assets/moku-icon-splash.svg";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode?: "loading" | "idle";
|
mode?: 'loading' | 'idle' | 'locked'
|
||||||
ringFull?: boolean;
|
ringFull?: boolean
|
||||||
failed?: boolean;
|
failed?: boolean
|
||||||
notConfigured?: boolean;
|
notConfigured?: boolean
|
||||||
showCards?: boolean;
|
showCards?: boolean
|
||||||
showFps?: boolean;
|
showFps?: boolean
|
||||||
onReady?: () => void;
|
pinLen?: number
|
||||||
onRetry?: () => void;
|
pinCorrect?: string
|
||||||
onBypass?: () => void;
|
onReady?: () => void
|
||||||
onDismiss?: () => void;
|
onUnlock?: () => void
|
||||||
|
onRetry?: () => void
|
||||||
|
onBypass?: () => void
|
||||||
|
onDismiss?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
mode = "loading", ringFull = false, failed = false,
|
mode = 'loading', ringFull = false, failed = false,
|
||||||
notConfigured = false, showCards = true, showFps = false,
|
notConfigured = false, showCards = true, showFps = false,
|
||||||
onReady, onRetry, onBypass, onDismiss,
|
pinLen = 4, pinCorrect = '',
|
||||||
}: Props = $props();
|
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const serverAuthActive = $derived(
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
||||||
settingsState.settings.serverAuthMode === "BASIC_AUTH" || settingsState.settings.serverAuthMode === "UI_LOGIN"
|
let dots = $state('')
|
||||||
);
|
let ringProg = $state(0.025)
|
||||||
|
let exiting = $state(false)
|
||||||
|
let exitLock = false
|
||||||
|
|
||||||
const lockEnabled = $derived(
|
let pinEntry = $state('')
|
||||||
settingsState.settings.appLockEnabled &&
|
let pinShake = $state(false)
|
||||||
(settingsState.settings.appLockPin?.length ?? 0) >= 4 &&
|
|
||||||
(mode === "idle" || !serverAuthActive)
|
|
||||||
);
|
|
||||||
|
|
||||||
let pinEntry = $state("");
|
const logoLoadingSize = 140
|
||||||
let pinShake = $state(false);
|
const logoIdleSize = 128
|
||||||
let pinUnlocked = $state(false);
|
const ringR = 70
|
||||||
let pinVisible = $state(false);
|
const ringPad = 12
|
||||||
let uiScale = $state(1);
|
const ringSize = (ringR + ringPad) * 2
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
|
const ringC = ringR + ringPad
|
||||||
|
const ringCirc = 2 * Math.PI * ringR
|
||||||
|
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
|
||||||
|
|
||||||
const logoLoadingSize = 140;
|
const EXIT_MS = 320
|
||||||
const logoIdleSize = 128;
|
const PHASE1_TARGET = 0.85
|
||||||
const logoLockSize = 96;
|
const PHASE1_MS = 3000
|
||||||
|
const PHASE2_TARGET = 0.95
|
||||||
|
const PHASE2_MS = 10000
|
||||||
|
|
||||||
const ringR = 70;
|
function triggerExit(cb?: () => void) {
|
||||||
const ringPad = 12;
|
if (exitLock) return
|
||||||
const ringSize = (ringR + ringPad) * 2;
|
exitLock = true
|
||||||
const ringC = ringR + ringPad;
|
exiting = true
|
||||||
const ringCirc = 2 * Math.PI * ringR;
|
setTimeout(() => cb?.(), EXIT_MS)
|
||||||
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
|
}
|
||||||
|
|
||||||
|
let animFrame: number
|
||||||
|
let animStart: number | null = null
|
||||||
|
let animPhase = 1
|
||||||
|
|
||||||
|
function animateRing(ts: number) {
|
||||||
|
if (exitLock) return
|
||||||
|
if (animStart === null) animStart = ts
|
||||||
|
const elapsed = ts - animStart
|
||||||
|
if (animPhase === 1) {
|
||||||
|
const t = Math.min(elapsed / PHASE1_MS, 1)
|
||||||
|
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025)
|
||||||
|
if (t >= 1) { animPhase = 2; animStart = ts }
|
||||||
|
} else {
|
||||||
|
const t = Math.min(elapsed / PHASE2_MS, 1)
|
||||||
|
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET)
|
||||||
|
}
|
||||||
|
animFrame = requestAnimationFrame(animateRing)
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
||||||
|
animStart = null
|
||||||
|
animPhase = 1
|
||||||
|
animFrame = requestAnimationFrame(animateRing)
|
||||||
|
return () => cancelAnimationFrame(animFrame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
||||||
|
cancelAnimationFrame(animFrame)
|
||||||
|
ringProg = 1
|
||||||
|
setTimeout(() => triggerExit(onReady), 650)
|
||||||
|
})
|
||||||
|
|
||||||
function submitPin() {
|
function submitPin() {
|
||||||
if (pinEntry === settingsState.settings.appLockPin) {
|
if (pinEntry === pinCorrect) {
|
||||||
pinUnlocked = true;
|
triggerExit(onUnlock)
|
||||||
pinEntry = "";
|
|
||||||
if (mode === "idle") triggerExit(onDismiss);
|
|
||||||
} else {
|
} else {
|
||||||
pinShake = true;
|
pinShake = true
|
||||||
pinEntry = "";
|
pinEntry = ''
|
||||||
setTimeout(() => (pinShake = false), 500);
|
setTimeout(() => (pinShake = false), 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPinKey(e: KeyboardEvent) {
|
function onPinKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") { submitPin(); return; }
|
if (mode !== 'locked' || exitLock) return
|
||||||
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
|
if (e.key === 'Enter') { e.preventDefault(); submitPin(); return }
|
||||||
|
if (e.key === 'Backspace') { e.preventDefault(); pinEntry = pinEntry.slice(0, -1); return }
|
||||||
if (/^\d$/.test(e.key)) {
|
if (/^\d$/.test(e.key)) {
|
||||||
pinEntry = (pinEntry + e.key).slice(0, 8);
|
e.preventDefault()
|
||||||
if (pinEntry.length >= (settingsState.settings.appLockPin?.length ?? 4)) submitPin();
|
pinEntry = (pinEntry + e.key).slice(0, 8)
|
||||||
|
if (pinEntry.length >= pinLen) submitPin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXIT_MS = 320;
|
|
||||||
const PHASE1_TARGET = 0.85;
|
|
||||||
const PHASE1_MS = 3000;
|
|
||||||
const PHASE2_TARGET = 0.95;
|
|
||||||
const PHASE2_MS = 10000;
|
|
||||||
|
|
||||||
let dots = $state("");
|
|
||||||
let ringProg = $state(0.025);
|
|
||||||
let exiting = $state(false);
|
|
||||||
let exitLock = false;
|
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
|
||||||
if (exitLock) return;
|
|
||||||
exitLock = true;
|
|
||||||
exiting = true;
|
|
||||||
setTimeout(() => cb?.(), EXIT_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
let animFrame: number;
|
|
||||||
let animStart: number | null = null;
|
|
||||||
let animPhase = 1;
|
|
||||||
|
|
||||||
function animateRing(ts: number) {
|
|
||||||
if (exitLock) return;
|
|
||||||
if (animStart === null) animStart = ts;
|
|
||||||
const elapsed = ts - animStart;
|
|
||||||
if (animPhase === 1) {
|
|
||||||
const t = Math.min(elapsed / PHASE1_MS, 1);
|
|
||||||
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
|
|
||||||
if (t >= 1) { animPhase = 2; animStart = ts; }
|
|
||||||
} else {
|
|
||||||
const t = Math.min(elapsed / PHASE2_MS, 1);
|
|
||||||
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
|
|
||||||
}
|
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mode === "loading" && !failed && !notConfigured && !ringFull) {
|
if (mode !== 'locked') return
|
||||||
animStart = null;
|
pinEntry = ''
|
||||||
animPhase = 1;
|
window.addEventListener('keydown', onPinKey)
|
||||||
animFrame = requestAnimationFrame(animateRing);
|
return () => window.removeEventListener('keydown', onPinKey)
|
||||||
return () => cancelAnimationFrame(animFrame);
|
})
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
onMount(() => {
|
||||||
if (!ringFull) {
|
const iv = setInterval(() => { dots = dots.length >= 3 ? '' : dots + '.' }, 420)
|
||||||
exitLock = false;
|
|
||||||
exiting = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimationFrame(animFrame);
|
|
||||||
animFrame = 0;
|
|
||||||
ringProg = 1;
|
|
||||||
if (lockEnabled && !pinUnlocked) {
|
|
||||||
setTimeout(() => (pinVisible = true), 400);
|
|
||||||
} else {
|
|
||||||
setTimeout(() => triggerExit(onReady), 650);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
if (mode === 'idle' && onDismiss) {
|
||||||
const needsPin =
|
const handler = () => triggerExit(onDismiss)
|
||||||
(mode === "idle" && lockEnabled) ||
|
|
||||||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
|
|
||||||
if (!needsPin) return;
|
|
||||||
window.addEventListener("keydown", onPinKey);
|
|
||||||
return () => window.removeEventListener("keydown", onPinKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
uiScale = await win.scaleFactor();
|
|
||||||
} catch {
|
|
||||||
uiScale = window.devicePixelRatio || 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dotsInterval = setInterval(() => {
|
|
||||||
dots = dots.length >= 3 ? "" : dots + ".";
|
|
||||||
}, 420);
|
|
||||||
|
|
||||||
if (mode === "idle" && onDismiss) {
|
|
||||||
if (lockEnabled) return () => clearInterval(dotsInterval);
|
|
||||||
const handler = () => triggerExit(onDismiss);
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
window.addEventListener('keydown', handler, { once: true })
|
||||||
window.addEventListener("mousedown", handler, { once: true });
|
window.addEventListener('mousedown', handler, { once: true })
|
||||||
window.addEventListener("touchstart", handler, { once: true });
|
window.addEventListener('touchstart', handler, { once: true })
|
||||||
}, 200);
|
}, 200)
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(t);
|
clearTimeout(t)
|
||||||
clearInterval(dotsInterval);
|
clearInterval(iv)
|
||||||
window.removeEventListener("keydown", handler);
|
window.removeEventListener('keydown', handler)
|
||||||
window.removeEventListener("mousedown", handler);
|
window.removeEventListener('mousedown', handler)
|
||||||
window.removeEventListener("touchstart", handler);
|
window.removeEventListener('touchstart', handler)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
return () => clearInterval(dotsInterval);
|
return () => clearInterval(iv)
|
||||||
});
|
})
|
||||||
|
|
||||||
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
|
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number }
|
||||||
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
|
interface CardTrig { cosA: number; sinA: number; tiltRad: number }
|
||||||
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
|
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number }
|
||||||
|
|
||||||
const LAYER_CFG = [
|
const LAYER_CFG = [
|
||||||
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
|
||||||
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
|
||||||
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
|
||||||
] as const;
|
] as const
|
||||||
|
|
||||||
const BUF = 80, COLS = 14;
|
const BUF = 80, COLS = 14
|
||||||
|
|
||||||
function hash(n: number): number {
|
function hash(n: number): number {
|
||||||
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
|
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b)
|
||||||
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
|
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b)
|
||||||
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
|
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCards(vw: number, vh: number) {
|
function buildCards(vw: number, vh: number) {
|
||||||
const cards: CardDef[] = [];
|
const cards: CardDef[] = []
|
||||||
const laneW = vw / COLS;
|
const laneW = vw / COLS
|
||||||
for (let layer = 0; layer < 3; layer++) {
|
for (let layer = 0; layer < 3; layer++) {
|
||||||
const cfg = LAYER_CFG[layer];
|
const cfg = LAYER_CFG[layer]
|
||||||
for (let col = 0; col < COLS; col++) {
|
for (let col = 0; col < COLS; col++) {
|
||||||
const seed = col * 31 + layer * 97 + 7;
|
const seed = col * 31 + layer * 97 + 7
|
||||||
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
|
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin)
|
||||||
const h = w * 1.44;
|
const h = w * 1.44
|
||||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
|
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin)
|
||||||
const travel = vh + h + BUF;
|
const travel = vh + h + BUF
|
||||||
cards.push({
|
cards.push({
|
||||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||||
w, h,
|
w, h,
|
||||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||||
alpha: cfg.alpha,
|
alpha: cfg.alpha,
|
||||||
speed,
|
speed,
|
||||||
cycleSec: travel / speed,
|
cycleSec: travel / speed,
|
||||||
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
|
||||||
travel,
|
travel,
|
||||||
yStart: vh + h / 2 + BUF / 2,
|
yStart: vh + h / 2 + BUF / 2,
|
||||||
angleStart: hash(seed + 3) * 50 - 25,
|
angleStart: hash(seed + 3) * 50 - 25,
|
||||||
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
tilt: (hash(seed + 4) * 2 - 1) * 18,
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const trigs: CardTrig[] = cards.map(c => ({
|
const trigs: CardTrig[] = cards.map(c => ({
|
||||||
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
|
||||||
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
|
||||||
tiltRad: c.tilt * (Math.PI / 180),
|
tiltRad: c.tilt * (Math.PI / 180),
|
||||||
}));
|
}))
|
||||||
return { cards, trigs };
|
return { cards, trigs }
|
||||||
}
|
}
|
||||||
|
|
||||||
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
ctx.beginPath();
|
ctx.beginPath()
|
||||||
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
|
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r)
|
||||||
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
|
||||||
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
|
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r)
|
||||||
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
|
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r)
|
||||||
ctx.closePath();
|
ctx.closePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAMP_PAD = 6;
|
const STAMP_PAD = 6
|
||||||
|
|
||||||
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement('canvas')
|
||||||
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
|
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr)
|
||||||
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
|
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr)
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext('2d')!
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr)
|
||||||
const x0 = STAMP_PAD, y0 = STAMP_PAD;
|
const x0 = STAMP_PAD, y0 = STAMP_PAD
|
||||||
const coverH = c.w * 0.72 * 1.05;
|
const coverH = c.w * 0.72 * 1.05
|
||||||
const lineY0 = y0 + 3 + coverH + 5;
|
const lineY0 = y0 + 3 + coverH + 5
|
||||||
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = 'rgba(0,0,0,0.5)'; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
|
ctx.fillStyle = 'rgba(255,255,255,0.07)'; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill()
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.75)";
|
ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke()
|
||||||
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
|
ctx.fillStyle = 'rgba(255,255,255,0.15)'; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
|
ctx.fillStyle = 'rgba(255,255,255,0.08)'; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill()
|
||||||
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
|
|
||||||
for (let li = 0; li < c.lines; li++) {
|
for (let li = 0; li < c.lines; li++) {
|
||||||
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
|
ctx.fillStyle = li === 0 ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.20)'
|
||||||
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
|
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2)
|
||||||
}
|
}
|
||||||
return oc;
|
return oc
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
|
||||||
const oc = document.createElement("canvas");
|
const oc = document.createElement('canvas')
|
||||||
oc.width = Math.round(vw * dpr);
|
oc.width = Math.round(vw * dpr)
|
||||||
oc.height = Math.round(vh * dpr);
|
oc.height = Math.round(vh * dpr)
|
||||||
const ctx = oc.getContext("2d")!;
|
const ctx = oc.getContext('2d')!
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr)
|
||||||
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
|
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65)
|
||||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
g.addColorStop(0, 'rgba(0,0,0,0)')
|
||||||
g.addColorStop(0.4, "rgba(0,0,0,0)");
|
g.addColorStop(0.4, 'rgba(0,0,0,0)')
|
||||||
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
|
g.addColorStop(0.7, 'rgba(0,0,0,0.25)')
|
||||||
g.addColorStop(1, "rgba(0,0,0,0.65)");
|
g.addColorStop(1, 'rgba(0,0,0,0.65)')
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g
|
||||||
ctx.fillRect(0, 0, vw, vh);
|
ctx.fillRect(0, 0, vw, vh)
|
||||||
return oc;
|
return oc
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawFrame(
|
function drawFrame(
|
||||||
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
|
||||||
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
|
||||||
) {
|
) {
|
||||||
ctx.clearRect(0, 0, cw, ch);
|
ctx.clearRect(0, 0, cw, ch)
|
||||||
for (let i = 0; i < cards.length; i++) {
|
for (let i = 0; i < cards.length; i++) {
|
||||||
const c = cards[i];
|
const c = cards[i]
|
||||||
const p = ((t / c.cycleSec) + c.phase) % 1;
|
const p = ((t / c.cycleSec) + c.phase) % 1
|
||||||
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
|
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha
|
||||||
if (alpha < 0.005) continue;
|
if (alpha < 0.005) continue
|
||||||
const cy = c.yStart - p * c.travel;
|
const cy = c.yStart - p * c.travel
|
||||||
const tg = trigs[i];
|
const tg = trigs[i]
|
||||||
const delta = tg.tiltRad * p;
|
const delta = tg.tiltRad * p
|
||||||
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
|
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta)
|
||||||
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
|
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta)
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha
|
||||||
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
|
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr)
|
||||||
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
|
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr
|
||||||
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
|
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh)
|
||||||
}
|
}
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1
|
||||||
ctx.drawImage(vignette, 0, 0, cw, ch);
|
ctx.drawImage(vignette, 0, 0, cw, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fps = 0, fpsFrames = 0, fpsLast = 0;
|
let fps = 0, fpsFrames = 0, fpsLast = 0
|
||||||
function tickFps(now: number) {
|
function tickFps(now: number) {
|
||||||
fpsFrames++;
|
fpsFrames++
|
||||||
if (now - fpsLast >= 500) {
|
if (now - fpsLast >= 500) {
|
||||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
|
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
|
||||||
fpsFrames = 0;
|
fpsFrames = 0
|
||||||
fpsLast = now;
|
fpsLast = now
|
||||||
if (fpsEl) fpsEl.textContent = `${fps} fps`;
|
if (fpsEl) fpsEl.textContent = `${fps} fps`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountCanvas(el: HTMLCanvasElement) {
|
function mountCanvas(el: HTMLCanvasElement) {
|
||||||
const ctx = el.getContext("2d")!;
|
const win = getCurrentWindow()
|
||||||
let live: RenderState | null = null;
|
const ctx = el.getContext('2d')!
|
||||||
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
|
let live: RenderState | null = null
|
||||||
|
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0
|
||||||
|
|
||||||
async function syncSize() {
|
async function syncSize() {
|
||||||
const gen = ++buildGen;
|
const gen = ++buildGen
|
||||||
const scale = window.devicePixelRatio || 1;
|
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()])
|
||||||
const logW = el.offsetWidth || el.parentElement?.offsetWidth || 800;
|
if (gen !== buildGen) return
|
||||||
const logH = el.offsetHeight || el.parentElement?.offsetHeight || 600;
|
const logW = phys.width / scale, logH = phys.height / scale
|
||||||
const phys = { width: Math.round(logW * scale), height: Math.round(logH * scale) };
|
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return
|
||||||
if (gen !== buildGen) return;
|
lastLogW = logW; lastLogH = logH; lastScale = scale
|
||||||
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
|
const built = buildCards(logW, logH)
|
||||||
lastLogW = logW; lastLogH = logH; lastScale = scale;
|
const stamps = built.cards.map(c => buildStamp(c, scale))
|
||||||
const built = buildCards(logW, logH);
|
const vig = buildVignette(logW, logH, scale)
|
||||||
const stamps = built.cards.map(c => buildStamp(c, scale));
|
el.width = phys.width; el.height = phys.height
|
||||||
const vig = buildVignette(logW, logH, scale);
|
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale }
|
||||||
el.width = phys.width; el.height = phys.height;
|
|
||||||
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ro = new ResizeObserver(() => syncSize());
|
const ro = new ResizeObserver(() => syncSize())
|
||||||
ro.observe(el);
|
ro.observe(el)
|
||||||
syncSize();
|
syncSize()
|
||||||
|
|
||||||
let raf = 0, t0 = -1, paused = false;
|
let raf = 0, t0 = -1, paused = false
|
||||||
|
|
||||||
function frame(now: number) {
|
function frame(now: number) {
|
||||||
if (paused) { raf = 0; return; }
|
if (paused) { raf = 0; return }
|
||||||
raf = requestAnimationFrame(frame);
|
raf = requestAnimationFrame(frame)
|
||||||
if (!live) return;
|
if (!live) return
|
||||||
if (t0 < 0) t0 = now;
|
if (t0 < 0) t0 = now
|
||||||
if (showFps) tickFps(now);
|
if (showFps) tickFps(now)
|
||||||
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
|
const { cards, trigs, stamps, vignette, CW, CH, scale } = live
|
||||||
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
|
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette)
|
||||||
}
|
}
|
||||||
|
|
||||||
function pause() { paused = true; t0 = -1; }
|
function pause() { paused = true; t0 = -1 }
|
||||||
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame); }
|
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame) }
|
||||||
|
function onVis() { document.hidden ? pause() : resume() }
|
||||||
|
|
||||||
function onVisibility() { document.hidden ? pause() : resume(); }
|
document.addEventListener('visibilitychange', onVis)
|
||||||
|
const unlistenFocus = win.onFocusChanged(({ payload: focused }) => { focused ? resume() : pause() })
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", onVisibility);
|
raf = requestAnimationFrame(frame)
|
||||||
|
|
||||||
let unlistenFocus: Promise<() => void> | null = null;
|
|
||||||
try {
|
|
||||||
const win = getCurrentWindow();
|
|
||||||
unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
|
|
||||||
focused ? resume() : pause();
|
|
||||||
});
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf)
|
||||||
ro.disconnect();
|
ro.disconnect()
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
document.removeEventListener('visibilitychange', onVis)
|
||||||
unlistenFocus?.then(f => f());
|
unlistenFocus.then(f => f())
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
|
<div class="splash" class:exiting style="cursor:{mode === 'idle' ? 'pointer' : 'default'}">
|
||||||
{#if showCards}
|
{#if showCards}
|
||||||
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
|
||||||
{#if showFps}
|
{#if showFps}
|
||||||
@@ -386,26 +338,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === "idle" && lockEnabled}
|
{#if mode === 'idle'}
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
|
|
||||||
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
|
|
||||||
<div class="logo-glow"></div>
|
|
||||||
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
|
|
||||||
</div>
|
|
||||||
<div class="pin-card">
|
|
||||||
<p class="pin-label">Enter PIN</p>
|
|
||||||
<div class="pin-block">
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{:else if mode === "idle"}
|
|
||||||
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
|
||||||
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
|
||||||
<div class="logo-glow"></div>
|
<div class="logo-glow"></div>
|
||||||
@@ -414,60 +347,53 @@
|
|||||||
<p class="hint">press any key to continue</p>
|
<p class="hint">press any key to continue</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if mode === 'locked'}
|
||||||
|
<div class="pin-card" class:pin-card--leaving={exiting}>
|
||||||
|
<div class="logo-wrap">
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:56px;height:56px;border-radius:14px;display:block;position:relative" />
|
||||||
|
</div>
|
||||||
|
<p class="pin-label">Enter PIN</p>
|
||||||
|
<div class="pin-dots" class:pin-shake={pinShake}>
|
||||||
|
{#each Array(pinLen) as _, i}
|
||||||
|
<div class="pin-dot" class:filled={i < pinEntry.length}></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
|
||||||
{#if !failed && !notConfigured}
|
{#if !failed && !notConfigured}
|
||||||
<svg width={ringSize} height={ringSize}
|
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
|
||||||
class="loading-ring"
|
|
||||||
class:ring-hide={lockEnabled && pinVisible}
|
|
||||||
style="position:absolute;top:0;left:0;pointer-events:none">
|
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
|
||||||
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-dasharray="{ringArc} {ringCirc}"
|
stroke-dasharray="{ringArc} {ringCirc}"
|
||||||
transform="rotate(-90 {ringC} {ringC})"
|
transform="rotate(-90 {ringC} {ringC})"
|
||||||
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
style="transition:stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
|
||||||
</div>
|
</div>
|
||||||
<div class="bottom-area" style="z-index:1">
|
<div class="bottom-area" style="z-index:1">
|
||||||
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
|
{#if failed || notConfigured}
|
||||||
{#if failed || notConfigured}
|
<div class="error-box anim-fade-up">
|
||||||
<div class="error-box anim-fade-up">
|
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
|
||||||
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
|
<div class="error-actions">
|
||||||
<div class="error-actions">
|
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
||||||
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
|
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
||||||
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if lockEnabled}
|
|
||||||
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
|
|
||||||
<div class="pin-card">
|
|
||||||
<p class="pin-label">Enter PIN</p>
|
|
||||||
<div class="pin-block">
|
|
||||||
<div class="pin-dots" class:pin-shake={pinShake}>
|
|
||||||
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
|
|
||||||
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
.splash { position:fixed; inset:0; z-index:9999; background:var(--bg-base); overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; animation:spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
|
||||||
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
.exiting { animation:spOut 320ms cubic-bezier(0.4,0,1,1) both; }
|
||||||
|
|
||||||
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
|
||||||
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
|
||||||
@@ -475,33 +401,33 @@
|
|||||||
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
|
||||||
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
|
||||||
|
|
||||||
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
|
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
|
||||||
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
|
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; }
|
||||||
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
|
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
|
||||||
|
|
||||||
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; }
|
.logo-wrap { position:relative; width:72px; height:72px; display:flex; align-items:center; justify-content:center; }
|
||||||
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
|
|
||||||
.error-actions { display: flex; gap: 6px; }
|
|
||||||
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
|
|
||||||
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
|
||||||
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
|
||||||
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
|
|
||||||
|
|
||||||
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
|
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||||
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
|
.pin-card--leaving { animation:cardOut 0.28s cubic-bezier(0.4,0,1,1) both; }
|
||||||
.status-slot-hide { opacity: 0; pointer-events: none; }
|
|
||||||
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
|
|
||||||
.loading-ring { transition: opacity 0.5s ease; }
|
|
||||||
.ring-hide { opacity: 0; }
|
|
||||||
|
|
||||||
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
|
.pin-label { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wider); text-transform:uppercase; margin:0; }
|
||||||
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
.pin-dots { display:flex; gap:12px; align-items:center; }
|
||||||
.pin-card { background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
|
.pin-dot { width:10px; height:10px; border-radius:50%; border:1px solid var(--border-strong); background:transparent; transition:background 0.12s, border-color 0.12s; }
|
||||||
.pin-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin: 0; }
|
.pin-dot.filled { background:var(--accent); border-color:var(--accent); }
|
||||||
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
|
.pin-shake { animation:pinShake 0.42s ease; }
|
||||||
.pin-dots { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
|
@keyframes cardIn { from { opacity:0; transform:translateY(28px) scale(0.97) } to { opacity:1; transform:translateY(0) scale(1) } }
|
||||||
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
|
@keyframes cardOut { from { opacity:1; transform:translateY(0) scale(1) } to { opacity:0; transform:translateY(18px) scale(0.97) } }
|
||||||
.pin-shake { animation: pinShake 0.42s ease; }
|
|
||||||
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
|
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; }
|
||||||
|
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
|
||||||
|
.loading-ring { transition:opacity 0.5s ease; }
|
||||||
|
|
||||||
|
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
|
||||||
|
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
|
||||||
|
.error-actions { display:flex; gap:6px; }
|
||||||
|
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
|
||||||
|
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
|
||||||
|
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
|
||||||
|
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
|
||||||
</style>
|
</style>
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
|
||||||
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
||||||
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
||||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||||
@@ -16,6 +15,7 @@
|
|||||||
Books, Folder, FolderSimple, FolderSimplePlus,
|
Books, Folder, FolderSimple, FolderSimplePlus,
|
||||||
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
|
import { openMangaFolder, openDownloadsFolder } from '$lib/core/filesystem'
|
||||||
|
|
||||||
const SIDEBAR_W = 52
|
const SIDEBAR_W = 52
|
||||||
const TITLEBAR_H = 36
|
const TITLEBAR_H = 36
|
||||||
@@ -115,26 +115,6 @@
|
|||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openMangaFolder(m: Manga) {
|
|
||||||
let base: string | undefined
|
|
||||||
try { base = await invoke<string>('get_default_downloads_path') } catch {}
|
|
||||||
if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
|
||||||
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
|
|
||||||
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
|
|
||||||
const path = source
|
|
||||||
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
|
|
||||||
: `${base}/mangas/${sanitize(m.title)}`
|
|
||||||
try { await invoke('open_path', { path }) }
|
|
||||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDownloadsFolder() {
|
|
||||||
let path: string | undefined
|
|
||||||
try { path = await invoke<string>('get_default_downloads_path') } catch {}
|
|
||||||
if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
|
||||||
try { await invoke('open_path', { path }) }
|
|
||||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSingleManga(m: Manga) {
|
async function refreshSingleManga(m: Manga) {
|
||||||
if (libraryState.refreshingMangaId !== null) return
|
if (libraryState.refreshingMangaId !== null) return
|
||||||
|
|||||||
@@ -31,6 +31,35 @@
|
|||||||
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
||||||
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
||||||
|
|
||||||
|
let lockEnabled = $state(settingsState.settings.appLockEnabled ?? false)
|
||||||
|
let lockPin = $state(settingsState.settings.appLockEnabled ? (settingsState.settings.appLockPin ?? '') : '')
|
||||||
|
let lockPinVis = $state(false)
|
||||||
|
let lockError = $state<string | null>(null)
|
||||||
|
let lockSaved = $state(false)
|
||||||
|
|
||||||
|
function onLockToggle() {
|
||||||
|
lockEnabled = !lockEnabled
|
||||||
|
lockError = null
|
||||||
|
lockSaved = false
|
||||||
|
if (!lockEnabled) {
|
||||||
|
lockPin = ''
|
||||||
|
updateSettings({ appLockEnabled: false, appLockPin: '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLockPinInput() {
|
||||||
|
lockPin = lockPin.replace(/\D/g, '')
|
||||||
|
lockError = null
|
||||||
|
lockSaved = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLockPin() {
|
||||||
|
if (lockPin.length < 4) { lockError = 'PIN must be at least 4 digits'; return }
|
||||||
|
updateSettings({ appLockEnabled: true, appLockPin: lockPin })
|
||||||
|
lockSaved = true
|
||||||
|
setTimeout(() => lockSaved = false, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
||||||
return 'NONE'
|
return 'NONE'
|
||||||
@@ -283,6 +312,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">App Lock</p>
|
||||||
|
<div class="s-section-body">
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Require PIN on launch</span>
|
||||||
|
<span class="s-desc">Lock the app behind a numeric PIN when it opens</span>
|
||||||
|
</div>
|
||||||
|
<button role="switch" aria-checked={lockEnabled} aria-label="Enable app lock" class="s-toggle" class:on={lockEnabled}
|
||||||
|
onclick={onLockToggle}><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
{#if lockEnabled}
|
||||||
|
{#if lockError}
|
||||||
|
<div class="s-banner s-banner-error" style="margin: 0">{lockError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">PIN</span>
|
||||||
|
<span class="s-desc">Minimum 4 digits</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-pin-row">
|
||||||
|
<div class="s-field-wrap">
|
||||||
|
<input class="s-input" type={lockPinVis ? 'text' : 'password'} inputmode="numeric" pattern="\d*"
|
||||||
|
bind:value={lockPin} oninput={onLockPinInput} placeholder="••••" autocomplete="off" spellcheck="false" maxlength="8" />
|
||||||
|
<button class="s-eye-btn" onclick={() => lockPinVis = !lockPinVis} tabindex="-1" aria-label={lockPinVis ? 'Hide PIN' : 'Show PIN'}>{@html lockPinVis ? EyeClose : EyeOpen}</button>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn s-btn-accent" onclick={saveLockPin} disabled={!lockPin}>
|
||||||
|
{lockSaved ? 'Saved ✓' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">FlareSolverr</p>
|
<p class="s-section-title">FlareSolverr</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
@@ -337,4 +401,5 @@
|
|||||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
.s-pin-row { display: flex; align-items: center; gap: 8px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -168,11 +168,11 @@
|
|||||||
if (!supportsFilesystem) return
|
if (!supportsFilesystem) return
|
||||||
storageLoading = true; storageError = null
|
storageLoading = true; storageError = null
|
||||||
try {
|
try {
|
||||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
const pathData = await gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
|
||||||
`{ downloadsPath localSourcePath }`
|
`{ settings { downloadsPath localSourcePath } }`
|
||||||
)
|
)
|
||||||
const dl = pathData.downloadsPath ?? ''
|
const dl = pathData.settings.downloadsPath ?? ''
|
||||||
const loc = pathData.localSourcePath ?? ''
|
const loc = pathData.settings.localSourcePath ?? ''
|
||||||
downloadsPathInput = dl; localSourcePathInput = loc
|
downloadsPathInput = dl; localSourcePathInput = loc
|
||||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
@@ -218,8 +218,8 @@
|
|||||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||||
pathsSaving = true
|
pathsSaving = true
|
||||||
try {
|
try {
|
||||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
await gql(`mutation($path: String!) { setSettings(input: { settings: { downloadsPath: $path } }) { settings { downloadsPath } } }`, { path: dl })
|
||||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
if (loc) await gql(`mutation($path: String!) { setSettings(input: { settings: { localSourcePath: $path } }) { settings { localSourcePath } } }`, { path: loc })
|
||||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||||
if (supportsFilesystem && !isExternalServer) {
|
if (supportsFilesystem && !isExternalServer) {
|
||||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||||
|
|||||||
+33
-24
@@ -1,16 +1,14 @@
|
|||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
import { addToast } from '$lib/state/notifications.svelte'
|
import { addToast } from '$lib/state/notifications.svelte'
|
||||||
import type { Manga } from '$lib/types'
|
import type { Manga } from '$lib/types'
|
||||||
|
|
||||||
function sanitizeTitle(title: string): string {
|
function sanitize(s: string): string {
|
||||||
return title.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
return s.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDownloadsRoot(): Promise<string> {
|
function getDownloadsRoot(): string {
|
||||||
let root = (seriesState.settings as any).downloadsPath?.trim() ?? ''
|
return settingsState.settings?.serverDownloadsPath?.trim() ?? ''
|
||||||
if (!root) root = await platformService.getDefaultDownloadsPath().catch(() => '')
|
|
||||||
return root
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function join(root: string, ...parts: string[]): string {
|
function join(root: string, ...parts: string[]): string {
|
||||||
@@ -18,31 +16,42 @@ function join(root: string, ...parts: string[]): string {
|
|||||||
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openMangaFolder(manga: Manga): Promise<void> {
|
function checkSupported(): boolean {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!platformService.isSupported('filesystem')) {
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
const root = await getDownloadsRoot()
|
return true
|
||||||
if (!root) return
|
|
||||||
await platformService.openPath(join(root, 'mangas', sanitizeTitle(manga.title))).catch(console.error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCustomFolder(path: string): Promise<void> {
|
function checkRoot(root: string): boolean {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!root) {
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' })
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (!path?.trim()) return
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMangaFolder(manga: Manga): Promise<void> {
|
||||||
|
if (!checkSupported()) return
|
||||||
|
const root = getDownloadsRoot()
|
||||||
|
if (!checkRoot(root)) return
|
||||||
|
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
|
||||||
|
const path = source
|
||||||
|
? join(root, 'mangas', sanitize(source), sanitize(manga.title))
|
||||||
|
: join(root, 'mangas', sanitize(manga.title))
|
||||||
await platformService.openPath(path).catch(console.error)
|
await platformService.openPath(path).catch(console.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openDownloadsFolder(): Promise<void> {
|
export async function openDownloadsFolder(): Promise<void> {
|
||||||
if (!platformService.isSupported('filesystem')) {
|
if (!checkSupported()) return
|
||||||
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
|
const root = getDownloadsRoot()
|
||||||
return
|
if (!checkRoot(root)) return
|
||||||
}
|
await platformService.openPath(root).catch(console.error)
|
||||||
const root = await getDownloadsRoot()
|
}
|
||||||
if (!root) return
|
|
||||||
await platformService.openPath(join(root, 'mangas')).catch(console.error)
|
export async function openCustomFolder(path: string): Promise<void> {
|
||||||
|
if (!checkSupported()) return
|
||||||
|
if (!path?.trim()) return
|
||||||
|
await platformService.openPath(path).catch(console.error)
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,11 @@ export class TauriAdapter implements PlatformAdapter {
|
|||||||
|
|
||||||
async loadStore(key: string): Promise<unknown> {
|
async loadStore(key: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
return await invoke<unknown>('load_store', { key })
|
const raw = await invoke<unknown>('load_store', { key })
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { return JSON.parse(raw) } catch { return null }
|
||||||
|
}
|
||||||
|
return raw
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Platform } from '$lib/platform-adapters/types'
|
import type { Platform } from '$lib/platform-adapters/types'
|
||||||
|
|
||||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
|
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'locked' | 'ready' | 'error'
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
settingsOpen: boolean = $state(false)
|
settingsOpen: boolean = $state(false)
|
||||||
navPage: string = $state('')
|
navPage: string = $state('')
|
||||||
scrollPositions: Map<string, number> = $state(new Map())
|
scrollPositions: Map<string, number> = $state(new Map())
|
||||||
|
|
||||||
setSettingsOpen(next: boolean) { this.settingsOpen = next }
|
setSettingsOpen(next: boolean) { this.settingsOpen = next }
|
||||||
setNavPage(next: string) { this.navPage = next }
|
setNavPage(next: string) { this.navPage = next }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { initPlatformService } from '$lib/platform-service'
|
|||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
|
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40
|
const MAX_ATTEMPTS = 40
|
||||||
const BG_MAX_ATTEMPTS = 120
|
const BG_MAX_ATTEMPTS = 120
|
||||||
@@ -31,13 +32,21 @@ export async function initPlatform(): Promise<void> {
|
|||||||
appState.appDir = await platformService.getAppDir()
|
appState.appDir = await platformService.getAppDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pinLockEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
settingsState.settings.appLockEnabled === true &&
|
||||||
|
typeof settingsState.settings.appLockPin === 'string' &&
|
||||||
|
settingsState.settings.appLockPin.length >= 4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function handleProbeSuccess(gen: number) {
|
function handleProbeSuccess(gen: number) {
|
||||||
if (gen !== probeGeneration) return
|
if (gen !== probeGeneration) return
|
||||||
boot.failed = false
|
boot.failed = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
appState.authenticated = true
|
appState.authenticated = true
|
||||||
appState.status = 'ready'
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthRequired(
|
function handleAuthRequired(
|
||||||
@@ -144,7 +153,7 @@ export async function submitLogin(): Promise<void> {
|
|||||||
boot.loginError = null
|
boot.loginError = null
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
appState.authenticated = true
|
appState.authenticated = true
|
||||||
appState.status = 'ready'
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
boot.loginError = e instanceof Error ? e.message : 'Login failed'
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import type { Settings } from '$lib/types/settings'
|
|||||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||||
import { saveSettings } from '$lib/core/persistence/persist'
|
import { saveSettings } from '$lib/core/persistence/persist'
|
||||||
|
|
||||||
export const settingsState = $state({ settings: { ...DEFAULT_SETTINGS } as Settings })
|
export const settingsState = $state({
|
||||||
|
settings: { ...DEFAULT_SETTINGS } as Settings,
|
||||||
|
loaded: false,
|
||||||
|
})
|
||||||
|
|
||||||
export async function loadSettingsIntoState(raw: unknown) {
|
export async function loadSettingsIntoState(raw: unknown) {
|
||||||
if (raw && typeof raw === 'object') {
|
if (raw && typeof raw === 'object') {
|
||||||
Object.assign(settingsState.settings, raw)
|
Object.assign(settingsState.settings, raw)
|
||||||
}
|
}
|
||||||
|
settingsState.loaded = true
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
|
||||||
}
|
}
|
||||||
@@ -16,7 +20,6 @@ export async function loadSettingsIntoState(raw: unknown) {
|
|||||||
export function updateSettings(patch: Partial<Settings>) {
|
export function updateSettings(patch: Partial<Settings>) {
|
||||||
Object.assign(settingsState.settings, patch)
|
Object.assign(settingsState.settings, patch)
|
||||||
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
||||||
|
|
||||||
if (typeof document !== 'undefined' && patch.uiZoom !== undefined) {
|
if (typeof document !== 'undefined' && patch.uiZoom !== undefined) {
|
||||||
document.documentElement.style.zoom = String(patch.uiZoom)
|
document.documentElement.style.zoom = String(patch.uiZoom)
|
||||||
}
|
}
|
||||||
@@ -24,5 +27,6 @@ export function updateSettings(patch: Partial<Settings>) {
|
|||||||
|
|
||||||
export function resetSettings() {
|
export function resetSettings() {
|
||||||
settingsState.settings = { ...DEFAULT_SETTINGS }
|
settingsState.settings = { ...DEFAULT_SETTINGS }
|
||||||
|
settingsState.loaded = true
|
||||||
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
|
||||||
}
|
}
|
||||||
+77
-46
@@ -1,22 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
import { appState, app } from '$lib/state/app.svelte'
|
import { appState, app } from '$lib/state/app.svelte'
|
||||||
import { notifications } from '$lib/state/notifications.svelte'
|
import { notifications } from '$lib/state/notifications.svelte'
|
||||||
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import * as discord from '$lib/core/discord'
|
import * as discord from '$lib/core/discord'
|
||||||
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
||||||
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
||||||
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
||||||
import TitleBar from '$lib/components/chrome/TitleBar.svelte'
|
import TitleBar from '$lib/components/chrome/TitleBar.svelte'
|
||||||
import Toaster from '$lib/components/chrome/Toaster.svelte'
|
import Toaster from '$lib/components/chrome/Toaster.svelte'
|
||||||
import Settings from '$lib/components/settings/Settings.svelte'
|
import Settings from '$lib/components/settings/Settings.svelte'
|
||||||
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
|
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
|
||||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||||
import { seriesState } from '$lib/state/series.svelte'
|
import { seriesState } from '$lib/state/series.svelte'
|
||||||
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
@@ -31,14 +31,15 @@
|
|||||||
if (polling) pollTimer = setTimeout(pollLoop, POLL_MS)
|
if (polling) pollTimer = setTimeout(pollLoop, POLL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
const ringFull = $derived(appState.status === 'ready' || appState.status === 'auth')
|
|
||||||
|
|
||||||
let splashVisible = $state(true)
|
let splashVisible = $state(true)
|
||||||
let bypassed = $state(false)
|
let bypassed = $state(false)
|
||||||
let themeEditorOpen = $state(false)
|
let themeEditorOpen = $state(false)
|
||||||
let themeEditorId = $state<string | null>(null)
|
let themeEditorId = $state<string | null>(null)
|
||||||
|
|
||||||
|
const ringFull = $derived(appState.status === 'ready')
|
||||||
|
|
||||||
const showApp = $derived(
|
const showApp = $derived(
|
||||||
appState.status === 'ready' ||
|
appState.status === 'ready' ||
|
||||||
appState.status === 'auth' ||
|
appState.status === 'auth' ||
|
||||||
@@ -50,23 +51,48 @@
|
|||||||
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (isTauri && settingsState.settings.autoStartServer) {
|
const { detectAdapter } = await import('$lib/platform-adapters')
|
||||||
const { startProbe } = await import('$lib/state/boot.svelte')
|
const { initPlatformService } = await import('$lib/platform-service')
|
||||||
|
const { loadSettings } = await import('$lib/core/persistence/persist')
|
||||||
|
const { startProbe } = await import('$lib/state/boot.svelte')
|
||||||
|
|
||||||
|
const adapter = detectAdapter()
|
||||||
|
initPlatformService(adapter)
|
||||||
|
await adapter.init()
|
||||||
|
appState.platform = adapter.platform
|
||||||
|
appState.version = await platformService.getVersion().catch(() => '')
|
||||||
|
appState.appDir = await platformService.getAppDir().catch(() => '')
|
||||||
|
|
||||||
|
const persisted = await loadSettings()
|
||||||
|
const raw = persisted?.settings ?? persisted ?? null
|
||||||
|
await loadSettingsIntoState(raw)
|
||||||
|
|
||||||
|
const s = (raw ?? {}) as Record<string, unknown>
|
||||||
|
appState.serverUrl = (s.serverUrl as string) ?? ''
|
||||||
|
appState.authMode = (s.serverAuthMode as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN') ?? 'NONE'
|
||||||
|
appState.authUser = (s.serverAuthUser as string) ?? ''
|
||||||
|
appState.authPass = (s.serverAuthPass as string) ?? ''
|
||||||
|
|
||||||
|
applyTheme(
|
||||||
|
settingsState.settings.theme ?? 'dark',
|
||||||
|
settingsState.settings.customThemes ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isTauri && settingsState.settings.autoStartServer) {
|
||||||
platformService.launchServer({
|
platformService.launchServer({
|
||||||
binary: settingsState.settings.serverBinary,
|
binary: settingsState.settings.serverBinary,
|
||||||
binaryArgs: settingsState.settings.serverBinaryArgs,
|
binaryArgs: settingsState.settings.serverBinaryArgs,
|
||||||
webUiEnabled: settingsState.settings.suwayomiWebUI,
|
webUiEnabled: settingsState.settings.suwayomiWebUI,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
startProbe(
|
|
||||||
appState.authMode ?? 'NONE',
|
|
||||||
appState.authUser ?? '',
|
|
||||||
appState.authPass ?? '',
|
|
||||||
2000,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startProbe(
|
||||||
|
appState.authMode ?? 'NONE',
|
||||||
|
appState.authUser ?? '',
|
||||||
|
appState.authPass ?? '',
|
||||||
|
isTauri && settingsState.settings.autoStartServer ? 2000 : 100,
|
||||||
|
)
|
||||||
|
|
||||||
if (settingsState.settings.discordRpc) {
|
if (settingsState.settings.discordRpc) {
|
||||||
await discord.initRpc()
|
await discord.initRpc()
|
||||||
await discord.setIdle()
|
await discord.setIdle()
|
||||||
@@ -75,11 +101,6 @@
|
|||||||
polling = true
|
polling = true
|
||||||
pollLoop()
|
pollLoop()
|
||||||
|
|
||||||
applyTheme(
|
|
||||||
settingsState.settings.theme ?? 'dark',
|
|
||||||
settingsState.settings.customThemes ?? []
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
polling = false
|
polling = false
|
||||||
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
|
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
|
||||||
@@ -93,20 +114,27 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const theme = settingsState.settings.theme ?? 'dark'
|
applyTheme(settingsState.settings.theme ?? 'dark', settingsState.settings.customThemes ?? [])
|
||||||
const customThemes = settingsState.settings.customThemes ?? []
|
|
||||||
applyTheme(theme, customThemes)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const enabled = settingsState.settings.systemThemeSync ?? false
|
mountSystemThemeSync(
|
||||||
const darkTheme = settingsState.settings.systemThemeDark ?? 'dark'
|
settingsState.settings.systemThemeSync ?? false,
|
||||||
const lightTheme = settingsState.settings.systemThemeLight ?? 'light'
|
settingsState.settings.systemThemeDark ?? 'dark',
|
||||||
mountSystemThemeSync(enabled, darkTheme, lightTheme, (id) => updateSettings({ theme: id }))
|
settingsState.settings.systemThemeLight ?? 'light',
|
||||||
|
(id) => updateSettings({ theme: id }),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSplashReady() { splashVisible = false }
|
function onSplashReady() { splashVisible = false }
|
||||||
function onSplashBypass() { bypassed = true; splashVisible = false }
|
function onSplashUnlock() { appState.status = 'ready'; splashVisible = false }
|
||||||
|
function onSplashBypass() { bypassed = true; splashVisible = false }
|
||||||
|
|
||||||
|
function onSplashRetry() {
|
||||||
|
import('$lib/state/boot.svelte').then(({ retryBoot }) => {
|
||||||
|
retryBoot(appState.authMode ?? 'NONE', appState.authUser ?? '', appState.authPass ?? '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function openThemeEditor(id?: string | null) {
|
function openThemeEditor(id?: string | null) {
|
||||||
themeEditorId = id ?? null
|
themeEditorId = id ?? null
|
||||||
@@ -116,12 +144,15 @@
|
|||||||
|
|
||||||
{#if splashVisible}
|
{#if splashVisible}
|
||||||
<SplashScreen
|
<SplashScreen
|
||||||
mode="loading"
|
mode={appState.status === 'locked' ? 'locked' : 'loading'}
|
||||||
{ringFull}
|
{ringFull}
|
||||||
failed={appState.status === 'error'}
|
failed={appState.status === 'error'}
|
||||||
|
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
||||||
|
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
||||||
onReady={onSplashReady}
|
onReady={onSplashReady}
|
||||||
|
onUnlock={onSplashUnlock}
|
||||||
onBypass={onSplashBypass}
|
onBypass={onSplashBypass}
|
||||||
onRetry={() => window.location.reload()}
|
onRetry={onSplashRetry}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -215,4 +246,4 @@
|
|||||||
contain: layout style;
|
contain: layout style;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
</style>x
|
</style>
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export const ssr = false
|
export const ssr = false
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
Reference in New Issue
Block a user