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