feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -84,11 +85,21 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
|
|
||||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||||
targetAbs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
// Empty / "/" / "." are valid here — they mean "drop these into
|
||||||
|
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
||||||
|
// rejects those for safety, so handle the root case explicitly.
|
||||||
|
var targetAbs string
|
||||||
|
trimmed := strings.Trim(body.TargetFolder, "/")
|
||||||
|
if trimmed == "" || trimmed == "." {
|
||||||
|
targetAbs = cfg.OriginalsRoot
|
||||||
|
} else {
|
||||||
|
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
targetAbs = abs
|
||||||
|
}
|
||||||
destAbs := targetAbs
|
destAbs := targetAbs
|
||||||
if subfolder != "" {
|
if subfolder != "" {
|
||||||
destAbs = filepath.Join(targetAbs, subfolder)
|
destAbs = filepath.Join(targetAbs, subfolder)
|
||||||
@@ -123,20 +134,32 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
moved, copied := 0, 0
|
moved, copied := 0, 0
|
||||||
|
|
||||||
for _, photo := range photos {
|
for _, photo := range photos {
|
||||||
|
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||||
|
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||||
|
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||||
|
// — moving that path would fail "file missing on disk" every
|
||||||
|
// time. Prefer the primary that lives in originals (Root=="/")
|
||||||
|
// and fall back to the first originals-rooted file. PhotoPrism
|
||||||
|
// regenerates sidecars on reindex, so they don't need to follow.
|
||||||
var file ppFile
|
var file ppFile
|
||||||
found := false
|
found := false
|
||||||
for _, f := range photo.Files {
|
for _, f := range photo.Files {
|
||||||
if f.Primary {
|
if f.Root == "/" && f.Primary {
|
||||||
file, found = f, true
|
file, found = f, true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
if len(photo.Files) == 0 {
|
for _, f := range photo.Files {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no primary file"})
|
if f.Root == "/" {
|
||||||
continue
|
file, found = f, true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
file = photo.Files[0]
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
srcRel := file.Name
|
srcRel := file.Name
|
||||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||||
@@ -185,9 +208,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reindex the destination + every source parent so PhotoPrism's
|
// Reindex the destination + every source parent so PhotoPrism's
|
||||||
// DB catches up. We do this in the background — the user gets
|
// DB catches up. We block on these so the response only goes out
|
||||||
// their counts immediately; PhotoPrism's timeline updates as the
|
// after the index reflects the move — callers (the frontend's
|
||||||
// reindex lands.
|
// invalidateQueries refetch in particular) need the next /photos
|
||||||
|
// fetch to return the moved files, otherwise the folder view
|
||||||
|
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
||||||
|
// internally; running them sequentially matches that contract
|
||||||
|
// without surprising the server.
|
||||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||||
paths := map[string]struct{}{destRel: {}}
|
paths := map[string]struct{}{destRel: {}}
|
||||||
for p := range sourceParents {
|
for p := range sourceParents {
|
||||||
@@ -202,7 +229,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
if p != "" && p != "." {
|
if p != "" && p != "." {
|
||||||
reindex = "/" + p
|
reindex = "/" + p
|
||||||
}
|
}
|
||||||
go fireReindex(cfg, pp, token, reindex)
|
fireReindex(cfg, pp, token, reindex)
|
||||||
}
|
}
|
||||||
|
|
||||||
heapDeleted := false
|
heapDeleted := false
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { batchEdit } from '$lib/services/batch';
|
import { batchEdit } from '$lib/services/batch';
|
||||||
import { patchTargets } from '$lib/services/bulk';
|
import { invalidatePhotos } from '$lib/services/bulk';
|
||||||
import {
|
import {
|
||||||
addToHeap,
|
addToHeap,
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive,
|
||||||
|
batchDelete,
|
||||||
|
batchRestore,
|
||||||
likePhoto,
|
likePhoto,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
unlikePhoto,
|
unlikePhoto,
|
||||||
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
target = !(first?.Archived ?? false);
|
target = !(first?.Archived ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await patchTargets(
|
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||||
ids,
|
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||||
{ Archived: target },
|
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
// back, so the toast fired but nothing moved.
|
||||||
(p) => ({ Archived: p.Archived ?? false })
|
try {
|
||||||
);
|
if (target) await batchArchive(ids);
|
||||||
|
else await batchRestore(ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||||
|
toast.success(label);
|
||||||
|
pushUndo(label, async () => {
|
||||||
|
if (target) await batchRestore(ids);
|
||||||
|
else await batchArchive(ids);
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permanently delete cull targets — only callable from the archive
|
||||||
|
* section (X is rerouted away from archive-toggle there). PhotoPrism
|
||||||
|
* rejects deletion of un-archived photos with a 4xx, so the section
|
||||||
|
* gate doubles as a safety guard against accidental deletes from the
|
||||||
|
* main timeline. Confirm dialog is mandatory — no undo path exists. */
|
||||||
|
async function deleteCullTargets() {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to delete', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg =
|
||||||
|
ids.length === 1
|
||||||
|
? 'Permanently delete this photo? This cannot be undone.'
|
||||||
|
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
try {
|
||||||
|
await batchDelete(ids);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
toast.success(`Deleted ${ids.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approve cull targets — clears them out of the review pile by
|
||||||
|
* bumping each photo's quality score above PhotoPrism's review
|
||||||
|
* threshold. The op is one-way (no /unapprove route), so we don't
|
||||||
|
* push an undo entry: a re-keyed S would just be a no-op on
|
||||||
|
* already-approved photos. */
|
||||||
|
async function approveCullTargets() {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to keep', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||||
|
invalidatePhotos(ids);
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||||
|
description: errors[0].message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Kept ${ids.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||||
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
case 'X':
|
case 'X':
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Archive section: X becomes permanent delete (Keep/Delete
|
||||||
|
// is the binary flow there, mirroring Review's Keep/Archive).
|
||||||
|
// Everywhere else X toggles archive on the cull targets.
|
||||||
|
if (filters.section === 'archive') {
|
||||||
|
void deleteCullTargets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
void toggleArchive('toggle');
|
void toggleArchive('toggle');
|
||||||
return;
|
return;
|
||||||
case 'u':
|
case 'u':
|
||||||
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
case 's':
|
case 's':
|
||||||
case 'S':
|
case 'S':
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
// Review section repurposes S as the Keep affordance —
|
||||||
|
// matches the BulkActionBar button and keeps the binary
|
||||||
|
// Keep/Archive flow on home-row keys (S/X). The heap chord
|
||||||
|
// is meaningless here anyway (review photos can't sensibly
|
||||||
|
// be filed before they're approved).
|
||||||
|
if (filters.section === 'review') {
|
||||||
|
void approveCullTargets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Archive section: S = Keep = restore back to the timeline
|
||||||
|
// (inverse of Delete on X). Same rationale as review —
|
||||||
|
// heap-filing an archived photo isn't a flow that fits the
|
||||||
|
// section's intent.
|
||||||
|
if (filters.section === 'archive') {
|
||||||
|
void toggleArchive('restore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||||
// otherwise we fall back to the currently-viewed heap.
|
// otherwise we fall back to the currently-viewed heap.
|
||||||
e.preventDefault();
|
|
||||||
clearSChord();
|
clearSChord();
|
||||||
sChordTimer = window.setTimeout(() => {
|
sChordTimer = window.setTimeout(() => {
|
||||||
sChordTimer = null;
|
sChordTimer = null;
|
||||||
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
const uid = tile.dataset.uid;
|
const uid = tile.dataset.uid;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
|
// Modifier clicks are the only paths this document-level handler
|
||||||
|
// owns. Plain clicks bubble to the tile button's onclick, which
|
||||||
|
// reduces selection to just that tile.
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRange(uid);
|
selectRange(uid);
|
||||||
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggle(uid);
|
toggle(uid);
|
||||||
setFocused(uid);
|
setFocused(uid);
|
||||||
} else if (selection.ids.size > 0) {
|
|
||||||
// When a multi-selection is active, a plain click reduces it to
|
|
||||||
// just this tile (matches mule-image's "selection mode" behaviour).
|
|
||||||
e.preventDefault();
|
|
||||||
selection.ids.clear();
|
|
||||||
selection.ids.add(uid);
|
|
||||||
setFocused(uid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,19 +27,24 @@ export interface NearBottomParams {
|
|||||||
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||||
let current: NearBottomParams = params;
|
let current: NearBottomParams = params;
|
||||||
let io: IntersectionObserver | null = null;
|
let io: IntersectionObserver | null = null;
|
||||||
|
// IntersectionObserver only emits on state changes. With a 4-viewport
|
||||||
|
// preload zone, the sentinel typically stays continuously intersecting
|
||||||
|
// across a whole fetchNextPage cycle: enabled flips false (fetching),
|
||||||
|
// the IO callback runs but no-ops, enabled flips back true — and no new
|
||||||
|
// event is emitted because the intersection state never changed. We'd
|
||||||
|
// stall mid-pagination. Remember the last reported intersection so the
|
||||||
|
// next `enabled` rising edge can re-fire manually.
|
||||||
|
let lastIntersecting = false;
|
||||||
|
|
||||||
function buildObserver(p: NearBottomParams) {
|
function buildObserver(p: NearBottomParams) {
|
||||||
io?.disconnect();
|
io?.disconnect();
|
||||||
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||||
io = new IntersectionObserver(
|
io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (!current.enabled) return;
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isIntersecting) {
|
lastIntersecting = e.isIntersecting;
|
||||||
current.onHit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (lastIntersecting && current.enabled) current.onHit();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: p.root ?? null,
|
root: p.root ?? null,
|
||||||
@@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
|||||||
update(next: NearBottomParams) {
|
update(next: NearBottomParams) {
|
||||||
const rootChanged = next.root !== current.root;
|
const rootChanged = next.root !== current.root;
|
||||||
const preloadChanged = next.preloadPx !== current.preloadPx;
|
const preloadChanged = next.preloadPx !== current.preloadPx;
|
||||||
|
const enabledRose = !current.enabled && !!next.enabled;
|
||||||
current = next;
|
current = next;
|
||||||
// `enabled` and `onHit` are read live inside the callback,
|
if (rootChanged || preloadChanged) {
|
||||||
// so they don't require rebuilding the observer. Root and
|
buildObserver(current);
|
||||||
// preloadPx are baked in at construction.
|
return;
|
||||||
if (rootChanged || preloadChanged) buildObserver(current);
|
}
|
||||||
|
// `enabled` rising while the sentinel is still in the preload
|
||||||
|
// zone — no IO event coming, so fire manually.
|
||||||
|
if (enabledRose && lastIntersecting) current.onHit();
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
io?.disconnect();
|
io?.disconnect();
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
* `filters.folderPath` matches (the sidebar nav case); the picker
|
* `filters.folderPath` matches (the sidebar nav case); the picker
|
||||||
* passes its own selection so the dialog has independent state. */
|
* passes its own selection so the dialog has independent state. */
|
||||||
selectedPath?: string | null;
|
selectedPath?: string | null;
|
||||||
|
/** Optional per-path photo count. When provided, each row renders a
|
||||||
|
* compact badge with the count — matching the heaps section's
|
||||||
|
* "{n} photos" affordance. Undefined keeps the badge off entirely
|
||||||
|
* (the picker dialog doesn't need it). */
|
||||||
|
counts?: Record<string, number>;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -69,7 +74,8 @@
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreateChild,
|
onCreateChild,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
selectedPath
|
selectedPath,
|
||||||
|
counts
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Auto-expanded folders, persisted to localStorage so the tree state
|
// Auto-expanded folders, persisted to localStorage so the tree state
|
||||||
@@ -114,7 +120,7 @@
|
|||||||
with the px-2 of Views/Heaps rows; +12px per nested level.
|
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
class:bg-primary={active}
|
class:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
@@ -137,7 +143,7 @@
|
|||||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center truncate text-left"
|
class="flex min-w-0 flex-1 items-center truncate text-left"
|
||||||
class:px-1={hasChildren || depth > 0}
|
class:px-1={hasChildren || depth > 0}
|
||||||
onclick={() => onPick(node.path)}
|
onclick={() => onPick(node.path)}
|
||||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
@@ -145,11 +151,23 @@
|
|||||||
>
|
>
|
||||||
<span class="truncate">{node.name}</span>
|
<span class="truncate">{node.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{#if counts && counts[node.path] !== undefined}
|
||||||
|
{@const n = counts[node.path]}
|
||||||
|
<span
|
||||||
|
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{n >= 1000 ? '1000+' : n}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#if !readonly}
|
{#if !readonly}
|
||||||
<!-- Hover-revealed kebab. Reserves zero width when idle so the
|
<!-- Hover-revealed kebab. `display: none` until row hover
|
||||||
row stays compact; expands on hover and stays visible while
|
(or while the menu is open via has-[[data-state=open]])
|
||||||
the menu is open. Suppressed in readonly mode (picker). -->
|
so the count holds the row's right edge by default
|
||||||
<div class="mr-1">
|
and the kebab pushes it left when it appears.
|
||||||
|
Suppressed in readonly mode (picker). -->
|
||||||
|
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||||
<KebabMenu label="Folder actions">
|
<KebabMenu label="Folder actions">
|
||||||
<Item
|
<Item
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||||
@@ -187,6 +205,7 @@
|
|||||||
{onCreateChild}
|
{onCreateChild}
|
||||||
{readonly}
|
{readonly}
|
||||||
{selectedPath}
|
{selectedPath}
|
||||||
|
{counts}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
General app preferences. Distinct from the PhotoPrism-library admin dialog:
|
General app preferences. The UI tab owns the SvelteKit shell's
|
||||||
this one owns settings that affect *this* SvelteKit shell (theme), not the
|
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||||
server. Opened from the bottom of the left sidebar.
|
PhotoPrism's /settings exposes. Search and Maps follow the same
|
||||||
|
pattern — server prefs round-trip via /api/v1/settings.
|
||||||
|
|
||||||
|
The Library admin dialog and this one share the ['settings'] cache,
|
||||||
|
so saves from either invalidate the other.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog } from 'bits-ui';
|
import { Dialog, Tabs } from 'bits-ui';
|
||||||
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||||
|
import {
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
type PpSettings
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -14,11 +25,98 @@
|
|||||||
}
|
}
|
||||||
let { open, onClose }: Props = $props();
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: 'light', label: 'Light', Icon: Sun },
|
{ value: 'light', label: 'Light', Icon: Sun },
|
||||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||||
{ value: 'system', label: 'System', Icon: Monitor }
|
{ value: 'system', label: 'System', Icon: Monitor }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// PhotoPrism palette names from its built-in themes. Any value
|
||||||
|
// outside this list is preserved verbatim (see `withCurrent`).
|
||||||
|
const ppThemes = [
|
||||||
|
'default',
|
||||||
|
'abyss',
|
||||||
|
'gemstone',
|
||||||
|
'grayscale',
|
||||||
|
'lavender',
|
||||||
|
'legacy',
|
||||||
|
'neon',
|
||||||
|
'onyx',
|
||||||
|
'raspberry',
|
||||||
|
'shadow',
|
||||||
|
'yellowstone'
|
||||||
|
];
|
||||||
|
|
||||||
|
// IETF subtags PhotoPrism ships translations for. Extend without
|
||||||
|
// fear — `withCurrent` keeps unknown values visible.
|
||||||
|
const ppLanguages = [
|
||||||
|
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
|
||||||
|
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
|
||||||
|
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ppStartPages = [
|
||||||
|
'default',
|
||||||
|
'browse',
|
||||||
|
'albums',
|
||||||
|
'favorites',
|
||||||
|
'calendar',
|
||||||
|
'moments',
|
||||||
|
'people',
|
||||||
|
'places',
|
||||||
|
'labels',
|
||||||
|
'states',
|
||||||
|
'library'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
|
||||||
|
|
||||||
|
// Returns `opts` with `current` prepended if it's set and not
|
||||||
|
// already in the list — so e.g. an experimental theme name in the
|
||||||
|
// server response shows up selected and editable instead of
|
||||||
|
// silently being overwritten by the dropdown's default.
|
||||||
|
function withCurrent(opts: string[], current?: string): string[] {
|
||||||
|
if (!current) return opts;
|
||||||
|
return opts.includes(current) ? opts : [current, ...opts];
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsQuery = createQuery<PpSettings>(() => ({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: getSettings,
|
||||||
|
enabled: open
|
||||||
|
}));
|
||||||
|
|
||||||
|
let draft = $state<PpSettings | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (settingsQuery.data && draft === null) {
|
||||||
|
draft = structuredClone(settingsQuery.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) draft = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMut = createMutation(() => ({
|
||||||
|
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||||
|
onSuccess: (next) => {
|
||||||
|
qc.setQueryData(['settings'], next);
|
||||||
|
draft = structuredClone(next);
|
||||||
|
toast.success('Settings saved');
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not save settings')
|
||||||
|
}));
|
||||||
|
|
||||||
|
function resetDraft() {
|
||||||
|
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
@@ -32,7 +130,7 @@
|
|||||||
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"
|
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
|
<Dialog.Content
|
||||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -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"
|
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -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">
|
<div class="flex items-start gap-2">
|
||||||
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -41,8 +139,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. Library-side settings live under
|
Preferences for this app and your PhotoPrism account.
|
||||||
Folders → ⚙.
|
Library admin lives under Folders → ⚙.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close
|
||||||
@@ -53,9 +151,24 @@
|
|||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-2 text-[12px]">
|
<Tabs.Root bind:value={activeTab}>
|
||||||
|
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
||||||
|
{#each ['ui', 'search', 'maps'] as const as t (t)}
|
||||||
|
<Tabs.Trigger
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
{/each}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<!-- UI — local app theme (mode-watcher) on top, then the
|
||||||
|
PhotoPrism per-user UI knobs that go to /settings. -->
|
||||||
|
<Tabs.Content value="ui" 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">
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Appearance
|
App theme
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="flex items-center overflow-hidden rounded-md border border-border"
|
class="flex items-center overflow-hidden rounded-md border border-border"
|
||||||
@@ -77,7 +190,180 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Light/dark for this app. Persists locally; no Save needed.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if settingsQuery.isPending}
|
||||||
|
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
|
||||||
|
{:else if settingsQuery.isError}
|
||||||
|
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
|
||||||
|
{:else if draft}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
PhotoPrism UI
|
||||||
|
</h3>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Theme</span>
|
||||||
|
<select bind:value={draft.ui!.theme} class={selectClass}>
|
||||||
|
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
|
||||||
|
<option value={v}>{v}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Language</span>
|
||||||
|
<select bind:value={draft.ui!.language} class={selectClass}>
|
||||||
|
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
|
||||||
|
<option value={v}>{v}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Time zone</span>
|
||||||
|
<!-- IANA tz list is ~400 entries, browser support varies; use
|
||||||
|
a datalist so we get autocomplete without spamming a
|
||||||
|
gigantic <select>. "Local" is PhotoPrism's special
|
||||||
|
"follow system" sentinel. -->
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="general-tz-list"
|
||||||
|
placeholder="Local"
|
||||||
|
bind:value={draft.ui!.timeZone}
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Start page</span>
|
||||||
|
<select bind:value={draft.ui!.startPage} class={selectClass}>
|
||||||
|
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
|
||||||
|
<option value={v}>{v}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
|
||||||
|
Always show scrollbars
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={draft.ui!.zoom} />
|
||||||
|
Allow image zoom
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
{#if settingsQuery.isPending && activeTab !== 'ui'}
|
||||||
|
<Tabs.Content value={activeTab} class="outline-none">
|
||||||
|
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
{:else if settingsQuery.isError && activeTab !== 'ui'}
|
||||||
|
<Tabs.Content value={activeTab} class="outline-none">
|
||||||
|
<p class="px-1 text-[12px] text-destructive">
|
||||||
|
Could not load settings.
|
||||||
|
</p>
|
||||||
|
</Tabs.Content>
|
||||||
|
{:else if draft}
|
||||||
|
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={draft.search!.listView} />
|
||||||
|
Default to list view
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={draft.search!.showTitles} />
|
||||||
|
Show titles
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
|
||||||
|
Show captions
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
Batch size (-1 = server default)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={draft.search!.batchSize}
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Tabs.Content>
|
||||||
|
|
||||||
|
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">Style</span>
|
||||||
|
<select bind:value={draft.maps!.style} class={selectClass}>
|
||||||
|
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
|
||||||
|
<option value={v}>{v}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
Animation duration (ms, 0 = off)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={draft.maps!.animate}
|
||||||
|
class={selectClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Tabs.Content>
|
||||||
|
{/if}
|
||||||
|
</Tabs.Root>
|
||||||
|
|
||||||
|
<!-- Datalist for time-zone autocomplete. Falls back to the
|
||||||
|
"Local" sentinel when the browser can't enumerate the
|
||||||
|
IANA list (older Safari, etc.). -->
|
||||||
|
<datalist id="general-tz-list">
|
||||||
|
<option value="Local"></option>
|
||||||
|
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
||||||
|
trip). The App theme group above persists itself, so we
|
||||||
|
only show the action row when there's something to save. -->
|
||||||
|
{#if draft}
|
||||||
|
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||||
|
onclick={resetDraft}
|
||||||
|
disabled={saveMut.isPending}
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
onclick={() => draft && saveMut.mutate(draft)}
|
||||||
|
disabled={saveMut.isPending}
|
||||||
|
>
|
||||||
|
{#if saveMut.isPending}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<script lang="ts" module>
|
||||||
|
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
|
||||||
|
// 15.3 and below) return undefined here. The component handles that
|
||||||
|
// by simply showing only the "Local" sentinel in the datalist.
|
||||||
|
export function tzOptions(): string[] {
|
||||||
|
const fn = (Intl as unknown as {
|
||||||
|
supportedValuesOf?: (k: string) => string[];
|
||||||
|
}).supportedValuesOf;
|
||||||
|
if (typeof fn !== 'function') return [];
|
||||||
|
try {
|
||||||
|
return fn('timeZone');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -92,7 +92,9 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!heap || !pickedPath) return;
|
// pickedPath === '' is the root selection; falsy check would
|
||||||
|
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
||||||
|
if (!heap || pickedPath === null) return;
|
||||||
convertMut.mutate({
|
convertMut.mutate({
|
||||||
uid: heap.UID,
|
uid: heap.UID,
|
||||||
body: {
|
body: {
|
||||||
@@ -154,6 +156,20 @@
|
|||||||
No folders. Create one from the sidebar first.
|
No folders. Create one from the sidebar first.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Root row: lets the user drop the heap directly into
|
||||||
|
originals/ without picking a subfolder. The empty
|
||||||
|
string is the sidecar's "root" sentinel — matches
|
||||||
|
resolveUnderRoot's special case in handlers_heap. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||||
|
class:bg-primary={pickedPath === ''}
|
||||||
|
class:text-primary-foreground={pickedPath === ''}
|
||||||
|
class:hover:bg-primary={pickedPath === ''}
|
||||||
|
onclick={() => (pickedPath = '')}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
<FolderTree
|
<FolderTree
|
||||||
nodes={folderTree}
|
nodes={folderTree}
|
||||||
onPick={(p) => (pickedPath = p)}
|
onPick={(p) => (pickedPath = p)}
|
||||||
@@ -213,7 +229,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
onclick={submit}
|
onclick={submit}
|
||||||
disabled={!pickedPath || convertMut.isPending}
|
disabled={pickedPath === null || convertMut.isPending}
|
||||||
>
|
>
|
||||||
{#if convertMut.isPending}
|
{#if convertMut.isPending}
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -10,14 +11,21 @@
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
deleteHeap,
|
deleteHeap,
|
||||||
duplicateHeap,
|
duplicateHeap,
|
||||||
|
getAllMarks,
|
||||||
|
getConfig,
|
||||||
|
getImportInfo,
|
||||||
heapDownloadUrl,
|
heapDownloadUrl,
|
||||||
|
listFolderCounts,
|
||||||
listFolders,
|
listFolders,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
logout,
|
logout,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
|
type ImportInfo,
|
||||||
|
type PhotoMarksMap,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
|
type PpClientConfig,
|
||||||
type PpFolder
|
type PpFolder
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +44,7 @@
|
|||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
|
FolderPlus,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -58,10 +67,90 @@
|
|||||||
enabled: isAuthenticated()
|
enabled: isAuthenticated()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
|
||||||
|
// 60s — the inbox only changes when files are uploaded or the indexer
|
||||||
|
// runs, neither of which happens often enough to justify a tighter cadence.
|
||||||
|
const importQuery = createQuery<ImportInfo>(() => ({
|
||||||
|
queryKey: ['import'],
|
||||||
|
queryFn: getImportInfo,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||||
|
// precomputed counter for every common bucket (all/favorites/archived/
|
||||||
|
// labels/places/…) updated incrementally on every mutation. Cheap to
|
||||||
|
// refetch, and gives us a stable total — `/photos` only returns
|
||||||
|
// per-page row counts via `X-Count`, never a total.
|
||||||
|
//
|
||||||
|
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||||
|
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||||
|
// across mutations (favorite, archive, restore, delete, heap add) — the
|
||||||
|
// counter map refreshes whenever the photo list does. Marks-derived
|
||||||
|
// counts (ratings/colors) react through the shared `['marks']` cache.
|
||||||
|
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||||
|
queryKey: ['photos', 'config'],
|
||||||
|
queryFn: getConfig,
|
||||||
|
enabled: isAuthenticated()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||||
|
queryKey: ['marks'],
|
||||||
|
queryFn: getAllMarks,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
staleTime: 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))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||||
|
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
||||||
|
// in parallel. Key the query off the folder-path list so it refetches
|
||||||
|
// when folders are added/renamed/deleted, and share the ['photos', …]
|
||||||
|
// prefix so it invalidates alongside the other photo caches whenever a
|
||||||
|
// mutation lands.
|
||||||
|
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||||
|
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||||
|
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||||
|
queryFn: () => listFolderCounts(folderPaths),
|
||||||
|
enabled: isAuthenticated() && folderPaths.length > 0,
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||||
|
|
||||||
|
// Root count = total photos minus the sum of every subfolder count.
|
||||||
|
// `config.count.all` is PhotoPrism's authoritative library total
|
||||||
|
// (kept in sync server-side); subtracting non-root photos gives an
|
||||||
|
// exact root-only count without a separate API trip.
|
||||||
|
const rootCount = $derived.by(() => {
|
||||||
|
const total = configQuery.data?.count?.all ?? 0;
|
||||||
|
const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
|
||||||
|
return Math.max(0, total - sub);
|
||||||
|
});
|
||||||
|
|
||||||
const createMut = createMutation(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
onSuccess: (h) => {
|
onSuccess: (h) => {
|
||||||
@@ -109,6 +198,24 @@
|
|||||||
// 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);
|
||||||
|
|
||||||
|
// Root-folder collapse state. Persisted to its own localStorage key so
|
||||||
|
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
|
||||||
|
// to open so first-time users see the full tree.
|
||||||
|
const ROOT_OPEN_KEY = 'mule_root_expanded';
|
||||||
|
let rootExpanded = $state(loadRootExpanded());
|
||||||
|
function loadRootExpanded(): boolean {
|
||||||
|
if (!browser) return true;
|
||||||
|
const raw = localStorage.getItem(ROOT_OPEN_KEY);
|
||||||
|
return raw === null ? true : raw === '1';
|
||||||
|
}
|
||||||
|
function toggleRoot() {
|
||||||
|
rootExpanded = !rootExpanded;
|
||||||
|
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootActive = $derived(filters.folderPath === '/');
|
||||||
|
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||||
|
|
||||||
async function onSignOut() {
|
async function onSignOut() {
|
||||||
await logout();
|
await logout();
|
||||||
await goto('/login', { replaceState: true });
|
await goto('/login', { replaceState: true });
|
||||||
@@ -221,24 +328,40 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single Views group — section-driven entries and route-driven entries
|
// Two groups: Views (everyday browse) and Manage (curation flows that
|
||||||
// mixed in display order. `kind` discriminates which click handler runs
|
// decide a photo's fate — review, dedup, unhide, delete). `kind`
|
||||||
// (sections go through `navigateTo` to seed filter state; routes are
|
// discriminates which click handler runs (sections go through
|
||||||
// plain links). Archive intentionally sits at the bottom to keep it out
|
// `navigateTo` to seed filter state; routes are plain links).
|
||||||
// of the way of the everyday-browse rows.
|
//
|
||||||
|
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||||
|
// derived value on every render — the arrays themselves are constant.
|
||||||
|
// `count.all` already excludes archived/review/hidden (PhotoPrism's
|
||||||
|
// "everything visible in the main timeline" tally), so it matches what
|
||||||
|
// the All photos view actually renders. `places` is the count of
|
||||||
|
// geocoded locations — semantically what the Map view groups by.
|
||||||
|
// Duplicates has no precomputed counter; we omit its badge.
|
||||||
type ViewItem =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string };
|
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||||
|
|
||||||
|
// "All photos" is not in this list: the root-folder row at the top
|
||||||
|
// of the sidebar is the canonical entry into the library, so a
|
||||||
|
// separate "everything regardless of folder" destination would just
|
||||||
|
// duplicate it for users whose photos live under the root.
|
||||||
const views: ViewItem[] = [
|
const views: ViewItem[] = [
|
||||||
{ kind: 'section', id: 'all-photos', label: 'All photos' },
|
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
||||||
{ kind: 'section', id: 'favorites', label: 'Favorites' },
|
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
||||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||||
{ kind: 'route', href: '/map', label: 'Map' },
|
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
|
||||||
{ kind: 'route', href: '/ratings', label: 'Ratings' },
|
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
|
||||||
{ kind: 'route', href: '/colors', label: 'Colors' },
|
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
|
||||||
{ kind: 'route', href: '/tags', label: 'Tags' },
|
];
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive' }
|
|
||||||
|
const manageViews: ViewItem[] = [
|
||||||
|
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||||
|
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
||||||
|
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||||
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
||||||
];
|
];
|
||||||
|
|
||||||
function isRouteActive(href: string): boolean {
|
function isRouteActive(href: string): boolean {
|
||||||
@@ -246,10 +369,176 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet viewRow(v: ViewItem)}
|
||||||
|
{@const active = v.kind === 'section' ? isActive(v.id) : isRouteActive(v.href)}
|
||||||
|
{@const count = v.getCount()}
|
||||||
|
{#if v.kind === 'section'}
|
||||||
|
<button
|
||||||
|
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
onclick={() => navigateTo(v.id)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{v.label}</span>
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span
|
||||||
|
class="ml-auto flex h-4 min-w-[20px] 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}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={v.href}
|
||||||
|
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={active}
|
||||||
|
class:text-primary-foreground={active}
|
||||||
|
class:hover:bg-primary={active}
|
||||||
|
>
|
||||||
|
<span class="truncate">{v.label}</span>
|
||||||
|
{#if count !== undefined}
|
||||||
|
<span
|
||||||
|
class="ml-auto flex h-4 min-w-[20px] 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>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
||||||
<!-- Views — section-driven entries + route-driven entries under a
|
<!-- Folders — top of the sidebar because the root folder is the
|
||||||
single uppercase eyebrow. Compact rows, no icons. -->
|
default landing view (see filters store init), making it the
|
||||||
|
primary navigation surface. Root-folder row + subfolder tree;
|
||||||
|
hover-revealed actions on the header for library settings and
|
||||||
|
new-top-level-folder. -->
|
||||||
|
<div>
|
||||||
|
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||||
|
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
Library
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
|
onclick={() => (settingsOpen = true)}
|
||||||
|
title="Library settings"
|
||||||
|
aria-label="Library settings"
|
||||||
|
>
|
||||||
|
<Settings class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||||
|
onclick={() => onCreateFolder(null)}
|
||||||
|
title="New top-level folder"
|
||||||
|
aria-label="New top-level folder"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
Root-folder entry. Mirrors a subfolder row's hover/active state
|
||||||
|
via the `/` sentinel; clicking the label filters the timeline to
|
||||||
|
photos whose Path is empty (handled by applyFolderScope in
|
||||||
|
+page.svelte). The chevron collapses/expands the subfolder tree
|
||||||
|
below — same affordance as nested folder rows.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={rootActive}
|
||||||
|
class:text-primary-foreground={rootActive}
|
||||||
|
class:hover:bg-primary={rootActive}
|
||||||
|
style="padding-left: 8px;"
|
||||||
|
>
|
||||||
|
{#if hasSubfolders}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||||
|
class:text-muted-foreground={!rootActive}
|
||||||
|
onclick={toggleRoot}
|
||||||
|
title={rootExpanded ? 'Collapse' : 'Expand'}
|
||||||
|
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
||||||
|
>
|
||||||
|
{rootExpanded ? '▾' : '▸'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex min-w-0 flex-1 items-center truncate text-left"
|
||||||
|
class:px-1={hasSubfolders}
|
||||||
|
onclick={() => pickFolder('/')}
|
||||||
|
title="Photos directly under originals/"
|
||||||
|
>
|
||||||
|
<span class="truncate">/</span>
|
||||||
|
</button>
|
||||||
|
{#if configQuery.data}
|
||||||
|
<span
|
||||||
|
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{rootCount >= 1000 ? '1000+' : rootCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||||
|
can't be renamed or deleted, so those entries are omitted
|
||||||
|
entirely rather than greyed out. Hidden until row hover (or
|
||||||
|
menu open) so the count holds the right edge by default. -->
|
||||||
|
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||||
|
<KebabMenu label="Root folder actions">
|
||||||
|
<Item
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||||
|
onSelect={() => onCreateFolder(null)}
|
||||||
|
>
|
||||||
|
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
New subfolder
|
||||||
|
</Item>
|
||||||
|
</KebabMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if foldersQuery.isPending}
|
||||||
|
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||||
|
{:else if !hasSubfolders}
|
||||||
|
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||||
|
{:else if rootExpanded}
|
||||||
|
<!--
|
||||||
|
depth=1 visually nests the top-level subfolders one indent
|
||||||
|
step under the root row above. Labels at depth=1 line up
|
||||||
|
12px right of the root label, matching the same per-level
|
||||||
|
step used for deeper folders.
|
||||||
|
-->
|
||||||
|
<FolderTree
|
||||||
|
nodes={folderTree}
|
||||||
|
depth={1}
|
||||||
|
onPick={pickFolder}
|
||||||
|
onRename={onRenameFolder}
|
||||||
|
onDelete={onDeleteFolder}
|
||||||
|
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||||
|
counts={folderCounts}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if filters.folderPath && filters.folderPath !== '/'}
|
||||||
|
<button
|
||||||
|
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={() => {
|
||||||
|
setFolderPath(null);
|
||||||
|
void goto('/', { keepFocus: true, noScroll: true });
|
||||||
|
}}
|
||||||
|
title="Clear folder filter"
|
||||||
|
>
|
||||||
|
<span class="truncate">✕ {filters.folderPath}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Views — everyday browse entries (section + route mixed) under
|
||||||
|
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||||
<div>
|
<div>
|
||||||
<div class="px-3 pb-1">
|
<div class="px-3 pb-1">
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
@@ -257,27 +546,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
{#if v.kind === 'section'}
|
{@render viewRow(v)}
|
||||||
<button
|
{/each}
|
||||||
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
</div>
|
||||||
class:bg-primary={isActive(v.id)}
|
|
||||||
class:text-primary-foreground={isActive(v.id)}
|
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||||
class:hover:bg-primary={isActive(v.id)}
|
row shape as Views; grouped separately so the binary-decision
|
||||||
onclick={() => navigateTo(v.id)}
|
destinations (Review/Archive) don't crowd the browse list. -->
|
||||||
>
|
<div>
|
||||||
<span class="truncate">{v.label}</span>
|
<div class="px-3 pb-1">
|
||||||
</button>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
{:else}
|
Manage
|
||||||
<a
|
</span>
|
||||||
href={v.href}
|
</div>
|
||||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
class:bg-primary={isRouteActive(v.href)}
|
{@render viewRow(v)}
|
||||||
class:text-primary-foreground={isRouteActive(v.href)}
|
|
||||||
class:hover:bg-primary={isRouteActive(v.href)}
|
|
||||||
>
|
|
||||||
<span class="truncate">{v.label}</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,19 +589,28 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
{@const active = isActive('heap', heap.UID)}
|
{@const active = isActive('heap', heap.UID)}
|
||||||
|
<!--
|
||||||
|
Count + kebab share the right edge: count is the
|
||||||
|
resting state, kebab swaps in on hover (or while the
|
||||||
|
menu is open). Moving the count out of the inner
|
||||||
|
button is what lets it reach the row's right edge
|
||||||
|
the way Views rows do — and the inner button still
|
||||||
|
owns the navigate-on-click area.
|
||||||
|
-->
|
||||||
<li
|
<li
|
||||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||||
class:bg-primary={active}
|
class:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="flex flex-1 items-center gap-2 px-2 text-left"
|
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left"
|
||||||
onclick={() => navigateTo('heap', heap.UID)}
|
onclick={() => navigateTo('heap', heap.UID)}
|
||||||
ondblclick={() => onRenameHeap(heap)}
|
ondblclick={() => onRenameHeap(heap)}
|
||||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||||
>
|
>
|
||||||
<span class="truncate">{heap.Title}</span>
|
<span class="truncate">{heap.Title}</span>
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
@@ -326,8 +618,7 @@
|
|||||||
>
|
>
|
||||||
{heap.PhotoCount ?? 0}
|
{heap.PhotoCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||||
<div class="mr-1">
|
|
||||||
<KebabMenu label="Heap actions">
|
<KebabMenu label="Heap actions">
|
||||||
<Item
|
<Item
|
||||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||||
@@ -373,54 +664,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
|
||||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
Folders
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
|
||||||
onclick={() => (settingsOpen = true)}
|
|
||||||
title="Library settings"
|
|
||||||
aria-label="Library settings"
|
|
||||||
>
|
|
||||||
<Settings class="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
|
||||||
onclick={() => onCreateFolder(null)}
|
|
||||||
title="New top-level folder"
|
|
||||||
aria-label="New top-level folder"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if foldersQuery.isPending}
|
|
||||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
|
||||||
{:else if (foldersQuery.data ?? []).length === 0}
|
|
||||||
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
|
||||||
{:else}
|
|
||||||
<FolderTree
|
|
||||||
nodes={folderTree}
|
|
||||||
onPick={pickFolder}
|
|
||||||
onRename={onRenameFolder}
|
|
||||||
onDelete={onDeleteFolder}
|
|
||||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if filters.folderPath}
|
|
||||||
<button
|
|
||||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
||||||
onclick={() => {
|
|
||||||
setFolderPath(null);
|
|
||||||
void goto('/', { keepFocus: true, noScroll: true });
|
|
||||||
}}
|
|
||||||
title="Clear folder filter"
|
|
||||||
>
|
|
||||||
<span class="truncate">✕ {filters.folderPath}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -429,7 +672,7 @@
|
|||||||
sign-out) that used to live in the top toolbar.
|
sign-out) that used to live in the top toolbar.
|
||||||
-->
|
-->
|
||||||
<footer
|
<footer
|
||||||
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
|
class="flex h-9 shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
@@ -19,25 +18,14 @@
|
|||||||
enabled: Boolean(preview.uid)
|
enabled: Boolean(preview.uid)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Track the last visible uid so we can return focus to the matching
|
// Mirror the currently-shown photo into selection.focused. The
|
||||||
// timeline tile when the overlay closes — lets the user keep moving
|
// timeline's tile uses `selection.focused === photo.UID` to draw the
|
||||||
// with arrow keys without re-clicking.
|
// blue ring, so this keeps the selection in lockstep with whatever
|
||||||
let lastShown: string | null = null;
|
// the user is paging through in preview. The host page (+page.svelte)
|
||||||
|
// owns the matching scroll-into-view on close so the tile actually
|
||||||
|
// mounts (it can be windowed out if the user navigated far).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (preview.uid !== null) {
|
if (preview.uid !== null) setFocused(preview.uid);
|
||||||
lastShown = preview.uid;
|
|
||||||
setFocused(preview.uid);
|
|
||||||
} else if (lastShown) {
|
|
||||||
const target = lastShown;
|
|
||||||
lastShown = null;
|
|
||||||
// Wait for the overlay to unmount before grabbing focus, otherwise
|
|
||||||
// the browser swallows it as the modal element is removed.
|
|
||||||
void tick().then(() => {
|
|
||||||
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
|
|
||||||
tile?.focus({ preventScroll: false });
|
|
||||||
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard handling lives at the document level so it works regardless
|
// Keyboard handling lives at the document level so it works regardless
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
addToHeap,
|
addToHeap,
|
||||||
|
approvePhoto,
|
||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
@@ -43,6 +44,16 @@
|
|||||||
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);
|
||||||
|
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||||
|
// every other action is hidden so the choice can't be confused with
|
||||||
|
// favoriting / heap-adding / restoring. The S keybinding is rerouted
|
||||||
|
// to approve from gridKeyNav for the same reason.
|
||||||
|
const isReview = $derived(filters.section === 'review');
|
||||||
|
// Archive section is the parallel two-button flow: Keep (restore back
|
||||||
|
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||||
|
// from "archive" to "delete" since the photo is already archived;
|
||||||
|
// gridKeyNav mirrors the rerouting.
|
||||||
|
const isArchive = $derived(filters.section === 'archive');
|
||||||
|
|
||||||
function clearAll() {
|
function clearAll() {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -59,6 +70,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onApprove() {
|
||||||
|
const ids = snapshotIds();
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
await withBusy(async () => {
|
||||||
|
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
|
||||||
|
// no /unapprove route. We fan out per-photo because there's no
|
||||||
|
// batch endpoint either. Errors are tallied rather than aborting
|
||||||
|
// the loop so a single bad UID doesn't block the rest.
|
||||||
|
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||||
|
if (errors.length) {
|
||||||
|
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||||
|
} else {
|
||||||
|
toast.success(`Kept ${ids.length}`);
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function onArchive() {
|
async function onArchive() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -160,11 +189,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if targetCount > 0}
|
{#if targetCount > 0}
|
||||||
|
<!--
|
||||||
|
Inline row at the bottom of the main content column (NOT fixed) so
|
||||||
|
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
|
||||||
|
bg-background/80 backdrop-blur visual so it reads as the timeline's
|
||||||
|
own footer.
|
||||||
|
-->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
|
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||||||
>
|
>
|
||||||
<div class="mx-auto flex max-w-7xl items-center gap-3">
|
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||||
<span class="text-sm font-medium text-foreground">
|
|
||||||
{#if isBulk}
|
{#if isBulk}
|
||||||
{targetCount} selected
|
{targetCount} selected
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,16 +206,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
<!--
|
||||||
|
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||||||
|
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
|
||||||
|
so the dropdown's `bottom-full` placement is clipped to zero
|
||||||
|
pixels above the 36px bar (it renders but is invisible). We
|
||||||
|
use `flex-wrap` instead so very narrow viewports get a second
|
||||||
|
row rather than a horizontal scroll, and the dropdown stays
|
||||||
|
free to escape upward.
|
||||||
|
-->
|
||||||
|
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||||
|
{#if isReview}
|
||||||
|
<!-- Review pile = binary decision. Keep approves (Quality →
|
||||||
|
3+, lands in the main timeline); Archive batches into
|
||||||
|
the archive section. Everything else (heap, favorite,
|
||||||
|
restore) is hidden so the choice reads as decisive. -->
|
||||||
|
<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"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onApprove}
|
||||||
|
title="Keep — accept into timeline"
|
||||||
|
>
|
||||||
|
✓ Keep
|
||||||
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||||
|
</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"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onArchive}
|
||||||
|
title="Archive"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||||
|
</button>
|
||||||
|
{:else if isArchive}
|
||||||
|
<!-- Archive section = mirror of review: Keep restores back
|
||||||
|
to the timeline; Delete is permanent and can't be
|
||||||
|
undone. X is repurposed from archive→delete since the
|
||||||
|
photo is already archived; the destructive styling
|
||||||
|
reinforces the irreversibility. -->
|
||||||
|
<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"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onRestore}
|
||||||
|
title="Keep — restore to timeline"
|
||||||
|
>
|
||||||
|
✓ Keep
|
||||||
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||||
|
</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"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={onDelete}
|
||||||
|
title="Permanently delete (no undo)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent 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 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
||||||
>S N</kbd
|
>S N</kbd
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@@ -220,70 +311,50 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onFavorite}
|
onclick={onFavorite}
|
||||||
title="Favorite"
|
title="Favorite"
|
||||||
>
|
>
|
||||||
♥ Favorite
|
♥ Favorite
|
||||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
|
||||||
>F</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border 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"
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||||
>X</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={onRestore}
|
onclick={onRestore}
|
||||||
title="Restore"
|
title="Restore"
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
|
||||||
>U</kbd
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
{#if filters.section === 'archive'}
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
|
||||||
disabled={busy}
|
|
||||||
onclick={onDelete}
|
|
||||||
title="Permanently delete (no undo)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||||
disabled={busy || undoStack.entries.length === 0}
|
disabled={busy || undoStack.entries.length === 0}
|
||||||
onclick={onUndo}
|
onclick={onUndo}
|
||||||
title="Undo last action"
|
title="Undo last action"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
|
||||||
>⌘Z</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||||
onclick={clearAll}
|
onclick={clearAll}
|
||||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||||
>
|
>
|
||||||
{isBulk ? 'Clear' : 'Dismiss'}
|
{isBulk ? 'Clear' : 'Dismiss'}
|
||||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
|
||||||
>Esc</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal file
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<!--
|
||||||
|
Flat photo grid for views that don't need infinite-scroll windowing or
|
||||||
|
month headers — the drill-in screens in /colors, /tags, /ratings. Wears
|
||||||
|
the same tile look + click semantics as the timeline so the user gets
|
||||||
|
selection rings, single-click select, dblclick preview, and arrow-key
|
||||||
|
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing.
|
||||||
|
|
||||||
|
The grid carries `data-photo-grid` so gridKeyNav can measure its
|
||||||
|
column count, and each tile carries `data-tile`+`data-uid` so the
|
||||||
|
action's document-level click handler can pick up shift/cmd/ctrl
|
||||||
|
modifiers and route them through the shared selection helpers.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
isSelected,
|
||||||
|
selection,
|
||||||
|
setAnchor,
|
||||||
|
setFocused,
|
||||||
|
setOrder
|
||||||
|
} from '$lib/stores/selection.svelte';
|
||||||
|
import { openPreview } from '$lib/stores/preview.svelte';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
|
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
photos: PpPhoto[];
|
||||||
|
/** Override the column template. Defaults to the global
|
||||||
|
* `view.thumbnailSize` so drill-in grids honour the same XS–XL
|
||||||
|
* preset the timeline uses. */
|
||||||
|
columns?: string;
|
||||||
|
}
|
||||||
|
let { photos, columns }: Props = $props();
|
||||||
|
const tracks = $derived(
|
||||||
|
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = $derived(photos.map((p) => p.UID));
|
||||||
|
$effect(() => {
|
||||||
|
setOrder(order);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent, uid: string) {
|
||||||
|
// Modifier clicks bubble to gridKeyNav's window handler (range +
|
||||||
|
// toggle paths). Plain clicks reduce the selection to this tile,
|
||||||
|
// matching the timeline's selectOnly semantics.
|
||||||
|
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();
|
||||||
|
openPreview(uid, order);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
|
||||||
|
{#each photos as photo (photo.UID)}
|
||||||
|
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||||
|
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-tile
|
||||||
|
data-uid={photo.UID}
|
||||||
|
onclick={(e) => onClick(e, photo.UID)}
|
||||||
|
ondblclick={(e) => onDblclick(e, photo.UID)}
|
||||||
|
class:scale-90={sel}
|
||||||
|
class:ring-2={sel}
|
||||||
|
class:ring-blue-500={sel}
|
||||||
|
class:ring-offset-2={sel}
|
||||||
|
class:ring-offset-background={sel}
|
||||||
|
class:transition-[transform,box-shadow]={sel}
|
||||||
|
class:duration-300={sel}
|
||||||
|
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
|
||||||
|
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbUrl(hash, 'tile_500')}
|
||||||
|
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||||
|
loading="lazy"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
class:transition={!sel}
|
||||||
|
class:group-hover:scale-105={!sel}
|
||||||
|
/>
|
||||||
|
{#if sel}
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||||
|
{/if}
|
||||||
|
{#if photo.Favorite}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
|
||||||
|
>♥</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if isVideo(photo)}
|
||||||
|
<span
|
||||||
|
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
|
||||||
|
>VIDEO</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -192,6 +192,17 @@ export async function batchDelete(uids: string[]): Promise<void> {
|
|||||||
await http.post('/batch/photos/delete', toBatchBody(uids));
|
await http.post('/batch/photos/delete', toBatchBody(uids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve a photo in the review pile. PhotoPrism's indexer leaves photos
|
||||||
|
* with low quality scores in `review:true` purgatory; approving bumps the
|
||||||
|
* score above the review threshold (Quality goes to 3+) so the photo
|
||||||
|
* lands in the main timeline. No corresponding "unapprove" endpoint — the
|
||||||
|
* review pile is one-way out.
|
||||||
|
*/
|
||||||
|
export async function approvePhoto(uid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${uid}/approve`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||||
* routes that are atomic; preferred over PUT for this one field.
|
* routes that are atomic; preferred over PUT for this one field.
|
||||||
@@ -269,6 +280,65 @@ export async function listFolders(): Promise<PpFolder[]> {
|
|||||||
return data.folders ?? [];
|
return data.folders ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
|
||||||
|
* files in a separate `/photoprism/import` root, exposed via
|
||||||
|
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
||||||
|
* `/folders/originals`, but the photo counts come from `X-Files` and
|
||||||
|
* `X-Folders` response headers since the body only lists subfolders.
|
||||||
|
*/
|
||||||
|
export interface ImportInfo {
|
||||||
|
files: number;
|
||||||
|
folders: number;
|
||||||
|
subfolders: PpFolder[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImportInfo(): Promise<ImportInfo> {
|
||||||
|
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
|
||||||
|
params: { recursive: true, uncached: true, files: false }
|
||||||
|
});
|
||||||
|
const num = (h: unknown) => {
|
||||||
|
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
|
||||||
|
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
|
||||||
|
subfolders: res.data.folders ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||||
|
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
|
||||||
|
* response has no total-rows header — X-Count is the per-page row count.
|
||||||
|
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
|
||||||
|
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
|
||||||
|
* pair counts twice if we trusted `data.length`. Capped at the server's
|
||||||
|
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
|
||||||
|
*
|
||||||
|
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
|
||||||
|
* children only, so summing the per-path counts (no double-counting from
|
||||||
|
* nested folders) is the right way to derive the root-folder photo
|
||||||
|
* count.
|
||||||
|
*
|
||||||
|
* Returns a plain object keyed by the input paths to keep it JSON-friendly
|
||||||
|
* for TanStack's structural sharing.
|
||||||
|
*/
|
||||||
|
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
paths.map(async (path) => {
|
||||||
|
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||||
|
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
|
||||||
|
});
|
||||||
|
const uids = new Set<string>();
|
||||||
|
for (const p of data) uids.add(p.UID);
|
||||||
|
return [path, uids.size] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PpGeoFeature {
|
export interface PpGeoFeature {
|
||||||
@@ -610,8 +680,21 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
|
|||||||
// merges server-side, so it's safe to round-trip an incomplete object.
|
// merges server-side, so it's safe to round-trip an incomplete object.
|
||||||
|
|
||||||
export interface PpSettings {
|
export interface PpSettings {
|
||||||
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
|
ui?: {
|
||||||
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
|
theme?: string;
|
||||||
|
language?: string;
|
||||||
|
timeZone?: string;
|
||||||
|
startPage?: string;
|
||||||
|
scrollbar?: boolean;
|
||||||
|
zoom?: boolean;
|
||||||
|
};
|
||||||
|
search?: {
|
||||||
|
batchSize?: number;
|
||||||
|
listView?: boolean;
|
||||||
|
showTitles?: boolean;
|
||||||
|
showCaptions?: boolean;
|
||||||
|
};
|
||||||
|
maps?: { animate?: number; style?: string };
|
||||||
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
|
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: 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 };
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
export type Section =
|
export type Section =
|
||||||
| 'all-photos'
|
| 'all-photos'
|
||||||
| 'favorites'
|
| 'favorites'
|
||||||
|
| 'review'
|
||||||
| 'archive'
|
| 'archive'
|
||||||
|
| 'hidden'
|
||||||
| 'heap';
|
| 'heap';
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
@@ -24,10 +26,14 @@ export interface FilterState {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default landing = root folder (`/`). The Folders group sits at the top
|
||||||
|
// of the sidebar; landing inside it gives users a stable starting point
|
||||||
|
// instead of dumping them into the full library. Picking any other view
|
||||||
|
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
|
||||||
export const filters = $state<FilterState>({
|
export const filters = $state<FilterState>({
|
||||||
section: 'all-photos',
|
section: 'all-photos',
|
||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: null,
|
folderPath: '/',
|
||||||
search: ''
|
search: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,9 +71,21 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
case 'favorites':
|
case 'favorites':
|
||||||
parts.push('favorite:true');
|
parts.push('favorite:true');
|
||||||
break;
|
break;
|
||||||
|
case 'review':
|
||||||
|
// PhotoPrism's review pile: photos the indexer flagged as
|
||||||
|
// uncertain (low quality score). Cleared per-photo via the
|
||||||
|
// `/approve` endpoint or by archiving.
|
||||||
|
parts.push('review:true');
|
||||||
|
break;
|
||||||
case 'archive':
|
case 'archive':
|
||||||
parts.push('archived:true');
|
parts.push('archived:true');
|
||||||
break;
|
break;
|
||||||
|
case 'hidden':
|
||||||
|
// Auto-hidden by the indexer (broken files, very low quality
|
||||||
|
// score). Excluded from every other view — this section is the
|
||||||
|
// only way to see them without a manual `q=hidden:true`.
|
||||||
|
parts.push('hidden:true');
|
||||||
|
break;
|
||||||
case 'heap':
|
case 'heap':
|
||||||
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
||||||
break;
|
break;
|
||||||
@@ -75,7 +93,13 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
|
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
|
||||||
|
// express "exact root match" (path:"" / path:/ both fall back to "no
|
||||||
|
// filter"), so we leave the server query unfiltered and let the
|
||||||
|
// timeline post-filter to `Path === ''` client-side.
|
||||||
|
if (f.folderPath && f.folderPath !== '/') {
|
||||||
|
parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
|
||||||
|
}
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
@@ -84,13 +108,24 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||||
const sectionRaw = params.get('section') as Section | null;
|
const sectionRaw = params.get('section') as Section | null;
|
||||||
const section: Section =
|
const section: Section =
|
||||||
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
|
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||||
? sectionRaw
|
? sectionRaw
|
||||||
: 'all-photos';
|
: 'all-photos';
|
||||||
|
// Bare URL (no section/folder/heap/q params) lands on the root folder
|
||||||
|
// — same default the store carries. Any explicit param means the user
|
||||||
|
// asked for a specific view, so the folder filter clears unless
|
||||||
|
// `folder=` is supplied on top.
|
||||||
|
const bare =
|
||||||
|
!params.has('section') &&
|
||||||
|
!params.has('folder') &&
|
||||||
|
!params.has('heap') &&
|
||||||
|
!params.has('q');
|
||||||
|
const folderRaw = params.get('folder');
|
||||||
|
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
heapUid: params.get('heap'),
|
heapUid: params.get('heap'),
|
||||||
folderPath: params.get('folder'),
|
folderPath,
|
||||||
search: params.get('q') ?? ''
|
search: params.get('q') ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,37 @@ export interface PpClientConfig {
|
|||||||
previewToken: string;
|
previewToken: string;
|
||||||
downloadToken: string;
|
downloadToken: string;
|
||||||
flags?: string;
|
flags?: string;
|
||||||
|
/**
|
||||||
|
* Precomputed library counters. PhotoPrism updates these incrementally
|
||||||
|
* on every mutation, so they're cheap to read and accurate without a
|
||||||
|
* separate aggregate query. `all` already nets out review/hidden, which
|
||||||
|
* is what the timeline shows — prefer it over `photos + videos`.
|
||||||
|
*/
|
||||||
count?: {
|
count?: {
|
||||||
|
all?: number;
|
||||||
photos?: number;
|
photos?: number;
|
||||||
videos?: number;
|
videos?: number;
|
||||||
|
live?: number;
|
||||||
|
animated?: number;
|
||||||
|
audio?: number;
|
||||||
|
documents?: number;
|
||||||
|
archived?: number;
|
||||||
|
hidden?: number;
|
||||||
|
favorites?: number;
|
||||||
|
review?: number;
|
||||||
|
private?: number;
|
||||||
albums?: number;
|
albums?: number;
|
||||||
labels?: number;
|
moments?: number;
|
||||||
|
months?: number;
|
||||||
|
states?: number;
|
||||||
|
folders?: number;
|
||||||
|
files?: number;
|
||||||
people?: number;
|
people?: number;
|
||||||
|
places?: number;
|
||||||
|
labels?: number;
|
||||||
|
cameras?: number;
|
||||||
|
lenses?: number;
|
||||||
|
countries?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +75,10 @@ export interface PpPhoto {
|
|||||||
Hash?: string;
|
Hash?: string;
|
||||||
/** Auto-derived display name (no extension). */
|
/** Auto-derived display name (no extension). */
|
||||||
Name?: string;
|
Name?: string;
|
||||||
|
/** Originals-relative folder this photo lives in. Empty string for
|
||||||
|
* photos directly under the originals root; otherwise the folder
|
||||||
|
* path (e.g. `2024/lyon`). Populated on list responses. */
|
||||||
|
Path?: string;
|
||||||
/** PhotoPrism filename + extension, populated on list responses. */
|
/** PhotoPrism filename + extension, populated on list responses. */
|
||||||
FileName?: string;
|
FileName?: string;
|
||||||
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
setFocused,
|
setFocused,
|
||||||
setOrder
|
setOrder
|
||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { openPreview } from '$lib/stores/preview.svelte';
|
import { openPreview, preview } from '$lib/stores/preview.svelte';
|
||||||
import {
|
import {
|
||||||
setRightSidebarWidth,
|
setRightSidebarWidth,
|
||||||
setThumbnailSize,
|
setThumbnailSize,
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
const next = parseUrlParams(page.url.searchParams);
|
const next = parseUrlParams(page.url.searchParams);
|
||||||
if (next.section !== undefined) filters.section = next.section;
|
if (next.section !== undefined) filters.section = next.section;
|
||||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||||
|
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||||
if (next.search !== undefined) filters.search = next.search;
|
if (next.search !== undefined) filters.search = next.search;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,13 +91,25 @@
|
|||||||
switch (filters.section) {
|
switch (filters.section) {
|
||||||
case 'favorites':
|
case 'favorites':
|
||||||
return 'Favorites';
|
return 'Favorites';
|
||||||
|
case 'review':
|
||||||
|
return 'Review';
|
||||||
case 'archive':
|
case 'archive':
|
||||||
return 'Archive';
|
return 'Archive';
|
||||||
|
case 'hidden':
|
||||||
|
return 'Hidden';
|
||||||
case 'heap': {
|
case 'heap': {
|
||||||
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
||||||
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
// 'all-photos' is the internal "no section filter" state —
|
||||||
|
// the visible context now comes from the folder filter
|
||||||
|
// (root by default). Show the folder path so the title
|
||||||
|
// reflects what's actually on screen; only the rare
|
||||||
|
// `folderPath === null` case (e.g. right after deleting a
|
||||||
|
// heap) still reads as "All photos".
|
||||||
|
if (filters.folderPath === '/') return 'Folder · /';
|
||||||
|
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
|
||||||
return 'All photos';
|
return 'All photos';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +146,15 @@
|
|||||||
* pages can repeat a photo when its file-row span straddles the offset
|
* pages can repeat a photo when its file-row span straddles the offset
|
||||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||||
* handlers) treat this as the single source of truth. */
|
* handlers) treat this as the single source of truth.
|
||||||
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
*
|
||||||
|
* When the user picks the root entry in the folder tree we filter
|
||||||
|
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||||||
|
* express that match, so the query fetches the whole library and
|
||||||
|
* we strip subfolder rows post-hoc. */
|
||||||
|
const photos = $derived<PpPhoto[]>(
|
||||||
|
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
|
||||||
|
);
|
||||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||||
if (!pages) return [];
|
if (!pages) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -148,6 +168,20 @@
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
// Root-folder scope is the only client-side filter we apply, and only
|
||||||
|
// when the timeline is actually showing a folder view — never when the
|
||||||
|
// user is in a heap, has a free-form search, or is on a non-default
|
||||||
|
// section (favorites / archive / review / hidden). Those views are
|
||||||
|
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||||
|
// or labels / search will silently drop subfolder photos when the
|
||||||
|
// store hasn't fully hydrated from the URL yet.
|
||||||
|
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
|
||||||
|
if (f.folderPath !== '/') return list;
|
||||||
|
if (f.section !== 'all-photos') return list;
|
||||||
|
if (f.heapUid) return list;
|
||||||
|
if (f.search) return list;
|
||||||
|
return list.filter((p) => !p.Path);
|
||||||
|
}
|
||||||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -331,6 +365,25 @@
|
|||||||
if (el) scrollTileIntoView(el);
|
if (el) scrollTileIntoView(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the preview overlay closes, scroll the just-shown photo back
|
||||||
|
// into the timeline window. PreviewOverlay already keeps
|
||||||
|
// selection.focused in lockstep with preview.uid, so we just need to
|
||||||
|
// make sure that tile is mounted (forcedExpand) and visible — the
|
||||||
|
// blue ring renders itself once the inner button is in the DOM.
|
||||||
|
let wasPreviewOpen = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
const open = preview.uid !== null;
|
||||||
|
const closing = wasPreviewOpen && !open;
|
||||||
|
wasPreviewOpen = open;
|
||||||
|
if (!closing) return;
|
||||||
|
const uid = selection.focused;
|
||||||
|
if (!uid) return;
|
||||||
|
untrack(() => {
|
||||||
|
const i = photos.findIndex((p) => p.UID === uid);
|
||||||
|
if (i >= 0) void scrollToIndex(i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||||
// The CSS Grid lays each photo into a cell with column count derived from
|
// The CSS Grid lays each photo into a cell with column count derived from
|
||||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||||
@@ -557,17 +610,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTileClick(e: MouseEvent, uid: string) {
|
function onTileClick(e: MouseEvent, uid: string) {
|
||||||
|
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
|
||||||
|
// document-level click handler — let them bubble.
|
||||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
if (selection.ids.size > 0) return;
|
// Plain click: select this tile only. Replaces the previous
|
||||||
// Establish the "starting photo" so a subsequent shift-click extends
|
// "click opens preview" semantics — preview now lives on dblclick.
|
||||||
// the range from this tile. Reset both focus and anchor — anchor on
|
// Reset both focus and anchor so a subsequent shift-click extends
|
||||||
// its own would stick to an older toggle/selectOnly tile and the
|
// the range from this tile, and clear sticky-column intent so
|
||||||
// shift-range would silently use the wrong starting point. Also
|
// arrow nav re-anchors off this tile's actual column.
|
||||||
// clear the sticky-column intent so the next arrow press anchors
|
selection.ids.clear();
|
||||||
// off the clicked tile's actual column.
|
selection.ids.add(uid);
|
||||||
setFocused(uid);
|
setFocused(uid);
|
||||||
setAnchor(uid);
|
setAnchor(uid);
|
||||||
intendedCol = null;
|
intendedCol = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTileDblclick(e: MouseEvent, uid: string) {
|
||||||
|
// Modifier-modified dblclicks shouldn't open the preview either —
|
||||||
|
// gridKeyNav already handled the underlying click.
|
||||||
|
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
openPreview(uid, photos.map((p) => p.UID));
|
openPreview(uid, photos.map((p) => p.UID));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +719,12 @@
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!--
|
||||||
|
Main column wraps the scrollable timeline and the action bar so
|
||||||
|
the bar's width matches the timeline only — the right aside is a
|
||||||
|
sibling at row level and stays full height when the bar appears.
|
||||||
|
-->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<main
|
<main
|
||||||
bind:this={scrollRoot}
|
bind:this={scrollRoot}
|
||||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||||
@@ -684,6 +752,13 @@
|
|||||||
Archive is empty.
|
Archive is empty.
|
||||||
{:else if filters.section === 'favorites'}
|
{:else if filters.section === 'favorites'}
|
||||||
No favorites yet. Heart a photo to add it here.
|
No favorites yet. Heart a photo to add it here.
|
||||||
|
{:else if filters.section === 'review'}
|
||||||
|
Nothing left to review. Photos PhotoPrism's 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'}
|
||||||
|
No hidden photos. PhotoPrism auto-hides files it can't index (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 bulk bar's
|
This heap has no photos yet. Select some photos and use the bulk bar's
|
||||||
"+ Add to heap" button.
|
"+ Add to heap" button.
|
||||||
@@ -742,6 +817,7 @@
|
|||||||
data-tile
|
data-tile
|
||||||
data-uid={photo.UID}
|
data-uid={photo.UID}
|
||||||
onclick={(e) => onTileClick(e, photo.UID)}
|
onclick={(e) => onTileClick(e, photo.UID)}
|
||||||
|
ondblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||||
class:scale-90={sel}
|
class:scale-90={sel}
|
||||||
class:ring-2={sel}
|
class:ring-2={sel}
|
||||||
class:ring-blue-500={sel}
|
class:ring-blue-500={sel}
|
||||||
@@ -804,6 +880,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<BulkActionBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !view.rightSidebarCollapsed}
|
{#if !view.rightSidebarCollapsed}
|
||||||
<aside
|
<aside
|
||||||
@@ -850,5 +928,3 @@
|
|||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
type PhotoMarksMap
|
type PhotoMarksMap
|
||||||
} 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 { openPreview } from '$lib/stores/preview.svelte';
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
// PhotoPrism's Color is auto-derived from image content — the user-set
|
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||||
@@ -110,7 +113,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
<main
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
|
use:gridKeyNav={{}}
|
||||||
|
>
|
||||||
{#if marksQuery.isPending || photosQuery.isPending}
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||||
{:else if marksQuery.isError || photosQuery.isError}
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
@@ -121,34 +127,11 @@
|
|||||||
sidebar to tag it.
|
sidebar to tag it.
|
||||||
</p>
|
</p>
|
||||||
{:else if selectedGroup}
|
{:else if selectedGroup}
|
||||||
<div
|
<PhotoGrid photos={selectedGroup.photos} />
|
||||||
class="grid gap-2"
|
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
|
||||||
>
|
|
||||||
{#each selectedGroup.photos as photo (photo.UID)}
|
|
||||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() =>
|
|
||||||
openPreview(
|
|
||||||
photo.UID,
|
|
||||||
selectedGroup.photos.map((p) => p.UID)
|
|
||||||
)}
|
|
||||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(hash, 'tile_500')}
|
|
||||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
|
||||||
loading="lazy"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-2"
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
>
|
>
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
{@const rep = group.photos[0]}
|
{@const rep = group.photos[0]}
|
||||||
@@ -178,3 +161,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
130
web/src/routes/inbox/+page.svelte
Normal file
130
web/src/routes/inbox/+page.svelte
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import {
|
||||||
|
cancelImport,
|
||||||
|
getImportInfo,
|
||||||
|
startImport,
|
||||||
|
type ImportInfo
|
||||||
|
} from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
|
||||||
|
// (separate from /originals which is what the timeline reads). The
|
||||||
|
// folders endpoint returns the staging tree + counts via headers;
|
||||||
|
// kicking off the import is a single POST to /import. Mutations land
|
||||||
|
// in originals after PhotoPrism finishes processing — invalidate the
|
||||||
|
// originals/folders/config caches so the rest of the UI catches up.
|
||||||
|
const importQuery = createQuery<ImportInfo>(() => ({
|
||||||
|
queryKey: ['import'],
|
||||||
|
queryFn: getImportInfo,
|
||||||
|
enabled: isAuthenticated(),
|
||||||
|
// Refetch every 5 s while the page is open so progress is visible
|
||||||
|
// without the user having to refresh. Cheap call — just headers
|
||||||
|
// and a folder list.
|
||||||
|
refetchInterval: 5_000
|
||||||
|
}));
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const importMut = createMutation(() => ({
|
||||||
|
// `move: true` is the typical workflow — once a file is indexed
|
||||||
|
// into originals it doesn't need to linger in the staging area.
|
||||||
|
mutationFn: () => startImport({ move: true }),
|
||||||
|
onSuccess: (r) => {
|
||||||
|
toast.success(r.message ?? 'Import started');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cancelMut = createMutation(() => ({
|
||||||
|
mutationFn: cancelImport,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.message('Import cancelled');
|
||||||
|
void qc.invalidateQueries({ queryKey: ['import'] });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fileCount = $derived(importQuery.data?.files ?? 0);
|
||||||
|
const folderCount = $derived(importQuery.data?.folders ?? 0);
|
||||||
|
const empty = $derived(fileCount === 0 && folderCount === 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Toolbar>
|
||||||
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
Inbox
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
|
||||||
|
? ''
|
||||||
|
: 's'}
|
||||||
|
</span>
|
||||||
|
{#snippet trailing()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||||
|
disabled={empty || importMut.isPending}
|
||||||
|
onclick={() => importMut.mutate()}
|
||||||
|
title="Index files from the inbox into the main library"
|
||||||
|
>
|
||||||
|
{importMut.isPending ? 'Importing…' : 'Start import'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={!importMut.isPending}
|
||||||
|
onclick={() => cancelMut.mutate()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{#if importQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading inbox…</p>
|
||||||
|
{:else if importQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Failed to read inbox: {importQuery.error instanceof Error
|
||||||
|
? importQuery.error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
</p>
|
||||||
|
{:else if empty}
|
||||||
|
<div class="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>The inbox is empty.</p>
|
||||||
|
<p>
|
||||||
|
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
|
||||||
|
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
|
||||||
|
and they'll show up here. Click <strong>Start import</strong> to move them into the
|
||||||
|
main library; PhotoPrism indexes them, deduplicates against existing originals, and
|
||||||
|
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
|
||||||
|
? ''
|
||||||
|
: 's'}.
|
||||||
|
</p>
|
||||||
|
{#if importQuery.data && importQuery.data.subfolders.length > 0}
|
||||||
|
<!-- PhotoPrism doesn't surface a per-folder file count for
|
||||||
|
/import; we just list the staging subfolders so the
|
||||||
|
user has a sense of what's in there. -->
|
||||||
|
<ul class="space-y-1 text-[12px]">
|
||||||
|
{#each importQuery.data.subfolders as f (f.Path)}
|
||||||
|
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
|
||||||
|
<span class="truncate font-mono">{f.Path || '/'}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
@@ -6,8 +6,11 @@
|
|||||||
type PhotoMarksMap
|
type PhotoMarksMap
|
||||||
} 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 { openPreview } from '$lib/stores/preview.svelte';
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||||
@@ -102,7 +105,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
<main
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
|
use:gridKeyNav={{}}
|
||||||
|
>
|
||||||
{#if marksQuery.isPending || photosQuery.isPending}
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||||
{:else if marksQuery.isError || photosQuery.isError}
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
@@ -113,34 +119,11 @@
|
|||||||
(or 1–5 in bulk mode) to rate it.
|
(or 1–5 in bulk mode) to rate it.
|
||||||
</p>
|
</p>
|
||||||
{:else if selectedGroup}
|
{:else if selectedGroup}
|
||||||
<div
|
<PhotoGrid photos={selectedGroup.photos} />
|
||||||
class="grid gap-2"
|
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
|
||||||
>
|
|
||||||
{#each selectedGroup.photos as photo (photo.UID)}
|
|
||||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() =>
|
|
||||||
openPreview(
|
|
||||||
photo.UID,
|
|
||||||
selectedGroup.photos.map((p) => p.UID)
|
|
||||||
)}
|
|
||||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={thumbUrl(hash, 'tile_500')}
|
|
||||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
|
||||||
loading="lazy"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-2"
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
>
|
>
|
||||||
{#each groups as group (group.rating)}
|
{#each groups as group (group.rating)}
|
||||||
{@const rep = group.photos[0]}
|
{@const rep = group.photos[0]}
|
||||||
@@ -169,3 +152,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { listLabels, type PpLabel } from '$lib/services/photoprism';
|
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { view } from '$lib/stores/view.svelte';
|
||||||
|
import { type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||||
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
|
// Mirror /colors: a label grid that drills into a photo grid in place,
|
||||||
|
// instead of navigating away to the timeline. Stays inside /tags so
|
||||||
|
// the user keeps their place when they back out.
|
||||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||||
queryKey: ['labels'],
|
queryKey: ['labels'],
|
||||||
queryFn: listLabels,
|
queryFn: listLabels,
|
||||||
enabled: isAuthenticated()
|
enabled: isAuthenticated()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function openLabel(slug: string) {
|
let selectedSlug = $state<string | null>(null);
|
||||||
// Tags drive search — clicking jumps to the timeline with the label
|
const selectedLabel = $derived(
|
||||||
// term applied. Bookmarkable URL via the existing filter sync.
|
selectedSlug !== null
|
||||||
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
|
? (labelsQuery.data ?? []).find(
|
||||||
|
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
|
||||||
|
) ?? null
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Photo pool for the selected label. PhotoPrism's q-DSL filters
|
||||||
|
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
|
||||||
|
// page reactive without paginating in place.
|
||||||
|
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||||
|
queryKey: ['photos', 'label', selectedSlug ?? ''],
|
||||||
|
queryFn: () =>
|
||||||
|
listPhotos({
|
||||||
|
q: `label:${selectedSlug}`,
|
||||||
|
count: 1000,
|
||||||
|
order: 'newest',
|
||||||
|
merged: true
|
||||||
|
}),
|
||||||
|
enabled: isAuthenticated() && Boolean(selectedSlug)
|
||||||
|
}));
|
||||||
|
|
||||||
|
function pickLabel(slug: string) {
|
||||||
|
selectedSlug = slug;
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
selectedSlug = null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,6 +54,24 @@
|
|||||||
<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">
|
||||||
Tags
|
Tags
|
||||||
</span>
|
</span>
|
||||||
|
{#if selectedLabel}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||||
|
onclick={clearSelection}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||||
|
{selectedLabel.Name}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
|
||||||
|
.data?.length ?? 0) === 1
|
||||||
|
? ''
|
||||||
|
: 's'}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="text-[11px] text-muted-foreground">
|
||||||
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||||
@@ -29,7 +79,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
<main
|
||||||
|
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||||
|
use:gridKeyNav={{}}
|
||||||
|
>
|
||||||
{#if labelsQuery.isPending}
|
{#if labelsQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||||
{:else if labelsQuery.isError}
|
{:else if labelsQuery.isError}
|
||||||
@@ -39,13 +92,26 @@
|
|||||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
||||||
indexer hasn't run on real photos yet, the list will be empty.
|
indexer hasn't run on real photos yet, the list will be empty.
|
||||||
</p>
|
</p>
|
||||||
|
{:else if selectedLabel}
|
||||||
|
{#if labelPhotosQuery.isPending}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading photos…</p>
|
||||||
|
{:else if labelPhotosQuery.isError}
|
||||||
|
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
|
||||||
|
{:else if (labelPhotosQuery.data ?? []).length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
|
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
{#each labelsQuery.data ?? [] as label (label.UID)}
|
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||||
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
|
onclick={() => pickLabel(label.CustomSlug ?? label.Slug)}
|
||||||
>
|
>
|
||||||
{#if label.Thumb}
|
{#if label.Thumb}
|
||||||
<img
|
<img
|
||||||
@@ -66,3 +132,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
Reference in New Issue
Block a user