@@ -1,54 +1,61 @@
< script lang = "ts" >
< script lang = "ts" >
import { browser } from ' $app/environment' ;
import { browser } from " $app/environment" ;
import { goto } from ' $app/navigation' ;
import { goto } from " $app/navigation" ;
import { page } from ' $app/state' ;
import { page } from " $app/state" ;
import { createInfiniteQuery , createQuery , useQueryClient } from '@tanstack/svelte-query' ;
import {
import { toast } from 'svelte-sonn er' ;
createInfiniteQu ery ,
createQuery ,
useQueryClient ,
} from "@tanstack/svelte-query" ;
import { toast } from "svelte-sonner" ;
import {
import {
batchDelete ,
batchDelete ,
getPhoto ,
getPhoto ,
listHeaps ,
listHeaps ,
listPhotos ,
listPhotos ,
type PpAlbum
type PpAlbum ,
} from ' $lib/services/photoprism' ;
} from " $lib/services/photoprism" ;
import {
import {
filters ,
filters ,
filtersToQ ,
filtersToQ ,
filtersToUrlParams ,
filtersToUrlParams ,
parseUrlParams ,
parseUrlParams ,
setSearch ,
setSearch ,
setSection
setSection ,
} from ' $lib/stores/filters.svelte' ;
} from " $lib/stores/filters.svelte" ;
import { isAuthenticated } from ' $lib/stores/session.svelte' ;
import { isAuthenticated } from " $lib/stores/session.svelte" ;
import { untrack } from ' svelte' ;
import { untrack } from " svelte" ;
import {
import {
isSelected ,
isSelected ,
selectRange ,
selectRange ,
selection ,
selection ,
setAnchor ,
setAnchor ,
setFocused ,
setFocused ,
setOrder
setOrder ,
} from ' $lib/stores/selection.svelte' ;
} from " $lib/stores/selection.svelte" ;
import { openPreview , preview } from ' $lib/stores/preview.svelte' ;
import { openPreview , preview } from " $lib/stores/preview.svelte" ;
import {
import {
setRightSidebarWidth ,
setRightSidebarWidth ,
setThumbnailSize ,
setThumbnailSize ,
THUMBNAIL_SIZE_LABELS ,
THUMBNAIL_SIZE_LABELS ,
THUMBNAIL_SIZE_PRESETS ,
THUMBNAIL_SIZE_PRESETS ,
view
view ,
} from ' $lib/stores/view.svelte' ;
} from " $lib/stores/view.svelte" ;
import { tick } from ' svelte' ;
import { tick } from " svelte" ;
import { resizable } from ' $lib/actions/resizable' ;
import { resizable } from " $lib/actions/resizable" ;
import { gridKeyNav , type ArrowKey } from ' $lib/actions/gridKeyNav' ;
import { gridKeyNav , type ArrowKey } from " $lib/actions/gridKeyNav" ;
import { nearBottom } from ' $lib/actions/nearBottom' ;
import { nearBottom } from " $lib/actions/nearBottom" ;
import { visibleRange , getVisibleRangeHandle } from '$lib/actions/visibleRange' ;
import {
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte' ;
visibleRange ,
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte' ;
getVisibleRangeHandle ,
import PhotoTile from ' $lib/components/timeline/PhotoTile.svelte' ;
} from " $lib/actions/visibleRange" ;
import RightSideb ar from ' $lib/components/sidebar/RightSideb ar.svelte' ;
import BulkActionB ar from " $lib/components/timeline/BulkActionB ar.svelte" ;
import SkeletonGrid from ' $lib/components/timeline/SkeletonGrid .svelte' ;
import BulkMetadataSidebar from " $lib/components/sidebar/BulkMetadataSidebar .svelte" ;
import Toolbar from ' $lib/components/layout/Toolbar .svelte' ;
import PhotoTile from " $lib/components/timeline/PhotoTile .svelte" ;
import { type PpPhoto } from ' $lib/types/photoprism' ;
import RightSidebar from " $lib/components/sidebar/RightSidebar.svelte" ;
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte" ;
import Toolbar from "$lib/components/layout/Toolbar.svelte" ;
import { type PpPhoto } from "$lib/types/photoprism" ;
// ── URL ↔ filter store sync ──────────────────────────────────────────────
// ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store.
// On nav (back/forward, deep link), reflect the URL into the store.
@@ -63,41 +70,43 @@
// When the store changes from in-app actions (left-sidebar nav, search
// When the store changes from in-app actions (left-sidebar nav, search
// box, etc.), push the matching query string back so the URL is shareable.
// box, etc.), push the matching query string back so the URL is shareable.
let lastWritten = $state ( '' );
let lastWritten = $state ( "" );
$effect (() => {
$effect (() => {
if ( ! browser ) return ;
if ( ! browser ) return ;
const qs = filtersToUrlParams (). toString ();
const qs = filtersToUrlParams (). toString ();
const here = page . url . searchParams . toString ();
const here = page . url . searchParams . toString ();
if ( qs === here || qs === lastWritten ) return ;
if ( qs === here || qs === lastWritten ) return ;
lastWritten = qs ;
lastWritten = qs ;
void goto ( `/ ${ qs ? '?' + qs : '' } ` , {
void goto ( `/ ${ qs ? "?" + qs : "" } ` , {
replaceState : true ,
replaceState : true ,
keepFocus : true ,
keepFocus : true ,
noScroll : true
noScroll : true ,
});
});
});
});
// ── Section title ────────────────────────────────────────────────────────
// ── Section title ────────────────────────────────────────────────────────
const heapsQuery = createQuery < PpAlbum [] >(() => ({
const heapsQuery = createQuery < PpAlbum [] >(() => ({
queryKey : [ ' heaps' ],
queryKey : [ " heaps" ],
queryFn : listHeaps ,
queryFn : listHeaps ,
enabled : isAuthenticated ()
enabled : isAuthenticated (),
}));
}));
const sectionLabel = $derived ( buildSectionLabel ());
const sectionLabel = $derived ( buildSectionLabel ());
function buildSectionLabel () : string {
function buildSectionLabel () : string {
switch ( filters . section ) {
switch ( filters . section ) {
case ' favorites' :
case " favorites" :
return ' Favorites' ;
return " Favorites" ;
case ' review' :
case " review" :
return ' Review' ;
return " Review" ;
case ' archive' :
case " archive" :
return ' Archive' ;
return " Archive" ;
case ' hidden' :
case " hidden" :
return ' Hidden' ;
return " Hidden" ;
case ' heap' : {
case " heap" : {
const heap = ( heapsQuery . data ?? []). find (( h ) => h . UID === filters . heapUid );
const heap = ( heapsQuery . data ?? []). find (
return heap ? `Heap · ${ heap . Title } ` : 'Heap' ;
( h ) => h . UID === filters . heapUid ,
);
return heap ? `Heap · ${ heap . Title } ` : "Heap" ;
}
}
default :
default :
// 'all-photos' is the internal "no section filter" state —
// 'all-photos' is the internal "no section filter" state —
@@ -106,9 +115,9 @@
// reflects what's actually on screen; only the rare
// reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a
// `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos".
// heap) still reads as "All photos".
if ( filters . folderPath === '/' ) return ' Folder · /' ;
if ( filters . folderPath === "/" ) return " Folder · /" ;
if ( filters . folderPath ) return `Folder · ${ filters . folderPath } ` ;
if ( filters . folderPath ) return `Folder · ${ filters . folderPath } ` ;
return ' All photos' ;
return " All photos" ;
}
}
}
}
@@ -120,14 +129,14 @@
// downstream into a single `photos` array consumers iterate.
// downstream into a single `photos` array consumers iterate.
const PHOTOS_PAGE_SIZE = 120 ;
const PHOTOS_PAGE_SIZE = 120 ;
const photosQuery = createInfiniteQuery < PpPhoto [] >(() => ({
const photosQuery = createInfiniteQuery < PpPhoto [] >(() => ({
queryKey : [ ' photos' , 'q' , filtersToQ ( filters ), { count : PHOTOS_PAGE_SIZE }],
queryKey : [ " photos" , "q" , filtersToQ ( filters ), { count : PHOTOS_PAGE_SIZE }],
queryFn : ({ pageParam }) =>
queryFn : ({ pageParam }) =>
listPhotos ({
listPhotos ({
q : filtersToQ ( filters ),
q : filtersToQ ( filters ),
count : PHOTOS_PAGE_SIZE ,
count : PHOTOS_PAGE_SIZE ,
offset : pageParam as number ,
offset : pageParam as number ,
order : ' newest' ,
order : " newest" ,
merged : true
merged : true ,
}),
}),
initialPageParam : 0 ,
initialPageParam : 0 ,
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
@@ -137,7 +146,7 @@
// tail (cheap; the empty response is small).
// tail (cheap; the empty response is small).
getNextPageParam : ( last , pages ) =>
getNextPageParam : ( last , pages ) =>
last . length === 0 ? undefined : pages . length * PHOTOS_PAGE_SIZE ,
last . length === 0 ? undefined : pages . length * PHOTOS_PAGE_SIZE ,
enabled : isAuthenticated ()
enabled : isAuthenticated (),
}));
}));
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
/** Flattened view of every loaded page, deduplicated by UID. Adjacent
@@ -155,7 +164,9 @@
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and
* express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */
* we strip subfolder rows post-hoc. */
const dedupedAll = $derived < PpPhoto [] >( dedupedPhotos ( photosQuery . data ? . pages ));
const dedupedAll = $derived < PpPhoto [] >(
dedupedPhotos ( photosQuery . data ? . pages ),
);
const photos = $derived < PpPhoto [] >( applyFolderScope ( dedupedAll , filters ));
const photos = $derived < PpPhoto [] >( applyFolderScope ( dedupedAll , filters ));
function dedupedPhotos ( pages : PpPhoto [][] | undefined ) : PpPhoto [] {
function dedupedPhotos ( pages : PpPhoto [][] | undefined ) : PpPhoto [] {
if ( ! pages ) return [];
if ( ! pages ) return [];
@@ -230,15 +241,15 @@
* one-dimensional even though the template renders headers inline).
* one-dimensional even though the template renders headers inline).
*/
*/
type Row =
type Row =
| { kind : ' header' ; key : string ; label : string ; count : number }
| { kind : " header" ; key : string ; label : string ; count : number }
| { kind : ' tile' ; photo : PpPhoto ; tileIndex : number };
| { kind : " tile" ; photo : PpPhoto ; tileIndex : number };
const rows = $derived < Row [] >( buildRows ( photos ));
const rows = $derived < Row [] >( buildRows ( photos ));
function buildRows ( list : PpPhoto []) : Row [] {
function buildRows ( list : PpPhoto []) : Row [] {
const out : Row [] = [];
const out : Row [] = [];
const fmt = new Intl . DateTimeFormat ( undefined , {
const fmt = new Intl . DateTimeFormat ( undefined , {
month : ' long' ,
month : " long" ,
year : ' numeric'
year : " numeric" ,
});
});
// First pass: count per month — used by the header chip. Sparse
// First pass: count per month — used by the header chip. Sparse
// Map: O(months) memory, O(photos) time, both trivial at 10k.
// Map: O(months) memory, O(photos) time, both trivial at 10k.
@@ -250,25 +261,33 @@
if ( ! labels . has ( key )) labels . set ( key , label );
if ( ! labels . has ( key )) labels . set ( key , label );
}
}
// Second pass: emit rows in document order.
// Second pass: emit rows in document order.
let prev = '' ;
let prev = "" ;
for ( let i = 0 ; i < list . length ; i ++ ) {
for ( let i = 0 ; i < list . length ; i ++ ) {
const p = list [ i ];
const p = list [ i ];
const { key } = monthKey ( p , fmt );
const { key } = monthKey ( p , fmt );
if ( key !== prev ) {
if ( key !== prev ) {
out . push ({ kind : 'header' , key , label : labels.get ( key ) ?? '' , c oun t: counts.get ( key ) ?? 0 });
out . push ({
kind : "header" ,
key ,
label : labels.get ( key ) ?? "" ,
count : counts.get ( key ) ?? 0 ,
});
prev = key ;
prev = key ;
}
}
out . push ({ kind : ' tile' , photo : p , tileIndex : i });
out . push ({ kind : " tile" , photo : p , tileIndex : i });
}
}
return out ;
return out ;
}
}
function monthKey ( p : PpPhoto , fmt : Intl.DateTimeFormat ) : { key : string ; label : string } {
function monthKey (
const raw = p . TakenAtLocal ?? p . TakenAt ?? '' ;
p : PpPhoto ,
if ( ! raw ) return { key : 'no-date' , label : 'No date' };
fmt : Intl.DateTimeFormat ,
) : { key : string ; label : string } {
const raw = p . TakenAtLocal ?? p . TakenAt ?? "" ;
if ( ! raw ) return { key : "no-date" , label : "No date" };
const d = new Date ( raw );
const d = new Date ( raw );
if ( Number . isNaN ( d . getTime ())) return { key : ' no-date' , label : ' No date' };
if ( Number . isNaN ( d . getTime ())) return { key : " no-date" , label : " No date" };
const k = ` ${ d . getUTCFullYear () } - ${ String ( d . getUTCMonth () + 1 ). padStart ( 2 , '0' ) } ` ;
const k = ` ${ d . getUTCFullYear () } - ${ String ( d . getUTCMonth () + 1 ). padStart ( 2 , "0" ) } ` ;
return { key : k , label : fmt.format ( d ) };
return { key : k , label : fmt.format ( d ) };
}
}
@@ -287,10 +306,10 @@
let visLast = $state ( INITIAL_VIS_LAST );
let visLast = $state ( INITIAL_VIS_LAST );
let forcedExpand = $state < number | null >( null );
let forcedExpand = $state < number | null >( null );
const renderFirst = $derived (
const renderFirst = $derived (
Math . max ( 0 , Math . min ( visFirst , forcedExpand ?? visFirst ) - TILE_BUFFER )
Math . max ( 0 , Math . min ( visFirst , forcedExpand ?? visFirst ) - TILE_BUFFER ),
);
);
const renderLast = $derived (
const renderLast = $derived (
Math . max ( visLast , forcedExpand ?? visLast ) + TILE_BUFFER
Math . max ( visLast , forcedExpand ?? visLast ) + TILE_BUFFER ,
);
);
// Hoist the selection reads out of the per-tile each-block. The same
// Hoist the selection reads out of the per-tile each-block. The same
@@ -348,7 +367,7 @@
},
},
destroy() {
destroy() {
handle ? . unregister ( node );
handle ? . unregister ( node );
}
},
};
};
}
}
@@ -362,7 +381,8 @@
* uses in its `scrollRowIntoView`. */
* uses in its `scrollRowIntoView`. */
function scrollTileIntoView ( el : HTMLElement ) {
function scrollTileIntoView ( el : HTMLElement ) {
if ( ! scrollRoot ) return ;
if ( ! scrollRoot ) return ;
const stickyH = scrollRoot . querySelector < HTMLElement >( 'h2.sticky' ) ? . offsetHeight ?? 0 ;
const stickyH =
scrollRoot . querySelector < HTMLElement >( "h2.sticky" ) ? . offsetHeight ?? 0 ;
const rootRect = scrollRoot . getBoundingClientRect ();
const rootRect = scrollRoot . getBoundingClientRect ();
const elRect = el . getBoundingClientRect ();
const elRect = el . getBoundingClientRect ();
const elTop = elRect . top - rootRect . top + scrollRoot . scrollTop ;
const elTop = elRect . top - rootRect . top + scrollRoot . scrollTop ;
@@ -384,7 +404,9 @@
forcedExpand = i ;
forcedExpand = i ;
await tick ();
await tick ();
if ( ! scrollRoot ) return ;
if ( ! scrollRoot ) return ;
const el = scrollRoot . querySelector < HTMLElement >( `[data-uid-shell=" ${ photos [ i ] ? . UID ?? '' } "]` );
const el = scrollRoot . querySelector < HTMLElement >(
`[data-uid-shell=" ${ photos [ i ] ? . UID ?? "" } "]` ,
);
if ( el ) scrollTileIntoView ( el );
if ( el ) scrollTileIntoView ( el );
}
}
@@ -430,7 +452,9 @@
function trackGridCols ( node : HTMLElement ) {
function trackGridCols ( node : HTMLElement ) {
gridEl = node ;
gridEl = node ;
const measure = () => {
const measure = () => {
const n = getComputedStyle ( node ). gridTemplateColumns . split ( ' ' ). filter ( Boolean ). length ;
const n = getComputedStyle ( node )
. gridTemplateColumns . split ( " " )
. filter ( Boolean ). length ;
cols = Math . max ( 1 , n );
cols = Math . max ( 1 , n );
};
};
measure ();
measure ();
@@ -444,7 +468,7 @@
destroy() {
destroy() {
ro . disconnect ();
ro . disconnect ();
if ( gridEl === node ) gridEl = undefined ;
if ( gridEl === node ) gridEl = undefined ;
}
},
};
};
}
}
@@ -455,7 +479,9 @@
void view . thumbnailSize ;
void view . thumbnailSize ;
queueMicrotask (() => {
queueMicrotask (() => {
if ( ! gridEl ) return ;
if ( ! gridEl ) return ;
const n = getComputedStyle ( gridEl ). gridTemplateColumns . split ( ' ' ). filter ( Boolean ). length ;
const n = getComputedStyle ( gridEl )
. gridTemplateColumns . split ( " " )
. filter ( Boolean ). length ;
cols = Math . max ( 1 , n );
cols = Math . max ( 1 , n );
});
});
});
});
@@ -474,15 +500,15 @@
function buildVisualGrid (
function buildVisualGrid (
list : PpPhoto [],
list : PpPhoto [],
colsPerRow : number
colsPerRow : number ,
) : { rows : VisualRow []; pos : Map < string , [ number , number ] > } {
) : { rows : VisualRow []; pos : Map < string , [ number , number ] > } {
const rows : VisualRow [] = [];
const rows : VisualRow [] = [];
const pos = new Map < string , [ number , number ] >();
const pos = new Map < string , [ number , number ] >();
const fmt = new Intl . DateTimeFormat ( undefined , {
const fmt = new Intl . DateTimeFormat ( undefined , {
month : ' long' ,
month : " long" ,
year : ' numeric'
year : " numeric" ,
});
});
let curMonth = '' ;
let curMonth = "" ;
let curRow : VisualRow | null = null ;
let curRow : VisualRow | null = null ;
for ( let i = 0 ; i < list . length ; i ++ ) {
for ( let i = 0 ; i < list . length ; i ++ ) {
const p = list [ i ];
const p = list [ i ];
@@ -528,8 +554,8 @@
// vertical move preserves whatever column the user is already on.
// vertical move preserves whatever column the user is already on.
if ( intendedCol === null ) intendedCol = c < 0 ? 0 : c ;
if ( intendedCol === null ) intendedCol = c < 0 ? 0 : c ;
if ( key === ' ArrowLeft' || key === ' ArrowRight' ) {
if ( key === " ArrowLeft" || key === " ArrowRight" ) {
c += key === ' ArrowRight' ? 1 : - 1 ;
c += key === " ArrowRight" ? 1 : - 1 ;
// Wrap across row boundaries (LeftArrow at col 0 → previous
// Wrap across row boundaries (LeftArrow at col 0 → previous
// row's last col, RightArrow at last col → next row's col 0).
// row's last col, RightArrow at last col → next row's col 0).
while ( c < 0 && r > 0 ) {
while ( c < 0 && r > 0 ) {
@@ -549,7 +575,7 @@
// subsequent vertical moves.
// subsequent vertical moves.
intendedCol = c ;
intendedCol = c ;
} else {
} else {
r += key === ' ArrowDown' ? 1 : - 1 ;
r += key === " ArrowDown" ? 1 : - 1 ;
if ( r < 0 ) r = 0 ;
if ( r < 0 ) r = 0 ;
if ( r >= rows . length ) r = rows . length - 1 ;
if ( r >= rows . length ) r = rows . length - 1 ;
// Vertical: prefer the intended column; clamp to destination
// Vertical: prefer the intended column; clamp to destination
@@ -571,7 +597,9 @@
// Try the cheap path first: rendered tile, our scroll helper that
// Try the cheap path first: rendered tile, our scroll helper that
// respects the sticky header. Fall back to scrollToIndex (windowing
// respects the sticky header. Fall back to scrollToIndex (windowing
// expand + scroll) when the destination is currently unmounted.
// expand + scroll) when the destination is currently unmounted.
const tile = scrollRoot ? . querySelector < HTMLElement >( `[data-uid=" ${ destUid } "]` );
const tile = scrollRoot ? . querySelector < HTMLElement >(
`[data-uid=" ${ destUid } "]` ,
);
if ( tile ) {
if ( tile ) {
scrollTileIntoView ( tile );
scrollTileIntoView ( tile );
} else {
} else {
@@ -581,11 +609,11 @@
}
}
const focusedPhotoQuery = createQuery < PpPhoto | null >(() => ({
const focusedPhotoQuery = createQuery < PpPhoto | null >(() => ({
queryKey : [ ' photo' , selection . focused ?? '' ],
queryKey : [ " photo" , selection . focused ?? "" ],
queryFn : () =>
queryFn : () =>
selection . focused ? getPhoto ( selection . focused ) : Promise . resolve ( null ),
selection . focused ? getPhoto ( selection . focused ) : Promise . resolve ( null ),
enabled : isAuthenticated () && Boolean ( selection . focused ),
enabled : isAuthenticated () && Boolean ( selection . focused ),
staleTime : 0
staleTime : 0 ,
}));
}));
const qc = useQueryClient ();
const qc = useQueryClient ();
@@ -602,7 +630,7 @@
if ( emptyingArchive ) return ;
if ( emptyingArchive ) return ;
if (
if (
! confirm (
! confirm (
' Permanently delete EVERY photo in the Archive? This cannot be undone.'
" Permanently delete EVERY photo in the Archive? This cannot be undone." ,
)
)
) {
) {
return ;
return ;
@@ -612,23 +640,23 @@
try {
try {
while ( true ) {
while ( true ) {
const batch = await listPhotos ({
const batch = await listPhotos ({
q : ' archived:true' ,
q : " archived:true" ,
count : 1000 ,
count : 1000 ,
offset : 0 ,
offset : 0 ,
order : ' newest' ,
order : " newest" ,
merged : false
merged : false ,
});
});
if ( batch . length === 0 ) break ;
if ( batch . length === 0 ) break ;
const uids = Array . from ( new Set ( batch . map (( p ) => p . UID )));
const uids = Array . from ( new Set ( batch . map (( p ) => p . UID )));
await batchDelete ( uids );
await batchDelete ( uids );
total += uids . length ;
total += uids . length ;
}
}
toast . success ( total === 0 ? ' Archive already empty' : `Deleted ${ total } ` );
toast . success ( total === 0 ? " Archive already empty" : `Deleted ${ total } ` );
} catch ( err ) {
} catch ( err ) {
toast . error ( err instanceof Error ? err . message : ' Empty archive failed' );
toast . error ( err instanceof Error ? err . message : " Empty archive failed" );
} finally {
} finally {
emptyingArchive = false ;
emptyingArchive = false ;
void qc . invalidateQueries ({ queryKey : [ ' photos' ] });
void qc . invalidateQueries ({ queryKey : [ " photos" ] });
}
}
}
}
@@ -653,14 +681,20 @@
// gridKeyNav already handled the underlying click.
// gridKeyNav already handled the underlying click.
if ( e . shiftKey || e . metaKey || e . ctrlKey ) return ;
if ( e . shiftKey || e . metaKey || e . ctrlKey ) return ;
e . preventDefault ();
e . preventDefault ();
openPreview ( uid , photos . map (( p ) => p . UID ));
openPreview (
uid ,
photos . map (( p ) => p . UID ),
);
}
}
// Single-click fallback for the dblclick preview gesture. Wired to the
// Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered
// hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview.
// dblclick can still get to the preview.
function onTileOpenPreview ( uid : string ) {
function onTileOpenPreview ( uid : string ) {
openPreview ( uid , photos . map (( p ) => p . UID ));
openPreview (
uid ,
photos . map (( p ) => p . UID ),
);
}
}
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
@@ -681,10 +715,10 @@
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet.
// focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [
const SEARCH_EXAMPLES = [
' label:dog' ,
" label:dog" ,
' keyword:vacation' ,
" keyword:vacation" ,
' taken:2024' ,
" taken:2024" ,
'"exact phrase"'
'"exact phrase"' ,
];
];
let searchFocused = $state ( false );
let searchFocused = $state ( false );
function onSearchFocus() {
function onSearchFocus() {
@@ -702,22 +736,12 @@
</ script >
</ script >
< Toolbar showRightToggle >
< Toolbar showRightToggle >
< span class = "rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground" >
< span
class = "rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"
>
{ sectionLabel }
{ sectionLabel }
</ span >
</ span >
<!--
{ #if filters . section === "archive" && photos . length > 0 }
Persistent gesture hint. The new click-semantics (single = select,
double = open) aren't intuitive for users arriving from Google
Photos / Apple Photos, so surface them in plain text where the eye
can see them without hover. Hidden below sm: so the search bar
still gets room on narrow viewports.
-->
{ #if photos . length > 0 }
< span class = "hidden text-[10px] text-muted-foreground lg:inline" >
click select · ⇧ range · ⌘ toggle · dblclick open
</ span >
{ /if }
{ #if filters . section === 'archive' && photos . length > 0 }
< button
< button
type = "button"
type = "button"
class = "rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
class = "rounded border border-destructive/40 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
@@ -725,13 +749,13 @@
onclick= { onEmptyArchive }
onclick= { onEmptyArchive }
title = "Permanently delete every archived photo"
title = "Permanently delete every archived photo"
>
>
{ emptyingArchive ? ' Emptying…' : ' Empty Archive' }
{ emptyingArchive ? " Emptying…" : " Empty Archive" }
</ button >
</ button >
{ /if }
{ /if }
< form class = "relative flex items-center gap-1" onsubmit = { onSearchSubmit } >
< form class = "relative flex items-center gap-1" onsubmit = { onSearchSubmit } >
< input
< input
type = "search"
type = "search"
placeholder = 'Search · label:website / "vacation"'
placeholder = { 'Search · label:website / "vacation"'}
class="w-56 rounded border border-input bg-background px-2 py-0 . 5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring "
class="w-56 rounded border border-input bg-background px-2 py-0 . 5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring "
bind:value = { searchDraft }
bind:value = { searchDraft }
onfocus= { onSearchFocus }
onfocus= { onSearchFocus }
@@ -748,8 +772,8 @@
type = "button"
type = "button"
class = "rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
class = "rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent"
onclick = {() => {
onclick = {() => {
searchDraft = '' ;
searchDraft = "" ;
setSearch ( '' );
setSearch ( "" );
}}
}}
title="Clear search "
title="Clear search "
>
>
@@ -765,7 +789,9 @@
< div
< div
class = "absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
class = "absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
>
< div class = "px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground" >
< div
class = "px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Examples
Examples
</ div >
</ div >
{ #each SEARCH_EXAMPLES as ex ( ex )}
{ #each SEARCH_EXAMPLES as ex ( ex )}
@@ -806,7 +832,6 @@
</ button >
</ button >
{ /each }
{ /each }
</ div >
</ div >
{ /snippet }
{ /snippet }
</ Toolbar >
</ Toolbar >
@@ -826,7 +851,7 @@
visFirst = f ;
visFirst = f ;
visLast = l ;
visLast = l ;
},
},
sampleEvery : TILE_SAMPLE
sampleEvery : TILE_SAMPLE ,
}}
}}
>
>
< div class = "p-6 pb-24" >
< div class = "p-6 pb-24" >
@@ -836,24 +861,24 @@
< p class = "text-sm text-destructive" >
< p class = "text-sm text-destructive" >
Failed to load photos: { photosQuery . error instanceof Error
Failed to load photos: { photosQuery . error instanceof Error
? photosQuery . error . message
? photosQuery . error . message
: ' unknown error' }
: " unknown error" }
</ p >
</ p >
{ :else if photos . length === 0 }
{ :else if photos . length === 0 }
< p class = "text-sm text-muted-foreground" >
< p class = "text-sm text-muted-foreground" >
{ #if filters . section === ' archive' }
{ #if filters . section === " archive" }
Archive is empty.
Archive is empty.
{ :else if filters . section === ' favorites' }
{ :else if filters . section === " favorites" }
No favorites yet. Heart a photo to add it here.
No favorites yet. Heart a photo to add it here.
{ :else if filters . section === ' review' }
{ :else if filters . section === " review" }
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
Nothing left to review. Photos PhotoPrism's indexer wasn't sure
land here — use Keep to accept them into the timeline or Archive to
about land here — use Keep to accept them into the timeline or
set them aside.
Archive to set them aside.
{ :else if filters . section === ' hidden' }
{ :else if filters . section === " hidden" }
No hidden photos. PhotoPrism auto-hides files it can't index (broken
No hidden photos. PhotoPrism auto-hides files it can't index
files, very low quality); they only ever show up here.
(broken files, very low quality); they only ever show up here.
{ :else if filters . section === ' heap' }
{ :else if filters . section === " heap" }
This heap has no photos yet. Select some photos and use the bulk bar's
This heap has no photos yet. Select some photos and use the bulk
"+ Add to heap" button.
bar's "+ Add to heap" button.
{ : else }
{ : else }
No photos. Index a folder via PhotoPrism's reindex command.
No photos. Index a folder via PhotoPrism's reindex command.
{ /if }
{ /if }
@@ -865,8 +890,8 @@
class = "grid gap-2"
class = "grid gap-2"
style = "grid-template-columns: repeat(auto-fill, minmax( { view . thumbnailSize } px, 1fr));"
style = "grid-template-columns: repeat(auto-fill, minmax( { view . thumbnailSize } px, 1fr));"
>
>
{ #each rows as row ( row . kind === ' header' ? `h:$ { row . key } ` : ` t :$ { row . photo . UID } `)}
{ #each rows as row ( row . kind === " header" ? `h:$ { row . key } ` : ` t :$ { row . photo . UID } `)}
{#if row.kind === ' header' }
{#if row.kind === " header" }
<!-- col-span-full + position:sticky pins the month label to
<!-- col-span-full + position:sticky pins the month label to
the top of the scrolling main as the user passes through.
the top of the scrolling main as the user passes through.
-mx-6 stretches the bar past the wrapper padding so it
-mx-6 stretches the bar past the wrapper padding so it
@@ -876,7 +901,9 @@
style="grid-column: 1 / -1;"
style="grid-column: 1 / -1;"
>
>
{row.label}
{row.label}
<span class="ml-2 text-[10px] font-normal text-muted-foreground">
<span
class="ml-2 text-[10px] font-normal text-muted-foreground"
>
{row.count}
{row.count}
</span>
</span>
</h2>
</h2>
@@ -893,7 +920,8 @@
use :tileRegister = { i }
use :tileRegister = { i }
>
>
{ #if inWindow }
{ #if inWindow }
{ @const sel = selectedIds . has ( photo . UID ) || focusedUid === photo . UID }
{ @const sel =
selectedIds . has ( photo . UID ) || focusedUid === photo . UID }
< PhotoTile
< PhotoTile
{ photo }
{ photo }
selected = { sel }
selected = { sel }
@@ -915,12 +943,15 @@
class = "h-4"
class = "h-4"
use : nearBottom = {{
use : nearBottom = {{
onHit : () => photosQuery . fetchNextPage (),
onHit : () => photosQuery . fetchNextPage (),
enabled : photosQuery.hasNextPage && ! photosQuery . isFetchingNextPage ,
enabled :
root : scrollRoot
photosQuery.hasNextPage && ! photosQuery . isFetchingNextPage ,
root : scrollRoot ,
}}
}}
> </ div >
> </ div >
{ #if photosQuery . isFetchingNextPage }
{ #if photosQuery . isFetchingNextPage }
< p class = "py-3 text-center text-xs text-muted-foreground" > Loading more … </ p >
< p class = "py-3 text-center text-xs text-muted-foreground" >
Loading more …
</ p >
{ /if }
{ /if }
{ /if }
{ /if }
</ div >
</ div >
@@ -947,8 +978,8 @@
< div class = "space-y-2 p-4 text-center" >
< div class = "space-y-2 p-4 text-center" >
< div class = "text-xl" > ⓘ </ div >
< div class = "text-xl" > ⓘ </ div >
< p class = "text-xs text-muted-foreground" >
< p class = "text-xs text-muted-foreground" >
Use arrow keys or < kbd class = "rounded bg-muted px-1" > ⌘ </ kbd > + click on a thumbnail
Use arrow keys or < kbd class = "rounded bg-muted px-1" > ⌘ </ kbd > + click
to view its metadata here .
on a thumbnail to view its metadata here .
</ p >
</ p >
</ div >
</ div >
{ /if }
{ /if }
@@ -958,9 +989,9 @@
< div
< div
class = "group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
class = "group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
use : resizable = {{
use : resizable = {{
edge : ' left' ,
edge : " left" ,
getWidth : () => view . rightSidebarWidth ,
getWidth : () => view . rightSidebarWidth ,
setWidth : setRightSidebarWidth
setWidth : setRightSidebarWidth ,
}}
}}
role = "separator"
role = "separator"
aria - orientation = "vertical"
aria - orientation = "vertical"