Compare commits
5 Commits
312a4c1ee4
...
0f65bfb94a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f65bfb94a | |||
| 9ba8d625bc | |||
| 4f04c1f7b0 | |||
| 9ef1b4c2f9 | |||
| ac7d0ac2eb |
29
README.md
29
README.md
@@ -102,6 +102,35 @@ labels still work; the following sidecar endpoints return an OS error:
|
|||||||
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
||||||
`PP_READONLY` and gates its own backwrite / import paths.
|
`PP_READONLY` and gates its own backwrite / import paths.
|
||||||
|
|
||||||
|
## Mobile & third-party apps (per-user)
|
||||||
|
|
||||||
|
PhotoPrism CE does **not** enforce `auth_users.base_path` on API reads —
|
||||||
|
any authenticated user can search the whole library. The sidecar
|
||||||
|
therefore ships a scoping proxy at `/api/v1/*` (see
|
||||||
|
[`sidecar/handlers_ppproxy.go`](sidecar/handlers_ppproxy.go)) and the
|
||||||
|
reverse proxy routes the public `/api/v1` there instead of straight to
|
||||||
|
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
|
||||||
|
sees only the logged-in user's photos.
|
||||||
|
|
||||||
|
- **Server URL for apps**: the site itself (e.g.
|
||||||
|
`https://photos.hubris.network`). Known-good client:
|
||||||
|
[Gallery for PhotoPrism](https://github.com/Radiokot/photoprism-android-client)
|
||||||
|
(Android/F-Droid).
|
||||||
|
- **Login**: the user's normal username/password. For OIDC accounts (no
|
||||||
|
password), mint an app password:
|
||||||
|
`docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>`
|
||||||
|
and use it as the password in the app.
|
||||||
|
- **What's scoped**: photo/geo searches, per-photo reads and edits,
|
||||||
|
batch operations, downloads by UID. Hash-addressed media (thumbnails,
|
||||||
|
video streams, file downloads) is token-guarded and passes through.
|
||||||
|
- **What's shared** (CE has no per-user variants of these): album
|
||||||
|
*names*, labels, and people — the photos inside them stay scoped.
|
||||||
|
Album zip downloads are generated by PhotoPrism and are not scoped.
|
||||||
|
- **Uploads**: the reconciler mirrors `base_path` into `upload_path`,
|
||||||
|
so WebDAV/app uploads land inside the user's own subtree.
|
||||||
|
- Sessions with the `admin` role bypass the proxy scoping entirely (the
|
||||||
|
web client's settings/users/index dialogs need the raw API).
|
||||||
|
|
||||||
## Dev iteration loop
|
## Dev iteration loop
|
||||||
|
|
||||||
For fast iteration on the sidecar without rebuilding its image on every
|
For fast iteration on the sidecar without rebuilding its image on every
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
|
bulkSetMarks,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
|
type PhotoMark,
|
||||||
|
type PhotoMarksMap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -36,7 +39,14 @@ import {
|
|||||||
setDetail,
|
setDetail,
|
||||||
markRemoved
|
markRemoved
|
||||||
} from '$lib/stores/bulkAction.svelte';
|
} from '$lib/stores/bulkAction.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import {
|
||||||
|
closeShortcuts,
|
||||||
|
openPreview,
|
||||||
|
toggleLeftSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
toggleShortcuts,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
@@ -68,8 +78,8 @@ export interface GridKeyNavParams {
|
|||||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||||
* ⌘A selects all visible.
|
* ⌘A selects all visible.
|
||||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
* 0–5 rating, 6–9 Lightroom color labels, / focuses search,
|
||||||
* keyboard shortcuts.
|
* ? opens the shortcut reference overlay.
|
||||||
*
|
*
|
||||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||||
* 1. multi-selection set
|
* 1. multi-selection set
|
||||||
@@ -377,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
|
||||||
|
// Bare 0–5 set the rating (0 clears; re-keying the current value also
|
||||||
|
// clears, matching the sidebar's click-to-toggle). 6–9 toggle the four
|
||||||
|
// Lightroom color labels. Multi-selection stamps the whole set.
|
||||||
|
const COLOR_KEYS: Record<string, string> = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' };
|
||||||
|
|
||||||
|
async function markCullTargets(patch: PhotoMark, label: string) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to mark', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Optimistic cache patch — the tile badges and facet panels read
|
||||||
|
// ['marks'], so stamping it up front makes the keystroke feel instant.
|
||||||
|
const prevMap = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
const next: PhotoMarksMap = { ...prevMap };
|
||||||
|
for (const id of ids) {
|
||||||
|
const merged: PhotoMark = { ...next[id], ...patch };
|
||||||
|
if (!merged.rating) delete merged.rating;
|
||||||
|
if (!merged.color) delete merged.color;
|
||||||
|
next[id] = merged;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(['marks'], next);
|
||||||
|
try {
|
||||||
|
await bulkSetMarks(ids, patch);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
|
toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`);
|
||||||
|
} catch (err) {
|
||||||
|
queryClient.setQueryData(['marks'], prevMap);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Mark failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingOfFirstTarget(): number {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return 0;
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.rating ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorOfFirstTarget(): string {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return '';
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.color ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function addCullTargetsToActiveHeap() {
|
async function addCullTargetsToActiveHeap() {
|
||||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||||
toast.message('Press S then 1–9 to pick a heap');
|
toast.message('Press S then 1–9 to pick a heap');
|
||||||
@@ -396,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
|
||||||
|
// Shortcuts overlay: Esc or ? closes it; every other key is inert
|
||||||
|
// while it's up so the reference card can't trigger the actions it
|
||||||
|
// documents.
|
||||||
|
if (view.shortcutsOpen) {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeShortcuts();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Modal owns arrow / Escape / Space while it's open — it handles
|
// Modal owns arrow / Escape / Space while it's open — it handles
|
||||||
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
||||||
// keys (X/S/U/A/Z) still pass through because they target the
|
// keys (X/S/U/A/Z) still pass through because they target the
|
||||||
@@ -431,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const meta = e.metaKey || e.ctrlKey;
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
|
|
||||||
|
// Bare digits: rating (0–5, re-key toggles off) and Lightroom color
|
||||||
|
// labels (6–9). Runs after the S-chord so "s 3" still files to heap 3.
|
||||||
|
if (!meta && !shift && /^[0-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (n <= 5) {
|
||||||
|
const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n;
|
||||||
|
void markCullTargets({ rating: value }, value ? `Rated ${value}★` : 'Rating cleared');
|
||||||
|
} else {
|
||||||
|
const color = COLOR_KEYS[e.key];
|
||||||
|
const value = colorOfFirstTarget() === color ? '' : color;
|
||||||
|
void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Space on a focused tile opens the full-screen preview modal.
|
// Space on a focused tile opens the full-screen preview modal.
|
||||||
// Matches the dblclick gesture so the user has both keyboard and
|
// Matches the dblclick gesture so the user has both keyboard and
|
||||||
// mouse paths to the same surface. `e.code === 'Space'` covers
|
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||||||
@@ -477,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
setFocused(null);
|
setFocused(null);
|
||||||
return;
|
return;
|
||||||
|
case '/':
|
||||||
|
// Jump to the search box (any page that renders one tags it
|
||||||
|
// with data-search-input).
|
||||||
|
if (meta) return;
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
|
||||||
|
return;
|
||||||
|
case '?':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcuts();
|
||||||
|
return;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||||
// Browsers reserve Tab for focus traversal — preventDefault
|
// Browsers reserve Tab for focus traversal — preventDefault
|
||||||
@@ -556,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleArchive('restore');
|
void toggleArchive('restore');
|
||||||
return;
|
return;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFavorite(cullTargets());
|
||||||
|
return;
|
||||||
case 'm':
|
case 'm':
|
||||||
case 'M': {
|
case 'M': {
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
|||||||
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!--
|
||||||
|
⌘K command palette. Jump to any section, heap, folder, or tag category,
|
||||||
|
plus a few global actions (dark mode, shortcut overlay). Data comes from
|
||||||
|
the same TanStack queries the sidebar already keeps warm (['heaps'],
|
||||||
|
['folders', …]), so opening the palette costs no extra fetches once the
|
||||||
|
app has booted. bits-ui's Command owns filtering and keyboard selection.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { Command, Dialog } from 'bits-ui';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { toggleMode } from 'mode-watcher';
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
EyeOff,
|
||||||
|
Folder,
|
||||||
|
Image,
|
||||||
|
Keyboard,
|
||||||
|
Layers,
|
||||||
|
ListChecks,
|
||||||
|
Moon,
|
||||||
|
NotebookPen,
|
||||||
|
Tags,
|
||||||
|
Users
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { listFolders, listHeaps, type PpAlbum, type PpFolder } from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
|
||||||
|
import { closePalette, toggleShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
|
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: listHeaps,
|
||||||
|
enabled: isAuthenticated() && view.paletteOpen
|
||||||
|
}));
|
||||||
|
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||||
|
queryKey: ['folders', userLibraryBase()],
|
||||||
|
queryFn: listFolders,
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: isAuthenticated() && view.paletteOpen
|
||||||
|
}));
|
||||||
|
|
||||||
|
function run(fn: () => void) {
|
||||||
|
closePalette();
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = (path: string) => () => run(() => void goto(path));
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
label: string;
|
||||||
|
icon: typeof Image;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: Entry[] = [
|
||||||
|
{ label: 'All photos', icon: Image, action: go('/') },
|
||||||
|
{ label: 'Review queue', icon: ListChecks, action: go('/review') },
|
||||||
|
{ label: 'Archive', icon: Archive, action: go('/?section=archive') },
|
||||||
|
{ label: 'Hidden', icon: EyeOff, action: go('/?section=hidden') },
|
||||||
|
{ label: 'Notes', icon: NotebookPen, action: go('/notes') },
|
||||||
|
{ label: 'Tags', icon: Tags, action: go('/tags/labels') },
|
||||||
|
{ label: 'People', icon: Users, action: go('/tags/people') },
|
||||||
|
{ label: 'Duplicates', icon: Layers, action: go('/review?tab=stacks') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIONS: Entry[] = [
|
||||||
|
{ label: 'Toggle dark mode', icon: Moon, action: () => run(toggleMode) },
|
||||||
|
{ label: 'Keyboard shortcuts', icon: Keyboard, action: () => run(toggleShortcuts) }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Folders can number in the hundreds; the palette lists them all and
|
||||||
|
// lets Command's fuzzy filter narrow. Sorted shallow-first so top-level
|
||||||
|
// folders surface before deep ones on an empty query.
|
||||||
|
const folderEntries = $derived(
|
||||||
|
[...(foldersQuery.data ?? [])]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.Path.split('/').length - b.Path.split('/').length || a.Path.localeCompare(b.Path)
|
||||||
|
)
|
||||||
|
.slice(0, 400)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
open={view.paletteOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) closePalette();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm" />
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-1/2 top-24 z-[81] w-[min(560px,92vw)] -translate-x-1/2 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl"
|
||||||
|
aria-label="Command palette"
|
||||||
|
>
|
||||||
|
<Command.Root class="flex max-h-[60vh] flex-col">
|
||||||
|
<Command.Input
|
||||||
|
class="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Jump to a view, heap, or folder…"
|
||||||
|
/>
|
||||||
|
<Command.List class="overflow-y-auto p-1.5">
|
||||||
|
<Command.Viewport>
|
||||||
|
<Command.Empty class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
No matches.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Go to
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each SECTIONS as s (s.label)}
|
||||||
|
<Command.Item
|
||||||
|
value={s.label}
|
||||||
|
onSelect={s.action}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<s.icon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{s.label}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{#if (heapsQuery.data ?? []).length > 0}
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Heaps
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
|
<Command.Item
|
||||||
|
value={`heap ${heap.Title}`}
|
||||||
|
onSelect={go(`/?section=heap&heap=${heap.UID}`)}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<Layers class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{heap.Title}
|
||||||
|
{#if heap.PhotoCount}
|
||||||
|
<span class="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
{heap.PhotoCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if folderEntries.length > 0}
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Folders
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each folderEntries as folder (folder.Path)}
|
||||||
|
<Command.Item
|
||||||
|
value={`folder ${folder.Path}`}
|
||||||
|
onSelect={go(`/?folder=${encodeURIComponent(folder.Path)}`)}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<Folder class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span class="truncate">{folder.Path}</span>
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each ACTIONS as a (a.label)}
|
||||||
|
<Command.Item
|
||||||
|
value={a.label}
|
||||||
|
onSelect={a.action}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<a.icon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{a.label}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
</Command.Viewport>
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!--
|
||||||
|
Keyboard-shortcut reference overlay, opened with `?` (and the toolbar
|
||||||
|
help affordance). Read-only: gridKeyNav swallows every key except
|
||||||
|
Esc / ? while it's up, so nothing here can fire the actions it lists.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { closeShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
keys: string[];
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
interface Group {
|
||||||
|
title: string;
|
||||||
|
rows: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
title: 'Navigate',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['↑', '↓', '←', '→'], desc: 'Move focus in the grid' },
|
||||||
|
{ keys: ['Shift', '+', 'Arrows'], desc: 'Extend selection' },
|
||||||
|
{ keys: ['Space'], desc: 'Open / close preview' },
|
||||||
|
{ keys: ['Esc'], desc: 'Collapse selection, then clear' },
|
||||||
|
{ keys: ['/'], desc: 'Focus search' },
|
||||||
|
{ keys: ['⌘', 'A'], desc: 'Select all visible' },
|
||||||
|
{ keys: ['⌘', 'K'], desc: 'Command palette' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rate & label',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['1', '…', '5'], desc: 'Set rating (re-key to clear)' },
|
||||||
|
{ keys: ['0'], desc: 'Clear rating' },
|
||||||
|
{ keys: ['6', '7', '8', '9'], desc: 'Color label: red / yellow / green / blue' },
|
||||||
|
{ keys: ['F'], desc: 'Toggle favorite' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Act',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['X'], desc: 'Archive (Delete in Archive view)' },
|
||||||
|
{ keys: ['U'], desc: 'Restore from archive' },
|
||||||
|
{ keys: ['S'], desc: 'Keep (review) · add to heap' },
|
||||||
|
{ keys: ['S', 'then', '1–9'], desc: 'Add to heap N' },
|
||||||
|
{ keys: ['A'], desc: 'Accept date & keep (EXIF review)' },
|
||||||
|
{ keys: ['M'], desc: 'Move to folder' },
|
||||||
|
{ keys: ['⌘', 'Z'], desc: 'Undo last action' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Panels',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['B'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['Tab'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['I'], desc: 'Toggle info sidebar' },
|
||||||
|
{ keys: ['?'], desc: 'This overlay' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view.shortcutsOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeShortcuts();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
class="max-h-[85vh] w-[min(680px,92vw)] overflow-y-auto rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={closeShortcuts}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
{#each GROUPS as group (group.title)}
|
||||||
|
<section>
|
||||||
|
<h3 class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<dl class="space-y-1">
|
||||||
|
{#each group.rows as row (row.desc)}
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<dt class="text-muted-foreground">{row.desc}</dt>
|
||||||
|
<dd class="flex shrink-0 items-center gap-0.5">
|
||||||
|
{#each row.keys as k (k)}
|
||||||
|
{#if k === 'then' || k === '+' || k === '…'}
|
||||||
|
<span class="px-0.5 text-[10px] text-muted-foreground">{k}</span>
|
||||||
|
{:else}
|
||||||
|
<kbd
|
||||||
|
class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -101,6 +101,90 @@
|
|||||||
setFocused(next);
|
setFocused(next);
|
||||||
setAnchor(next);
|
setAnchor(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zoom & pan ───────────────────────────────────────────────────────
|
||||||
|
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
|
||||||
|
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
|
||||||
|
// sharp image scale together. Resets on photo change. Past 1.25× the
|
||||||
|
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
|
||||||
|
const MAX_ZOOM = 6;
|
||||||
|
let zoom = $state(1);
|
||||||
|
let tx = $state(0);
|
||||||
|
let ty = $state(0);
|
||||||
|
let zoomHost = $state<HTMLElement | undefined>();
|
||||||
|
let panning = $state(false);
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void uid;
|
||||||
|
zoom = 1;
|
||||||
|
tx = 0;
|
||||||
|
ty = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyZoom(next: number, clientX: number, clientY: number) {
|
||||||
|
if (!zoomHost) return;
|
||||||
|
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
|
||||||
|
if (clamped === zoom) return;
|
||||||
|
// Keep the point under the cursor fixed: translate offsets are in
|
||||||
|
// post-scale pixels around the container centre.
|
||||||
|
const rect = zoomHost.getBoundingClientRect();
|
||||||
|
const cx = clientX - rect.left - rect.width / 2;
|
||||||
|
const cy = clientY - rect.top - rect.height / 2;
|
||||||
|
const s = clamped / zoom;
|
||||||
|
tx = cx + (tx - cx) * s;
|
||||||
|
ty = cy + (ty - cy) * s;
|
||||||
|
zoom = clamped;
|
||||||
|
if (zoom === 1) {
|
||||||
|
tx = 0;
|
||||||
|
ty = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
|
||||||
|
* so the listener is attached manually as non-passive. */
|
||||||
|
function wheelZoom(node: HTMLElement) {
|
||||||
|
node.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('wheel', onWheel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDblClickZoom(e: MouseEvent) {
|
||||||
|
if (zoom > 1) {
|
||||||
|
zoom = 1;
|
||||||
|
tx = 0;
|
||||||
|
ty = 0;
|
||||||
|
} else {
|
||||||
|
applyZoom(2.5, e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (zoom === 1) return;
|
||||||
|
panning = true;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!panning) return;
|
||||||
|
tx += e.clientX - lastX;
|
||||||
|
ty += e.clientY - lastY;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
}
|
||||||
|
function onPointerUp() {
|
||||||
|
panning = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||||
@@ -145,6 +229,27 @@
|
|||||||
photoQuery.data.OriginalName ??
|
photoQuery.data.OriginalName ??
|
||||||
pf.Name ??
|
pf.Name ??
|
||||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
bind:this={zoomHost}
|
||||||
|
use:wheelZoom
|
||||||
|
ondblclick={onDblClickZoom}
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
onpointercancel={onPointerUp}
|
||||||
|
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
|
||||||
|
? panning
|
||||||
|
? 'cursor-grabbing'
|
||||||
|
: 'cursor-grab'
|
||||||
|
: 'cursor-zoom-in'}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative flex h-full w-full items-center justify-center"
|
||||||
|
class:transition-transform={!panning}
|
||||||
|
class:duration-150={!panning}
|
||||||
|
style="transform: translate({tx}px, {ty}px) scale({zoom});"
|
||||||
|
>
|
||||||
{#if pf.Width && pf.Height}
|
{#if pf.Width && pf.Height}
|
||||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||||
the tile_*'s square center-crop against the sharp image's
|
the tile_*'s square center-crop against the sharp image's
|
||||||
@@ -163,12 +268,22 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
draggable="false"
|
||||||
|
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{#if zoom > 1}
|
||||||
|
<span
|
||||||
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
|
||||||
|
>
|
||||||
|
{Math.round(zoom * 100)}% · double-click to reset
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
Aperture,
|
Aperture,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Copy,
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Heart,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -37,12 +39,14 @@
|
|||||||
type UpdatePhotoBody
|
type UpdatePhotoBody
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { invalidateFacets } from '$lib/services/bulk';
|
import { invalidateFacets } from '$lib/services/bulk';
|
||||||
|
import { toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
|
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
import { countryName } from '$lib/utils/countries';
|
import { countryName } from '$lib/utils/countries';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
@@ -307,6 +311,36 @@
|
|||||||
const joined = `${make} ${model}`.trim();
|
const joined = `${make} ${model}`.trim();
|
||||||
return joined && joined !== 'Unknown' ? joined : '';
|
return joined && joined !== 'Unknown' ? joined : '';
|
||||||
}
|
}
|
||||||
|
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
|
||||||
|
* duplicated here since that helper isn't exported. */
|
||||||
|
function quoteTerm(v: string): string {
|
||||||
|
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
|
||||||
|
* q-DSL escape hatch from the toolbar search box, triggered by click
|
||||||
|
* instead of typing. */
|
||||||
|
async function jumpToSearch(term: string): Promise<void> {
|
||||||
|
setSection('all-photos');
|
||||||
|
setSearch(term);
|
||||||
|
await goto('/', { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
async function copyExif(): Promise<void> {
|
||||||
|
const lines = [
|
||||||
|
cameraStr && `Camera: ${cameraStr}`,
|
||||||
|
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
|
||||||
|
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
|
||||||
|
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
|
||||||
|
exposureParts.iso && exposureParts.iso,
|
||||||
|
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
|
||||||
|
photo.TakenAt && `Taken: ${photo.TakenAt}`
|
||||||
|
].filter(Boolean);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
toast.message('No EXIF to copy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(lines.join('\n'));
|
||||||
|
toast.success('EXIF copied');
|
||||||
|
}
|
||||||
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
||||||
return {
|
return {
|
||||||
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
||||||
@@ -501,6 +535,19 @@
|
|||||||
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 p-0.5 transition-colors {photo.Favorite
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => void toggleFavorite([photo.UID])}
|
||||||
|
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
|
||||||
|
aria-pressed={photo.Favorite ?? false}
|
||||||
|
aria-label="Favorite"
|
||||||
|
>
|
||||||
|
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -641,20 +688,51 @@
|
|||||||
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<ImageIcon class="h-3 w-3" /> File
|
<ImageIcon class="h-3 w-3" /> File
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="normal-case text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void copyExif();
|
||||||
|
}}
|
||||||
|
title="Copy EXIF summary"
|
||||||
|
aria-label="Copy EXIF summary"
|
||||||
|
>
|
||||||
|
<Copy class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</summary>
|
</summary>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||||
{#if cameraStr}
|
{#if cameraStr}
|
||||||
<dt class="text-muted-foreground">Camera</dt>
|
<dt class="text-muted-foreground">Camera</dt>
|
||||||
<dd class="text-foreground/80">{cameraStr}</dd>
|
<dd class="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||||
|
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
|
||||||
|
title={`View other photos taken with ${cameraStr}`}
|
||||||
|
>
|
||||||
|
{cameraStr}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if lensStr && lensStr !== cameraStr}
|
{#if lensStr && lensStr !== cameraStr}
|
||||||
<dt class="text-muted-foreground">Lens</dt>
|
<dt class="text-muted-foreground">Lens</dt>
|
||||||
<dd class="text-foreground/80">{lensStr}</dd>
|
<dd class="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||||
|
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
|
||||||
|
title={`View other photos taken with ${lensStr}`}
|
||||||
|
>
|
||||||
|
{lensStr}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||||
<dt class="text-muted-foreground">Exposure</dt>
|
<dt class="text-muted-foreground">Exposure</dt>
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
import { view } from "$lib/stores/view.svelte";
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||||
|
import { toggleFavorite } from "$lib/services/photoActions";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { Loader2, Check, X } from "lucide-svelte";
|
import { Loader2, Check, Heart, X } from "lucide-svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
@@ -191,4 +192,28 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
Favorite heart — a *sibling* of the tile button (nested buttons are
|
||||||
|
invalid HTML and break click semantics). Filled + always visible when
|
||||||
|
favorited; otherwise fades in on hover. Mirrors the `f` shortcut.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute bottom-1.5 right-1.5 z-10 rounded-full bg-background/70 p-1 backdrop-blur transition-opacity {photo.Favorite
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100'}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleFavorite([photo.UID]);
|
||||||
|
}}
|
||||||
|
ondblclick={(e) => e.stopPropagation()}
|
||||||
|
title={photo.Favorite ? "Remove from favorites (f)" : "Add to favorites (f)"}
|
||||||
|
aria-pressed={photo.Favorite ?? false}
|
||||||
|
aria-label="Favorite"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
class="h-3.5 w-3.5 {photo.Favorite ? 'text-red-500' : 'text-foreground/80'}"
|
||||||
|
fill={photo.Favorite ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: 1
|
retry: 1,
|
||||||
|
// The indexer WebSocket (stores/indexer.svelte.ts) already
|
||||||
|
// invalidates ['photos'] and friends on live changes, so a
|
||||||
|
// window-focus refetch only adds a redundant full-timeline
|
||||||
|
// re-render (visible flash) every time the tab regains focus.
|
||||||
|
refetchOnWindowFocus: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
buildTakenAtPatch,
|
buildTakenAtPatch,
|
||||||
|
likePhoto,
|
||||||
|
unlikePhoto,
|
||||||
updatePhoto
|
updatePhoto
|
||||||
} from './photoprism';
|
} from './photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Patch `Favorite` on every cached copy of the uids (timeline pages,
|
||||||
|
* per-photo detail) so hearts flip instantly without a refetch. */
|
||||||
|
function patchFavoriteCaches(uids: string[], value: boolean): void {
|
||||||
|
const target = new Set(uids);
|
||||||
|
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||||
|
for (const [key, data] of lists) {
|
||||||
|
if (!data) continue;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
key,
|
||||||
|
(data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||||
|
if (!Array.isArray(pages)) continue;
|
||||||
|
queryClient.setQueryData(key, {
|
||||||
|
...(data as object),
|
||||||
|
pages: pages.map((pg) =>
|
||||||
|
pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const uid of uids) {
|
||||||
|
const p = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle PhotoPrism's native favorite flag on a set of photos. Target
|
||||||
|
* state comes from the first uid (mixed selections converge). Optimistic
|
||||||
|
* cache flip with rollback; undo re-toggles.
|
||||||
|
*/
|
||||||
|
export async function toggleFavorite(uids: string[]): Promise<void> {
|
||||||
|
if (uids.length === 0) {
|
||||||
|
toast.message('Nothing to favorite', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = !(cachedPhoto(uids[0])?.Favorite ?? false);
|
||||||
|
patchFavoriteCaches(uids, value);
|
||||||
|
const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id)));
|
||||||
|
if (errors.length) {
|
||||||
|
patchFavoriteCaches(uids, !value);
|
||||||
|
toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
value
|
||||||
|
? uids.length === 1
|
||||||
|
? 'Added to favorites'
|
||||||
|
: `Favorited ${uids.length}`
|
||||||
|
: uids.length === 1
|
||||||
|
? 'Removed from favorites'
|
||||||
|
: `Unfavorited ${uids.length}`
|
||||||
|
);
|
||||||
|
pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => {
|
||||||
|
patchFavoriteCaches(uids, !value);
|
||||||
|
await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1050,6 +1050,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
|
|||||||
}) as Promise<FolderMoveResult>;
|
}) as Promise<FolderMoveResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||||
|
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
|
||||||
|
// PhotoPrism-compatible client app.
|
||||||
|
|
||||||
|
export async function likePhoto(uid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikePhoto(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||||
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||||
// internal fields). We store them in mule-sidecar instead.
|
// internal fields). We store them in mule-sidecar instead.
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
|
||||||
|
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
|
||||||
|
export const SORT_LABELS: Record<SortOrder, string> = {
|
||||||
|
newest: 'Newest first',
|
||||||
|
oldest: 'Oldest first',
|
||||||
|
added: 'Recently added',
|
||||||
|
name: 'File name'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
|
||||||
|
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
|
||||||
|
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
|
||||||
|
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
|
||||||
|
photo: 'Photos',
|
||||||
|
video: 'Videos',
|
||||||
|
raw: 'RAW',
|
||||||
|
live: 'Live'
|
||||||
|
};
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
section: Section;
|
section: Section;
|
||||||
/** Heap UID, used when section === 'heap'. */
|
/** Heap UID, used when section === 'heap'. */
|
||||||
@@ -49,6 +68,14 @@ export interface FilterState {
|
|||||||
folderPath: string | null;
|
folderPath: string | null;
|
||||||
/** Free-form search text, ANDed with section-derived terms. */
|
/** Free-form search text, ANDed with section-derived terms. */
|
||||||
search: string;
|
search: string;
|
||||||
|
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
|
||||||
|
sort: SortOrder;
|
||||||
|
/** Media-type chip; null = any. */
|
||||||
|
mediaType: MediaType | null;
|
||||||
|
/** Year chip; null = any. Compiles to `year:<n>`. */
|
||||||
|
year: number | null;
|
||||||
|
/** Favorites-only chip. Compiles to `favorite:true`. */
|
||||||
|
favorite: boolean;
|
||||||
/**
|
/**
|
||||||
* Active tag-browser category and selected value. Set by the
|
* Active tag-browser category and selected value. Set by the
|
||||||
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
||||||
@@ -68,10 +95,42 @@ export const filters = $state<FilterState>({
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: '/',
|
folderPath: '/',
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: null,
|
tagCategory: null,
|
||||||
tagValue: null
|
tagValue: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function setSort(sort: SortOrder): void {
|
||||||
|
filters.sort = sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaType(t: MediaType | null): void {
|
||||||
|
filters.mediaType = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setYear(y: number | null): void {
|
||||||
|
filters.year = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFavorite(on: boolean): void {
|
||||||
|
filters.favorite = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any toolbar chip narrows the view (excludes sort — a sort
|
||||||
|
* isn't a filter). Drives the "Clear" affordance. */
|
||||||
|
export function chipsActive(f: FilterState = filters): boolean {
|
||||||
|
return f.mediaType !== null || f.year !== null || f.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChips(): void {
|
||||||
|
filters.mediaType = null;
|
||||||
|
filters.year = null;
|
||||||
|
filters.favorite = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setSection(section: Section, heapUid: string | null = null): void {
|
export function setSection(section: Section, heapUid: string | null = null): void {
|
||||||
filters.section = section;
|
filters.section = section;
|
||||||
filters.heapUid = section === 'heap' ? heapUid : null;
|
filters.heapUid = section === 'heap' ? heapUid : null;
|
||||||
@@ -250,7 +309,19 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
// Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
|
||||||
|
// `photo:true`, …) are the documented DSL forms; `year:` and
|
||||||
|
// `favorite:` are plain filters.
|
||||||
|
if (f.mediaType) parts.push(`${f.mediaType}:true`);
|
||||||
|
if (f.year) parts.push(`year:${f.year}`);
|
||||||
|
if (f.favorite) parts.push('favorite:true');
|
||||||
|
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
|
||||||
|
// like `label:dog`, `taken:2024`) as well as plain free text. Only
|
||||||
|
// quote it when it has no `:` — a colon means the user (or a
|
||||||
|
// jump-to-search link) already wrote a structured term, and wrapping
|
||||||
|
// the whole thing in quotes would turn `camera:iPhone` into a literal
|
||||||
|
// phrase search for the text "camera:iPhone" instead of the operator.
|
||||||
|
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
|||||||
!params.has('q');
|
!params.has('q');
|
||||||
const folderRaw = params.get('folder');
|
const folderRaw = params.get('folder');
|
||||||
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||||
|
const sortRaw = params.get('sort');
|
||||||
|
const typeRaw = params.get('type');
|
||||||
|
const yearRaw = params.get('year');
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
heapUid: params.get('heap'),
|
heapUid: params.get('heap'),
|
||||||
folderPath,
|
folderPath,
|
||||||
search: params.get('q') ?? ''
|
search: params.get('q') ?? '',
|
||||||
|
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
|
||||||
|
? (sortRaw as SortOrder)
|
||||||
|
: 'newest',
|
||||||
|
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
|
||||||
|
? (typeRaw as MediaType)
|
||||||
|
: null,
|
||||||
|
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
|
||||||
|
favorite: params.get('fav') === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
|
|||||||
if (f.heapUid) params.set('heap', f.heapUid);
|
if (f.heapUid) params.set('heap', f.heapUid);
|
||||||
if (f.folderPath) params.set('folder', f.folderPath);
|
if (f.folderPath) params.set('folder', f.folderPath);
|
||||||
if (f.search) params.set('q', f.search);
|
if (f.search) params.set('q', f.search);
|
||||||
|
if (f.sort !== 'newest') params.set('sort', f.sort);
|
||||||
|
if (f.mediaType) params.set('type', f.mediaType);
|
||||||
|
if (f.year) params.set('year', String(f.year));
|
||||||
|
if (f.favorite) params.set('fav', '1');
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const view = $state<{
|
|||||||
* persisted — a refresh always returns to the grid.
|
* persisted — a refresh always returns to the grid.
|
||||||
*/
|
*/
|
||||||
previewOpen: boolean;
|
previewOpen: boolean;
|
||||||
|
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
||||||
|
shortcutsOpen: boolean;
|
||||||
|
/** Ephemeral: true while the ⌘K command palette is open. */
|
||||||
|
paletteOpen: boolean;
|
||||||
metadataSections: Record<string, boolean>;
|
metadataSections: Record<string, boolean>;
|
||||||
}>({
|
}>({
|
||||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
@@ -107,6 +111,8 @@ export const view = $state<{
|
|||||||
),
|
),
|
||||||
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
||||||
previewOpen: false,
|
previewOpen: false,
|
||||||
|
shortcutsOpen: false,
|
||||||
|
paletteOpen: false,
|
||||||
metadataSections:
|
metadataSections:
|
||||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||||
? { ...initial.metadataSections }
|
? { ...initial.metadataSections }
|
||||||
@@ -164,6 +170,22 @@ export function togglePreview(): void {
|
|||||||
view.previewOpen = !view.previewOpen;
|
view.previewOpen = !view.previewOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleShortcuts(): void {
|
||||||
|
view.shortcutsOpen = !view.shortcutsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeShortcuts(): void {
|
||||||
|
view.shortcutsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePalette(): void {
|
||||||
|
view.paletteOpen = !view.paletteOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePalette(): void {
|
||||||
|
view.paletteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
view.thumbnailSize = size;
|
view.thumbnailSize = size;
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface PpPhoto {
|
|||||||
Height?: number;
|
Height?: number;
|
||||||
Rating?: number;
|
Rating?: number;
|
||||||
Color?: string | number;
|
Color?: string | number;
|
||||||
|
Favorite?: boolean;
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
Files?: PpFile[];
|
Files?: PpFile[];
|
||||||
Lat?: number;
|
Lat?: number;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||||
|
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
||||||
|
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
|
||||||
|
import { togglePalette } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -58,8 +61,18 @@
|
|||||||
else stopIndexerWatch();
|
else stopIndexerWatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
|
||||||
|
// from any route and even while a form field holds focus.
|
||||||
|
function onGlobalKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKey} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mulimage</title>
|
<title>Mulimage</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -122,6 +135,10 @@
|
|||||||
store. Opened from the heap/folder kebabs, the BulkActionBar
|
store. Opened from the heap/folder kebabs, the BulkActionBar
|
||||||
button, and the `m` shortcut — all through openMove(). -->
|
button, and the `m` shortcut — all through openMove(). -->
|
||||||
<MoveToFolderDialog />
|
<MoveToFolderDialog />
|
||||||
|
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
||||||
|
<ShortcutsDialog />
|
||||||
|
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
|
||||||
|
<CommandPalette />
|
||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,14 +17,26 @@
|
|||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
} from "$lib/services/photoprism";
|
} from "$lib/services/photoprism";
|
||||||
import {
|
import {
|
||||||
|
chipsActive,
|
||||||
|
clearChips,
|
||||||
consumePendingFocus,
|
consumePendingFocus,
|
||||||
filters,
|
filters,
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
filtersToUrlParams,
|
filtersToUrlParams,
|
||||||
|
MEDIA_TYPE_LABELS,
|
||||||
|
MEDIA_TYPES,
|
||||||
parseUrlParams,
|
parseUrlParams,
|
||||||
|
setFavorite,
|
||||||
|
setMediaType,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSection,
|
setSection,
|
||||||
|
setSort,
|
||||||
|
setYear,
|
||||||
|
SORT_LABELS,
|
||||||
|
SORT_ORDERS,
|
||||||
|
type MediaType,
|
||||||
type PendingFocus,
|
type PendingFocus,
|
||||||
|
type SortOrder,
|
||||||
} from "$lib/stores/filters.svelte";
|
} from "$lib/stores/filters.svelte";
|
||||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
@@ -81,6 +93,10 @@
|
|||||||
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.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||||
if (next.search !== undefined) filters.search = next.search;
|
if (next.search !== undefined) filters.search = next.search;
|
||||||
|
if (next.sort !== undefined) filters.sort = next.sort;
|
||||||
|
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
|
||||||
|
if (next.year !== undefined) filters.year = next.year;
|
||||||
|
if (next.favorite !== undefined) filters.favorite = next.favorite;
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the store changes from in-app actions (left-sidebar nav, search
|
// When the store changes from in-app actions (left-sidebar nav, search
|
||||||
@@ -182,15 +198,20 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"q",
|
"q",
|
||||||
filtersToQ(filters),
|
filtersToQ(filters),
|
||||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
{
|
||||||
|
count: PHOTOS_PAGE_SIZE,
|
||||||
|
anchor: anchor?.takenAt ?? null,
|
||||||
|
sort: filters.sort,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
queryFn: ({ pageParam }) => {
|
queryFn: ({ pageParam }) => {
|
||||||
const offset = pageParam as number;
|
const offset = pageParam as number;
|
||||||
const baseQ = filtersToQ(filters);
|
const baseQ = filtersToQ(filters);
|
||||||
// Page 0 + anchor → load a window around the anchor's date.
|
// Page 0 + anchor → load a window around the anchor's date.
|
||||||
// Subsequent pages aren't reachable in anchor mode (see
|
// Subsequent pages aren't reachable in anchor mode (see
|
||||||
// getNextPageParam).
|
// getNextPageParam). Anchor windows assume chronological order,
|
||||||
if (offset === 0 && anchor?.takenAt) {
|
// so any non-default sort falls back to plain paging.
|
||||||
|
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
|
||||||
return listPhotosAround({
|
return listPhotosAround({
|
||||||
q: baseQ,
|
q: baseQ,
|
||||||
takenAt: anchor.takenAt,
|
takenAt: anchor.takenAt,
|
||||||
@@ -203,7 +224,7 @@
|
|||||||
q: baseQ,
|
q: baseQ,
|
||||||
count: PHOTOS_PAGE_SIZE,
|
count: PHOTOS_PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
order: "newest",
|
order: filters.sort,
|
||||||
merged: true,
|
merged: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -222,7 +243,7 @@
|
|||||||
// photos exceeding the page size keep the cursor at the same
|
// photos exceeding the page size keep the cursor at the same
|
||||||
// value). To "see more," the user clears the anchor by
|
// value). To "see more," the user clears the anchor by
|
||||||
// navigating fresh.
|
// navigating fresh.
|
||||||
if (anchor?.takenAt) return undefined;
|
if (anchor?.takenAt && filters.sort === "newest") return undefined;
|
||||||
return pages.length * PHOTOS_PAGE_SIZE;
|
return pages.length * PHOTOS_PAGE_SIZE;
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
@@ -800,6 +821,16 @@
|
|||||||
setSearch(searchDraft.trim());
|
setSearch(searchDraft.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toolbar chips: years from the current year back to 1990 — static
|
||||||
|
// range keeps it dependency-free; PhotoPrism just returns an empty
|
||||||
|
// page for years with no photos.
|
||||||
|
const CHIP_YEARS = Array.from(
|
||||||
|
{ length: new Date().getFullYear() - 1989 },
|
||||||
|
(_, i) => new Date().getFullYear() - i,
|
||||||
|
);
|
||||||
|
const CHIP_SELECT_CLASS =
|
||||||
|
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
|
||||||
|
|
||||||
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||||
const SEARCH_EXAMPLES = [
|
const SEARCH_EXAMPLES = [
|
||||||
@@ -843,6 +874,7 @@
|
|||||||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
data-search-input
|
||||||
placeholder={'Search · label:website / "vacation"'}
|
placeholder={'Search · label:website / "vacation"'}
|
||||||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
bind:value={searchDraft}
|
bind:value={searchDraft}
|
||||||
@@ -898,6 +930,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Filter chips: sort / media type / year / favorites. Compile into
|
||||||
|
the same q-DSL the search box feeds, so they stack with search,
|
||||||
|
folders, and sections. URL-persisted for shareable views. -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.sort}
|
||||||
|
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
|
||||||
|
title="Sort order"
|
||||||
|
aria-label="Sort order"
|
||||||
|
>
|
||||||
|
{#each SORT_ORDERS as s (s)}
|
||||||
|
<option value={s}>{SORT_LABELS[s]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.mediaType ?? ""}
|
||||||
|
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
|
||||||
|
title="Media type"
|
||||||
|
aria-label="Media type"
|
||||||
|
>
|
||||||
|
<option value="">Any type</option>
|
||||||
|
{#each MEDIA_TYPES as t (t)}
|
||||||
|
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.year ? String(filters.year) : ""}
|
||||||
|
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
|
||||||
|
title="Year"
|
||||||
|
aria-label="Year"
|
||||||
|
>
|
||||||
|
<option value="">Any year</option>
|
||||||
|
{#each CHIP_YEARS as y (y)}
|
||||||
|
<option value={String(y)}>{y}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
|
||||||
|
? 'border-red-400 bg-red-500/10 text-red-500'
|
||||||
|
: 'border-input text-muted-foreground hover:bg-accent'}"
|
||||||
|
aria-pressed={filters.favorite}
|
||||||
|
onclick={() => setFavorite(!filters.favorite)}
|
||||||
|
title="Favorites only (f toggles a photo's favorite)"
|
||||||
|
>
|
||||||
|
♥ Favorites
|
||||||
|
</button>
|
||||||
|
{#if chipsActive(filters)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
|
||||||
|
onclick={clearChips}
|
||||||
|
title="Clear type / year / favorites filters"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||||||
Persisted to localStorage via view.svelte.ts. -->
|
Persisted to localStorage via view.svelte.ts. -->
|
||||||
|
|||||||
@@ -82,6 +82,10 @@
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: null,
|
folderPath: null,
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: category,
|
tagCategory: category,
|
||||||
tagValue: selectedValue
|
tagValue: selectedValue
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user