photos: immich accounts are configured from the ui, not the environment
IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong twice over: bun auto-loads .env into every process started in this directory, so `officer` itself held an immich credential it has no code to use — and connecting a 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 connection: any number of labelled accounts with one selected, the same shape headscale_servers uses. two keys against the same instance (one per immich user) is the ordinary case, so the label is what has to be unique, not the url. one active account per owner is enforced by a partial unique index rather than by convention. keys are encrypted at rest and write-only across the sidecar boundary — no route returns one, masked or otherwise. every save is validated against the live instance first, so a wrong or under-scoped key is a 400 with the reason instead of a stored row that makes every later screen fail mysteriously. the drizzle snapshot under migrations/ is regenerated; nothing applies it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { photosSectionPath } from './shared';
|
||||
import { usePhotosAccountActions, usePhotosAccounts, usePhotosHealth } from './usePhotosData';
|
||||
|
||||
// Which Immich library you are looking at, and how to change it.
|
||||
//
|
||||
// The subtitle under "Photos" was already the connection state, so the switcher lives there rather than
|
||||
// adding a second control saying nearly the same thing. With one account it is exactly what it was: a line
|
||||
// of text plus a way into the settings screen.
|
||||
//
|
||||
// Switching invalidates the whole ['photos'] key, because it changes the answer to every query in the
|
||||
// workspace without changing any of their inputs — the grid, the albums, the map are all the other library's
|
||||
// now. Anything less and the previous account's thumbnails stay on screen.
|
||||
|
||||
export const AccountSwitcher = () => {
|
||||
const { data } = usePhotosAccounts();
|
||||
const { data: health } = usePhotosHealth();
|
||||
const { activate } = usePhotosAccountActions();
|
||||
|
||||
const accounts = data?.accounts ?? [];
|
||||
const active = accounts.find((account) => account.isActive) ?? null;
|
||||
|
||||
// Before the registry answers, fall back to health — it is the query that was already driving this line.
|
||||
const status = active?.label ?? (health?.ok ? (health.version ?? 'connected') : 'not connected');
|
||||
|
||||
if (accounts.length < 2) {
|
||||
return (
|
||||
<Link
|
||||
to={photosSectionPath('settings')}
|
||||
className="block truncate text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{status}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<span className="truncate">{status}</span>
|
||||
{activate.isPending ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Immich accounts</DropdownMenuLabel>
|
||||
{accounts.map((account) => (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
disabled={account.isActive || activate.isPending}
|
||||
onSelect={() => void activate.mutateAsync(account.id).catch(() => undefined)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${account.isActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{account.label}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{account.url}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={photosSectionPath('settings')} className="gap-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage accounts
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { PhotosAccount } 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.';
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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 [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 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;
|
||||
setApiKey('');
|
||||
} 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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
placeholder="Replace API key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
disabled={!apiKey.trim() || edit.isPending}
|
||||
onClick={() => void run(edit.mutateAsync({ id: account.id, apiKey: apiKey.trim() }))}
|
||||
>
|
||||
{edit.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionSection = () => {
|
||||
const { data, isLoading } = usePhotosAccounts();
|
||||
const { add } = usePhotosAccountActions();
|
||||
|
||||
const [label, setLabel] = useState('');
|
||||
const [url, setUrl] = useState(DEFAULT_URL);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
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 (!apiKey.trim()) return setError('An API key is required');
|
||||
|
||||
try {
|
||||
await add.mutateAsync({ label: label.trim(), url: url.trim(), apiKey: apiKey.trim() });
|
||||
setLabel('');
|
||||
setApiKey('');
|
||||
setUrl(DEFAULT_URL);
|
||||
} 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 an API key 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}
|
||||
/>
|
||||
<Field
|
||||
label="API key"
|
||||
value={apiKey}
|
||||
onChange={setApiKey}
|
||||
placeholder="••••••••••••"
|
||||
type="password"
|
||||
hint={KEY_HINT}
|
||||
/>
|
||||
|
||||
{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,8 +1,9 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Archive, Compass, Heart, Images, Map as MapIcon, Search, Share2, Trash2, Users } from 'lucide-react';
|
||||
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, usePhotosHealth } from './usePhotosData';
|
||||
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.
|
||||
@@ -20,6 +21,7 @@ const ICONS: Record<PhotosSectionId, LucideIcon> = {
|
||||
archive: Archive,
|
||||
sharing: Share2,
|
||||
trash: Trash2,
|
||||
settings: Plug,
|
||||
};
|
||||
|
||||
// Immich draws the same line: browsing the library, then everything else.
|
||||
@@ -29,7 +31,6 @@ const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-le
|
||||
|
||||
export const PhotosNav = () => {
|
||||
const { data: stats } = useAssetStats();
|
||||
const { data: health } = usePhotosHealth();
|
||||
|
||||
const group = (ids: PhotosSectionId[]) =>
|
||||
PHOTOS_SECTIONS.filter((section) => ids.includes(section.id)).map(({ id, label }) => {
|
||||
@@ -71,9 +72,7 @@ export const PhotosNav = () => {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Photos</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{health?.ok ? (health.version ?? 'connected') : 'not connected'}
|
||||
</div>
|
||||
<AccountSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
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();
|
||||
|
||||
// Every section is useless without the upstream, and a wall of empty grids is a worse answer than saying so.
|
||||
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 connected</p>
|
||||
<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 Immich. Check IMMICH_URL and IMMICH_API_KEY.'}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PHOTOS_SECTIONS = [
|
||||
{ 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'];
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
TimeBucketColumns,
|
||||
} from './shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { bucketAssets } from './shared';
|
||||
|
||||
@@ -234,14 +234,125 @@ export function useServerConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
export type PhotosHealth = { ok: boolean; version?: string | null; user?: string | null; error?: string };
|
||||
// ── 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: () => get<PhotosHealth>('/photos/_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,
|
||||
});
|
||||
}
|
||||
|
||||
export type AddPhotosAccountInput = { label: string; url: string; apiKey: string };
|
||||
export type EditPhotosAccountInput = { id: number; label?: string; url?: string; apiKey?: 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user