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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user