feat(web): fold duplicates+inbox into /review; sidebar UX cleanup
- /duplicates and /inbox routes removed and folded into /review as additional tabs alongside cause tabs; /duplicates keeps a redirect for bookmarks. - LeftSidebar: drop import/inbox tile and favorites; show per-user BasePath label at the folder root. - RightSidebar: split file header into read-only path over editable basename (matches sidecar rename contract); date field switches to plain-text ISO YYYY-MM-DD (no native datetime picker) with strict validation and revert-on-invalid-blur; preserves original hour. - BulkMetadataSidebar: same ISO-only date input with invalid-state styling and apply-button gating. - BulkActionBar: drop redundant Restore and Undo buttons; ⌘Z still reachable via gridKeyNav. - gridKeyNav: remove favorite toggle (F) alongside the favorites view retirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,7 @@ import {
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
likePhoto,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
@@ -55,7 +53,7 @@ export interface GridKeyNavParams {
|
||||
* - Arrow-key focus navigation (with shift-extend) inside the visible grid
|
||||
* - Click + shift/ctrl click selection mutations
|
||||
* - Window-level shortcuts mirroring mule-image's keyboard layer:
|
||||
* x archive-toggle, u restore, f favorite-toggle, s + (1–9) add to
|
||||
* x archive-toggle, u restore, s + (1–9) add to
|
||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||
* left sidebar, i toggles right sidebar, space/enter opens preview,
|
||||
* esc clears, ⌘Z undoes, ⌘A selects all visible.
|
||||
@@ -302,46 +300,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
|
||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||
* target's cached `Favorite` to decide direction so a mixed selection
|
||||
* resolves to "make them all favorited" when the first isn't, mirroring
|
||||
* the way `toggleArchive('toggle')` works. */
|
||||
async function toggleFavoriteOnTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to favorite', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const first = cachedPhoto(ids[0]);
|
||||
const next = !(first?.Favorite ?? false);
|
||||
const { updated, errors } = await batchEdit(ids, (id) =>
|
||||
next ? likePhoto(id) : unlikePhoto(id)
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
const verb = next ? 'Favorited' : 'Unfavorited';
|
||||
if (errors.length) {
|
||||
// Surface the actual first error message — silent failures here are
|
||||
// the #1 reason `f` "doesn't work" (e.g. permission, network, 404).
|
||||
toast.error(`${verb} ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`${verb} ${ids.length}`);
|
||||
pushUndo(`${verb} ${ids.length}`, async () => {
|
||||
await batchEdit(ids, (id) => (next ? unlikePhoto(id) : likePhoto(id)));
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
for (const id of ids) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||
// Press S: arm a short timer. A digit 1–9 within the window adds the
|
||||
// cull targets to the Nth heap in the heap list. Any other key cancels
|
||||
@@ -548,12 +506,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
e.preventDefault();
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleFavoriteOnTargets();
|
||||
return;
|
||||
case 's':
|
||||
case 'S':
|
||||
if (meta || shift) return;
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
'default',
|
||||
'browse',
|
||||
'albums',
|
||||
'favorites',
|
||||
'calendar',
|
||||
'moments',
|
||||
'people',
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
duplicateHeap,
|
||||
getAllMarks,
|
||||
getConfig,
|
||||
getImportInfo,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
@@ -24,7 +23,6 @@
|
||||
scanCrossFolderDuplicates,
|
||||
triggerDownload,
|
||||
type CrossFolderScanResult,
|
||||
type ImportInfo,
|
||||
type PhotoMarksMap,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
@@ -73,27 +71,17 @@
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
|
||||
// 60s — the inbox only changes when files are uploaded or the indexer
|
||||
// runs, neither of which happens often enough to justify a tighter cadence.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/favorites/archived/
|
||||
// labels/places/…) updated incrementally on every mutation. Cheap to
|
||||
// refetch, and gives us a stable total — `/photos` only returns
|
||||
// per-page row counts via `X-Count`, never a total.
|
||||
// precomputed counter for every common bucket (all/archived/labels/
|
||||
// places/…) updated incrementally on every mutation. Cheap to refetch,
|
||||
// and gives us a stable total — `/photos` only returns per-page row
|
||||
// counts via `X-Count`, never a total.
|
||||
//
|
||||
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||
// across mutations (favorite, archive, restore, delete, heap add) — the
|
||||
// counter map refreshes whenever the photo list does. Marks-derived
|
||||
// counts (ratings/colors) react through the shared `['marks']` cache.
|
||||
// across mutations (archive, restore, delete, heap add) — the counter
|
||||
// map refreshes whenever the photo list does. Marks-derived counts
|
||||
// (ratings/colors) react through the shared `['marks']` cache.
|
||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||
queryKey: ['photos', 'config'],
|
||||
queryFn: getConfig,
|
||||
@@ -267,6 +255,13 @@
|
||||
const rootActive = $derived(filters.folderPath === '/');
|
||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||
|
||||
// Root-folder label. Every account (admins included) gets a
|
||||
// BasePath named after them on disk, so surface that identity
|
||||
// here instead of an opaque "/".
|
||||
const rootLabel = $derived(
|
||||
session.user?.DisplayName?.trim() || session.user?.Name || '/'
|
||||
);
|
||||
|
||||
async function onSignOut() {
|
||||
await logout();
|
||||
await goto('/login', { replaceState: true });
|
||||
@@ -390,9 +385,9 @@
|
||||
// "everything visible in the main timeline" tally), so it matches what
|
||||
// the All photos view actually renders. `places` is the count of
|
||||
// geocoded locations — semantically what the Map view groups by.
|
||||
// Duplicates sums stacks + cross-folder groups; cross-folder only
|
||||
// contributes once its tab has been visited (the scan is opt-in
|
||||
// per-visit, not eager from the sidebar).
|
||||
// Review rolls in the duplicates tabs hosted under /review — stacks
|
||||
// always contributes; cross-folder only contributes once its tab has
|
||||
// been opened (the scan is lazy, not eager from the sidebar).
|
||||
type ViewItem =
|
||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||
@@ -402,8 +397,6 @@
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
||||
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
||||
// the badge shows total labels + keywords + ratings + colors so the
|
||||
@@ -421,15 +414,14 @@
|
||||
];
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
{ kind: 'route', href: '/review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||
{
|
||||
kind: 'route',
|
||||
href: '/duplicates',
|
||||
label: 'Duplicates',
|
||||
href: '/review',
|
||||
label: 'Review',
|
||||
getCount: () => {
|
||||
const stacks = stacksQuery.data?.length;
|
||||
if (stacks === undefined) return undefined;
|
||||
return stacks + (crossFolderQuery.data?.groups.length ?? 0);
|
||||
const review = configQuery.data?.count?.review;
|
||||
if (review === undefined) return undefined;
|
||||
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
||||
}
|
||||
},
|
||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||
@@ -560,9 +552,9 @@
|
||||
class="flex min-w-0 flex-1 items-center text-left"
|
||||
class:px-1={hasSubfolders}
|
||||
onclick={() => pickFolder('/')}
|
||||
title="Photos directly under originals/"
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<span class="truncate">/</span>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import {
|
||||
buildTakenAtPatch,
|
||||
bulkSetMarks,
|
||||
isValidISODate,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
@@ -56,12 +57,12 @@
|
||||
noteDraft = '';
|
||||
}
|
||||
|
||||
const dateDraftValid = $derived(dateDraft === '' || isValidISODate(dateDraft));
|
||||
async function applyDate() {
|
||||
if (busy || !dateDraft) return;
|
||||
// datetime-local omits the timezone; treat the input as UTC (same
|
||||
// convention as the single-photo sidebar) and let PhotoPrism's
|
||||
// backwrite stamp the local timezone field downstream.
|
||||
const iso = `${dateDraft}:00Z`;
|
||||
if (busy || !dateDraft || !isValidISODate(dateDraft)) return;
|
||||
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
|
||||
// fill the local timezone field downstream.
|
||||
const iso = `${dateDraft}T00:00:00Z`;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
@@ -200,14 +201,18 @@
|
||||
<Calendar class="h-3 w-3" /> Date taken
|
||||
</div>
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="YYYY-MM-DD"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
aria-invalid={!dateDraftValid}
|
||||
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||
bind:value={dateDraft}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
class="w-full rounded-md border border-border px-2 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || !dateDraft}
|
||||
disabled={busy || !dateDraft || !dateDraftValid}
|
||||
onclick={applyDate}
|
||||
>
|
||||
Apply date to {ids.length}
|
||||
|
||||
@@ -13,10 +13,8 @@
|
||||
Calendar,
|
||||
Camera,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Lock,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
@@ -26,10 +24,9 @@
|
||||
import {
|
||||
buildTakenAtPatch,
|
||||
getAllMarks,
|
||||
likePhoto,
|
||||
isValidISODate,
|
||||
renameOnDisk,
|
||||
setMark,
|
||||
unlikePhoto,
|
||||
updatePhoto,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
@@ -52,7 +49,7 @@
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let filename = $state('');
|
||||
let basename = $state('');
|
||||
let caption = $state('');
|
||||
let takenAt = $state('');
|
||||
let lat = $state('');
|
||||
@@ -60,18 +57,22 @@
|
||||
let country = $state('');
|
||||
let keywords = $state<string[]>([]);
|
||||
let keywordDraft = $state('');
|
||||
let subject = $state('');
|
||||
let artist = $state('');
|
||||
let copyright = $state('');
|
||||
let license = $state('');
|
||||
let notes = $state('');
|
||||
let renaming = $state(false);
|
||||
|
||||
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||
* basename — it preserves the directory on disk — so we mirror that
|
||||
* contract in the UI by exposing just the basename for edit. */
|
||||
function splitName(full: string): { dir: string; base: string } {
|
||||
const i = full.lastIndexOf('/');
|
||||
return i < 0 ? { dir: '', base: full } : { dir: full.slice(0, i), base: full.slice(i + 1) };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const pf = primaryFile(photo);
|
||||
filename = pf.Name ?? '';
|
||||
basename = splitName(pf.Name ?? '').base;
|
||||
caption = photo.Caption ?? '';
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 16);
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||
lat = photo.Lat ? String(photo.Lat) : '';
|
||||
lng = photo.Lng ? String(photo.Lng) : '';
|
||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||
@@ -80,11 +81,6 @@
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
subject = det.Subject ?? '';
|
||||
artist = det.Artist ?? '';
|
||||
copyright = det.Copyright ?? '';
|
||||
license = det.License ?? '';
|
||||
notes = det.Notes ?? '';
|
||||
});
|
||||
|
||||
const patchMutation = createMutation(() => ({
|
||||
@@ -100,32 +96,15 @@
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||
}));
|
||||
|
||||
const favoriteMutation = createMutation(() => ({
|
||||
mutationFn: async (next: boolean) => {
|
||||
if (next) await likePhoto(photo.UID);
|
||||
else await unlikePhoto(photo.UID);
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
pushUndo(next ? 'Favorited' : 'Unfavorited', async () => {
|
||||
if (next) await unlikePhoto(photo.UID);
|
||||
else await likePhoto(photo.UID);
|
||||
void qc.invalidateQueries({ queryKey: ['photo', photo.UID] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
function commit(patch: UpdatePhotoBody) {
|
||||
patchMutation.mutate(patch);
|
||||
}
|
||||
|
||||
async function commitFilename() {
|
||||
const pf = primaryFile(photo);
|
||||
const next = filename.trim();
|
||||
if (!next || next === pf.Name) return;
|
||||
const { base: currentBase } = splitName(pf.Name ?? '');
|
||||
const next = basename.trim();
|
||||
if (!next || next === currentBase) return;
|
||||
renaming = true;
|
||||
try {
|
||||
const result = await renameOnDisk(photo.UID, next);
|
||||
@@ -139,7 +118,7 @@
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Rename failed');
|
||||
filename = pf.Name ?? '';
|
||||
basename = currentBase;
|
||||
} finally {
|
||||
renaming = false;
|
||||
}
|
||||
@@ -149,9 +128,19 @@
|
||||
if (caption === (photo.Caption ?? '')) return;
|
||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||
}
|
||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||
function commitTakenAt() {
|
||||
if (!takenAt) return;
|
||||
const iso = `${takenAt}:00Z`;
|
||||
if (!isValidISODate(takenAt)) {
|
||||
// Revert to the photo's stored date so the field doesn't sit in
|
||||
// a broken state once focus leaves it.
|
||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||
return;
|
||||
}
|
||||
// Keep the original time-of-day so date-only edits don't clobber the
|
||||
// hour. Fall back to midnight UTC when the photo has no prior TakenAt.
|
||||
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||
const iso = `${takenAt}${tail}`;
|
||||
if (iso === photo.TakenAt) return;
|
||||
commit(buildTakenAtPatch(iso));
|
||||
}
|
||||
@@ -170,7 +159,7 @@
|
||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||
}
|
||||
|
||||
type DetailsKey = 'Keywords' | 'Subject' | 'Artist' | 'Copyright' | 'License' | 'Notes';
|
||||
type DetailsKey = 'Keywords';
|
||||
function commitDetails(field: DetailsKey, value: string) {
|
||||
const prev = (photo.Details ?? {})[field] ?? '';
|
||||
if (value === prev) return;
|
||||
@@ -189,14 +178,6 @@
|
||||
commitDetails('Keywords', keywords.join(', '));
|
||||
}
|
||||
|
||||
function togglePrivate() {
|
||||
const prev = photo.Private ?? false;
|
||||
commit({ Private: !prev });
|
||||
pushUndo(prev ? 'Made public' : 'Made private', () => {
|
||||
commit({ Private: prev });
|
||||
});
|
||||
}
|
||||
|
||||
// Marks (rating + color) live on the mule-sidecar — PhotoPrism's PUT
|
||||
// silently drops these fields. One query holds the whole map; mutations
|
||||
// patch the cache optimistically and PUT to the sidecar.
|
||||
@@ -265,6 +246,7 @@
|
||||
const currentColor = $derived(photoMark.color ?? '');
|
||||
|
||||
const pf = $derived(primaryFile(photo));
|
||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||
const sizeStr = $derived(
|
||||
pf.Size
|
||||
@@ -307,47 +289,44 @@
|
||||
</script>
|
||||
|
||||
<aside class="space-y-2.5 bg-card p-2.5 text-xs">
|
||||
<!-- Header strip — thumb + filename + favorite + private -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Header strip — thumb + path (read-only) over editable basename. The
|
||||
sidecar's rename endpoint only accepts a bare basename and preserves
|
||||
the directory on disk, so the split UI mirrors that contract. -->
|
||||
<div class="flex items-start gap-2">
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'tile_100')}
|
||||
alt=""
|
||||
class="h-10 w-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
bind:value={filename}
|
||||
disabled={renaming}
|
||||
onblur={commitFilename}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||
/>
|
||||
<!-- Inline spinner next to the filename so the user sees the rename
|
||||
in flight without having to scan to the bottom of the sidebar. -->
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{#if dirPath}
|
||||
<div
|
||||
class="truncate text-[10px] text-muted-foreground"
|
||||
title={dirPath}
|
||||
>
|
||||
{dirPath}/
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs font-medium leading-snug break-all hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50"
|
||||
bind:value={basename}
|
||||
disabled={renaming}
|
||||
onblur={commitFilename}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
||||
/>
|
||||
</div>
|
||||
<!-- Inline spinner so the user sees the rename in flight without
|
||||
scanning to the bottom of the sidebar. -->
|
||||
{#if renaming}
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
<Loader2 class="mt-1 h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
<button
|
||||
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
||||
class:text-red-500={photo.Favorite}
|
||||
class:text-muted-foreground={!photo.Favorite}
|
||||
disabled={favoriteMutation.isPending}
|
||||
onclick={() => favoriteMutation.mutate(!photo.Favorite)}
|
||||
title={photo.Favorite ? 'Remove favorite (F)' : 'Add favorite (F)'}
|
||||
>
|
||||
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
||||
class:text-foreground={photo.Private}
|
||||
class:text-muted-foreground={!photo.Private}
|
||||
disabled={patchMutation.isPending}
|
||||
onclick={togglePrivate}
|
||||
title={photo.Private ? 'Private' : 'Public'}
|
||||
>
|
||||
<Lock class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compact info rows -->
|
||||
@@ -356,8 +335,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
placeholder="YYYY-MM-DD"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
aria-invalid={!takenAtValid}
|
||||
class="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 text-xs hover:border-input focus:border-input focus:outline-none focus:ring-1 focus:ring-ring aria-invalid:border-destructive aria-invalid:text-destructive aria-invalid:focus:ring-destructive"
|
||||
bind:value={takenAt}
|
||||
onblur={commitTakenAt}
|
||||
/>
|
||||
@@ -606,66 +589,6 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- IPTC credits. Static default (closed); user's choice persists. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
open={getMetadataSectionOpen('credits', false)}
|
||||
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Credits & notes
|
||||
</summary>
|
||||
<div class="space-y-1 p-2 pt-1">
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="w-16 text-[10px] text-muted-foreground">Subject</span>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={subject}
|
||||
onblur={() => commitDetails('Subject', subject)}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="w-16 text-[10px] text-muted-foreground">Artist</span>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={artist}
|
||||
onblur={() => commitDetails('Artist', artist)}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="w-16 text-[10px] text-muted-foreground">Copyright</span>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={copyright}
|
||||
onblur={() => commitDetails('Copyright', copyright)}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="w-16 text-[10px] text-muted-foreground">License</span>
|
||||
<input
|
||||
type="text"
|
||||
class="min-w-0 flex-1 rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={license}
|
||||
onblur={() => commitDetails('License', license)}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-start gap-1">
|
||||
<span class="w-16 pt-0.5 text-[10px] text-muted-foreground">Private notes</span>
|
||||
<textarea
|
||||
rows="2"
|
||||
class="min-w-0 flex-1 resize-y rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
bind:value={notes}
|
||||
onblur={() => commitDetails('Notes', notes)}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- File metadata. Closed by default; persists once opened. -->
|
||||
<details
|
||||
class="rounded border border-border"
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
likePhoto,
|
||||
listHeaps,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { batchEdit } from '$lib/services/batch';
|
||||
@@ -21,7 +19,7 @@
|
||||
setFocused
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
|
||||
const qc = useQueryClient();
|
||||
@@ -51,8 +49,8 @@
|
||||
const isBulk = $derived(selection.ids.size > 0);
|
||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||
// every other action is hidden so the choice can't be confused with
|
||||
// favoriting / heap-adding / restoring. The S keybinding is rerouted
|
||||
// to approve from gridKeyNav for the same reason.
|
||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||
// from gridKeyNav for the same reason.
|
||||
const isReview = $derived(filters.section === 'review');
|
||||
// Archive section is the parallel two-button flow: Keep (restore back
|
||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||
@@ -152,30 +150,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function onFavorite() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
const { updated, errors } = await batchEdit(ids, (id) => likePhoto(id));
|
||||
if (errors.length) {
|
||||
toast.error(`Favorited ${updated.length}; ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`Favorited ${ids.length}`);
|
||||
}
|
||||
pushUndo(`Favorited ${ids.length}`, async () => {
|
||||
await batchEdit(ids, (id) => unlikePhoto(id));
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
async function onUndo() {
|
||||
const entry = await popAndRun();
|
||||
if (entry) toast.success(`Undone: ${entry.label}`);
|
||||
else toast.message('Nothing to undo');
|
||||
}
|
||||
|
||||
async function onAddToHeap(heap: PpAlbum) {
|
||||
const ids = snapshotIds();
|
||||
if (!ids.length) return;
|
||||
@@ -244,8 +218,8 @@
|
||||
{#if isReview}
|
||||
<!-- Review pile = binary decision. Keep approves (Quality →
|
||||
3+, lands in the main timeline); Archive batches into
|
||||
the archive section. Everything else (heap, favorite,
|
||||
restore) is hidden so the choice reads as decisive. -->
|
||||
the archive section. Everything else (heap, restore)
|
||||
is hidden so the choice reads as decisive. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
@@ -335,15 +309,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onFavorite}
|
||||
title="Favorite"
|
||||
>
|
||||
♥ Favorite
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
@@ -353,25 +318,7 @@
|
||||
Archive
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Restore"
|
||||
>
|
||||
Restore
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || undoStack.entries.length === 0}
|
||||
onclick={onUndo}
|
||||
title="Undo last action"
|
||||
>
|
||||
Undo
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
onclick={clearAll}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Single photo tile — shared by the timeline (+page.svelte) and the flat
|
||||
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
|
||||
and can't drift between the two. Owns: thumbnail, selection animation,
|
||||
favorite/video badges, and the hover-only "open preview" affordance.
|
||||
video badge, and the hover-only "open preview" affordance.
|
||||
|
||||
Sizing is delegated to the caller — PhotoTile fills its container with
|
||||
h-full w-full, so the timeline can wrap it in its windowing shell and
|
||||
@@ -144,12 +144,6 @@
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if photo.Favorite}
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
|
||||
>♥</span
|
||||
>
|
||||
{/if}
|
||||
{#if isVideo(photo)}
|
||||
<span
|
||||
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||
|
||||
@@ -175,8 +175,6 @@ export interface UpdatePhotoBody {
|
||||
OriginalName?: string;
|
||||
Caption?: string;
|
||||
CaptionSrc?: 'manual' | '';
|
||||
Favorite?: boolean;
|
||||
Private?: boolean;
|
||||
Archived?: boolean;
|
||||
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
|
||||
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
|
||||
@@ -195,6 +193,16 @@ export interface UpdatePhotoBody {
|
||||
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
|
||||
}
|
||||
|
||||
/** True when `s` is a real calendar date in strict `YYYY-MM-DD` form. Rejects
|
||||
* shape mismatches AND out-of-range parts that `Date` would silently roll
|
||||
* over (e.g. `2026-02-30` → Mar 2). */
|
||||
export function isValidISODate(s: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
||||
const d = new Date(`${s}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return false;
|
||||
return d.toISOString().slice(0, 10) === s;
|
||||
}
|
||||
|
||||
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return {};
|
||||
@@ -264,18 +272,6 @@ export async function approvePhoto(uid: string): Promise<void> {
|
||||
await http.post(`/photos/${uid}/approve`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||
* routes that are atomic; preferred over PUT for this one field.
|
||||
*/
|
||||
export async function likePhoto(uid: string): Promise<void> {
|
||||
await http.post(`/photos/${uid}/like`);
|
||||
}
|
||||
|
||||
export async function unlikePhoto(uid: string): Promise<void> {
|
||||
await http.delete(`/photos/${uid}/like`);
|
||||
}
|
||||
|
||||
// ── Stack file operations ───────────────────────────────────────────────────
|
||||
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
|
||||
// a single Photo entity. The duplicate-resolution flow needs two ops, both
|
||||
@@ -324,8 +320,6 @@ export interface PpFolder {
|
||||
Root: string;
|
||||
Title: string;
|
||||
FileCount?: number;
|
||||
Favorite?: boolean;
|
||||
Private?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,39 +348,6 @@ export async function listFolders(): Promise<PpFolder[]> {
|
||||
.filter((f) => f.Path !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
|
||||
* files in a separate `/photoprism/import` root, exposed via
|
||||
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
||||
* `/folders/originals`, but the photo counts come from `X-Files` and
|
||||
* `X-Folders` response headers since the body only lists subfolders.
|
||||
*
|
||||
* BasePath does NOT apply: `/folders/import` is a separate root from
|
||||
* originals (PhotoPrism's `import.path`, not under originals/), so we
|
||||
* don't filter the result by the signed-in user's BasePath. If/when
|
||||
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
|
||||
*/
|
||||
export interface ImportInfo {
|
||||
files: number;
|
||||
folders: number;
|
||||
subfolders: PpFolder[];
|
||||
}
|
||||
|
||||
export async function getImportInfo(): Promise<ImportInfo> {
|
||||
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
|
||||
params: { recursive: true, uncached: true, files: false }
|
||||
});
|
||||
const num = (h: unknown) => {
|
||||
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
return {
|
||||
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
|
||||
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
|
||||
subfolders: res.data.folders ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||
* endpoint reports `FileCount: 0` even when populated, so the count has
|
||||
@@ -466,7 +427,6 @@ export interface PpLabel {
|
||||
Slug: string;
|
||||
CustomSlug?: string;
|
||||
Name: string;
|
||||
Favorite?: boolean;
|
||||
Priority?: number;
|
||||
Description?: string;
|
||||
PhotoCount?: number;
|
||||
@@ -551,7 +511,6 @@ export interface PpAlbum {
|
||||
Type: string;
|
||||
Title: string;
|
||||
Description?: string;
|
||||
Favorite?: boolean;
|
||||
PhotoCount?: number;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
* string PhotoPrism's `q` parameter accepts.
|
||||
*
|
||||
* Sections behave like saved searches: picking a section sets a default
|
||||
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
|
||||
* etc.), and the search box on top stacks an additional `q` term.
|
||||
* filter shape (archive → `archived:true`, etc.), and the search box on
|
||||
* top stacks an additional `q` term.
|
||||
*/
|
||||
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
|
||||
|
||||
export type Section =
|
||||
| 'all-photos'
|
||||
| 'favorites'
|
||||
| 'review'
|
||||
| 'archive'
|
||||
| 'hidden'
|
||||
@@ -30,7 +29,7 @@ export interface FilterState {
|
||||
// Default landing = root folder (`/`). The Folders group sits at the top
|
||||
// of the sidebar; landing inside it gives users a stable starting point
|
||||
// instead of dumping them into the full library. Picking any other view
|
||||
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
|
||||
// (a heap, "All photos") clears `folderPath` to `null`.
|
||||
export const filters = $state<FilterState>({
|
||||
section: 'all-photos',
|
||||
heapUid: null,
|
||||
@@ -69,9 +68,6 @@ function quoteIfNeeded(v: string): string {
|
||||
export function filtersToQ(f: FilterState = filters): string {
|
||||
const parts: string[] = [];
|
||||
switch (f.section) {
|
||||
case 'favorites':
|
||||
parts.push('favorite:true');
|
||||
break;
|
||||
case 'review':
|
||||
// PhotoPrism's review pile: photos the indexer flagged as
|
||||
// uncertain (low quality score). Cleared per-photo via the
|
||||
@@ -127,7 +123,7 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||
const sectionRaw = params.get('section') as Section | null;
|
||||
const section: Section =
|
||||
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||
sectionRaw && ['all-photos', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||
? sectionRaw
|
||||
: 'all-photos';
|
||||
// Bare URL (no section/folder/heap/q params) lands on the root folder
|
||||
|
||||
@@ -55,9 +55,7 @@ export interface PpClientConfig {
|
||||
documents?: number;
|
||||
archived?: number;
|
||||
hidden?: number;
|
||||
favorites?: number;
|
||||
review?: number;
|
||||
private?: number;
|
||||
albums?: number;
|
||||
moments?: number;
|
||||
months?: number;
|
||||
@@ -131,8 +129,6 @@ export interface PpPhoto {
|
||||
Height?: number;
|
||||
Rating?: number;
|
||||
Color?: string | number;
|
||||
Favorite?: boolean;
|
||||
Private?: boolean;
|
||||
Archived?: boolean;
|
||||
Files?: PpFile[];
|
||||
Lat?: number;
|
||||
@@ -213,7 +209,7 @@ export interface PpPhotoLabel {
|
||||
Source?: string;
|
||||
Priority?: number;
|
||||
Uncertainty?: number;
|
||||
Label?: { Slug: string; Name: string; Favorite?: boolean };
|
||||
Label?: { Slug: string; Name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,8 +94,6 @@
|
||||
const sectionLabel = $derived(buildSectionLabel());
|
||||
function buildSectionLabel(): string {
|
||||
switch (filters.section) {
|
||||
case "favorites":
|
||||
return "Favorites";
|
||||
case "review":
|
||||
return "Review";
|
||||
case "archive":
|
||||
@@ -184,7 +182,7 @@
|
||||
// Root-folder scope is the only client-side filter we apply, and only
|
||||
// when the timeline is actually showing a folder view — never when the
|
||||
// user is in a heap, has a free-form search, or is on a non-default
|
||||
// section (favorites / archive / review / hidden). Those views are
|
||||
// section (archive / review / hidden). Those views are
|
||||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||
// or labels / search will silently drop subfolder photos when the
|
||||
// store hasn't fully hydrated from the URL yet.
|
||||
@@ -867,8 +865,6 @@
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#if filters.section === "archive"}
|
||||
Archive is empty.
|
||||
{:else if filters.section === "favorites"}
|
||||
No favorites yet. Heart a photo to add it here.
|
||||
{:else if filters.section === "review"}
|
||||
Nothing left to review. Photos PhotoPrism's indexer wasn't sure
|
||||
about land here — use Keep to accept them into the timeline or
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
type DuplicateGroup
|
||||
} from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
setThumbnailSize,
|
||||
THUMBNAIL_SIZE_LABELS,
|
||||
THUMBNAIL_SIZE_PRESETS,
|
||||
view
|
||||
} from '$lib/stores/view.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||
|
||||
// Same pill-tab pattern as /tags: tab state is URL-driven so the user
|
||||
// can share / refresh / hit Back and land on the right panel.
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
||||
function parseTab(raw: string | null): Tab {
|
||||
return raw === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||
}
|
||||
function setTab(tab: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tab !== 'stacks') params.set('tab', tab);
|
||||
void goto(`/duplicates${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
|
||||
// toolbar bounces don't refetch the (potentially expensive) stack
|
||||
// listing. Invalidation by mutations is explicit, not time-driven.
|
||||
// Enabled on both tabs so the stacks-count badge stays accurate even
|
||||
// while the cross-folder tab is open.
|
||||
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
// Observe-only: DuplicatesView's matching query (same key) is what
|
||||
// actually triggers the scan when the cross-folder tab is active.
|
||||
// Here we just read the cached count for the tab badge.
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const stacksCount = $derived(dupesQuery.data?.length);
|
||||
const crossCount = $derived(crossQuery.data?.groups.length);
|
||||
const TABS = $derived<{ id: Tab; label: string; count: number | undefined }[]>([
|
||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossCount }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Duplicates
|
||||
</span>
|
||||
<!-- Tabs mirror /tags' pill row visually: same height, same active
|
||||
treatment, same hover affordance. Count badge appears once the
|
||||
underlying query has data — cross-folder stays unbadged until
|
||||
the tab has been opened at least once (lazy scan). -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each TABS as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
{#if t.count !== undefined}
|
||||
<span
|
||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||
t.id
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet trailing()}
|
||||
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
|
||||
store is global, so the picked size persists across routes —
|
||||
when you come back to the timeline it stays where you left it. -->
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded border border-border"
|
||||
role="group"
|
||||
aria-label="Thumbnail size"
|
||||
>
|
||||
{#each THUMBNAIL_SIZE_PRESETS as size, i (size)}
|
||||
<button
|
||||
type="button"
|
||||
class="px-1.5 py-0.5 text-[10px] font-medium hover:bg-accent"
|
||||
class:bg-accent={view.thumbnailSize === size}
|
||||
class:text-foreground={view.thumbnailSize === size}
|
||||
class:text-muted-foreground={view.thumbnailSize !== size}
|
||||
onclick={() => setThumbnailSize(size)}
|
||||
title={`${THUMBNAIL_SIZE_LABELS[i]} · ${size}px`}
|
||||
>
|
||||
{THUMBNAIL_SIZE_LABELS[i]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DuplicatesView
|
||||
{activeTab}
|
||||
groups={dupesQuery.data ?? []}
|
||||
pending={dupesQuery.isPending}
|
||||
error={dupesQuery.error}
|
||||
/>
|
||||
</main>
|
||||
11
web/src/routes/duplicates/+page.ts
Normal file
11
web/src/routes/duplicates/+page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// /duplicates was folded into /review as additional tabs. Preserve
|
||||
// bookmarks and external links with a server-side redirect to the
|
||||
// equivalent /review URL.
|
||||
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = ({ url }) => {
|
||||
const tab = url.searchParams.get('tab') === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||
redirect(307, `/review?tab=${tab}`);
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
cancelImport,
|
||||
getImportInfo,
|
||||
startImport,
|
||||
type ImportInfo
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
|
||||
// (separate from /originals which is what the timeline reads). The
|
||||
// folders endpoint returns the staging tree + counts via headers;
|
||||
// kicking off the import is a single POST to /import. Mutations land
|
||||
// in originals after PhotoPrism finishes processing — invalidate the
|
||||
// originals/folders/config caches so the rest of the UI catches up.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
// Refetch every 5 s while the page is open so progress is visible
|
||||
// without the user having to refresh. Cheap call — just headers
|
||||
// and a folder list.
|
||||
refetchInterval: 5_000
|
||||
}));
|
||||
|
||||
const qc = useQueryClient();
|
||||
const importMut = createMutation(() => ({
|
||||
// `move: true` is the typical workflow — once a file is indexed
|
||||
// into originals it doesn't need to linger in the staging area.
|
||||
mutationFn: () => startImport({ move: true }),
|
||||
onSuccess: (r) => {
|
||||
toast.success(r.message ?? 'Import started');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
}));
|
||||
|
||||
const cancelMut = createMutation(() => ({
|
||||
mutationFn: cancelImport,
|
||||
onSuccess: () => {
|
||||
toast.message('Import cancelled');
|
||||
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
const fileCount = $derived(importQuery.data?.files ?? 0);
|
||||
const folderCount = $derived(importQuery.data?.folders ?? 0);
|
||||
const empty = $derived(fileCount === 0 && folderCount === 0);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Inbox
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={empty || importMut.isPending}
|
||||
onclick={() => importMut.mutate()}
|
||||
title="Index files from the inbox into the main library"
|
||||
>
|
||||
{importMut.isPending ? 'Importing…' : 'Start import'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={!importMut.isPending}
|
||||
onclick={() => cancelMut.mutate()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
{#if importQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading inbox…</p>
|
||||
{:else if importQuery.isError}
|
||||
<p class="text-sm text-destructive">
|
||||
Failed to read inbox: {importQuery.error instanceof Error
|
||||
? importQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if empty}
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
<p>The inbox is empty.</p>
|
||||
<p>
|
||||
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
|
||||
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
|
||||
and they'll show up here. Click <strong>Start import</strong> to move them into the
|
||||
main library; PhotoPrism indexes them, deduplicates against existing originals, and
|
||||
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
|
||||
? ''
|
||||
: 's'}.
|
||||
</p>
|
||||
{#if importQuery.data && importQuery.data.subfolders.length > 0}
|
||||
<!-- PhotoPrism doesn't surface a per-folder file count for
|
||||
/import; we just list the staging subfolders so the
|
||||
user has a sense of what's in there. -->
|
||||
<ul class="space-y-1 text-[12px]">
|
||||
{#each importQuery.data.subfolders as f (f.Path)}
|
||||
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
|
||||
<span class="truncate font-mono">{f.Path || '/'}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1,15 +1,18 @@
|
||||
<!--
|
||||
/review — PhotoPrism's quality-flagged photo queue, one tab per
|
||||
cause. Tiles, selection, keyboard nav, and bulk actions all come
|
||||
from the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||
BulkActionBar, BulkMetadataSidebar) so muscle memory carries
|
||||
across routes.
|
||||
/review — PhotoPrism's quality-flagged photo queue *plus* the
|
||||
duplicate-resolution panels (stacks & cross-folder). Cause tabs and
|
||||
duplicate tabs share the same pill row so the user has a single
|
||||
"things to clean up" surface instead of two routes.
|
||||
|
||||
Cause tabs use the shared timeline machinery (PhotoGrid, gridKeyNav,
|
||||
BulkActionBar, BulkMetadataSidebar); duplicate tabs are a different
|
||||
flow (per-group card with its own action buttons) so they render in
|
||||
a stripped-down layout with no BulkActionBar / right sidebar.
|
||||
|
||||
The route flips `filters.section = 'review'` while it's mounted —
|
||||
that's what swings the shared action surface into review semantics
|
||||
(BulkActionBar shows Dismiss/Archive, gridKeyNav's S maps to
|
||||
approve). The previous section is restored on unmount so going
|
||||
back to `/` lands on whatever the user had before.
|
||||
approve). The previous section is restored on unmount.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -20,6 +23,14 @@
|
||||
type CauseKey,
|
||||
type ReviewGroup
|
||||
} from '$lib/services/adapters/review';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
type DuplicateGroup
|
||||
} from '$lib/services/adapters/duplicates';
|
||||
import {
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { clearSelection, selection } from '$lib/stores/selection.svelte';
|
||||
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||
@@ -39,6 +50,14 @@
|
||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||
|
||||
type DupTab = 'stacks' | 'cross-folder';
|
||||
type Tab = CauseKey | DupTab;
|
||||
|
||||
function isDupTab(t: Tab | null): t is DupTab {
|
||||
return t === 'stacks' || t === 'cross-folder';
|
||||
}
|
||||
|
||||
// Stash the section that was active when the user arrived; restore
|
||||
// on unmount so navigating away doesn't leak `section=review` to
|
||||
@@ -59,6 +78,24 @@
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
// Stacks is a cheap PhotoPrism query so we run it eagerly — the
|
||||
// "Stacks" tab badge needs the count even while the user is on a
|
||||
// cause tab. Cross-folder is the expensive disk scan; the page
|
||||
// observes its cache (enabled:false) and DuplicatesView is what
|
||||
// triggers the actual scan when its tab is active.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||
queryKey: ['photo', selection.focused ?? ''],
|
||||
queryFn: () => (selection.focused ? getPhoto(selection.focused) : Promise.resolve(null)),
|
||||
@@ -66,20 +103,33 @@
|
||||
}));
|
||||
|
||||
const groups = $derived(reviewQuery.data ?? []);
|
||||
const tabs = $derived(
|
||||
groups.map((g) => ({ id: g.cause, label: g.meta.title, count: g.photos.length }))
|
||||
);
|
||||
const stacksCount = $derived(stacksQuery.data?.length);
|
||||
const crossFolderCount = $derived(crossFolderQuery.data?.groups.length);
|
||||
|
||||
type TabSpec = { id: Tab; label: string; count: number | undefined };
|
||||
const tabs = $derived<TabSpec[]>([
|
||||
...groups.map((g) => ({
|
||||
id: g.cause as Tab,
|
||||
label: g.meta.title,
|
||||
count: g.photos.length as number | undefined
|
||||
})),
|
||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
|
||||
]);
|
||||
|
||||
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||
const activeTab: CauseKey | null = $derived.by(() => {
|
||||
if (tabs.length === 0) return null;
|
||||
const activeTab: Tab = $derived.by(() => {
|
||||
const want = tabs.find((t) => t.id === requestedTab);
|
||||
return (want ?? tabs[0]).id;
|
||||
});
|
||||
const activeIsDup = $derived(isDupTab(activeTab));
|
||||
|
||||
// Selection is global — without this effect, a user who multi-
|
||||
// selected in `low_resolution` then switched to `stripped_exif`
|
||||
// would carry the previous tab's UIDs into the new tab's
|
||||
// BulkActionBar verbs and accidentally act on the wrong photos.
|
||||
// Also fires when switching into a duplicates tab (where selection
|
||||
// is meaningless anyway).
|
||||
$effect(() => {
|
||||
void activeTab;
|
||||
clearSelection();
|
||||
@@ -87,9 +137,13 @@
|
||||
|
||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||
|
||||
function setTab(id: CauseKey) {
|
||||
function setTab(id: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tabs.length > 0 && tabs[0].id !== id) params.set('tab', id);
|
||||
// First cause tab (if any) is the default — same convention as
|
||||
// the old /review behaviour, so back-from-cross-folder lands on
|
||||
// the user's review queue rather than the empty Stacks panel.
|
||||
const defaultId = tabs[0]?.id;
|
||||
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
@@ -106,13 +160,22 @@
|
||||
{#each tabs as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
class="inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
<span class="ml-1 text-muted-foreground/70">({t.count})</span>
|
||||
<span>{t.label}</span>
|
||||
{#if t.count !== undefined}
|
||||
<span
|
||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
||||
t.id
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{t.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -140,60 +203,81 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||
{#if reviewQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {reviewQuery.error instanceof Error
|
||||
? reviewQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>The review queue is empty.</p>
|
||||
<p class="text-xs">
|
||||
PhotoPrism's indexer flags photos with a low quality score for human
|
||||
review. New arrivals with missing EXIF, low resolution, or unknown
|
||||
cameras will land here.
|
||||
<!--
|
||||
Two layout branches:
|
||||
• Cause tabs use the timeline-style layout (grid key nav, bulk
|
||||
action bar, right metadata sidebar) since the user is acting on
|
||||
individual photos.
|
||||
• Duplicate tabs use a stripped layout — DuplicatesView renders
|
||||
its own per-group cards with built-in actions, so the bulk bar
|
||||
and right sidebar would just clutter.
|
||||
-->
|
||||
{#if activeIsDup}
|
||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DuplicatesView
|
||||
activeTab={activeTab as DupTab}
|
||||
groups={stacksQuery.data ?? []}
|
||||
pending={stacksQuery.isPending}
|
||||
error={stacksQuery.error}
|
||||
/>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||
{#if reviewQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
||||
{:else if reviewQuery.error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load review queue: {reviewQuery.error instanceof Error
|
||||
? reviewQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/if}
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<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} showRelated />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>The review queue is empty.</p>
|
||||
<p class="text-xs">
|
||||
PhotoPrism's indexer flags photos with a low quality score for human
|
||||
review. New arrivals with missing EXIF, low resolution, or unknown
|
||||
cameras will land here. The Stacks and Cross-folder tabs above stay
|
||||
available for duplicate cleanup.
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeGroup}
|
||||
{#key activeGroup.cause}
|
||||
<CauseGroupCard group={activeGroup} />
|
||||
{/key}
|
||||
{/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>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed && (selection.focused || selection.ids.size >= 2)}
|
||||
<aside
|
||||
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||
style="width: {view.rightSidebarWidth}px;"
|
||||
>
|
||||
<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} showRelated />
|
||||
{:else if focusedPhotoQuery.isFetching}
|
||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
||||
{/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>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user