feat(timeline): focus follows archive, snaps to first on view load
- New selection.focusAfter(excluded) walks selection.order forward past the archived/restored set so X-ing through the timeline keeps the cursor on the next live photo instead of falling back to photo[0] via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X key) and BulkActionBar.onArchive. - Auto-focus effect on the timeline always re-anchors to photos[0] on view load (pageCount → 1), instead of preserving a stale uid from the previous filter. - PhotoGrid re-anchors focus when the previously focused uid isn't in the new photo set, so drilling into a /tags category drops the cursor on its first tile instead of carrying a stale selection from whatever view the user came from. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { filters } from '$lib/stores/filters.svelte';
|
|||||||
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
|
||||||
import {
|
import {
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
focusAfter,
|
||||||
indexOf,
|
indexOf,
|
||||||
selectRange,
|
selectRange,
|
||||||
selection,
|
selection,
|
||||||
@@ -218,6 +219,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Move focus forward before the photos query refetches, so the
|
||||||
|
// user can keep X-ing through the timeline without their cursor
|
||||||
|
// snapping back to photo[0]. Walks past every uid we just
|
||||||
|
// archived/restored — relevant when the cull targets came from a
|
||||||
|
// multi-selection rather than the single focused tile.
|
||||||
|
focusAfter(ids);
|
||||||
invalidatePhotos(ids);
|
invalidatePhotos(ids);
|
||||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||||
toast.success(label);
|
toast.success(label);
|
||||||
@@ -344,12 +351,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await addToHeap(heap.UID, ids);
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
// PhotoPrism returns 200 even when nothing was added — distinguish
|
||||||
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
// "really added N" from "skipped all N" so the toast tells the
|
||||||
await removeFromHeap(heap.UID, ids);
|
// truth.
|
||||||
|
if (added.length === 0) {
|
||||||
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
|
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (added.length < ids.length) {
|
||||||
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
|
description: 'The rest were already in this heap.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||||
|
}
|
||||||
|
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||||
|
await removeFromHeap(heap.UID, added);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,10 +119,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||||
];
|
];
|
||||||
|
|
||||||
async function applyKeyword() {
|
async function applyKeyword() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Heart,
|
Heart,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
MapPin,
|
MapPin,
|
||||||
Star,
|
Star,
|
||||||
@@ -244,11 +245,14 @@
|
|||||||
void applyMark({ color: value });
|
void applyMark({ color: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tooltips follow the Lightroom culling convention so the swatches
|
||||||
|
// read as actions, not just colors. Red = reject, Yellow = pick,
|
||||||
|
// Green = keep, Orange = review-later.
|
||||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
|
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
|
||||||
@@ -314,6 +318,11 @@
|
|||||||
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
||||||
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
|
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. -->
|
||||||
|
{#if renaming}
|
||||||
|
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
class="rounded p-1 hover:bg-accent disabled:opacity-50"
|
||||||
class:text-red-500={photo.Favorite}
|
class:text-red-500={photo.Favorite}
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { batchEdit } from '$lib/services/batch';
|
import { batchEdit } from '$lib/services/batch';
|
||||||
import { clearSelection, selection, setFocused } from '$lib/stores/selection.svelte';
|
import {
|
||||||
|
clearSelection,
|
||||||
|
focusAfter,
|
||||||
|
selection,
|
||||||
|
setFocused
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
@@ -98,6 +103,10 @@
|
|||||||
await batchRestore(ids);
|
await batchRestore(ids);
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
});
|
});
|
||||||
|
// Advance focus to the photo immediately after the archived
|
||||||
|
// set before the multi-selection is dropped — lets the user
|
||||||
|
// keep stepping through the timeline with X.
|
||||||
|
focusAfter(ids);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
toast.success(`Archived ${ids.length}`);
|
toast.success(`Archived ${ids.length}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -173,11 +182,27 @@
|
|||||||
heapPickerOpen = false;
|
heapPickerOpen = false;
|
||||||
await withBusy(async () => {
|
await withBusy(async () => {
|
||||||
try {
|
try {
|
||||||
await addToHeap(heap.UID, ids);
|
const { added } = await addToHeap(heap.UID, ids);
|
||||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
toast.success(`Added ${ids.length} → ${heap.Title}`);
|
// PhotoPrism returns 200 even when nothing was added (UIDs
|
||||||
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
|
// already present or unknown to the index) — surface the
|
||||||
await removeFromHeap(heap.UID, ids);
|
// real delta so the user isn't fooled by a green toast over
|
||||||
|
// a no-op.
|
||||||
|
if (added.length === 0) {
|
||||||
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
|
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (added.length < ids.length) {
|
||||||
|
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||||
|
description: 'The rest were already in this heap.'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||||
|
}
|
||||||
|
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||||
|
await removeFromHeap(heap.UID, added);
|
||||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
});
|
});
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
helpers.
|
helpers.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import {
|
import {
|
||||||
isSelected,
|
isSelected,
|
||||||
selection,
|
selection,
|
||||||
@@ -40,6 +41,26 @@
|
|||||||
const order = $derived(photos.map((p) => p.UID));
|
const order = $derived(photos.map((p) => p.UID));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
setOrder(order);
|
setOrder(order);
|
||||||
|
// Re-anchor focus when the previously focused photo isn't part of
|
||||||
|
// this grid — covers drilling into a /tags category from any
|
||||||
|
// other view, where the selection.focused module state would
|
||||||
|
// otherwise leak across surfaces and the new grid would render
|
||||||
|
// with no tile highlighted. Crucially we only re-anchor when the
|
||||||
|
// uid is *absent*, so refetches that keep the focused photo
|
||||||
|
// around (e.g. after `focusAfter` set the next photo on archive)
|
||||||
|
// don't snap focus back to photos[0].
|
||||||
|
untrack(() => {
|
||||||
|
if (order.length === 0) {
|
||||||
|
setFocused(null);
|
||||||
|
selection.ids.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cur = selection.focused;
|
||||||
|
if (cur && order.includes(cur)) return;
|
||||||
|
setFocused(order[0]);
|
||||||
|
setAnchor(order[0]);
|
||||||
|
selection.ids.clear();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function onClick(e: MouseEvent, uid: string) {
|
function onClick(e: MouseEvent, uid: string) {
|
||||||
|
|||||||
@@ -500,8 +500,19 @@ export async function deleteHeap(uid: string): Promise<void> {
|
|||||||
await http.delete(`/albums/${uid}`);
|
await http.delete(`/albums/${uid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addToHeap(uid: string, photos: string[]): Promise<void> {
|
/**
|
||||||
await http.post(`/albums/${uid}/photos`, { photos });
|
* PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`.
|
||||||
|
* Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism
|
||||||
|
* silently skips UIDs that are missing from the index or already in the album,
|
||||||
|
* which used to look like a successful add to the user.
|
||||||
|
*/
|
||||||
|
export interface AddToHeapResult {
|
||||||
|
added: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToHeap(uid: string, photos: string[]): Promise<AddToHeapResult> {
|
||||||
|
const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
|
||||||
|
return { added: data.added ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
|
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
|
||||||
|
|||||||
@@ -113,3 +113,51 @@ export function setFocused(uid: string | null): void {
|
|||||||
export function setAnchor(uid: string | null): void {
|
export function setAnchor(uid: string | null): void {
|
||||||
selection.anchor = uid;
|
selection.anchor = uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance focus to the photo immediately after `excluded` in the current
|
||||||
|
* order, skipping any uid that's in `excluded`. Falls back to the closest
|
||||||
|
* non-excluded uid *before* the excluded set when the user is already at
|
||||||
|
* the tail. Returns `null` when nothing else is left.
|
||||||
|
*
|
||||||
|
* Used right after a mutation that removes the focused photo from the
|
||||||
|
* current view (archive / restore / approve / delete) — calling this
|
||||||
|
* *before* the photo cache refetches keeps focus stable instead of the
|
||||||
|
* effect-driven anchor falling back to photo[0].
|
||||||
|
*/
|
||||||
|
export function focusAfter(excluded: Iterable<string>): string | null {
|
||||||
|
const excludedSet = excluded instanceof Set ? excluded : new Set(excluded);
|
||||||
|
const order = selection.order;
|
||||||
|
if (order.length === 0) {
|
||||||
|
setFocused(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Anchor index: prefer current focus, else the first excluded uid we
|
||||||
|
// can find (covers the case where focus was already null).
|
||||||
|
let anchorIdx = indexOf(selection.focused);
|
||||||
|
if (anchorIdx === -1) {
|
||||||
|
for (let i = 0; i < order.length; i++) {
|
||||||
|
if (excludedSet.has(order[i])) {
|
||||||
|
anchorIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anchorIdx === -1) return null;
|
||||||
|
for (let i = anchorIdx + 1; i < order.length; i++) {
|
||||||
|
if (!excludedSet.has(order[i])) {
|
||||||
|
setFocused(order[i]);
|
||||||
|
setAnchor(order[i]);
|
||||||
|
return order[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = anchorIdx - 1; i >= 0; i--) {
|
||||||
|
if (!excludedSet.has(order[i])) {
|
||||||
|
setFocused(order[i]);
|
||||||
|
setAnchor(order[i]);
|
||||||
|
return order[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFocused(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -191,6 +191,13 @@
|
|||||||
* append silently — we never want the focus to jump back to the top of
|
* append silently — we never want the focus to jump back to the top of
|
||||||
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
|
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
|
||||||
* from immediately re-triggering this effect.
|
* from immediately re-triggering this effect.
|
||||||
|
*
|
||||||
|
* Always re-anchors to photos[0] when pageCount lands on 1 (which
|
||||||
|
* only happens on initial load or after a filter change resets the
|
||||||
|
* infinite-query) so switching views drops the user back at the top
|
||||||
|
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
|
||||||
|
* etc.) advance focus themselves via `focusAfter` and don't flip
|
||||||
|
* pageCount, so they don't get clobbered by this re-anchor.
|
||||||
*/
|
*/
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Re-read pageCount so the effect bottoms out cleanly on filter
|
// Re-read pageCount so the effect bottoms out cleanly on filter
|
||||||
@@ -204,10 +211,7 @@
|
|||||||
// Only re-anchor focus on the very first page; later pages
|
// Only re-anchor focus on the very first page; later pages
|
||||||
// must not pull focus back to photo[0].
|
// must not pull focus back to photo[0].
|
||||||
if (pages !== 1) return;
|
if (pages !== 1) return;
|
||||||
const cur = selection.focused;
|
|
||||||
if (!cur || !photos.some((p) => p.UID === cur)) {
|
|
||||||
setFocused(photos[0].UID);
|
setFocused(photos[0].UID);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -658,6 +662,28 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSearch(searchDraft.trim());
|
setSearch(searchDraft.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||||
|
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||||
|
const SEARCH_EXAMPLES = [
|
||||||
|
'label:dog',
|
||||||
|
'keyword:vacation',
|
||||||
|
'taken:2024',
|
||||||
|
'"exact phrase"'
|
||||||
|
];
|
||||||
|
let searchFocused = $state(false);
|
||||||
|
function onSearchFocus() {
|
||||||
|
searchFocused = true;
|
||||||
|
}
|
||||||
|
function onSearchBlur() {
|
||||||
|
// Defer so a click on an example fires before the popover unmounts.
|
||||||
|
setTimeout(() => (searchFocused = false), 120);
|
||||||
|
}
|
||||||
|
function applySearchExample(ex: string) {
|
||||||
|
searchDraft = ex;
|
||||||
|
setSearch(ex);
|
||||||
|
searchFocused = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar showRightToggle>
|
<Toolbar showRightToggle>
|
||||||
@@ -687,12 +713,14 @@
|
|||||||
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
|
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
|
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder='Search · label:website / "vacation"'
|
placeholder='Search · label:website / "vacation"'
|
||||||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
bind:value={searchDraft}
|
bind:value={searchDraft}
|
||||||
|
onfocus={onSearchFocus}
|
||||||
|
onblur={onSearchBlur}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -713,6 +741,32 @@
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!--
|
||||||
|
Cheat-sheet popover: opens on input focus, lists working q-DSL
|
||||||
|
patterns. Clicking an example fills the input AND fires the
|
||||||
|
search, so it doubles as a one-click "try it" affordance.
|
||||||
|
-->
|
||||||
|
{#if searchFocused}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Examples
|
||||||
|
</div>
|
||||||
|
{#each SEARCH_EXAMPLES as ex (ex)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
applySearchExample(ex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ex}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
|
|||||||
@@ -138,11 +138,15 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Titles follow the Lightroom culling convention so users see the
|
||||||
|
// swatch's *intent* (reject/review/pick/keep), not just its color.
|
||||||
|
// Used both as tooltip on swatches and as the visible card label in
|
||||||
|
// the colors-tab picker grid below.
|
||||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
|
||||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
|
||||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
|
||||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
|
||||||
];
|
];
|
||||||
interface ColorGroup {
|
interface ColorGroup {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user