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