- New bulkAction store: tracks active/label/detail state for the pill and a Map<uid, pending|done|error> for per-tile overlays - Extract StatusPill.svelte from IndexerStatusPill (generic active/label/detail props); IndexerStatusPill becomes a one-line wrapper - +layout.svelte: render a second StatusPill driven by bulkAction store, alongside the indexer pill in the AnimatedMule header - BulkActionBar: extend withBusy with optional BulkConfig (ids/label/doneLabel); pending tiles dim + spinner on start, green checkmark flashes for 400ms before cache invalidation removes them; red overlay on error, auto-clears after 2s - onApprove/batchEdit: wire onProgress callback to setDetail so the pill shows the filename currently being processed during fan-out keep operations - batch.ts: add completedId as third arg to onProgress (backwards-compatible) - PhotoTile: derive bulkState from store; pending/done/error overlays sit above the selection tint; hover-video guarded against pending tiles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
124 lines
4.7 KiB
Svelte
124 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import { onMount } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/state';
|
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
|
import { ModeWatcher } from 'mode-watcher';
|
|
import { Toaster } from 'svelte-sonner';
|
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
|
import { bootstrapSessionFromPhotoPrism } from '$lib/services/photoprism';
|
|
import { setLeftSidebarWidth, view } from '$lib/stores/view.svelte';
|
|
import { resizable } from '$lib/actions/resizable';
|
|
import { queryClient } from '$lib/queryClient';
|
|
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
|
import { bulkAction } from '$lib/stores/bulkAction.svelte';
|
|
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
|
import StatusPill from '$lib/components/layout/StatusPill.svelte';
|
|
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
|
|
|
let { children } = $props();
|
|
|
|
// Bootstrap state: the OIDC return drops the user back on `/` with
|
|
// PhotoPrism's session info written to localStorage under
|
|
// `pp:<storageNamespace>:session.*`, but the SPA store is empty. We
|
|
// try to adopt that session on first mount before the auth guard
|
|
// can punt to /login.
|
|
let bootstrapped = $state(false);
|
|
|
|
onMount(async () => {
|
|
if (!isAuthenticated()) {
|
|
await bootstrapSessionFromPhotoPrism();
|
|
}
|
|
bootstrapped = true;
|
|
});
|
|
|
|
// Auth guard. Anything outside /login requires a session; otherwise
|
|
// punt to the login page (which itself redirects authenticated users
|
|
// back to /). Held until the bootstrap pass has had a chance to run.
|
|
$effect(() => {
|
|
if (!browser || !bootstrapped) return;
|
|
const onLogin = page.url.pathname === '/login';
|
|
if (!isAuthenticated() && !onLogin) {
|
|
void goto('/login', { replaceState: true });
|
|
}
|
|
});
|
|
|
|
// Indexer status feed. The store opens a WS to PhotoPrism's /api/v1/ws
|
|
// channel once we're signed in and tears it down on logout. Kept here
|
|
// (not in the store's top-level) so SvelteKit's SSR pass never touches
|
|
// `WebSocket` and so logout can short-circuit reconnect attempts.
|
|
$effect(() => {
|
|
if (!browser || !bootstrapped) return;
|
|
if (isAuthenticated()) startIndexerWatch();
|
|
else stopIndexerWatch();
|
|
});
|
|
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Mulimage</title>
|
|
</svelte:head>
|
|
|
|
<ModeWatcher />
|
|
<Toaster richColors position="bottom-right" />
|
|
|
|
<QueryClientProvider client={queryClient}>
|
|
{#if isAuthenticated() && page.url.pathname !== '/login'}
|
|
<!-- App shell locks to viewport height; only the main thumbnail
|
|
region inside each page scrolls. The mule header, toolbar, and
|
|
sidebars stay fixed regardless of how far you scroll the grid. -->
|
|
<div class="flex h-screen flex-col overflow-hidden">
|
|
<AnimatedMule>
|
|
<IndexerStatusPill />
|
|
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
|
|
</AnimatedMule>
|
|
<div class="flex min-h-0 flex-1">
|
|
{#if !view.leftSidebarCollapsed}
|
|
<aside
|
|
class="relative hidden h-full shrink-0 border-r border-border bg-card/30 md:block"
|
|
style="width: {view.leftSidebarWidth}px;"
|
|
>
|
|
<!-- LeftSidebar owns its own flex-col layout so its footer
|
|
row (user/theme/settings/logout) can pin to the bottom
|
|
while the nav above scrolls. -->
|
|
<LeftSidebar />
|
|
<div
|
|
class="group absolute -right-1.5 top-0 z-20 hidden h-full w-3 cursor-col-resize md:block"
|
|
use:resizable={{
|
|
edge: 'right',
|
|
getWidth: () => view.leftSidebarWidth,
|
|
setWidth: setLeftSidebarWidth
|
|
}}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="Resize left panel"
|
|
>
|
|
<div
|
|
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
|
></div>
|
|
</div>
|
|
</aside>
|
|
{/if}
|
|
<!-- Right column hosts the route's content. Pages render as
|
|
a flex column whose first child is the Toolbar (shrink-0)
|
|
and whose remaining content takes the rest, so each route
|
|
can decide which of its panes is the scrollable one. -->
|
|
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
{@render children?.()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Single shared preview modal. Reads view.previewOpen +
|
|
selection.focused; any route can pop it via the openPreview
|
|
helper (called by the timeline / PhotoGrid dblclick paths and
|
|
by gridKeyNav's Space handler). -->
|
|
<PreviewModal />
|
|
{:else}
|
|
{@render children?.()}
|
|
{/if}
|
|
</QueryClientProvider>
|