Files
mule-image/web/src/routes/+layout.svelte
dtoro da63ad769a feat: bulk action progress — header pill + per-thumbnail states
- 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>
2026-06-07 09:43:12 +02:00

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>