photos, extracted into plugins/photos — the platform half
Fifth extraction, and the clean case: no reverse dependency, configuration already server-side, frontend already panels. Everything photos now lives at gitea.officer.dev/plugins/photos. Deleted, not commented: the sidecar, the api router, the db directory, the Photos app (25 files), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, both database barrels, the hono.ts import comment, and the app-store catalogue entry. The catalogue deletion is required rather than tidying — the trap the runbook records as having bitten twice. permissionAvailability() derives from sidecar_installs, which a plugin never gets a row in, so a leftover entry makes the permission permanently 'unavailable' and withholds the route and dock tile on a server where the plugin is installed and working. ── Test fixtures, repointed rather than deleted ── Photos was the canonical app-store fixture across four test files, chosen for being existing|provisioned with members: 'accounts'. Jellyfin has the identical shape, so the assertions moved there — they are about the installer, not about photos, and deleting them would have quietly dropped coverage of the provisioned install path along with the plugin. One loss worth naming: Immich's `provisioned` install mode went with the catalogue entry. Docker provisioning for plugins does not exist yet, so photos is point-at-your-own-instance until it does. bunx tsgo clean. 1080 pass / 0 fail, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||
import { appRegistryMetas as photosMetas } from '../apps/Photos';
|
||||
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||
@@ -36,7 +35,6 @@ export const apps = [
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...soulseekMetas,
|
||||
...photosMetas,
|
||||
...jellyfinMetas,
|
||||
...transmissionMetas,
|
||||
...giteaMetas,
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { photosSectionPath } from './shared';
|
||||
import { usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||
|
||||
// Which Immich library you are looking at, and how to change it.
|
||||
//
|
||||
// The subtitle under "Photos" was already the connection state, so the switcher lives there rather than
|
||||
// adding a second control saying nearly the same thing. With one account it is exactly what it was: a line
|
||||
// of text plus a way into the settings screen.
|
||||
//
|
||||
// Switching invalidates the whole ['photos'] key, because it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs — the grid, the albums, the map are all the other library's
|
||||
// now. Anything less and the previous account's thumbnails stay on screen.
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { data } = usePhotosAccounts();
|
||||
const { data: health } = usePhotosHealth();
|
||||
const { activate } = usePhotosAccountActions();
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||
|
||||
if (accounts.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={photosSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Immich accounts</DropdownMenuLabel>
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
disabled={account.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{account.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{account.url}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={photosSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage accounts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
||||
import { useAlbum, useAlbums } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
import { useSelection } from './useSelection';
|
||||
|
||||
// Two states in one section, switched by `?album=<id>`: the wall of album covers, and one album's photos.
|
||||
// A separate `/photos/albums/:id` route would work too, but the album is a selection inside the section — the
|
||||
// same shape as `?selected=` elsewhere in the app — and this keeps the nav highlight where it belongs.
|
||||
|
||||
export const AlbumsSection = () => {
|
||||
const [params] = useSearchParams();
|
||||
const albumId = params.get(ALBUM_PARAM);
|
||||
return albumId ? <AlbumDetailView albumId={albumId} /> : <AlbumListView />;
|
||||
};
|
||||
|
||||
const AlbumListView = () => {
|
||||
const { token } = useClient();
|
||||
const { data: albums, isLoading } = useAlbums();
|
||||
const { createAlbum, deleteAlbum, renameAlbum } = useAssetActions();
|
||||
|
||||
const sorted = useMemo(() => [...(albums ?? [])].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), [albums]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Albums</h1>
|
||||
<span className="text-xs text-muted-foreground">{sorted.length}</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const albumName = window.prompt('New album name');
|
||||
if (albumName) createAlbum.mutate({ albumName });
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Create album
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
{!isLoading && sorted.length === 0 && <p className="text-sm text-muted-foreground">No albums yet.</p>}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4">
|
||||
{sorted.map((album) => (
|
||||
<div key={album.id} className="group relative">
|
||||
<Link to={`?${ALBUM_PARAM}=${album.id}`} className="block">
|
||||
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<img
|
||||
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:brightness-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{album.assetCount} items
|
||||
{album.shared ? ' · shared' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 h-7 w-7 bg-black/50 text-white opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const albumName = window.prompt('Rename album', album.albumName);
|
||||
if (albumName) renameAlbum.mutate({ albumId: album.id, albumName });
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.confirm(`Delete "${album.albumName}"?`) && deleteAlbum.mutate(album.id)}
|
||||
>
|
||||
Delete album
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||
const { token } = useClient();
|
||||
const [params] = useSearchParams();
|
||||
const { data: album, isLoading } = useAlbum(albumId);
|
||||
|
||||
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
||||
const selection = useSelection(assets.map((asset) => asset.id));
|
||||
|
||||
const back = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(ALBUM_PARAM);
|
||||
const search = next.toString();
|
||||
return search ? `?${search}` : '?';
|
||||
})();
|
||||
|
||||
const range = formatDateRange(album?.startDate, album?.endDate);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{selection.isSelecting ? (
|
||||
<SelectionBar selection={selection} albumId={albumId} />
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button asChild variant="ghost" size="icon" title="Back to albums">
|
||||
<Link to={back}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold">{album?.albumName ?? 'Album'}</h1>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{isLoading ? (
|
||||
<p className="p-2 text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<PhotoGrid
|
||||
assets={assets}
|
||||
token={token}
|
||||
selection={selection}
|
||||
empty={<p className="p-2 text-sm text-muted-foreground">This album is empty.</p>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Asset } from './shared';
|
||||
import { Link } from 'react-router';
|
||||
import { Camera, Image as ImageIcon, MapPin } from 'lucide-react';
|
||||
import { PERSON_PARAM, formatBytes, formatLongDate, formatTime, personThumbUrl, photosSectionPath } from './shared';
|
||||
|
||||
// The lightbox's info drawer, in the order Immich's own panel uses: description, date, file, camera, location,
|
||||
// people. Sections with nothing in them are omitted rather than shown empty — a library full of screenshots
|
||||
// has no camera data and shouldn't render a row of dashes for every one.
|
||||
|
||||
type AssetInfoPanelProps = { asset: Asset | undefined; token: string | null };
|
||||
|
||||
export const AssetInfoPanel = ({ asset, token }: AssetInfoPanelProps) => {
|
||||
if (!asset) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
|
||||
const exif = asset.exifInfo;
|
||||
const megapixels = asset.width && asset.height ? (asset.width * asset.height) / 1_000_000 : 0;
|
||||
const place = [exif?.city, exif?.state, exif?.country].filter(Boolean).join(', ');
|
||||
const aperture = exif?.fNumber ? `ƒ/${exif.fNumber}` : null;
|
||||
const shot = [
|
||||
aperture,
|
||||
exif?.exposureTime ? `${exif.exposureTime}s` : null,
|
||||
exif?.focalLength ? `${Math.round(exif.focalLength)}mm` : null,
|
||||
exif?.iso ? `ISO ${exif.iso}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-4 text-sm">
|
||||
{exif?.description && <p className="text-muted-foreground">{exif.description}</p>}
|
||||
|
||||
<section>
|
||||
<p className="font-medium">{formatLongDate(asset.localDateTime)}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatTime(asset.localDateTime)}</p>
|
||||
</section>
|
||||
|
||||
<Section icon={<ImageIcon className="h-4 w-4" />} title={asset.originalFileName}>
|
||||
<Row
|
||||
label={`${asset.width} × ${asset.height}`}
|
||||
value={megapixels >= 0.1 ? `${megapixels.toFixed(1)} MP` : null}
|
||||
/>
|
||||
<Row label="Size" value={formatBytes(exif?.fileSizeInByte)} />
|
||||
<Row label="Type" value={asset.originalMimeType ?? asset.type} />
|
||||
<p className="break-all pt-1 text-xs text-muted-foreground">{asset.originalPath}</p>
|
||||
</Section>
|
||||
|
||||
{(exif?.make || exif?.model || shot.length > 0) && (
|
||||
<Section icon={<Camera className="h-4 w-4" />} title={[exif?.make, exif?.model].filter(Boolean).join(' ')}>
|
||||
{exif?.lensModel && <p className="text-xs text-muted-foreground">{exif.lensModel}</p>}
|
||||
{shot.length > 0 && <p className="text-xs text-muted-foreground">{shot.join(' · ')}</p>}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(place || (exif?.latitude != null && exif?.longitude != null)) && (
|
||||
<Section icon={<MapPin className="h-4 w-4" />} title={place || 'Location'}>
|
||||
{exif?.latitude != null && exif?.longitude != null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{exif.latitude.toFixed(5)}, {exif.longitude.toFixed(5)}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{asset.people && asset.people.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">People</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{asset.people.map((person) => (
|
||||
<Link
|
||||
key={person.id}
|
||||
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
|
||||
className="w-16 text-center"
|
||||
>
|
||||
<img
|
||||
src={personThumbUrl(person.id, token)}
|
||||
alt=""
|
||||
className="h-16 w-16 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<span className="mt-1 block truncate text-xs">{person.name || 'Unnamed'}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SectionProps = { icon: React.ReactNode; title: string; children?: React.ReactNode };
|
||||
|
||||
const Section = ({ icon, title, children }: SectionProps) => (
|
||||
<section className="flex gap-3">
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">{icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string | null }) => (
|
||||
<p className="text-xs text-muted-foreground">{value ? `${label} · ${value}` : label}</p>
|
||||
);
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { GridAsset } from './shared';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { CheckCircle2, Circle, Heart, Layers, Play } from 'lucide-react';
|
||||
import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
|
||||
|
||||
// One tile in the justified grid.
|
||||
//
|
||||
// The tile is a real <Link> to `?asset=<id>` — the lightbox is addressable, so a photo can be cmd-clicked
|
||||
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
|
||||
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
|
||||
// keyboard behaviour of both.
|
||||
|
||||
type AssetTileProps = {
|
||||
asset: GridAsset;
|
||||
token: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
selected: boolean;
|
||||
/** True once anything is selected — the checkbox stops being hover-only and the click selects, not opens. */
|
||||
selecting: boolean;
|
||||
onToggle: (id: string, ev: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export const AssetTile = ({ asset, token, width, height, selected, selecting, onToggle }: AssetTileProps) => {
|
||||
const [params] = useSearchParams();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const duration = formatDuration(asset.duration);
|
||||
|
||||
const href = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(ASSET_PARAM, asset.id);
|
||||
return `?${next.toString()}`;
|
||||
})();
|
||||
|
||||
const badges = (
|
||||
<>
|
||||
{asset.isFavorite && <Heart className="absolute bottom-1.5 left-1.5 h-4 w-4 fill-white text-white drop-shadow" />}
|
||||
{asset.stackCount > 1 && (
|
||||
<span className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded bg-black/60 px-1 text-[10px] font-medium text-white">
|
||||
<Layers className="h-3 w-3" />
|
||||
{asset.stackCount}
|
||||
</span>
|
||||
)}
|
||||
{!asset.isImage && (
|
||||
<span className="absolute bottom-1.5 right-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
|
||||
<Play className="h-2.5 w-2.5 fill-white" />
|
||||
{duration ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
{/* A neutral block until the thumbnail decodes, so the row doesn't flash white on a dark page. */}
|
||||
<div className={`absolute inset-0 bg-white/5 transition-opacity ${loaded ? 'opacity-0' : 'opacity-100'}`} />
|
||||
<img
|
||||
src={thumbUrl(asset.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
{badges}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
|
||||
{selecting ? (
|
||||
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
|
||||
// you can rubber-band through a month without the lightbox opening on every tile.
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => onToggle(asset.id, ev)}
|
||||
className="group absolute inset-0 block h-full w-full cursor-pointer"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<Link to={href} className="group absolute inset-0 block h-full w-full">
|
||||
{inner}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => onToggle(asset.id, ev)}
|
||||
title={selected ? 'Deselect' : 'Select'}
|
||||
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
|
||||
selected ? 'opacity-100' : 'opacity-0 hover:opacity-100 focus-visible:opacity-100'
|
||||
} ${selecting ? 'opacity-100' : ''}`}
|
||||
>
|
||||
{selected ? (
|
||||
<CheckCircle2 className="h-5 w-5 fill-primary text-white" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{selected && <span className="pointer-events-none absolute inset-0 rounded-sm ring-2 ring-inset ring-primary" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,254 +0,0 @@
|
||||
import type { Asset } from './shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Heart,
|
||||
Info,
|
||||
Trash2,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ASSET_PARAM, originalUrl, thumbUrl, videoUrl } from './shared';
|
||||
import { AssetInfoPanel } from './AssetInfoPanel';
|
||||
import { useAsset } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
// The lightbox. Which asset is open lives in `?asset=<id>`, so it is linkable, survives a reload, and closes
|
||||
// with the browser's back button — the rule from docs/navigation-audit.md applied to the one piece of this
|
||||
// screen that most wants to be a modal with a useState behind it.
|
||||
|
||||
type AssetViewerProps = {
|
||||
/** Every asset currently on screen, in display order — this is what prev/next walk. */
|
||||
ids: string[];
|
||||
token: string | null;
|
||||
/** Trash shows restore/delete-forever instead of archive/delete. */
|
||||
inTrash?: boolean;
|
||||
};
|
||||
|
||||
export const AssetViewer = ({ ids, token, inTrash = false }: AssetViewerProps) => {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const openId = params.get(ASSET_PARAM);
|
||||
const { data: asset } = useAsset(openId);
|
||||
|
||||
const index = openId ? ids.indexOf(openId) : -1;
|
||||
const prev = index > 0 ? ids[index - 1] : undefined;
|
||||
const next = index >= 0 && index < ids.length - 1 ? ids[index + 1] : undefined;
|
||||
|
||||
const goTo = useCallback(
|
||||
(id: string | undefined) => {
|
||||
if (!id) return;
|
||||
setParams(
|
||||
(current) => {
|
||||
const nextParams = new URLSearchParams(current);
|
||||
nextParams.set(ASSET_PARAM, id);
|
||||
return nextParams;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// Replace, not push: stepping through fifty photos shouldn't bury the grid fifty entries deep in history.
|
||||
// Opening the viewer pushed one entry, so one Back still returns to where you were.
|
||||
const close = useCallback(() => {
|
||||
setParams((current) => {
|
||||
const nextParams = new URLSearchParams(current);
|
||||
nextParams.delete(ASSET_PARAM);
|
||||
return nextParams;
|
||||
});
|
||||
}, [setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openId) return;
|
||||
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') close();
|
||||
else if (ev.key === 'ArrowLeft') goTo(prev);
|
||||
else if (ev.key === 'ArrowRight') goTo(next);
|
||||
else if (ev.key === 'i') setShowInfo((value) => !value);
|
||||
else return;
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [openId, prev, next, close, goTo]);
|
||||
|
||||
if (!openId) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black/95">
|
||||
<ViewerHeader
|
||||
asset={asset}
|
||||
id={openId}
|
||||
token={token}
|
||||
inTrash={inTrash}
|
||||
showInfo={showInfo}
|
||||
onToggleInfo={() => setShowInfo((value) => !value)}
|
||||
onClose={close}
|
||||
onAdvance={() => (next ? goTo(next) : close())}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative flex min-w-0 flex-1 items-center justify-center">
|
||||
<NavButton side="left" disabled={!prev} onClick={() => goTo(prev)} />
|
||||
|
||||
{asset?.type === 'VIDEO' ? (
|
||||
<video
|
||||
key={openId}
|
||||
src={videoUrl(openId, token)}
|
||||
poster={thumbUrl(openId, token, 'preview')}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
) : (
|
||||
// `preview` rather than `original`: originals are frequently 30MB HEIC/RAW the browser may not even
|
||||
// decode, and Immich's own viewer shows the preview for the same reason. Download gets the original.
|
||||
<img
|
||||
key={openId}
|
||||
src={thumbUrl(openId, token, 'preview')}
|
||||
alt={asset?.originalFileName ?? ''}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
|
||||
<NavButton side="right" disabled={!next} onClick={() => goTo(next)} />
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<aside className="w-80 shrink-0 overflow-y-auto border-l border-white/10 bg-background">
|
||||
<AssetInfoPanel asset={asset} token={token} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NavButtonProps = { side: 'left' | 'right'; disabled: boolean; onClick: () => void };
|
||||
|
||||
const NavButton = ({ side, disabled, onClick }: NavButtonProps) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={side === 'left' ? 'Previous' : 'Next'}
|
||||
className={`absolute ${side === 'left' ? 'left-2' : 'right-2'} z-10 rounded-full bg-black/40 p-2 text-white transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-0`}
|
||||
>
|
||||
{side === 'left' ? <ChevronLeft className="h-6 w-6" /> : <ChevronRight className="h-6 w-6" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
type ViewerHeaderProps = {
|
||||
asset: Asset | undefined;
|
||||
id: string;
|
||||
token: string | null;
|
||||
inTrash: boolean;
|
||||
showInfo: boolean;
|
||||
onToggleInfo: () => void;
|
||||
onClose: () => void;
|
||||
onAdvance: () => void;
|
||||
};
|
||||
|
||||
const ViewerHeader = ({ asset, id, token, inTrash, showInfo, onToggleInfo, onClose, onAdvance }: ViewerHeaderProps) => {
|
||||
const { setFavorite, setArchived, trash, restore, deleteForever } = useAssetActions();
|
||||
const archived = asset?.visibility === 'archive';
|
||||
|
||||
// Anything that removes the asset from the current view advances to the next one first — otherwise the
|
||||
// viewer is left pointing at an id that no longer exists in the grid behind it.
|
||||
const removeThen = (run: () => void) => {
|
||||
run();
|
||||
onAdvance();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-1 px-2 py-2 text-white">
|
||||
<Button variant="ghost" size="icon" onClick={onClose} title="Close (Esc)" className="text-white">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<span className="ml-1 min-w-0 flex-1 truncate text-sm text-white/70">{asset?.originalFileName}</span>
|
||||
|
||||
{inTrash ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Restore"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => restore.mutate([id]))}
|
||||
>
|
||||
<Undo2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => deleteForever.mutate([id]))}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={asset?.isFavorite ? 'Remove from favorites' : 'Favorite'}
|
||||
className="text-white"
|
||||
onClick={() => setFavorite.mutate({ ids: [id], isFavorite: !asset?.isFavorite })}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${asset?.isFavorite ? 'fill-white' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={archived ? 'Unarchive' : 'Archive'}
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => setArchived.mutate({ ids: [id], archived: !archived }))}
|
||||
>
|
||||
{archived ? <ArchiveRestore className="h-5 w-5" /> : <Archive className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Move to trash"
|
||||
className="text-white"
|
||||
onClick={() => removeThen(() => trash.mutate([id]))}
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={originalUrl(id, token)}
|
||||
download={asset?.originalFileName ?? ''}
|
||||
title="Download original"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white hover:bg-white/10"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Info (i)"
|
||||
className={`text-white ${showInfo ? 'bg-white/15' : ''}`}
|
||||
onClick={onToggleInfo}
|
||||
>
|
||||
<Info className="h-5 w-5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
import type { PhotosAccount, PhotosCredential } from './usePhotosData';
|
||||
import { useState } from 'react';
|
||||
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { formatShortDate } from './shared';
|
||||
import { photosErrorMessage, usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||
|
||||
// Connecting Immich libraries to Officer, from the app.
|
||||
//
|
||||
// This screen is BOTH the setup wizard and the permanent settings page: PhotosView renders it in place of
|
||||
// whatever section the URL asks for while nothing is connected, and /photos/settings renders it for good.
|
||||
// One component, so a second account added later goes through exactly the code path that stored the first.
|
||||
//
|
||||
// It is a registry, not a single connection — two keys against the same instance (one per Immich user) is
|
||||
// the ordinary case, which is why each account carries a LABEL and why the label, not the URL, is what has
|
||||
// to be unique. The API key is never displayed, because Officer cannot display it: it is encrypted at rest
|
||||
// and the sidecar's GET has no field that could carry it back.
|
||||
|
||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
||||
|
||||
/** Immich's own default — its compose file publishes 2283, and the sidecar dials from this machine. */
|
||||
const DEFAULT_URL = 'http://localhost:2283';
|
||||
|
||||
const URL_HINT =
|
||||
"The instance's base URL, without /api. Officer reaches it from the server, not from this browser — so " +
|
||||
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
||||
|
||||
const KEY_HINT =
|
||||
'Immich → Account Settings → API Keys. Grant all permissions unless you have a reason not to: Immich keys ' +
|
||||
'are scoped, and a partial key looks like a broken feature rather than a rejected credential.';
|
||||
|
||||
const SIGN_IN_HINT =
|
||||
'Your Immich login. It is used once, by the sidecar, to mint an all-permissions API key — the password is ' +
|
||||
'never stored and the sign-in session is closed straight after, so no phantom device is left in Immich. ' +
|
||||
'The key appears in Immich → Account Settings → API Keys as "Officer"; revoke it there.';
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{hint && <span className={HINT}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ── credentials ──────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two ways to prove the same thing, so they share one piece of state rather than two forms. Sign-in is the
|
||||
// default: it is the only path that does not require the owner to go and read an API key out of Immich's own
|
||||
// settings first, and what it stores is the same kind of key they would have pasted.
|
||||
|
||||
type CredentialMode = 'login' | 'key';
|
||||
|
||||
function useCredential() {
|
||||
const [mode, setMode] = useState<CredentialMode>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
const filled = mode === 'key' ? !!apiKey.trim() : !!email.trim() && !!password;
|
||||
const payload: PhotosCredential = mode === 'key' ? { apiKey: apiKey.trim() } : { email: email.trim(), password };
|
||||
|
||||
const reset = () => {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setApiKey('');
|
||||
};
|
||||
|
||||
return { mode, setMode, email, setEmail, password, setPassword, apiKey, setApiKey, filled, payload, reset };
|
||||
}
|
||||
|
||||
type CredentialState = ReturnType<typeof useCredential>;
|
||||
|
||||
const TAB = 'rounded-md px-2 py-1 text-[11px] transition-colors';
|
||||
|
||||
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium">Credential</span>
|
||||
<div className="ml-auto flex items-center gap-1 rounded-lg bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cred.setMode('login')}
|
||||
className={`${TAB} ${cred.mode === 'login' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cred.setMode('key')}
|
||||
className={`${TAB} ${cred.mode === 'key' ? 'bg-background shadow-sm' : 'text-muted-foreground'}`}
|
||||
>
|
||||
API key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cred.mode === 'login' ? (
|
||||
<>
|
||||
<Field
|
||||
label="Email"
|
||||
value={cred.email}
|
||||
onChange={cred.setEmail}
|
||||
placeholder="you@example.com"
|
||||
type="email"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
value={cred.password}
|
||||
onChange={cred.setPassword}
|
||||
placeholder="••••••••"
|
||||
type="password"
|
||||
hint={SIGN_IN_HINT}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Field
|
||||
label="API key"
|
||||
value={cred.apiKey}
|
||||
onChange={cred.setApiKey}
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
hint={KEY_HINT}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
type AccountRowProps = { account: PhotosAccount };
|
||||
|
||||
/**
|
||||
* One stored account. The active one carries the live health line, because health only ever describes the
|
||||
* account actually being used — showing a status next to the others would be inventing one.
|
||||
*/
|
||||
const AccountRow = ({ account }: AccountRowProps) => {
|
||||
const { data: health, refetch: recheck, isFetching: checking } = usePhotosHealth();
|
||||
const { edit, activate, remove } = usePhotosAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
// Removing an account throws away a key Officer can never show again, so the bin asks once.
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const run = async (action: Promise<unknown>) => {
|
||||
setError(null);
|
||||
try {
|
||||
await action;
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
} catch (err) {
|
||||
setError(photosErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${account.isActive ? 'border-primary/40' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{account.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate font-medium">{account.label}</span>
|
||||
{account.isActive && (
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{!account.isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => void run(activate.mutateAsync(account.id))}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
)}
|
||||
{account.isActive && (
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => void recheck()}>
|
||||
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!confirming) return setConfirming(true);
|
||||
setConfirming(false);
|
||||
void run(remove.mutateAsync(account.id));
|
||||
}}
|
||||
>
|
||||
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="truncate text-muted-foreground">{account.url}</p>
|
||||
|
||||
{account.isActive ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{health?.ok ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-500" />
|
||||
)}
|
||||
<span>
|
||||
{health?.ok
|
||||
? `Immich ${health.version ?? '?'}${health.user ? ` · ${health.user}` : ''}`
|
||||
: (health?.error ?? 'not checked yet')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{account.version ? `Immich ${account.version}` : 'version unknown'}
|
||||
{account.lastSeenAt ? ` · last answered ${formatShortDate(account.lastSeenAt)}` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{replacing ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<CredentialFields cred={cred} autoFocus />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!cred.filled || edit.isPending}
|
||||
onClick={() => void run(edit.mutateAsync({ id: account.id, ...cred.payload }))}
|
||||
>
|
||||
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
cred.reset();
|
||||
setReplacing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplacing(true)}
|
||||
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
Replace credential
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionSection = () => {
|
||||
const { data, isLoading } = usePhotosAccounts();
|
||||
const { add } = usePhotosAccountActions();
|
||||
|
||||
const cred = useCredential();
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const hasAccounts = accounts.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
if (!url.trim()) return setError('The Immich URL is required');
|
||||
if (!cred.filled) {
|
||||
return setError(cred.mode === 'key' ? 'An API key is required' : 'An email and password are required');
|
||||
}
|
||||
|
||||
try {
|
||||
await add.mutateAsync({ label: label.trim(), url: url.trim(), ...cred.payload });
|
||||
setLabel('');
|
||||
setUrl(DEFAULT_URL);
|
||||
cred.reset();
|
||||
} catch (err) {
|
||||
setError(photosErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-400">
|
||||
<Plug className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">{hasAccounts ? 'Immich accounts' : 'Connect Immich'}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasAccounts
|
||||
? 'Officer stores each instance and its key encrypted, for this account only. One is in use at a time.'
|
||||
: 'Photos needs an Immich instance and a login before it can show anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && accounts.map((account) => <AccountRow key={account.id} account={account} />)}
|
||||
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||
>
|
||||
<p className="text-xs font-medium">{hasAccounts ? 'Add another account' : 'Add an account'}</p>
|
||||
<Field
|
||||
label="Label"
|
||||
value={label}
|
||||
onChange={setLabel}
|
||||
placeholder="Optional — defaults to the Immich user"
|
||||
hint="What the switcher calls this account. Two accounts cannot share a label; two can share a URL."
|
||||
autoFocus={hasAccounts}
|
||||
/>
|
||||
<Field
|
||||
label="Immich URL"
|
||||
value={url}
|
||||
onChange={setUrl}
|
||||
placeholder={DEFAULT_URL}
|
||||
hint={URL_HINT}
|
||||
autoFocus={!hasAccounts}
|
||||
/>
|
||||
<CredentialFields cred={cred} />
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={add.isPending}>
|
||||
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{add.isPending ? 'Verifying…' : hasAccounts ? 'Add account' : 'Connect'}
|
||||
</Button>
|
||||
{add.isPending && <span className={HINT}>Checking the instance and the key…</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { PERSON_PARAM, QUERY_PARAM, personThumbUrl, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useExplore, usePeople } from './usePhotosData';
|
||||
|
||||
// Explore: the faces row, then whatever Immich has clustered the library by — places and things.
|
||||
//
|
||||
// Immich's own explore tiles link to a structured search (`{"city":"Lisbon"}`). This proxy exposes smart and
|
||||
// metadata search only, so a tile links to a smart search for its label instead. Close in spirit, and honest
|
||||
// about it: a city tile finds photos that LOOK like that city rather than ones tagged with it.
|
||||
|
||||
const SECTION_TITLES: Record<string, string> = {
|
||||
'exifInfo.city': 'Places',
|
||||
'exifInfo.country': 'Countries',
|
||||
'smartInfo.objects': 'Things',
|
||||
'exifInfo.state': 'Regions',
|
||||
};
|
||||
|
||||
export const ExploreSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: explore, isLoading } = useExplore();
|
||||
const { data: people } = usePeople();
|
||||
|
||||
const faces = (people?.people ?? []).filter((person) => person.name).slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Explore</h1>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||
{faces.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">People</h2>
|
||||
<Link to={photosSectionPath('people')} className="text-xs text-muted-foreground hover:underline">
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{faces.map((person) => (
|
||||
<Link
|
||||
key={person.id}
|
||||
to={`${photosSectionPath('people')}?${PERSON_PARAM}=${person.id}`}
|
||||
className="w-20 text-center"
|
||||
>
|
||||
<img
|
||||
src={personThumbUrl(person.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-20 w-20 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
<span className="mt-1 block truncate text-xs">{person.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
|
||||
{(explore ?? []).map((section) => (
|
||||
<section key={section.fieldName}>
|
||||
<h2 className="mb-2 text-sm font-semibold">{SECTION_TITLES[section.fieldName] ?? section.fieldName}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={`${section.fieldName}:${item.value}`}
|
||||
to={`${photosSectionPath('search')}?${QUERY_PARAM}=${encodeURIComponent(item.value)}`}
|
||||
className="group relative aspect-square overflow-hidden rounded-lg bg-white/5"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(item.data.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition group-hover:scale-105"
|
||||
/>
|
||||
<span className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 text-sm font-medium text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && (explore ?? []).length === 0 && faces.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Nothing to explore yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, ZoomIn } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { thumbUrl } from './shared';
|
||||
|
||||
// The contents of one map cluster, listed. Clicking a circle marked "712" used to do nothing but zoom, which
|
||||
// answers "where are they" and never "what are they" — and for a tight cluster of photos taken in one place,
|
||||
// zooming never separates them at all.
|
||||
//
|
||||
// The id list is SNAPSHOT at click time by the caller, not paged out of the cluster on demand. maplibre's
|
||||
// cluster ids are assigned per zoom level and stop existing the moment the camera moves, so a panel that
|
||||
// fetched page 2 lazily would fail exactly when someone scrolled it and then nudged the map. A cluster of
|
||||
// 12,000 assets is 12,000 short strings — cheap to hold, and it makes the panel independent of the map.
|
||||
//
|
||||
// Lazy is therefore about DOM and images, not data: `PAGE` tiles at a time, grown by an IntersectionObserver.
|
||||
|
||||
/** What the caller snapshots when a cluster is clicked. */
|
||||
export type ClusterSelection = {
|
||||
/** maplibre's cluster id — only valid at the zoom it was read at, so it is used for highlighting and nothing else. */
|
||||
clusterId: number;
|
||||
/** Every asset in the cluster, in the order the source returned them. */
|
||||
ids: string[];
|
||||
center: [number, number];
|
||||
/** The zoom that would break this cluster apart, resolved at click time while the id was still live. */
|
||||
expansionZoom: number;
|
||||
};
|
||||
|
||||
/** How many tiles to add each time the end of the list comes into view. */
|
||||
const PAGE = 90;
|
||||
|
||||
type MapClusterPanelProps = {
|
||||
cluster: ClusterSelection;
|
||||
/** Human name for the area, derived from the markers' own city/country. */
|
||||
place: string | null;
|
||||
token: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
onZoom: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const MapClusterPanel = ({ cluster, place, token, onOpen, onZoom, onClose }: MapClusterPanelProps) => {
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const total = cluster.ids.length;
|
||||
|
||||
useEffect(() => setShown(PAGE), [cluster.ids]);
|
||||
|
||||
// The sentinel is always rendered, so the observer never has to be torn down and re-attached as the list
|
||||
// grows; once everything is shown the callback just clamps to the same value and React bails out.
|
||||
useEffect(() => {
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) setShown((count) => Math.min(count + PAGE, total));
|
||||
},
|
||||
{ rootMargin: '400px' },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [total]);
|
||||
|
||||
// Safe to own Escape: AssetViewer only registers its own handler while an asset is open, and when one is it
|
||||
// sits above this panel, so the two never both fire.
|
||||
useEffect(() => {
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
onClose();
|
||||
ev.preventDefault();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<aside className="absolute inset-y-0 left-0 z-10 flex w-[min(21rem,72vw)] flex-col border-r bg-background/95 shadow-xl backdrop-blur">
|
||||
<header className="flex items-center gap-1 border-b px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{place ?? 'Selected area'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{total.toLocaleString()} photo{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" title="Zoom in on this cluster" onClick={onZoom}>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Close (Esc)" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{cluster.ids.slice(0, shown).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
title="Open photo"
|
||||
onClick={() => onOpen(id)}
|
||||
className="relative aspect-square overflow-hidden rounded-sm bg-muted"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover transition hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
|
||||
{shown < total && (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">{(total - shown).toLocaleString()} more…</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -1,453 +0,0 @@
|
||||
import type { GeoJSONSource } from 'maplibre-gl';
|
||||
import type { ClusterSelection } from './MapClusterPanel';
|
||||
import type { MapFilter } from './usePhotosData';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { LngLatBounds, Map as MapLibreMap, Marker, NavigationControl } from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useColorMode } from '@/components/ui/ThemeProvider';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { MapClusterPanel } from './MapClusterPanel';
|
||||
import { ASSET_PARAM, thumbUrl } from './shared';
|
||||
import { useMapMarkers, useServerConfig } from './usePhotosData';
|
||||
|
||||
// The map, as close to Immich's as the pieces allow.
|
||||
//
|
||||
// Four things are worth knowing before changing this:
|
||||
//
|
||||
// 1. The BASEMAP is the only thing on /photos the browser fetches from outside Officer. Its URL comes from
|
||||
// Immich's own `/server/config` (proxied), but the vector tiles themselves come straight from
|
||||
// tiles.immich.cloud — exactly as Immich's web app does it. Proxying a basemap would mean relaying
|
||||
// thousands of tile requests that contain none of the owner's data, through a sidecar whose job is the
|
||||
// library. The tile server learns which parts of the world are being looked at and nothing else.
|
||||
// 2. CLUSTERING is maplibre's, not ours. A geotagged library is tens of thousands of pins, and the GeoJSON
|
||||
// source does the bucketing on a worker thread. That is also why markers are fetched in one unbucketed
|
||||
// request: a marker is six numbers, and clustering needs all of them at once to be correct.
|
||||
// 3. The photo THUMBNAILS are DOM markers layered over an invisible-by-occlusion circle layer, capped at
|
||||
// THUMB_LIMIT and rebuilt on `idle`. Rendering an image per asset into the GL context would mean
|
||||
// uploading thousands of textures; the browser only ever holds the ones actually on screen.
|
||||
// 4. Clicking a cluster OPENS IT rather than zooming — see MapClusterPanel. Zooming is still available from
|
||||
// the panel, but it is not the answer to "what is in there": photos taken in one place stay one circle at
|
||||
// every zoom, so expansion alone can leave a count you can never break down.
|
||||
//
|
||||
// maplibre-gl is PINNED TO 5.x on purpose. In 6.0 the tile worker became a separate `maplibre-gl-worker.mjs`
|
||||
// resolved at runtime from `import.meta.url`. Officer bundles the SPA and serves an index.html fallback for
|
||||
// unknown paths, so that request comes back as HTML and the browser refuses it — "non-JavaScript MIME type of
|
||||
// text/html", and no map at all. 5.x inlines the worker as a blob, which needs no serving infrastructure.
|
||||
// Upgrading means either copying maplibre's worker + shared chunks into public/ at build time or calling
|
||||
// `setWorkerUrl()`; do not bump the major without doing one of those.
|
||||
|
||||
const SOURCE = 'photos-assets';
|
||||
const HALO_LAYER = 'photos-cluster-halo';
|
||||
const CLUSTER_LAYER = 'photos-clusters';
|
||||
const COUNT_LAYER = 'photos-cluster-count';
|
||||
const POINT_LAYER = 'photos-points';
|
||||
|
||||
/** How many photo thumbnails to draw at once. Past this it is a wall of images, not a map. */
|
||||
const THUMB_LIMIT = 120;
|
||||
|
||||
const ACCENT = '#10b981';
|
||||
|
||||
type PointFeature = {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: { id: string };
|
||||
};
|
||||
type PointCollection = { type: 'FeatureCollection'; features: PointFeature[] };
|
||||
|
||||
type BoolFilterKey = 'isFavorite' | 'isArchived' | 'withPartners' | 'withSharedAlbums';
|
||||
const TOGGLES: { key: BoolFilterKey; label: string; title: string }[] = [
|
||||
{ key: 'isFavorite', label: 'Favorites', title: 'Only favorited photos' },
|
||||
{ key: 'isArchived', label: 'Archived', title: 'Include archived photos' },
|
||||
{ key: 'withPartners', label: 'Partners', title: "Include partners' photos" },
|
||||
{ key: 'withSharedAlbums', label: 'Shared', title: 'Include photos from shared albums' },
|
||||
];
|
||||
|
||||
/**
|
||||
* A marker is a plain DOM button rather than JSX, because maplibre owns the element's position and React must
|
||||
* not re-parent it. Styles are inline for the same reason Tailwind is avoided here: the element is created
|
||||
* outside the render tree, so keeping its appearance next to its construction is the readable choice.
|
||||
*/
|
||||
const makeThumbElement = (id: string, token: string | null, onOpen: (id: string) => void) => {
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.title = 'Open photo';
|
||||
el.style.cssText =
|
||||
'width:46px;height:46px;padding:0;border-radius:9999px;border:2px solid rgba(255,255,255,.85);' +
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,.45);background-color:rgba(0,0,0,.35);background-size:cover;' +
|
||||
'background-position:center;cursor:pointer;display:block';
|
||||
el.style.backgroundImage = `url("${thumbUrl(id, token)}")`;
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
onOpen(id);
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
export const MapSection = () => {
|
||||
const { token } = useClient();
|
||||
const { colorMode } = useColorMode();
|
||||
const [, setParams] = useSearchParams();
|
||||
|
||||
const [filter, setFilter] = useState<MapFilter>({ withPartners: true, withSharedAlbums: true });
|
||||
const { data: config } = useServerConfig();
|
||||
const { data: markers, isLoading, error: markersError } = useMapMarkers(filter);
|
||||
|
||||
const styleUrl = colorMode === 'dark' ? config?.mapDarkStyleUrl : config?.mapLightStyleUrl;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const thumbsRef = useRef<Marker[]>([]);
|
||||
const shownRef = useRef('');
|
||||
const loadedRef = useRef(false);
|
||||
const fittedRef = useRef(false);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [mapError, setMapError] = useState<string | null>(null);
|
||||
const [visibleIds, setVisibleIds] = useState<string[]>([]);
|
||||
const [cluster, setCluster] = useState<ClusterSelection | null>(null);
|
||||
|
||||
const open = useCallback(
|
||||
(id: string) => {
|
||||
setParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.set(ASSET_PARAM, id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const data = useMemo<PointCollection>(
|
||||
() => ({
|
||||
type: 'FeatureCollection',
|
||||
features: (markers ?? []).map((marker) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [marker.lon, marker.lat] },
|
||||
properties: { id: marker.id },
|
||||
})),
|
||||
}),
|
||||
[markers],
|
||||
);
|
||||
|
||||
// Build the map. Keyed on the style URL, so flipping Officer's light/dark mode rebuilds it against Immich's
|
||||
// matching basemap — cheaper to reason about than swapping the style and re-adding every source by hand.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !styleUrl) return;
|
||||
|
||||
loadedRef.current = false;
|
||||
fittedRef.current = false;
|
||||
setMapError(null);
|
||||
|
||||
const map = new MapLibreMap({
|
||||
container,
|
||||
style: styleUrl,
|
||||
center: [0, 25],
|
||||
zoom: 1.2,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
map.addControl(new NavigationControl({ showCompass: false }), 'top-right');
|
||||
|
||||
// Immich's basemap style asks for icons its own sprite sheet doesn't ship — `places_locality` selects
|
||||
// `capital` for capital cities, and none of the four sprite variants contain it (`townspot` is there).
|
||||
// That is upstream's bug, visible in Immich's own web map too, and it costs nothing but a console warning
|
||||
// per tile batch. Handing maplibre a blank keeps the log readable; the label still draws, just iconless.
|
||||
map.on('styleimagemissing', (ev) => {
|
||||
if (!map.hasImage(ev.id)) map.addImage(ev.id, { width: 1, height: 1, data: new Uint8Array(4) });
|
||||
});
|
||||
|
||||
map.on('load', () => {
|
||||
loadedRef.current = true;
|
||||
setReady(true);
|
||||
});
|
||||
// Registering ANY `error` listener turns OFF maplibre's own console logging, so log it back out. Only
|
||||
// pre-load failures are banner-worthy — a single tile 404 after that would make a working map look
|
||||
// broken — but swallowing the rest leaves a blank map above an empty console, which cannot be debugged.
|
||||
map.on('error', (ev) => {
|
||||
console.error('[photos/map]', ev.error ?? ev);
|
||||
if (!loadedRef.current) setMapError(ev.error?.message ?? 'The basemap could not be loaded.');
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
thumbsRef.current.forEach((marker) => marker.remove());
|
||||
thumbsRef.current = [];
|
||||
shownRef.current = '';
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
setReady(false);
|
||||
setCluster(null);
|
||||
};
|
||||
}, [styleUrl]);
|
||||
|
||||
// Feed the markers in, adding the layers the first time and only swapping data after that.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !ready) return;
|
||||
|
||||
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined;
|
||||
if (existing) {
|
||||
existing.setData(data);
|
||||
} else {
|
||||
map.addSource(SOURCE, { type: 'geojson', data, cluster: true, clusterRadius: 60, clusterMaxZoom: 15 });
|
||||
|
||||
// Drawn under the clusters so the selected one gets a ring around it. Its filter is set from state; an
|
||||
// impossible cluster id is the "nothing selected" case, since maplibre has no way to hide a layer's
|
||||
// features other than a filter that matches none of them.
|
||||
map.addLayer({
|
||||
id: HALO_LAYER,
|
||||
type: 'circle',
|
||||
source: SOURCE,
|
||||
filter: ['==', ['get', 'cluster_id'], -1],
|
||||
paint: {
|
||||
'circle-color': 'rgba(0,0,0,0)',
|
||||
'circle-radius': ['step', ['get', 'point_count'], 23, 10, 27, 100, 33, 1000, 41],
|
||||
'circle-stroke-width': 3,
|
||||
'circle-stroke-color': ACCENT,
|
||||
},
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: CLUSTER_LAYER,
|
||||
type: 'circle',
|
||||
source: SOURCE,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': ACCENT,
|
||||
'circle-opacity': 0.85,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 16, 10, 20, 100, 26, 1000, 34],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': 'rgba(255,255,255,.75)',
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: COUNT_LAYER,
|
||||
type: 'symbol',
|
||||
source: SOURCE,
|
||||
filter: ['has', 'point_count'],
|
||||
// Immich's own style ships this fontstack; anything else would 404 and silently drop the numbers.
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-font': ['Noto Sans Medium'],
|
||||
'text-size': 12,
|
||||
},
|
||||
paint: { 'text-color': '#05231b' },
|
||||
});
|
||||
map.addLayer({
|
||||
id: POINT_LAYER,
|
||||
type: 'circle',
|
||||
source: SOURCE,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-color': ACCENT,
|
||||
'circle-radius': 6,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': 'rgba(255,255,255,.8)',
|
||||
},
|
||||
});
|
||||
|
||||
// Open the cluster rather than zoom into it. Both the leaves and the expansion zoom are read HERE, while
|
||||
// the cluster id is still live — it is only meaningful at the zoom it was queried at, so resolving either
|
||||
// one later (when the panel's zoom button is pressed, say) would be a race against the camera.
|
||||
map.on('click', CLUSTER_LAYER, (ev) => {
|
||||
const feature = ev.features?.[0];
|
||||
const clusterId = feature?.properties?.cluster_id;
|
||||
if (feature?.geometry.type !== 'Point' || typeof clusterId !== 'number') return;
|
||||
|
||||
const source = map.getSource(SOURCE) as GeoJSONSource | undefined;
|
||||
if (!source) return;
|
||||
|
||||
const [lon, lat] = feature.geometry.coordinates as [number, number];
|
||||
const count = Number(feature.properties?.point_count ?? 0);
|
||||
|
||||
void Promise.all([
|
||||
source.getClusterLeaves(clusterId, count, 0),
|
||||
source.getClusterExpansionZoom(clusterId),
|
||||
]).then(([leaves, expansionZoom]) => {
|
||||
const ids = leaves.map((leaf) => leaf.properties?.id).filter((id): id is string => typeof id === 'string');
|
||||
setCluster({ clusterId, ids, center: [lon, lat], expansionZoom });
|
||||
});
|
||||
});
|
||||
map.on('click', POINT_LAYER, (ev) => {
|
||||
const id = ev.features?.[0]?.properties?.id;
|
||||
if (typeof id === 'string') open(id);
|
||||
});
|
||||
for (const layer of [CLUSTER_LAYER, POINT_LAYER]) {
|
||||
map.on('mouseenter', layer, () => (map.getCanvas().style.cursor = 'pointer'));
|
||||
map.on('mouseleave', layer, () => (map.getCanvas().style.cursor = ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Frame the library once. Re-fitting on every filter change would yank the view out from under someone
|
||||
// who just zoomed into a city and then unticked "archived".
|
||||
if (!fittedRef.current && data.features.length > 0) {
|
||||
const bounds = new LngLatBounds();
|
||||
for (const feature of data.features) bounds.extend(feature.geometry.coordinates);
|
||||
map.fitBounds(bounds, { padding: 64, maxZoom: 12, animate: false });
|
||||
fittedRef.current = true;
|
||||
}
|
||||
}, [ready, data, open]);
|
||||
|
||||
// Ring the open cluster. The id goes stale as soon as the camera moves — the ring then simply matches nothing
|
||||
// and disappears, which is the honest outcome: the panel's list is a snapshot, and that circle is gone.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !ready || !map.getLayer(HALO_LAYER)) return;
|
||||
map.setFilter(HALO_LAYER, ['==', ['get', 'cluster_id'], cluster?.clusterId ?? -1]);
|
||||
}, [ready, cluster]);
|
||||
|
||||
// Swap the thumbnail markers whenever the map settles. `idle` rather than `move`, so a drag costs one rebuild
|
||||
// at the end instead of one per frame.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !ready) return;
|
||||
|
||||
const refresh = () => {
|
||||
if (!map.getLayer(POINT_LAYER)) return;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const picked: { id: string; at: [number, number] }[] = [];
|
||||
for (const feature of map.queryRenderedFeatures({ layers: [POINT_LAYER] })) {
|
||||
const id = feature.properties?.id;
|
||||
if (typeof id !== 'string' || seen.has(id) || feature.geometry.type !== 'Point') continue;
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
if (typeof lon !== 'number' || typeof lat !== 'number') continue;
|
||||
seen.add(id);
|
||||
picked.push({ id, at: [lon, lat] });
|
||||
if (picked.length >= THUMB_LIMIT) break;
|
||||
}
|
||||
|
||||
// Rebuilding identical markers would restart every image fade for no reason.
|
||||
const signature = picked.map((item) => item.id).join(',');
|
||||
if (signature === shownRef.current) return;
|
||||
shownRef.current = signature;
|
||||
|
||||
thumbsRef.current.forEach((marker) => marker.remove());
|
||||
thumbsRef.current = picked.map(({ id, at }) =>
|
||||
new Marker({ element: makeThumbElement(id, token, open) }).setLngLat(at).addTo(map),
|
||||
);
|
||||
setVisibleIds(picked.map((item) => item.id));
|
||||
};
|
||||
|
||||
map.on('idle', refresh);
|
||||
return () => {
|
||||
map.off('idle', refresh);
|
||||
};
|
||||
}, [ready, token, open]);
|
||||
|
||||
const toggle = (key: BoolFilterKey) => {
|
||||
// The open cluster is a snapshot of ids from the previous marker set; a filter change can only make it a
|
||||
// list of photos that are no longer on the map.
|
||||
setCluster(null);
|
||||
setFilter((current) => ({ ...current, [key]: current[key] ? undefined : true }));
|
||||
};
|
||||
|
||||
const count = markers?.length ?? 0;
|
||||
|
||||
// Name the open cluster from the markers' own reverse-geocoding. Computed here rather than at click time
|
||||
// because the click handler is registered once and would close over the first marker set forever.
|
||||
const place = useMemo(() => {
|
||||
if (!cluster || !markers) return null;
|
||||
|
||||
const byId = new Map(markers.map((marker) => [marker.id, marker]));
|
||||
const counts = new Map<string, number>();
|
||||
for (const id of cluster.ids) {
|
||||
const marker = byId.get(id);
|
||||
const name = [marker?.city, marker?.country].filter(Boolean).join(', ');
|
||||
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const ranked = [...counts].sort(([, a], [, b]) => b - a);
|
||||
const top = ranked[0]?.[0];
|
||||
if (!top) return null;
|
||||
return ranked.length > 1 ? `${top} +${ranked.length - 1} more` : top;
|
||||
}, [cluster, markers]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Map</h1>
|
||||
{/* A failed marker fetch used to look identical to an empty library: the map simply stayed on its
|
||||
opening view. Say so instead — the basemap can be perfectly fine while the pins are missing. */}
|
||||
<span className={`text-xs ${markersError ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{markersError
|
||||
? `Markers failed: ${markersError instanceof Error ? markersError.message : 'request failed'}`
|
||||
: isLoading
|
||||
? 'Loading…'
|
||||
: `${count.toLocaleString()} geotagged`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{TOGGLES.map(({ key, label, title }) => (
|
||||
<Button
|
||||
key={key}
|
||||
size="sm"
|
||||
variant={filter[key] ? 'default' : 'ghost'}
|
||||
title={title}
|
||||
onClick={() => toggle(key)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</header>
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{/*
|
||||
Sized with h-full/w-full, NOT `absolute inset-0`, and that is not a style preference.
|
||||
|
||||
maplibre puts `position: relative; overflow: hidden` on whatever element it is handed, via its own
|
||||
stylesheet's `.maplibregl-map`. Tailwind 4 emits its utilities inside `@layer utilities`, and an
|
||||
UNLAYERED rule beats a layered one no matter which comes later in the file — so `.maplibregl-map`
|
||||
wins over `.absolute`, `inset-0` stops applying, and the div collapses to height 0. It then clips
|
||||
its own canvas away, which looks exactly like a map that failed to load: the GL context is healthy,
|
||||
tiles download, pixels are drawn, and none of it is ever composited. Nothing logs a thing.
|
||||
|
||||
h-full survives because maplibre's stylesheet sets no height, so there is no conflict to lose.
|
||||
*/}
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{!styleUrl && !mapError && (
|
||||
<p className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Loading map…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mapError && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">The map could not load</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
The basemap is fetched from Immich's tile server, so this needs outbound internet access.
|
||||
{mapError ? ` (${mapError})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cluster && (
|
||||
<MapClusterPanel
|
||||
cluster={cluster}
|
||||
place={place}
|
||||
token={token}
|
||||
onOpen={open}
|
||||
onZoom={() => mapRef.current?.easeTo({ center: cluster.center, zoom: cluster.expansionZoom })}
|
||||
onClose={() => setCluster(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ready && !isLoading && count === 0 && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center">
|
||||
<p className="rounded-full bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow">
|
||||
No geotagged photos match this filter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* With a cluster open, prev/next walk that cluster — otherwise the arrows would jump to whatever
|
||||
thumbnails happen to be on the map behind the panel. */}
|
||||
<AssetViewer ids={cluster ? cluster.ids : visibleIds} token={token} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { TimelineGrid } from './TimelineGrid';
|
||||
import { PERSON_PARAM, personThumbUrl } from './shared';
|
||||
import { useBucketAssets, usePeople, usePerson, useTimeBuckets } from './usePhotosData';
|
||||
import { useSelection } from './useSelection';
|
||||
|
||||
// Faces. The wall of people, and one person's photos behind `?person=<id>`.
|
||||
//
|
||||
// A person's photos come from the same bucketed timeline endpoint with `personId` set, so a face with 12,000
|
||||
// photos loads the same way the main timeline does rather than as one enormous response.
|
||||
|
||||
export const PeopleSection = () => {
|
||||
const [params] = useSearchParams();
|
||||
const personId = params.get(PERSON_PARAM);
|
||||
return personId ? <PersonDetailView personId={personId} /> : <PeopleListView />;
|
||||
};
|
||||
|
||||
const PeopleListView = () => {
|
||||
const { token } = useClient();
|
||||
const { data, isLoading } = usePeople();
|
||||
|
||||
// Immich hides unnamed faces behind a toggle; the named ones are what anyone actually browses by.
|
||||
const [showUnnamed, setShowUnnamed] = useState(false);
|
||||
const people = useMemo(
|
||||
() => (data?.people ?? []).filter((person) => showUnnamed || person.name),
|
||||
[data, showUnnamed],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">People</h1>
|
||||
<span className="text-xs text-muted-foreground">{people.length}</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowUnnamed((value) => !value)}>
|
||||
{showUnnamed ? 'Hide unnamed' : 'Show unnamed'}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
{!isLoading && people.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No people yet — Immich populates this as faces are recognised.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-4">
|
||||
{people.map((person) => (
|
||||
<Link key={person.id} to={`?${PERSON_PARAM}=${person.id}`} className="group text-center">
|
||||
<img
|
||||
src={personThumbUrl(person.id, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="mx-auto aspect-square w-full rounded-full object-cover ring-1 ring-white/10 transition group-hover:ring-primary"
|
||||
/>
|
||||
<span className="mt-2 block truncate text-sm">{person.name || 'Unnamed'}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PersonDetailView = ({ personId }: { personId: string }) => {
|
||||
const { token } = useClient();
|
||||
const [params] = useSearchParams();
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [loaded, setLoaded] = useState<string[]>([]);
|
||||
|
||||
const { data: person } = usePerson(personId);
|
||||
const filter = useMemo(() => ({ personId }), [personId]);
|
||||
const { data: buckets } = useTimeBuckets(filter);
|
||||
const assetsByBucket = useBucketAssets(filter, loaded);
|
||||
|
||||
const orderedIds = useMemo(
|
||||
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
|
||||
[buckets, assetsByBucket],
|
||||
);
|
||||
const selection = useSelection(orderedIds);
|
||||
|
||||
const back = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete(PERSON_PARAM);
|
||||
const search = next.toString();
|
||||
return search ? `?${search}` : '?';
|
||||
})();
|
||||
|
||||
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{selection.isSelecting ? (
|
||||
<SelectionBar selection={selection} />
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Button asChild variant="ghost" size="icon" title="Back to people">
|
||||
<Link to={back}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<img src={personThumbUrl(personId, token)} alt="" className="h-7 w-7 rounded-full object-cover" />
|
||||
<h1 className="truncate text-sm font-semibold">{person?.name || 'Unnamed'}</h1>
|
||||
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
|
||||
<TimelineGrid
|
||||
buckets={buckets ?? []}
|
||||
assetsByBucket={assetsByBucket}
|
||||
onBucketVisible={(bucket) => setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]))}
|
||||
token={token}
|
||||
selection={selection}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={orderedIds} token={token} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { GridAsset } from './shared';
|
||||
import type { Selection } from './useSelection';
|
||||
import { useMemo } from 'react';
|
||||
import { AssetTile } from './AssetTile';
|
||||
import { justifyRows } from './shared';
|
||||
import { useContainerWidth } from './useContainerWidth';
|
||||
|
||||
// A justified grid over a flat list of assets — albums, search results, a person's photos.
|
||||
//
|
||||
// The timeline uses TimelineGrid instead, because it has to lay out months it has not downloaded yet. This one
|
||||
// always has every asset in hand, so it can lay the whole thing out in one pass.
|
||||
|
||||
type PhotoGridProps = {
|
||||
assets: GridAsset[];
|
||||
token: string | null;
|
||||
selection: Selection;
|
||||
rowHeight?: number;
|
||||
gap?: number;
|
||||
empty?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const PhotoGrid = ({ assets, token, selection, rowHeight = 210, gap = 4, empty }: PhotoGridProps) => {
|
||||
const [ref, width] = useContainerWidth<HTMLDivElement>();
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
width > 0
|
||||
? justifyRows({ items: assets, ratioOf: (a) => a.ratio, containerWidth: width, targetHeight: rowHeight, gap })
|
||||
: [],
|
||||
[assets, width, rowHeight, gap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{assets.length === 0 && empty}
|
||||
<div className="flex flex-col" style={{ gap }}>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="flex" style={{ gap }}>
|
||||
{row.tiles.map(({ item, width: w, height: h }) => (
|
||||
<AssetTile
|
||||
key={item.id}
|
||||
asset={item}
|
||||
token={token}
|
||||
width={w}
|
||||
height={h}
|
||||
selected={selection.selected.has(item.id)}
|
||||
selecting={selection.isSelecting}
|
||||
onToggle={(id, ev) => selection.toggle(id, ev)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Archive, Compass, Heart, Images, Map as MapIcon, Plug, Search, Share2, Trash2, Users } from 'lucide-react';
|
||||
import { AccountSwitcher } from './AccountSwitcher';
|
||||
import { PHOTOS_SECTIONS, photosSectionPath, type PhotosSectionId } from './shared';
|
||||
import { useAssetStats } from './usePhotosData';
|
||||
|
||||
// Left panel of the /photos workspace, mirroring Immich's own sidebar: library sections up top, the sharing
|
||||
// and cleanup ones below a divider, and the library size at the bottom.
|
||||
//
|
||||
// Sections are react-router NavLinks to /photos/<section> — active state from the router, cmd-click works.
|
||||
|
||||
const ICONS: Record<PhotosSectionId, LucideIcon> = {
|
||||
timeline: Images,
|
||||
explore: Compass,
|
||||
map: MapIcon,
|
||||
search: Search,
|
||||
albums: Images,
|
||||
people: Users,
|
||||
favorites: Heart,
|
||||
archive: Archive,
|
||||
sharing: Share2,
|
||||
trash: Trash2,
|
||||
settings: Plug,
|
||||
};
|
||||
|
||||
// Immich draws the same line: browsing the library, then everything else.
|
||||
const LIBRARY: PhotosSectionId[] = ['timeline', 'explore', 'map', 'search', 'albums', 'people', 'favorites'];
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const PhotosNav = () => {
|
||||
const { data: stats } = useAssetStats();
|
||||
|
||||
const group = (ids: PhotosSectionId[]) =>
|
||||
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={photosSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
});
|
||||
|
||||
const rest = PHOTOS_SECTIONS.map((section) => section.id).filter((id) => !LIBRARY.includes(id));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/15 text-emerald-400 ring-1 ring-black/5">
|
||||
<Images className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
||||
<AccountSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2">{group(LIBRARY)}</nav>
|
||||
<div className="mx-4 my-2 border-t" />
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">{group(rest)}</nav>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{stats && (
|
||||
<div className="border-t px-4 py-3 text-xs text-muted-foreground">
|
||||
<p>{stats.total.toLocaleString()} items</p>
|
||||
<p>
|
||||
{stats.images.toLocaleString()} photos · {stats.videos.toLocaleString()} videos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { AlbumsSection } from './AlbumsSection';
|
||||
import { ConnectionSection } from './ConnectionSection';
|
||||
import { ExploreSection } from './ExploreSection';
|
||||
import { MapSection } from './MapSection';
|
||||
import { PeopleSection } from './PeopleSection';
|
||||
import { SearchSection } from './SearchSection';
|
||||
import { SharingSection } from './SharingSection';
|
||||
import { TimelineSection } from './TimelineSection';
|
||||
import { photosSectionPath } from './shared';
|
||||
import { usePhotosHealth } from './usePhotosData';
|
||||
import { usePhotosSection } from './usePhotosSection';
|
||||
|
||||
// Right panel of the /photos workspace — renders the section named by the URL.
|
||||
//
|
||||
// Timeline, favorites, archive and trash are one component with a filter; the rest are their own.
|
||||
//
|
||||
// Health gates all of them, and its two failure modes are answered differently: nothing configured yet is
|
||||
// the setup form, whatever section was asked for, because there is nothing else useful to show; a stored
|
||||
// instance that is failing keeps its own message and a way back to the connection screen, since replacing a
|
||||
// working key by accident is worse than a wall of text.
|
||||
|
||||
export const PhotosView = () => {
|
||||
const section = usePhotosSection();
|
||||
const { data: health, isLoading } = usePhotosHealth();
|
||||
|
||||
if (section === 'settings') return <ConnectionSection />;
|
||||
|
||||
if (!isLoading && health && !health.ok) {
|
||||
if (health.configured === false) return <ConnectionSection />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
|
||||
<p className="text-sm font-medium">Photos is not answering</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
{health.error ?? 'The photos sidecar could not reach the configured Immich instance.'}
|
||||
</p>
|
||||
<Link to={photosSectionPath('settings')} className="mt-2 text-xs font-medium text-primary hover:underline">
|
||||
Check the connection
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (section) {
|
||||
case 'explore':
|
||||
return <ExploreSection />;
|
||||
case 'map':
|
||||
return <MapSection />;
|
||||
case 'search':
|
||||
return <SearchSection />;
|
||||
case 'albums':
|
||||
return <AlbumsSection />;
|
||||
case 'people':
|
||||
return <PeopleSection />;
|
||||
case 'sharing':
|
||||
return <SharingSection />;
|
||||
case 'favorites':
|
||||
case 'archive':
|
||||
case 'trash':
|
||||
case 'timeline':
|
||||
default:
|
||||
return <TimelineSection section={section} />;
|
||||
}
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Images } from 'lucide-react';
|
||||
import { PHOTOS_SECTIONS } from './shared';
|
||||
import { usePhotosSection } from './usePhotosSection';
|
||||
|
||||
// Panel header for the right (photos-view) panel.
|
||||
|
||||
export const PhotosViewHeader = () => {
|
||||
const section = usePhotosSection();
|
||||
const label = PHOTOS_SECTIONS.find((s) => s.id === section)?.label ?? 'Photos';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Images className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">{label}</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Search, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { ALBUM_PARAM, QUERY_PARAM, photosSectionPath, thumbUrl, toGridAsset } from './shared';
|
||||
import { useSearch } from './usePhotosData';
|
||||
import { useSelection } from './useSelection';
|
||||
|
||||
// Search. The query lives in `?q=`, so a search is a link — the same reason the lightbox lives in `?asset=`.
|
||||
//
|
||||
// Smart search is Immich's CLIP model ("red bicycle", "birthday cake") and is the default because it is the
|
||||
// thing Immich has that a file browser does not. The toggle falls back to filename/metadata matching.
|
||||
|
||||
export const SearchSection = () => {
|
||||
const { token } = useClient();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const submitted = params.get(QUERY_PARAM) ?? '';
|
||||
|
||||
const [draft, setDraft] = useState(submitted);
|
||||
const [smart, setSmart] = useState(true);
|
||||
|
||||
// The box follows the URL, so a link into /photos/search?q=… arrives with its own query already typed.
|
||||
useEffect(() => setDraft(submitted), [submitted]);
|
||||
|
||||
const { data, isFetching } = useSearch({ query: submitted, smart, enabled: submitted.length > 0 });
|
||||
|
||||
const assets = useMemo(() => (data?.assets.items ?? []).map(toGridAsset), [data]);
|
||||
const selection = useSelection(assets.map((asset) => asset.id));
|
||||
|
||||
const submit = (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
setParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
if (draft.trim()) next.set(QUERY_PARAM, draft.trim());
|
||||
else next.delete(QUERY_PARAM);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{selection.isSelecting ? (
|
||||
<SelectionBar selection={selection} />
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<form onSubmit={submit} className="flex flex-1 items-center gap-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
placeholder={smart ? 'Describe a photo — "sunset over water"' : 'Filename or path'}
|
||||
className="h-8"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="secondary">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
variant={smart ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
title="Smart search uses Immich's CLIP model instead of matching filenames"
|
||||
onClick={() => setSmart((value) => !value)}
|
||||
>
|
||||
<Sparkles className="mr-1.5 h-4 w-4" />
|
||||
Smart
|
||||
</Button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{!submitted && <p className="p-2 text-sm text-muted-foreground">Search your library.</p>}
|
||||
{submitted && isFetching && <p className="p-2 text-sm text-muted-foreground">Searching…</p>}
|
||||
|
||||
{data && data.albums.items.length > 0 && (
|
||||
<section className="mb-4">
|
||||
<h2 className="mb-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Albums</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.albums.items.map((album) => (
|
||||
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`} className="w-32">
|
||||
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||
{album.albumThumbnailAssetId && (
|
||||
<img
|
||||
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs">{album.albumName}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{submitted && !isFetching && (
|
||||
<PhotoGrid
|
||||
assets={assets}
|
||||
token={token}
|
||||
selection={selection}
|
||||
empty={<p className="p-2 text-sm text-muted-foreground">No matches.</p>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { Selection } from './useSelection';
|
||||
import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAlbums } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
// The bar that replaces the header once anything is selected — Immich does the same, and it keeps the
|
||||
// destructive buttons off screen entirely until they have something to act on.
|
||||
|
||||
type SelectionBarProps = {
|
||||
selection: Selection;
|
||||
/** Trash swaps the whole action set: restore and delete-forever, not favorite and archive. */
|
||||
inTrash?: boolean;
|
||||
/** When viewing one album, offer "remove from album" alongside the library-wide actions. */
|
||||
albumId?: string;
|
||||
/** Currently in the archive view, so the archive button should read "unarchive". */
|
||||
archived?: boolean;
|
||||
};
|
||||
|
||||
export const SelectionBar = ({ selection, inTrash = false, albumId, archived = false }: SelectionBarProps) => {
|
||||
const { ids, count, clear, selectAll } = selection;
|
||||
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
|
||||
useAssetActions();
|
||||
const { data: albums } = useAlbums();
|
||||
|
||||
const run = (action: () => void) => {
|
||||
action();
|
||||
clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b px-2 py-1.5">
|
||||
<Button variant="ghost" size="icon" onClick={clear} title="Clear selection">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="mr-2 text-sm font-medium">{count} selected</span>
|
||||
<Button variant="ghost" size="sm" onClick={selectAll}>
|
||||
Select all
|
||||
</Button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{inTrash ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => run(() => restore.mutate(ids))}>
|
||||
<Undo2 className="mr-1.5 h-4 w-4" />
|
||||
Restore
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => run(() => deleteForever.mutate(ids))}>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
Delete permanently
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Add to album
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const albumName = window.prompt('New album name');
|
||||
if (albumName) run(() => createAlbum.mutate({ albumName, assetIds: ids }));
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New album
|
||||
</DropdownMenuItem>
|
||||
{albums && albums.length > 0 && <DropdownMenuSeparator />}
|
||||
{albums?.map((album) => (
|
||||
<DropdownMenuItem
|
||||
key={album.id}
|
||||
onClick={() => run(() => addToAlbum.mutate({ albumId: album.id, ids }))}
|
||||
>
|
||||
{album.albumName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{albumId && (
|
||||
<Button variant="ghost" size="sm" onClick={() => run(() => removeFromAlbum.mutate({ albumId, ids }))}>
|
||||
Remove from album
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Favorite"
|
||||
onClick={() => run(() => setFavorite.mutate({ ids, isFavorite: true }))}
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={archived ? 'Unarchive' : 'Archive'}
|
||||
onClick={() => run(() => setArchived.mutate({ ids, archived: !archived }))}
|
||||
>
|
||||
{archived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Move to trash" onClick={() => run(() => trash.mutate(ids))}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Copy, ImageOff, Link2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Sharing: shared albums and shared links, read-mostly.
|
||||
//
|
||||
// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
|
||||
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
|
||||
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
|
||||
|
||||
export const SharingSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: links, isLoading } = useSharedLinks();
|
||||
const { data: albums } = useAlbums();
|
||||
|
||||
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">Sharing</h1>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold">Shared albums</h2>
|
||||
{sharedAlbums.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No shared albums.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||
{sharedAlbums.map((album) => (
|
||||
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`}>
|
||||
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<img
|
||||
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{album.assetCount} items · {album.albumUsers.length} people
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-semibold">Shared links</h2>
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
{!isLoading && (links ?? []).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No shared links.</p>
|
||||
)}
|
||||
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{(links ?? []).map((link) => (
|
||||
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
||||
{link.allowDownload ? ' · download' : ''}
|
||||
{link.allowUpload ? ' · upload' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy share key — append it to your instance's /share/ URL"
|
||||
onClick={() => {
|
||||
void copyToClipboard(link.key);
|
||||
toast.success('Share key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { GridAsset, TimeBucket } from './shared';
|
||||
import type { Selection } from './useSelection';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { AssetTile } from './AssetTile';
|
||||
import { estimateBucketHeight, formatBucket, justifyRows } from './shared';
|
||||
import { useContainerWidth } from './useContainerWidth';
|
||||
|
||||
// The month-bucketed timeline.
|
||||
//
|
||||
// Every month in the library is rendered from the moment the screen opens, but only as a correctly-sized empty
|
||||
// box: the bucket index gives a count, and `estimateBucketHeight` turns a count into a height. That is what
|
||||
// makes the scrollbar honest before anything is downloaded — the page is already its true length, so dragging
|
||||
// to the bottom lands on the oldest month instead of on a spinner that then pushes the content away.
|
||||
//
|
||||
// An IntersectionObserver with a tall rootMargin turns a box into a real request just before it reaches the
|
||||
// viewport. Loaded months are never unloaded; React Query holds them and scrolling back up is instant.
|
||||
|
||||
const ROW_HEIGHT = 210;
|
||||
const GAP = 4;
|
||||
|
||||
type TimelineGridProps = {
|
||||
buckets: TimeBucket[];
|
||||
assetsByBucket: Record<string, GridAsset[]>;
|
||||
onBucketVisible: (bucket: string) => void;
|
||||
token: string | null;
|
||||
selection: Selection;
|
||||
/** The scroll container the observer should watch. Panels scroll their own body, not the window. */
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export const TimelineGrid = ({
|
||||
buckets,
|
||||
assetsByBucket,
|
||||
onBucketVisible,
|
||||
token,
|
||||
selection,
|
||||
scrollRef,
|
||||
}: TimelineGridProps) => {
|
||||
const [ref, width] = useContainerWidth<HTMLDivElement>();
|
||||
const nodes = useRef(new Map<string, HTMLElement>());
|
||||
const notify = useRef(onBucketVisible);
|
||||
notify.current = onBucketVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (!width) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const bucket = entry.target.getAttribute('data-bucket');
|
||||
if (entry.isIntersecting && bucket) notify.current(bucket);
|
||||
}
|
||||
},
|
||||
// Two viewports of lead time in each direction: enough that a fast scroll rarely outruns the fetch,
|
||||
// small enough that opening the screen doesn't request the entire library.
|
||||
{ root: scrollRef.current ?? null, rootMargin: '200% 0px' },
|
||||
);
|
||||
|
||||
for (const node of nodes.current.values()) observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [width, buckets, scrollRef]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{buckets.map((bucket) => (
|
||||
<BucketSection
|
||||
key={bucket.timeBucket}
|
||||
bucket={bucket}
|
||||
assets={assetsByBucket[bucket.timeBucket]}
|
||||
width={width}
|
||||
token={token}
|
||||
selection={selection}
|
||||
register={(node) => {
|
||||
if (node) nodes.current.set(bucket.timeBucket, node);
|
||||
else nodes.current.delete(bucket.timeBucket);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BucketSectionProps = {
|
||||
bucket: TimeBucket;
|
||||
assets: GridAsset[] | undefined;
|
||||
width: number;
|
||||
token: string | null;
|
||||
selection: Selection;
|
||||
register: (node: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
const BucketSection = ({ bucket, assets, width, token, selection, register }: BucketSectionProps) => {
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
assets && width > 0
|
||||
? justifyRows({
|
||||
items: assets,
|
||||
ratioOf: (a) => a.ratio,
|
||||
containerWidth: width,
|
||||
targetHeight: ROW_HEIGHT,
|
||||
gap: GAP,
|
||||
})
|
||||
: [],
|
||||
[assets, width],
|
||||
);
|
||||
|
||||
// Until the month arrives, hold its place with the height it will occupy. The estimate uses the same row
|
||||
// height and gap the real layout will, so the jump when it loads is a few pixels rather than a page.
|
||||
const placeholder =
|
||||
!assets && width > 0
|
||||
? estimateBucketHeight({ count: bucket.count, containerWidth: width, rowHeight: ROW_HEIGHT, gap: GAP })
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section ref={register} data-bucket={bucket.timeBucket} className="mb-6">
|
||||
<h2 className="sticky top-0 z-10 -mx-1 mb-2 bg-background/85 px-1 py-1.5 text-sm font-semibold backdrop-blur">
|
||||
{formatBucket(bucket.timeBucket)}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">{bucket.count}</span>
|
||||
</h2>
|
||||
|
||||
{assets ? (
|
||||
<div className="flex flex-col" style={{ gap: GAP }}>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="flex" style={{ gap: GAP }}>
|
||||
{row.tiles.map(({ item, width: w, height: h }) => (
|
||||
<AssetTile
|
||||
key={item.id}
|
||||
asset={item}
|
||||
token={token}
|
||||
width={w}
|
||||
height={h}
|
||||
selected={selection.selected.has(item.id)}
|
||||
selecting={selection.isSelecting}
|
||||
onToggle={(id, ev) => selection.toggle(id, ev)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: placeholder }} className="w-full rounded-sm bg-white/[0.03]" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { PhotosSectionId } from './shared';
|
||||
import type { TimelineFilter } from './usePhotosData';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { TimelineGrid } from './TimelineGrid';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
import { useBucketAssets, useTimeBuckets } from './usePhotosData';
|
||||
import { useSelection } from './useSelection';
|
||||
|
||||
// Timeline, Favorites, Archive and Trash are the same screen with a different filter — which is also true in
|
||||
// Immich, and is why the bucket API takes the filter as query params rather than having four endpoints.
|
||||
|
||||
const FILTERS: Partial<Record<PhotosSectionId, TimelineFilter>> = {
|
||||
timeline: { visibility: 'timeline', withPartners: true, withStacked: true },
|
||||
favorites: { isFavorite: true, withStacked: true },
|
||||
archive: { visibility: 'archive', withStacked: true },
|
||||
trash: { isTrashed: true },
|
||||
};
|
||||
|
||||
const TITLES: Partial<Record<PhotosSectionId, string>> = {
|
||||
timeline: 'Photos',
|
||||
favorites: 'Favorites',
|
||||
archive: 'Archive',
|
||||
trash: 'Trash',
|
||||
};
|
||||
|
||||
type TimelineSectionProps = { section: PhotosSectionId };
|
||||
|
||||
export const TimelineSection = ({ section }: TimelineSectionProps) => {
|
||||
const { token } = useClient();
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [loaded, setLoaded] = useState<string[]>([]);
|
||||
const { emptyTrash } = useAssetActions();
|
||||
|
||||
const filter = FILTERS[section] ?? FILTERS.timeline!;
|
||||
const inTrash = section === 'trash';
|
||||
|
||||
const { data: buckets, isLoading } = useTimeBuckets(filter);
|
||||
const assetsByBucket = useBucketAssets(filter, loaded);
|
||||
|
||||
const orderedIds = useMemo(
|
||||
() => (buckets ?? []).flatMap((bucket) => (assetsByBucket[bucket.timeBucket] ?? []).map((asset) => asset.id)),
|
||||
[buckets, assetsByBucket],
|
||||
);
|
||||
const selection = useSelection(orderedIds);
|
||||
|
||||
const onBucketVisible = useCallback((bucket: string) => {
|
||||
setLoaded((prev) => (prev.includes(bucket) ? prev : [...prev, bucket]));
|
||||
}, []);
|
||||
|
||||
const total = (buckets ?? []).reduce((sum, bucket) => sum + bucket.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{selection.isSelecting ? (
|
||||
<SelectionBar selection={selection} inTrash={inTrash} archived={section === 'archive'} />
|
||||
) : (
|
||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<h1 className="text-sm font-semibold">{TITLES[section] ?? 'Photos'}</h1>
|
||||
<span className="text-xs text-muted-foreground">{total.toLocaleString()} items</span>
|
||||
<div className="flex-1" />
|
||||
{inTrash && total > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.confirm(`Permanently delete ${total} items?`) && emptyTrash.mutate()}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
Empty trash
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-2 pt-2">
|
||||
{isLoading && <p className="p-4 text-sm text-muted-foreground">Loading…</p>}
|
||||
{!isLoading && total === 0 && <p className="p-4 text-sm text-muted-foreground">Nothing here.</p>}
|
||||
<TimelineGrid
|
||||
buckets={buckets ?? []}
|
||||
assetsByBucket={assetsByBucket}
|
||||
onBucketVisible={onBucketVisible}
|
||||
token={token}
|
||||
selection={selection}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={orderedIds} token={token} inTrash={inTrash} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { PhotosNav } from './PhotosNav';
|
||||
import { PhotosView } from './PhotosView';
|
||||
import { PhotosViewHeader } from './PhotosViewHeader';
|
||||
|
||||
export { PhotosNav, PhotosView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'photos-nav', name: 'Photos', icon: PanelLeft, component: PhotosNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'photos-view',
|
||||
name: 'Photos',
|
||||
icon: LayoutGrid,
|
||||
component: PhotosView,
|
||||
header: PhotosViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -1,400 +0,0 @@
|
||||
// Shared types, constants and pure helpers for the /photos workspace panels.
|
||||
//
|
||||
// Unlike a plugin whose sidecar reshapes an upstream API into Officer's own types — offscale does — the
|
||||
// photos sidecar is a deliberate pass-through, so these types mirror Immich's wire shapes directly. They were
|
||||
// generated from the LIVE instance's OpenAPI document (`GET /api/spec.json`, Immich 3.0.3), not from memory:
|
||||
// several of them changed shape in 3.0 (`isArchived` became `visibility`, and the timeline bucket response
|
||||
// became columnar), so anything written from an older mental model would be subtly wrong.
|
||||
|
||||
export const PHOTOS_SECTIONS = [
|
||||
{ id: 'timeline', label: 'Photos' },
|
||||
{ id: 'explore', label: 'Explore' },
|
||||
{ id: 'map', label: 'Map' },
|
||||
{ id: 'search', label: 'Search' },
|
||||
{ id: 'albums', label: 'Albums' },
|
||||
{ id: 'people', label: 'People' },
|
||||
{ id: 'favorites', label: 'Favorites' },
|
||||
{ id: 'archive', label: 'Archive' },
|
||||
{ id: 'sharing', label: 'Sharing' },
|
||||
{ id: 'trash', label: 'Trash' },
|
||||
{ id: 'settings', label: 'Connection' },
|
||||
] as const;
|
||||
|
||||
export type PhotosSectionId = (typeof PHOTOS_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /photos lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_PHOTOS_SECTION: PhotosSectionId = 'timeline';
|
||||
|
||||
export const isPhotosSection = (value: string | undefined): value is PhotosSectionId =>
|
||||
PHOTOS_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
|
||||
export const photosSectionPath = (id: PhotosSectionId) => `/photos/${id}`;
|
||||
|
||||
/** Which asset is open in the lightbox — `?asset=<id>`. */
|
||||
export const ASSET_PARAM = 'asset';
|
||||
/** Which album is open — `?album=<id>`. */
|
||||
export const ALBUM_PARAM = 'album';
|
||||
/** Which person is open — `?person=<id>`. */
|
||||
export const PERSON_PARAM = 'person';
|
||||
/** The search text — `?q=…`. */
|
||||
export const QUERY_PARAM = 'q';
|
||||
|
||||
// ── Wire types ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AssetVisibility = 'archive' | 'timeline' | 'hidden' | 'locked';
|
||||
export type AssetType = 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER';
|
||||
|
||||
export type ExifInfo = {
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
lensModel?: string | null;
|
||||
exifImageWidth?: number | null;
|
||||
exifImageHeight?: number | null;
|
||||
fileSizeInByte?: number | null;
|
||||
dateTimeOriginal?: string | null;
|
||||
fNumber?: number | null;
|
||||
focalLength?: number | null;
|
||||
iso?: number | null;
|
||||
exposureTime?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
country?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
type: AssetType;
|
||||
thumbhash: string | null;
|
||||
localDateTime: string;
|
||||
fileCreatedAt: string;
|
||||
/** Milliseconds, null for stills. It was an `HH:MM:SS.mmm` string before Immich 3.0 — it is a number now. */
|
||||
duration: number | null;
|
||||
livePhotoVideoId?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
ownerId: string;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
originalMimeType?: string;
|
||||
isFavorite: boolean;
|
||||
isArchived: boolean;
|
||||
isTrashed: boolean;
|
||||
isOffline: boolean;
|
||||
visibility: AssetVisibility;
|
||||
exifInfo?: ExifInfo;
|
||||
people?: Person[];
|
||||
stack?: { id: string; primaryAssetId: string; assetCount: number } | null;
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
id: string;
|
||||
albumName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
shared: boolean;
|
||||
hasSharedLink: boolean;
|
||||
assetCount: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
albumUsers: { user: { id: string; name: string; email: string } }[];
|
||||
};
|
||||
|
||||
/** `GET /albums/{id}` returns the album with its assets attached. */
|
||||
export type AlbumDetail = Album & { assets?: Asset[] };
|
||||
|
||||
export type Person = {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: string | null;
|
||||
thumbnailPath: string;
|
||||
isHidden: boolean;
|
||||
isFavorite?: boolean;
|
||||
};
|
||||
|
||||
export type PeopleResponse = { total: number; hidden: number; people: Person[]; hasNextPage?: boolean };
|
||||
|
||||
export type SearchResponse = {
|
||||
assets: { total: number; count: number; items: Asset[]; nextPage: string | null };
|
||||
albums: { total: number; count: number; items: Album[] };
|
||||
};
|
||||
|
||||
export type ExploreItem = { value: string; data: Asset };
|
||||
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
|
||||
|
||||
export type SharedLink = {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
album?: Album | null;
|
||||
assets: Asset[];
|
||||
description?: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
};
|
||||
|
||||
export type TimeBucket = { timeBucket: string; count: number };
|
||||
|
||||
/** `GET /map/markers` — one row per geotagged asset. Deliberately tiny: the whole library fits in one response. */
|
||||
export type MapMarker = { id: string; lat: number; lon: number; city: string | null; country: string | null };
|
||||
|
||||
/**
|
||||
* The slice of `GET /server/config` this UI needs.
|
||||
*
|
||||
* The map style is Immich's own, fetched by the browser straight from `tiles.immich.cloud` — the same place
|
||||
* Immich's web app gets it, and the only part of /photos that talks to anything other than Officer. It is not
|
||||
* proxied because a vector basemap is thousands of tile requests that have nothing to do with the library.
|
||||
*/
|
||||
export type ServerConfig = { mapDarkStyleUrl: string; mapLightStyleUrl: string; version?: string };
|
||||
|
||||
/**
|
||||
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
|
||||
* because a month of assets is tens of thousands of near-identical objects and the array-of-arrays form is a
|
||||
* fraction of the JSON. Zip it with `bucketAssets()` before touching it.
|
||||
*/
|
||||
export type TimeBucketColumns = {
|
||||
id: string[];
|
||||
ownerId: string[];
|
||||
ratio: number[];
|
||||
isFavorite: boolean[];
|
||||
visibility: AssetVisibility[];
|
||||
isTrashed: boolean[];
|
||||
isImage: boolean[];
|
||||
thumbhash: (string | null)[];
|
||||
createdAt: string[];
|
||||
fileCreatedAt: string[];
|
||||
localOffsetHours: number[];
|
||||
/** Milliseconds, null for stills — same units as `Asset.duration`. */
|
||||
duration: (number | null)[];
|
||||
stack?: (string[] | null)[];
|
||||
projectionType: (string | null)[];
|
||||
livePhotoVideoId: (string | null)[];
|
||||
city?: (string | null)[];
|
||||
country?: (string | null)[];
|
||||
};
|
||||
|
||||
/** The minimum a tile needs to draw itself. Both the columnar timeline and a full Asset reduce to this. */
|
||||
export type GridAsset = {
|
||||
id: string;
|
||||
/** width / height. The whole point of the columnar payload: layout without loading a single image. */
|
||||
ratio: number;
|
||||
isFavorite: boolean;
|
||||
isImage: boolean;
|
||||
isTrashed: boolean;
|
||||
/** Milliseconds, null for stills. */
|
||||
duration: number | null;
|
||||
livePhotoVideoId: string | null;
|
||||
fileCreatedAt: string;
|
||||
stackCount: number;
|
||||
};
|
||||
|
||||
export function bucketAssets(cols: TimeBucketColumns): GridAsset[] {
|
||||
return cols.id.map((id, i) => ({
|
||||
id,
|
||||
ratio: cols.ratio[i] || 1,
|
||||
isFavorite: cols.isFavorite[i] ?? false,
|
||||
isImage: cols.isImage[i] ?? true,
|
||||
isTrashed: cols.isTrashed[i] ?? false,
|
||||
duration: cols.duration?.[i] ?? null,
|
||||
livePhotoVideoId: cols.livePhotoVideoId?.[i] ?? null,
|
||||
fileCreatedAt: cols.fileCreatedAt[i] ?? '',
|
||||
// stack is [stackId, assetCount] when present.
|
||||
stackCount: Number(cols.stack?.[i]?.[1] ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
export const toGridAsset = (a: Asset): GridAsset => ({
|
||||
id: a.id,
|
||||
ratio: a.height ? a.width / a.height : 1,
|
||||
isFavorite: a.isFavorite,
|
||||
isImage: a.type === 'IMAGE',
|
||||
isTrashed: a.isTrashed,
|
||||
duration: a.duration ?? null,
|
||||
livePhotoVideoId: a.livePhotoVideoId ?? null,
|
||||
fileCreatedAt: a.fileCreatedAt,
|
||||
stackCount: a.stack?.assetCount ?? 0,
|
||||
});
|
||||
|
||||
// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the same escape
|
||||
// hatch userMiddleware already supports for <audio>/<video> and the one the music app uses for cover art.
|
||||
// The photos sidecar strips the token before forwarding, so it never reaches Immich or its access log.
|
||||
|
||||
const withToken = (url: string, token: string | null) =>
|
||||
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
|
||||
|
||||
export const PHOTOS_API = '/api/photos/_officer';
|
||||
|
||||
/** `thumbnail` is the grid tile (~250px); `preview` is the lightbox; `fullsize` is the decoded original. */
|
||||
export const thumbUrl = (id: string, token: string | null, size: 'thumbnail' | 'preview' | 'fullsize' = 'thumbnail') =>
|
||||
withToken(`${PHOTOS_API}/assets/${id}/thumbnail?size=${size}`, token);
|
||||
|
||||
export const originalUrl = (id: string, token: string | null) =>
|
||||
withToken(`${PHOTOS_API}/assets/${id}/original`, token);
|
||||
|
||||
export const videoUrl = (id: string, token: string | null) =>
|
||||
withToken(`${PHOTOS_API}/assets/${id}/video/playback`, token);
|
||||
|
||||
export const personThumbUrl = (id: string, token: string | null) =>
|
||||
withToken(`${PHOTOS_API}/people/${id}/thumbnail`, token);
|
||||
|
||||
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const formatBytes = (bytes: number | null | undefined): string => {
|
||||
if (!bytes) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Milliseconds → the grid badge's `M:SS` (or `H:MM:SS` when it's long).
|
||||
*
|
||||
* Immich 3.0 sends durations as an integer count of milliseconds. Older versions sent an `HH:MM:SS.mmm`
|
||||
* string, which is worth knowing because every stale example on the internet still shows that form — and
|
||||
* `'…'.split(':')` on a number is a render-time TypeError, not a wrong label.
|
||||
*/
|
||||
export const formatDuration = (ms: number | null | undefined): string | null => {
|
||||
if (!ms || !Number.isFinite(ms)) return null;
|
||||
const total = Math.floor(ms / 1000);
|
||||
if (total <= 0) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
|
||||
};
|
||||
|
||||
const MONTH_YEAR = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' });
|
||||
const LONG_DATE = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
const SHORT_DATE = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
const TIME = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
/**
|
||||
* `2026-08-01` → `August 2026`.
|
||||
*
|
||||
* Parsed by hand, not with `new Date(bucket)`: a bare `YYYY-MM-DD` is read as UTC midnight, so formatting it
|
||||
* in a timezone west of UTC lands on the last day of the PREVIOUS month and the header reads July.
|
||||
*/
|
||||
export const formatBucket = (bucket: string): string => {
|
||||
const [year, month] = bucket.split('-').map(Number);
|
||||
if (!year || !month) return bucket;
|
||||
return MONTH_YEAR.format(new Date(year, month - 1, 1));
|
||||
};
|
||||
|
||||
export const formatLongDate = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '—';
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? '—' : LONG_DATE.format(date);
|
||||
};
|
||||
|
||||
export const formatShortDate = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '—';
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? '—' : SHORT_DATE.format(date);
|
||||
};
|
||||
|
||||
export const formatTime = (iso: string | null | undefined): string => {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
return Number.isNaN(date.getTime()) ? '' : TIME.format(date);
|
||||
};
|
||||
|
||||
/** `2020-01-05` + `2020-03-11` → `5 Jan 2020 – 11 Mar 2020`, collapsing to one when they match. */
|
||||
export const formatDateRange = (start?: string, end?: string): string | null => {
|
||||
if (!start && !end) return null;
|
||||
const a = start ? formatShortDate(start) : null;
|
||||
const b = end ? formatShortDate(end) : null;
|
||||
if (a && b && a !== b) return `${a} – ${b}`;
|
||||
return a ?? b;
|
||||
};
|
||||
|
||||
// ── Justified rows ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type LaidOutTile<T> = { item: T; width: number; height: number };
|
||||
export type LaidOutRow<T> = { tiles: LaidOutTile<T>[]; height: number };
|
||||
|
||||
type JustifyParams<T> = {
|
||||
items: T[];
|
||||
ratioOf: (item: T) => number;
|
||||
containerWidth: number;
|
||||
targetHeight: number;
|
||||
gap: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Immich's signature layout: variable-width tiles packed into rows of equal height, each row filling the
|
||||
* container exactly, so nothing is cropped and there are no ragged gaps.
|
||||
*
|
||||
* Greedy — take tiles at the target height until the row overflows, then scale the row down to fit. The last
|
||||
* row is left at the target height rather than stretched, because stretching four holiday photos across a
|
||||
* 2000px viewport makes them enormous.
|
||||
*/
|
||||
export function justifyRows<T>({
|
||||
items,
|
||||
ratioOf,
|
||||
containerWidth,
|
||||
targetHeight,
|
||||
gap,
|
||||
}: JustifyParams<T>): LaidOutRow<T>[] {
|
||||
if (containerWidth <= 0 || items.length === 0) return [];
|
||||
|
||||
// A panorama can be wider than the container on its own; clamp so one asset can't own a 12-wide row. The
|
||||
// SAME clamped value has to drive both the row's ratio sum and the tile's width, or the row is scaled to fit
|
||||
// a total it then overflows.
|
||||
const clampRatio = (item: T) => Math.min(Math.max(ratioOf(item) || 1, 0.2), 5);
|
||||
|
||||
const rows: LaidOutRow<T>[] = [];
|
||||
let current: T[] = [];
|
||||
let ratioSum = 0;
|
||||
|
||||
const flush = (stretch: boolean) => {
|
||||
if (current.length === 0) return;
|
||||
const available = containerWidth - gap * (current.length - 1);
|
||||
const height = stretch ? available / ratioSum : targetHeight;
|
||||
rows.push({
|
||||
height,
|
||||
tiles: current.map((item) => ({ item, width: Math.max(1, clampRatio(item) * height), height })),
|
||||
});
|
||||
current = [];
|
||||
ratioSum = 0;
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const ratio = clampRatio(item);
|
||||
current.push(item);
|
||||
ratioSum += ratio;
|
||||
const width = ratioSum * targetHeight + gap * (current.length - 1);
|
||||
if (width >= containerWidth) flush(true);
|
||||
}
|
||||
flush(false);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Estimated pixel height of an unloaded bucket, so the scrollbar is roughly honest before it loads. */
|
||||
type EstimateParams = { count: number; containerWidth: number; rowHeight: number; gap: number };
|
||||
export const estimateBucketHeight = ({ count, containerWidth, rowHeight, gap }: EstimateParams): number => {
|
||||
const perRow = Math.max(1, Math.round(containerWidth / (rowHeight * 1.35)));
|
||||
const rows = Math.ceil(count / perRow);
|
||||
return rows * (rowHeight + gap);
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { Album } from './shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Every mutation the /photos panels can perform, in one place.
|
||||
//
|
||||
// All of them are bulk-shaped even when the UI is acting on a single tile: Immich's own API is
|
||||
// `PUT /assets {ids, …}` / `DELETE /assets {ids}`, and having one code path means the selection bar and the
|
||||
// lightbox's single-asset buttons cannot drift into behaving differently.
|
||||
//
|
||||
// Invalidation is broad — the whole ['photos'] key. Favoriting an asset changes the favorites bucket index,
|
||||
// the timeline tile, the album cover and the asset detail; the queries are cheap and correctness is worth
|
||||
// more than a surgical cache patch.
|
||||
|
||||
export function useAssetActions() {
|
||||
const { put, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
|
||||
const fail = (verb: string) => (err: unknown) =>
|
||||
toast.error(`Could not ${verb}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
const setFavorite = useMutation({
|
||||
mutationFn: ({ ids, isFavorite }: { ids: string[]; isFavorite: boolean }) =>
|
||||
put('/photos/_officer/assets', { ids, isFavorite }),
|
||||
onSuccess: invalidate,
|
||||
onError: fail('update favorites'),
|
||||
});
|
||||
|
||||
const setArchived = useMutation({
|
||||
mutationFn: ({ ids, archived }: { ids: string[]; archived: boolean }) =>
|
||||
put('/photos/_officer/assets', { ids, visibility: archived ? 'archive' : 'timeline' }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Moved');
|
||||
},
|
||||
onError: fail('archive'),
|
||||
});
|
||||
|
||||
// Immich's delete is a move to trash unless `force`. Keep those as two named actions rather than one with a
|
||||
// boolean at the call site — "delete" and "delete permanently" should not look alike in the code either.
|
||||
const trash = useMutation({
|
||||
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids }),
|
||||
onSuccess: (_data, ids) => {
|
||||
invalidate();
|
||||
toast.success(`${ids.length} moved to trash`);
|
||||
},
|
||||
onError: fail('delete'),
|
||||
});
|
||||
|
||||
const deleteForever = useMutation({
|
||||
mutationFn: (ids: string[]) => del('/photos/_officer/assets', { ids, force: true }),
|
||||
onSuccess: (_data, ids) => {
|
||||
invalidate();
|
||||
toast.success(`${ids.length} permanently deleted`);
|
||||
},
|
||||
onError: fail('delete permanently'),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (ids: string[]) => post('/photos/_officer/trash/restore/assets', { ids }),
|
||||
onSuccess: (_data, ids) => {
|
||||
invalidate();
|
||||
toast.success(`${ids.length} restored`);
|
||||
},
|
||||
onError: fail('restore'),
|
||||
});
|
||||
|
||||
const emptyTrash = useMutation({
|
||||
mutationFn: () => post('/photos/_officer/trash/empty'),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Trash emptied');
|
||||
},
|
||||
onError: fail('empty the trash'),
|
||||
});
|
||||
|
||||
const addToAlbum = useMutation({
|
||||
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
||||
put(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
||||
onSuccess: (_data, { ids }) => {
|
||||
invalidate();
|
||||
toast.success(`${ids.length} added to album`);
|
||||
},
|
||||
onError: fail('add to the album'),
|
||||
});
|
||||
|
||||
const removeFromAlbum = useMutation({
|
||||
mutationFn: ({ albumId, ids }: { albumId: string; ids: string[] }) =>
|
||||
del(`/photos/_officer/albums/${albumId}/assets`, { ids }),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Removed from album');
|
||||
},
|
||||
onError: fail('remove from the album'),
|
||||
});
|
||||
|
||||
const createAlbum = useMutation({
|
||||
mutationFn: ({ albumName, assetIds }: { albumName: string; assetIds?: string[] }) =>
|
||||
post<Album>('/photos/_officer/albums', { albumName, assetIds: assetIds ?? [] }),
|
||||
onSuccess: invalidate,
|
||||
onError: fail('create the album'),
|
||||
});
|
||||
|
||||
const deleteAlbum = useMutation({
|
||||
mutationFn: (albumId: string) => del(`/photos/_officer/albums/${albumId}`),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Album deleted');
|
||||
},
|
||||
onError: fail('delete the album'),
|
||||
});
|
||||
|
||||
// PATCH, not PUT: on Immich, PUT /albums/{id}/assets means "add assets" — the album itself is patched.
|
||||
const renameAlbum = useMutation({
|
||||
mutationFn: ({ albumId, albumName }: { albumId: string; albumName: string }) =>
|
||||
patch(`/photos/_officer/albums/${albumId}`, { albumName }),
|
||||
onSuccess: invalidate,
|
||||
onError: fail('rename the album'),
|
||||
});
|
||||
|
||||
return {
|
||||
setFavorite,
|
||||
setArchived,
|
||||
trash,
|
||||
deleteForever,
|
||||
restore,
|
||||
emptyTrash,
|
||||
addToAlbum,
|
||||
removeFromAlbum,
|
||||
createAlbum,
|
||||
deleteAlbum,
|
||||
renameAlbum,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Measure an element's inner width and keep it current.
|
||||
*
|
||||
* The justified grid needs a real pixel width before it can lay anything out, and panels here are resized by
|
||||
* dragging a splitter — a width read once on mount is wrong within seconds. ResizeObserver rather than a
|
||||
* window resize listener for exactly that reason: the window never changes size, the panel does.
|
||||
*/
|
||||
export function useContainerWidth<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
setWidth(el.clientWidth);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const next = entries[0]?.contentRect.width ?? 0;
|
||||
setWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next));
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, width] as const;
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
import type {
|
||||
Album,
|
||||
AlbumDetail,
|
||||
Asset,
|
||||
ExploreSection,
|
||||
GridAsset,
|
||||
MapMarker,
|
||||
PeopleResponse,
|
||||
Person,
|
||||
SearchResponse,
|
||||
ServerConfig,
|
||||
SharedLink,
|
||||
TimeBucket,
|
||||
TimeBucketColumns,
|
||||
} from './shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { bucketAssets } from './shared';
|
||||
|
||||
// Reads for the /photos panels. Everything goes through /api/photos/_officer/*, which is the auth proxy in
|
||||
// front of the officer-photos sidecar — the browser never learns the Immich URL or its API key.
|
||||
//
|
||||
// The timeline is two queries, not one: a cheap bucket INDEX (one row per month) and a per-bucket fetch the
|
||||
// grid triggers as you scroll. Immich's own web app works this way, and it is the only reason a library of
|
||||
// 100k assets can render a correct scrollbar without downloading 100k rows first.
|
||||
|
||||
const KEY = 'photos';
|
||||
|
||||
/** Timeline filters. The same query shape serves the main timeline, favorites, archive, trash and albums. */
|
||||
export type TimelineFilter = {
|
||||
albumId?: string;
|
||||
personId?: string;
|
||||
tagId?: string;
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
visibility?: 'timeline' | 'archive';
|
||||
withPartners?: boolean;
|
||||
withStacked?: boolean;
|
||||
};
|
||||
|
||||
type QueryValues = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
const filterQuery = (filter: QueryValues): string => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (value !== undefined && value !== null) params.set(key, String(value));
|
||||
}
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export function useTimeBuckets(filter: TimelineFilter, enabled = true) {
|
||||
const { get } = useClient();
|
||||
const query = filterQuery(filter);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'buckets', query],
|
||||
queryFn: () => get<TimeBucket[]>(`/photos/_officer/timeline/buckets?${query}`),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch several buckets at once — the months the grid has actually scrolled into view.
|
||||
*
|
||||
* `useQueries` rather than a loop of `useTimeBucket`, because the number of loaded months grows as you scroll
|
||||
* and hooks cannot be called conditionally. Each bucket keeps its own cache entry, so scrolling back up is
|
||||
* free and a mutation invalidating ['photos'] refetches only the months still mounted. A month that has
|
||||
* already scrolled past does not refetch when you scroll back to it.
|
||||
*/
|
||||
export function useBucketAssets(filter: TimelineFilter, buckets: string[]) {
|
||||
const { get } = useClient();
|
||||
const query = filterQuery(filter);
|
||||
|
||||
const results = useQueries({
|
||||
queries: buckets.map((bucket) => ({
|
||||
queryKey: [KEY, 'bucket', query, bucket],
|
||||
queryFn: () =>
|
||||
get<TimeBucketColumns>(`/photos/_officer/timeline/bucket?${query}&timeBucket=${encodeURIComponent(bucket)}`),
|
||||
staleTime: 5 * 60_000,
|
||||
})),
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const map: Record<string, GridAsset[]> = {};
|
||||
buckets.forEach((bucket, i) => {
|
||||
const cols = results[i]?.data;
|
||||
if (cols) map[bucket] = bucketAssets(cols);
|
||||
});
|
||||
return map;
|
||||
// `results` is a fresh array every render; its identity is meaningless. The data is what matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [buckets, results.map((r) => r.dataUpdatedAt).join(',')]);
|
||||
}
|
||||
|
||||
export function useAlbums() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'albums'],
|
||||
queryFn: () => get<Album[]>('/photos/_officer/albums'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAlbum(id: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'album', id],
|
||||
queryFn: () => get<AlbumDetail>(`/photos/_officer/albums/${id}`),
|
||||
enabled: !!id,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePeople() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'people'],
|
||||
queryFn: () => get<PeopleResponse>('/photos/_officer/people?withHidden=false&size=1000'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePerson(id: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'person', id],
|
||||
queryFn: () => get<Person>(`/photos/_officer/people/${id}`),
|
||||
enabled: !!id,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useExplore() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'explore'],
|
||||
queryFn: () => get<ExploreSection[]>('/photos/_officer/search/explore'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSharedLinks() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'shared-links'],
|
||||
queryFn: () => get<SharedLink[]>('/photos/_officer/shared-links'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAsset(id: string | null) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'asset', id],
|
||||
queryFn: () => get<Asset>(`/photos/_officer/assets/${id}`),
|
||||
enabled: !!id,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search. `smart` is Immich's CLIP search ("a dog on a beach") and is what the box does by default; `metadata`
|
||||
* matches filenames, paths and EXIF text. They take the same filters and return the same envelope, so the mode
|
||||
* is one flag rather than two hooks.
|
||||
*/
|
||||
type SearchParams = { query: string; smart: boolean; enabled: boolean };
|
||||
export function useSearch({ query, smart, enabled }: SearchParams) {
|
||||
const { post } = useClient();
|
||||
const text = query.trim();
|
||||
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'search', smart ? 'smart' : 'metadata', text],
|
||||
queryFn: () =>
|
||||
smart
|
||||
? post<SearchResponse>('/photos/_officer/search/smart', { query: text, size: 250, withExif: true })
|
||||
: post<SearchResponse>('/photos/_officer/search/metadata', {
|
||||
originalFileName: text,
|
||||
size: 250,
|
||||
withExif: true,
|
||||
order: 'desc',
|
||||
}),
|
||||
enabled: enabled && text.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type AssetStats = { images: number; videos: number; total: number };
|
||||
|
||||
export function useAssetStats() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'stats'],
|
||||
queryFn: () => get<AssetStats>('/photos/_officer/assets/statistics'),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map pins. `/map/markers` is NOT bucketed — it returns every geotagged asset in one array, because a marker is
|
||||
* six numbers and the whole library is a couple of megabytes at most. Clustering happens in the browser.
|
||||
*
|
||||
* Note `isArchived`, not `visibility`: the map endpoint kept the old boolean flags when the timeline moved to
|
||||
* the `visibility` enum, so this filter deliberately does not match `TimelineFilter`.
|
||||
*/
|
||||
export type MapFilter = {
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
withPartners?: boolean;
|
||||
withSharedAlbums?: boolean;
|
||||
fileCreatedAfter?: string;
|
||||
fileCreatedBefore?: string;
|
||||
};
|
||||
|
||||
export function useMapMarkers(filter: MapFilter) {
|
||||
const { get } = useClient();
|
||||
const query = filterQuery(filter);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'map-markers', query],
|
||||
queryFn: () => get<MapMarker[]>(`/photos/_officer/map/markers?${query}`),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Immich's own map style URLs live in its server config, so the owner's Immich settings pick the basemap. */
|
||||
export function useServerConfig() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'server-config'],
|
||||
queryFn: () => get<ServerConfig>('/photos/_officer/server/config'),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Immich instances and their API keys are the owner's to set from /photos/settings; nothing reads them from
|
||||
// the environment any more. It is a REGISTRY — any number of labelled accounts, one of them selected — so
|
||||
// two keys against the same instance (one per Immich user) is an ordinary thing to have.
|
||||
//
|
||||
// The key is WRITE-ONLY across this boundary: an account carries its label, URL and whether it is selected,
|
||||
// and has no field that could carry the key back to the browser.
|
||||
|
||||
export type PhotosHealth = {
|
||||
ok: boolean;
|
||||
/** False only when no account is stored. It is what separates "set this up" from "this used to work". */
|
||||
configured?: boolean;
|
||||
/** Label of the selected account, so a failure names which library did not answer. */
|
||||
account?: string;
|
||||
version?: string | null;
|
||||
user?: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type PhotosAccount = {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
version: string | null;
|
||||
isActive: boolean;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type PhotosAccounts = { configured: boolean; activeId: number | null; accounts: PhotosAccount[] };
|
||||
|
||||
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
|
||||
export function photosErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
return raw.slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health, including its failure bodies.
|
||||
*
|
||||
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two
|
||||
* cases the UI most needs to tell apart — 503 not connected and 502 connected-but-broken. Both carry a
|
||||
* JSON body, so the throw is turned back into the answer rather than an error state.
|
||||
*/
|
||||
export function usePhotosHealth() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'health'],
|
||||
queryFn: async (): Promise<PhotosHealth> => {
|
||||
try {
|
||||
return await get<PhotosHealth>('/photos/_health');
|
||||
} catch (err) {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const body = JSON.parse(raw) as PhotosHealth;
|
||||
if (body && body.ok === false) return body;
|
||||
} catch {
|
||||
/* not the sidecar's body */
|
||||
}
|
||||
}
|
||||
// Anything else — the platform proxy, auth, the sidecar being down — is a configured instance
|
||||
// that is failing, not an unconfigured one. Never offer the setup form on a guess.
|
||||
return { ok: false, configured: true, error: photosErrorMessage(err) };
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePhotosAccounts() {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: [KEY, 'accounts'],
|
||||
queryFn: () => get<PhotosAccounts>('/photos/_config'),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Either a pasted API key or a sign-in the sidecar trades for one.
|
||||
*
|
||||
* The password goes to the sidecar, which logs in, mints an all-permissions key, closes the session again and
|
||||
* stores only the key. It never reaches Postgres and never comes back to the browser.
|
||||
*/
|
||||
export type PhotosCredential = { apiKey: string } | { email: string; password: string };
|
||||
|
||||
export type AddPhotosAccountInput = { label: string; url: string } & PhotosCredential;
|
||||
export type EditPhotosAccountInput = { id: number; label?: string; url?: string } & Partial<
|
||||
Record<'apiKey' | 'email' | 'password', string>
|
||||
>;
|
||||
|
||||
export function usePhotosAccountActions() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
// Adding, editing, switching and removing all change what every other query in this workspace can even
|
||||
// answer — a switch in particular changes the answer to all of them without changing any of their inputs.
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (input: AddPhotosAccountInput) => post<{ account: PhotosAccount }>('/photos/_config', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const edit = useMutation({
|
||||
mutationFn: ({ id, ...rest }: EditPhotosAccountInput) =>
|
||||
patch<{ account: PhotosAccount }>(`/photos/_config/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<PhotosAccounts>(`/photos/_config/${id}/activate`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => del<PhotosAccounts>(`/photos/_config/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { add, edit, activate, remove };
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_PHOTOS_SECTION, isPhotosSection, type PhotosSectionId } from './shared';
|
||||
|
||||
// The URL names the section; nothing else does. PhotosScreen redirects anything unrecognised, so the fallback
|
||||
// here only covers the instant before that lands.
|
||||
|
||||
export function usePhotosSection(): PhotosSectionId {
|
||||
const { section } = useParams();
|
||||
return isPhotosSection(section) ? section : DEFAULT_PHOTOS_SECTION;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Multi-select over an ordered list of asset ids.
|
||||
*
|
||||
* Shift-click selects a range, which is why the hook needs the ORDER of everything currently on screen and not
|
||||
* just the set of chosen ids. The order comes from the view, so a timeline that has loaded three months and an
|
||||
* album that has loaded one page both get the same behaviour without either knowing how the other paginates.
|
||||
*/
|
||||
export function useSelection(orderedIds: string[]) {
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
const anchor = useRef<string | null>(null);
|
||||
|
||||
const order = useMemo(() => new Map(orderedIds.map((id, i) => [id, i])), [orderedIds]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string, ev?: { shiftKey?: boolean; preventDefault?: () => void; stopPropagation?: () => void }) => {
|
||||
ev?.preventDefault?.();
|
||||
ev?.stopPropagation?.();
|
||||
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
const from = anchor.current;
|
||||
|
||||
if (ev?.shiftKey && from && order.has(from) && order.has(id)) {
|
||||
const a = order.get(from) ?? 0;
|
||||
const b = order.get(id) ?? 0;
|
||||
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) {
|
||||
const between = orderedIds[i];
|
||||
if (between) next.add(between);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
anchor.current = id;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[order, orderedIds],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
anchor.current = null;
|
||||
setSelected(new Set());
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback(() => setSelected(new Set(orderedIds)), [orderedIds]);
|
||||
|
||||
const ids = useMemo(() => orderedIds.filter((id) => selected.has(id)), [orderedIds, selected]);
|
||||
|
||||
return {
|
||||
selected,
|
||||
/** In document order, so "add to album" preserves what the eye saw rather than insertion order. */
|
||||
ids,
|
||||
count: selected.size,
|
||||
isSelecting: selected.size > 0,
|
||||
toggle,
|
||||
clear,
|
||||
selectAll,
|
||||
};
|
||||
}
|
||||
|
||||
export type Selection = ReturnType<typeof useSelection>;
|
||||
@@ -33,10 +33,6 @@ export type { SelectedSession } from './apps/ChatHistory';
|
||||
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
|
||||
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
|
||||
export { CodeEditorView } from './apps/CodeEditor';
|
||||
// Same for /photos.
|
||||
export { DEFAULT_PHOTOS_SECTION, photosSectionPath, isPhotosSection } from './apps/Photos/shared';
|
||||
export type { PhotosSectionId } from './apps/Photos/shared';
|
||||
|
||||
// Same for /jellyfin.
|
||||
export { DEFAULT_JELLYFIN_SECTION, jellyfinSectionPath, isJellyfinSection } from './apps/Jellyfin/shared';
|
||||
export type { JellyfinSectionId } from './apps/Jellyfin/shared';
|
||||
|
||||
Reference in New Issue
Block a user