photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:
• No reverse dependency. Nothing in src/ imported anything of photos'.
• Configuration was already right. The URL and API key live in photos_config,
a per-user registry encrypted at rest and driven from the app. Vault had to
move VAULTWARDEN_URL out of .env and invent a settings panel; here the
sidecar already read the database, and its own comment already said why
nothing reads process.env.
• The frontend was already panels. The screen was a WorkspaceView around
exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
the screen's contents with the wrapper removed.
The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.
Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.
Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Photos — the extraction record
|
||||||
|
|
||||||
|
Extracted from the platform on **2026-08-18**, the fifth plugin out (after offscale, music, email, vault).
|
||||||
|
`plugins/EXTRACTING-A-PLUGIN.md` is the runbook; this file is what it cost.
|
||||||
|
|
||||||
|
## The clean case
|
||||||
|
|
||||||
|
Offscale's shape rather than music's or vault's: **nothing stayed behind, and nothing had to be
|
||||||
|
redesigned.** Worth recording why, because it is the cheapest extraction so far and the reasons are
|
||||||
|
structural rather than luck.
|
||||||
|
|
||||||
|
- **No reverse dependency.** Nothing in `src/` imported anything of photos'. The whole platform surface
|
||||||
|
was one commented router import, one barrel line, and the ordinary wiring (route, screen, registry,
|
||||||
|
dock rule, catalogue entry). Compare vault, whose extraction had to invent an account-lifecycle hook
|
||||||
|
because three core auth handlers called into it.
|
||||||
|
- **Configuration was already right.** The URL and API key live in `photos_config` — a per-user registry
|
||||||
|
with one active row, encrypted at rest, driven from the app. Vault had to move `VAULTWARDEN_URL` out of
|
||||||
|
`.env` and invent a settings panel; here the sidecar already read the database and its own comment
|
||||||
|
already said why nothing reads `process.env`: an `IMMICH_API_KEY` there would sit in the environment of
|
||||||
|
every pm2 process, including the one with the largest attack surface and no code to use it.
|
||||||
|
- **The frontend was already panels.** Two of them, `photos-nav` and `photos-view`, plus a layout. The
|
||||||
|
screen was a `WorkspaceView` around exactly those, so `web/panels.ts` and `web/layout.ts` are the
|
||||||
|
screen's own contents with the wrapper removed.
|
||||||
|
|
||||||
|
## What moved
|
||||||
|
|
||||||
|
| Was | Is now |
|
||||||
|
| ------------------------------------------ | ------------------- |
|
||||||
|
| `src/servers/sidecar/photos/*` (5 files) | `sidecar/*` |
|
||||||
|
| `src/servers/api/photos/router.ts` | `api/router.ts` |
|
||||||
|
| `src/databases/officer_db/src/photos/*` | `db/*` |
|
||||||
|
| `src/workspaces/officerdev/src/apps/Photos/*` (25 files) | `web/*` |
|
||||||
|
| `src/apps/officer-web/Screens/Dashboard/Photos/*` | `web/layout.ts` |
|
||||||
|
|
||||||
|
The prefix in `api/router.ts` is now DERIVED from `mountPrefix` rather than the literal `/api/photos`,
|
||||||
|
which was right only by coincidence — see the comment there, and `plugins/music/api/router.ts` for the
|
||||||
|
same fix and the same reasoning.
|
||||||
|
|
||||||
|
## What the platform lost
|
||||||
|
|
||||||
|
Deleted, not commented: the sidecar, the api router, the db directory, the Photos app, the screen, the
|
||||||
|
`App.tsx` routes, 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`
|
||||||
|
comment, and the `app-store/catalogue.ts` entry.
|
||||||
|
|
||||||
|
**The catalogue deletion is required, not 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`, puts `/photos` into `deniedRoutes` and
|
||||||
|
withholds the dock tile, on a server where the plugin is installed and working.
|
||||||
|
|
||||||
|
## Two things paid down on the way
|
||||||
|
|
||||||
|
**Palette classes.** The runbook says to pay these down as part of the extraction rather than exporting
|
||||||
|
colour debt into a repository nobody revisits, and it puts Photos at 47. It was **6** — someone had
|
||||||
|
already done most of it. All six were state colours wearing palette numbers (`text-emerald-400` for the
|
||||||
|
app's own tile, `text-emerald-500`/`text-amber-500` on a connection check) and are now `success` and
|
||||||
|
`warning` from the closed tone set, so they follow the theme.
|
||||||
|
|
||||||
|
**Test fixtures.** Photos was the canonical app-store fixture across four test files, chosen for being
|
||||||
|
`existing|provisioned` with `members: 'accounts'`. Repointed at `jellyfin`, which has the identical
|
||||||
|
shape, rather than deleted — the assertions are about the installer, not about photos.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
- **Immich is still provisioned by the app store**, not by this plugin. `modes: ['existing',
|
||||||
|
'provisioned']` and a compose template lived in the catalogue entry that had to be deleted, so the
|
||||||
|
`provisioned` half has no home right now: you point it at an instance you already run. Docker
|
||||||
|
provisioning for plugins is a design still in progress, and this is one of the features waiting for it.
|
||||||
|
- **No `assets/icon.png`.** Falls back to the `Images` glyph, which is what the catalogue entry used.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Photos
|
||||||
|
|
||||||
|
Your Immich library, reached through Officer.
|
||||||
|
|
||||||
|
Photos does not store photographs and does not install Immich. You point it at an instance you already
|
||||||
|
run; it forwards to it and holds the API key server-side, so no device ever carries one. Your library,
|
||||||
|
your originals and every thumbnail stay exactly where they are.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- **The whole library** — timeline, albums, people, places, sharing and search, as panels inside Officer.
|
||||||
|
- **A map** of everything with coordinates, clustered.
|
||||||
|
- **Upload from the phone**, through Officer rather than to Immich directly.
|
||||||
|
- **More than one instance.** Accounts are a per-user registry with a label, a URL and a key; one is
|
||||||
|
active and you switch between them in the app.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys).
|
||||||
|
|
||||||
|
Create the key with **all permissions**. A scoped key returns 403 per route, which does not read as a
|
||||||
|
permissions problem — it reads as a broken feature, one screen at a time.
|
||||||
|
|
||||||
|
Nothing else. No system packages, no host binaries.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install the plugin.
|
||||||
|
2. Open **/photos → Connection** and add an instance: a label, its URL, and the API key.
|
||||||
|
3. It probes the server, records its version, and marks the account active.
|
||||||
|
|
||||||
|
## What it deliberately does not do yet
|
||||||
|
|
||||||
|
- **It is a client, not a backup.** Nothing is copied to Officer. If your Immich goes away, so does the
|
||||||
|
library — this plugin never held it.
|
||||||
|
- **The key is write-only across the boundary.** The account list reports label, URL and which is active,
|
||||||
|
and has no field that could carry a key, masked or otherwise. The only way to change one is to replace
|
||||||
|
it. That is deliberate, and it means the app cannot show you a key you have forgotten.
|
||||||
|
- **Editing lives in Immich.** This browses, searches, uploads and organises into albums; it is not a
|
||||||
|
photo editor and will not become one.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
|
// /api/photos/* — auth, then forward to officer-photos. No routes of its own and no Immich knowledge:
|
||||||
|
// this file must never grow app logic. The sidecar owns the Immich contract and holds its API key.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/photos', which is right only by coincidence: `mountPrefix` returns `/photos`
|
||||||
|
// for a first-party publisher, and the proxy strips exactly `prefix.length` characters to build the
|
||||||
|
// sidecar's path. Published by anyone else this mounts at `/api/p/<publisher>/photos` and a literal would
|
||||||
|
// forward `/alice/photos/assets` to a sidecar expecting `/assets`. Same reasoning, and the same fix, as
|
||||||
|
// `plugins/music/api/router.ts`.
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'photos',
|
||||||
|
prefix: `/api${mountPrefix({ appName: 'photos', manifest })}`,
|
||||||
|
// Originals and `download/archive` zips are large and Immich builds the archive as it streams it, so the
|
||||||
|
// socket can sit quiet longer than the default 60s idle drop allows.
|
||||||
|
timeoutSeconds: 600,
|
||||||
|
// Immich judges an asset from its first few KB and rejects immediately, while we are still writing —
|
||||||
|
// and the response is lost in the teardown, which is what made every large upload fail with an
|
||||||
|
// unexplained empty 400. Buffering here removes the overlap. The sidecar does the same on its own hop
|
||||||
|
// to Immich (sidecar/routes.ts → readBodyCapped), and both are needed: fixing one alone still lost the
|
||||||
|
// answer roughly two runs in three at 32 MB.
|
||||||
|
bufferRequestBody: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = proxy.router;
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { db } from 'officerdb/db';
|
||||||
|
import { photosConfig } from './schema';
|
||||||
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
|
||||||
|
// Immich account registry for the officer-photos sidecar. Callers deal in PLAINTEXT — encryption to and from
|
||||||
|
// at-rest ciphertext happens here. See ../crypto.ts and ../schema/photos.ts.
|
||||||
|
//
|
||||||
|
// Two return types, and the split is the safety property:
|
||||||
|
// PhotosAccount — safe to serialize to the browser. Has NO api key field at all, not even a masked one.
|
||||||
|
// PhotosCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned by a route.
|
||||||
|
// `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
|
||||||
|
// response the moment someone forgot to strip it.
|
||||||
|
|
||||||
|
export type PhotosAccount = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
version: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastSeenAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PhotosCredentials = { id: number; label: string; url: string; apiKey: string };
|
||||||
|
|
||||||
|
const accountCols = {
|
||||||
|
id: photosConfig.id,
|
||||||
|
label: photosConfig.label,
|
||||||
|
url: photosConfig.url,
|
||||||
|
version: photosConfig.version,
|
||||||
|
isActive: photosConfig.isActive,
|
||||||
|
lastSeenAt: photosConfig.lastSeenAt,
|
||||||
|
createdAt: photosConfig.createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Every account the owner has added, active first then newest. Never includes the API key. */
|
||||||
|
export async function listPhotosAccounts(userId: number): Promise<PhotosAccount[]> {
|
||||||
|
return db
|
||||||
|
.select(accountCols)
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(eq(photosConfig.userId, userId))
|
||||||
|
.orderBy(desc(photosConfig.isActive), desc(photosConfig.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected account with its key decrypted, or null when none is added. */
|
||||||
|
export async function getActivePhotosCredentials(userId: number): Promise<PhotosCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
if (!row) return null;
|
||||||
|
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||||
|
export async function getPhotosCredentials(userId: number, id: number): Promise<PhotosCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||||
|
if (!row) return null;
|
||||||
|
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePhotosAccountParams = {
|
||||||
|
userId: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
apiKey: string;
|
||||||
|
version: string | null;
|
||||||
|
/** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */
|
||||||
|
activate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add an account. The key is encrypted before write; the returned row carries no key. */
|
||||||
|
export async function createPhotosAccount(params: CreatePhotosAccountParams): Promise<PhotosAccount> {
|
||||||
|
const { userId, label, url, apiKey, version, activate } = params;
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
if (activate) {
|
||||||
|
await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
}
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(photosConfig)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
label,
|
||||||
|
url,
|
||||||
|
apiKey: encryptSecret('photos', apiKey),
|
||||||
|
version,
|
||||||
|
isActive: activate,
|
||||||
|
lastSeenAt: version ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning(accountCols);
|
||||||
|
return row!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePhotosAccountParams = { label?: string; url?: string; apiKey?: string; version?: string | null };
|
||||||
|
|
||||||
|
/** Edit an account. Omitted fields are left alone; a supplied key is re-encrypted. */
|
||||||
|
export async function updatePhotosAccount(
|
||||||
|
userId: number,
|
||||||
|
id: number,
|
||||||
|
params: UpdatePhotosAccountParams,
|
||||||
|
): Promise<PhotosAccount | null> {
|
||||||
|
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
|
if (params.label !== undefined) set.label = params.label;
|
||||||
|
if (params.url !== undefined) set.url = params.url;
|
||||||
|
if (params.apiKey !== undefined) set.apiKey = encryptSecret('photos', params.apiKey);
|
||||||
|
if (params.version !== undefined) set.version = params.version;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.update(photosConfig)
|
||||||
|
.set(set)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning(accountCols);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */
|
||||||
|
export async function setActivePhotosAccount(userId: number, id: number): Promise<PhotosAccount | null> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||||
|
const [row] = await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning(accountCols);
|
||||||
|
return row ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the
|
||||||
|
* account in use would leave the owner with accounts added but none selected, which reads as "not connected"
|
||||||
|
* and is a confusing place to land.
|
||||||
|
*/
|
||||||
|
export async function deletePhotosAccount(userId: number, id: number): Promise<boolean> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [deleted] = await tx
|
||||||
|
.delete(photosConfig)
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)))
|
||||||
|
.returning({ id: photosConfig.id, wasActive: photosConfig.isActive });
|
||||||
|
if (!deleted) return false;
|
||||||
|
|
||||||
|
if (deleted.wasActive) {
|
||||||
|
const [next] = await tx
|
||||||
|
.select({ id: photosConfig.id })
|
||||||
|
.from(photosConfig)
|
||||||
|
.where(eq(photosConfig.userId, userId))
|
||||||
|
.orderBy(desc(photosConfig.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (next) {
|
||||||
|
await tx
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
|
.where(eq(photosConfig.id, next.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */
|
||||||
|
export async function recordPhotosProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(photosConfig)
|
||||||
|
.set({ version, lastSeenAt: new Date() })
|
||||||
|
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
|
// The Immich accounts behind /photos, for the officer-photos sidecar.
|
||||||
|
//
|
||||||
|
// This used to be IMMICH_URL + IMMICH_API_KEY in the platform-wide `.env`, which was wrong twice over: Bun
|
||||||
|
// auto-loads `.env` into EVERY process started in the platform directory, so `officer` itself held an Immich
|
||||||
|
// credential it has no code to use — and connecting a photo library was a shell task on the server rather
|
||||||
|
// than something the owner could do from the app.
|
||||||
|
//
|
||||||
|
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
|
||||||
|
// same shape offscale_servers uses. Two accounts on the same instance is the normal case (one key per
|
||||||
|
// Immich user), which is why the uniqueness below is on the label and not on the URL.
|
||||||
|
//
|
||||||
|
// `api_key` is encrypted at rest via ../crypto.ts. An Immich key can read and delete the entire library, so a
|
||||||
|
// DB dump must not hand it over. Encryption is confined to queries/photos.ts; nothing outside that file sees
|
||||||
|
// ciphertext, and no route ever returns the key at all.
|
||||||
|
export const photosConfig = pgTable(
|
||||||
|
'photos_config',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
|
||||||
|
label: text('label').notNull(),
|
||||||
|
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
|
||||||
|
url: text('url').notNull(),
|
||||||
|
apiKey: text('api_key').notNull(), // encrypted
|
||||||
|
/** Immich version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
||||||
|
version: text('version'),
|
||||||
|
isActive: boolean('is_active').notNull().default(false),
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// Labels are how the owner tells two accounts apart — duplicates would make the switcher useless. Not
|
||||||
|
// unique on url: several keys against one instance is the whole point.
|
||||||
|
uniqueIndex('uq_photos_config_user_label').on(t.userId, t.label),
|
||||||
|
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
|
||||||
|
// index over the active rows only. setActivePhotosAccount still clears the others in a transaction, but a
|
||||||
|
// bug there fails loudly here instead of silently leaving two accounts active and the UI picking one.
|
||||||
|
uniqueIndex('uq_photos_config_one_active')
|
||||||
|
.on(t.userId)
|
||||||
|
.where(sql`${t.isActive}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photos — your Immich library, reached through Officer.
|
||||||
|
*
|
||||||
|
* ── What this plugin is, and what it is not ──
|
||||||
|
*
|
||||||
|
* It does NOT store photographs, and it does not install Immich. You point it at an instance you already
|
||||||
|
* run and it forwards to it, holding the API key server-side so no device ever carries one. The library,
|
||||||
|
* the originals and every thumbnail stay where they are.
|
||||||
|
*
|
||||||
|
* The account registry is per-user and already server-side (`photos_config`): a label, a URL and an
|
||||||
|
* encrypted API key, with one marked active. Adding and switching instances happens in the app rather
|
||||||
|
* than in a shell, which is why nothing here reads `process.env` — an `IMMICH_API_KEY` in `.env` would
|
||||||
|
* also sit in the environment of every other pm2 process, including the one with the largest attack
|
||||||
|
* surface and no code to use it.
|
||||||
|
*
|
||||||
|
* ── No `osDependencies` ──
|
||||||
|
*
|
||||||
|
* Nothing shells out. The sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
|
||||||
|
*/
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Photos',
|
||||||
|
summary: 'Your Immich library — browse, search, upload from the phone',
|
||||||
|
icon: 'Images',
|
||||||
|
color: '#10b981',
|
||||||
|
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'photos',
|
||||||
|
label: 'Photos',
|
||||||
|
description: 'Browse the photo library',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import type { UpstreamTarget } from './upstream';
|
||||||
|
import {
|
||||||
|
createPhotosAccount,
|
||||||
|
deletePhotosAccount,
|
||||||
|
getPhotosCredentials,
|
||||||
|
listPhotosAccounts,
|
||||||
|
recordPhotosProbe,
|
||||||
|
setActivePhotosAccount,
|
||||||
|
updatePhotosAccount,
|
||||||
|
} from '../db/queries';
|
||||||
|
import { UpstreamError, callUpstream, invalidateConfig, mintApiKey, normalizeBase } from './upstream';
|
||||||
|
|
||||||
|
// `/_config` — the Immich account registry, driven from the app rather than from a shell on the server.
|
||||||
|
//
|
||||||
|
// The API key is WRITE-ONLY across this boundary. The list reports each account's label, URL and whether it
|
||||||
|
// is selected; it has no field that could carry a key, masked or otherwise. The only way to change one is to
|
||||||
|
// send a new one, which is the same shape the offscale plugin's server registry uses.
|
||||||
|
//
|
||||||
|
// "A new one" has two forms: a key the owner pasted from Immich's own settings screen, or an email and
|
||||||
|
// password the sidecar trades for one (see mintApiKey). The password is used for that exchange and never
|
||||||
|
// stored — only the key it yields is.
|
||||||
|
//
|
||||||
|
// A save is validated before it is stored: an Immich key that is missing, wrong or under-scoped is a 400
|
||||||
|
// with the reason, not a saved row that makes every later screen fail mysteriously. That matters more here
|
||||||
|
// than usual — Immich keys are SCOPED, and a key created without the right permissions returns 403 on
|
||||||
|
// individual routes while looking perfectly valid on the ones it does cover.
|
||||||
|
//
|
||||||
|
// Adding, editing, switching and removing all invalidate the upstream cache. Forgetting one would leave the
|
||||||
|
// sidecar serving the previous account's photos for up to a minute, which reads as a caching bug in the grid
|
||||||
|
// rather than as what it is.
|
||||||
|
|
||||||
|
export type ProbeResult =
|
||||||
|
| { ok: true; version: string | null; user: string | null }
|
||||||
|
| { ok: false; version: string | null; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask an instance whether it is really there and whether the key works.
|
||||||
|
*
|
||||||
|
* Two calls, because they answer different questions: `/server/version` is unauthenticated, so a failure
|
||||||
|
* there means the URL is wrong or Immich is down, while `/users/me` failing after it succeeded means the
|
||||||
|
* key is the problem. Collapsing them would report "instance unreachable" for a mistyped key.
|
||||||
|
*/
|
||||||
|
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
|
||||||
|
let version: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const versionRes = await callUpstream(cfg, { path: '/api/server/version', withKey: false });
|
||||||
|
if (!versionRes.ok) return { ok: false, version: null, error: `instance returned ${versionRes.status}` };
|
||||||
|
|
||||||
|
const v = (await versionRes.json()) as { major?: number; minor?: number; patch?: number };
|
||||||
|
if ([v.major, v.minor, v.patch].every((n) => typeof n === 'number')) {
|
||||||
|
version = `${v.major}.${v.minor}.${v.patch}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version: null, error: `could not reach the instance (${String(err)})` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const meRes = await callUpstream(cfg, { path: '/api/users/me' });
|
||||||
|
if (!meRes.ok) {
|
||||||
|
const reason = meRes.status === 403 ? 'API key is under-scoped for this instance' : 'API key was rejected';
|
||||||
|
return { ok: false, version, error: `${reason} (${meRes.status})` };
|
||||||
|
}
|
||||||
|
const me = (await meRes.json()) as { email?: string; name?: string };
|
||||||
|
return { ok: true, version, user: me.email ?? me.name ?? null };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version, error: String(err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the browser is allowed to know about the registry. Never includes a key. */
|
||||||
|
async function accountList(userId: number): Promise<Response> {
|
||||||
|
const accounts = await listPhotosAccounts(userId);
|
||||||
|
const active = accounts.find((account) => account.isActive) ?? null;
|
||||||
|
return Response.json({ configured: !!active, activeId: active?.id ?? null, accounts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
|
||||||
|
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
|
await recordPhotosProbe(userId, id, version).catch(() => {
|
||||||
|
/* a stale lastSeenAt is not worth failing a request over */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||||
|
|
||||||
|
type AccountBody = { label?: unknown; url?: unknown; apiKey?: unknown; email?: unknown; password?: unknown };
|
||||||
|
|
||||||
|
const readBody = async (req: Request): Promise<AccountBody> =>
|
||||||
|
((await req.json().catch(() => null)) as AccountBody | null) ?? {};
|
||||||
|
|
||||||
|
const readLabel = (body: AccountBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
|
||||||
|
const readUrl = (body: AccountBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
|
||||||
|
const readKey = (body: AccountBody): string => (typeof body.apiKey === 'string' ? body.apiKey.trim() : '');
|
||||||
|
const readEmail = (body: AccountBody): string => (typeof body.email === 'string' ? body.email.trim() : '');
|
||||||
|
const readPassword = (body: AccountBody): string => (typeof body.password === 'string' ? body.password : '');
|
||||||
|
|
||||||
|
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key to store, however the owner chose to supply it.
|
||||||
|
*
|
||||||
|
* A pasted key wins; otherwise an email and password are traded for a fresh one and the password is discarded
|
||||||
|
* with this function's stack frame. Returns '' when neither was given, which for an edit means "keep what is
|
||||||
|
* stored" and for an add is an error.
|
||||||
|
*/
|
||||||
|
async function resolveKey(body: AccountBody, base: string): Promise<string> {
|
||||||
|
const apiKey = readKey(body);
|
||||||
|
if (apiKey) return apiKey;
|
||||||
|
|
||||||
|
const email = readEmail(body);
|
||||||
|
const password = readPassword(body);
|
||||||
|
if (!email || !password) return '';
|
||||||
|
|
||||||
|
return mintApiKey(base, email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A refused sign-in already carries Immich's own words; anything else never reached Immich at all. */
|
||||||
|
const signInError = (err: unknown): string =>
|
||||||
|
err instanceof UpstreamError ? err.message : `could not reach the instance (${String(err)})`;
|
||||||
|
|
||||||
|
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_photos_config_user_label');
|
||||||
|
|
||||||
|
/** Add an account: validated against the live instance, then stored encrypted. */
|
||||||
|
async function addAccount(req: Request, userId: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const url = readUrl(body);
|
||||||
|
let label = readLabel(body);
|
||||||
|
|
||||||
|
if (!url) return bad('url is required');
|
||||||
|
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||||
|
|
||||||
|
let apiKey: string;
|
||||||
|
try {
|
||||||
|
apiKey = await resolveKey(body, url);
|
||||||
|
} catch (err) {
|
||||||
|
return bad(signInError(err));
|
||||||
|
}
|
||||||
|
if (!apiKey) return bad('an API key, or an email and password to mint one, is required');
|
||||||
|
|
||||||
|
const result = await probe({ base: url, key: apiKey });
|
||||||
|
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||||
|
|
||||||
|
// An unlabelled account takes the name Immich itself knows it by, which is nearly always what the owner
|
||||||
|
// would have typed. Falling back to the host keeps the switcher readable even for an anonymous key.
|
||||||
|
if (!label) label = result.user ?? new URL(url).host;
|
||||||
|
|
||||||
|
// The first account wins the selection: a registry with rows but nothing selected reads as "not connected".
|
||||||
|
const existing = await listPhotosAccounts(userId);
|
||||||
|
const activate = existing.length === 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await createPhotosAccount({ userId, label, url, apiKey, version: result.version, activate });
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return Response.json({ account, user: result.user });
|
||||||
|
} catch (err) {
|
||||||
|
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit one account. A blank key means "keep the stored one", so a rename does not need the key re-typed. */
|
||||||
|
async function editAccount(req: Request, userId: number, id: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const label = readLabel(body);
|
||||||
|
const url = readUrl(body);
|
||||||
|
|
||||||
|
const current = await getPhotosCredentials(userId, id);
|
||||||
|
if (!current) return bad('no such account', 404);
|
||||||
|
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||||
|
|
||||||
|
let apiKey: string;
|
||||||
|
try {
|
||||||
|
apiKey = await resolveKey(body, url || current.url);
|
||||||
|
} catch (err) {
|
||||||
|
return bad(signInError(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate whenever what we would talk to changes. A rename on its own never touches the instance.
|
||||||
|
let version: string | null | undefined;
|
||||||
|
if ((url && url !== current.url) || apiKey) {
|
||||||
|
const result = await probe({ base: url || current.url, key: apiKey || current.apiKey });
|
||||||
|
if (!result.ok) return Response.json({ error: result.error, version: result.version }, { status: 400 });
|
||||||
|
version = result.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await updatePhotosAccount(userId, id, {
|
||||||
|
label: label || undefined,
|
||||||
|
url: url || undefined,
|
||||||
|
apiKey: apiKey || undefined,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
if (!account) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return Response.json({ account });
|
||||||
|
} catch (err) {
|
||||||
|
if (duplicateLabel(err)) return bad(`you already have an account called "${label}"`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `subpath` is '' for /_config, or '/<id>' / '/<id>/activate'. */
|
||||||
|
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||||
|
const [, rawId, action] = subpath.split('/');
|
||||||
|
|
||||||
|
if (!rawId) {
|
||||||
|
if (req.method === 'GET') return accountList(userId);
|
||||||
|
if (req.method === 'POST' || req.method === 'PUT') return addAccount(req, userId);
|
||||||
|
return bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(rawId);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return bad('invalid account id', 404);
|
||||||
|
|
||||||
|
if (action === 'activate') {
|
||||||
|
if (req.method !== 'POST') return bad('method not allowed', 405);
|
||||||
|
const account = await setActivePhotosAccount(userId, id);
|
||||||
|
if (!account) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return accountList(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) return bad('not found', 404);
|
||||||
|
|
||||||
|
if (req.method === 'PATCH' || req.method === 'PUT') return editAccount(req, userId, id);
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
const removed = await deletePhotosAccount(userId, id);
|
||||||
|
if (!removed) return bad('no such account', 404);
|
||||||
|
invalidateConfig(userId);
|
||||||
|
return accountList(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bad('method not allowed', 405);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
|
import { handleConfigRoute, noteProbe, probe } from './config';
|
||||||
|
import { handleLockedRoute } from './locked';
|
||||||
|
import { handleOfficerRoute } from './routes';
|
||||||
|
import { getConfig } from './upstream';
|
||||||
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
|
// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
|
||||||
|
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
|
||||||
|
// credentials.
|
||||||
|
//
|
||||||
|
// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no
|
||||||
|
// longer read from the environment. See upstream.ts for why that move mattered.
|
||||||
|
//
|
||||||
|
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
|
||||||
|
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
|
||||||
|
//
|
||||||
|
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
|
||||||
|
//
|
||||||
|
// GET /_health ours. Confirms the key is live; reports the Immich version and who the key is.
|
||||||
|
// GET /_config the account registry MINUS every key: { configured, activeId, accounts[] }
|
||||||
|
// POST /_config { label?, url, and EITHER apiKey OR email+password } — the password mints a
|
||||||
|
// key and is never stored. Validated against the instance, then stored encrypted
|
||||||
|
// PATCH /_config/:id same fields, all optional — no credential keeps the stored key
|
||||||
|
// POST /_config/:id/activate switch to that account
|
||||||
|
// DEL /_config/:id remove it; the newest survivor is promoted if it was the active one
|
||||||
|
// * /_officer/<path> forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
|
||||||
|
//
|
||||||
|
// GET /_locked/status { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state
|
||||||
|
// POST /_locked/pin { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough)
|
||||||
|
// POST /_locked/unlock { password, pinCode } — signs in, elevates, holds the session IN MEMORY
|
||||||
|
// POST /_locked/lock drops elevation and closes the session
|
||||||
|
// * /_locked/api/<path> forwarded on that session — assets, search and timeline only (locked.ts)
|
||||||
|
//
|
||||||
|
// anything else 404
|
||||||
|
//
|
||||||
|
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
|
||||||
|
// loopback only, so its presence is the trust signal — a request without it did not come through the
|
||||||
|
// platform, and the connection is per-owner data.
|
||||||
|
//
|
||||||
|
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
|
||||||
|
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
|
||||||
|
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
|
function getFreePort(): number {
|
||||||
|
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
|
const p = probeServer.port;
|
||||||
|
probeServer.stop(true);
|
||||||
|
if (p == null) throw new Error('failed to acquire a free port');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = getFreePort();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
// Uploads go through this proxy as multipart bodies, and a phone's video is not small.
|
||||||
|
maxRequestBodySize: 4 * 1024 * 1024 * 1024,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
const officerUser = req.headers.get('X-Officer-User');
|
||||||
|
const userId = Number(officerUser);
|
||||||
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
||||||
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
||||||
|
try {
|
||||||
|
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = await getConfig(userId);
|
||||||
|
|
||||||
|
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
||||||
|
// configured-but-broken instance is the whole reason the flag is on the response.
|
||||||
|
if (url.pathname === '/_health') {
|
||||||
|
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await probe(cfg);
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json(
|
||||||
|
{ ok: false, configured: true, account: cfg.label, version: result.version, error: result.error, ms },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await noteProbe(userId, cfg.id, result.version);
|
||||||
|
return Response.json({
|
||||||
|
ok: true,
|
||||||
|
configured: true,
|
||||||
|
account: cfg.label,
|
||||||
|
version: result.version,
|
||||||
|
user: result.user,
|
||||||
|
ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/_officer/')) {
|
||||||
|
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
|
||||||
|
try {
|
||||||
|
const res = await handleOfficerRoute(cfg, req, url);
|
||||||
|
if (res) return res;
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[photos] ${req.method} ${url.pathname} failed`, err);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) {
|
||||||
|
if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
|
||||||
|
try {
|
||||||
|
return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length));
|
||||||
|
} catch (err) {
|
||||||
|
// Never let the error text out: this surface handles a password and a PIN, and an upstream failure
|
||||||
|
// carrying either into a response body or a log line is the one leak worth being paranoid about.
|
||||||
|
console.error(`[photos] ${req.method} ${url.pathname} failed`);
|
||||||
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[photos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in photos_config)`);
|
||||||
|
|
||||||
|
type ReplyFn = (msg: SidecarEvent) => void;
|
||||||
|
|
||||||
|
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||||
|
switch (cmd.type) {
|
||||||
|
case 'ping':
|
||||||
|
reply({ type: 'pong', id: cmd.id });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
reply({
|
||||||
|
type: 'error',
|
||||||
|
id: (cmd as SidecarCommand).id,
|
||||||
|
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = createSidecarConnector({
|
||||||
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
|
name: 'photos',
|
||||||
|
handles: ['photos'],
|
||||||
|
onCommand(cmd, reply) {
|
||||||
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
|
},
|
||||||
|
onConnected() {
|
||||||
|
connection.send({ type: 'photos:server', port });
|
||||||
|
console.log(`[photos] reported server port ${port} to API`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[photos] ${signal} received, shutting down...`);
|
||||||
|
try {
|
||||||
|
server.stop(true);
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
connection.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import type { UpstreamConfig } from './upstream';
|
||||||
|
import { relayResponse } from './routes';
|
||||||
|
import { UpstreamError, callUpstream, login, logout } from './upstream';
|
||||||
|
|
||||||
|
// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach.
|
||||||
|
//
|
||||||
|
// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts
|
||||||
|
//
|
||||||
|
// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running
|
||||||
|
// 3.1.0 image rather than inferred:
|
||||||
|
//
|
||||||
|
// utils/access.js requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw`
|
||||||
|
// services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'`
|
||||||
|
// services/session.service.js create → the same guard, so a key cannot even mint a session to escape this
|
||||||
|
// repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)`
|
||||||
|
//
|
||||||
|
// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key
|
||||||
|
// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape
|
||||||
|
// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and
|
||||||
|
// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings.
|
||||||
|
//
|
||||||
|
// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not
|
||||||
|
// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The
|
||||||
|
// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed
|
||||||
|
// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still
|
||||||
|
// does not open the private folder — the PIN and the password are never at rest here.
|
||||||
|
//
|
||||||
|
// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the
|
||||||
|
// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a
|
||||||
|
// standing security-posture change and this is not: everything below is forgotten when the process restarts.
|
||||||
|
//
|
||||||
|
// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it
|
||||||
|
// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the
|
||||||
|
// request asked for rather than a door left open for anything under `auth/`.
|
||||||
|
|
||||||
|
/** What the API key alone can do, which is more than expected and worth being precise about. */
|
||||||
|
const PIN_PATH = '/api/auth/pin-code';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror
|
||||||
|
* that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used
|
||||||
|
* for longer than the window can never be elevated again, so it is closed rather than left open indefinitely.
|
||||||
|
*/
|
||||||
|
const IDLE_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */
|
||||||
|
const PIN_RE = /^\d{6}$/;
|
||||||
|
|
||||||
|
type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number };
|
||||||
|
|
||||||
|
// Keyed by Officer user. The value never leaves this module and is never persisted.
|
||||||
|
const elevations = new Map<number, Elevation>();
|
||||||
|
|
||||||
|
/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
|
||||||
|
async function drop(userId: number): Promise<void> {
|
||||||
|
const current = elevations.get(userId);
|
||||||
|
if (!current) return;
|
||||||
|
elevations.delete(userId);
|
||||||
|
await logout(current.base, current.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live session for this user, or null.
|
||||||
|
*
|
||||||
|
* Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was
|
||||||
|
* minted against, and serving the previous library's private folder after a switch would be the worst possible
|
||||||
|
* version of the caching bug the config routes already guard against.
|
||||||
|
*/
|
||||||
|
function current(userId: number, cfg: UpstreamConfig): Elevation | null {
|
||||||
|
const found = elevations.get(userId);
|
||||||
|
if (!found) return null;
|
||||||
|
|
||||||
|
if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) {
|
||||||
|
void drop(userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||||
|
|
||||||
|
/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */
|
||||||
|
const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 });
|
||||||
|
|
||||||
|
type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown };
|
||||||
|
|
||||||
|
const readBody = async (req: Request): Promise<Body> => ((await req.json().catch(() => null)) as Body | null) ?? {};
|
||||||
|
const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
||||||
|
|
||||||
|
/** Immich answers `{ message }`; anything else means we never really reached it. */
|
||||||
|
async function message(res: Response, fallback: string): Promise<string> {
|
||||||
|
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
|
||||||
|
return typeof body?.message === 'string' ? body.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The email to sign in as, taken from the key rather than from the caller.
|
||||||
|
*
|
||||||
|
* The client knows the password but has no reason to know which Immich account the stored key belongs to, and
|
||||||
|
* asking it to send an email would let a wrong one turn a valid password into "sign-in failed".
|
||||||
|
*/
|
||||||
|
async function ownerEmail(cfg: UpstreamConfig): Promise<string> {
|
||||||
|
const res = await callUpstream(cfg, { path: '/api/users/me' });
|
||||||
|
if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status);
|
||||||
|
|
||||||
|
const me = (await res.json()) as { email?: unknown };
|
||||||
|
if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500);
|
||||||
|
return me.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked.
|
||||||
|
*
|
||||||
|
* Answered through the session when there is one, because only then does Immich report `isElevated` and
|
||||||
|
* `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session.
|
||||||
|
* `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than
|
||||||
|
* being handed a form that cannot work.
|
||||||
|
*/
|
||||||
|
async function status(cfg: UpstreamConfig, userId: number): Promise<Response> {
|
||||||
|
const session = current(userId, cfg);
|
||||||
|
const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token });
|
||||||
|
if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status);
|
||||||
|
|
||||||
|
const info = (await res.json()) as {
|
||||||
|
pinCode?: boolean;
|
||||||
|
password?: boolean;
|
||||||
|
isElevated?: boolean;
|
||||||
|
pinExpiresAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold.
|
||||||
|
if (session && !info.isElevated) await drop(userId);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
hasPin: !!info.pinCode,
|
||||||
|
hasPassword: !!info.password,
|
||||||
|
unlocked: !!info.isElevated,
|
||||||
|
expiresAt: info.pinExpiresAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /_locked/unlock` — sign in, elevate, keep the session.
|
||||||
|
*
|
||||||
|
* A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one
|
||||||
|
* per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account.
|
||||||
|
*/
|
||||||
|
async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const password = typeof body.password === 'string' ? body.password : '';
|
||||||
|
const pinCode = str(body.pinCode);
|
||||||
|
|
||||||
|
if (!password) return bad('the Immich account password is required to unlock');
|
||||||
|
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
|
||||||
|
|
||||||
|
let token: string;
|
||||||
|
try {
|
||||||
|
token = await login(cfg.base, await ownerEmail(cfg), password);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400);
|
||||||
|
return bad(`could not reach the instance (${String(err)})`, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/api/auth/session/unlock',
|
||||||
|
method: 'POST',
|
||||||
|
bearer: token,
|
||||||
|
body: JSON.stringify({ pinCode }),
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
await logout(cfg.base, token);
|
||||||
|
return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace rather than accumulate: unlocking twice should not leave the first session open.
|
||||||
|
await drop(userId);
|
||||||
|
elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() });
|
||||||
|
|
||||||
|
return Response.json({ unlocked: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */
|
||||||
|
async function lock(cfg: UpstreamConfig, userId: number): Promise<Response> {
|
||||||
|
const session = current(userId, cfg);
|
||||||
|
if (session) {
|
||||||
|
await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token });
|
||||||
|
await drop(userId);
|
||||||
|
}
|
||||||
|
return Response.json({ unlocked: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/_locked/pin` — set up, change and remove the PIN, all on the API key.
|
||||||
|
*
|
||||||
|
* These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never
|
||||||
|
* touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder
|
||||||
|
* something Officer can offer end to end instead of sending the owner to Immich's own settings screen first.
|
||||||
|
*/
|
||||||
|
async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const pinCode = str(body.pinCode);
|
||||||
|
const newPinCode = str(body.newPinCode);
|
||||||
|
const password = typeof body.password === 'string' ? body.password : '';
|
||||||
|
|
||||||
|
const call = (method: string, payload: Record<string, string>) =>
|
||||||
|
callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' });
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
|
||||||
|
const res = await call('POST', { pinCode });
|
||||||
|
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change and reset both prove intent with the OLD pin or the account password, so the two are read together.
|
||||||
|
const proof: Record<string, string> | null = password ? { password } : pinCode ? { pinCode } : null;
|
||||||
|
if (!proof) return bad('the current PIN, or the account password, is required');
|
||||||
|
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits');
|
||||||
|
const res = await call('PUT', { ...proof, newPinCode });
|
||||||
|
return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await call('DELETE', proof);
|
||||||
|
if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status);
|
||||||
|
|
||||||
|
// Immich drops elevation from every session when the PIN is reset; our copy has to go with it.
|
||||||
|
await drop(userId);
|
||||||
|
return Response.json({ hasPin: false, unlocked: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The elevated forward, deliberately a fraction of the main allow-list.
|
||||||
|
*
|
||||||
|
* Three resources, because three are enough to see the folder and move things in and out of it: read the
|
||||||
|
* assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the
|
||||||
|
* trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an
|
||||||
|
* irreversible route is not worth putting behind a credential the owner cannot see.
|
||||||
|
*/
|
||||||
|
const ELEVATED: Record<string, readonly string[]> = {
|
||||||
|
assets: ['GET', 'PUT'],
|
||||||
|
search: ['POST'],
|
||||||
|
timeline: ['GET'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
|
||||||
|
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
|
||||||
|
// The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will
|
||||||
|
// never carry has to say so whether or not the folder happens to be open — answering 423 would invite a
|
||||||
|
// client to prompt for a password and a PIN to reach something that stays a 404 afterwards.
|
||||||
|
const [resource] = rest.split('/');
|
||||||
|
const allowed = resource ? ELEVATED[resource] : undefined;
|
||||||
|
if (!allowed) return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405);
|
||||||
|
|
||||||
|
const session = current(userId, cfg);
|
||||||
|
if (!session) return locked();
|
||||||
|
|
||||||
|
// Officer's own JWT arrives in `?token=` because an <img> cannot send a header. Forwarding it would write the
|
||||||
|
// owner's session credential into Immich's access log for every locked thumbnail.
|
||||||
|
const query = new URLSearchParams(url.search);
|
||||||
|
query.delete('token');
|
||||||
|
const search = query.toString();
|
||||||
|
|
||||||
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/api/${rest}`,
|
||||||
|
method: req.method,
|
||||||
|
query: search ? `?${search}` : '',
|
||||||
|
// Streamed, not buffered — same reason as the unlocked forwarder in routes.ts.
|
||||||
|
body: hasBody ? req.body : null,
|
||||||
|
contentType: req.headers.get('content-type'),
|
||||||
|
range: req.headers.get('range'),
|
||||||
|
ifNoneMatch: req.headers.get('if-none-match'),
|
||||||
|
bearer: session.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error.
|
||||||
|
if (res.status === 401) {
|
||||||
|
await drop(userId);
|
||||||
|
return locked('the private folder locked itself again');
|
||||||
|
}
|
||||||
|
|
||||||
|
session.lastUsedAt = Date.now();
|
||||||
|
return relayResponse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets/<id>/thumbnail'. */
|
||||||
|
export async function handleLockedRoute(
|
||||||
|
req: Request,
|
||||||
|
cfg: UpstreamConfig,
|
||||||
|
userId: number,
|
||||||
|
subpath: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const [, head, ...tail] = subpath.split('/');
|
||||||
|
|
||||||
|
if (head === 'api') return forward(req, url, cfg, userId, tail.join('/'));
|
||||||
|
|
||||||
|
if (head === 'status') {
|
||||||
|
return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'unlock') {
|
||||||
|
return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'lock') {
|
||||||
|
return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'pin') {
|
||||||
|
const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE';
|
||||||
|
return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ error: 'not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close sessions nobody came back for.
|
||||||
|
*
|
||||||
|
* Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account
|
||||||
|
* until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there.
|
||||||
|
*/
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [userId, session] of elevations) {
|
||||||
|
if (now - session.lastUsedAt > IDLE_MS) void drop(userId);
|
||||||
|
}
|
||||||
|
}, 60_000).unref();
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type { UpstreamConfig } from './upstream';
|
||||||
|
import { callUpstream } from './upstream';
|
||||||
|
|
||||||
|
// The Officer-owned route surface. This is an allow-list, not a passthrough.
|
||||||
|
//
|
||||||
|
// Immich's API is already REST and already shaped the way a photo UI wants it, so the value this sidecar
|
||||||
|
// adds is (a) holding the API key, (b) streaming the binary routes through without buffering them, and
|
||||||
|
// (c) refusing to expose the administrative half of the API at all.
|
||||||
|
//
|
||||||
|
// The gate is the FIRST path segment plus the method. Anything whose first segment is not listed here is a
|
||||||
|
// 404 before a request is made, so the deny side is closed by construction rather than by enumeration.
|
||||||
|
//
|
||||||
|
// DELIBERATELY NOT EXPOSED: admin/* (user creation, deletion, quotas), auth/* and oauth/* (session and
|
||||||
|
// password machinery — the key is the credential here, and nothing should be minting sessions through a
|
||||||
|
// dashboard), api-keys/* (a proxy that can mint its own credentials is not a proxy), sessions/*, jobs/*
|
||||||
|
//
|
||||||
|
// `auth/*` is STILL refused here, and locked.ts does not change that. Immich's private folder needs four auth
|
||||||
|
// routes (`auth/status`, `auth/pin-code`, `auth/session/unlock`, `auth/session/lock`), and they are reached
|
||||||
|
// through named endpoints on a separate `/_locked` surface that each do one thing — not by making `auth` a
|
||||||
|
// resource anything may address. The distinction is the point: the decision that was taken was "the sidecar
|
||||||
|
// may unlock the private folder", not "the sidecar may reach session and password machinery".
|
||||||
|
// (queue control), system-config/* and system-metadata/* (rewrites the deployment), libraries/* (external
|
||||||
|
// library paths and scans) and sync/*. Those either mutate the deployment or can lock the owner out of it.
|
||||||
|
// Adding one should be a decision, not an accident.
|
||||||
|
|
||||||
|
const RESOURCES: Record<string, readonly string[]> = {
|
||||||
|
// The library itself.
|
||||||
|
assets: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
albums: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
|
timeline: ['GET'],
|
||||||
|
memories: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
people: ['GET', 'POST', 'PUT'],
|
||||||
|
faces: ['GET', 'PUT'],
|
||||||
|
tags: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
stacks: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||||
|
// Finding things. `search/metadata`, `search/smart` and `search/random` are POSTs with a JSON body.
|
||||||
|
search: ['GET', 'POST'],
|
||||||
|
duplicates: ['GET'],
|
||||||
|
map: ['GET'],
|
||||||
|
view: ['GET'],
|
||||||
|
// Getting things out. `download/info` then `download/archive` — both POST, the archive streams a zip.
|
||||||
|
download: ['POST'],
|
||||||
|
// Recoverable deletes. `trash/empty` is the one irreversible route in this list; it is here because it is
|
||||||
|
// the counterpart of a delete the UI can already perform, not because it is safe.
|
||||||
|
trash: ['GET', 'POST'],
|
||||||
|
// Sharing and social. Read-mostly, but a shared link is the point of them.
|
||||||
|
'shared-links': ['GET', 'POST', 'PATCH', 'DELETE'],
|
||||||
|
activities: ['GET', 'POST', 'DELETE'],
|
||||||
|
partners: ['GET'],
|
||||||
|
notifications: ['GET', 'PUT', 'DELETE'],
|
||||||
|
// Read-only context: who the key acts as, and what the server is.
|
||||||
|
users: ['GET'],
|
||||||
|
server: ['GET'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response headers worth carrying back. Content-Type and Content-Length for every response; the rest so a
|
||||||
|
// thumbnail can be cached and revalidated and a video can be seeked without the proxy understanding either.
|
||||||
|
const PASSTHROUGH_HEADERS = [
|
||||||
|
'content-type',
|
||||||
|
'content-length',
|
||||||
|
'content-disposition',
|
||||||
|
'content-range',
|
||||||
|
'accept-ranges',
|
||||||
|
'etag',
|
||||||
|
'last-modified',
|
||||||
|
'cache-control',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand one Immich response back to the caller, keeping only the headers above.
|
||||||
|
*
|
||||||
|
* Shared with the locked-folder surface so both forwarders stream rather than buffer and agree on what a
|
||||||
|
* thumbnail is allowed to carry back. 204 and 304 must not have a body attached — Bun throws if one is.
|
||||||
|
*/
|
||||||
|
export function relayResponse(res: Response): Response {
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const name of PASSTHROUGH_HEADERS) {
|
||||||
|
const value = res.headers.get(name);
|
||||||
|
if (value) headers.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = res.status === 204 || res.status === 304 ? null : res.body;
|
||||||
|
return new Response(body, { status: res.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest request body held in memory. Above this the body is STREAMED instead — never refused.
|
||||||
|
*
|
||||||
|
* The cap exists to bound heap, not to bound uploads: a 4 GB video must still go through. What is given
|
||||||
|
* up above the cap is only the guarantee that a REJECTION carries its message, and an upload that big
|
||||||
|
* which Immich rejects was going to fail either way — see `readBodyCapped`.
|
||||||
|
*/
|
||||||
|
const MAX_BUFFERED_BODY = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the whole request body into memory, refusing above `MAX_BUFFERED_BODY`.
|
||||||
|
*
|
||||||
|
* ── Why this is buffered rather than streamed ──
|
||||||
|
*
|
||||||
|
* It used to stream, which is the obvious thing for an upload and was wrong for one specific reason:
|
||||||
|
* when the body is still being written and Immich answers early, the response is lost. Immich rejects
|
||||||
|
* an asset as soon as it has enough of the file to judge it, then closes; our writer is still going;
|
||||||
|
* Node's HTTP server calls the leftover bytes a protocol violation and replaces the app's answer with a
|
||||||
|
* bodyless `400 Bad Request` + `Connection: close`. The app's real message never reaches the wire and
|
||||||
|
* Immich logs nothing, so the client sees an unexplained 400 and the server has no record of it.
|
||||||
|
*
|
||||||
|
* Measured on this machine (COMMS/UPLOAD_EMPTY_400*.md): streamed lost the response in 2 of 4 attempts
|
||||||
|
* at 8 MB, 32 MB and 64 MB alike; buffered lost it in 0 of 4 at every size. It is a race, not a size
|
||||||
|
* limit — its probability just approaches certainty as the body grows, which is why a phone's video
|
||||||
|
* failed every time while a small photo usually got through.
|
||||||
|
*
|
||||||
|
* A size threshold was considered and rejected: buffering only the small bodies would leave exactly the
|
||||||
|
* large uploads that always fail on the broken path. Cancelling the write once the response arrives was
|
||||||
|
* tried and does not help — by the time `fetch` resolves, the teardown has already happened.
|
||||||
|
*
|
||||||
|
* The cost is real and was accepted deliberately: a 300 MB video is 300 MB of this process's heap for
|
||||||
|
* the duration of the upload. That is the price of the upload working at all.
|
||||||
|
*
|
||||||
|
* ── And why there is a ceiling on it ──
|
||||||
|
*
|
||||||
|
* Buffering everything would mean a 4 GB video sitting in this heap (and another copy in officer's), so
|
||||||
|
* above MAX_BUFFERED_BODY the body is streamed as it always was. That is not a refusal and not a
|
||||||
|
* degradation of uploads: a file Immich ACCEPTS never triggers the race, because it is read to the end
|
||||||
|
* before anything is answered. The only thing lost above the cap is the error message on a file Immich
|
||||||
|
* rejects — and an upload that size which Immich rejects fails either way. Refusing it outright would
|
||||||
|
* break the legitimate case to improve the diagnosis of the doomed one.
|
||||||
|
*/
|
||||||
|
async function readBodyCapped(
|
||||||
|
req: Request,
|
||||||
|
): Promise<{ ok: true; bytes: Uint8Array<ArrayBuffer> } | { ok: false; seen: number }> {
|
||||||
|
// Decided from `content-length` alone. Officer sends one whenever IT buffered, so the two hops agree
|
||||||
|
// without coordinating: a body small enough for officer to hold is small enough to hold here. When the
|
||||||
|
// header is absent officer streamed, which means it was over the cap there too.
|
||||||
|
const declared = Number(req.headers.get('content-length') ?? '');
|
||||||
|
if (!Number.isFinite(declared) || declared <= 0 || declared > MAX_BUFFERED_BODY) return { ok: false, seen: declared };
|
||||||
|
|
||||||
|
const reader = req.body!.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.length;
|
||||||
|
if (total > MAX_BUFFERED_BODY) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
return { ok: false, seen: total };
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(total));
|
||||||
|
let at = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, at);
|
||||||
|
at += chunk.length;
|
||||||
|
}
|
||||||
|
return { ok: true, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward one request to Immich and stream the answer back.
|
||||||
|
*
|
||||||
|
* The RESPONSE is still a stream, never an ArrayBuffer: a full-resolution original or a
|
||||||
|
* `download/archive` zip is hundreds of megabytes and buffering it would hold all of it in the
|
||||||
|
* sidecar's heap for no reason. Only the request body is held, and only for the reason above.
|
||||||
|
*/
|
||||||
|
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const rest = url.pathname.slice('/_officer/'.length);
|
||||||
|
if (!rest) return null;
|
||||||
|
|
||||||
|
const [resource] = rest.split('/');
|
||||||
|
const allowedMethods = resource ? RESOURCES[resource] : undefined;
|
||||||
|
if (!allowedMethods) return null;
|
||||||
|
if (!allowedMethods.includes(req.method)) {
|
||||||
|
return Response.json({ error: `${req.method} not allowed on ${resource}` }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||||
|
|
||||||
|
// An <img>/<video> cannot send an Authorization header, so the browser puts Officer's JWT in `?token=`
|
||||||
|
// (userMiddleware accepts it there). That token is Officer's, not Immich's: forwarding it would write the
|
||||||
|
// owner's session credential into Immich's access log for every thumbnail. Drop it at the boundary.
|
||||||
|
const query = new URLSearchParams(url.search);
|
||||||
|
query.delete('token');
|
||||||
|
const search = query.toString();
|
||||||
|
|
||||||
|
// Buffered, not streamed — see readBodyCapped. A body that is still being written when Immich answers
|
||||||
|
// loses the answer, which is what made every large upload fail with an unexplained empty 400.
|
||||||
|
let body: BodyInit | null = null;
|
||||||
|
if (hasBody && req.body) {
|
||||||
|
const read = await readBodyCapped(req);
|
||||||
|
// Over the cap (or no declared length): stream it, exactly as this always did. Not an error.
|
||||||
|
body = read.ok ? read.bytes : req.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/api/${rest}`,
|
||||||
|
method: req.method,
|
||||||
|
query: search ? `?${search}` : '',
|
||||||
|
body,
|
||||||
|
contentType: req.headers.get('content-type'),
|
||||||
|
range: req.headers.get('range'),
|
||||||
|
ifNoneMatch: req.headers.get('if-none-match'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return relayResponse(res);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Immich upstream config for the officer-photos sidecar.
|
||||||
|
//
|
||||||
|
// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin
|
||||||
|
// auth+forward proxy and holds NO Immich credentials.
|
||||||
|
//
|
||||||
|
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see
|
||||||
|
// databases/officer_db/src/photos/queries.ts). It is deliberately no longer read from the environment:
|
||||||
|
// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there
|
||||||
|
// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code
|
||||||
|
// to use it and the largest attack surface in the system. Nothing in this file reads process.env.
|
||||||
|
//
|
||||||
|
// Two things about Immich's API are load-bearing:
|
||||||
|
//
|
||||||
|
// 1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
|
||||||
|
// 403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
|
||||||
|
// credential. Create the key with all permissions unless there is a reason not to.
|
||||||
|
// 2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
|
||||||
|
// credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
|
||||||
|
// exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
|
||||||
|
// nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.
|
||||||
|
|
||||||
|
import { getActivePhotosCredentials } from '../db/queries';
|
||||||
|
|
||||||
|
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||||
|
export type UpstreamTarget = { base: string; key: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored account, which is where every real call goes.
|
||||||
|
*
|
||||||
|
* `id` and `label` ride along because the owner can have several accounts registered and only one selected:
|
||||||
|
* a probe has to be recorded against the row it actually reached, and a log line saying which library
|
||||||
|
* answered is the difference between "photos is broken" and "you are looking at the other account".
|
||||||
|
*/
|
||||||
|
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||||
|
|
||||||
|
/** Trailing slashes off, so `${base}/api/...` never doubles the separator. */
|
||||||
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// A thumbnail grid is a hundred requests in a second and each one needs the key, so the row is cached rather
|
||||||
|
// than re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in
|
||||||
|
// psql, which then takes effect within a minute instead of needing a restart.
|
||||||
|
const TTL_MS = 60_000;
|
||||||
|
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's SELECTED account, or null when /photos has no account yet — the sidecar then answers 503,
|
||||||
|
* and the UI turns that into the setup form rather than a wall of empty grids.
|
||||||
|
*/
|
||||||
|
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||||
|
const hit = cache.get(userId);
|
||||||
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||||
|
|
||||||
|
const creds = await getActivePhotosCredentials(userId);
|
||||||
|
const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : null;
|
||||||
|
cache.set(userId, { cfg, at: Date.now() });
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
|
||||||
|
export function invalidateConfig(userId: number): void {
|
||||||
|
cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries the upstream status so callers can tell a rejected credential from an unreachable instance. */
|
||||||
|
export class UpstreamError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */
|
||||||
|
async function upstreamMessage(res: Response, fallback: string): Promise<string> {
|
||||||
|
const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
|
||||||
|
const message = typeof body?.message === 'string' ? body.message : '';
|
||||||
|
return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade an email and password for a SESSION token.
|
||||||
|
*
|
||||||
|
* A session is not an API key and the difference is load-bearing in both directions: sessions are listed as
|
||||||
|
* devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the
|
||||||
|
* `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate.
|
||||||
|
* `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against
|
||||||
|
* the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to
|
||||||
|
* get there. That is why this is exported rather than inlined into mintApiKey.
|
||||||
|
*
|
||||||
|
* The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it.
|
||||||
|
*/
|
||||||
|
export async function login(base: string, email: string, password: string): Promise<string> {
|
||||||
|
const res = await fetch(`${base}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
// Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus
|
||||||
|
// "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them.
|
||||||
|
if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status);
|
||||||
|
|
||||||
|
const session = (await res.json()) as { accessToken?: unknown };
|
||||||
|
if (typeof session.accessToken !== 'string' || !session.accessToken) {
|
||||||
|
throw new UpstreamError('sign-in returned no access token', res.status);
|
||||||
|
}
|
||||||
|
return session.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the
|
||||||
|
* caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich
|
||||||
|
* account for every sign-in Officer makes.
|
||||||
|
*/
|
||||||
|
export async function logout(base: string, token: string): Promise<void> {
|
||||||
|
await fetch(`${base}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
redirect: 'manual',
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
|
||||||
|
* settings screen.
|
||||||
|
*
|
||||||
|
* `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
|
||||||
|
* grantable because a session has every permission — an API key can never mint a key stronger than itself
|
||||||
|
* (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped,
|
||||||
|
* and an under-scoped one returns 403 per route, which reads as a broken feature.
|
||||||
|
*
|
||||||
|
* The session exists only to hold the key and is closed in the `finally`. The password is used here and
|
||||||
|
* nowhere else; only the key survives this function.
|
||||||
|
*/
|
||||||
|
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
|
||||||
|
const accessToken = await login(base, email, password);
|
||||||
|
const bearer = { Authorization: `Bearer ${accessToken}` };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await fetch(`${base}/api/api-keys`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'Officer', permissions: ['all'] }),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
if (!created.ok) {
|
||||||
|
throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await created.json()) as { secret?: unknown };
|
||||||
|
if (typeof payload.secret !== 'string' || !payload.secret) {
|
||||||
|
throw new UpstreamError('Immich created a key but returned no secret', created.status);
|
||||||
|
}
|
||||||
|
return payload.secret;
|
||||||
|
} finally {
|
||||||
|
await logout(base, accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallOptions = {
|
||||||
|
/** Absolute path on the Immich host, e.g. `/api/albums`. */
|
||||||
|
path: string;
|
||||||
|
method?: string;
|
||||||
|
/** Raw search string including the leading `?`, or empty. */
|
||||||
|
query?: string;
|
||||||
|
/**
|
||||||
|
* A string for the JSON callers, or the forwarder's already-buffered request body.
|
||||||
|
*
|
||||||
|
* This used to be a stream, for the obvious reason that a phone's video should not sit in this
|
||||||
|
* process's heap. It was changed back deliberately: a request body still being written when Immich
|
||||||
|
* answers loses the answer entirely — Node replaces the app's response with a bodyless 400 and closes
|
||||||
|
* — which is what made every large upload fail inexplicably. `readBodyCapped` in routes.ts has the
|
||||||
|
* measurements and the alternatives that were tried; the heap cost is the accepted price.
|
||||||
|
*
|
||||||
|
* A buffer also carries a Content-Length, so the request to Immich is no longer chunked.
|
||||||
|
*/
|
||||||
|
body?: BodyInit | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
/** Byte range for thumbnail/original/video reads, forwarded verbatim. */
|
||||||
|
range?: string | null;
|
||||||
|
ifNoneMatch?: string | null;
|
||||||
|
/** Set false for the version/ping routes, which Immich serves unauthenticated. */
|
||||||
|
withKey?: boolean;
|
||||||
|
/**
|
||||||
|
* A session token to authenticate as, INSTEAD of the stored API key.
|
||||||
|
*
|
||||||
|
* Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that
|
||||||
|
* must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are
|
||||||
|
* mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its
|
||||||
|
* header precedence rather than to this file.
|
||||||
|
*/
|
||||||
|
bearer?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The single door to Immich. Everything the sidecar fetches goes through here. */
|
||||||
|
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||||
|
|
||||||
|
if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
|
||||||
|
else if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
|
||||||
|
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||||
|
if (opts.range) headers.Range = opts.range;
|
||||||
|
if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;
|
||||||
|
|
||||||
|
// `duplex: 'half'` is required by the fetch spec whenever the body is a stream, and is absent from
|
||||||
|
// TypeScript's RequestInit — hence the widened type, same as createSidecarProxy does. Harmless for the
|
||||||
|
// string bodies.
|
||||||
|
const init: RequestInit & { duplex?: 'half' } = {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body ?? undefined,
|
||||||
|
duplex: 'half',
|
||||||
|
// A redirect from an API route means something has gone wrong with auth; surface it rather than
|
||||||
|
// following it into an HTML page.
|
||||||
|
redirect: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, init);
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
// 403 is the interesting one: the key is valid but lacks the permission this route needs.
|
||||||
|
console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
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-success" />
|
||||||
|
) : (
|
||||||
|
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
|
||||||
|
)}
|
||||||
|
<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-success/15 text-success">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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-success/15 text-success 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'photos-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'photos-nav', appType: 'photos-nav' }, size: 18 },
|
||||||
|
{ node: { type: 'panel', id: 'photos-view', appType: 'photos-view' }, size: 82 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||||
|
import { PhotosNav } from './PhotosNav';
|
||||||
|
import { PhotosView } from './PhotosView';
|
||||||
|
import { PhotosViewHeader } from './PhotosViewHeader';
|
||||||
|
|
||||||
|
// `panels.ts`, not `.tsx` — discovery checks for that exact name. Two panels: a section nav on the left
|
||||||
|
// and the view on the right, both reading `:section` from the URL rather than being told by each other.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` on both, because the screen renders the workspace `locked` — these are not
|
||||||
|
// swappable, and offering them in a picker would be a lie.
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
// 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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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>;
|
||||||
Reference in New Issue
Block a user