Compare commits
19 Commits
d2a76fa58c
...
feat/gpu-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c96c22b33 | |||
| 70dc1b6bdf | |||
| e3d4f6d92e | |||
| 9fc650fb12 | |||
| 29f7ad7073 | |||
| c134afe023 | |||
| a54d90a2d9 | |||
| 97f51a05c4 | |||
| d1ddc48f81 | |||
| 55c870c155 | |||
| 981328faff | |||
| e1707c314d | |||
| 64c0da794d | |||
| 0f4e2e0b8f | |||
| ea1803ec2f | |||
| fc5f30fad1 | |||
| a7b8a60473 | |||
| 24dfa996b3 | |||
| e364e4128f |
15
README.md
15
README.md
@@ -115,6 +115,7 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
|
|||||||
.
|
.
|
||||||
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
|
├── docker-compose.yml base stack: mariadb + photoprism + sidecar
|
||||||
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
|
├── docker-compose.podman.yml rootless-podman overlay (keep-id mapping)
|
||||||
|
├── docker-compose.gpu.yml opt-in VA-API GPU passthrough overlay
|
||||||
├── .env.example required env vars (copy to .env)
|
├── .env.example required env vars (copy to .env)
|
||||||
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
|
├── mariadb/init/ first-boot SQL: creates mule_sidecar DB + user
|
||||||
├── pp/ PhotoPrism bind-mounted state (storage, import)
|
├── pp/ PhotoPrism bind-mounted state (storage, import)
|
||||||
@@ -122,4 +123,18 @@ Full instructions in [`sidecar/README.md`](sidecar/README.md#dev-iteration-loop-
|
|||||||
└── web/ SvelteKit frontend
|
└── web/ SvelteKit frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## GPU video acceleration (optional)
|
||||||
|
|
||||||
|
Hosts with a VA-API-capable GPU (Intel iGPU, AMD APU, etc.) can layer
|
||||||
|
[`docker-compose.gpu.yml`](docker-compose.gpu.yml) to hand `/dev/dri/*`
|
||||||
|
to PhotoPrism and switch ffmpeg to hardware encode/decode — a large
|
||||||
|
perf win for video thumbnails and HEVC→H.264 transcodes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `PP_FFMPEG_ENCODER=vaapi` in `.env` (default for the overlay). Verify
|
||||||
|
with `docker exec pp-app photoprism show config | grep -i ffmpeg`.
|
||||||
|
|
||||||
[pp]: https://photoprism.app/
|
[pp]: https://photoprism.app/
|
||||||
|
|||||||
24
docker-compose.gpu.yml
Normal file
24
docker-compose.gpu.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Overlay for hosts with a VA-API-capable GPU passed through (Intel
|
||||||
|
# QSV, AMD VCN/VCE, any VA-API driver). PhotoPrism's :latest image
|
||||||
|
# ships VA-API-enabled ffmpeg; this file just wires the device + group
|
||||||
|
# membership + encoder selection. Layered in by the deploy script on
|
||||||
|
# hosts where /dev/dri/renderD128 exists.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
photoprism:
|
||||||
|
devices:
|
||||||
|
- /dev/dri/renderD128:/dev/dri/renderD128
|
||||||
|
- /dev/dri/card0:/dev/dri/card0
|
||||||
|
# Match host GIDs (render=992, video=44 on Debian). PhotoPrism's
|
||||||
|
# container user (PP_UID:PP_GID, typically 33:10000) is not in
|
||||||
|
# these groups by default; group_add grants access to the device
|
||||||
|
# nodes without changing the primary user.
|
||||||
|
group_add:
|
||||||
|
- "992"
|
||||||
|
- "44"
|
||||||
|
environment:
|
||||||
|
PHOTOPRISM_FFMPEG_ENCODER: ${PP_FFMPEG_ENCODER:-vaapi}
|
||||||
|
PHOTOPRISM_FFMPEG_BITRATE: ${PP_FFMPEG_BITRATE:-32}
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
|
<!-- SVG favicon adapts to light/dark via `prefers-color-scheme`
|
||||||
|
inside the file itself; PNG remains as a fallback for browsers
|
||||||
|
that don't support SVG icons. Apple touch icon stays PNG
|
||||||
|
since iOS home-screen icons can't be SVG. -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +28,6 @@ import {
|
|||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
@@ -160,35 +162,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up a photo's current cached state without forcing a refetch.
|
|
||||||
* Walks every `['photos', …]` cache entry first, then the per-photo
|
|
||||||
* cache. Lets `x` decide "archive vs restore" based on the actual current
|
|
||||||
* state instead of always sending Archived=true.
|
|
||||||
*
|
|
||||||
* The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]`
|
|
||||||
* (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope
|
|
||||||
* (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite
|
|
||||||
* scroll. Walk both — assuming a flat array on the timeline cache used
|
|
||||||
* to throw `list.find is not a function` and abort the F/X handlers. */
|
|
||||||
function cachedPhoto(uid: string): PpPhoto | undefined {
|
|
||||||
const lists = queryClient.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 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 hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||||
const ids = cullTargets();
|
const ids = cullTargets();
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@@ -333,7 +306,7 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
// truth.
|
// truth.
|
||||||
if (added.length === 0) {
|
if (added.length === 0) {
|
||||||
toast.error(`Nothing added to ${heap.Title}`, {
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -498,6 +471,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
if (meta) {
|
if (meta) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
for (const id of selection.order) selection.ids.add(id);
|
for (const id of selection.order) selection.ids.add(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shift) return;
|
||||||
|
// Bare `a` on the EXIF Stripped review tab fires the same
|
||||||
|
// "Accept date & Keep" flow as the bar button. Mirrors the
|
||||||
|
// bar's all-targets-have-a-suggestion gate so the shortcut
|
||||||
|
// can't silently approve photos without a date fix.
|
||||||
|
if (
|
||||||
|
filters.section === 'review' &&
|
||||||
|
new URL(window.location.href).searchParams.get('tab') === 'stripped_exif'
|
||||||
|
) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
for (const id of ids) {
|
||||||
|
const p = cachedPhoto(id);
|
||||||
|
if (!p) return;
|
||||||
|
const { fileName, path } = photoNameAndDir(p);
|
||||||
|
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
void acceptDateAndKeep(ids);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'x':
|
case 'x':
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
||||||
{
|
{
|
||||||
description: losingIndexed
|
description: losingIndexed
|
||||||
? 'The previously-indexed copy was moved; PhotoPrism will drop it on the next index pass.'
|
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
|
||||||
: 'Files moved to .duplicates/ inside originals.'
|
: 'Files moved to .duplicates/ inside originals.'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
bind:this={sectionEl}
|
bind:this={sectionEl}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={`Cross-folder duplicate · ${group.files.length} copies`}
|
aria-label={`Duplicate group · ${group.files.length} copies`}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
{#if isIndexed}
|
{#if isIndexed}
|
||||||
<span
|
<span
|
||||||
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
title="Currently indexed by PhotoPrism"
|
title="Currently in the library"
|
||||||
>
|
>
|
||||||
Indexed
|
Indexed
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||||
import StackGroupCard from './StackGroupCard.svelte';
|
import StackGroupCard from './StackGroupCard.svelte';
|
||||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
|
||||||
|
|
||||||
type Tab = 'stacks' | 'cross-folder';
|
type Tab = 'stacks' | 'cross-folder';
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
toast.error(
|
toast.error(
|
||||||
crossQuery.error instanceof Error
|
crossQuery.error instanceof Error
|
||||||
? crossQuery.error.message
|
? crossQuery.error.message
|
||||||
: 'Cross-folder scan failed'
|
: 'Duplicates scan failed'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -75,20 +77,24 @@
|
|||||||
{#if activeTab === 'stacks'}
|
{#if activeTab === 'stacks'}
|
||||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||||
{#if pending}
|
{#if pending}
|
||||||
<p class="text-sm text-muted-foreground">Loading stacks…</p>
|
<InlineLoader label="Loading stacks…" />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="text-sm text-destructive">
|
<EmptyState
|
||||||
Could not load stacks: {error instanceof Error ? error.message : 'unknown error'}
|
tone="destructive"
|
||||||
</p>
|
icon={AlertCircle}
|
||||||
|
title="Could not load stacks"
|
||||||
|
description={error instanceof Error ? error.message : 'unknown error'}
|
||||||
|
/>
|
||||||
{:else if groups.length === 0}
|
{:else if groups.length === 0}
|
||||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
<EmptyState icon={Copy} title="No stacks">
|
||||||
<p>No stacks.</p>
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-xs">
|
<p>
|
||||||
PhotoPrism stacks byte-identical (or EXIF-identical) files. If you don't have
|
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||||
any, this tab stays empty. Cross-folder copies PhotoPrism rejected at index
|
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||||
time live under the Cross-folder tab.
|
the Duplicates tab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each groups as group, i (group.photo.UID)}
|
{#each groups as group, i (group.photo.UID)}
|
||||||
@@ -99,12 +105,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Cross-folder tab ----------------------------------------------- -->
|
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||||
{#if activeTab === 'cross-folder'}
|
{#if activeTab === 'cross-folder'}
|
||||||
<div role="tabpanel" aria-label="Cross-folder duplicates" class="space-y-3 px-6 py-4 pb-6">
|
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||||
<header class="flex items-baseline justify-between gap-3">
|
<header class="flex items-baseline justify-between gap-3">
|
||||||
<p class="text-[11px] text-muted-foreground">
|
<p class="text-[11px] text-muted-foreground">
|
||||||
Byte-identical files PhotoPrism dropped at index time. Found by scanning the
|
Byte-identical files the indexer dropped at index time. Found by scanning the
|
||||||
originals tree directly.
|
originals tree directly.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -122,22 +128,26 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if crossQuery.isFetching && !crossQuery.data}
|
{#if crossQuery.isFetching && !crossQuery.data}
|
||||||
<p class="text-sm text-muted-foreground">Hashing files under originals…</p>
|
<InlineLoader label="Hashing files under originals…" />
|
||||||
{:else if crossQuery.isError}
|
{:else if crossQuery.isError}
|
||||||
<p class="text-sm text-destructive">
|
<EmptyState
|
||||||
Scan failed: {crossQuery.error instanceof Error
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Scan failed"
|
||||||
|
description={crossQuery.error instanceof Error
|
||||||
? crossQuery.error.message
|
? crossQuery.error.message
|
||||||
: 'unknown error'}
|
: 'unknown error'}
|
||||||
</p>
|
/>
|
||||||
{:else if crossCount === 0}
|
{:else if crossCount === 0}
|
||||||
<p class="text-sm text-muted-foreground">
|
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||||
No cross-folder duplicates found.
|
{#snippet descriptionSnippet()}
|
||||||
{#if crossQuery.data}
|
{#if crossQuery.data}
|
||||||
<span class="ml-1 text-[10px] text-muted-foreground/70">
|
<p class="text-[10px] text-muted-foreground/70">
|
||||||
(scanned in {crossQuery.data.scannedMs} ms)
|
scanned in {crossQuery.data.scannedMs} ms
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
||||||
|
|||||||
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<!--
|
||||||
|
Shared empty / no-data placeholder. Doubles as an error display when
|
||||||
|
`tone="destructive"` (swaps colors and announces with role=alert).
|
||||||
|
Use `size="compact"` inside sidebars where vertical space is tight.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Component, Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon?: Component<any> | any;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
descriptionSnippet?: Snippet;
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
tone?: 'muted' | 'destructive';
|
||||||
|
size?: 'compact' | 'default';
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
descriptionSnippet,
|
||||||
|
align,
|
||||||
|
tone = 'muted',
|
||||||
|
size = 'default',
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
|
||||||
|
const isDestructive = $derived(tone === 'destructive');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if size === 'compact'}
|
||||||
|
<div
|
||||||
|
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
|
||||||
|
? 'items-center justify-center text-center'
|
||||||
|
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
|
||||||
|
role={isDestructive ? 'alert' : undefined}
|
||||||
|
aria-live={isDestructive ? 'assertive' : undefined}
|
||||||
|
>
|
||||||
|
{#if Icon}
|
||||||
|
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span>{title}</span>
|
||||||
|
{#if descriptionSnippet}
|
||||||
|
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
|
||||||
|
{:else if description}
|
||||||
|
<div class="mt-0.5 opacity-80">{description}</div>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
<div class="mt-1.5">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
|
||||||
|
? 'items-center text-center'
|
||||||
|
: 'items-start text-left'}"
|
||||||
|
role={isDestructive ? 'alert' : undefined}
|
||||||
|
aria-live={isDestructive ? 'assertive' : undefined}
|
||||||
|
>
|
||||||
|
{#if Icon}
|
||||||
|
<Icon
|
||||||
|
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
|
||||||
|
{#if descriptionSnippet}
|
||||||
|
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
|
||||||
|
{@render descriptionSnippet()}
|
||||||
|
</div>
|
||||||
|
{:else if description}
|
||||||
|
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
<div class="mt-2">{@render children()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!--
|
||||||
|
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
|
||||||
|
in sidebars, popovers, and right rails. For the initial photo-grid load,
|
||||||
|
use SkeletonGrid instead (layout-preserving).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
srOnly?: boolean;
|
||||||
|
polite?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label = 'Loading…',
|
||||||
|
size = 'default',
|
||||||
|
align = 'left',
|
||||||
|
srOnly = false,
|
||||||
|
polite = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
|
||||||
|
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
|
||||||
|
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
|
||||||
|
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-live={polite ? 'polite' : 'off'}
|
||||||
|
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
|
||||||
|
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
|
||||||
|
</p>
|
||||||
2
web/src/lib/components/feedback/index.ts
Normal file
2
web/src/lib/components/feedback/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as EmptyState } from './EmptyState.svelte';
|
||||||
|
export { default as InlineLoader } from './InlineLoader.svelte';
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||||
import Self from './FolderTree.svelte';
|
import Self from './FolderTree.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
@@ -105,6 +106,37 @@
|
|||||||
if (selectedPath !== undefined) return selectedPath === path;
|
if (selectedPath !== undefined) return selectedPath === path;
|
||||||
return filters.folderPath === path;
|
return filters.folderPath === path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-expand the ancestor chain of the active folder so the
|
||||||
|
// highlighted row is actually visible after a deep-link navigation
|
||||||
|
// (RightSidebar's open-folder icon, URL hydration, etc.). Each
|
||||||
|
// FolderTree instance only owns the openSet entries for the nodes
|
||||||
|
// rendered at its depth, but since the root instance expands the
|
||||||
|
// top-level ancestor first, the child instance for that subtree is
|
||||||
|
// then mounted and runs the same effect — the cascade naturally
|
||||||
|
// reaches the leaf. Skipped in `readonly` mode (the heap-convert
|
||||||
|
// picker has its own selectedPath and shouldn't drive the sidebar
|
||||||
|
// state). Skipped for top-level paths (nothing to expand).
|
||||||
|
$effect(() => {
|
||||||
|
if (readonly || !browser) return;
|
||||||
|
const fp = selectedPath ?? filters.folderPath;
|
||||||
|
if (!fp || fp === '/' || !fp.includes('/')) return;
|
||||||
|
untrack(() => {
|
||||||
|
const parts = fp.split('/');
|
||||||
|
let changed = false;
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const ancestor = parts.slice(0, i).join('/');
|
||||||
|
if (ancestor && !openSet.has(ancestor)) {
|
||||||
|
openSet.add(ancestor);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
openSet = new Set(openSet);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
import {
|
import {
|
||||||
getSettings,
|
getSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
setUserPassword,
|
||||||
type PpSettings
|
type PpSettings
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,7 +29,29 @@
|
|||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
|
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
|
||||||
|
|
||||||
|
// ── Account tab — password change ─────────────────────────────────────
|
||||||
|
let pwOld = $state('');
|
||||||
|
let pwNew = $state('');
|
||||||
|
let pwConfirm = $state('');
|
||||||
|
|
||||||
|
const pwMut = createMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!session.user) throw new Error('Not signed in');
|
||||||
|
if (pwNew.length < 8) throw new Error('New password must be at least 8 characters');
|
||||||
|
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
|
||||||
|
await setUserPassword(session.user.UID, pwOld, pwNew);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
pwOld = '';
|
||||||
|
pwNew = '';
|
||||||
|
pwConfirm = '';
|
||||||
|
toast.success('Password updated');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not update password')
|
||||||
|
}));
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: 'light', label: 'Light', Icon: Sun },
|
{ value: 'light', label: 'Light', Icon: Sun },
|
||||||
@@ -160,8 +184,8 @@
|
|||||||
General settings
|
General settings
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||||
Preferences for this app and your PhotoPrism account.
|
Preferences for Mulimage and your account. Library admin lives
|
||||||
Library admin lives under Folders → ⚙.
|
under Folders → ⚙.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close
|
||||||
@@ -174,7 +198,7 @@
|
|||||||
|
|
||||||
<Tabs.Root bind:value={activeTab}>
|
<Tabs.Root bind:value={activeTab}>
|
||||||
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
||||||
{#each ['ui', 'search', 'maps'] as const as t (t)}
|
{#each ['ui', 'search', 'maps', 'account'] as const as t (t)}
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={t}
|
value={t}
|
||||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||||
@@ -217,13 +241,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if settingsQuery.isPending}
|
{#if settingsQuery.isPending}
|
||||||
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
|
<p class="px-1 text-muted-foreground">Loading server settings…</p>
|
||||||
{:else if settingsQuery.isError}
|
{:else if settingsQuery.isError}
|
||||||
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
|
<p class="px-1 text-destructive">Could not load server settings.</p>
|
||||||
{:else if draft}
|
{:else if draft}
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
PhotoPrism UI
|
Server UI
|
||||||
</h3>
|
</h3>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
<span class="text-muted-foreground">Theme</span>
|
<span class="text-muted-foreground">Theme</span>
|
||||||
@@ -275,11 +299,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
{#if settingsQuery.isPending && activeTab !== 'ui'}
|
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
|
||||||
<Tabs.Content value={activeTab} class="outline-none">
|
<Tabs.Content value={activeTab} class="outline-none">
|
||||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{:else if settingsQuery.isError && activeTab !== 'ui'}
|
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
|
||||||
<Tabs.Content value={activeTab} class="outline-none">
|
<Tabs.Content value={activeTab} class="outline-none">
|
||||||
<p class="px-1 text-[12px] text-destructive">
|
<p class="px-1 text-[12px] text-destructive">
|
||||||
Could not load settings.
|
Could not load settings.
|
||||||
@@ -332,6 +356,97 @@
|
|||||||
</label>
|
</label>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Account — independent of /settings; reads from the session
|
||||||
|
store and round-trips its own mutation. -->
|
||||||
|
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
|
||||||
|
<section class="space-y-2">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Signed in as
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 rounded border border-border bg-muted/30 p-2">
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">Name</span>
|
||||||
|
<span class="font-medium">{session.user?.Name ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
{#if session.user?.DisplayName}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">Display name</span>
|
||||||
|
<span>{session.user.DisplayName}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if session.user?.Email}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">Email</span>
|
||||||
|
<span>{session.user.Email}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-between gap-3">
|
||||||
|
<span class="text-muted-foreground">Role</span>
|
||||||
|
<span>{session.user?.Role ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pwMut.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Change password
|
||||||
|
</h3>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Current password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
bind:value={pwOld}
|
||||||
|
required
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">New password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={pwNew}
|
||||||
|
required
|
||||||
|
minlength={8}
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Confirm new password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
bind:value={pwConfirm}
|
||||||
|
required
|
||||||
|
minlength={8}
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
disabled={pwMut.isPending ||
|
||||||
|
!pwOld ||
|
||||||
|
pwNew.length < 8 ||
|
||||||
|
pwNew !== pwConfirm}
|
||||||
|
>
|
||||||
|
{#if pwMut.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Update password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
|
|
||||||
<!-- Datalist for time-zone autocomplete. Falls back to the
|
<!-- Datalist for time-zone autocomplete. Falls back to the
|
||||||
@@ -344,8 +459,9 @@
|
|||||||
|
|
||||||
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
||||||
trip). The App theme group above persists itself, so we
|
trip). The App theme group above persists itself, so we
|
||||||
only show the action row when there's something to save. -->
|
only show the action row when there's something to save.
|
||||||
{#if draft}
|
Account tab has its own Update-password button, so skip. -->
|
||||||
|
{#if draft && activeTab !== 'account'}
|
||||||
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import { Dialog } from 'bits-ui';
|
import { Dialog } from 'bits-ui';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { FolderInput, Loader2 } from 'lucide-svelte';
|
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import {
|
import {
|
||||||
convertHeap,
|
convertHeap,
|
||||||
listFolders,
|
listFolders,
|
||||||
@@ -153,11 +154,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="max-h-[200px] overflow-y-auto">
|
<div class="max-h-[200px] overflow-y-auto">
|
||||||
{#if foldersQuery.isPending}
|
{#if foldersQuery.isPending}
|
||||||
<p class="px-2 py-1 text-[11px] text-muted-foreground">Loading folders…</p>
|
<InlineLoader size="sm" label="Loading folders…" />
|
||||||
{:else if (foldersQuery.data ?? []).length === 0}
|
{:else if (foldersQuery.data ?? []).length === 0}
|
||||||
<p class="px-2 py-1 text-[11px] text-muted-foreground">
|
<EmptyState
|
||||||
No folders. Create one from the sidebar first.
|
size="compact"
|
||||||
</p>
|
icon={FolderOpen}
|
||||||
|
title="No folders"
|
||||||
|
description="Create one from the sidebar first."
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Root row: lets the user drop the heap directly into
|
<!-- Root row: lets the user drop the heap directly into
|
||||||
originals/ without picking a subfolder. The empty
|
originals/ without picking a subfolder. The empty
|
||||||
|
|||||||
@@ -13,32 +13,35 @@
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
deleteHeap,
|
deleteHeap,
|
||||||
duplicateHeap,
|
duplicateHeap,
|
||||||
getAllMarks,
|
|
||||||
getConfig,
|
getConfig,
|
||||||
heapDownloadUrl,
|
heapDownloadUrl,
|
||||||
listFolderCounts,
|
listFolderCounts,
|
||||||
listFolders,
|
listFolders,
|
||||||
listGeo,
|
|
||||||
listHeaps,
|
listHeaps,
|
||||||
|
listPhotosWithNotes,
|
||||||
logout,
|
logout,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
scanCrossFolderDuplicates,
|
scanCrossFolderDuplicates,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
type AggregatedKeyword,
|
|
||||||
type CrossFolderScanResult,
|
type CrossFolderScanResult,
|
||||||
type PhotoMarksMap,
|
type PhotoWithNote,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
type PpClientConfig,
|
type PpClientConfig,
|
||||||
type PpFolder,
|
type PpFolder
|
||||||
type PpGeoCollection
|
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
listDuplicateGroups,
|
listDuplicateGroups,
|
||||||
type DuplicateGroup
|
type DuplicateGroup
|
||||||
} from '$lib/services/adapters/duplicates';
|
} from '$lib/services/adapters/duplicates';
|
||||||
|
import {
|
||||||
|
listReviewGroups,
|
||||||
|
type CauseKey,
|
||||||
|
type ReviewGroup
|
||||||
|
} from '$lib/services/adapters/review';
|
||||||
import {
|
import {
|
||||||
filters,
|
filters,
|
||||||
|
navigateToFolder,
|
||||||
setFolderPath,
|
setFolderPath,
|
||||||
setSection,
|
setSection,
|
||||||
TAG_CATEGORIES,
|
TAG_CATEGORIES,
|
||||||
@@ -51,18 +54,23 @@
|
|||||||
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
import SettingsDialog from './SettingsDialog.svelte';
|
import SettingsDialog from './SettingsDialog.svelte';
|
||||||
|
import UsersDialog from './UsersDialog.svelte';
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
Layers,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Pencil,
|
Pencil,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Trash2
|
Trash2,
|
||||||
|
Users
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -128,28 +136,15 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// One query per badge. Admins with no BasePath skip these
|
// One query per badge. Admins with no BasePath skip this
|
||||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||||
// used directly — same chrome as before that fix, no extra
|
// used directly — same chrome as before that fix, no extra
|
||||||
// round-trips.
|
// round-trip. Review and Hidden have no aggregate badge (pure
|
||||||
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
|
// toggles in the sidebar now, like Tags), so they don't appear here.
|
||||||
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
|
||||||
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
|
||||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||||
// Labels is special: `configQuery.count.labels` is the number of distinct
|
|
||||||
// label categories (PhotoPrism's roll-up), not the number of photos that
|
|
||||||
// carry a label. The Tags surface wants picture counts everywhere, so we
|
|
||||||
// always run a `countPhotos('label:*')` query regardless of the admin/
|
|
||||||
// BasePath shape and never fall back to the category-count.
|
|
||||||
const labelsCountQuery = createQuery<number>(() => ({
|
|
||||||
queryKey: ['photos', 'scoped-count', 'labels', userBasePath(), isAdminUser],
|
|
||||||
queryFn: () => countPhotos(scoped('label:*')),
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
staleTime: 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
function bucketCount(
|
function bucketCount(
|
||||||
key: 'favorites' | 'review' | 'hidden' | 'archived',
|
key: 'archived',
|
||||||
query: { data: number | undefined; isPending: boolean }
|
query: { data: number | undefined; isPending: boolean }
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (wantScoped) {
|
if (wantScoped) {
|
||||||
@@ -163,13 +158,6 @@
|
|||||||
return c[key];
|
return c[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
|
||||||
queryKey: ['marks'],
|
|
||||||
queryFn: getAllMarks,
|
|
||||||
enabled: isAuthenticated(),
|
|
||||||
staleTime: 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
||||||
// PhotoPrism query so we always fetch it; cross-folder is an
|
// PhotoPrism query so we always fetch it; cross-folder is an
|
||||||
// O(disk) scan, so the sidebar only *observes* its cache
|
// O(disk) scan, so the sidebar only *observes* its cache
|
||||||
@@ -189,50 +177,17 @@
|
|||||||
staleTime: 5 * 60_000
|
staleTime: 5 * 60_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Geotagged-photo count for the Map sidebar badge. PhotoPrism's
|
// Notes-view badge. Cheap (one list round-trip, no fan-out) so we
|
||||||
// `count.places` is the number of distinct *locations* (cities/states),
|
// fetch eagerly — sharing the queryKey with /notes means the page hits
|
||||||
// not the number of geotagged photos — so the sidebar would disagree
|
// the warm cache, and the ['photos', …] prefix lets existing mutation
|
||||||
// with the "N geotagged" footer on /map. Sharing the `['geo']` cache
|
// invalidations keep both in sync.
|
||||||
// keeps both numbers in lockstep and is free after /map's first visit.
|
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
|
||||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
queryKey: ['photos', 'with-notes'],
|
||||||
queryKey: ['geo'],
|
queryFn: listPhotosWithNotes,
|
||||||
queryFn: () => listGeo(),
|
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
staleTime: 5 * 60_000
|
staleTime: 60_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Keywords contribution to the Tags badge. Aggregation is heavy
|
|
||||||
// (1000-photo fan-out), so the sidebar observes the cache populated
|
|
||||||
// by /tags?tab=keywords rather than triggering its own fetch — same
|
|
||||||
// lazy pattern as the cross-folder duplicates count above.
|
|
||||||
const keywordsQuery = createQuery<AggregatedKeyword[]>(() => ({
|
|
||||||
queryKey: ['photos', 'keywords'],
|
|
||||||
queryFn: aggregateKeywords,
|
|
||||||
enabled: false,
|
|
||||||
staleTime: 5 * 60_000
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ratingsCount = $derived(countRatings(marksQuery.data));
|
|
||||||
const colorsCount = $derived(countColors(marksQuery.data));
|
|
||||||
|
|
||||||
function countRatings(marks: PhotoMarksMap | undefined): number {
|
|
||||||
if (!marks) return 0;
|
|
||||||
let n = 0;
|
|
||||||
for (const m of Object.values(marks)) {
|
|
||||||
if ((m.rating ?? 0) > 0) n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countColors(marks: PhotoMarksMap | undefined): number {
|
|
||||||
if (!marks) return 0;
|
|
||||||
let n = 0;
|
|
||||||
for (const m of Object.values(marks)) {
|
|
||||||
if (m.color) n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderTree = $derived(
|
const folderTree = $derived(
|
||||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||||
);
|
);
|
||||||
@@ -269,37 +224,48 @@
|
|||||||
}));
|
}));
|
||||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||||
|
|
||||||
// Root entry shows "the user's library" — for admins without a
|
// Root entry shows "the user's library" using the same filter the
|
||||||
// BasePath that's still the whole library, served cheaply from
|
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
|
||||||
// /api/v1/config's `count.all`. For any user with a non-empty
|
// resolves to the visible listing (no archived / hidden / review).
|
||||||
// BasePath the precomputed total is wrong (it's library-wide), so we
|
// Earlier this used /config's `count.all`, but that aggregate
|
||||||
// ask the sidecar for a recursive count rooted at the user's
|
// includes those buckets and didn't match what the user can actually
|
||||||
// BasePath — listFolderCounts maps `""` through toOriginalsPath, which
|
// click "select all" on; the discrepancy was confusing
|
||||||
// resolves to the BasePath itself, and the sidecar fan-out recurses.
|
// (LeftSidebar said 357, the action bar said ~329).
|
||||||
|
//
|
||||||
|
// `scopedRootCountQuery` retains the sidecar fan-out for users with
|
||||||
|
// a BasePath — `listFolderCounts(['''])` resolves `''` through
|
||||||
|
// `toOriginalsPath` to the user's BasePath and recurses, so it picks
|
||||||
|
// up the same subset PhotoPrism would. Empty BasePath admins use the
|
||||||
|
// PhotoPrism count-via-X-Count path so both surfaces agree.
|
||||||
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
||||||
queryKey: ['photos', 'root-count', userBasePath()],
|
queryKey: ['photos', 'root-count', userBasePath()],
|
||||||
queryFn: () => listFolderCounts(['']),
|
queryFn: () => listFolderCounts(['']),
|
||||||
enabled: isAuthenticated() && userBasePath() !== '',
|
enabled: isAuthenticated() && userBasePath() !== '',
|
||||||
staleTime: 60_000
|
staleTime: 60_000
|
||||||
}));
|
}));
|
||||||
|
const visibleRootCountQuery = createQuery<number>(() => ({
|
||||||
|
queryKey: ['photos', 'visible-root-count', userBasePath()],
|
||||||
|
// `merged: true` so the count matches the timeline's photo entries
|
||||||
|
// (one per logical photo) rather than its file-row total. Without
|
||||||
|
// it, sidecar/companion files inflate the badge — e.g. a HEIC + JPG
|
||||||
|
// pair counts twice — and "select all" in the timeline never
|
||||||
|
// reaches the badge's number.
|
||||||
|
queryFn: () => countPhotos(scoped(''), { merged: true }),
|
||||||
|
enabled: isAuthenticated() && userBasePath() === '' && isAdminUser,
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
const rootCount = $derived(
|
const rootCount = $derived(
|
||||||
userBasePath() === ''
|
userBasePath() === ''
|
||||||
? isAdminUser
|
? isAdminUser
|
||||||
? (configQuery.data?.count?.all ?? 0)
|
? (visibleRootCountQuery.data ?? 0)
|
||||||
: 0
|
: 0
|
||||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Favorites / Review / Hidden / Archive nav entries use these
|
// Archive nav entry uses this derived value rather than peeking at
|
||||||
// derived values rather than peeking at configQuery directly so the
|
// configQuery directly so the scoped path is invisible to the
|
||||||
// scoped path is invisible to the views[]/manageViews[] declarations.
|
// manageViews[] declarations.
|
||||||
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
|
|
||||||
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
|
||||||
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
|
||||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||||
const labelsBadge = $derived<number | undefined>(
|
|
||||||
labelsCountQuery.isPending ? undefined : labelsCountQuery.data
|
|
||||||
);
|
|
||||||
|
|
||||||
const createMut = createMutation(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
@@ -348,6 +314,10 @@
|
|||||||
// admin dialog above — opened from the bottom-of-sidebar footer.
|
// admin dialog above — opened from the bottom-of-sidebar footer.
|
||||||
let generalSettingsOpen = $state(false);
|
let generalSettingsOpen = $state(false);
|
||||||
|
|
||||||
|
// Admin-only user management dialog. Footer icon is gated on
|
||||||
|
// `isAdminUser` so non-admins never see the entry point.
|
||||||
|
let usersOpen = $state(false);
|
||||||
|
|
||||||
// Root-folder collapse state. Persisted to its own localStorage key so
|
// Root-folder collapse state. Persisted to its own localStorage key so
|
||||||
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
|
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
|
||||||
// to open so first-time users see the full tree.
|
// to open so first-time users see the full tree.
|
||||||
@@ -379,27 +349,61 @@
|
|||||||
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
|
||||||
|
// row in Manage can expose the same set of tabs the /review page shows
|
||||||
|
// (cause groups + duplicates panels). Defaults to collapsed.
|
||||||
|
const REVIEW_OPEN_KEY = 'mule_review_expanded';
|
||||||
|
let reviewExpanded = $state(loadReviewExpanded());
|
||||||
|
function loadReviewExpanded(): boolean {
|
||||||
|
if (!browser) return false;
|
||||||
|
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
|
||||||
|
}
|
||||||
|
function toggleReview() {
|
||||||
|
reviewExpanded = !reviewExpanded;
|
||||||
|
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause-tab list is dynamic (only buckets with hits show up on /review),
|
||||||
|
// so the sidebar mirrors that by reusing the same query. Gated on
|
||||||
|
// `reviewExpanded` to avoid paying the /photos round-trip for users who
|
||||||
|
// never expand the section; the queryKey is shared with the /review page
|
||||||
|
// so visiting that route warms the cache for free.
|
||||||
|
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
|
||||||
|
queryKey: ['review-groups'],
|
||||||
|
queryFn: listReviewGroups,
|
||||||
|
enabled: isAuthenticated() && reviewExpanded,
|
||||||
|
staleTime: 30_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ReviewTabId = CauseKey | 'stacks' | 'cross-folder';
|
||||||
|
// Stacks + Duplicates are always present on the /review tab strip
|
||||||
|
// regardless of count (the cross-folder scan is lazy from its own
|
||||||
|
// panel), so they tail every cause-tab list the sidebar renders.
|
||||||
|
// The 'cross-folder' tab id is kept internal/URL-stable; the label
|
||||||
|
// the user sees is "Duplicates".
|
||||||
|
const reviewTabs = $derived<{ id: ReviewTabId; label: string }[]>([
|
||||||
|
...(reviewGroupsQuery.data ?? []).map((g) => ({
|
||||||
|
id: g.cause as ReviewTabId,
|
||||||
|
label: g.meta.title
|
||||||
|
})),
|
||||||
|
{ id: 'stacks', label: 'Stacks' },
|
||||||
|
{ id: 'cross-folder', label: 'Duplicates' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reviewActive = $derived(page.url.pathname === '/review');
|
||||||
|
function isReviewTabActive(id: ReviewTabId): boolean {
|
||||||
|
if (!reviewActive) return false;
|
||||||
|
return page.url.searchParams.get('tab') === id;
|
||||||
|
}
|
||||||
|
|
||||||
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||||
labels: 'Labels',
|
labels: 'Labels',
|
||||||
keywords: 'Keywords',
|
keywords: 'Keywords',
|
||||||
|
people: 'People',
|
||||||
colors: 'Colors',
|
colors: 'Colors',
|
||||||
ratings: 'Ratings'
|
ratings: 'Ratings'
|
||||||
};
|
};
|
||||||
|
|
||||||
function tagCategoryCount(cat: TagCategory): number | undefined {
|
|
||||||
// Labels reads PhotoPrism's pre-computed distinct-label counter
|
|
||||||
// (`/api/v1/config` → count.labels), not the photo-count from
|
|
||||||
// `countPhotos('label:*')`. The photo-count returned 0 on libraries
|
|
||||||
// whose indexer hadn't surfaced labelled photos yet, leaving the
|
|
||||||
// badge silently empty; the precomputed counter is always present
|
|
||||||
// and reads as "how many labels you can pick from", matching the
|
|
||||||
// Keywords sub-row's distinct-count semantics.
|
|
||||||
if (cat === 'labels') return configQuery.data?.count?.labels;
|
|
||||||
if (cat === 'keywords') return keywordsQuery.data?.length;
|
|
||||||
if (cat === 'ratings') return ratingsCount;
|
|
||||||
return colorsCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||||
return page.url.pathname.startsWith(`/tags/${cat}`);
|
return page.url.pathname.startsWith(`/tags/${cat}`);
|
||||||
}
|
}
|
||||||
@@ -504,14 +508,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickFolder(folderPath: string) {
|
async function pickFolder(folderPath: string) {
|
||||||
// Folder selection works on top of the All Photos section; clearing
|
await navigateToFolder(folderPath);
|
||||||
// the heap/section context mirrors mule-image's "drill into folder"
|
|
||||||
// behaviour. The URL sync $effect on the timeline picks this up.
|
|
||||||
setSection('all-photos');
|
|
||||||
setFolderPath(folderPath);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('folder', folderPath);
|
|
||||||
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateHeap() {
|
function onCreateHeap() {
|
||||||
@@ -545,14 +542,12 @@
|
|||||||
//
|
//
|
||||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||||
// derived value on every render — the arrays themselves are constant.
|
// derived value on every render — the arrays themselves are constant.
|
||||||
// `count.all` already excludes archived/review/hidden (PhotoPrism's
|
// Map and Tags intentionally render without a count badge; the count
|
||||||
// "everything visible in the main timeline" tally), so it matches what
|
// columns inside the TagsBrowserSidebar are the canonical surface for
|
||||||
// the All photos view actually renders. Map uses the shared `['geo']`
|
// per-tag totals. Review rolls in the duplicates tabs hosted under
|
||||||
// cache so its badge matches /map's "N geotagged" footer exactly —
|
// /review — stacks always contributes; cross-folder only contributes
|
||||||
// `count.places` would have shown distinct locations instead.
|
// once its tab has been opened (the scan is lazy, not eager from the
|
||||||
// Review rolls in the duplicates tabs hosted under /review — stacks
|
// sidebar).
|
||||||
// always contributes; cross-folder only contributes once its tab has
|
|
||||||
// been opened (the scan is lazy, not eager from the sidebar).
|
|
||||||
type ViewItem =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||||
@@ -562,41 +557,24 @@
|
|||||||
// separate "everything regardless of folder" destination would just
|
// separate "everything regardless of folder" destination would just
|
||||||
// duplicate it for users whose photos live under the root.
|
// duplicate it for users whose photos live under the root.
|
||||||
const views: ViewItem[] = [
|
const views: ViewItem[] = [
|
||||||
// Map's `geoQuery` already returns the GeoJSON the user is
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
|
||||||
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
|
||||||
// so the badge is per-user-correct without extra scoping.
|
|
||||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length }
|
|
||||||
// Tags is rendered as a bespoke expandable block below the
|
// Tags is rendered as a bespoke expandable block below the
|
||||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||||
// Ratings) and a chevron, neither of which fits the flat
|
// Ratings) and a chevron, neither of which fits the flat
|
||||||
// section/route ViewItem shape.
|
// section/route ViewItem shape. Notes lives under that expandable
|
||||||
|
// alongside the tag categories.
|
||||||
];
|
];
|
||||||
|
|
||||||
// Total badge for the "Tags" header row. Rolls up labels + keywords +
|
function isNotesActive(): boolean {
|
||||||
// ratings + colors. Labels flows through countPhotos (scoped); keywords/
|
return page.url.pathname === '/notes';
|
||||||
// ratings/colors are library-wide marks tables and only contribute when
|
|
||||||
// we're in admin-without-BasePath mode (their sources don't scope).
|
|
||||||
const tagsTotal = $derived.by<number | undefined>(() => {
|
|
||||||
if (labelsBadge === undefined) return undefined;
|
|
||||||
if (wantScoped) return labelsBadge;
|
|
||||||
const keywords = keywordsQuery.data?.length ?? 0;
|
|
||||||
return labelsBadge + keywords + ratingsCount + colorsCount;
|
|
||||||
});
|
|
||||||
|
|
||||||
const manageViews: ViewItem[] = [
|
|
||||||
{
|
|
||||||
kind: 'route',
|
|
||||||
href: '/review',
|
|
||||||
label: 'Review',
|
|
||||||
getCount: () => {
|
|
||||||
if (reviewBadge === undefined) return undefined;
|
|
||||||
// The two duplicates queries are library-wide; only admins
|
|
||||||
// without a BasePath roll them into the Review badge.
|
|
||||||
if (wantScoped) return reviewBadge;
|
|
||||||
return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
|
// Review is rendered separately below as a pure expandable toggle
|
||||||
|
// (mirroring Tags — no /review landing entry from the sidebar,
|
||||||
|
// navigation only via subitems, with Hidden tucked in alongside the
|
||||||
|
// tab subitems). This list carries the flat Manage entries that
|
||||||
|
// follow it.
|
||||||
|
const manageViews: ViewItem[] = [
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -757,9 +735,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if foldersQuery.isPending}
|
{#if foldersQuery.isPending}
|
||||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading folders…" />
|
||||||
{:else if !hasSubfolders}
|
{:else if !hasSubfolders}
|
||||||
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
|
||||||
{:else if rootExpanded}
|
{:else if rootExpanded}
|
||||||
<!--
|
<!--
|
||||||
depth=1 visually nests the top-level subfolders one indent
|
depth=1 visually nests the top-level subfolders one indent
|
||||||
@@ -795,11 +773,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if heapsQuery.isPending}
|
{#if heapsQuery.isPending}
|
||||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading heaps…" />
|
||||||
{:else if heapsQuery.isError}
|
{:else if heapsQuery.isError}
|
||||||
<p class="px-2 text-[11px] text-destructive">Failed to load heaps</p>
|
<EmptyState size="compact" tone="destructive" title="Failed to load heaps" />
|
||||||
{:else if (heapsQuery.data ?? []).length === 0}
|
{:else if (heapsQuery.data ?? []).length === 0}
|
||||||
<p class="px-2 text-[11px] text-muted-foreground">No heaps yet.</p>
|
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
@@ -890,9 +868,10 @@
|
|||||||
{@render viewRow(v)}
|
{@render viewRow(v)}
|
||||||
{/each}
|
{/each}
|
||||||
<!--
|
<!--
|
||||||
Tags expandable. Whole row is a toggle (chevron + label + badge);
|
Tags expandable. Whole row is a toggle (chevron + label); there is
|
||||||
there is no landing page at /tags — selecting a sub-category is the
|
no landing page at /tags — selecting a sub-category is the only way
|
||||||
only way into a real view.
|
into a real view. Counts intentionally live in the TagsBrowserSidebar
|
||||||
|
(secondary sidebar) so this row stays a pure navigator.
|
||||||
-->
|
-->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -909,19 +888,39 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
<span class="truncate">Tags</span>
|
<span class="truncate">Tags</span>
|
||||||
{#if tagsTotal !== undefined}
|
|
||||||
<span
|
|
||||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-secondary px-1 text-[10px] tabular-nums text-muted-foreground"
|
|
||||||
>
|
|
||||||
{tagsTotal}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{#if tagsExpanded}
|
{#if tagsExpanded}
|
||||||
|
<!--
|
||||||
|
Notes lives alongside the tag categories — same indent and row
|
||||||
|
chrome — but routes to /notes rather than /tags/*. Tucked at
|
||||||
|
the top of the expandable so it's the first thing the user
|
||||||
|
sees when opening Tags. Count badge renders once the shared
|
||||||
|
['photos', 'with-notes'] query has resolved.
|
||||||
|
-->
|
||||||
|
{@const notesActive = isNotesActive()}
|
||||||
|
{@const notesCount = notesQuery.data?.length}
|
||||||
|
<a
|
||||||
|
href="/notes"
|
||||||
|
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={notesActive}
|
||||||
|
class:text-primary-foreground={notesActive}
|
||||||
|
class:hover:bg-primary={notesActive}
|
||||||
|
style="padding-left: 36px;"
|
||||||
|
>
|
||||||
|
<span class="truncate">Notes</span>
|
||||||
|
{#if notesCount !== undefined}
|
||||||
|
<span
|
||||||
|
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {notesActive
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{notesCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
{#each TAG_CATEGORIES as cat (cat)}
|
{#each TAG_CATEGORIES as cat (cat)}
|
||||||
{@const active = isTagCategoryActive(cat)}
|
{@const active = isTagCategoryActive(cat)}
|
||||||
{@const count = tagCategoryCount(cat)}
|
|
||||||
<a
|
<a
|
||||||
href={`/tags/${cat}`}
|
href={`/tags/${cat}`}
|
||||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
@@ -933,15 +932,6 @@
|
|||||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||||
>
|
>
|
||||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||||
{#if count !== undefined}
|
|
||||||
<span
|
|
||||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
|
||||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
|
||||||
: 'bg-secondary text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -956,6 +946,62 @@
|
|||||||
Manage
|
Manage
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!--
|
||||||
|
Review expandable. Mirrors the Tags affordance — pure toggle
|
||||||
|
with no landing page; the only way into a tab is to expand and
|
||||||
|
pick a subitem. Cause buckets are dynamic (only buckets with
|
||||||
|
hits show up); Stacks/Cross-folder are always present.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
style="padding-left: 4px;"
|
||||||
|
onclick={toggleReview}
|
||||||
|
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
|
||||||
|
aria-expanded={reviewExpanded}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{reviewExpanded ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||||
|
<span class="truncate">Review</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if reviewExpanded}
|
||||||
|
{#each reviewTabs as t (t.id)}
|
||||||
|
{@const active = isReviewTabActive(t.id)}
|
||||||
|
<a
|
||||||
|
href={`/review?tab=${t.id}`}
|
||||||
|
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
style="padding-left: 36px;"
|
||||||
|
>
|
||||||
|
<span class="truncate">{t.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
<!--
|
||||||
|
Hidden lives under Review since it's the resting place for
|
||||||
|
photos dismissed during review. Section-nav (not a ?tab=),
|
||||||
|
so it's a button that flips filters.section like the flat
|
||||||
|
Manage entries — just with the subitem indent.
|
||||||
|
-->
|
||||||
|
{@const hiddenActive = isActive('hidden')}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={hiddenActive}
|
||||||
|
class:text-primary-foreground={hiddenActive}
|
||||||
|
class:hover:bg-primary={hiddenActive}
|
||||||
|
style="padding-left: 36px;"
|
||||||
|
onclick={() => navigateTo('hidden')}
|
||||||
|
>
|
||||||
|
<span class="truncate">Hidden</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
{@render viewRow(v)}
|
{@render viewRow(v)}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -999,6 +1045,17 @@
|
|||||||
>
|
>
|
||||||
<Settings class="h-3.5 w-3.5" />
|
<Settings class="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{#if isAdminUser}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={() => (usersOpen = true)}
|
||||||
|
title="Users"
|
||||||
|
aria-label="Manage users"
|
||||||
|
>
|
||||||
|
<Users class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
@@ -1017,3 +1074,6 @@
|
|||||||
open={generalSettingsOpen}
|
open={generalSettingsOpen}
|
||||||
onClose={() => (generalSettingsOpen = false)}
|
onClose={() => (generalSettingsOpen = false)}
|
||||||
/>
|
/>
|
||||||
|
{#if isAdminUser}
|
||||||
|
<UsersDialog open={usersOpen} onClose={() => (usersOpen = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
import { Dialog, Tabs } from 'bits-ui';
|
import { Dialog, Tabs } from 'bits-ui';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import {
|
import {
|
||||||
cancelImport,
|
cancelImport,
|
||||||
cancelIndex,
|
cancelIndex,
|
||||||
|
getConfig,
|
||||||
getErrors,
|
getErrors,
|
||||||
getSettings,
|
getSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
type PpLogEntry,
|
type PpLogEntry,
|
||||||
type PpSettings
|
type PpSettings
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import type { PpClientConfig } from '$lib/types/photoprism';
|
||||||
import { userBasePath } from '$lib/stores/session.svelte';
|
import { userBasePath } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
let activeTab = $state<'library' | 'index' | 'import' | 'logs'>('library');
|
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
|
||||||
|
|
||||||
// ── Library tab ───────────────────────────────────────────────────────
|
// ── Library tab ───────────────────────────────────────────────────────
|
||||||
// Pull settings only while the dialog is open so we don't keep them
|
// Pull settings only while the dialog is open so we don't keep them
|
||||||
@@ -45,22 +48,42 @@
|
|||||||
enabled: open
|
enabled: open
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let draft = $state<PpSettings | null>(null);
|
/**
|
||||||
$effect(() => {
|
* Force the shape on every clone so each `bind:value={draft.index!.*}`
|
||||||
if (settingsQuery.data && draft === null) {
|
* etc. has a real object to write into. Older PhotoPrism versions
|
||||||
draft = structuredClone(settingsQuery.data);
|
* return /settings without one or more of these sub-objects, and
|
||||||
|
* non-null assertions on a missing sub-object throw on the next tick
|
||||||
|
* when Svelte's bind getter reads through it.
|
||||||
|
*
|
||||||
|
* Same shape-coercion pattern used by GeneralSettingsDialog —
|
||||||
|
* keep them in sync if you add a new top-level group there.
|
||||||
|
*/
|
||||||
|
function normalize(s: PpSettings): PpSettings {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
index: s.index ?? {},
|
||||||
|
import: s.import ?? {},
|
||||||
|
stack: s.stack ?? {},
|
||||||
|
download: s.download ?? {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
|
||||||
// Reset the draft when the dialog closes so the next open re-reads.
|
let draft = $state<PpSettings | null>(null);
|
||||||
|
// Re-clone on every open so reopening shows the freshest server state.
|
||||||
|
// Resetting on open (not close) avoids the race where bits-ui's exit
|
||||||
|
// animation keeps the form mounted with `draft === null` and the
|
||||||
|
// `bind:value={draft.download!.originals}` getter throws.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!open) draft = null;
|
if (open && settingsQuery.data) {
|
||||||
|
draft = normalize(structuredClone(settingsQuery.data));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMut = createMutation(() => ({
|
const saveMut = createMutation(() => ({
|
||||||
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||||
onSuccess: (next) => {
|
onSuccess: (next) => {
|
||||||
qc.setQueryData(['settings'], next);
|
qc.setQueryData(['settings'], next);
|
||||||
draft = structuredClone(next);
|
draft = normalize(structuredClone(next));
|
||||||
toast.success('Settings saved');
|
toast.success('Settings saved');
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
@@ -68,7 +91,7 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function resetDraft() {
|
function resetDraft() {
|
||||||
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
|
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Index tab ─────────────────────────────────────────────────────────
|
// ── Index tab ─────────────────────────────────────────────────────────
|
||||||
@@ -119,6 +142,64 @@
|
|||||||
enabled: open && activeTab === 'logs',
|
enabled: open && activeTab === 'logs',
|
||||||
refetchInterval: open && activeTab === 'logs' ? 5000 : false
|
refetchInterval: open && activeTab === 'logs' ? 5000 : false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// ── About tab ─────────────────────────────────────────────────────────
|
||||||
|
// Reuses the same query key as the LeftSidebar's `['photos', 'config']`
|
||||||
|
// so the About tab never triggers an extra round-trip — config is
|
||||||
|
// already warm by the time the user opens this dialog.
|
||||||
|
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||||
|
queryKey: ['photos', 'config'],
|
||||||
|
queryFn: getConfig,
|
||||||
|
enabled: open && activeTab === 'about'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// PhotoPrism's `flags` is a space-separated bag of feature toggles
|
||||||
|
// ("experimental tensorflow places webdav share download import oidc").
|
||||||
|
// Parse once so the chip grid can render in stable order.
|
||||||
|
const flagSet = $derived.by<Set<string>>(() => {
|
||||||
|
const raw = configQuery.data?.flags ?? '';
|
||||||
|
return new Set(raw.split(/\s+/).filter(Boolean));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Env-driven knobs that don't have a runtime PP API. Listed here so the
|
||||||
|
// About tab can render a "you need to edit .env and restart" help
|
||||||
|
// section instead of pretending these are mutable from the UI.
|
||||||
|
interface EnvKnob {
|
||||||
|
envVar: string;
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
}
|
||||||
|
const envKnobs = $derived.by<EnvKnob[]>(() => {
|
||||||
|
const f = flagSet;
|
||||||
|
const oidc = configQuery.data?.ext?.oidc?.enabled === true;
|
||||||
|
return [
|
||||||
|
{ envVar: 'OIDC_*', label: 'OIDC SSO', on: oidc },
|
||||||
|
{ envVar: 'PP_AUTH_MODE=public', label: 'Public (no-auth) mode', on: f.has('public') },
|
||||||
|
{ envVar: 'PHOTOPRISM_DISABLE_TF', label: 'TensorFlow / AI classifier', on: f.has('tensorflow') },
|
||||||
|
{ envVar: 'PHOTOPRISM_DISABLE_PLACES', label: 'Places (geocoding)', on: f.has('places') },
|
||||||
|
{ envVar: 'PHOTOPRISM_DISABLE_WEBDAV', label: 'WebDAV', on: f.has('webdav') }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the config block collapsed by default — most users only want the
|
||||||
|
// version + counts; the env help is for the rare admin moment.
|
||||||
|
let envHelpOpen = $state(false);
|
||||||
|
|
||||||
|
// Library counts surfaced as a compact 2-column grid. Order matches
|
||||||
|
// what users care about most often (photos, then derived buckets).
|
||||||
|
const COUNT_ROWS: { key: keyof NonNullable<PpClientConfig['count']>; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'Photos' },
|
||||||
|
{ key: 'videos', label: 'Videos' },
|
||||||
|
{ key: 'live', label: 'Live photos' },
|
||||||
|
{ key: 'favorites', label: 'Favorites' },
|
||||||
|
{ key: 'review', label: 'In review' },
|
||||||
|
{ key: 'archived', label: 'Archived' },
|
||||||
|
{ key: 'hidden', label: 'Hidden' },
|
||||||
|
{ key: 'people', label: 'People' },
|
||||||
|
{ key: 'labels', label: 'Labels' },
|
||||||
|
{ key: 'folders', label: 'Folders' },
|
||||||
|
{ key: 'albums', label: 'Albums' }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
@@ -141,7 +222,7 @@
|
|||||||
Library settings
|
Library settings
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||||
Drive PhotoPrism's library, indexer, importer and server log.
|
Drive the library, indexer, importer and server log.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close
|
||||||
@@ -156,7 +237,7 @@
|
|||||||
<Tabs.List
|
<Tabs.List
|
||||||
class="mb-3 flex gap-1 border-b border-border"
|
class="mb-3 flex gap-1 border-b border-border"
|
||||||
>
|
>
|
||||||
{#each ['library', 'index', 'import', 'logs'] as const as t (t)}
|
{#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={t}
|
value={t}
|
||||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||||
@@ -272,8 +353,92 @@
|
|||||||
/>
|
/>
|
||||||
Disable downloads entirely
|
Disable downloads entirely
|
||||||
</label>
|
</label>
|
||||||
|
{#if draft.download?.crc32 !== undefined}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.download!.crc32}
|
||||||
|
/>
|
||||||
|
Include CRC32 checksum
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if draft.download?.sha1 !== undefined}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.download!.sha1}
|
||||||
|
/>
|
||||||
|
Include SHA1 checksum
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Indexer advanced — only renders the fields PP actually
|
||||||
|
reported. Older PP versions return a smaller `index`
|
||||||
|
block and we don't want to fabricate UI for missing keys. -->
|
||||||
|
{#if draft.index?.skipMeta !== undefined || draft.index?.skipRaw !== undefined || draft.index?.skipHidden !== undefined}
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Indexer advanced
|
||||||
|
</h3>
|
||||||
|
{#if draft.index?.skipMeta !== undefined}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.index!.skipMeta}
|
||||||
|
/>
|
||||||
|
Skip metadata-only changes
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if draft.index?.skipRaw !== undefined}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.index!.skipRaw}
|
||||||
|
/>
|
||||||
|
Skip RAW files
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if draft.index?.skipHidden !== undefined}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.index!.skipHidden}
|
||||||
|
/>
|
||||||
|
Skip hidden files
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Features — PhotoPrism's gating bag. Render only the
|
||||||
|
keys actually present in the response (PP version
|
||||||
|
drift), labelled human-readably. -->
|
||||||
|
{#if draft.features && Object.keys(draft.features).length > 0}
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Features
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Toggling a feature off hides it from PhotoPrism's own
|
||||||
|
UI and disables the underlying API surface.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||||
|
{#each Object.keys(draft.features).sort() as key (key)}
|
||||||
|
{#if typeof draft.features![key] === 'boolean'}
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={draft.features![key]}
|
||||||
|
/>
|
||||||
|
<span class="capitalize">{key}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-end gap-2">
|
<div class="mt-4 flex items-center justify-end gap-2">
|
||||||
@@ -398,12 +563,136 @@
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<!-- About — version, library counts, env-driven config help -->
|
||||||
|
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
|
||||||
|
{#if configQuery.isPending}
|
||||||
|
<InlineLoader size="sm" label="Loading server info…" />
|
||||||
|
{:else if configQuery.isError || !configQuery.data}
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Could not load server info"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{@const cfg = configQuery.data}
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Server
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
||||||
|
<span class="text-muted-foreground">PhotoPrism</span>
|
||||||
|
<span class="text-right tabular-nums">{cfg.edition} {cfg.version}</span>
|
||||||
|
<span class="text-muted-foreground">Site</span>
|
||||||
|
<span class="truncate text-right" title={cfg.siteUrl}>
|
||||||
|
{cfg.siteUrl || '—'}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted-foreground">Auth mode</span>
|
||||||
|
<span class="text-right">{cfg.mode}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Features
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each envKnobs as knob (knob.envVar)}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] {knob.on
|
||||||
|
? 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'
|
||||||
|
: 'border-border bg-secondary text-muted-foreground'}"
|
||||||
|
title={knob.envVar}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full {knob.on
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-muted-foreground/40'}"
|
||||||
|
></span>
|
||||||
|
{knob.label}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if cfg.count}
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Library
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1 rounded border border-border bg-muted/30 p-2">
|
||||||
|
{#each COUNT_ROWS as row (row.key)}
|
||||||
|
{@const v = cfg.count?.[row.key]}
|
||||||
|
{#if v !== undefined}
|
||||||
|
<span class="text-muted-foreground">{row.label}</span>
|
||||||
|
<span class="text-right tabular-nums">{v}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Env-driven config: there is no PhotoPrism API for these.
|
||||||
|
The panel surfaces what's on/off and reminds the admin
|
||||||
|
where to flip the switch — .env + restart. -->
|
||||||
|
<section class="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded border border-border bg-muted/30 px-2 py-1.5 text-left hover:bg-accent"
|
||||||
|
onclick={() => (envHelpOpen = !envHelpOpen)}
|
||||||
|
aria-expanded={envHelpOpen}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Server configuration (env-driven)
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground">
|
||||||
|
{envHelpOpen ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{#if envHelpOpen}
|
||||||
|
<div class="space-y-2 rounded border border-border bg-muted/20 p-2 text-[11px]">
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
These knobs aren't exposed through the API. Edit
|
||||||
|
<code class="rounded bg-background px-1">.env</code>
|
||||||
|
on the host and restart PhotoPrism:
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
class="overflow-x-auto rounded bg-background p-2 font-mono text-[11px] leading-snug"
|
||||||
|
>docker compose up -d photoprism
|
||||||
|
# or, with podman-compose:
|
||||||
|
podman-compose --env-file .env -f docker-compose.yml -f docker-compose.podman.yml up -d photoprism</pre>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#each envKnobs as knob (knob.envVar)}
|
||||||
|
<li>
|
||||||
|
<code class="rounded bg-background px-1">{knob.envVar}</code>
|
||||||
|
—
|
||||||
|
<span class:text-green-600={knob.on}
|
||||||
|
class:text-muted-foreground={!knob.on}>
|
||||||
|
{knob.on ? 'enabled' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if cfg.ext?.oidc?.enabled}
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
OIDC provider:
|
||||||
|
<span class="text-foreground">
|
||||||
|
{cfg.ext.oidc.provider ?? '—'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
<!-- Logs — recent server errors -->
|
<!-- Logs — recent server errors -->
|
||||||
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
|
<Tabs.Content value="logs" class="space-y-2 text-[12px] outline-none">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-muted-foreground">
|
<p class="text-muted-foreground">
|
||||||
Most recent PhotoPrism errors and warnings. Auto-refreshes
|
Most recent server errors and warnings. Auto-refreshes every
|
||||||
every 5 seconds.
|
5 seconds.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -418,11 +707,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if errorsQuery.isPending}
|
{#if errorsQuery.isPending}
|
||||||
<p class="px-1 text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading error log…" />
|
||||||
{:else if errorsQuery.isError}
|
{:else if errorsQuery.isError}
|
||||||
<p class="px-1 text-destructive">Could not load error log.</p>
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Could not load error log"
|
||||||
|
/>
|
||||||
{:else if (errorsQuery.data ?? []).length === 0}
|
{:else if (errorsQuery.data ?? []).length === 0}
|
||||||
<p class="px-1 text-muted-foreground">No errors logged.</p>
|
<EmptyState size="compact" icon={CheckCircle2} title="No errors logged" />
|
||||||
{:else}
|
{:else}
|
||||||
<ul
|
<ul
|
||||||
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
|
class="max-h-[55vh] space-y-1 overflow-y-auto rounded border border-border bg-background p-2 font-mono text-[11px]"
|
||||||
|
|||||||
484
web/src/lib/components/layout/UsersDialog.svelte
Normal file
484
web/src/lib/components/layout/UsersDialog.svelte
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
<!--
|
||||||
|
Admin-only user management. PhotoPrism exposes /api/v1/users CRUD; this
|
||||||
|
dialog wraps it in a list/edit two-pane so the PP web UI never has to be
|
||||||
|
opened for routine user changes. Mounted from LeftSidebar's footer
|
||||||
|
(visible only when session.user.Role === 'admin').
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Dialog } from 'bits-ui';
|
||||||
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Loader2, Plus, Trash2, Users as UsersIcon, X } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
deleteUser,
|
||||||
|
listUsers,
|
||||||
|
setUserPassword,
|
||||||
|
updateUser,
|
||||||
|
type CreateUserBody
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import type { PpRole, PpUser } from '$lib/types/photoprism';
|
||||||
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const usersQuery = createQuery<PpUser[]>(() => ({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: listUsers,
|
||||||
|
enabled: open
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selection state: a UID picks an existing user from the list; `null`
|
||||||
|
// means "no selection" (right pane empty); `'new'` opens the new-user
|
||||||
|
// form. Reset whenever the dialog opens so reopening doesn't strand a
|
||||||
|
// stale form.
|
||||||
|
type Selection = string | 'new' | null;
|
||||||
|
let selection = $state<Selection>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) selection = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROLES: PpRole[] = ['admin', 'user', 'contributor', 'guest', 'visitor'];
|
||||||
|
|
||||||
|
// Editable copy of the selected user. Re-cloned on every selection
|
||||||
|
// change so the form starts from the server-side snapshot (and a
|
||||||
|
// failed save doesn't leak stale values into the next selection).
|
||||||
|
let draft = $state<EditableUser>(emptyDraft());
|
||||||
|
|
||||||
|
interface EditableUser {
|
||||||
|
UID: string;
|
||||||
|
Name: string;
|
||||||
|
DisplayName: string;
|
||||||
|
Email: string;
|
||||||
|
Role: PpRole;
|
||||||
|
BasePath: string;
|
||||||
|
UploadPath: string;
|
||||||
|
WebDAV: boolean;
|
||||||
|
Password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(): EditableUser {
|
||||||
|
return {
|
||||||
|
UID: '',
|
||||||
|
Name: '',
|
||||||
|
DisplayName: '',
|
||||||
|
Email: '',
|
||||||
|
Role: 'user',
|
||||||
|
BasePath: '',
|
||||||
|
UploadPath: '',
|
||||||
|
WebDAV: false,
|
||||||
|
Password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function userToDraft(u: PpUser): EditableUser {
|
||||||
|
return {
|
||||||
|
UID: u.UID,
|
||||||
|
Name: u.Name ?? '',
|
||||||
|
DisplayName: u.DisplayName ?? '',
|
||||||
|
Email: u.Email ?? '',
|
||||||
|
Role: u.Role ?? 'user',
|
||||||
|
BasePath: u.BasePath ?? '',
|
||||||
|
UploadPath: u.UploadPath ?? '',
|
||||||
|
// Server may or may not return WebDAV depending on PP version;
|
||||||
|
// default to false rather than guessing the current value.
|
||||||
|
WebDAV: Boolean((u as PpUser & { WebDAV?: boolean }).WebDAV),
|
||||||
|
Password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selection === 'new') {
|
||||||
|
draft = emptyDraft();
|
||||||
|
} else if (selection) {
|
||||||
|
const u = (usersQuery.data ?? []).find((x) => x.UID === selection);
|
||||||
|
if (u) draft = userToDraft(u);
|
||||||
|
} else {
|
||||||
|
draft = emptyDraft();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password sub-form (only relevant when editing an existing user).
|
||||||
|
// Decoupled from `draft` because the password endpoint is a separate
|
||||||
|
// PUT and never goes through createUser/updateUser.
|
||||||
|
let pwNew = $state('');
|
||||||
|
let pwConfirm = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reset password fields whenever the selection changes.
|
||||||
|
void selection;
|
||||||
|
pwNew = '';
|
||||||
|
pwConfirm = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function toBody(d: EditableUser): CreateUserBody {
|
||||||
|
const body: CreateUserBody = {
|
||||||
|
Name: d.Name.trim(),
|
||||||
|
Role: d.Role
|
||||||
|
};
|
||||||
|
if (d.DisplayName.trim()) body.DisplayName = d.DisplayName.trim();
|
||||||
|
if (d.Email.trim()) body.Email = d.Email.trim();
|
||||||
|
if (d.BasePath.trim()) body.BasePath = d.BasePath.trim();
|
||||||
|
if (d.UploadPath.trim()) body.UploadPath = d.UploadPath.trim();
|
||||||
|
body.WebDAV = d.WebDAV;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMut = createMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!draft.Name.trim()) throw new Error('Username is required');
|
||||||
|
if (!draft.Password || draft.Password.length < 8) {
|
||||||
|
throw new Error('Password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
const body = toBody(draft);
|
||||||
|
body.Password = draft.Password;
|
||||||
|
return createUser(body);
|
||||||
|
},
|
||||||
|
onSuccess: (u) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success(`Created user ${u.Name}`);
|
||||||
|
selection = u.UID;
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not create user')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updateMut = createMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||||
|
return updateUser(selection, toBody(draft));
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success('User updated');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not update user')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deleteMut = createMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||||
|
return deleteUser(selection);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
toast.success('User deleted');
|
||||||
|
selection = null;
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not delete user')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pwMut = createMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!selection || selection === 'new') throw new Error('No user selected');
|
||||||
|
if (pwNew.length < 8) throw new Error('Password must be at least 8 characters');
|
||||||
|
if (pwNew !== pwConfirm) throw new Error('Passwords do not match');
|
||||||
|
// Admin-issued password reset: PhotoPrism accepts an empty `old`
|
||||||
|
// when the caller is an admin acting on another user.
|
||||||
|
await setUserPassword(selection, '', pwNew);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
pwNew = '';
|
||||||
|
pwConfirm = '';
|
||||||
|
toast.success('Password updated');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not update password')
|
||||||
|
}));
|
||||||
|
|
||||||
|
function onDeleteClick() {
|
||||||
|
if (!draft.Name) return;
|
||||||
|
if (!confirm(`Delete user "${draft.Name}"? This cannot be undone.`)) return;
|
||||||
|
deleteMut.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = $derived(
|
||||||
|
selection !== 'new' && selection !== null && selection === session.user?.UID
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
{open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay
|
||||||
|
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||||
|
/>
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[760px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<UsersIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Dialog.Title class="text-sm font-semibold leading-tight">Users</Dialog.Title>
|
||||||
|
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Manage accounts. Roles + per-user library paths come from the
|
||||||
|
server's ACL — changes apply immediately.
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
<Dialog.Close
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-3.5 w-3.5" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[220px_1fr] gap-4">
|
||||||
|
<!-- Left pane: user list + new-user trigger. -->
|
||||||
|
<div class="flex max-h-[480px] flex-col overflow-hidden rounded border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 shrink-0 items-center gap-1.5 border-b border-border px-2 text-left text-[12px] hover:bg-accent"
|
||||||
|
class:bg-accent={selection === 'new'}
|
||||||
|
onclick={() => (selection = 'new')}
|
||||||
|
>
|
||||||
|
<Plus class="h-3.5 w-3.5" />
|
||||||
|
<span>New user</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if usersQuery.isPending}
|
||||||
|
<p class="px-2 py-2 text-[12px] text-muted-foreground">Loading…</p>
|
||||||
|
{:else if usersQuery.isError}
|
||||||
|
<p class="px-2 py-2 text-[12px] text-destructive">
|
||||||
|
Could not load users.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul>
|
||||||
|
{#each usersQuery.data ?? [] as u (u.UID)}
|
||||||
|
{@const active = selection === u.UID}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col gap-0.5 border-b border-border/40 px-2 py-1.5 text-left text-[12px] hover:bg-accent"
|
||||||
|
class:bg-accent={active}
|
||||||
|
onclick={() => (selection = u.UID)}
|
||||||
|
>
|
||||||
|
<span class="flex items-center justify-between gap-2">
|
||||||
|
<span class="truncate font-medium">
|
||||||
|
{u.DisplayName?.trim() || u.Name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded bg-secondary px-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
{u.Role}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{#if u.BasePath}
|
||||||
|
<span class="truncate text-[11px] text-muted-foreground">
|
||||||
|
{u.BasePath}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right pane: edit form for selected user (or empty/new form). -->
|
||||||
|
<div class="min-w-0">
|
||||||
|
{#if selection === null}
|
||||||
|
<div
|
||||||
|
class="flex h-full min-h-[280px] items-center justify-center rounded border border-dashed border-border p-4 text-center text-[12px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
Pick a user on the left, or click "New user" to create one.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form
|
||||||
|
class="space-y-3"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selection === 'new') createMut.mutate();
|
||||||
|
else updateMut.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
Username
|
||||||
|
<span class="text-destructive">*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={draft.Name}
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={selection !== 'new'}
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">Display name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={draft.DisplayName}
|
||||||
|
autocomplete="off"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">Email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
bind:value={draft.Email}
|
||||||
|
autocomplete="off"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">Role</span>
|
||||||
|
<select bind:value={draft.Role} class={inputClass}>
|
||||||
|
{#each ROLES as r (r)}
|
||||||
|
<option value={r}>{r}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
Base path
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={draft.BasePath}
|
||||||
|
placeholder="e.g. alice"
|
||||||
|
autocomplete="off"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
Upload path
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={draft.UploadPath}
|
||||||
|
autocomplete="off"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-[12px]">
|
||||||
|
<input type="checkbox" bind:checked={draft.WebDAV} />
|
||||||
|
Allow WebDAV access
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if selection === 'new'}
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
Initial password <span class="text-destructive">*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={draft.Password}
|
||||||
|
required
|
||||||
|
minlength={8}
|
||||||
|
autocomplete="new-password"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 border-t border-border pt-3">
|
||||||
|
{#if selection !== 'new'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded border border-destructive/40 px-3 py-1 text-[12px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
|
onclick={onDeleteClick}
|
||||||
|
disabled={isSelf || deleteMut.isPending}
|
||||||
|
title={isSelf ? 'Cannot delete yourself' : 'Delete user'}
|
||||||
|
>
|
||||||
|
{#if deleteMut.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Trash2 class="h-3 w-3" />
|
||||||
|
{/if}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span></span>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
disabled={createMut.isPending || updateMut.isPending || !draft.Name.trim()}
|
||||||
|
>
|
||||||
|
{#if createMut.isPending || updateMut.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{selection === 'new' ? 'Create user' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if selection !== 'new'}
|
||||||
|
<!-- Admin-issued password reset. Separate from the user's own
|
||||||
|
password change in GeneralSettingsDialog (which requires
|
||||||
|
their current password); admins reset without old-pw. -->
|
||||||
|
<form
|
||||||
|
class="mt-4 space-y-3 rounded border border-border bg-muted/30 p-3"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
pwMut.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Reset password
|
||||||
|
</h4>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">New password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={pwNew}
|
||||||
|
minlength={8}
|
||||||
|
autocomplete="new-password"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">Confirm</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={pwConfirm}
|
||||||
|
minlength={8}
|
||||||
|
autocomplete="new-password"
|
||||||
|
class={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex items-center gap-1.5 rounded border border-border px-3 py-1 text-[12px] hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={pwMut.isPending || pwNew.length < 8 || pwNew !== pwConfirm}
|
||||||
|
>
|
||||||
|
{#if pwMut.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Set password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
@@ -148,12 +155,19 @@
|
|||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#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>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
import PreviewCarousel from './PreviewCarousel.svelte';
|
import PreviewCarousel from './PreviewCarousel.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import { InlineLoader } from '$lib/components/feedback';
|
||||||
|
|
||||||
const focusedUid = $derived(selection.focused);
|
const focusedUid = $derived(selection.focused);
|
||||||
|
|
||||||
@@ -133,7 +134,11 @@
|
|||||||
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|
||||||
<!-- Top row: preview pane (fills) + sidebar (fixed width). -->
|
<!-- Top row: preview pane (fills) + sidebar (fixed width).
|
||||||
|
BulkActionBar lives inside the main column — same shape as the
|
||||||
|
timeline (+page.svelte) so the bar stays bounded by the
|
||||||
|
column's width and doesn't stretch under the metadata
|
||||||
|
sidebar. -->
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<div class="relative flex min-w-0 flex-1 flex-col">
|
<div class="relative flex min-w-0 flex-1 flex-col">
|
||||||
<button
|
<button
|
||||||
@@ -148,19 +153,24 @@
|
|||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
<PreviewPane uid={focusedUid} order={selection.order} />
|
<PreviewPane uid={focusedUid} order={selection.order} />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
||||||
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
{#if focusedPhotoQuery.data}
|
<!-- Sidebar stays mounted across photo changes so the preview
|
||||||
|
pane doesn't reflow on arrow-skim; contents swap between
|
||||||
|
the metadata panel and a small loader the same way the
|
||||||
|
timeline's right-aside does. -->
|
||||||
<aside
|
<aside
|
||||||
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
|
||||||
>
|
>
|
||||||
|
{#if focusedPhotoQuery.data}
|
||||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
</aside>
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
|
<InlineLoader size="sm" label="Loading metadata…" />
|
||||||
{/if}
|
{/if}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|
||||||
<!-- Bottom filmstrip across selection.order. -->
|
<!-- Bottom filmstrip across selection.order. -->
|
||||||
<PreviewCarousel />
|
<PreviewCarousel />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
|
|||||||
@@ -9,12 +9,15 @@
|
|||||||
throw away. Until the timer fires, the poster image stands in.
|
throw away. Until the timer fires, the poster image stands in.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
import { thumbSrc, thumbSrcSet, thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uid: string | null;
|
uid: string | null;
|
||||||
@@ -25,6 +28,8 @@
|
|||||||
}
|
}
|
||||||
let { uid, order, showChevrons = true }: Props = $props();
|
let { uid, order, showChevrons = true }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||||
queryKey: ['photo', uid ?? ''],
|
queryKey: ['photo', uid ?? ''],
|
||||||
queryFn: () => getPhoto(uid as string),
|
queryFn: () => getPhoto(uid as string),
|
||||||
@@ -33,6 +38,48 @@
|
|||||||
|
|
||||||
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
const currentIndex = $derived(uid ? order.indexOf(uid) : -1);
|
||||||
|
|
||||||
|
/** Mirrors PreviewCarousel.lookup — pulls a PpPhoto out of TanStack's
|
||||||
|
* cache without firing a fetch, so we can resolve adjacent hashes for
|
||||||
|
* prefetching without making the prefetch itself trigger more work. */
|
||||||
|
function lookupCached(target: string): PpPhoto | null {
|
||||||
|
const direct = qc.getQueryData<PpPhoto>(['photo', target]);
|
||||||
|
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 === target);
|
||||||
|
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 === target);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch fit_1280 for the ±2 neighbours of the focused photo so
|
||||||
|
// arrow-skim feels instant. We `new Image()` rather than `<link
|
||||||
|
// rel=preload>` because the URLs are runtime-derived and a throwaway
|
||||||
|
// Image() reuses the browser's HTTP cache the same way.
|
||||||
|
$effect(() => {
|
||||||
|
if (!uid || currentIndex < 0) return;
|
||||||
|
for (const offset of [-1, 1, -2, 2]) {
|
||||||
|
const idx = currentIndex + offset;
|
||||||
|
if (idx < 0 || idx >= order.length) continue;
|
||||||
|
const photo = lookupCached(order[idx]);
|
||||||
|
if (!photo) continue;
|
||||||
|
const hash = primaryFile(photo).Hash;
|
||||||
|
if (!hash) continue;
|
||||||
|
const img = new Image();
|
||||||
|
img.src = thumbUrl(hash, 'fit_1280');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const VIDEO_LOAD_DELAY_MS = 250;
|
const VIDEO_LOAD_DELAY_MS = 250;
|
||||||
let armedUid = $state<string | null>(null);
|
let armedUid = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -58,11 +105,11 @@
|
|||||||
|
|
||||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||||
{#if uid === null}
|
{#if uid === null}
|
||||||
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
|
<EmptyState icon={ImageIcon} title="Select a photo to preview" />
|
||||||
{:else if photoQuery.isPending}
|
{:else if photoQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading…</p>
|
<InlineLoader label="Loading photo…" align="center" />
|
||||||
{:else if photoQuery.isError}
|
{:else if photoQuery.isError}
|
||||||
<p class="text-sm text-destructive">Failed to load photo.</p>
|
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photo" />
|
||||||
{:else if photoQuery.data}
|
{:else if photoQuery.data}
|
||||||
{@const pf = primaryFile(photoQuery.data)}
|
{@const pf = primaryFile(photoQuery.data)}
|
||||||
{#if showChevrons && currentIndex > 0}
|
{#if showChevrons && currentIndex > 0}
|
||||||
@@ -84,9 +131,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isVideo(photoQuery.data)}
|
{#if isVideo(photoQuery.data) && armedUid === uid}
|
||||||
{@const vf = videoFile(photoQuery.data)}
|
{@const vf = videoFile(photoQuery.data)}
|
||||||
{#if armedUid === uid}
|
|
||||||
{#key vf.Hash}
|
{#key vf.Hash}
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
src={videoUrl(vf.Hash)}
|
src={videoUrl(vf.Hash)}
|
||||||
@@ -95,17 +141,33 @@
|
|||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
{:else}
|
{:else}
|
||||||
|
{@const altText =
|
||||||
|
photoQuery.data.OriginalName ??
|
||||||
|
pf.Name ??
|
||||||
|
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||||
|
{#if pf.Width && pf.Height}
|
||||||
|
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||||
|
the tile_*'s square center-crop against the sharp image's
|
||||||
|
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||||
|
lands in the exact same bounding box as the sharp <img>
|
||||||
|
beside it (object-contain semantics, but expressible on a
|
||||||
|
positioned element). Paints from the HTTP cache the moment
|
||||||
|
the modal opens. -->
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Video'}
|
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||||
|
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||||
alt={photoQuery.data.OriginalName ?? pf.Name ?? 'Photo'}
|
alt={altText}
|
||||||
class="max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,171 +1,29 @@
|
|||||||
<!--
|
<!--
|
||||||
One review-queue cause group rendered as a card. Mirrors StackGroupCard's
|
One review-queue cause group, rendered as a thin wrapper around
|
||||||
chrome (focusable container, ResizeObserver column tracking, keyboard
|
PhotoGrid. The page mounts the standard timeline chrome (gridKeyNav on
|
||||||
nav) but the per-tile semantics differ:
|
main, BulkActionBar below, RightSidebar/BulkMetadataSidebar on the
|
||||||
|
right) — this component just adds the per-group header + suggestion
|
||||||
|
row above the grid, then delegates tiles to PhotoGrid so selection,
|
||||||
|
keyboard nav, and previews work the same way they do everywhere else.
|
||||||
|
|
||||||
- Click a tile → emits `select` so the parent can open the metadata
|
The Low Resolution tab opts in to PhotoTile's dimension badge so the
|
||||||
sidebar. Shift-click toggles bulk-select instead of opening.
|
user can spot under-2-MP photos without opening each tile.
|
||||||
- Header has `Approve all` + `Archive all` for the whole group.
|
|
||||||
- Suggestion line above the grid spotlights the likely-correct bulk
|
|
||||||
action with an inline button (per the plan).
|
|
||||||
- Keyboard: arrows move the focused tile; `S` approves the focused
|
|
||||||
tile, `A` archives it, `Enter` opens detail, `Esc` blurs.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import { batchArchive } from '$lib/services/photoprism';
|
||||||
approvePhoto,
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
batchArchive
|
import { type ReviewGroup } from '$lib/services/adapters/review';
|
||||||
} from '$lib/services/photoprism';
|
|
||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
|
||||||
import { view } from '$lib/stores/view.svelte';
|
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
|
||||||
import {
|
|
||||||
deriveCauses,
|
|
||||||
type ReviewGroup
|
|
||||||
} from '$lib/services/adapters/review';
|
|
||||||
import CauseBadges from './CauseBadges.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: ReviewGroup;
|
group: ReviewGroup;
|
||||||
autoFocus?: boolean;
|
|
||||||
/** Parent emits when the user picks a tile to inspect (Enter or
|
|
||||||
* plain click). Parent owns the RightSidebar mount. */
|
|
||||||
onSelect?: (photo: PpPhoto) => void;
|
|
||||||
}
|
}
|
||||||
let { group, autoFocus = false, onSelect }: Props = $props();
|
let { group }: Props = $props();
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
let sectionEl: HTMLElement | undefined = $state();
|
|
||||||
let gridEl: HTMLElement | undefined = $state();
|
|
||||||
let focusedIdx = $state(0);
|
|
||||||
let cols = $state(1);
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Match StackGroupCard's column-tracking trick so arrow Up/Down jump
|
|
||||||
// by row width.
|
|
||||||
$effect(() => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const measure = () => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const n = getComputedStyle(gridEl)
|
|
||||||
.gridTemplateColumns.split(' ')
|
|
||||||
.filter(Boolean).length;
|
|
||||||
cols = Math.max(1, n);
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(gridEl);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
void view.thumbnailSize;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const n = getComputedStyle(gridEl)
|
|
||||||
.gridTemplateColumns.split(' ')
|
|
||||||
.filter(Boolean).length;
|
|
||||||
cols = Math.max(1, n);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function moveFocus(delta: number) {
|
|
||||||
if (group.photos.length === 0) return;
|
|
||||||
focusedIdx = Math.min(
|
|
||||||
Math.max(0, focusedIdx + delta),
|
|
||||||
group.photos.length - 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dims(p: PpPhoto): string {
|
|
||||||
const f = primaryFile(p);
|
|
||||||
const w = p.Width ?? f.Width;
|
|
||||||
const h = p.Height ?? f.Height;
|
|
||||||
if (!w || !h) return '';
|
|
||||||
return `${w}×${h}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function thumb(p: PpPhoto): string {
|
|
||||||
// list endpoint puts the hash on the photo itself; primaryFile is
|
|
||||||
// the fallback for detail responses.
|
|
||||||
const h = p.Hash ?? primaryFile(p).Hash;
|
|
||||||
return h ? thumbUrl(h, 'tile_500') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approveOne(p: PpPhoto) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await approvePhoto(p.UID);
|
|
||||||
toast.success('Approved');
|
|
||||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Approve failed');
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveOne(p: PpPhoto) {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
try {
|
|
||||||
await batchArchive([p.UID]);
|
|
||||||
toast.success('Archived');
|
|
||||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approveAll() {
|
|
||||||
if (busy || group.photos.length === 0) return;
|
|
||||||
if (!confirm(`Approve all ${group.photos.length} photos in "${group.meta.title}"?`)) return;
|
|
||||||
busy = true;
|
|
||||||
const total = group.photos.length;
|
|
||||||
let done = 0;
|
|
||||||
const toastId = toast.loading(`Approving 0 / ${total}…`);
|
|
||||||
try {
|
|
||||||
// PhotoPrism has no batch-approve, so fan out one-at-a-time.
|
|
||||||
// A small concurrency cap keeps the server responsive without
|
|
||||||
// stalling for very large groups.
|
|
||||||
const QUEUE = 4;
|
|
||||||
const uids = group.photos.map((p) => p.UID);
|
|
||||||
let idx = 0;
|
|
||||||
async function worker() {
|
|
||||||
while (idx < uids.length) {
|
|
||||||
const my = idx++;
|
|
||||||
try {
|
|
||||||
await approvePhoto(uids[my]);
|
|
||||||
} catch {
|
|
||||||
// Carry on — partial success is better than abort.
|
|
||||||
}
|
|
||||||
done++;
|
|
||||||
toast.loading(`Approving ${done} / ${total}…`, { id: toastId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(Array.from({ length: Math.min(QUEUE, uids.length) }, worker));
|
|
||||||
toast.success(`Approved ${done} / ${total}`, { id: toastId });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Approve all failed', {
|
|
||||||
id: toastId
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function archiveAll() {
|
async function archiveAll() {
|
||||||
if (busy || group.photos.length === 0) return;
|
if (busy || group.photos.length === 0) return;
|
||||||
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||||
@@ -183,64 +41,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
|
||||||
if (busy) return;
|
|
||||||
const p = group.photos[focusedIdx];
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault();
|
|
||||||
moveFocus(-1);
|
|
||||||
return;
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault();
|
|
||||||
moveFocus(1);
|
|
||||||
return;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
moveFocus(-cols);
|
|
||||||
return;
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
moveFocus(cols);
|
|
||||||
return;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
if (p) onSelect?.(p);
|
|
||||||
return;
|
|
||||||
case 's':
|
|
||||||
case 'S':
|
|
||||||
e.preventDefault();
|
|
||||||
if (p) void approveOne(p);
|
|
||||||
return;
|
|
||||||
case 'a':
|
|
||||||
case 'A':
|
|
||||||
e.preventDefault();
|
|
||||||
if (p) void archiveOne(p);
|
|
||||||
return;
|
|
||||||
case 'Escape':
|
|
||||||
(e.target as HTMLElement)?.blur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runSuggestion() {
|
function runSuggestion() {
|
||||||
if (group.meta.suggestedAction === 'approve') void approveAll();
|
// Only 'archive' suggestions are reachable through this button now —
|
||||||
else if (group.meta.suggestedAction === 'archive') void archiveAll();
|
// the page's BulkActionBar handles per-photo / multi-select Keep.
|
||||||
// 'manual' suggestion has no button — the suggestion line is text-only.
|
if (group.meta.suggestedAction === 'archive') void archiveAll();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<div class="space-y-2">
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<div
|
|
||||||
bind:this={sectionEl}
|
|
||||||
tabindex="0"
|
|
||||||
role="application"
|
|
||||||
aria-label={`Cause group ${group.meta.title} with ${group.photos.length} photos`}
|
|
||||||
onkeydown={onKeydown}
|
|
||||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
|
||||||
>
|
|
||||||
<header class="flex items-center justify-between gap-3">
|
<header class="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="text-sm font-medium text-foreground">
|
<div class="text-sm font-medium text-foreground">
|
||||||
@@ -248,128 +56,30 @@
|
|||||||
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
|
<span class="ml-1 text-muted-foreground">({group.photos.length})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
||||||
disabled={busy || group.photos.length === 0}
|
|
||||||
onclick={approveAll}
|
|
||||||
title="Approve every photo in this group"
|
|
||||||
>
|
|
||||||
Approve all
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-accent disabled:opacity-50"
|
|
||||||
disabled={busy || group.photos.length === 0}
|
|
||||||
onclick={archiveAll}
|
|
||||||
title="Archive every photo in this group"
|
|
||||||
>
|
|
||||||
Archive all
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Suggestion line — sits above the grid, surfaces the likely-correct
|
<!-- Suggestion line — surfaces the likely-correct bulk action. Only
|
||||||
bulk action with an inline trigger. 'manual' causes get no
|
'archive' renders a quick-button; 'manual' is text-only and
|
||||||
inline button; the user has to use the header bulk bar instead. -->
|
'approve' is unused today. Per-photo Keep / Archive comes from the
|
||||||
|
page's BulkActionBar (review section) once the user selects. -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
|
class="flex items-center justify-between gap-3 rounded border border-dashed border-border/60 bg-muted/30 px-3 py-1.5 text-[11px] text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span>{group.meta.suggestion}</span>
|
<span>{group.meta.suggestion}</span>
|
||||||
{#if group.meta.suggestedAction !== 'manual'}
|
{#if group.meta.suggestedAction === 'archive'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
class="shrink-0 rounded border border-border bg-background px-2 py-0.5 text-[11px] font-medium text-foreground hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={runSuggestion}
|
onclick={runSuggestion}
|
||||||
>
|
>
|
||||||
{group.meta.suggestedAction === 'approve' ? 'Approve all' : 'Archive all'}
|
Archive all
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<PhotoGrid
|
||||||
bind:this={gridEl}
|
photos={group.photos}
|
||||||
class="grid gap-2"
|
dimensionBadge={group.cause === 'low_resolution'}
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
|
||||||
>
|
|
||||||
{#each group.photos as photo, i (photo.UID)}
|
|
||||||
{@const causes = deriveCauses(photo)}
|
|
||||||
{@const isFocused = i === focusedIdx}
|
|
||||||
<!-- Tile is a <div> with role=button so the inner per-tile
|
|
||||||
action buttons aren't nested inside another <button> (which
|
|
||||||
is invalid HTML and trips a11y linters). -->
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-label={`${photo.FileName ?? photo.Name ?? photo.UID} — press Enter to inspect`}
|
|
||||||
onclick={() => onSelect?.(photo)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
onSelect?.(photo);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
class:ring-2={isFocused}
|
|
||||||
class:ring-blue-500={isFocused}
|
|
||||||
class:ring-offset-2={isFocused}
|
|
||||||
class:ring-offset-background={isFocused}
|
|
||||||
class="group relative flex cursor-pointer flex-col overflow-hidden rounded-md border border-border bg-secondary text-left transition-shadow"
|
|
||||||
>
|
|
||||||
<div class="relative aspect-square w-full overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={thumb(photo)}
|
|
||||||
alt={photo.FileName ?? photo.Name ?? ''}
|
|
||||||
loading="lazy"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
{#if dims(photo)}
|
|
||||||
<span
|
|
||||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
|
||||||
>
|
|
||||||
{dims(photo)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<!-- Per-tile hover actions: stop propagation so a click
|
|
||||||
here doesn't also open the sidebar. -->
|
|
||||||
<div
|
|
||||||
class="absolute bottom-1.5 right-1.5 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void approveOne(photo);
|
|
||||||
}}
|
|
||||||
title="Approve (S)"
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-background"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
void archiveOne(photo);
|
|
||||||
}}
|
|
||||||
title="Archive (A)"
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 px-2 py-1.5">
|
|
||||||
<CauseBadges {causes} />
|
|
||||||
<div
|
|
||||||
class="truncate text-[10px] leading-tight text-muted-foreground"
|
|
||||||
title={photo.FileName ?? photo.Name ?? ''}
|
|
||||||
>
|
|
||||||
{photo.FileName ?? photo.Name ?? ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
<!--
|
|
||||||
One horizontal strip of related-photo thumbnails for the metadata
|
|
||||||
sidebar. Used three times on the /review sidebar (folder / camera /
|
|
||||||
year). Self-fetches via the PhotoPrism DSL so each strip stays
|
|
||||||
independent.
|
|
||||||
|
|
||||||
The header is clickable: it navigates back to the timeline with the
|
|
||||||
same DSL applied as a `?q=` param, so the user can drill into the
|
|
||||||
full result set if they want to. Strips with zero hits collapse
|
|
||||||
silently — no header, no whitespace.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
|
||||||
import { listPhotos } from '$lib/services/photoprism';
|
|
||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
|
||||||
import { setFocused } from '$lib/stores/selection.svelte';
|
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
/** PhotoPrism DSL fragment, e.g. `path:"2024/lyon"` or `year:2024`. */
|
|
||||||
q: string;
|
|
||||||
/** Cap on tiles rendered in the strip. Defaults to a small set
|
|
||||||
* that fits one row in a typical sidebar width. */
|
|
||||||
limit?: number;
|
|
||||||
/** UID to filter out — usually the photo whose sidebar this strip
|
|
||||||
* is on, so the user doesn't see itself in its own "related"
|
|
||||||
* list. */
|
|
||||||
excludeUid?: string;
|
|
||||||
}
|
|
||||||
let { title, q, limit = 12, excludeUid }: Props = $props();
|
|
||||||
|
|
||||||
const stripQuery = createQuery<PpPhoto[]>(() => ({
|
|
||||||
queryKey: ['related', q, limit],
|
|
||||||
queryFn: () => listPhotos({ q, count: limit + 1, order: 'newest' }),
|
|
||||||
// Strips are cheap to refetch; the data behind them changes
|
|
||||||
// rarely, but a stale-while-revalidate window keeps the sidebar
|
|
||||||
// snappy when the user clicks through similar photos.
|
|
||||||
staleTime: 60_000,
|
|
||||||
enabled: q.length > 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
const photos = $derived(
|
|
||||||
(stripQuery.data ?? []).filter((p) => p.UID !== excludeUid).slice(0, limit)
|
|
||||||
);
|
|
||||||
|
|
||||||
function openTimeline() {
|
|
||||||
// Same `?q=` param the timeline already accepts (see filters store)
|
|
||||||
// — clicking the strip header pivots the main timeline into the
|
|
||||||
// same filtered scope so the user can browse the full set.
|
|
||||||
const params = new URLSearchParams({ q });
|
|
||||||
void goto(`/?${params.toString()}`, { keepFocus: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function openOne(uid: string) {
|
|
||||||
// Focus the picked photo so the sidebar re-renders against it.
|
|
||||||
// Useful for the "decide these together" workflow without leaving
|
|
||||||
// the review page.
|
|
||||||
setFocused(uid);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if stripQuery.isPending}
|
|
||||||
<div class="text-[10px] text-muted-foreground/70">Loading {title.toLowerCase()}…</div>
|
|
||||||
{:else if stripQuery.isError}
|
|
||||||
<!-- Errors shouldn't break the sidebar; just hide the strip. -->
|
|
||||||
{null}
|
|
||||||
{:else if photos.length > 0}
|
|
||||||
<div class="space-y-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-baseline justify-between text-[10px] uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
|
||||||
onclick={openTimeline}
|
|
||||||
title={`Open the timeline filtered by ${q}`}
|
|
||||||
>
|
|
||||||
<span>{title}</span>
|
|
||||||
<span class="text-muted-foreground/70">({photos.length}+)</span>
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-1 overflow-x-auto">
|
|
||||||
{#each photos as p (p.UID)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="h-12 w-12 shrink-0 overflow-hidden rounded border border-border bg-secondary hover:border-primary"
|
|
||||||
onclick={() => openOne(p.UID)}
|
|
||||||
title={p.FileName ?? p.Name ?? p.UID}
|
|
||||||
>
|
|
||||||
{#if p.Hash}
|
|
||||||
<img
|
|
||||||
src={thumbUrl(p.Hash, 'tile_100')}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -6,17 +6,20 @@
|
|||||||
PUT (Details fields need the full body).
|
PUT (Details fields need the full body).
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
Aperture,
|
Aperture,
|
||||||
|
ArrowUpRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
ExternalLink,
|
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Map as MapIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -37,16 +40,15 @@
|
|||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
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 { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import RelatedStrip from './RelatedStrip.svelte';
|
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||||
|
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** When true, append related-photo strips (folder/camera/year) below
|
|
||||||
* Keywords. Used by the /review route; left off on the timeline. */
|
|
||||||
showRelated?: boolean;
|
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
}
|
}
|
||||||
let { photo, showRelated = false }: Props = $props();
|
let { photo }: Props = $props();
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
@@ -130,6 +132,32 @@
|
|||||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||||
}
|
}
|
||||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||||
|
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||||
|
// are the photos with definitionally-untrusted dates, and showing the
|
||||||
|
// row anywhere else would compete with the existing TakenAt.
|
||||||
|
// PhotoPrism stores a TakenAt for stripped-EXIF photos too (filename
|
||||||
|
// guess or file mtime), so a per-photo "needs date" heuristic would
|
||||||
|
// silently hide the suggestion — the tab is the more reliable signal.
|
||||||
|
const onExifStrippedTab = $derived(
|
||||||
|
page.url.pathname === '/review' &&
|
||||||
|
page.url.searchParams.get('tab') === 'stripped_exif'
|
||||||
|
);
|
||||||
|
const dateSuggestion = $derived.by(() => {
|
||||||
|
const { fileName, path } = photoNameAndDir(photo);
|
||||||
|
return suggestDateFromPath({
|
||||||
|
fileName,
|
||||||
|
originalName: photo.OriginalName,
|
||||||
|
path
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const showDateSuggestion = $derived(
|
||||||
|
onExifStrippedTab && !!dateSuggestion && dateSuggestion.iso !== takenAt
|
||||||
|
);
|
||||||
|
function applyDateSuggestion() {
|
||||||
|
if (!dateSuggestion) return;
|
||||||
|
takenAt = dateSuggestion.iso;
|
||||||
|
commitTakenAt();
|
||||||
|
}
|
||||||
function commitTakenAt() {
|
function commitTakenAt() {
|
||||||
if (!takenAt) return;
|
if (!takenAt) return;
|
||||||
if (!isValidISODate(takenAt)) {
|
if (!isValidISODate(takenAt)) {
|
||||||
@@ -225,29 +253,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
|
||||||
@@ -266,12 +285,6 @@
|
|||||||
? photo.Country.toUpperCase()
|
? photo.Country.toUpperCase()
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
const mapsHref = $derived(
|
|
||||||
photo.Lat && photo.Lng
|
|
||||||
? `https://www.openstreetmap.org/?mlat=${photo.Lat}&mlon=${photo.Lng}&zoom=15`
|
|
||||||
: ''
|
|
||||||
);
|
|
||||||
|
|
||||||
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
function formatCameraLens(c?: { Make?: string; Model?: string; Name?: string }): string {
|
||||||
if (!c) return '';
|
if (!c) return '';
|
||||||
const make = c.Make ?? '';
|
const make = c.Make ?? '';
|
||||||
@@ -333,19 +346,61 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Folder (read-only). The `px-1 py-0.5` mirrors the input
|
<!-- Date suggestion derived from filename / folder signals. Only
|
||||||
padding on filename / date so the read-only text starts at the
|
shown on the EXIF Stripped review tab; amber styling marks
|
||||||
same x-offset as the editable rows above — otherwise spans
|
it as unconfirmed. `(estimated day)` hint appears when the
|
||||||
hug the icon while inputs sit 4px in. -->
|
day was synthesised because only Y-M was available — same
|
||||||
{#if dirPath}
|
row, just so the user knows that part is fabricated. Apply
|
||||||
<div class="flex items-center gap-2">
|
writes the value into the date input above and commits as
|
||||||
<Folder class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
a manual TakenAt edit. -->
|
||||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground" title={dirPath}>
|
{#if showDateSuggestion && dateSuggestion}
|
||||||
{dirPath}/
|
<div
|
||||||
|
class="flex items-center gap-2 rounded border border-amber-300/70 bg-amber-50/40 px-1.5 py-1 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<Folder class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
|
Suggested from path: <span class="font-medium">{dateSuggestion.iso}</span>
|
||||||
|
{#if dateSuggestion.source === 'path-ym-default-day'}
|
||||||
|
<span class="text-amber-600/80 dark:text-amber-400/70">(estimated day)</span>
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded border border-amber-400/60 bg-amber-100/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30"
|
||||||
|
onclick={applyDateSuggestion}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Folder (read-only label + open-in-timeline icon). 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. Root-level
|
||||||
|
files render as `/` so the row never disappears. The arrow-up-
|
||||||
|
right icon navigates to the timeline filtered by this folder
|
||||||
|
with the photo pre-focused. -->
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={() =>
|
||||||
|
void navigateToFolder(dirPath || '/', {
|
||||||
|
focusUid: photo.UID,
|
||||||
|
focusTakenAt: photo.TakenAt ?? null
|
||||||
|
})}
|
||||||
|
title="Open folder in timeline"
|
||||||
|
aria-label="Open folder in timeline"
|
||||||
|
>
|
||||||
|
<ArrowUpRight class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dimensions -->
|
<!-- Dimensions -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ImageIcon class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -362,45 +417,41 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location -->
|
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||||
|
right icon flies the in-app map to the photo's coordinates at
|
||||||
|
zoom 17 (close enough for the photo's marker to be its own,
|
||||||
|
out of any cluster). Hidden when the photo has no
|
||||||
|
coordinates. -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<MapPin 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">
|
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||||
{placeLabel || 'No location'}
|
{placeLabel || 'No location'}
|
||||||
</span>
|
</span>
|
||||||
{#if mapsHref}
|
{#if photo.Lat && photo.Lng}
|
||||||
<a
|
<button
|
||||||
href={mapsHref}
|
type="button"
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="text-muted-foreground hover:text-foreground"
|
class="text-muted-foreground hover:text-foreground"
|
||||||
title="Open in OpenStreetMap"
|
onclick={() =>
|
||||||
|
void goto(
|
||||||
|
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||||
|
)}
|
||||||
|
title="Open on map"
|
||||||
|
aria-label="Open on map"
|
||||||
>
|
>
|
||||||
<ExternalLink class="h-3 w-3" />
|
<MapIcon class="h-3 w-3" />
|
||||||
</a>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<!-- Note (PhotoPrism's Caption field — labelled "Note" to match
|
<!-- Tags — note, score, color label, keywords, and auto-labels grouped
|
||||||
mule-image's nomenclature). -->
|
under one collapsible section. Note (PhotoPrism's Caption field,
|
||||||
<div class="space-y-1">
|
labelled here to match mule-image's nomenclature) sits at the top
|
||||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
of the group since it's the most-edited per-photo field. Score +
|
||||||
<textarea
|
color are stored on the mule-sidecar (PhotoPrism's PUT can't
|
||||||
rows="2"
|
persist them); keywords live on Details; auto-labels come from
|
||||||
placeholder="Add a note…"
|
PhotoPrism's TF classifier and are read-only. Open by default
|
||||||
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
since these are the culling marks the user reaches for first. -->
|
||||||
bind:value={caption}
|
|
||||||
onblur={commitCaption}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags — score, color label, keywords, and auto-labels grouped under
|
|
||||||
one collapsible section. Score + color are stored on the mule-
|
|
||||||
sidecar (PhotoPrism's PUT can't persist them); keywords live on
|
|
||||||
Details; auto-labels come from PhotoPrism's TF classifier and are
|
|
||||||
read-only. Open by default since these are the culling marks the
|
|
||||||
user reaches for first. -->
|
|
||||||
<details
|
<details
|
||||||
class="rounded border border-border"
|
class="rounded border border-border"
|
||||||
open={getMetadataSectionOpen('tags', true)}
|
open={getMetadataSectionOpen('tags', true)}
|
||||||
@@ -414,6 +465,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="space-y-2 p-2 pt-1">
|
<div class="space-y-2 p-2 pt-1">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||||
|
<textarea
|
||||||
|
rows="2"
|
||||||
|
placeholder="Add a note…"
|
||||||
|
class="w-full resize-y rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={caption}
|
||||||
|
onblur={commitCaption}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Score</div>
|
||||||
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
|
<div class="flex items-center gap-0.5" role="group" aria-label="Rating">
|
||||||
@@ -434,14 +496,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}`}
|
||||||
@@ -511,35 +575,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Related strips (only the /review route opts in). The three
|
|
||||||
scopes match the three decisions the user usually makes here:
|
|
||||||
"did all these come from the same shoot?" (folder), "same
|
|
||||||
camera, EXIF-stripped together?" (camera), "right year?"
|
|
||||||
(year). Strips with zero hits collapse silently. -->
|
|
||||||
{#if showRelated}
|
|
||||||
<div class="space-y-2 border-t border-border pt-2">
|
|
||||||
<RelatedStrip
|
|
||||||
title="Same folder"
|
|
||||||
q={`path:"${photo.Path ?? ''}"`}
|
|
||||||
excludeUid={photo.UID}
|
|
||||||
/>
|
|
||||||
{#if photo.CameraID && photo.CameraID !== 1}
|
|
||||||
<RelatedStrip
|
|
||||||
title="Same camera"
|
|
||||||
q={`camera:${photo.CameraID}`}
|
|
||||||
excludeUid={photo.UID}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if photo.Year}
|
|
||||||
<RelatedStrip
|
|
||||||
title="Same year"
|
|
||||||
q={`year:${photo.Year}`}
|
|
||||||
excludeUid={photo.UID}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- GPS detail. Static default (closed); user's expand/collapse
|
<!-- GPS detail. Static default (closed); user's expand/collapse
|
||||||
choice persists across photo switches via the view store.
|
choice persists across photo switches via the view store.
|
||||||
Avoid data-driven defaults here — they make the `open` attr
|
Avoid data-driven defaults here — they make the `open` attr
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
getAllMarks,
|
getAllMarks,
|
||||||
listLabels,
|
listLabels,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
|
listSubjects,
|
||||||
type AggregatedKeyword,
|
type AggregatedKeyword,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpLabel
|
type PpLabel,
|
||||||
|
type PpSubject
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
import { nearBottom } from '$lib/actions/nearBottom';
|
import { nearBottom } from '$lib/actions/nearBottom';
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
starLabel
|
starLabel
|
||||||
} from '$lib/utils/tagGroups';
|
} from '$lib/utils/tagGroups';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { Hash, Tag, User } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
category: TagCategory;
|
category: TagCategory;
|
||||||
@@ -54,6 +58,12 @@
|
|||||||
staleTime: 5 * 60_000
|
staleTime: 5 * 60_000
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const subjectsQuery = createQuery<PpSubject[]>(() => ({
|
||||||
|
queryKey: ['subjects'],
|
||||||
|
queryFn: listSubjects,
|
||||||
|
enabled: isAuthenticated() && category === 'people'
|
||||||
|
}));
|
||||||
|
|
||||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||||
queryKey: ['marks'],
|
queryKey: ['marks'],
|
||||||
queryFn: getAllMarks,
|
queryFn: getAllMarks,
|
||||||
@@ -96,6 +106,20 @@
|
|||||||
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
|
return keywordsSorted.filter((k) => k.keyword.toLowerCase().includes(q));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const subjectsSorted = $derived(
|
||||||
|
[...(subjectsQuery.data ?? [])].sort(
|
||||||
|
(a, b) => (b.PhotoCount ?? 0) - (a.PhotoCount ?? 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const filteredSubjects = $derived.by(() => {
|
||||||
|
const q = filterText.trim().toLowerCase();
|
||||||
|
if (!q) return subjectsSorted;
|
||||||
|
return subjectsSorted.filter(
|
||||||
|
(s) =>
|
||||||
|
s.Name.toLowerCase().includes(q) || s.Slug.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const ratingGroups = $derived(
|
const ratingGroups = $derived(
|
||||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||||
);
|
);
|
||||||
@@ -122,8 +146,10 @@
|
|||||||
|
|
||||||
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
|
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
|
||||||
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
|
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
|
||||||
|
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
|
||||||
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
|
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
|
||||||
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
|
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
|
||||||
|
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
visibleCount += PAGE_SIZE;
|
visibleCount += PAGE_SIZE;
|
||||||
@@ -138,6 +164,9 @@
|
|||||||
function pickKeyword(value: string) {
|
function pickKeyword(value: string) {
|
||||||
if (selectedValue !== value) onSelect(value);
|
if (selectedValue !== value) onSelect(value);
|
||||||
}
|
}
|
||||||
|
function pickPerson(value: string) {
|
||||||
|
if (selectedValue !== value) onSelect(value);
|
||||||
|
}
|
||||||
function pickColor(key: string) {
|
function pickColor(key: string) {
|
||||||
if (selectedValue !== key) onSelect(key);
|
if (selectedValue !== key) onSelect(key);
|
||||||
}
|
}
|
||||||
@@ -159,6 +188,9 @@
|
|||||||
if (category === 'keywords') {
|
if (category === 'keywords') {
|
||||||
return keywordsSorted[0]?.keyword ?? null;
|
return keywordsSorted[0]?.keyword ?? null;
|
||||||
}
|
}
|
||||||
|
if (category === 'people') {
|
||||||
|
return subjectsSorted[0]?.Slug ?? null;
|
||||||
|
}
|
||||||
if (category === 'colors') {
|
if (category === 'colors') {
|
||||||
return colorGroups[0]?.key ?? null;
|
return colorGroups[0]?.key ?? null;
|
||||||
}
|
}
|
||||||
@@ -187,12 +219,16 @@
|
|||||||
? 'Labels'
|
? 'Labels'
|
||||||
: category === 'keywords'
|
: category === 'keywords'
|
||||||
? 'Keywords'
|
? 'Keywords'
|
||||||
|
: category === 'people'
|
||||||
|
? 'People'
|
||||||
: category === 'colors'
|
: category === 'colors'
|
||||||
? 'Colors'
|
? 'Colors'
|
||||||
: 'Ratings'
|
: 'Ratings'
|
||||||
);
|
);
|
||||||
|
|
||||||
const showFilterInput = $derived(category === 'labels' || category === 'keywords');
|
const showFilterInput = $derived(
|
||||||
|
category === 'labels' || category === 'keywords' || category === 'people'
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
@@ -214,13 +250,15 @@
|
|||||||
|
|
||||||
{#if category === 'labels'}
|
{#if category === 'labels'}
|
||||||
{#if labelsQuery.isPending}
|
{#if labelsQuery.isPending}
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading labels…</p>
|
<InlineLoader size="sm" label="Loading labels…" />
|
||||||
{:else if labelsQuery.isError}
|
{:else if labelsQuery.isError}
|
||||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load labels.</p>
|
<EmptyState size="compact" tone="destructive" title="Failed to load labels" />
|
||||||
{:else if filteredLabels.length === 0}
|
{:else if filteredLabels.length === 0}
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<EmptyState
|
||||||
{filterText ? 'No labels match the filter.' : 'No labels yet.'}
|
size="compact"
|
||||||
</p>
|
icon={Tag}
|
||||||
|
title={filterText ? 'No labels match the filter' : 'No labels yet'}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||||
{#each visibleLabels as label (label.UID ?? label.Slug)}
|
{#each visibleLabels as label (label.UID ?? label.Slug)}
|
||||||
@@ -276,17 +314,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if category === 'keywords'}
|
{:else if category === 'keywords'}
|
||||||
{#if keywordsQuery.isPending}
|
{#if keywordsQuery.isPending}
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<InlineLoader
|
||||||
Loading keywords… (aggregates from photo details — first load may take a few seconds)
|
size="sm"
|
||||||
</p>
|
label="Loading keywords… (aggregates from photo details — first load may take a few seconds)"
|
||||||
|
/>
|
||||||
{:else if keywordsQuery.isError}
|
{:else if keywordsQuery.isError}
|
||||||
<p class="px-3 py-2 text-[11px] text-destructive">Failed to load keywords.</p>
|
<EmptyState size="compact" tone="destructive" title="Failed to load keywords" />
|
||||||
{:else if filteredKeywords.length === 0}
|
{:else if filteredKeywords.length === 0}
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<EmptyState
|
||||||
{filterText
|
size="compact"
|
||||||
? 'No keywords match the filter.'
|
icon={Hash}
|
||||||
: 'No user-set keywords yet. Add them from a photo’s right-sidebar metadata panel.'}
|
title={filterText ? 'No keywords match the filter' : 'No user-set keywords yet'}
|
||||||
</p>
|
description={filterText
|
||||||
|
? undefined
|
||||||
|
: 'Add them from a photo’s right-sidebar metadata panel.'}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||||
{#each visibleKeywords as kw (kw.keyword)}
|
{#each visibleKeywords as kw (kw.keyword)}
|
||||||
@@ -333,6 +375,74 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if category === 'people'}
|
||||||
|
{#if subjectsQuery.isPending}
|
||||||
|
<InlineLoader size="sm" label="Loading people…" />
|
||||||
|
{:else if subjectsQuery.isError}
|
||||||
|
<EmptyState size="compact" tone="destructive" title="Failed to load people" />
|
||||||
|
{:else if filteredSubjects.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
icon={User}
|
||||||
|
title={filterText ? 'No people match the filter' : 'No people yet'}
|
||||||
|
description={filterText
|
||||||
|
? undefined
|
||||||
|
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{#each visibleSubjects as subject (subject.UID ?? subject.Slug)}
|
||||||
|
{@const active = subject.Slug === selectedValue}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
onclick={() => pickPerson(subject.Slug)}
|
||||||
|
title={subject.Name}
|
||||||
|
>
|
||||||
|
{#if subject.Thumb}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(subject.Thumb, 'tile_50')}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
class="h-5 w-5 shrink-0 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-secondary"
|
||||||
|
>
|
||||||
|
<User class="h-3 w-3 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="min-w-0 flex-1 truncate">{subject.Name}</span>
|
||||||
|
<span
|
||||||
|
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{subject.PhotoCount ?? 0}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<div
|
||||||
|
use:nearBottom={{
|
||||||
|
onHit: loadMore,
|
||||||
|
enabled: hasMoreSubjects,
|
||||||
|
root: scrollEl ?? null,
|
||||||
|
preloadPx: 400
|
||||||
|
}}
|
||||||
|
class="h-px"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
{#if hasMoreSubjects}
|
||||||
|
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
|
||||||
|
Loading more… ({visibleCount} / {filteredSubjects.length})
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if category === 'colors'}
|
{:else if category === 'colors'}
|
||||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
|
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
|
||||||
@@ -355,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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,9 @@
|
|||||||
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 { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import {
|
import {
|
||||||
clearBulkToFirst,
|
clearBulkToFirst,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
@@ -22,6 +26,8 @@
|
|||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { Layers } from 'lucide-svelte';
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -48,11 +54,50 @@
|
|||||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||||
);
|
);
|
||||||
const isBulk = $derived(selection.ids.size > 0);
|
const isBulk = $derived(selection.ids.size > 0);
|
||||||
|
// Filename for the single-focus label. Re-derives whenever
|
||||||
|
// selection.focused flips — cachedPhoto reads from the same query
|
||||||
|
// cache that drives the visible tiles, so the name resolves on the
|
||||||
|
// same tick the tile renders.
|
||||||
|
const focusedPhoto = $derived(selection.focused ? cachedPhoto(selection.focused) : undefined);
|
||||||
|
const focusedName = $derived(
|
||||||
|
focusedPhoto ? photoNameAndDir(focusedPhoto).fileName : ''
|
||||||
|
);
|
||||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||||
// every other action is hidden so the choice can't be confused with
|
// every other action is hidden so the choice can't be confused with
|
||||||
// heap-adding / restoring. The S keybinding is rerouted to approve
|
// heap-adding / restoring. The S keybinding is rerouted to approve
|
||||||
// from gridKeyNav for the same reason.
|
// from gridKeyNav for the same reason.
|
||||||
const isReview = $derived(filters.section === 'review');
|
const isReview = $derived(filters.section === 'review');
|
||||||
|
// "Accept date & Keep" is scoped to the EXIF Stripped review tab —
|
||||||
|
// that's where path-derived dates are the most useful fix. Outside the
|
||||||
|
// tab the button stays hidden even if a selected photo would otherwise
|
||||||
|
// have a path-parseable date, to keep other tabs uncluttered.
|
||||||
|
const onExifStrippedTab = $derived(
|
||||||
|
isReview && page.url.searchParams.get('tab') === 'stripped_exif'
|
||||||
|
);
|
||||||
|
// Surface the button only when EVERY targeted photo has a derivable
|
||||||
|
// suggestion — otherwise clicking it would silently approve some
|
||||||
|
// photos without a date fix, which contradicts the verb. A uid not in
|
||||||
|
// any cache also counts as "no suggestion" so we don't promise
|
||||||
|
// something we can't verify.
|
||||||
|
const allHaveSuggestion = $derived.by(() => {
|
||||||
|
if (!onExifStrippedTab) return false;
|
||||||
|
const ids =
|
||||||
|
selection.ids.size > 0
|
||||||
|
? Array.from(selection.ids)
|
||||||
|
: selection.focused
|
||||||
|
? [selection.focused]
|
||||||
|
: [];
|
||||||
|
if (ids.length === 0) return false;
|
||||||
|
for (const id of ids) {
|
||||||
|
const p = cachedPhoto(id);
|
||||||
|
if (!p) return false;
|
||||||
|
const { fileName, path } = photoNameAndDir(p);
|
||||||
|
if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
// Archive section is the parallel two-button flow: Keep (restore back
|
// Archive section is the parallel two-button flow: Keep (restore back
|
||||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||||
// from "archive" to "delete" since the photo is already archived;
|
// from "archive" to "delete" since the photo is already archived;
|
||||||
@@ -97,6 +142,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onAcceptDateAndKeep() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await withBusy(() => acceptDateAndKeep(ids));
|
||||||
|
}
|
||||||
|
|
||||||
async function onArchive() {
|
async function onArchive() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -172,7 +223,7 @@
|
|||||||
// a no-op.
|
// a no-op.
|
||||||
if (added.length === 0) {
|
if (added.length === 0) {
|
||||||
toast.error(`Nothing added to ${heap.Title}`, {
|
toast.error(`Nothing added to ${heap.Title}`, {
|
||||||
description: `PhotoPrism rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -205,13 +256,19 @@
|
|||||||
<div
|
<div
|
||||||
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||||||
>
|
>
|
||||||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
|
||||||
{#if isBulk}
|
{#if isBulk}
|
||||||
|
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||||
{targetCount} selected
|
{targetCount} selected
|
||||||
{:else}
|
|
||||||
Focused photo
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">Focused</span>
|
||||||
|
<span
|
||||||
|
class="min-w-0 truncate text-[11px] font-medium text-foreground"
|
||||||
|
title={focusedName || undefined}
|
||||||
|
>
|
||||||
|
{focusedName || 'photo'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||||||
@@ -229,16 +286,31 @@
|
|||||||
the archive section. Everything else (heap, restore)
|
the archive section. Everything else (heap, restore)
|
||||||
is hidden so the choice reads as decisive. -->
|
is hidden so the choice reads as decisive. -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onApprove}
|
onclick={onApprove}
|
||||||
title="Keep — accept into timeline"
|
title="Keep — accept into timeline"
|
||||||
>
|
>
|
||||||
✓ Keep
|
✓ Keep
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
{#if allHaveSuggestion}
|
||||||
|
<!-- Visible only when every selected photo has a path-
|
||||||
|
derivable date. Clicking applies each photo's
|
||||||
|
suggestion then approves it; mirrored by the bare
|
||||||
|
`a` shortcut in gridKeyNav. -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-amber-400/60 bg-amber-100/40 px-2 py-0.5 text-[11px] text-amber-800 hover:bg-amber-100 disabled:opacity-50 dark:border-amber-400/40 dark:bg-amber-500/15 dark:text-amber-200 dark:hover:bg-amber-500/25"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onAcceptDateAndKeep}
|
||||||
|
title="Accept the date suggested from the file/folder path, then keep"
|
||||||
|
>
|
||||||
|
📅 Accept date & Keep
|
||||||
|
<kbd class="rounded bg-amber-200/40 px-1 text-[9px] font-medium text-amber-900 dark:bg-amber-500/30 dark:text-amber-100">A</kbd>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onArchive}
|
onclick={onArchive}
|
||||||
title="Archive"
|
title="Archive"
|
||||||
@@ -253,13 +325,13 @@
|
|||||||
photo is already archived; the destructive styling
|
photo is already archived; the destructive styling
|
||||||
reinforces the irreversibility. -->
|
reinforces the irreversibility. -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onRestore}
|
onclick={onRestore}
|
||||||
title="Keep — restore to timeline"
|
title="Keep — restore to timeline"
|
||||||
>
|
>
|
||||||
✓ Keep
|
✓ Keep
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90">S</kbd>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||||
@@ -273,13 +345,13 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded bg-primary px-2 py-0.5 text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||||
title="Add to heap (S then 1–9 picks a heap)"
|
title="Add to heap (S then 1–9 picks a heap)"
|
||||||
>
|
>
|
||||||
+ Add to heap
|
+ Add to heap
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-primary-foreground/15 px-1 text-[9px] font-medium text-primary-foreground/90"
|
||||||
>S N</kbd
|
>S N</kbd
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@@ -288,9 +360,9 @@
|
|||||||
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
|
class="absolute bottom-full right-0 mb-2 max-h-72 w-56 overflow-y-auto rounded-md border border-border bg-background p-1 text-xs shadow-lg"
|
||||||
>
|
>
|
||||||
{#if heapsQuery.isPending}
|
{#if heapsQuery.isPending}
|
||||||
<p class="px-2 py-1 text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading heaps…" />
|
||||||
{:else if (heapsQuery.data ?? []).length === 0}
|
{:else if (heapsQuery.data ?? []).length === 0}
|
||||||
<p class="px-2 py-1 text-muted-foreground">No heaps yet</p>
|
<EmptyState size="compact" icon={Layers} title="No heaps yet" />
|
||||||
{:else}
|
{:else}
|
||||||
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
|
{#each heapsQuery.data ?? [] as heap, i (heap.UID)}
|
||||||
<button
|
<button
|
||||||
@@ -318,7 +390,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onArchive}
|
onclick={onArchive}
|
||||||
title="Archive"
|
title="Archive"
|
||||||
@@ -328,7 +400,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
onclick={clearAll}
|
onclick={clearAll}
|
||||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||||
>
|
>
|
||||||
|
|||||||
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal file
81
web/src/lib/components/timeline/NotesPhotoGrid.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
Notes-view flat grid — same skeleton as PhotoGrid, but each cell pairs
|
||||||
|
a photo with its note hint via NotesPhotoTile. Kept as a sibling
|
||||||
|
component (rather than threading a `note` slot through PhotoGrid) so
|
||||||
|
the Notes-view chrome stays out of the shared timeline/tags codepath.
|
||||||
|
|
||||||
|
The grid carries `data-photo-grid` and each tile (rendered inside
|
||||||
|
PhotoTile) carries `data-tile`+`data-uid` — same contract the
|
||||||
|
gridKeyNav action and shared selection helpers expect, so arrow-key
|
||||||
|
nav, range select, and bulk action bar work for free.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import {
|
||||||
|
isSelected,
|
||||||
|
selection,
|
||||||
|
setAnchor,
|
||||||
|
setFocused,
|
||||||
|
setOrder
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
|
import { openPreview, view } from '$lib/stores/view.svelte';
|
||||||
|
import type { PhotoWithNote } from '$lib/services/photoprism';
|
||||||
|
import NotesPhotoTile from './NotesPhotoTile.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: PhotoWithNote[];
|
||||||
|
columns?: string;
|
||||||
|
}
|
||||||
|
let { items, columns }: Props = $props();
|
||||||
|
const tracks = $derived(
|
||||||
|
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = $derived(items.map((it) => it.photo.UID));
|
||||||
|
$effect(() => {
|
||||||
|
setOrder(order);
|
||||||
|
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) {
|
||||||
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
|
selection.ids.clear();
|
||||||
|
selection.ids.add(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblclick(e: MouseEvent, uid: string) {
|
||||||
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
selection.ids.clear();
|
||||||
|
selection.ids.add(uid);
|
||||||
|
setFocused(uid);
|
||||||
|
setAnchor(uid);
|
||||||
|
openPreview();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
|
||||||
|
{#each items as item (item.photo.UID)}
|
||||||
|
{@const sel = isSelected(item.photo.UID) || selection.focused === item.photo.UID}
|
||||||
|
<NotesPhotoTile
|
||||||
|
photo={item.photo}
|
||||||
|
note={item.note}
|
||||||
|
selected={sel}
|
||||||
|
onClick={(e) => onClick(e, item.photo.UID)}
|
||||||
|
onDblclick={(e) => onDblclick(e, item.photo.UID)}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal file
35
web/src/lib/components/timeline/NotesPhotoTile.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!--
|
||||||
|
PhotoTile + footer card showing a hint of the photo's note. Only the
|
||||||
|
Notes view (/notes) uses this — every other surface keeps the bare
|
||||||
|
PhotoTile, so the note-card chrome doesn't leak into the timeline or
|
||||||
|
tag drill-ins.
|
||||||
|
|
||||||
|
Composition: square PhotoTile on top, presentational note strip
|
||||||
|
underneath. Clicks/dblclicks land on PhotoTile's own <button data-tile>
|
||||||
|
so selection/keyboard/preview behave exactly like every other tile.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import PhotoTile from './PhotoTile.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photo: PpPhoto;
|
||||||
|
selected: boolean;
|
||||||
|
note: string;
|
||||||
|
onClick: (e: MouseEvent) => void;
|
||||||
|
onDblclick: (e: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
let { photo, selected, note, onClick, onDblclick }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-full w-full flex-col">
|
||||||
|
<div class="aspect-square">
|
||||||
|
<PhotoTile {photo} {selected} {onClick} {onDblclick} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="line-clamp-2 rounded-b-md border border-t-0 border-border bg-card px-2 py-1.5 text-[11px] leading-snug text-muted-foreground"
|
||||||
|
title={note}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -31,8 +31,12 @@
|
|||||||
* `view.thumbnailSize` so drill-in grids honour the same XS–XL
|
* `view.thumbnailSize` so drill-in grids honour the same XS–XL
|
||||||
* preset the timeline uses. */
|
* preset the timeline uses. */
|
||||||
columns?: string;
|
columns?: string;
|
||||||
|
/** Forwarded to every PhotoTile. The Low Resolution review tab
|
||||||
|
* opts in so users can spot pixel dimensions without opening
|
||||||
|
* each tile. */
|
||||||
|
dimensionBadge?: boolean;
|
||||||
}
|
}
|
||||||
let { photos, columns }: Props = $props();
|
let { photos, columns, dimensionBadge = false }: Props = $props();
|
||||||
const tracks = $derived(
|
const tracks = $derived(
|
||||||
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
||||||
);
|
);
|
||||||
@@ -91,6 +95,7 @@
|
|||||||
<PhotoTile
|
<PhotoTile
|
||||||
{photo}
|
{photo}
|
||||||
selected={sel}
|
selected={sel}
|
||||||
|
{dimensionBadge}
|
||||||
onClick={(e) => onClick(e, photo.UID)}
|
onClick={(e) => onClick(e, photo.UID)}
|
||||||
onDblclick={(e) => onDblclick(e, photo.UID)}
|
onDblclick={(e) => onDblclick(e, photo.UID)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,11 +22,23 @@
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: (e: MouseEvent) => void;
|
onClick: (e: MouseEvent) => void;
|
||||||
onDblclick: (e: MouseEvent) => void;
|
onDblclick: (e: MouseEvent) => void;
|
||||||
|
/** Opt-in `WxH` overlay in the top-right corner. Used by the Low
|
||||||
|
* Resolution review tab so the user can spot-check pixel dimensions
|
||||||
|
* without opening each tile. Default off so other surfaces stay
|
||||||
|
* uncluttered. */
|
||||||
|
dimensionBadge?: boolean;
|
||||||
}
|
}
|
||||||
let { photo, selected, onClick, onDblclick }: Props = $props();
|
let { photo, selected, onClick, onDblclick, dimensionBadge = false }: Props = $props();
|
||||||
|
|
||||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||||
const video = $derived(isVideo(photo));
|
const video = $derived(isVideo(photo));
|
||||||
|
const dims = $derived.by(() => {
|
||||||
|
if (!dimensionBadge) return '';
|
||||||
|
const f = primaryFile(photo);
|
||||||
|
const w = photo.Width ?? f.Width;
|
||||||
|
const h = photo.Height ?? f.Height;
|
||||||
|
return w && h ? `${w}×${h}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
// Hover preview: PhotoPrism plays a muted, looping preview of the actual
|
||||||
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
// video when you hover the tile in the grid. We wait HOVER_DELAY ms
|
||||||
@@ -144,5 +156,11 @@
|
|||||||
>VIDEO</span
|
>VIDEO</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if dims}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
||||||
|
>{dims}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="grid gap-2"
|
class="mt-2 grid gap-2"
|
||||||
style="grid-template-columns: {tracks};"
|
style="grid-template-columns: {tracks};"
|
||||||
>
|
>
|
||||||
{#each Array.from({ length: count }) as _, i (i)}
|
{#each Array.from({ length: count }) as _, i (i)}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
|
|||||||
title: 'Implausible year',
|
title: 'Implausible year',
|
||||||
chip: 'bad year',
|
chip: 'bad year',
|
||||||
suggestion:
|
suggestion:
|
||||||
"Filenames suggest a date PhotoPrism doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
|
"Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
|
||||||
suggestedAction: 'manual'
|
suggestedAction: 'manual'
|
||||||
},
|
},
|
||||||
non_image_type: {
|
non_image_type: {
|
||||||
@@ -66,7 +66,7 @@ export const CAUSES: Record<CauseKey, CauseMeta> = {
|
|||||||
title: 'Other quality issues',
|
title: 'Other quality issues',
|
||||||
chip: 'low quality',
|
chip: 'low quality',
|
||||||
suggestion:
|
suggestion:
|
||||||
'PhotoPrism flagged these but the metadata looks fine. Open the first one to investigate.',
|
'The indexer flagged these but the metadata looks fine. Open the first one to investigate.',
|
||||||
suggestedAction: 'manual'
|
suggestedAction: 'manual'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,49 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { batchEdit } from './batch';
|
import { batchEdit } from './batch';
|
||||||
import { invalidatePhotos } from './bulk';
|
import { invalidatePhotos } from './bulk';
|
||||||
import { approvePhoto, batchArchive, batchRestore } from './photoprism';
|
import {
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive,
|
||||||
|
batchRestore,
|
||||||
|
buildTakenAtPatch,
|
||||||
|
updatePhoto
|
||||||
|
} from './photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
|
import { clearSelection, focusAfter } from '$lib/stores/selection.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
|
import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
/** Walk every cache that might hold a photo's metadata — timeline list
|
||||||
|
* (flat or infinite), review-groups bucket, per-photo detail — without
|
||||||
|
* forcing a refetch. Returns undefined when the uid hasn't been seen.
|
||||||
|
* Shared by callers that need to look up photo state by uid from
|
||||||
|
* outside a component (gridKeyNav, photoActions). */
|
||||||
|
export function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||||
|
const lists = queryClient.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 hit;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||||
|
if (!Array.isArray(pages)) continue;
|
||||||
|
for (const pg of pages) {
|
||||||
|
const hit = pg?.find?.((p) => p.UID === uid);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']);
|
||||||
|
if (review) {
|
||||||
|
for (const group of review) {
|
||||||
|
const hit = group.photos?.find((p) => p.UID === uid);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dismiss photos out of the review queue by bumping their quality
|
* Dismiss photos out of the review queue by bumping their quality
|
||||||
@@ -44,6 +83,43 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
|
|||||||
toast.success(`Dismissed ${uids.length}`);
|
toast.success(`Dismissed ${uids.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the selected uids, applying each photo's path-derived date
|
||||||
|
* suggestion (when one exists) before approving it. UIDs without a
|
||||||
|
* suggestion fall through to a plain approve. Used by the EXIF Stripped
|
||||||
|
* review tab — the `📅 Accept date & Keep` button and the bare `a`
|
||||||
|
* keyboard shortcut both route here so wording / focus / toast
|
||||||
|
* behaviour stay in lockstep.
|
||||||
|
*/
|
||||||
|
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||||
|
if (uids.length === 0) return;
|
||||||
|
const { updated, errors } = await batchEdit(uids, async (id) => {
|
||||||
|
const p = cachedPhoto(id);
|
||||||
|
if (p) {
|
||||||
|
const { fileName, path } = photoNameAndDir(p);
|
||||||
|
const guess = suggestDateFromPath({
|
||||||
|
fileName,
|
||||||
|
originalName: p.OriginalName,
|
||||||
|
path
|
||||||
|
});
|
||||||
|
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
|
||||||
|
}
|
||||||
|
await approvePhoto(id);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
focusAfter(uids);
|
||||||
|
clearSelection();
|
||||||
|
invalidatePhotos(uids);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||||
|
description: errors[0].message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Kept ${uids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { primaryFile } from '$lib/types/photoprism';
|
|||||||
import type {
|
import type {
|
||||||
PpClientConfig,
|
PpClientConfig,
|
||||||
PpPhoto,
|
PpPhoto,
|
||||||
|
PpRole,
|
||||||
PpSessionResponse,
|
PpSessionResponse,
|
||||||
PpUser
|
PpUser
|
||||||
} from '$lib/types/photoprism';
|
} from '$lib/types/photoprism';
|
||||||
@@ -155,6 +156,95 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
|
||||||
|
* older photos preceded by `after` newer ones, merged newest-first.
|
||||||
|
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
|
||||||
|
* neighbours can be loaded without paging through the whole filter.
|
||||||
|
*
|
||||||
|
* Used by the timeline's deep-link focus mode: an in-app navigation
|
||||||
|
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
|
||||||
|
* date for page 0, and the target photo lands ~`afterCount` tiles
|
||||||
|
* down with `~beforeCount` older neighbours below it.
|
||||||
|
*
|
||||||
|
* Subsequent infinite-scroll pages use plain `listPhotos` with the
|
||||||
|
* standard offset cursor — the anchor mode only matters for page 0.
|
||||||
|
*/
|
||||||
|
export interface AroundParams {
|
||||||
|
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
|
||||||
|
q?: string;
|
||||||
|
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
|
||||||
|
takenAt: string;
|
||||||
|
/** How many photos newer than the anchor to fetch. */
|
||||||
|
afterCount?: number;
|
||||||
|
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
|
||||||
|
beforeCount?: number;
|
||||||
|
merged?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
|
||||||
|
const afterCount = p.afterCount ?? 30;
|
||||||
|
const beforeCount = p.beforeCount ?? 90;
|
||||||
|
const baseQ = p.q?.trim() ?? '';
|
||||||
|
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
|
||||||
|
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
|
||||||
|
// the `before:` half (so the target tile is in the merged result).
|
||||||
|
const anchorDate = new Date(p.takenAt);
|
||||||
|
if (Number.isNaN(anchorDate.getTime())) {
|
||||||
|
// Date parse failed — fall back to a plain newest-first page.
|
||||||
|
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
|
||||||
|
}
|
||||||
|
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
|
||||||
|
// up/down by a day so the anchor's own day is included in the
|
||||||
|
// `before:` half — the bounds are inclusive day boundaries, so a
|
||||||
|
// timestamp-precision anchor lands inside the `[beforeBound,
|
||||||
|
// afterBound]` window.
|
||||||
|
function ymd(d: Date): string {
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${dd}`;
|
||||||
|
}
|
||||||
|
const dayMs = 86_400_000;
|
||||||
|
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
|
||||||
|
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
|
||||||
|
const newerQ = `${baseQ} after:${afterBound}`.trim();
|
||||||
|
const olderQ = `${baseQ} before:${beforeBound}`.trim();
|
||||||
|
|
||||||
|
const [newerOldestFirst, older] = await Promise.all([
|
||||||
|
listPhotos({
|
||||||
|
q: newerQ,
|
||||||
|
count: afterCount,
|
||||||
|
order: 'oldest',
|
||||||
|
merged: p.merged ?? true
|
||||||
|
}),
|
||||||
|
listPhotos({
|
||||||
|
q: olderQ,
|
||||||
|
count: beforeCount,
|
||||||
|
order: 'newest',
|
||||||
|
merged: p.merged ?? true
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
|
||||||
|
// to match the standard timeline order, then concat the older window.
|
||||||
|
// Dedupe by UID in case the anchor itself shows up in both halves.
|
||||||
|
const merged: PpPhoto[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of newerOldestFirst.slice().reverse()) {
|
||||||
|
if (!seen.has(p.UID)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.UID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of older) {
|
||||||
|
if (!seen.has(p.UID)) {
|
||||||
|
merged.push(p);
|
||||||
|
seen.add(p.UID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count photos matching a DSL query, scoped to whatever the caller's
|
* Count photos matching a DSL query, scoped to whatever the caller's
|
||||||
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
||||||
@@ -168,10 +258,17 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
|||||||
* the signed-in user actually sees, not the global library aggregate
|
* the signed-in user actually sees, not the global library aggregate
|
||||||
* exposed by `/config.count`.
|
* exposed by `/config.count`.
|
||||||
*/
|
*/
|
||||||
export async function countPhotos(q: string): Promise<number> {
|
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
||||||
const resp = await http.get('/photos', {
|
const merged = opts.merged ?? false;
|
||||||
params: { count: 10000, offset: 0, merged: false, q }
|
const resp = await http.get<PpPhoto[]>('/photos', {
|
||||||
|
params: { count: 10000, offset: 0, merged, q }
|
||||||
});
|
});
|
||||||
|
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
||||||
|
// `Files[]` entry), regardless of `merged`. With `merged: true` the
|
||||||
|
// response body is one entry per logical photo — so the body length
|
||||||
|
// is the canonical photo count when callers need to match what the
|
||||||
|
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
|
||||||
|
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
|
||||||
const header = resp.headers['x-count'];
|
const header = resp.headers['x-count'];
|
||||||
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
@@ -477,6 +574,33 @@ export interface AggregatedKeyword {
|
|||||||
sampleHash: string;
|
sampleHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photos carrying a non-empty user note. mule-image's "Note" is
|
||||||
|
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
|
||||||
|
* is a top-level scalar — present on the list response, so a single
|
||||||
|
* round-trip is enough.
|
||||||
|
*/
|
||||||
|
export interface PhotoWithNote {
|
||||||
|
photo: PpPhoto;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
||||||
|
const list = await listPhotos({ count: 1000, order: 'newest', merged: true });
|
||||||
|
const out: PhotoWithNote[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const p of list) {
|
||||||
|
// `merged: true` can repeat a photo across file-rows; dedupe by UID
|
||||||
|
// so the same tile doesn't render twice.
|
||||||
|
if (seen.has(p.UID)) continue;
|
||||||
|
seen.add(p.UID);
|
||||||
|
const note = p.Caption?.trim();
|
||||||
|
if (!note) continue;
|
||||||
|
out.push({ photo: p, note });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||||
const list = await listPhotos({ count: 1000, merged: true });
|
const list = await listPhotos({ count: 1000, merged: true });
|
||||||
const buckets = new Map<string, AggregatedKeyword>();
|
const buckets = new Map<string, AggregatedKeyword>();
|
||||||
@@ -525,6 +649,39 @@ export async function listLabels(): Promise<PpLabel[]> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Subjects (people / face recognition) ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// PhotoPrism's face indexer clusters detected faces into Subjects, each with a
|
||||||
|
// stable UID, a human-editable Name, and a slug. The DSL operator `person:<slug>`
|
||||||
|
// filters photos to those carrying a marker assigned to that subject.
|
||||||
|
|
||||||
|
export interface PpSubject {
|
||||||
|
UID: string;
|
||||||
|
Slug: string;
|
||||||
|
Name: string;
|
||||||
|
Favorite?: boolean;
|
||||||
|
Private?: boolean;
|
||||||
|
Excluded?: boolean;
|
||||||
|
PhotoCount?: number;
|
||||||
|
Thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSubjects(): Promise<PpSubject[]> {
|
||||||
|
const { data } = await http.get<PpSubject[]>('/subjects', {
|
||||||
|
params: { count: 1000, order: 'count' }
|
||||||
|
});
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||||
|
const { data } = await http.put<PpSubject>(`/subjects/${uid}`, patch);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSubject(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/subjects/${uid}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Albums = Heaps ───────────────────────────────────────────────────────────
|
// ── Albums = Heaps ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PpAlbum {
|
export interface PpAlbum {
|
||||||
@@ -831,7 +988,15 @@ export interface PpSettings {
|
|||||||
showCaptions?: boolean;
|
showCaptions?: boolean;
|
||||||
};
|
};
|
||||||
maps?: { animate?: number; style?: string };
|
maps?: { animate?: number; style?: string };
|
||||||
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
|
index?: {
|
||||||
|
path?: string;
|
||||||
|
convert?: boolean;
|
||||||
|
rescan?: boolean;
|
||||||
|
skipArchived?: boolean;
|
||||||
|
skipMeta?: boolean;
|
||||||
|
skipRaw?: boolean;
|
||||||
|
skipHidden?: boolean;
|
||||||
|
};
|
||||||
import?: { path?: string; move?: boolean; dest?: string };
|
import?: { path?: string; move?: boolean; dest?: string };
|
||||||
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
||||||
download?: {
|
download?: {
|
||||||
@@ -840,6 +1005,41 @@ export interface PpSettings {
|
|||||||
originals?: boolean;
|
originals?: boolean;
|
||||||
mediaRaw?: boolean;
|
mediaRaw?: boolean;
|
||||||
mediaSidecar?: boolean;
|
mediaSidecar?: boolean;
|
||||||
|
crc32?: boolean;
|
||||||
|
sha1?: boolean;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
|
||||||
|
* matching API endpoints) inside PP's own SPA — disabling `share` for
|
||||||
|
* example hides every share button. Optional because older PP versions
|
||||||
|
* don't return the block; the Library tab only renders toggles for
|
||||||
|
* keys it actually sees in the response.
|
||||||
|
*/
|
||||||
|
features?: {
|
||||||
|
archive?: boolean;
|
||||||
|
private?: boolean;
|
||||||
|
review?: boolean;
|
||||||
|
files?: boolean;
|
||||||
|
folders?: boolean;
|
||||||
|
moments?: boolean;
|
||||||
|
calendar?: boolean;
|
||||||
|
places?: boolean;
|
||||||
|
edit?: boolean;
|
||||||
|
share?: boolean;
|
||||||
|
library?: boolean;
|
||||||
|
import?: boolean;
|
||||||
|
logs?: boolean;
|
||||||
|
search?: boolean;
|
||||||
|
account?: boolean;
|
||||||
|
settings?: boolean;
|
||||||
|
services?: boolean;
|
||||||
|
people?: boolean;
|
||||||
|
labels?: boolean;
|
||||||
|
download?: boolean;
|
||||||
|
upload?: boolean;
|
||||||
|
delete?: boolean;
|
||||||
|
ratings?: boolean;
|
||||||
|
[k: string]: boolean | undefined;
|
||||||
};
|
};
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -907,6 +1107,55 @@ export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEnt
|
|||||||
return data ?? [];
|
return data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Users ────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// PhotoPrism's admin user endpoints. List/create/update/delete require an
|
||||||
|
// admin session; the password endpoint accepts the user's own UID with their
|
||||||
|
// current password as `old`.
|
||||||
|
|
||||||
|
export interface CreateUserBody {
|
||||||
|
Name: string;
|
||||||
|
DisplayName?: string;
|
||||||
|
Email?: string;
|
||||||
|
Role: PpRole;
|
||||||
|
BasePath?: string;
|
||||||
|
UploadPath?: string;
|
||||||
|
WebDAV?: boolean;
|
||||||
|
Password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateUserBody = Partial<CreateUserBody>;
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<PpUser[]> {
|
||||||
|
const { data } = await http.get<PpUser[] | { users?: PpUser[] }>('/users', {
|
||||||
|
params: { count: 1000, order: 'name' }
|
||||||
|
});
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
return data.users ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(body: CreateUserBody): Promise<PpUser> {
|
||||||
|
const { data } = await http.post<PpUser>('/users', body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(uid: string, patch: UpdateUserBody): Promise<PpUser> {
|
||||||
|
const { data } = await http.put<PpUser>(`/users/${uid}`, patch);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/users/${uid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserPassword(
|
||||||
|
uid: string,
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<void> {
|
||||||
|
await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Re-exports ───────────────────────────────────────────────────────────────
|
// ── Re-exports ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ export type Section =
|
|||||||
| 'hidden'
|
| 'hidden'
|
||||||
| 'heap';
|
| 'heap';
|
||||||
|
|
||||||
export type TagCategory = 'labels' | 'keywords' | 'colors' | 'ratings';
|
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
|
||||||
|
|
||||||
export const TAG_CATEGORIES: readonly TagCategory[] = [
|
export const TAG_CATEGORIES: readonly TagCategory[] = [
|
||||||
'labels',
|
'labels',
|
||||||
'keywords',
|
'keywords',
|
||||||
|
'people',
|
||||||
'colors',
|
'colors',
|
||||||
'ratings'
|
'ratings'
|
||||||
] as const;
|
] as const;
|
||||||
@@ -85,6 +86,59 @@ export function setTagFilter(
|
|||||||
filters.tagValue = value;
|
filters.tagValue = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot focus hand-off between an in-app navigation source (e.g. the
|
||||||
|
* RightSidebar's Folder open icon) and the timeline. We deliberately
|
||||||
|
* avoid encoding this in the URL — the store→URL effect on the
|
||||||
|
* timeline strips any param that `filtersToUrlParams` doesn't emit, so
|
||||||
|
* a `?focus=` param wouldn't survive the round-trip. A module-level
|
||||||
|
* stash that's consumed once on the next pageCount=1 landing is the
|
||||||
|
* simplest contract: not shareable, not replayed on refresh, but
|
||||||
|
* matches the "deep-link click" UX we want.
|
||||||
|
*
|
||||||
|
* `takenAt` (when known) lets the timeline anchor its first-page
|
||||||
|
* query around the target's date via PhotoPrism's `before:`/`after:`
|
||||||
|
* DSL — so deep-link focus works even for photos that aren't in the
|
||||||
|
* newest-120 page of the destination filter. Caller passes `null` if
|
||||||
|
* the date isn't readily available; the timeline can still attempt a
|
||||||
|
* page-1 match.
|
||||||
|
*/
|
||||||
|
export interface PendingFocus {
|
||||||
|
uid: string;
|
||||||
|
takenAt: string | null;
|
||||||
|
}
|
||||||
|
let pendingFocus: PendingFocus | null = null;
|
||||||
|
export function setPendingFocus(uid: string, takenAt: string | null = null): void {
|
||||||
|
pendingFocus = { uid, takenAt };
|
||||||
|
}
|
||||||
|
export function consumePendingFocus(): PendingFocus | null {
|
||||||
|
const v = pendingFocus;
|
||||||
|
pendingFocus = null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drill into a folder on the timeline. Mirrors the LeftSidebar tree's
|
||||||
|
* click handler: clear heap/section context so the folder filter
|
||||||
|
* applies on top of "all photos", then navigate. When `focusUid` is
|
||||||
|
* provided, the timeline's focus effect consumes the pending-focus
|
||||||
|
* stash on its first-page landing and pre-selects + scrolls to that
|
||||||
|
* photo instead of snapping to `photos[0]`. `focusTakenAt` enables
|
||||||
|
* the anchor-mode query so the photo can be found even when it would
|
||||||
|
* otherwise be past page 1.
|
||||||
|
*/
|
||||||
|
export async function navigateToFolder(
|
||||||
|
folderPath: string,
|
||||||
|
opts: { focusUid?: string; focusTakenAt?: string | null } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
setSection('all-photos');
|
||||||
|
setFolderPath(folderPath);
|
||||||
|
if (opts.focusUid) setPendingFocus(opts.focusUid, opts.focusTakenAt ?? null);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('folder', folderPath);
|
||||||
|
await goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to a tag-category browse URL. Path-segment shape
|
* Navigate to a tag-category browse URL. Path-segment shape
|
||||||
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
|
* (`/tags/labels/sunset`) keeps the URL readable and lets SvelteKit's
|
||||||
@@ -176,12 +230,15 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
}
|
}
|
||||||
// Tag drill-down clauses for server-resolvable tag categories.
|
// Tag drill-down clauses for server-resolvable tag categories.
|
||||||
// Colors/ratings live in the mule-sidecar marks store and are
|
// Colors/ratings live in the mule-sidecar marks store and are
|
||||||
// applied client-side after the photo pool is fetched.
|
// applied client-side after the photo pool is fetched. People uses
|
||||||
|
// PhotoPrism's `person:` operator, which accepts the subject's slug.
|
||||||
if (f.tagCategory && f.tagValue) {
|
if (f.tagCategory && f.tagValue) {
|
||||||
if (f.tagCategory === 'labels') {
|
if (f.tagCategory === 'labels') {
|
||||||
parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
|
parts.push(`label:${quoteIfNeeded(f.tagValue)}`);
|
||||||
} else if (f.tagCategory === 'keywords') {
|
} else if (f.tagCategory === 'keywords') {
|
||||||
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
|
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
|
||||||
|
} else if (f.tagCategory === 'people') {
|
||||||
|
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
|
|||||||
@@ -213,6 +213,23 @@ export interface PpPhotoLabel {
|
|||||||
Label?: { Slug: string; Name: string };
|
Label?: { Slug: string; Name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a photo into its basename + directory portion using the
|
||||||
|
* primary file's relative `Name` (`'2024/02/IMG.jpg'` → `{ fileName:
|
||||||
|
* 'IMG.jpg', path: '2024/02' }`). Falls back to `photo.Path` when the
|
||||||
|
* primary file's `Name` lacks a directory prefix — that pairs with the
|
||||||
|
* list-endpoint shape where `Path` is its own field. Shared so date-
|
||||||
|
* suggestion code paths in RightSidebar / photoActions / gridKeyNav
|
||||||
|
* derive inputs the same way regardless of which cache shape they
|
||||||
|
* have on hand (list vs detail).
|
||||||
|
*/
|
||||||
|
export function photoNameAndDir(p: PpPhoto): { fileName: string; path: string } {
|
||||||
|
const full = primaryFile(p).Name ?? '';
|
||||||
|
const i = full.lastIndexOf('/');
|
||||||
|
if (i < 0) return { fileName: full, path: p.Path ?? '' };
|
||||||
|
return { fileName: full.slice(i + 1), path: full.slice(0, i) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the photo's primary file (the one with `Primary: true`) or the
|
* Return the photo's primary file (the one with `Primary: true`) or the
|
||||||
* first file if no primary marker is set. Falls back to a synthetic entry
|
* first file if no primary marker is set. Falls back to a synthetic entry
|
||||||
|
|||||||
221
web/src/lib/utils/suggestDateFromPath.ts
Normal file
221
web/src/lib/utils/suggestDateFromPath.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Best-effort calendar date guessed from a photo's filename / parent
|
||||||
|
* folders, with a confidence label so the UI can warn the user when
|
||||||
|
* the day is fabricated.
|
||||||
|
*
|
||||||
|
* Conventions we support (no false-positive risk):
|
||||||
|
* - Samsung / Android stock 20240226_135421.jpg
|
||||||
|
* - Google Pixel PXL_20240226_135421.123.jpg
|
||||||
|
* - WhatsApp IMG-20240226-WA0001.jpg
|
||||||
|
* - Telegram photo_2024-02-26_13-54-21.jpg
|
||||||
|
* - macOS screenshot Screen Shot 2024-02-26 at 1.54.21 PM.png
|
||||||
|
* - Android screenshot Screenshot_20240226-135421.png
|
||||||
|
* - Manual dot-format 2024.02.26 - title.jpg
|
||||||
|
* - WeChat mmexport1645900000000.jpeg (13-digit ms)
|
||||||
|
* - Facebook saves FB_IMG_1583926812.jpg (10-digit s)
|
||||||
|
* - Path forms 2024/02/26, 2024-02-26, 2024_02_26,
|
||||||
|
* 2024.02.26, plus Y-M-only 2024/02/
|
||||||
|
*
|
||||||
|
* Conventions we deliberately do NOT parse (locale-ambiguous or no
|
||||||
|
* recoverable signal):
|
||||||
|
* - DD-MM-YYYY / MM-DD-YYYY 26-02-2024.jpg, 02-26-2024.jpg
|
||||||
|
* - 2-digit years 24-02-26.jpg
|
||||||
|
* - Bare sequence numbers IMG_1234.HEIC, DSC_0123.NEF,
|
||||||
|
* GOPR0123.JPG, DJI_0123.JPG
|
||||||
|
*
|
||||||
|
* Used by the EXIF Stripped review tab to surface a date suggestion
|
||||||
|
* row in the metadata sidebar and to power the "Accept date & Keep"
|
||||||
|
* bulk action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isValidISODate } from '$lib/services/photoprism';
|
||||||
|
|
||||||
|
interface Input {
|
||||||
|
fileName?: string; // basename, e.g. '20240226_000000_A6D42DF3.jpg'
|
||||||
|
originalName?: string; // optional second filename signal (PpPhoto.OriginalName)
|
||||||
|
path?: string; // directory portion, e.g. '2024/02'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateGuess {
|
||||||
|
iso: string;
|
||||||
|
confidence: 'high' | 'medium';
|
||||||
|
source:
|
||||||
|
| 'filename-agrees-path'
|
||||||
|
| 'filename-only'
|
||||||
|
| 'unix-timestamp'
|
||||||
|
| 'path-ymd'
|
||||||
|
| 'path-ym-default-day';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YMD {
|
||||||
|
y: number;
|
||||||
|
m: number;
|
||||||
|
d: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YM {
|
||||||
|
y: number;
|
||||||
|
m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return n < 10 ? `0${n}` : String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoOf(ymd: YMD): string {
|
||||||
|
return `${ymd.y}-${pad2(ymd.m)}-${pad2(ymd.d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryYMD(y: number, m: number, d: number): YMD | null {
|
||||||
|
if (y < 1900 || y > 2100) return null;
|
||||||
|
if (m < 1 || m > 12) return null;
|
||||||
|
if (d < 1 || d > 31) return null;
|
||||||
|
if (!isValidISODate(`${y}-${pad2(m)}-${pad2(d)}`)) return null;
|
||||||
|
return { y, m, d };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryYM(y: number, m: number): YM | null {
|
||||||
|
if (y < 1900 || y > 2100) return null;
|
||||||
|
if (m < 1 || m > 12) return null;
|
||||||
|
return { y, m };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `YYYY[sep]MM[sep]DD` for basenames. sep ∈ {nothing, -, _, ., space}. The
|
||||||
|
// non-digit lookbehind/ahead keeps a leading prefix like `PXL_` and a
|
||||||
|
// trailing time like `_135421` from polluting the match.
|
||||||
|
const BASENAME_YMD = /(?<!\d)(\d{4})[-_. ]?(\d{2})[-_. ]?(\d{2})(?!\d)/;
|
||||||
|
|
||||||
|
// Path Y-M-D and Y-M. Includes `/` for directory separators and `.` for
|
||||||
|
// rare dot-organised libraries (`Photos/2024.02/...`).
|
||||||
|
const PATH_YMD = /(?<!\d)(\d{4})[-_/.](\d{2})[-_/.](\d{2})(?!\d)/;
|
||||||
|
const PATH_YM = /(?<!\d)(\d{4})[-_/.](\d{2})(?!\d)/;
|
||||||
|
|
||||||
|
// 10- or 13-digit Unix epoch, anchored. Years widened to [1990, current+1]
|
||||||
|
// to dodge accidental matches on phone numbers, hex hashes containing
|
||||||
|
// digits, etc. — but 10-digit seconds still has to round-trip into a
|
||||||
|
// plausible calendar year before we trust it.
|
||||||
|
const BASENAME_EPOCH = /(?<!\d)(\d{10}|\d{13})(?!\d)/;
|
||||||
|
|
||||||
|
function parseFilenameYMD(name: string): YMD | null {
|
||||||
|
const m = name.match(BASENAME_YMD);
|
||||||
|
if (!m) return null;
|
||||||
|
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnixTimestampInName(name: string): YMD | null {
|
||||||
|
const m = name.match(BASENAME_EPOCH);
|
||||||
|
if (!m) return null;
|
||||||
|
const digits = m[1];
|
||||||
|
const ms = digits.length === 13 ? Number(digits) : Number(digits) * 1000;
|
||||||
|
if (!Number.isFinite(ms)) return null;
|
||||||
|
const d = new Date(ms);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
const y = d.getUTCFullYear();
|
||||||
|
if (y < 1990 || y > new Date().getUTCFullYear() + 1) return null;
|
||||||
|
return tryYMD(y, d.getUTCMonth() + 1, d.getUTCDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePathYMD(path: string): YMD | null {
|
||||||
|
const m = path.match(PATH_YMD);
|
||||||
|
if (!m) return null;
|
||||||
|
return tryYMD(Number(m[1]), Number(m[2]), Number(m[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePathYM(path: string): YM | null {
|
||||||
|
const m = path.match(PATH_YM);
|
||||||
|
if (!m) return null;
|
||||||
|
return tryYM(Number(m[1]), Number(m[2]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymdAgreesWithYM(ymd: YMD, ym: YM): boolean {
|
||||||
|
return ymd.y === ym.y && ymd.m === ym.m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ymdEqual(a: YMD, b: YMD): boolean {
|
||||||
|
return a.y === b.y && a.m === b.m && a.d === b.d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick the filename-derived YMD that best aligns with the path. When
|
||||||
|
* both `fileName` and `originalName` yield candidates, prefer the one
|
||||||
|
* that matches the path's year+month; ties fall back to `fileName`. */
|
||||||
|
function pickFilenameYMD(
|
||||||
|
fileName: string,
|
||||||
|
originalName: string,
|
||||||
|
pathYM: YM | null
|
||||||
|
): YMD | null {
|
||||||
|
const candidates: YMD[] = [];
|
||||||
|
const a = parseFilenameYMD(fileName);
|
||||||
|
if (a) candidates.push(a);
|
||||||
|
if (originalName && originalName !== fileName) {
|
||||||
|
const b = parseFilenameYMD(originalName);
|
||||||
|
if (b && !candidates.some((c) => ymdEqual(c, b))) candidates.push(b);
|
||||||
|
}
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
if (!pathYM) return candidates[0];
|
||||||
|
const aligned = candidates.find((c) => ymdAgreesWithYM(c, pathYM));
|
||||||
|
return aligned ?? candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUnixTimestamp(fileName: string, originalName: string): YMD | null {
|
||||||
|
return (
|
||||||
|
parseUnixTimestampInName(fileName) ??
|
||||||
|
(originalName && originalName !== fileName
|
||||||
|
? parseUnixTimestampInName(originalName)
|
||||||
|
: null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestDateFromPath(input: Input): DateGuess | null {
|
||||||
|
const fileName = (input.fileName ?? '').trim();
|
||||||
|
const originalName = (input.originalName ?? '').trim();
|
||||||
|
const path = (input.path ?? '').trim();
|
||||||
|
|
||||||
|
const pathYMD = path ? parsePathYMD(path) : null;
|
||||||
|
const pathYM = path && !pathYMD ? parsePathYM(path) : null;
|
||||||
|
|
||||||
|
// 1. Filename Y-M-D corroborated by the path.
|
||||||
|
const fnYMD = pickFilenameYMD(fileName, originalName, pathYM);
|
||||||
|
if (fnYMD) {
|
||||||
|
if (pathYMD && ymdEqual(fnYMD, pathYMD)) {
|
||||||
|
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
|
||||||
|
}
|
||||||
|
if (pathYM && ymdAgreesWithYM(fnYMD, pathYM)) {
|
||||||
|
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-agrees-path' };
|
||||||
|
}
|
||||||
|
// 2. Filename Y-M-D with no path signal at all.
|
||||||
|
if (!pathYMD && !pathYM) {
|
||||||
|
return { iso: isoOf(fnYMD), confidence: 'high', source: 'filename-only' };
|
||||||
|
}
|
||||||
|
// Filename present but disagrees with path → fall through.
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Unix epoch in filename, optionally corroborated.
|
||||||
|
const epoch = pickUnixTimestamp(fileName, originalName);
|
||||||
|
if (epoch) {
|
||||||
|
if (!pathYM && !pathYMD) {
|
||||||
|
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||||
|
}
|
||||||
|
if (pathYM && ymdAgreesWithYM(epoch, pathYM)) {
|
||||||
|
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||||
|
}
|
||||||
|
if (pathYMD && ymdEqual(epoch, pathYMD)) {
|
||||||
|
return { iso: isoOf(epoch), confidence: 'high', source: 'unix-timestamp' };
|
||||||
|
}
|
||||||
|
// disagreement → fall through to path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Path Y-M-D standalone.
|
||||||
|
if (pathYMD) {
|
||||||
|
return { iso: isoOf(pathYMD), confidence: 'high', source: 'path-ymd' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Path Y-M with synthesised day = 01.
|
||||||
|
if (pathYM) {
|
||||||
|
const ymd = tryYMD(pathYM.y, pathYM.m, 1);
|
||||||
|
if (ymd) {
|
||||||
|
return { iso: isoOf(ymd), confidence: 'medium', source: 'path-ym-default-day' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -13,15 +13,18 @@
|
|||||||
getPhoto,
|
getPhoto,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
|
listPhotosAround,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
} from "$lib/services/photoprism";
|
} from "$lib/services/photoprism";
|
||||||
import {
|
import {
|
||||||
|
consumePendingFocus,
|
||||||
filters,
|
filters,
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
filtersToUrlParams,
|
filtersToUrlParams,
|
||||||
parseUrlParams,
|
parseUrlParams,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSection,
|
setSection,
|
||||||
|
type PendingFocus,
|
||||||
} from "$lib/stores/filters.svelte";
|
} from "$lib/stores/filters.svelte";
|
||||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
@@ -56,6 +59,16 @@
|
|||||||
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
|
import RightSidebar from "$lib/components/sidebar/RightSidebar.svelte";
|
||||||
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
|
import SkeletonGrid from "$lib/components/timeline/SkeletonGrid.svelte";
|
||||||
import Toolbar from "$lib/components/layout/Toolbar.svelte";
|
import Toolbar from "$lib/components/layout/Toolbar.svelte";
|
||||||
|
import { EmptyState, InlineLoader } from "$lib/components/feedback";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Archive,
|
||||||
|
EyeOff,
|
||||||
|
ImageOff,
|
||||||
|
Layers,
|
||||||
|
MousePointerClick,
|
||||||
|
Sparkles,
|
||||||
|
} from "lucide-svelte";
|
||||||
import { type PpPhoto } from "$lib/types/photoprism";
|
import { type PpPhoto } from "$lib/types/photoprism";
|
||||||
|
|
||||||
// ── URL ↔ filter store sync ──────────────────────────────────────────────
|
// ── URL ↔ filter store sync ──────────────────────────────────────────────
|
||||||
@@ -127,24 +140,90 @@
|
|||||||
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
// SQL rows, well below PhotoPrism's 1000-row server cap. Pages flatten
|
||||||
// downstream into a single `photos` array consumers iterate.
|
// downstream into a single `photos` array consumers iterate.
|
||||||
const PHOTOS_PAGE_SIZE = 120;
|
const PHOTOS_PAGE_SIZE = 120;
|
||||||
|
|
||||||
|
// Anchor mode: when an in-app deep link stashes a pending focus with a
|
||||||
|
// TakenAt, the first page is fetched as a window around that date via
|
||||||
|
// PhotoPrism's `before:`/`after:` DSL — so the target photo is in the
|
||||||
|
// loaded page even when it would otherwise be hundreds of entries past
|
||||||
|
// the newest-first cursor. Subsequent pages continue chronologically
|
||||||
|
// with a `before:<oldest-loaded-TakenAt>` cursor instead of the
|
||||||
|
// standard offset, so the listing stays in newest-first order without
|
||||||
|
// jumping around the library. Cleared when the filter changes — a new
|
||||||
|
// filter is a fresh listing, possibly with its own anchor.
|
||||||
|
let anchor = $state<PendingFocus | null>(null);
|
||||||
|
let lastFilterQ: string | null = null;
|
||||||
|
// Watch every URL change so we catch pending-focus stashes even when the
|
||||||
|
// filter didn't change (e.g. user clicks the open-folder icon for a photo
|
||||||
|
// in the folder they're already on — the goto sets the same URL but the
|
||||||
|
// user still expects to land on THAT photo). Pure filter changes with
|
||||||
|
// no pending stash clear any stale anchor so a subsequent refetch
|
||||||
|
// doesn't keep the old window.
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
void page.url.search;
|
||||||
|
untrack(() => {
|
||||||
|
const pending = consumePendingFocus();
|
||||||
|
if (pending) {
|
||||||
|
anchor = pending;
|
||||||
|
lastFilterQ = filtersToQ(filters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const q = filtersToQ(filters);
|
||||||
|
if (q !== lastFilterQ) {
|
||||||
|
anchor = null;
|
||||||
|
lastFilterQ = q;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
const photosQuery = createInfiniteQuery<PpPhoto[]>(() => ({
|
||||||
queryKey: ["photos", "q", filtersToQ(filters), { count: PHOTOS_PAGE_SIZE }],
|
queryKey: [
|
||||||
queryFn: ({ pageParam }) =>
|
"photos",
|
||||||
listPhotos({
|
"q",
|
||||||
q: filtersToQ(filters),
|
filtersToQ(filters),
|
||||||
|
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
||||||
|
],
|
||||||
|
queryFn: ({ pageParam }) => {
|
||||||
|
const offset = pageParam as number;
|
||||||
|
const baseQ = filtersToQ(filters);
|
||||||
|
// Page 0 + anchor → load a window around the anchor's date.
|
||||||
|
// Subsequent pages aren't reachable in anchor mode (see
|
||||||
|
// getNextPageParam).
|
||||||
|
if (offset === 0 && anchor?.takenAt) {
|
||||||
|
return listPhotosAround({
|
||||||
|
q: baseQ,
|
||||||
|
takenAt: anchor.takenAt,
|
||||||
|
afterCount: 30,
|
||||||
|
beforeCount: 90,
|
||||||
|
merged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return listPhotos({
|
||||||
|
q: baseQ,
|
||||||
count: PHOTOS_PAGE_SIZE,
|
count: PHOTOS_PAGE_SIZE,
|
||||||
offset: pageParam as number,
|
offset,
|
||||||
order: "newest",
|
order: "newest",
|
||||||
merged: true,
|
merged: true,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
|
// PhotoPrism's `count` limits SQL rows; with `merged=true` each
|
||||||
// photo expands into its file rows, so a "full" page of count=120
|
// photo expands into its file rows, so a "full" page of count=120
|
||||||
// typically returns ~60 photo entries. The only reliable end-of-
|
// typically returns ~60 photo entries. The only reliable end-of-
|
||||||
// pagination signal is an empty page. Costs one extra fetch at the
|
// pagination signal is an empty page. Costs one extra fetch at the
|
||||||
// tail (cheap; the empty response is small).
|
// tail (cheap; the empty response is small).
|
||||||
getNextPageParam: (last, pages) =>
|
getNextPageParam: (last, pages) => {
|
||||||
last.length === 0 ? undefined : pages.length * PHOTOS_PAGE_SIZE,
|
if (last.length === 0) return undefined;
|
||||||
|
// Anchor mode terminates after page 0 — the user sees the 120-
|
||||||
|
// photo window around the deep-linked photo. PhotoPrism's
|
||||||
|
// `before:` cursor is day-precision, so paginating further
|
||||||
|
// chronologically risks dense-day infinite loops (same-day
|
||||||
|
// photos exceeding the page size keep the cursor at the same
|
||||||
|
// value). To "see more," the user clears the anchor by
|
||||||
|
// navigating fresh.
|
||||||
|
if (anchor?.takenAt) return undefined;
|
||||||
|
return pages.length * PHOTOS_PAGE_SIZE;
|
||||||
|
},
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -227,7 +306,22 @@
|
|||||||
// 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;
|
||||||
|
// Anchor (from an in-app deep link) takes precedence — its UID is
|
||||||
|
// guaranteed in `photos` because page 0 was fetched as a window
|
||||||
|
// around its TakenAt. Plain navigations leave anchor null and we
|
||||||
|
// snap to photos[0] as before. `scrollToIndex` expands the
|
||||||
|
// windowed render set + scrolls the tile into view (with sticky-
|
||||||
|
// header peek) — same helper gridKeyNav uses for arrow nav.
|
||||||
|
const targetIdx =
|
||||||
|
anchor?.uid != null
|
||||||
|
? photos.findIndex((p) => p.UID === anchor!.uid)
|
||||||
|
: -1;
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
setFocused(photos[targetIdx].UID);
|
||||||
|
void scrollToIndex(targetIdx);
|
||||||
|
} else {
|
||||||
setFocused(photos[0].UID);
|
setFocused(photos[0].UID);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -831,29 +925,42 @@
|
|||||||
{#if photosQuery.isPending}
|
{#if photosQuery.isPending}
|
||||||
<SkeletonGrid />
|
<SkeletonGrid />
|
||||||
{:else if photosQuery.isError}
|
{:else if photosQuery.isError}
|
||||||
<p class="text-sm text-destructive">
|
<EmptyState
|
||||||
Failed to load photos: {photosQuery.error instanceof Error
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Failed to load photos"
|
||||||
|
description={photosQuery.error instanceof Error
|
||||||
? photosQuery.error.message
|
? photosQuery.error.message
|
||||||
: "unknown error"}
|
: "unknown error"}
|
||||||
</p>
|
/>
|
||||||
{:else if photos.length === 0}
|
{:else if photos.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{#if filters.section === "archive"}
|
{#if filters.section === "archive"}
|
||||||
Archive is empty.
|
<EmptyState icon={Archive} title="Archive is empty" />
|
||||||
{:else if filters.section === "review"}
|
{:else if filters.section === "review"}
|
||||||
Nothing left to review. Photos PhotoPrism's indexer wasn't
|
<EmptyState
|
||||||
sure about land here — use Keep to accept them into the
|
icon={Sparkles}
|
||||||
timeline or Archive to set them aside.
|
title="Nothing left to review"
|
||||||
|
description="Photos the indexer wasn't sure about land here — use Keep to accept them into the timeline or Archive to set them aside."
|
||||||
|
/>
|
||||||
{:else if filters.section === "hidden"}
|
{:else if filters.section === "hidden"}
|
||||||
No hidden photos. PhotoPrism auto-hides files it can't index
|
<EmptyState
|
||||||
(broken files, very low quality); they only ever show up here.
|
icon={EyeOff}
|
||||||
|
title="No hidden photos"
|
||||||
|
description="The indexer auto-hides files it can't read (broken files, very low quality); they only ever show up here."
|
||||||
|
/>
|
||||||
{:else if filters.section === "heap"}
|
{:else if filters.section === "heap"}
|
||||||
This heap has no photos yet. Select some photos and use the
|
<EmptyState
|
||||||
bulk bar's "+ Add to heap" button.
|
icon={Layers}
|
||||||
|
title="This heap has no photos yet"
|
||||||
|
description={'Select some photos and use the bulk bar’s “+ Add to heap” button.'}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
No photos. Index a folder via PhotoPrism's reindex command.
|
<EmptyState
|
||||||
|
icon={ImageOff}
|
||||||
|
title="No photos"
|
||||||
|
description="Index a folder from Settings → Index, or run a reindex from the server."
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
data-photo-grid
|
data-photo-grid
|
||||||
@@ -920,9 +1027,12 @@
|
|||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
{#if photosQuery.isFetchingNextPage}
|
{#if photosQuery.isFetchingNextPage}
|
||||||
<p class="py-3 text-center text-xs text-muted-foreground">
|
<InlineLoader
|
||||||
Loading more…
|
size="sm"
|
||||||
</p>
|
align="center"
|
||||||
|
polite={false}
|
||||||
|
label="Loading more photos…"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -944,15 +1054,16 @@
|
|||||||
{:else if focusedPhotoQuery.data}
|
{:else if focusedPhotoQuery.data}
|
||||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
{:else if focusedPhotoQuery.isFetching}
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading metadata…" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2 p-4 text-center">
|
<EmptyState icon={MousePointerClick} title="No photo selected">
|
||||||
<div class="text-xl">ⓘ</div>
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-xs text-muted-foreground">
|
<p>
|
||||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click
|
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd
|
||||||
on a thumbnail to view its metadata here.
|
>+click on a thumbnail to view its metadata here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- Resize handle on the left edge; mirrors the layout's left aside
|
<!-- Resize handle on the left edge; mirrors the layout's left aside
|
||||||
|
|||||||
@@ -61,8 +61,8 @@
|
|||||||
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
|
class="w-full max-w-sm space-y-5 rounded-lg border border-border bg-card p-8 shadow-sm"
|
||||||
>
|
>
|
||||||
<header class="space-y-1">
|
<header class="space-y-1">
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mule</h1>
|
<h1 class="text-2xl font-semibold tracking-tight text-foreground">Mulimage</h1>
|
||||||
<p class="text-sm text-muted-foreground">Sign in with your PhotoPrism account.</p>
|
<p class="text-sm text-muted-foreground">Sign in to your account.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<label class="block space-y-1.5">
|
<label class="block space-y-1.5">
|
||||||
|
|||||||
@@ -299,6 +299,37 @@
|
|||||||
markersOnScreen.clear();
|
markersOnScreen.clear();
|
||||||
markers.clear();
|
markers.clear();
|
||||||
|
|
||||||
|
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||||
|
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||||
|
// rather than fitting to the full library extent. Strip the params
|
||||||
|
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||||
|
// through to the default fitBounds when the params aren't present.
|
||||||
|
const sp = new URL(window.location.href).searchParams;
|
||||||
|
const latParam = Number(sp.get('lat'));
|
||||||
|
const lngParam = Number(sp.get('lng'));
|
||||||
|
if (
|
||||||
|
(data.features?.length ?? 0) > 0 &&
|
||||||
|
Number.isFinite(latParam) &&
|
||||||
|
Number.isFinite(lngParam) &&
|
||||||
|
sp.has('lat') &&
|
||||||
|
sp.has('lng')
|
||||||
|
) {
|
||||||
|
const zoom = Number(sp.get('zoom')) || 17;
|
||||||
|
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||||
|
const stripped = new URL(window.location.href);
|
||||||
|
stripped.searchParams.delete('lat');
|
||||||
|
stripped.searchParams.delete('lng');
|
||||||
|
stripped.searchParams.delete('zoom');
|
||||||
|
stripped.searchParams.delete('focus');
|
||||||
|
const qs = stripped.searchParams.toString();
|
||||||
|
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||||
|
replaceState: true,
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fit to data extent on the first non-empty load — prefer the
|
// Fit to data extent on the first non-empty load — prefer the
|
||||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||||
// the features.
|
// the features.
|
||||||
|
|||||||
122
web/src/routes/notes/+page.svelte
Normal file
122
web/src/routes/notes/+page.svelte
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
getPhoto,
|
||||||
|
listPhotosWithNotes,
|
||||||
|
type PhotoWithNote
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { setRightSidebarWidth, view } from '$lib/stores/view.svelte';
|
||||||
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import { resizable } from '$lib/actions/resizable';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
|
import NotesPhotoGrid from '$lib/components/timeline/NotesPhotoGrid.svelte';
|
||||||
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
|
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, MousePointerClick, StickyNote } from 'lucide-svelte';
|
||||||
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
// Photos with a non-empty Details.Notes. The query is shared by the
|
||||||
|
// LeftSidebar's count badge ($derived off the same key), so visiting
|
||||||
|
// /notes warms the badge and vice-versa. Keyed under the ['photos', …]
|
||||||
|
// prefix so the existing mutation invalidations cascade in.
|
||||||
|
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
|
||||||
|
queryKey: ['photos', 'with-notes'],
|
||||||
|
queryFn: listPhotosWithNotes,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const items = $derived<PhotoWithNote[]>(notesQuery.data ?? []);
|
||||||
|
const count = $derived(items.length);
|
||||||
|
|
||||||
|
// Right-sidebar metadata for the focused tile. Same wiring as the tag
|
||||||
|
// drill-in page so the metadata panel reads consistently.
|
||||||
|
const focusedPhotoQuery = createQuery<PpPhoto | null>(() => ({
|
||||||
|
queryKey: ['photo', selection.focused ?? ''],
|
||||||
|
queryFn: () =>
|
||||||
|
selection.focused ? getPhoto(selection.focused) : Promise.resolve(null),
|
||||||
|
enabled: isAuthenticated() && Boolean(selection.focused),
|
||||||
|
staleTime: 0
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar showRightToggle>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Notes
|
||||||
|
</span>
|
||||||
|
{#if !notesQuery.isPending && !notesQuery.isError}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{count} photo{count === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<main
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
|
use:gridKeyNav={{}}
|
||||||
|
>
|
||||||
|
{#if notesQuery.isPending}
|
||||||
|
<SkeletonGrid />
|
||||||
|
{:else if notesQuery.isError}
|
||||||
|
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load notes" />
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={StickyNote}
|
||||||
|
title="No photos with notes"
|
||||||
|
description="Add a note to a photo from its metadata sidebar and it will appear here."
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<NotesPhotoGrid {items} />
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
<BulkActionBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !view.rightSidebarCollapsed}
|
||||||
|
<aside
|
||||||
|
class="relative h-full shrink-0 border-l border-border bg-card/30"
|
||||||
|
style="width: {view.rightSidebarWidth}px;"
|
||||||
|
>
|
||||||
|
<div class="h-full overflow-y-auto">
|
||||||
|
{#if selection.ids.size >= 2}
|
||||||
|
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||||
|
{:else if focusedPhotoQuery.data}
|
||||||
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
|
<InlineLoader size="sm" label="Loading metadata…" />
|
||||||
|
{:else}
|
||||||
|
<EmptyState icon={MousePointerClick} title="No photo selected">
|
||||||
|
{#snippet descriptionSnippet()}
|
||||||
|
<p>
|
||||||
|
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a
|
||||||
|
thumbnail to view its metadata here.
|
||||||
|
</p>
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="group absolute -left-1.5 top-0 z-20 h-full w-3 cursor-col-resize"
|
||||||
|
use:resizable={{
|
||||||
|
edge: 'left',
|
||||||
|
getWidth: () => view.rightSidebarWidth,
|
||||||
|
setWidth: setRightSidebarWidth
|
||||||
|
}}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="Resize info panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="ml-1 h-full w-0.5 bg-transparent transition-colors group-hover:bg-primary/40"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
approve). The previous section is restored on unmount.
|
approve). The previous section is restored on unmount.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +50,8 @@
|
|||||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
import CauseGroupCard from '$lib/components/review/CauseGroupCard.svelte';
|
||||||
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
import DuplicatesView from '$lib/components/duplicates/DuplicatesView.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, Sparkles } from 'lucide-svelte';
|
||||||
|
|
||||||
type DupTab = 'stacks' | 'cross-folder';
|
type DupTab = 'stacks' | 'cross-folder';
|
||||||
type Tab = CauseKey | DupTab;
|
type Tab = CauseKey | DupTab;
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
count: g.photos.length as number | undefined
|
count: g.photos.length as number | undefined
|
||||||
})),
|
})),
|
||||||
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
{ id: 'stacks', label: 'Stacks', count: stacksCount },
|
||||||
{ id: 'cross-folder', label: 'Cross-folder', count: crossFolderCount }
|
{ id: 'cross-folder', label: 'Duplicates', count: crossFolderCount }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
const requestedTab = $derived(page.url.searchParams.get('tab'));
|
||||||
@@ -136,49 +137,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
const activeGroup = $derived(groups.find((g) => g.cause === activeTab));
|
||||||
|
const activeTabSpec = $derived(tabs.find((t) => t.id === activeTab));
|
||||||
function setTab(id: Tab) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
// First cause tab (if any) is the default — same convention as
|
|
||||||
// the old /review behaviour, so back-from-cross-folder lands on
|
|
||||||
// the user's review queue rather than the empty Stacks panel.
|
|
||||||
const defaultId = tabs[0]?.id;
|
|
||||||
if (defaultId !== undefined && id !== defaultId) params.set('tab', id);
|
|
||||||
void goto(`/review${params.size ? '?' + params : ''}`, {
|
|
||||||
keepFocus: true,
|
|
||||||
noScroll: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
Review
|
Review
|
||||||
</span>
|
</span>
|
||||||
{#if tabs.length > 0}
|
{#if activeTabSpec}
|
||||||
<div class="flex items-center gap-1">
|
<span class="text-[11px] font-medium">{activeTabSpec.label}</span>
|
||||||
{#each tabs as t (t.id)}
|
{#if activeTabSpec.count !== undefined}
|
||||||
<button
|
<span class="text-[11px] text-muted-foreground">{activeTabSpec.count}</span>
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 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)}
|
|
||||||
>
|
|
||||||
<span>{t.label}</span>
|
|
||||||
{#if t.count !== undefined}
|
|
||||||
<span
|
|
||||||
class="flex h-4 min-w-4.5 items-center justify-center rounded px-1 text-[10px] tabular-nums {activeTab ===
|
|
||||||
t.id
|
|
||||||
? 'bg-primary/15 text-primary'
|
|
||||||
: 'bg-secondary text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{t.count}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<div
|
<div
|
||||||
@@ -226,23 +196,27 @@
|
|||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
<main class="min-h-0 flex-1 overflow-y-auto px-6 py-4 pb-6" use:gridKeyNav={{}}>
|
||||||
{#if reviewQuery.isPending}
|
{#if reviewQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading review queue…</p>
|
<InlineLoader label="Loading review queue…" />
|
||||||
{:else if reviewQuery.error}
|
{:else if reviewQuery.error}
|
||||||
<p class="text-sm text-destructive">
|
<EmptyState
|
||||||
Could not load review queue: {reviewQuery.error instanceof Error
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Could not load review queue"
|
||||||
|
description={reviewQuery.error instanceof Error
|
||||||
? reviewQuery.error.message
|
? reviewQuery.error.message
|
||||||
: 'unknown error'}
|
: 'unknown error'}
|
||||||
</p>
|
/>
|
||||||
{:else if groups.length === 0}
|
{:else if groups.length === 0}
|
||||||
<div class="max-w-prose space-y-2 text-sm text-muted-foreground">
|
<EmptyState icon={Sparkles} title="Nothing to review">
|
||||||
<p>The review queue is empty.</p>
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-xs">
|
<p>
|
||||||
PhotoPrism's indexer flags photos with a low quality score for human
|
The indexer flags photos with a low quality score for human review. New
|
||||||
review. New arrivals with missing EXIF, low resolution, or unknown
|
arrivals with missing EXIF, low resolution, or unknown cameras will land
|
||||||
cameras will land here. The Stacks and Cross-folder tabs above stay
|
here. The Stacks and Duplicates tabs above stay available for
|
||||||
available for duplicate cleanup.
|
duplicate cleanup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{:else if activeGroup}
|
{:else if activeGroup}
|
||||||
{#key activeGroup.cause}
|
{#key activeGroup.cause}
|
||||||
<CauseGroupCard group={activeGroup} />
|
<CauseGroupCard group={activeGroup} />
|
||||||
@@ -261,9 +235,9 @@
|
|||||||
{#if selection.ids.size >= 2}
|
{#if selection.ids.size >= 2}
|
||||||
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
<BulkMetadataSidebar ids={Array.from(selection.ids)} />
|
||||||
{:else if focusedPhotoQuery.data}
|
{:else if focusedPhotoQuery.data}
|
||||||
<RightSidebar photo={focusedPhotoQuery.data} showRelated />
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
{:else if focusedPhotoQuery.isFetching}
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading metadata…" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
getPhoto,
|
getPhoto,
|
||||||
listLabels,
|
listLabels,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
|
listSubjects,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpLabel
|
type PpLabel,
|
||||||
|
type PpSubject
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
@@ -32,6 +34,8 @@
|
|||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
|
import { AlertCircle, ImageOff, MousePointerClick, Tag } from 'lucide-svelte';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
// URL-driven category + value. `isTagCategory` rejects typos so a stray
|
// URL-driven category + value. `isTagCategory` rejects typos so a stray
|
||||||
@@ -63,7 +67,9 @@
|
|||||||
// filter on top would make drill counts disagree with the badges (a
|
// filter on top would make drill counts disagree with the badges (a
|
||||||
// label badge of 157 could otherwise drill into 0 photos because the
|
// label badge of 157 could otherwise drill into 0 photos because the
|
||||||
// session is scoped to a folder that has none of them).
|
// session is scoped to a folder that has none of them).
|
||||||
const useServer = $derived(category === 'labels' || category === 'keywords');
|
const useServer = $derived(
|
||||||
|
category === 'labels' || category === 'keywords' || category === 'people'
|
||||||
|
);
|
||||||
const drillQ = $derived(
|
const drillQ = $derived(
|
||||||
useServer && selectedValue
|
useServer && selectedValue
|
||||||
? filtersToQ({
|
? filtersToQ({
|
||||||
@@ -126,6 +132,11 @@
|
|||||||
queryFn: listLabels,
|
queryFn: listLabels,
|
||||||
enabled: isAuthenticated() && category === 'labels'
|
enabled: isAuthenticated() && category === 'labels'
|
||||||
}));
|
}));
|
||||||
|
const subjectsQuery = createQuery<PpSubject[]>(() => ({
|
||||||
|
queryKey: ['subjects'],
|
||||||
|
queryFn: listSubjects,
|
||||||
|
enabled: isAuthenticated() && category === 'people'
|
||||||
|
}));
|
||||||
const drillTitle = $derived.by(() => {
|
const drillTitle = $derived.by(() => {
|
||||||
if (!selectedValue) return '';
|
if (!selectedValue) return '';
|
||||||
if (category === 'labels') {
|
if (category === 'labels') {
|
||||||
@@ -135,6 +146,10 @@
|
|||||||
return hit?.Name ?? selectedValue;
|
return hit?.Name ?? selectedValue;
|
||||||
}
|
}
|
||||||
if (category === 'keywords') return selectedValue;
|
if (category === 'keywords') return selectedValue;
|
||||||
|
if (category === 'people') {
|
||||||
|
const hit = (subjectsQuery.data ?? []).find((s) => s.Slug === selectedValue);
|
||||||
|
return hit?.Name ?? selectedValue;
|
||||||
|
}
|
||||||
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
|
if (category === 'ratings') return starLabel(parseInt(selectedValue, 10));
|
||||||
if (category === 'colors') {
|
if (category === 'colors') {
|
||||||
return (
|
return (
|
||||||
@@ -188,12 +203,11 @@
|
|||||||
|
|
||||||
{#if !selectedValue}
|
{#if !selectedValue}
|
||||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||||
<div class="max-w-sm space-y-2 text-center">
|
<EmptyState
|
||||||
<p class="text-sm font-medium">Pick a {category ?? 'tag'} from the sidebar</p>
|
icon={Tag}
|
||||||
<p class="text-xs text-muted-foreground">
|
title={`Pick a ${category ?? 'tag'} from the sidebar`}
|
||||||
Click a row in the panel on the left to filter the photo grid by that tag.
|
description="Click a row in the panel on the left to filter the photo grid by that tag."
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
@@ -205,13 +219,14 @@
|
|||||||
{#if showSkeleton}
|
{#if showSkeleton}
|
||||||
<SkeletonGrid />
|
<SkeletonGrid />
|
||||||
{:else if showError}
|
{:else if showError}
|
||||||
<p class="text-sm text-destructive">Failed to load photos.</p>
|
<EmptyState tone="destructive" icon={AlertCircle} title="Failed to load photos" />
|
||||||
{:else if drillPhotos.length === 0}
|
{:else if drillPhotos.length === 0}
|
||||||
<p class="text-sm text-muted-foreground">No photos under this tag.</p>
|
<EmptyState icon={ImageOff} title="No photos under this tag" />
|
||||||
{:else}
|
{:else}
|
||||||
<PhotoGrid photos={drillPhotos} />
|
<PhotoGrid photos={drillPhotos} />
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
<BulkActionBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !view.rightSidebarCollapsed}
|
{#if !view.rightSidebarCollapsed}
|
||||||
@@ -225,15 +240,16 @@
|
|||||||
{:else if focusedPhotoQuery.data}
|
{:else if focusedPhotoQuery.data}
|
||||||
<RightSidebar photo={focusedPhotoQuery.data} />
|
<RightSidebar photo={focusedPhotoQuery.data} />
|
||||||
{:else if focusedPhotoQuery.isFetching}
|
{:else if focusedPhotoQuery.isFetching}
|
||||||
<p class="px-3 py-2 text-xs text-muted-foreground">Loading…</p>
|
<InlineLoader size="sm" label="Loading metadata…" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2 p-4 text-center">
|
<EmptyState icon={MousePointerClick} title="No photo selected">
|
||||||
<div class="text-xl">ⓘ</div>
|
{#snippet descriptionSnippet()}
|
||||||
<p class="text-xs text-muted-foreground">
|
<p>
|
||||||
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a
|
Use arrow keys or <kbd class="rounded bg-muted px-1">⌘</kbd>+click on a
|
||||||
thumbnail to view its metadata here.
|
thumbnail to view its metadata here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -255,5 +271,3 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|||||||
10
web/static/favicon.svg
Normal file
10
web/static/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 73 100">
|
||||||
|
<style>
|
||||||
|
path { fill: #0f172a; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #f8fafc; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<path d="m45.268 55.124q-13.062 14-22.732 14-6.206 0-11.546-4.7629v3.8248q0 6.6391 2.0193 13.351 2.3093 7.4328 2.3093 9.8145 0 3.3195-2.0925 5.4846-2.0924 2.165-5.1956 2.165-3.1753 0-5.0515-2.5257-1.8753-2.5257-1.8753-6.0618 0-2.598 1.8753-9.3814 2.1657-7.5053 2.1657-14.577v-65.452h11.907v42.792q0 6.495 1.0817 9.5259 1.1549 3.031 3.9692 4.9795 2.8867 1.8764 6.495 1.8764 6.4225 0 16.67-8.8042v-50.371h11.979v50.153q0 6.3504 1.299 8.8042 1.2989 2.3815 4.4023 2.3815 4.907 0 6.3504-9.5979h2.5979q-1.3721 16.381-13.856 16.381-5.4122 0-9.0207-3.6082-3.536-3.6804-3.7525-10.392z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 804 B |
Reference in New Issue
Block a user