web: single-pointer selection + post-action focus, video debounce, tags metadata sidebar
Selection: plain arrow nav now clears prior multi-selection so exactly one tile is ringed at a time; shift-extend still grows from the anchor. onApprove / onRestore / onDelete advance focus via focusAfter(ids) before clearing selection, matching onArchive. Preview: defer mounting <VideoPlayer> by 250ms so arrow-skim across video tiles doesn't open and immediately cancel range requests; hard- abort the underlying <video> on unmount so the connection releases. Tags drill view: right-sidebar metadata wired in (single-photo RightSidebar, BulkMetadataSidebar for >=2 selected), resizable edge mirrors the timeline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
clearSelection,
|
||||
isSelected,
|
||||
selectRange,
|
||||
selection,
|
||||
@@ -570,6 +571,10 @@
|
||||
const destUid = rows[r]?.uids[c];
|
||||
if (!destUid) return;
|
||||
|
||||
// Plain arrow nav collapses any prior multi-selection to the cursor
|
||||
// (mirrors gridKeyNav.moveFocus). Shift-extend keeps `ids` growing
|
||||
// from the anchor below.
|
||||
if (!extending) clearSelection();
|
||||
setFocused(destUid);
|
||||
if (!extending) setAnchor(destUid);
|
||||
if (extending && selection.focused) selectRange(destUid);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
getAllMarks,
|
||||
getPhoto,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
type AggregatedKeyword,
|
||||
@@ -13,13 +14,16 @@
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, session, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import InlinePreview from '$lib/components/preview/InlinePreview.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||
import SplitGrid from '$lib/components/preview/SplitGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
@@ -295,6 +299,17 @@
|
||||
: drillPhotosQuery.data ?? []
|
||||
);
|
||||
|
||||
// Metadata for the focused tile in the drill-in PhotoGrid. Mirrors the
|
||||
// timeline (+page.svelte) and /review wiring so the right sidebar
|
||||
// reads consistently across views.
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () =>
|
||||
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||
staleTime: 0
|
||||
}));
|
||||
|
||||
function starLabel(rating: number): string {
|
||||
return '★'.repeat(rating);
|
||||
}
|
||||
@@ -319,7 +334,7 @@
|
||||
const drillCount = $derived<number>(drillPhotos.length);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<Toolbar showRightToggle={Boolean(drillKey)}>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
@@ -392,30 +407,74 @@
|
||||
</Toolbar>
|
||||
|
||||
{#if drillKey}
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<SplitGrid>
|
||||
{#snippet preview()}
|
||||
<InlinePreview uid={selection.focused} order={selection.order} />
|
||||
{/snippet}
|
||||
{#snippet grid()}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL
|
||||
drives labels/keywords while ratings/colors resolve locally
|
||||
from the marks pool already in cache. -->
|
||||
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
||||
{:else if drillPhotos.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL
|
||||
drives labels/keywords while ratings/colors resolve locally
|
||||
from the marks pool already in cache. -->
|
||||
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
|
||||
<SkeletonGrid />
|
||||
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
||||
{:else if drillPhotos.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
</main>
|
||||
{/snippet}
|
||||
</SplitGrid>
|
||||
<div class="h-full overflow-y-auto">
|
||||
{#if selection.ids.size >= 2}
|
||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||
{:else if focusedPhotoQuery.data}
|
||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
{:else}
|
||||
<div class="space-y-2 p-4 text-center">
|
||||
<div class="text-xl">ⓘ</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click
|
||||
on a thumbnail to view its metadata here.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||
use:resizable={{
|
||||
edge: 'left',
|
||||
getWidth: () => view.rightSidebarWidth,
|
||||
setWidth: setRightSidebarWidth
|
||||
}}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize info panel"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||
></div>
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
|
||||
Reference in New Issue
Block a user