web: 8-color label palette + VIDEO badge in carousel

Expand color labels from the 4-swatch Lightroom culling palette to 8
neutral colors (red/orange/yellow/green/teal/blue/purple/pink) with no
attached semantics, rendered as outlines that fill in when picked.
Carousel tiles now show a VIDEO badge, and the folder row in the right
sidebar always renders ("/" for root) instead of disappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 09:56:05 +02:00
parent fc5f30fad1
commit ea1803ec2f
5 changed files with 70 additions and 53 deletions

View File

@@ -22,7 +22,7 @@
setFocused, setFocused,
toggle toggle
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -30,22 +30,22 @@
* any time, plenty to cover normal arrow-skim without bloating. */ * any time, plenty to cover normal arrow-skim without bloating. */
const WINDOW = 50; const WINDOW = 50;
function lookup(uid: string): string | null { function lookup(uid: string): PpPhoto | null {
const direct = qc.getQueryData<PpPhoto>(['photo', uid]); const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
if (direct) return primaryFile(direct).Hash ?? null; if (direct) return direct;
const lists = qc.getQueriesData({ queryKey: ['photos'] }); const lists = qc.getQueriesData({ queryKey: ['photos'] });
for (const [, data] of lists) { for (const [, data] of lists) {
if (!data) continue; if (!data) continue;
if (Array.isArray(data)) { if (Array.isArray(data)) {
const hit = (data as PpPhoto[]).find((p) => p.UID === uid); const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null; if (hit) return hit;
continue; continue;
} }
const pages = (data as { pages?: PpPhoto[][] }).pages; const pages = (data as { pages?: PpPhoto[][] }).pages;
if (!Array.isArray(pages)) continue; if (!Array.isArray(pages)) continue;
for (const page of pages) { for (const page of pages) {
const hit = page?.find?.((p) => p.UID === uid); const hit = page?.find?.((p) => p.UID === uid);
if (hit) return primaryFile(hit).Hash ?? null; if (hit) return hit;
} }
} }
return null; return null;
@@ -58,6 +58,7 @@
interface Tile { interface Tile {
uid: string; uid: string;
hash: string | null; hash: string | null;
video: boolean;
idx: number; idx: number;
} }
const slice = $derived.by<Tile[]>(() => { const slice = $derived.by<Tile[]>(() => {
@@ -66,7 +67,13 @@
const hi = Math.min(order.length, focusedIdx + WINDOW + 1); const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
const out: Tile[] = []; const out: Tile[] = [];
for (let i = lo; i < hi; i++) { for (let i = lo; i < hi; i++) {
out.push({ uid: order[i], hash: lookup(order[i]), idx: i }); const photo = lookup(order[i]);
out.push({
uid: order[i],
hash: photo ? (primaryFile(photo).Hash ?? null) : null,
video: photo ? isVideo(photo) : false,
idx: i
});
} }
return out; return out;
}); });
@@ -154,6 +161,12 @@
{#if isSelected} {#if isSelected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div> <div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if} {/if}
{#if tile.video}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button> </button>
{/each} {/each}
</div> </div>

View File

@@ -18,6 +18,7 @@
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { patchTargets } from '$lib/services/bulk'; import { patchTargets } from '$lib/services/bulk';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -119,13 +120,6 @@
colorDraft = null; colorDraft = null;
} }
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
async function applyKeyword() { async function applyKeyword() {
if (busy) return; if (busy) return;
const kw = keywordDraft.trim().replace(/,/g, ''); const kw = keywordDraft.trim().replace(/,/g, '');
@@ -270,16 +264,18 @@
</button> </button>
</section> </section>
<!-- Color label — same pattern as Score. --> <!-- Colors — same pattern as Score. -->
<section class="space-y-1"> <section class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div> <div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label"> <div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)} {#each COLOR_SWATCHES as c (c.key)}
{@const picked = colorDraft === c.key}
<button <button
type="button" type="button"
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}" class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
class:ring-foreground={colorDraft === c.key} ? c.bg
class:ring-transparent={colorDraft !== c.key} : 'bg-transparent'}"
aria-pressed={picked}
disabled={busy} disabled={busy}
onclick={() => (colorDraft = c.key)} onclick={() => (colorDraft = c.key)}
title={`Pick ${c.title}`} title={`Pick ${c.title}`}

View File

@@ -38,6 +38,7 @@
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import RelatedStrip from './RelatedStrip.svelte'; import RelatedStrip from './RelatedStrip.svelte';
interface Props { interface Props {
@@ -225,29 +226,20 @@
} }
/** Click-to-toggle: clicking the current color clears it; clicking a /** Click-to-toggle: clicking the current color clears it; clicking a
* different swatch swaps. Same four-swatch palette as mule-image. */ * different swatch swaps. */
function setColor(next: string) { function setColor(next: string) {
const value = currentColor === next ? '' : next; const value = currentColor === next ? '' : next;
if (value === currentColor) return; if (value === currentColor) return;
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 }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ 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] ?? {});
const currentRating = $derived(photoMark.rating ?? 0); const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? ''); const currentColor = $derived(photoMark.color ?? '');
const pf = $derived(primaryFile(photo)); const pf = $derived(primaryFile(photo));
const dirPath = $derived(splitName(pf.Name ?? '').dir); const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—'); const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
const sizeStr = $derived( const sizeStr = $derived(
pf.Size pf.Size
@@ -336,15 +328,14 @@
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input <!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
padding on filename / date so the read-only text starts at the padding on filename / date so the read-only text starts at the
same x-offset as the editable rows above — otherwise spans same x-offset as the editable rows above — otherwise spans
hug the icon while inputs sit 4px in. --> hug the icon while inputs sit 4px in. Root-level files render
{#if dirPath} as `/` so the row never disappears and the layout stays stable. -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}> <span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={folderLabel}>
{dirPath}/ {folderLabel}
</span> </span>
</div> </div>
{/if}
<!-- Dimensions --> <!-- Dimensions -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -434,14 +425,16 @@
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div> <div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
<div class="flex items-center gap-1" role="group" aria-label="Color label"> <div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
{#each COLOR_SWATCHES as c (c.key)} {#each COLOR_SWATCHES as c (c.key)}
{@const picked = currentColor === c.key}
<button <button
type="button" type="button"
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}" class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
class:ring-foreground={currentColor === c.key} ? c.bg
class:ring-transparent={currentColor !== c.key} : 'bg-transparent'}"
aria-pressed={picked}
onclick={() => setColor(c.key)} onclick={() => setColor(c.key)}
title={c.title} title={c.title}
aria-label={`Color ${c.key}`} aria-label={`Color ${c.key}`}

View File

@@ -465,7 +465,8 @@
onclick={() => pickColor(swatch.key)} onclick={() => pickColor(swatch.key)}
title={swatch.title} title={swatch.title}
> >
<span class="h-3 w-3 shrink-0 rounded-full {swatch.bg}"></span> <span class="h-3 w-3 shrink-0 rounded-full border-2 bg-transparent {swatch.border}"
></span>
<span class="min-w-0 flex-1 truncate">{swatch.title}</span> <span class="min-w-0 flex-1 truncate">{swatch.title}</span>
<span <span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active

View File

@@ -10,15 +10,29 @@ import type { PhotoMarksMap } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
/** /**
* Lightroom culling convention: red rejects, orange reviews, yellow * Color labels are purely a marking dimension — no semantic meaning is
* picks, green keeps. Order here is the order the TagsBrowser renders * attached. Order here is the order the TagsBrowser renders rows in,
* rows in — fixed so the user can build muscle memory. * roughly rainbow-then-neutrals so the picker reads naturally.
*
* `bg` and `border` are paired Tailwind classes so a swatch can be
* rendered either filled (e.g. to indicate selection) or as a colored
* outline (the default display). Literal strings keep Tailwind's
* content scanner happy — do not interpolate.
*/ */
export const COLOR_SWATCHES: readonly { key: string; bg: string; title: string }[] = [ export const COLOR_SWATCHES: readonly {
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' }, key: string;
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' }, bg: string;
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' }, border: string;
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' } title: string;
}[] = [
{ key: 'red', bg: 'bg-red-500', border: 'border-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', border: 'border-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', border: 'border-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', border: 'border-green-500', title: 'Green' },
{ key: 'teal', bg: 'bg-teal-500', border: 'border-teal-500', title: 'Teal' },
{ key: 'blue', bg: 'bg-blue-500', border: 'border-blue-500', title: 'Blue' },
{ key: 'purple', bg: 'bg-purple-500', border: 'border-purple-500', title: 'Purple' },
{ key: 'pink', bg: 'bg-pink-500', border: 'border-pink-500', title: 'Pink' }
] as const; ] as const;
export interface RatingGroup { export interface RatingGroup {