mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-14 18:00:04 -05:00
Fix: Basic Auth Fall-back Management & Settings Drop-down Portal (WIP)
This commit is contained in:
@@ -1,14 +1,15 @@
|
|||||||
<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 { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||||
|
|
||||||
function handleBypass() {
|
function handleBypass() {
|
||||||
bypassBoot(appState.authMode, boot.loginUser)
|
bypassBoot(appState.authMode, boot.loginUser, boot.loginPass)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if appState.status === 'auth'}
|
{#if appState.authRequired && !authVerifiedState.value}
|
||||||
<div class="overlay overlay--clear">
|
<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" />
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
ringFull?: boolean
|
ringFull?: boolean
|
||||||
failed?: boolean
|
failed?: boolean
|
||||||
notConfigured?: boolean
|
notConfigured?: boolean
|
||||||
|
authRequired?: boolean
|
||||||
showCards?: boolean
|
showCards?: boolean
|
||||||
showFps?: boolean
|
showFps?: boolean
|
||||||
showDevOverlay?: boolean
|
showDevOverlay?: boolean
|
||||||
@@ -56,14 +57,15 @@
|
|||||||
onUnlock?: () => void
|
onUnlock?: () => void
|
||||||
onRetry?: () => void
|
onRetry?: () => void
|
||||||
onBypass?: () => void
|
onBypass?: () => void
|
||||||
|
onSkip?: () => void
|
||||||
onDismiss?: () => void
|
onDismiss?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
mode = 'loading', ringFull = false, failed = false,
|
mode = 'loading', ringFull = false, failed = false,
|
||||||
notConfigured = false, showCards = true, showFps = false, showDevOverlay = false,
|
notConfigured = false, authRequired = false, showCards = true, showFps = false, showDevOverlay = false,
|
||||||
pinLen = 4, pinCorrect = '',
|
pinLen = 4, pinCorrect = '',
|
||||||
onReady, onUnlock, onRetry, onBypass, onDismiss,
|
onReady, onUnlock, onRetry, onBypass, onSkip, onDismiss,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
let fpsEl = $state<HTMLSpanElement | undefined>(undefined)
|
||||||
@@ -91,11 +93,10 @@
|
|||||||
const PHASE2_MS = 10000
|
const PHASE2_MS = 10000
|
||||||
|
|
||||||
function triggerExit(cb?: () => void) {
|
function triggerExit(cb?: () => void) {
|
||||||
console.log('[splash] triggerExit called — exitLock:', exitLock, 'mode:', mode, 'cb:', cb?.name ?? String(cb))
|
if (exitLock) return
|
||||||
if (exitLock) { console.log('[splash] triggerExit blocked by exitLock'); return }
|
|
||||||
exitLock = true
|
exitLock = true
|
||||||
exiting = true
|
exiting = true
|
||||||
setTimeout(() => { console.log('[splash] triggerExit timeout — calling cb'); cb?.() }, EXIT_MS)
|
setTimeout(() => cb?.(), EXIT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
let animFrame = 0
|
let animFrame = 0
|
||||||
@@ -126,13 +127,13 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log('[splash] ringFull effect — ringFull:', ringFull, 'mode:', mode, 'exitLock:', exitLock)
|
|
||||||
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
||||||
cancelAnimationFrame(animFrame)
|
cancelAnimationFrame(animFrame)
|
||||||
animFrame = 0
|
animFrame = 0
|
||||||
ringProg = 1
|
ringProg = 1
|
||||||
const t = setTimeout(() => { console.log('[splash] ringFull timeout firing — calling triggerExit(onReady)'); triggerExit(onReady) }, 650)
|
if (authRequired) return
|
||||||
return () => { console.log('[splash] ringFull effect cleanup — cancelling timeout'); clearTimeout(t) }
|
const t = setTimeout(() => triggerExit(onReady), 650)
|
||||||
|
return () => clearTimeout(t)
|
||||||
})
|
})
|
||||||
|
|
||||||
function submitPin() {
|
function submitPin() {
|
||||||
@@ -532,6 +533,13 @@
|
|||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if authRequired && ringFull}
|
||||||
|
<div class="error-box anim-fade-up">
|
||||||
|
<p class="error-label">Waiting for login</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<button class="err-btn" onclick={() => { onSkip?.() }}>Skip</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
const entries = $derived(
|
const entries = $derived(
|
||||||
historyState.sessions
|
historyState.sessions
|
||||||
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
|
||||||
.slice(0, 6)
|
.slice(0, 5)
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
let libraryManga: Manga[] = $state([])
|
let libraryManga: Manga[] = $state([])
|
||||||
|
|
||||||
let ctrl: AbortController | null = null
|
let ctrl: AbortController | null = null
|
||||||
let statusPollTimer: ReturnType<typeof setTimeout> | null = null
|
let statusPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -221,8 +221,11 @@
|
|||||||
try {
|
try {
|
||||||
if (updaterRunning) {
|
if (updaterRunning) {
|
||||||
await getAdapter().stopLibraryUpdate()
|
await getAdapter().stopLibraryUpdate()
|
||||||
|
updaterRunning = false
|
||||||
|
stopStatusPolling()
|
||||||
} else {
|
} else {
|
||||||
await getAdapter().startLibraryUpdate()
|
await getAdapter().startLibraryUpdate()
|
||||||
|
updaterRunning = true
|
||||||
scheduleStatusPoll()
|
scheduleStatusPoll()
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -239,11 +242,13 @@
|
|||||||
{historyConfirmClear}
|
{historyConfirmClear}
|
||||||
hasHistory={historyState.sessions.length > 0}
|
hasHistory={historyState.sessions.length > 0}
|
||||||
{updatesLoading}
|
{updatesLoading}
|
||||||
|
{updaterRunning}
|
||||||
onTabChange={(t) => tab = t}
|
onTabChange={(t) => tab = t}
|
||||||
onHistorySearchChange={(v) => historySearch = v}
|
onHistorySearchChange={(v) => historySearch = v}
|
||||||
onUpdatesSearchChange={(v) => updatesSearch = v}
|
onUpdatesSearchChange={(v) => updatesSearch = v}
|
||||||
onHistoryClear={handleHistoryClear}
|
onHistoryClear={handleHistoryClear}
|
||||||
onRefreshUpdates={() => loadUpdates(true)}
|
onRefreshUpdates={() => loadUpdates(true)}
|
||||||
|
onToggleUpdate={toggleLibraryUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
ArrowsClockwise, BookOpen, CircleNotch,
|
ArrowsClockwise, BookOpen, CircleNotch,
|
||||||
MagnifyingGlass, NewspaperClipping, Trash,
|
MagnifyingGlass, NewspaperClipping, Trash, X,
|
||||||
} from 'phosphor-svelte'
|
} from 'phosphor-svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,18 +11,20 @@
|
|||||||
historyConfirmClear: boolean
|
historyConfirmClear: boolean
|
||||||
hasHistory: boolean
|
hasHistory: boolean
|
||||||
updatesLoading: boolean
|
updatesLoading: boolean
|
||||||
|
updaterRunning: boolean
|
||||||
onTabChange: (tab: 'updates' | 'history') => void
|
onTabChange: (tab: 'updates' | 'history') => void
|
||||||
onHistorySearchChange: (v: string) => void
|
onHistorySearchChange: (v: string) => void
|
||||||
onUpdatesSearchChange: (v: string) => void
|
onUpdatesSearchChange: (v: string) => void
|
||||||
onHistoryClear: () => void
|
onHistoryClear: () => void
|
||||||
onRefreshUpdates: () => void
|
onRefreshUpdates: () => void
|
||||||
|
onToggleUpdate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
||||||
updatesLoading,
|
updatesLoading, updaterRunning,
|
||||||
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
||||||
onHistoryClear, onRefreshUpdates,
|
onHistoryClear, onRefreshUpdates, onToggleUpdate,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -57,12 +59,15 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
onclick={onRefreshUpdates}
|
class:running={updaterRunning}
|
||||||
disabled={updatesLoading}
|
onclick={updaterRunning ? onToggleUpdate : onRefreshUpdates}
|
||||||
title="Reload update list"
|
disabled={updatesLoading && !updaterRunning}
|
||||||
|
title={updaterRunning ? 'Stop library update' : 'Run library update'}
|
||||||
>
|
>
|
||||||
{#if updatesLoading}
|
{#if updatesLoading && !updaterRunning}
|
||||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||||
|
{:else if updaterRunning}
|
||||||
|
<X size={14} weight="bold" />
|
||||||
{:else}
|
{:else}
|
||||||
<ArrowsClockwise size={14} weight="bold" />
|
<ArrowsClockwise size={14} weight="bold" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||||
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
|
||||||
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
|
||||||
|
import { anchorToModal } from '$lib/core/ui/selectPortal'
|
||||||
|
|
||||||
import GeneralSettings from './sections/GeneralSettings.svelte'
|
import GeneralSettings from './sections/GeneralSettings.svelte'
|
||||||
import AppearanceSettings from './sections/AppearanceSettings.svelte'
|
import AppearanceSettings from './sections/AppearanceSettings.svelte'
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
let tabSlideDir = $state<'up'|'down'>('down')
|
let tabSlideDir = $state<'up'|'down'>('down')
|
||||||
let tabIconKey = $state(0)
|
let tabIconKey = $state(0)
|
||||||
let contentBodyEl: HTMLDivElement
|
let contentBodyEl: HTMLDivElement
|
||||||
|
let modalEl: HTMLDivElement
|
||||||
let bugReporterOpen = $state(false)
|
let bugReporterOpen = $state(false)
|
||||||
|
|
||||||
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
let selectOpen: string | null = $state(null)
|
let selectOpen: string | null = $state(null)
|
||||||
let closingSelect: string | null = $state(null)
|
let closingSelect: string | null = $state(null)
|
||||||
const CLOSE_ANIM_MS = 120
|
const CLOSE_ANIM_MS = 120
|
||||||
|
const selectTriggers = new Map<string, HTMLElement>()
|
||||||
|
|
||||||
function closeSelect() {
|
function closeSelect() {
|
||||||
if (!selectOpen) return
|
if (!selectOpen) return
|
||||||
@@ -102,6 +105,14 @@
|
|||||||
else { closingSelect = null; selectOpen = id }
|
else { closingSelect = null; selectOpen = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerTrigger(id: string, el: HTMLElement) {
|
||||||
|
selectTriggers.set(id, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrigger(id: string): HTMLElement | undefined {
|
||||||
|
return selectTriggers.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (!selectOpen) return
|
if (!selectOpen) return
|
||||||
@@ -118,7 +129,7 @@
|
|||||||
<div class="s-backdrop" role="presentation" tabindex="-1"
|
<div class="s-backdrop" role="presentation" tabindex="-1"
|
||||||
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
onclick={(e) => { if (e.target === e.currentTarget) close() }}
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
|
||||||
<div class="s-modal" role="dialog" aria-label="Settings">
|
<div class="s-modal" role="dialog" aria-label="Settings" bind:this={modalEl}>
|
||||||
|
|
||||||
<div class="s-sidebar">
|
<div class="s-sidebar">
|
||||||
<p class="s-sidebar-title">Settings</p>
|
<p class="s-sidebar-title">Settings</p>
|
||||||
@@ -164,13 +175,13 @@
|
|||||||
|
|
||||||
<div class="s-content-body" bind:this={contentBodyEl}>
|
<div class="s-content-body" bind:this={contentBodyEl}>
|
||||||
{#if tab === 'general'}
|
{#if tab === 'general'}
|
||||||
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} {anims} />
|
||||||
{:else if tab === 'appearance'}
|
{:else if tab === 'appearance'}
|
||||||
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
|
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} {anims} {onOpenThemeEditor} />
|
||||||
{:else if tab === 'reader'}
|
{:else if tab === 'reader'}
|
||||||
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} {anims} />
|
||||||
{:else if tab === 'library'}
|
{:else if tab === 'library'}
|
||||||
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
|
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} {anims} />
|
||||||
{:else if tab === 'automation'}
|
{:else if tab === 'automation'}
|
||||||
<AutomationSettings />
|
<AutomationSettings />
|
||||||
{:else if tab === 'performance'}
|
{:else if tab === 'performance'}
|
||||||
@@ -178,13 +189,13 @@
|
|||||||
{:else if tab === 'keybinds'}
|
{:else if tab === 'keybinds'}
|
||||||
<KeybindsSettings bind:listeningKey />
|
<KeybindsSettings bind:listeningKey />
|
||||||
{:else if tab === 'storage'}
|
{:else if tab === 'storage'}
|
||||||
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
|
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} />
|
||||||
{:else if tab === 'folders'}
|
{:else if tab === 'folders'}
|
||||||
<FoldersSettings />
|
<FoldersSettings />
|
||||||
{:else if tab === 'tracking'}
|
{:else if tab === 'tracking'}
|
||||||
<TrackingSettings />
|
<TrackingSettings />
|
||||||
{:else if tab === 'security'}
|
{:else if tab === 'security'}
|
||||||
<SecuritySettings {selectOpen} {toggleSelect} />
|
<SecuritySettings {selectOpen} {toggleSelect} {registerTrigger} {getTrigger} {anchorToModal} {modalEl} />
|
||||||
{:else if tab === 'content'}
|
{:else if tab === 'content'}
|
||||||
<ContentSettings />
|
<ContentSettings />
|
||||||
{:else if tab === 'about'}
|
{:else if tab === 'about'}
|
||||||
|
|||||||
@@ -4,15 +4,22 @@
|
|||||||
|
|
||||||
const isTauri = platformService.platform === 'tauri'
|
const isTauri = platformService.platform === 'tauri'
|
||||||
|
|
||||||
|
import { selectPortal as _defaultPortal } from '$lib/core/ui/selectPortal'
|
||||||
|
import type { Action } from 'svelte/action'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectOpen: string | null
|
selectOpen: string | null
|
||||||
closingSelect: string | null
|
closingSelect: string | null
|
||||||
toggleSelect: (id: string) => void
|
toggleSelect: (id: string) => void
|
||||||
anims: boolean
|
registerTrigger: (id: string, el: HTMLElement) => void
|
||||||
|
getTrigger: (id: string) => HTMLElement | undefined
|
||||||
|
selectPortal: Action<HTMLElement, HTMLElement | undefined>
|
||||||
|
anims: boolean
|
||||||
}
|
}
|
||||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props()
|
let { selectOpen, closingSelect, toggleSelect, registerTrigger, getTrigger, selectPortal, anims }: Props = $props()
|
||||||
|
|
||||||
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
|
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
|
||||||
|
$effect(() => { if (triggerIdleTimeout) registerTrigger('idle-timeout', triggerIdleTimeout) })
|
||||||
let serverAdvancedOpen = $state(false)
|
let serverAdvancedOpen = $state(false)
|
||||||
|
|
||||||
async function pickServerBinary() {
|
async function pickServerBinary() {
|
||||||
@@ -138,7 +145,7 @@
|
|||||||
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||||
</button>
|
</button>
|
||||||
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
|
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
|
||||||
<div class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
|
<div use:selectPortal={getTrigger('idle-timeout')} class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
|
||||||
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
|
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
|
||||||
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
|
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
import { getBlobUrl } from "$lib/core/cache/imageCache";
|
import { getBlobUrl } from "$lib/core/cache/imageCache";
|
||||||
|
import { appState } from "$lib/state/app.svelte";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
src,
|
src,
|
||||||
@@ -17,8 +20,8 @@
|
|||||||
id?: string | number;
|
id?: string | number;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
loading?: string;
|
loading?: "lazy" | "eager";
|
||||||
decoding?: string;
|
decoding?: "async" | "auto" | "sync";
|
||||||
priority?: number;
|
priority?: number;
|
||||||
onerror?: ((e: Event) => void) | undefined;
|
onerror?: ((e: Event) => void) | undefined;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
return withBust(base);
|
return withBust(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
const isAuth = $derived(appState.authMode !== "NONE");
|
||||||
|
|
||||||
let blobUrl = $state("");
|
let blobUrl = $state("");
|
||||||
let reqId = 0;
|
let reqId = 0;
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const plainUrl = $derived(plainThumbUrl(src));
|
const plainUrl = $derived(plainThumbUrl(src));
|
||||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
const resolved = $derived(isAuth ? (blobUrl || undefined) : (plainUrl || undefined));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||||
+56
-23
@@ -1,3 +1,7 @@
|
|||||||
|
import { appState } from '$lib/state/app.svelte'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
|
import { LOGIN_MUTATION, REFRESH_MUTATION } from '$lib/server-adapters/suwayomi/meta'
|
||||||
|
|
||||||
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
||||||
const SKEW_MS = 60_000 * 2
|
const SKEW_MS = 60_000 * 2
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ let accessToken: string | null = null
|
|||||||
let refreshToken: string | null = null
|
let refreshToken: string | null = null
|
||||||
let accessExpiresAt: number | null = null
|
let accessExpiresAt: number | null = null
|
||||||
let refreshInFlight = false
|
let refreshInFlight = false
|
||||||
|
let authSnoozed = false
|
||||||
|
|
||||||
function parseExpiry(token: string): number | null {
|
function parseExpiry(token: string): number | null {
|
||||||
try {
|
try {
|
||||||
@@ -56,16 +61,34 @@ export function getUiAuthDebugStatus(): UiAuthDebugStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reportUnauthorized(): void {
|
||||||
|
if (config.mode === 'NONE') return
|
||||||
|
if (authSnoozed) return
|
||||||
|
appState.authRequired = true
|
||||||
|
authVerifiedState.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportAuthOk(): void {
|
||||||
|
appState.authRequired = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snoozeAuthPrompt(): void {
|
||||||
|
authSnoozed = true
|
||||||
|
appState.authRequired = false
|
||||||
|
}
|
||||||
|
|
||||||
export function configureAuth(
|
export function configureAuth(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||||
user?: string,
|
user?: string,
|
||||||
pass?: string,
|
pass?: string,
|
||||||
): void {
|
): void {
|
||||||
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
||||||
accessToken = null
|
accessToken = null
|
||||||
refreshToken = null
|
refreshToken = null
|
||||||
accessExpiresAt = null
|
accessExpiresAt = null
|
||||||
|
authSnoozed = false
|
||||||
|
appState.authRequired = false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authHeaders(): Record<string, string> {
|
export function authHeaders(): Record<string, string> {
|
||||||
@@ -86,9 +109,16 @@ async function gql<T>(query: string, variables?: Record<string, unknown>, bare =
|
|||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
reportUnauthorized()
|
||||||
|
throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
return json.data as T
|
return json.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,34 +139,33 @@ export async function probeServer(): Promise<'ok' | 'auth_required' | 'unreachab
|
|||||||
} catch { return 'unreachable' }
|
} catch { return 'unreachable' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGIN_MUTATION = `
|
export function loginBasic(user: string, pass: string): void {
|
||||||
mutation Login($username: String!, $password: String!) {
|
config.user = user
|
||||||
login(input: { username: $username, password: $password }) {
|
config.pass = pass
|
||||||
accessToken refreshToken
|
config.mode = 'BASIC_AUTH'
|
||||||
}
|
authSnoozed = false
|
||||||
}
|
reportAuthOk()
|
||||||
`
|
}
|
||||||
|
|
||||||
const REFRESH_MUTATION = `
|
/**
|
||||||
mutation RefreshToken($refreshToken: String!) {
|
* Verify basic-auth credentials by making a real GQL request with them.
|
||||||
refreshToken(input: { refreshToken: $refreshToken }) {
|
* Throws if the server returns 401/403 or an auth error.
|
||||||
accessToken
|
*/
|
||||||
}
|
export async function verifyBasicAuth(user: string, pass: string): Promise<void> {
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
|
||||||
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
const prev = { user: config.user, pass: config.pass, mode: config.mode }
|
||||||
config.user = user
|
config.user = user
|
||||||
config.pass = pass
|
config.pass = pass
|
||||||
config.mode = 'BASIC_AUTH'
|
config.mode = 'BASIC_AUTH'
|
||||||
const probe = await probeServer()
|
try {
|
||||||
if (probe !== 'ok') {
|
await gql<unknown>('{ settings { authMode } }')
|
||||||
|
} catch {
|
||||||
config.user = prev.user
|
config.user = prev.user
|
||||||
config.pass = prev.pass
|
config.pass = prev.pass
|
||||||
config.mode = prev.mode as typeof config.mode
|
config.mode = prev.mode as typeof config.mode
|
||||||
throw new Error('Invalid credentials')
|
throw new Error('Invalid credentials')
|
||||||
}
|
}
|
||||||
|
authSnoozed = false
|
||||||
|
reportAuthOk()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginUI(user: string, pass: string): Promise<void> {
|
export async function loginUI(user: string, pass: string): Promise<void> {
|
||||||
@@ -148,6 +177,8 @@ export async function loginUI(user: string, pass: string): Promise<void> {
|
|||||||
accessExpiresAt = parseExpiry(accessToken)
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
config.mode = 'UI_LOGIN'
|
config.mode = 'UI_LOGIN'
|
||||||
config.user = user
|
config.user = user
|
||||||
|
authSnoozed = false
|
||||||
|
reportAuthOk()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
export async function refreshUiAccessToken(force = false): Promise<string | null> {
|
||||||
@@ -163,8 +194,10 @@ export async function refreshUiAccessToken(force = false): Promise<string | null
|
|||||||
)
|
)
|
||||||
accessToken = data.refreshToken.accessToken
|
accessToken = data.refreshToken.accessToken
|
||||||
accessExpiresAt = parseExpiry(accessToken)
|
accessExpiresAt = parseExpiry(accessToken)
|
||||||
|
reportAuthOk()
|
||||||
return accessToken
|
return accessToken
|
||||||
} catch {
|
} catch {
|
||||||
|
reportUnauthorized()
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false
|
refreshInFlight = false
|
||||||
|
|||||||
Vendored
+3
-18
@@ -1,6 +1,5 @@
|
|||||||
import { platformService } from "$lib/platform-service";
|
import { platformService } from "$lib/platform-service";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { authHeaders } from "$lib/core/auth";
|
||||||
import { getUIAccessToken } from "$lib/core/auth";
|
|
||||||
|
|
||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
@@ -18,22 +17,8 @@ interface QueueEntry {
|
|||||||
|
|
||||||
const queue: QueueEntry[] = [];
|
const queue: QueueEntry[] = [];
|
||||||
|
|
||||||
async function getAuthHeaders(): Promise<Record<string, string>> {
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "UI_LOGIN") {
|
|
||||||
const token = getUIAccessToken();
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
||||||
}
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const user = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const pass = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doFetch(url: string, gen: number): Promise<string> {
|
async function doFetch(url: string, gen: number): Promise<string> {
|
||||||
const headers = await getAuthHeaders();
|
const headers = authHeaders();
|
||||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
const blob = await platformService.fetchImage(url, headers);
|
const blob = await platformService.fetchImage(url, headers);
|
||||||
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
if (gen !== generation) throw new DOMException("Cancelled", "AbortError");
|
||||||
|
|||||||
Vendored
+3
-8
@@ -1,5 +1,6 @@
|
|||||||
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
import { getBlobUrl, preloadBlobUrls, revokeBlobUrl } from "$lib/core/cache/imageCache";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
import { authHeaders } from "$lib/core/auth";
|
||||||
|
import { settingsState } from "$lib/state/settings.svelte";
|
||||||
|
|
||||||
const pageCache = new Map<number, string[]>();
|
const pageCache = new Map<number, string[]>();
|
||||||
const inflight = new Map<number, Promise<string[]>>();
|
const inflight = new Map<number, Promise<string[]>>();
|
||||||
@@ -12,13 +13,7 @@ function getServerUrl(): string {
|
|||||||
|
|
||||||
async function fetchChapterPagesFromServer(chapterId: number): Promise<string[]> {
|
async function fetchChapterPagesFromServer(chapterId: number): Promise<string[]> {
|
||||||
const base = getServerUrl();
|
const base = getServerUrl();
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json", ...authHeaders() };
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
const query = `mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } }`;
|
const query = `mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } }`;
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
const res = await fetch(`${base}/api/graphql`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* position:fixed dropdown anchored to a trigger element.
|
||||||
|
*
|
||||||
|
* getBoundingClientRect() returns full viewport coords.
|
||||||
|
* position:fixed is also relative to the viewport.
|
||||||
|
* So we just divide by zoom — no sidebar/titlebar subtraction needed
|
||||||
|
* (those subtractions are only needed in ContextMenu because its x/y come
|
||||||
|
* from a MouseEvent which is relative to the zoomed content area, not the viewport).
|
||||||
|
*/
|
||||||
|
export function selectPortal(
|
||||||
|
node: HTMLElement,
|
||||||
|
trigger: HTMLElement | undefined,
|
||||||
|
): { update(t: HTMLElement | undefined): void; destroy(): void } {
|
||||||
|
let currentTrigger = trigger
|
||||||
|
|
||||||
|
node.style.visibility = 'hidden'
|
||||||
|
|
||||||
|
function getZoom(): number {
|
||||||
|
const raw = parseFloat(document.documentElement.style.zoom || '1') || 1
|
||||||
|
return raw > 10 ? raw / 100 : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function position() {
|
||||||
|
if (!currentTrigger) return
|
||||||
|
|
||||||
|
const zoom = getZoom()
|
||||||
|
const r = currentTrigger.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Convert viewport px → CSS px by dividing by zoom
|
||||||
|
const left = r.left / zoom
|
||||||
|
const top = r.top / zoom
|
||||||
|
const bottom = r.bottom / zoom
|
||||||
|
const width = r.width / zoom
|
||||||
|
|
||||||
|
const vw = window.innerWidth / zoom
|
||||||
|
const vh = window.innerHeight / zoom
|
||||||
|
|
||||||
|
const menuH = node.offsetHeight
|
||||||
|
const menuW = node.offsetWidth
|
||||||
|
|
||||||
|
const above = menuH > 0 && (vh - bottom) < menuH + 8 && top > menuH + 8
|
||||||
|
|
||||||
|
const cssLeft = Math.min(left, vw - menuW - 4)
|
||||||
|
const cssTop = above ? top - menuH - 4 : bottom + 4
|
||||||
|
|
||||||
|
node.style.left = `${Math.max(4, cssLeft)}px`
|
||||||
|
node.style.top = `${cssTop}px`
|
||||||
|
node.style.minWidth = `${width}px`
|
||||||
|
node.style.visibility = 'visible'
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => position())
|
||||||
|
window.addEventListener('scroll', position, { capture: true, passive: true })
|
||||||
|
window.addEventListener('resize', position, { passive: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(t) {
|
||||||
|
currentTrigger = t
|
||||||
|
node.style.visibility = 'hidden'
|
||||||
|
requestAnimationFrame(() => position())
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('scroll', position, true)
|
||||||
|
window.removeEventListener('resize', position)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ import {
|
|||||||
RESTORE_BACKUP,
|
RESTORE_BACKUP,
|
||||||
VALIDATE_BACKUP,
|
VALIDATE_BACKUP,
|
||||||
} from './meta'
|
} from './meta'
|
||||||
import { authHeaders } from '$lib/core/auth'
|
import { authHeaders, reportUnauthorized } from '$lib/core/auth'
|
||||||
import {
|
import {
|
||||||
type GQLResponse,
|
type GQLResponse,
|
||||||
mapManga,
|
mapManga,
|
||||||
@@ -171,9 +171,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async gql<T>(
|
private async gql<T>(
|
||||||
query: string,
|
query: string,
|
||||||
variables?: Record<string, unknown>,
|
variables?: Record<string, unknown>,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -181,12 +181,40 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
reportUnauthorized()
|
||||||
|
throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||||
const json: GQLResponse<T> = await res.json()
|
const json: GQLResponse<T> = await res.json()
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
return json.data
|
return json.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private multipartGql<T>(query: string, file: File): Promise<T> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
||||||
|
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||||
|
form.append('0', file, file.name)
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
||||||
|
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
||||||
|
.then(r => {
|
||||||
|
if (r.status === 401 || r.status === 403) { reportUnauthorized(); throw new Error(`Suwayomi HTTP ${r.status}`) }
|
||||||
|
if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
})
|
||||||
|
.then((json: GQLResponse<T>) => {
|
||||||
|
if (json.errors?.length) {
|
||||||
|
if (/unauthorized|unauthenticated/i.test(json.errors[0].message)) reportUnauthorized()
|
||||||
|
throw new Error(json.errors[0].message)
|
||||||
|
}
|
||||||
|
return json.data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async getAboutServer(): Promise<AboutServer> {
|
async getAboutServer(): Promise<AboutServer> {
|
||||||
const data = await this.gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER)
|
const data = await this.gql<{ aboutServer: AboutServer }>(GET_ABOUT_SERVER)
|
||||||
return data.aboutServer
|
return data.aboutServer
|
||||||
@@ -502,7 +530,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
ids: number[],
|
ids: number[],
|
||||||
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
|
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Suwayomi has no bulk-category-patch mutation; fan out individually.
|
|
||||||
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
|
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,17 +673,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return data.createBackup
|
return data.createBackup
|
||||||
}
|
}
|
||||||
|
|
||||||
private multipartGql<T>(query: string, file: File): Promise<T> {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append('operations', JSON.stringify({ query, variables: { backup: null } }))
|
|
||||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
|
||||||
form.append('0', file, file.name)
|
|
||||||
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() }
|
|
||||||
return fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers, body: form })
|
|
||||||
.then(r => { if (!r.ok) throw new Error(`Suwayomi HTTP ${r.status}`); return r.json() })
|
|
||||||
.then((json: GQLResponse<T>) => { if (json.errors?.length) throw new Error(json.errors[0].message); return json.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
async restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }> {
|
||||||
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
const data = await this.multipartGql<{ restoreBackup: { id: string; status: RestoreStatus } }>(RESTORE_BACKUP, file)
|
||||||
return data.restoreBackup
|
return data.restoreBackup
|
||||||
|
|||||||
@@ -102,4 +102,20 @@ export const SET_FLARE_SOLVERR = `
|
|||||||
settings { flareSolverrEnabled flareSolverrUrl }
|
settings { flareSolverrEnabled flareSolverrUrl }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const LOGIN_MUTATION = `
|
||||||
|
mutation Login($username: String!, $password: String!) {
|
||||||
|
login(input: { username: $username, password: $password }) {
|
||||||
|
accessToken refreshToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const REFRESH_MUTATION = `
|
||||||
|
mutation RefreshToken($refreshToken: String!) {
|
||||||
|
refreshToken(input: { refreshToken: $refreshToken }) {
|
||||||
|
accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
`
|
`
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Platform } from '$lib/platform-adapters/types'
|
import type { Platform } from '$lib/platform-adapters/types'
|
||||||
|
|
||||||
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'locked' | 'ready' | 'error'
|
export type AppStatus = 'booting' | 'not-configured' | 'locked' | 'ready' | 'error'
|
||||||
|
|
||||||
class AppStore {
|
class AppStore {
|
||||||
settingsOpen: boolean = $state(false)
|
settingsOpen: boolean = $state(false)
|
||||||
@@ -23,6 +23,7 @@ export const app = new AppStore()
|
|||||||
|
|
||||||
export const appState = $state({
|
export const appState = $state({
|
||||||
status: 'booting' as AppStatus,
|
status: 'booting' as AppStatus,
|
||||||
|
authRequired: false as boolean,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
serverUrl: '',
|
serverUrl: '',
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const authVerifiedState = $state({ value: false })
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { detectAdapter } from '$lib/platform-adapters'
|
import { detectAdapter } from '$lib/platform-adapters'
|
||||||
import { initPlatformService } from '$lib/platform-service'
|
import { initPlatformService } from '$lib/platform-service'
|
||||||
import { platformService } from '$lib/platform-service'
|
import { platformService } from '$lib/platform-service'
|
||||||
import { probeServer, loginBasic, loginUI, configureAuth } from '$lib/core/auth'
|
import { probeServer, loginBasic, loginUI, verifyBasicAuth, configureAuth } from '$lib/core/auth'
|
||||||
|
import { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import { appState } from '$lib/state/app.svelte'
|
import { appState } from '$lib/state/app.svelte'
|
||||||
import { settingsState } from '$lib/state/settings.svelte'
|
import { settingsState } from '$lib/state/settings.svelte'
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 40
|
const MAX_ATTEMPTS = 40
|
||||||
const WEB_MAX_ATTEMPTS = 1
|
const WEB_MAX_ATTEMPTS = 1
|
||||||
const BG_MAX_ATTEMPTS = 120
|
const BG_MAX_ATTEMPTS = 120
|
||||||
|
|
||||||
export const boot = $state({
|
export const boot = $state({
|
||||||
failed: false,
|
failed: false,
|
||||||
@@ -40,11 +41,12 @@ function pinLockEnabled(): boolean {
|
|||||||
|
|
||||||
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
|
authVerifiedState.value = true
|
||||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
appState.authenticated = true
|
||||||
|
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthRequired(
|
function handleAuthRequired(
|
||||||
@@ -59,20 +61,17 @@ function handleAuthRequired(
|
|||||||
appState.authMode = authMode
|
appState.authMode = authMode
|
||||||
|
|
||||||
if (authMode === 'BASIC_AUTH' && user && pass) {
|
if (authMode === 'BASIC_AUTH' && user && pass) {
|
||||||
|
// Saved creds — set optimistically; a real 401 will re-prompt via reportUnauthorized
|
||||||
loginBasic(user, pass)
|
loginBasic(user, pass)
|
||||||
.then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) })
|
handleProbeSuccess(gen)
|
||||||
.catch(() => {
|
|
||||||
if (gen !== probeGeneration) return
|
|
||||||
boot.loginUser = user
|
|
||||||
boot.loginRequired = true
|
|
||||||
appState.status = 'auth'
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
boot.loginUser = user
|
boot.loginUser = user
|
||||||
boot.loginRequired = true
|
boot.loginRequired = true
|
||||||
appState.status = 'auth'
|
authVerifiedState.value = false
|
||||||
|
appState.authRequired = true
|
||||||
|
appState.status = 'ready' // let layout render, AuthGate overlay will block
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startProbe(
|
export async function startProbe(
|
||||||
@@ -82,12 +81,13 @@ export async function startProbe(
|
|||||||
initialDelay = 100,
|
initialDelay = 100,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const gen = ++probeGeneration
|
const gen = ++probeGeneration
|
||||||
boot.failed = false
|
boot.failed = false
|
||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.serverProbeOk = false
|
boot.serverProbeOk = false
|
||||||
appState.status = 'booting'
|
authVerifiedState.value = false
|
||||||
appState.authMode = authMode
|
appState.status = 'booting'
|
||||||
|
appState.authMode = authMode
|
||||||
|
|
||||||
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
|
||||||
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
|
configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
|
||||||
@@ -150,16 +150,18 @@ export async function submitLogin(): Promise<void> {
|
|||||||
if (appState.authMode === 'UI_LOGIN') {
|
if (appState.authMode === 'UI_LOGIN') {
|
||||||
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
|
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
|
||||||
} else {
|
} else {
|
||||||
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim())
|
await verifyBasicAuth(boot.loginUser.trim(), boot.loginPass.trim())
|
||||||
}
|
}
|
||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.sessionExpired = false
|
boot.sessionExpired = false
|
||||||
boot.skipped = false
|
boot.skipped = false
|
||||||
boot.loginPass = ''
|
boot.loginPass = ''
|
||||||
boot.loginError = null
|
boot.loginError = null
|
||||||
boot.serverProbeOk = true
|
boot.serverProbeOk = true
|
||||||
appState.authenticated = true
|
authVerifiedState.value = true
|
||||||
appState.status = pinLockEnabled() ? 'locked' : 'ready'
|
appState.authenticated = true
|
||||||
|
appState.authRequired = false
|
||||||
|
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 {
|
||||||
@@ -184,9 +186,11 @@ export function bypassBoot(
|
|||||||
user = '',
|
user = '',
|
||||||
pass = '',
|
pass = '',
|
||||||
) {
|
) {
|
||||||
boot.loginRequired = false
|
boot.loginRequired = false
|
||||||
boot.sessionExpired = false
|
boot.sessionExpired = false
|
||||||
boot.skipped = true
|
boot.skipped = true
|
||||||
appState.status = 'ready'
|
authVerifiedState.value = true // user explicitly opted out of the auth gate
|
||||||
|
appState.authRequired = false
|
||||||
|
appState.status = 'ready'
|
||||||
startBackgroundProbe(probeGeneration, authMode, user, pass)
|
startBackgroundProbe(probeGeneration, authMode, user, pass)
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
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 { authVerifiedState } from '$lib/state/auth.svelte'
|
||||||
import '../app.css'
|
import '../app.css'
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
@@ -45,7 +46,6 @@
|
|||||||
appState.status === 'booting' ||
|
appState.status === 'booting' ||
|
||||||
appState.status === 'locked' ||
|
appState.status === 'locked' ||
|
||||||
appState.status === 'error' ||
|
appState.status === 'error' ||
|
||||||
appState.status === 'auth' ||
|
|
||||||
(appState.status === 'ready' && !splashDismissed)
|
(appState.status === 'ready' && !splashDismissed)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
const ringFull = $derived(appState.status === 'ready')
|
const ringFull = $derived(appState.status === 'ready')
|
||||||
const showApp = $derived(!splashVisible)
|
const showApp = $derived(!splashVisible)
|
||||||
|
|
||||||
function onSplashReady() { splashDismissed = true }
|
function onSplashReady() { if (!appState.authRequired || authVerifiedState.value) splashDismissed = true }
|
||||||
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
function onSplashUnlock() { appState.status = 'ready'; splashDismissed = true }
|
||||||
function onSplashBypass() {
|
function onSplashBypass() {
|
||||||
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
import('$lib/state/boot.svelte').then(({ bypassBoot }) => {
|
||||||
@@ -208,11 +208,13 @@
|
|||||||
{ringFull}
|
{ringFull}
|
||||||
failed={appState.status === 'error'}
|
failed={appState.status === 'error'}
|
||||||
notConfigured={boot.notConfigured}
|
notConfigured={boot.notConfigured}
|
||||||
|
authRequired={appState.authRequired && !authVerifiedState.value}
|
||||||
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
pinLen={settingsState.settings.appLockPin?.length ?? 0}
|
||||||
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
pinCorrect={settingsState.settings.appLockPin ?? ''}
|
||||||
onReady={onSplashReady}
|
onReady={onSplashReady}
|
||||||
onUnlock={onSplashUnlock}
|
onUnlock={onSplashUnlock}
|
||||||
onBypass={onSplashBypass}
|
onBypass={onSplashBypass}
|
||||||
|
onSkip={onSplashBypass}
|
||||||
onRetry={onSplashRetry}
|
onRetry={onSplashRetry}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user