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:
@@ -22,7 +22,7 @@
|
||||
setFocused,
|
||||
toggle
|
||||
} 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();
|
||||
|
||||
@@ -30,22 +30,22 @@
|
||||
* any time, plenty to cover normal arrow-skim without bloating. */
|
||||
const WINDOW = 50;
|
||||
|
||||
function lookup(uid: string): string | null {
|
||||
function lookup(uid: string): PpPhoto | null {
|
||||
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (direct) return primaryFile(direct).Hash ?? null;
|
||||
if (direct) return direct;
|
||||
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
if (hit) return hit;
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
for (const page of pages) {
|
||||
const hit = page?.find?.((p) => p.UID === uid);
|
||||
if (hit) return primaryFile(hit).Hash ?? null;
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -58,6 +58,7 @@
|
||||
interface Tile {
|
||||
uid: string;
|
||||
hash: string | null;
|
||||
video: boolean;
|
||||
idx: number;
|
||||
}
|
||||
const slice = $derived.by<Tile[]>(() => {
|
||||
@@ -66,7 +67,13 @@
|
||||
const hi = Math.min(order.length, focusedIdx + WINDOW + 1);
|
||||
const out: Tile[] = [];
|
||||
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;
|
||||
});
|
||||
@@ -154,6 +161,12 @@
|
||||
{#if isSelected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -119,13 +120,6 @@
|
||||
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() {
|
||||
if (busy) return;
|
||||
const kw = keywordDraft.trim().replace(/,/g, '');
|
||||
@@ -270,16 +264,18 @@
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Color label — same pattern as Score. -->
|
||||
<!-- Colors — same pattern as Score. -->
|
||||
<section class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = colorDraft === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full ring-2 transition-all disabled:opacity-50 {c.bg}"
|
||||
class:ring-foreground={colorDraft === c.key}
|
||||
class:ring-transparent={colorDraft !== c.key}
|
||||
class="h-4 w-4 rounded-full border-2 transition-all disabled:opacity-50 {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
disabled={busy}
|
||||
onclick={() => (colorDraft = c.key)}
|
||||
title={`Pick ${c.title}`}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import RelatedStrip from './RelatedStrip.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -225,29 +226,20 @@
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const value = currentColor === next ? '' : next;
|
||||
if (value === currentColor) return;
|
||||
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 currentRating = $derived(photoMark.rating ?? 0);
|
||||
const currentColor = $derived(photoMark.color ?? '');
|
||||
|
||||
const pf = $derived(primaryFile(photo));
|
||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||
const sizeStr = $derived(
|
||||
pf.Size
|
||||
@@ -336,15 +328,14 @@
|
||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
||||
padding on filename / date so the read-only text starts at the
|
||||
same x-offset as the editable rows above — otherwise spans
|
||||
hug the icon while inputs sit 4px in. -->
|
||||
{#if dirPath}
|
||||
<div class="flex items-center gap-2">
|
||||
<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}>
|
||||
{dirPath}/
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
hug the icon while inputs sit 4px in. Root-level files render
|
||||
as `/` so the row never disappears and the layout stays stable. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<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={folderLabel}>
|
||||
{folderLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Dimensions -->
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -434,14 +425,16 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Color label</div>
|
||||
<div class="flex items-center gap-1" role="group" aria-label="Color label">
|
||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Colors</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="Colors">
|
||||
{#each COLOR_SWATCHES as c (c.key)}
|
||||
{@const picked = currentColor === c.key}
|
||||
<button
|
||||
type="button"
|
||||
class="h-4 w-4 rounded-full ring-2 transition-all {c.bg}"
|
||||
class:ring-foreground={currentColor === c.key}
|
||||
class:ring-transparent={currentColor !== c.key}
|
||||
class="h-4 w-4 rounded-full border-2 transition-all {c.border} {picked
|
||||
? c.bg
|
||||
: 'bg-transparent'}"
|
||||
aria-pressed={picked}
|
||||
onclick={() => setColor(c.key)}
|
||||
title={c.title}
|
||||
aria-label={`Color ${c.key}`}
|
||||
|
||||
@@ -465,7 +465,8 @@
|
||||
onclick={() => pickColor(swatch.key)}
|
||||
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="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
|
||||
@@ -10,15 +10,29 @@ import type { PhotoMarksMap } from '$lib/services/photoprism';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
|
||||
/**
|
||||
* Lightroom culling convention: red rejects, orange reviews, yellow
|
||||
* picks, green keeps. Order here is the order the TagsBrowser renders
|
||||
* rows in — fixed so the user can build muscle memory.
|
||||
* Color labels are purely a marking dimension — no semantic meaning is
|
||||
* attached. Order here is the order the TagsBrowser renders rows in,
|
||||
* 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 }[] = [
|
||||
{ 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' }
|
||||
export const COLOR_SWATCHES: readonly {
|
||||
key: string;
|
||||
bg: string;
|
||||
border: string;
|
||||
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;
|
||||
|
||||
export interface RatingGroup {
|
||||
|
||||
Reference in New Issue
Block a user