Mulimage 2.0 #1
@@ -1,5 +1,6 @@
|
||||
<!--
|
||||
Duplicate-resolution page body. Two tabs:
|
||||
Duplicate-resolution page body. Two panels driven by the parent
|
||||
route's `activeTab` prop (URL-bound):
|
||||
|
||||
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
|
||||
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
|
||||
@@ -14,6 +15,9 @@
|
||||
The cross-folder scan is opt-in (button-triggered) rather than
|
||||
auto-run because it's an O(disk) operation. With size pre-filtering
|
||||
the scan stays fast (~250ms for 400 files in practice).
|
||||
|
||||
Tabs themselves render in the parent route's Toolbar so they line up
|
||||
visually with the `/tags` pill row.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -26,18 +30,18 @@
|
||||
import StackGroupCard from './StackGroupCard.svelte';
|
||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
|
||||
interface Props {
|
||||
activeTab: Tab;
|
||||
groups: DuplicateGroup[];
|
||||
pending: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
let { groups, pending, error }: Props = $props();
|
||||
let { activeTab, groups, pending, error }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
let activeTab = $state<Tab>('stacks');
|
||||
|
||||
// Cross-folder scan is a manually-triggered query: `enabled` stays
|
||||
// false until the user clicks "Scan filesystem". Subsequent clicks
|
||||
// invalidate the cache so each press kicks a fresh scan.
|
||||
@@ -45,7 +49,7 @@
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: scanRequested,
|
||||
enabled: scanRequested && activeTab === 'cross-folder',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
@@ -67,159 +71,89 @@
|
||||
}
|
||||
});
|
||||
|
||||
const stackCount = $derived(groups.length);
|
||||
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
|
||||
|
||||
// Tabs: only the visible card under the active tab should auto-focus.
|
||||
// We pass `autoFocus={i === 0}` into the FIRST card of the active tab
|
||||
// (and only when that tab is selected) so keyboard navigation lands
|
||||
// on the right place when the user switches tabs.
|
||||
function tabBtnClass(tab: Tab) {
|
||||
const base =
|
||||
'inline-flex items-center gap-2 border-b-2 px-3 py-1.5 text-sm transition-colors';
|
||||
return tab === activeTab
|
||||
? `${base} border-foreground text-foreground`
|
||||
: `${base} border-transparent text-muted-foreground hover:text-foreground`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Tab bar — sticky so it stays visible while the panel scrolls.
|
||||
Same horizontal padding as the panels below so labels line up. -->
|
||||
<div
|
||||
role="tablist"
|
||||
class="sticky top-0 z-10 flex items-center gap-1 border-b border-border bg-background px-6"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'stacks'}
|
||||
class={tabBtnClass('stacks')}
|
||||
onclick={() => (activeTab = 'stacks')}
|
||||
>
|
||||
Stacks
|
||||
<span
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{pending ? '…' : stackCount}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'cross-folder'}
|
||||
class={tabBtnClass('cross-folder')}
|
||||
onclick={() => (activeTab = 'cross-folder')}
|
||||
>
|
||||
Cross-folder
|
||||
<span
|
||||
class="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{#if !scanRequested}
|
||||
·
|
||||
{:else if crossQuery.isFetching && !crossQuery.data}
|
||||
…
|
||||
{:else}
|
||||
{crossCount}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Stacks tab ----------------------------------------------------- -->
|
||||
{#if activeTab === 'stacks'}
|
||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||
{#if pending}
|
||||
<p class="text-sm text-muted-foreground">Loading stacks…</p>
|
||||
{:else if error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'}
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>No stacks.</p>
|
||||
<p class="text-xs">
|
||||
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
|
||||
time live under the Cross-folder tab.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each groups as group, i (group.photo.UID)}
|
||||
<StackGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stacks tab ----------------------------------------------------- -->
|
||||
{#if activeTab === 'stacks'}
|
||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 pb-6">
|
||||
{#if pending}
|
||||
<p class="text-sm text-muted-foreground">Loading stacks…</p>
|
||||
{:else if error}
|
||||
<p class="text-sm text-destructive">
|
||||
Could not load stacks: {error instanceof Error
|
||||
? error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if stackCount === 0}
|
||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
||||
<p>No stacks.</p>
|
||||
<p class="text-xs">
|
||||
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you
|
||||
don't have any, this tab stays empty. Cross-folder copies that
|
||||
PhotoPrism rejected at index time live under the
|
||||
<button
|
||||
type="button"
|
||||
class="underline hover:text-foreground"
|
||||
onclick={() => (activeTab = 'cross-folder')}
|
||||
>
|
||||
Cross-folder
|
||||
</button>
|
||||
tab.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each groups as group, i (group.photo.UID)}
|
||||
<StackGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Cross-folder tab ----------------------------------------------- -->
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||
<header class="flex items-baseline justify-between gap-3">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Byte-identical files PhotoPrism dropped at index time. Found by scanning the
|
||||
originals tree directly.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={crossQuery.isFetching}
|
||||
onclick={triggerScan}
|
||||
>
|
||||
{#if crossQuery.isFetching}
|
||||
Scanning…
|
||||
{:else if scanRequested}
|
||||
Rescan filesystem
|
||||
{:else}
|
||||
Scan filesystem
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Cross-folder tab ----------------------------------------------- -->
|
||||
{#if activeTab === 'cross-folder'}
|
||||
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 pb-6">
|
||||
<header class="flex items-baseline justify-between gap-3">
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Byte-identical files PhotoPrism dropped at index time. Found by
|
||||
scanning the originals tree directly.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={crossQuery.isFetching}
|
||||
onclick={triggerScan}
|
||||
>
|
||||
{#if crossQuery.isFetching}
|
||||
Scanning…
|
||||
{:else if scanRequested}
|
||||
Rescan filesystem
|
||||
{:else}
|
||||
Scan filesystem
|
||||
{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if !scanRequested}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Click <em>Scan filesystem</em> to look for byte-identical files spread
|
||||
across folders. Pre-filtered by size, so even large libraries finish
|
||||
in a few seconds.
|
||||
</p>
|
||||
{:else if crossQuery.isFetching && !crossQuery.data}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Hashing files under originals…
|
||||
</p>
|
||||
{:else if crossQuery.isError}
|
||||
<p class="text-sm text-destructive">
|
||||
Scan failed: {crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if crossCount === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No cross-folder duplicates found.
|
||||
{#if crossQuery.data}
|
||||
<span class="ml-1 text-[10px] text-muted-foreground/70">
|
||||
(scanned in {crossQuery.data.scannedMs} ms)
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
||||
<CrossFolderGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !scanRequested}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Click <em>Scan filesystem</em> to look for byte-identical files spread across
|
||||
folders. Pre-filtered by size, so even large libraries finish in a few seconds.
|
||||
</p>
|
||||
{:else if crossQuery.isFetching && !crossQuery.data}
|
||||
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
|
||||
{:else if crossQuery.isError}
|
||||
<p class="text-sm text-destructive">
|
||||
Scan failed: {crossQuery.error instanceof Error
|
||||
? crossQuery.error.message
|
||||
: 'unknown error'}
|
||||
</p>
|
||||
{:else if crossCount === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No cross-folder duplicates found.
|
||||
{#if crossQuery.data}
|
||||
<span class="ml-1 text-[10px] text-muted-foreground/70">
|
||||
(scanned in {crossQuery.data.scannedMs} ms)
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
||||
<CrossFolderGroupCard {group} autoFocus={i === 0} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -352,9 +352,19 @@
|
||||
{ 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 },
|
||||
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
|
||||
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
|
||||
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
|
||||
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
||||
// the badge shows total labels + keywords + ratings + colors so the
|
||||
// number reflects the combined "things you can filter by" surface.
|
||||
{
|
||||
kind: 'route',
|
||||
href: '/tags',
|
||||
label: 'Tags',
|
||||
getCount: () => {
|
||||
const labels = configQuery.data?.count?.labels;
|
||||
if (labels === undefined) return undefined;
|
||||
return labels + ratingsCount + colorsCount;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
|
||||
@@ -486,6 +486,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-only:
|
||||
editing labels requires re-indexing on PhotoPrism's side. The
|
||||
dashed border + lower contrast distinguishes them from the user-
|
||||
editable Keywords chips above. -->
|
||||
{#if (photo.Labels ?? []).length > 0}
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<Tag class="h-3 w-3" /> Labels (auto)
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each photo.Labels ?? [] as lbl (lbl.UID ?? lbl.Label?.Slug)}
|
||||
{@const slug = lbl.Label?.Slug}
|
||||
{@const name = lbl.Label?.Name ?? slug ?? '(unknown)'}
|
||||
<a
|
||||
href={slug ? `/?q=${encodeURIComponent(`label:${slug}`)}` : '#'}
|
||||
class="inline-flex items-center rounded-full border border-dashed border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:text-foreground"
|
||||
title={`Source: ${lbl.Source ?? 'classifier'}`}
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- GPS detail (collapsed by default) -->
|
||||
<details class="rounded border border-border" open={Boolean(photo.Lat || photo.Lng)}>
|
||||
<summary
|
||||
|
||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
|
||||
import { primaryFile } from '$lib/types/photoprism';
|
||||
import type {
|
||||
PpClientConfig,
|
||||
PpPhoto,
|
||||
@@ -385,6 +386,62 @@ export interface PpLabel {
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-set keyword aggregation. PhotoPrism's `/labels` endpoint only
|
||||
* surfaces classifier-derived labels; user-typed keywords live in
|
||||
* `Details.Keywords` (a flat comma-separated string) which is *not*
|
||||
* included in the `/photos` list response. The only path to it is the
|
||||
* single-photo endpoint, so we fetch the library top-end (capped at
|
||||
* PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches,
|
||||
* and aggregate.
|
||||
*
|
||||
* Slow but cached upstream via TanStack — keys under the `['photos', …]`
|
||||
* prefix so the existing photo-mutation invalidations cascade to it.
|
||||
*/
|
||||
export interface AggregatedKeyword {
|
||||
keyword: string;
|
||||
count: number;
|
||||
/** UID of an arbitrary photo carrying this keyword — used as the
|
||||
* thumbnail source so the tile is visually consistent with
|
||||
* classifier-label tiles. */
|
||||
sampleUid: string;
|
||||
sampleHash: string;
|
||||
}
|
||||
|
||||
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
const list = await listPhotos({ count: 1000, merged: true });
|
||||
const buckets = new Map<string, AggregatedKeyword>();
|
||||
const concurrency = 8;
|
||||
for (let i = 0; i < list.length; i += concurrency) {
|
||||
const slice = list.slice(i, i + concurrency);
|
||||
const fulls = await Promise.all(
|
||||
slice.map((p) => getPhoto(p.UID).catch(() => null))
|
||||
);
|
||||
for (let j = 0; j < slice.length; j++) {
|
||||
const photo = slice[j];
|
||||
const full = fulls[j];
|
||||
if (!full) continue;
|
||||
const raw = full.Details?.Keywords ?? '';
|
||||
if (!raw) continue;
|
||||
for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) {
|
||||
const bucket = buckets.get(kw);
|
||||
if (bucket) {
|
||||
bucket.count++;
|
||||
continue;
|
||||
}
|
||||
const hash = photo.Hash ?? primaryFile(photo).Hash;
|
||||
buckets.set(kw, {
|
||||
keyword: kw,
|
||||
count: 1,
|
||||
sampleUid: photo.UID,
|
||||
sampleHash: hash
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export async function listLabels(): Promise<PpLabel[]> {
|
||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||
// low-confidence classifier hits, manually-removed labels). They're
|
||||
|
||||
@@ -1,165 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
getAllMarks,
|
||||
listPhotos,
|
||||
type PhotoMarksMap
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||
// label lives in mule-sidecar's marks map alongside ratings. Pool the
|
||||
// recent photo list so we can resolve thumbnail hashes for each labelled
|
||||
// UID. Matches the four-swatch palette used by RightSidebar.
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const photosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'colors-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||
];
|
||||
|
||||
interface ColorGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
bg: string;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
|
||||
const groups = $derived<ColorGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
|
||||
|
||||
function buildGroups(
|
||||
marks: PhotoMarksMap | undefined,
|
||||
pool: PpPhoto[] | undefined
|
||||
): ColorGroup[] {
|
||||
if (!marks || !pool) return [];
|
||||
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||
const buckets = new Map<string, PpPhoto[]>();
|
||||
for (const [uid, mark] of Object.entries(marks)) {
|
||||
const c = mark.color;
|
||||
if (!c) continue;
|
||||
const photo = byUid.get(uid);
|
||||
if (!photo) continue;
|
||||
const arr = buckets.get(c) ?? [];
|
||||
arr.push(photo);
|
||||
buckets.set(c, arr);
|
||||
}
|
||||
const out: ColorGroup[] = [];
|
||||
for (const swatch of COLOR_SWATCHES) {
|
||||
const photos = buckets.get(swatch.key);
|
||||
if (photos && photos.length > 0) {
|
||||
out.push({ ...swatch, photos });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let selected = $state<string | null>(null);
|
||||
const selectedGroup = $derived(
|
||||
selected !== null ? groups.find((g) => g.key === selected) ?? null : null
|
||||
);
|
||||
|
||||
function pickGroup(key: string) {
|
||||
selected = key;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected = null;
|
||||
}
|
||||
// Colors moved into /tags as a tab. Redirect on mount so old bookmarks
|
||||
// and any in-app links still land somewhere meaningful. Uses
|
||||
// replaceState so the browser back button skips the redirect hop.
|
||||
onMount(() => {
|
||||
void goto('/tags?tab=colors', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Colors
|
||||
</span>
|
||||
{#if selectedGroup}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||
<span class="h-2.5 w-2.5 rounded-full {selectedGroup.bg}"></span>
|
||||
{selectedGroup.title}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{groups.length} color{groups.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load colors.</p>
|
||||
{:else if groups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No color labels yet. Open a photo and use the four-swatch row in the right
|
||||
sidebar to tag it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.key)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => pickGroup(group.key)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={group.title}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 truncate font-medium">
|
||||
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
|
||||
{group.title}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
<p class="p-6 text-sm text-muted-foreground">Redirecting to Tags · Colors…</p>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
listDuplicateGroups,
|
||||
@@ -14,21 +16,56 @@
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||
|
||||
// Same pill-tab pattern as /tags: tab state is URL-driven so the user
|
||||
// can share / refresh / hit Back and land on the right panel.
|
||||
type Tab = 'stacks' | 'cross-folder';
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'stacks', label: 'Stacks' },
|
||||
{ id: 'cross-folder', label: 'Cross-folder' }
|
||||
];
|
||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
||||
function parseTab(raw: string | null): Tab {
|
||||
return raw === 'cross-folder' ? 'cross-folder' : 'stacks';
|
||||
}
|
||||
function setTab(tab: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tab !== 'stacks') params.set('tab', tab);
|
||||
void goto(`/duplicates${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// Stale-time matches mule-image's DuplicatesView (30 s) so quick
|
||||
// toolbar bounces don't refetch the (potentially expensive) stack
|
||||
// listing. Invalidation by mutations is explicit, not time-driven.
|
||||
const dupesQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
enabled: isAuthenticated(),
|
||||
enabled: isAuthenticated() && activeTab === 'stacks',
|
||||
staleTime: 30_000
|
||||
}));
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Duplicates · stacks
|
||||
Duplicates
|
||||
</span>
|
||||
<!-- Tabs mirror /tags' pill row visually: same height, same active
|
||||
treatment, same hover affordance. -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each TABS as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#snippet trailing()}
|
||||
<!-- Thumbnail-size control mirrors the timeline Toolbar. The view
|
||||
store is global, so the picked size persists across routes —
|
||||
@@ -52,14 +89,12 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{dupesQuery.data?.length ?? 0} group{dupesQuery.data?.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto">
|
||||
<DuplicatesView
|
||||
{activeTab}
|
||||
groups={dupesQuery.data ?? []}
|
||||
pending={dupesQuery.isPending}
|
||||
error={dupesQuery.error}
|
||||
|
||||
@@ -1,156 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import {
|
||||
getAllMarks,
|
||||
listPhotos,
|
||||
type PhotoMarksMap
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||
// they live in mule-sidecar's marks map. We fan in two queries: marks
|
||||
// (UID → {rating, color}) and a recent slice of photos (UID → photo)
|
||||
// so we can resolve the thumbnail hash for each rated UID.
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const photosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'ratings-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
interface RatingGroup {
|
||||
rating: number;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
|
||||
const groups = $derived<RatingGroup[]>(buildGroups(marksQuery.data, photosQuery.data));
|
||||
|
||||
function buildGroups(
|
||||
marks: PhotoMarksMap | undefined,
|
||||
pool: PpPhoto[] | undefined
|
||||
): RatingGroup[] {
|
||||
if (!marks || !pool) return [];
|
||||
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||
const buckets = new Map<number, PpPhoto[]>();
|
||||
for (const [uid, mark] of Object.entries(marks)) {
|
||||
const r = mark.rating ?? 0;
|
||||
if (r <= 0) continue;
|
||||
const photo = byUid.get(uid);
|
||||
if (!photo) continue;
|
||||
const arr = buckets.get(r) ?? [];
|
||||
arr.push(photo);
|
||||
buckets.set(r, arr);
|
||||
}
|
||||
const out: RatingGroup[] = [];
|
||||
for (let r = 5; r >= 1; r--) {
|
||||
const photos = buckets.get(r);
|
||||
if (photos && photos.length > 0) out.push({ rating: r, photos });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let selected = $state<number | null>(null);
|
||||
const selectedGroup = $derived(
|
||||
selected !== null ? groups.find((g) => g.rating === selected) ?? null : null
|
||||
);
|
||||
|
||||
function pickGroup(rating: number) {
|
||||
selected = rating;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected = null;
|
||||
}
|
||||
|
||||
function starLabel(rating: number): string {
|
||||
return '★'.repeat(rating);
|
||||
}
|
||||
// Ratings moved into /tags as a tab. Redirect on mount so old bookmarks
|
||||
// and any in-app links still land somewhere meaningful. Uses
|
||||
// replaceState so the browser back button skips the redirect hop.
|
||||
onMount(() => {
|
||||
void goto('/tags?tab=ratings', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Ratings
|
||||
</span>
|
||||
{#if selectedGroup}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="text-[11px] font-medium text-yellow-500">
|
||||
{starLabel(selectedGroup.rating)}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{selectedGroup.photos.length} photo{selectedGroup.photos.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{groups.length} rating{groups.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load ratings.</p>
|
||||
{:else if groups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No rated photos yet. Open a photo and use the star row in the right sidebar
|
||||
(or 1–5 in bulk mode) to rate it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.rating)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => pickGroup(group.rating)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={starLabel(group.rating)}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium text-yellow-500">
|
||||
{starLabel(group.rating)}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
<p class="p-6 text-sm text-muted-foreground">Redirecting to Tags · Ratings…</p>
|
||||
|
||||
@@ -1,81 +1,322 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { type PpPhoto } from '$lib/types/photoprism';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// Mirror /colors: a label grid that drills into a photo grid in place,
|
||||
// instead of navigating away to the timeline. Stays inside /tags so
|
||||
// the user keeps their place when they back out.
|
||||
// Tag-flavoured surfaces, all under one route so the user can swap
|
||||
// between them without losing place. Four tabs:
|
||||
// - labels classifier-derived (PhotoPrism's /labels)
|
||||
// - keywords user-typed (Details.Keywords aggregated)
|
||||
// - ratings 1-5 star marks from mule-sidecar
|
||||
// - colors 4-swatch marks from mule-sidecar
|
||||
// Tab + drill state live in the URL so refresh / share / back work.
|
||||
|
||||
type Tab = 'labels' | 'keywords' | 'ratings' | 'colors';
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'labels', label: 'Labels (auto)' },
|
||||
{ id: 'keywords', label: 'Keywords' },
|
||||
{ id: 'ratings', label: 'Ratings' },
|
||||
{ id: 'colors', label: 'Colors' }
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
const activeTab = $derived<Tab>(parseTab(page.url.searchParams.get('tab')));
|
||||
const activePage = $derived(parsePage(page.url.searchParams.get('page')));
|
||||
const drillKey = $derived(page.url.searchParams.get('drill'));
|
||||
|
||||
function parseTab(raw: string | null): Tab {
|
||||
if (raw === 'keywords' || raw === 'ratings' || raw === 'colors') return raw;
|
||||
return 'labels';
|
||||
}
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = raw ? parseInt(raw, 10) : 0;
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
function setTab(tab: Tab) {
|
||||
const params = new URLSearchParams();
|
||||
if (tab !== 'labels') params.set('tab', tab);
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function setPage(p: number) {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
if (p === 0) params.delete('page');
|
||||
else params.set('page', String(p));
|
||||
params.delete('drill');
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function drillInto(key: string) {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
params.set('drill', key);
|
||||
void goto(`/tags?${params}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
function clearDrill() {
|
||||
const params = new URLSearchParams(page.url.searchParams);
|
||||
params.delete('drill');
|
||||
void goto(`/tags${params.size ? '?' + params : ''}`, {
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
}
|
||||
|
||||
// ── Data sources ─────────────────────────────────────────────────────────
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated()
|
||||
enabled: isAuthenticated() && activeTab === 'labels'
|
||||
}));
|
||||
|
||||
let selectedSlug = $state<string | null>(null);
|
||||
const selectedLabel = $derived(
|
||||
selectedSlug !== null
|
||||
? (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
|
||||
) ?? null
|
||||
: null
|
||||
);
|
||||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
||||
queryKey: ['photos', 'keywords'],
|
||||
queryFn: aggregateKeywords,
|
||||
enabled: isAuthenticated() && activeTab === 'keywords',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
// Photo pool for the selected label. PhotoPrism's q-DSL filters
|
||||
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
|
||||
// page reactive without paginating in place.
|
||||
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'label', selectedSlug ?? ''],
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors'),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// Marks → photo resolution. The sidecar's marks map is keyed by UID;
|
||||
// we need a thumbnail per UID, so pool the most-recent photo list.
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && (activeTab === 'ratings' || activeTab === 'colors')
|
||||
}));
|
||||
|
||||
// ── Group builders ───────────────────────────────────────────────────────
|
||||
interface RatingGroup {
|
||||
rating: number;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
const ratingGroups = $derived<RatingGroup[]>(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
function buildRatingGroups(
|
||||
marks: PhotoMarksMap | undefined,
|
||||
pool: PpPhoto[] | undefined
|
||||
): RatingGroup[] {
|
||||
if (!marks || !pool) return [];
|
||||
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||
const buckets = new Map<number, PpPhoto[]>();
|
||||
for (const [uid, mark] of Object.entries(marks)) {
|
||||
const r = mark.rating ?? 0;
|
||||
if (r <= 0) continue;
|
||||
const photo = byUid.get(uid);
|
||||
if (!photo) continue;
|
||||
const arr = buckets.get(r) ?? [];
|
||||
arr.push(photo);
|
||||
buckets.set(r, arr);
|
||||
}
|
||||
const out: RatingGroup[] = [];
|
||||
for (let r = 5; r >= 1; r--) {
|
||||
const photos = buckets.get(r);
|
||||
if (photos && photos.length > 0) out.push({ rating: r, photos });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
|
||||
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
|
||||
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
|
||||
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
|
||||
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
|
||||
];
|
||||
interface ColorGroup {
|
||||
key: string;
|
||||
title: string;
|
||||
bg: string;
|
||||
photos: PpPhoto[];
|
||||
}
|
||||
const colorGroups = $derived<ColorGroup[]>(
|
||||
buildColorGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
function buildColorGroups(
|
||||
marks: PhotoMarksMap | undefined,
|
||||
pool: PpPhoto[] | undefined
|
||||
): ColorGroup[] {
|
||||
if (!marks || !pool) return [];
|
||||
const byUid = new Map(pool.map((p) => [p.UID, p]));
|
||||
const buckets = new Map<string, PpPhoto[]>();
|
||||
for (const [uid, mark] of Object.entries(marks)) {
|
||||
const c = mark.color;
|
||||
if (!c) continue;
|
||||
const photo = byUid.get(uid);
|
||||
if (!photo) continue;
|
||||
const arr = buckets.get(c) ?? [];
|
||||
arr.push(photo);
|
||||
buckets.set(c, arr);
|
||||
}
|
||||
const out: ColorGroup[] = [];
|
||||
for (const swatch of COLOR_SWATCHES) {
|
||||
const photos = buckets.get(swatch.key);
|
||||
if (photos && photos.length > 0) out.push({ ...swatch, photos });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Sorted full lists per tab (most-common first), then page slice ───────
|
||||
const labelsSorted = $derived(
|
||||
[...(labelsQuery.data ?? [])].sort((a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0))
|
||||
);
|
||||
const keywordsSorted = $derived(keywordsQuery.data ?? []); // already sorted
|
||||
// Ratings/Colors are small fixed-cardinality buckets — no pagination.
|
||||
const totalPages = $derived.by(() => {
|
||||
if (activeTab === 'labels') return Math.ceil(labelsSorted.length / PAGE_SIZE);
|
||||
if (activeTab === 'keywords') return Math.ceil(keywordsSorted.length / PAGE_SIZE);
|
||||
return 0;
|
||||
});
|
||||
const pageSlice = $derived.by(() => {
|
||||
const start = activePage * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
if (activeTab === 'labels') return labelsSorted.slice(start, end);
|
||||
if (activeTab === 'keywords') return keywordsSorted.slice(start, end);
|
||||
return [];
|
||||
});
|
||||
|
||||
// ── Drill-down: server-side q-DSL query for the picked group/tile ───────
|
||||
const drillQ = $derived.by(() => {
|
||||
if (!drillKey) return '';
|
||||
if (activeTab === 'labels') return `label:${drillKey}`;
|
||||
if (activeTab === 'keywords') return `keywords:${drillKey}`;
|
||||
return ''; // ratings/colors resolve locally from the marks pool
|
||||
});
|
||||
const drillPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'tag-drill', activeTab, drillQ],
|
||||
queryFn: () =>
|
||||
listPhotos({
|
||||
q: `label:${selectedSlug}`,
|
||||
q: drillQ,
|
||||
count: 1000,
|
||||
order: 'newest',
|
||||
merged: true
|
||||
}),
|
||||
enabled: isAuthenticated() && Boolean(selectedSlug)
|
||||
enabled: isAuthenticated() && Boolean(drillQ)
|
||||
}));
|
||||
|
||||
function pickLabel(slug: string) {
|
||||
selectedSlug = slug;
|
||||
}
|
||||
function clearSelection() {
|
||||
selectedSlug = null;
|
||||
// Local drill resolution (ratings / colors): the marks pool already
|
||||
// carries the photos; just pick the bucket the user clicked.
|
||||
const localDrillPhotos = $derived.by<PpPhoto[]>(() => {
|
||||
if (!drillKey) return [];
|
||||
if (activeTab === 'ratings') {
|
||||
const r = parseInt(drillKey, 10);
|
||||
return ratingGroups.find((g) => g.rating === r)?.photos ?? [];
|
||||
}
|
||||
if (activeTab === 'colors') {
|
||||
return colorGroups.find((g) => g.key === drillKey)?.photos ?? [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const drillPhotos = $derived<PpPhoto[]>(
|
||||
activeTab === 'ratings' || activeTab === 'colors'
|
||||
? localDrillPhotos
|
||||
: drillPhotosQuery.data ?? []
|
||||
);
|
||||
|
||||
function starLabel(rating: number): string {
|
||||
return '★'.repeat(rating);
|
||||
}
|
||||
|
||||
// Friendly label for the current drill — used in the toolbar pill.
|
||||
const drillTitle = $derived.by(() => {
|
||||
if (!drillKey) return '';
|
||||
if (activeTab === 'labels') {
|
||||
const hit = (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === drillKey
|
||||
);
|
||||
return hit?.Name ?? drillKey;
|
||||
}
|
||||
if (activeTab === 'keywords') return drillKey;
|
||||
if (activeTab === 'ratings') return starLabel(parseInt(drillKey, 10));
|
||||
if (activeTab === 'colors') {
|
||||
return COLOR_SWATCHES.find((c) => c.key === drillKey)?.title ?? drillKey;
|
||||
}
|
||||
return drillKey;
|
||||
});
|
||||
|
||||
const drillCount = $derived<number>(drillPhotos.length);
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
{#if selectedLabel}
|
||||
{#if drillKey}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearSelection}
|
||||
onclick={clearDrill}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{selectedLabel.Name}
|
||||
</span>
|
||||
<span class="text-[11px] font-medium">{drillTitle}</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
|
||||
.data?.length ?? 0) === 1
|
||||
? ''
|
||||
: 's'}
|
||||
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
{:else}
|
||||
<!-- Pill row of tabs. The active tab uses the same primary-tinted
|
||||
treatment as the sidebar's active rows so the active state
|
||||
reads consistently across the app. -->
|
||||
<div class="flex items-center gap-1">
|
||||
{#each TABS as t (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-2 py-0.5 text-[11px] {activeTab === t.id
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||
onclick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
{#if !drillKey && (activeTab === 'labels' || activeTab === 'keywords') && totalPages > 1}
|
||||
<!-- Page navigator only renders when there's actually a page to
|
||||
turn — the ratings/colors tabs cap at 5 / 4 buckets which
|
||||
always fits a single screen. -->
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={activePage === 0}
|
||||
onclick={() => setPage(activePage - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{activePage + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={activePage >= totalPages - 1}
|
||||
onclick={() => setPage(activePage + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
@@ -83,53 +324,185 @@
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if labelsQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||
{:else if labelsQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load labels.</p>
|
||||
{:else if (labelsQuery.data ?? []).length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
||||
indexer hasn't run on real photos yet, the list will be empty.
|
||||
</p>
|
||||
{:else if selectedLabel}
|
||||
{#if labelPhotosQuery.isPending}
|
||||
{#if drillKey}
|
||||
<!-- Drill-down photo grid. Reused for all four tabs; the q-DSL drives
|
||||
labels/keywords while ratings/colors resolve locally from the
|
||||
marks pool already in cache. -->
|
||||
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading photos…</p>
|
||||
{:else if labelPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
|
||||
{:else if (labelPhotosQuery.data ?? []).length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
|
||||
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
||||
{:else if drillPhotos.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
|
||||
<PhotoGrid photos={drillPhotos} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => pickLabel(label.CustomSlug ?? label.Slug)}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
{:else if activeTab === 'labels'}
|
||||
{#if labelsQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||
{:else if labelsQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load labels.</p>
|
||||
{:else if labelsSorted.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content;
|
||||
if the indexer hasn't run on real photos yet, the list will be empty.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each pageSlice as item (item)}
|
||||
{@const label = item as PpLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(label.CustomSlug ?? label.Slug)}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
<img
|
||||
src={thumbUrl(label.Thumb, 'tile_500')}
|
||||
alt={label.Name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium">{label.Name}</span>
|
||||
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'keywords'}
|
||||
{#if keywordsQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Loading keywords…
|
||||
<br />
|
||||
<span class="text-[11px]">
|
||||
This walks every photo's metadata once — the result is cached after the first load.
|
||||
</span>
|
||||
</p>
|
||||
{:else if keywordsQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load keywords.</p>
|
||||
{:else if keywordsSorted.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No user-set keywords yet. Add them from a photo's right-sidebar metadata panel.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each pageSlice as item (item)}
|
||||
{@const kw = item as AggregatedKeyword}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(kw.keyword)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(label.Thumb, 'tile_500')}
|
||||
alt={label.Name}
|
||||
src={thumbUrl(kw.sampleHash, 'tile_500')}
|
||||
alt={kw.keyword}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium">{kw.keyword}</span>
|
||||
<span class="text-muted-foreground">{kw.count}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'ratings'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load ratings.</p>
|
||||
{:else if ratingGroups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No rated photos yet. Open a photo and use the star row in the right sidebar (or
|
||||
1–5 in bulk mode) to rate it.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each ratingGroups as group (group.rating)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(String(group.rating))}
|
||||
>
|
||||
<span class="truncate font-medium">{label.Name}</span>
|
||||
<span class="text-muted-foreground">{label.PhotoCount ?? 0}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={starLabel(group.rating)}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="truncate font-medium text-yellow-500">
|
||||
{starLabel(group.rating)}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if activeTab === 'colors'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||
{:else if marksQuery.isError || marksPoolQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load colors.</p>
|
||||
{:else if colorGroups.length === 0}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No color labels yet. Open a photo and use the four-swatch row in the right
|
||||
sidebar to tag it.
|
||||
</p>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each colorGroups as group (group.key)}
|
||||
{@const rep = group.photos[0]}
|
||||
{@const hash = rep.Hash ?? primaryFile(rep).Hash}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => drillInto(group.key)}
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={group.title}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-center justify-between bg-background/85 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span class="flex items-center gap-1.5 truncate font-medium">
|
||||
<span class="h-2.5 w-2.5 rounded-full {group.bg}"></span>
|
||||
{group.title}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{group.photos.length}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user