Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f9e608a88 | ||
|
|
70fc2b08c6 |
@@ -14,6 +14,19 @@ your originals and every thumbnail stay exactly where they are.
|
||||
- **More than one instance.** Accounts are a per-user registry with a label, a URL and a key; one is
|
||||
active and you switch between them in the app.
|
||||
|
||||
## Sharing
|
||||
|
||||
Make a public link to an album, or to any selection of photos — from the album header, the selection bar,
|
||||
or the Sharing panel. A link can carry a password and an expiry, and you choose whether the people who open
|
||||
it may download originals, add photos of their own, or see where and when each shot was taken. Revoking one
|
||||
closes it for everybody holding it.
|
||||
|
||||
The link opens on **your Immich**, not on Officer — that is the only way somebody with no Officer account
|
||||
can see it. So Officer needs to know your instance's public address, and it deliberately stores no copy of
|
||||
one: it reads **External Domain** from Immich's own admin settings, the same field Immich's own copy-link
|
||||
button uses, so the two can never disagree. Leave that field empty and the panel shows share keys instead of
|
||||
URLs, and says so.
|
||||
|
||||
## What you need
|
||||
|
||||
**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys).
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
||||
*/
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.27.01',
|
||||
version: '2026.09.03.01',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Photos',
|
||||
|
||||
+15
-2
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { ArrowLeft, ImageOff, MoreVertical, Plus } from 'lucide-react';
|
||||
import { ArrowLeft, ImageOff, MoreVertical, Plus, Share2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,6 +12,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import { AssetViewer } from './AssetViewer';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { SelectionBar } from './SelectionBar';
|
||||
import { ShareLinkDialog } from './ShareLinkDialog';
|
||||
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
||||
import { useAlbum, useAlbums } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
@@ -123,6 +124,7 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||
|
||||
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
||||
const selection = useSelection(assets.map((asset) => asset.id));
|
||||
const [sharing, setSharing] = useState(false);
|
||||
|
||||
const back = (() => {
|
||||
const next = new URLSearchParams(params);
|
||||
@@ -150,6 +152,11 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setSharing(true)} disabled={!album}>
|
||||
<Share2 className="mr-1.5 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -167,6 +174,12 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
||||
</div>
|
||||
|
||||
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
||||
|
||||
<ShareLinkDialog
|
||||
open={sharing}
|
||||
onOpenChange={setSharing}
|
||||
target={album ? { kind: 'album', albumId: album.id, albumName: album.albumName } : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-1
@@ -1,5 +1,6 @@
|
||||
import type { Selection } from './useSelection';
|
||||
import { Archive, ArchiveRestore, Heart, Plus, Trash2, Undo2, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Archive, ArchiveRestore, Heart, Plus, Share2, Trash2, Undo2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ShareLinkDialog } from './ShareLinkDialog';
|
||||
import { useAlbums } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
@@ -29,6 +31,12 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
|
||||
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
|
||||
useAssetActions();
|
||||
const { data: albums } = useAlbums();
|
||||
/*
|
||||
The ids are captured when Share is pressed rather than read live, so the dialog keeps acting on what was
|
||||
selected even if the selection changes behind it. Selection is deliberately NOT cleared: a link is usually
|
||||
made to send, and losing the selection makes a second link over the same photos a re-selection job.
|
||||
*/
|
||||
const [sharing, setSharing] = useState<string[] | null>(null);
|
||||
|
||||
const run = (action: () => void) => {
|
||||
action();
|
||||
@@ -95,6 +103,11 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => setSharing(ids)}>
|
||||
<Share2 className="mr-1.5 h-4 w-4" />
|
||||
Share
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -116,6 +129,12 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ShareLinkDialog
|
||||
open={!!sharing}
|
||||
onOpenChange={(next) => !next && setSharing(null)}
|
||||
target={sharing ? { kind: 'assets', assetIds: sharing } : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Copy, ExternalLink } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import type { SharedLink } from './shared';
|
||||
import { shareUrl } from './shared';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
import { useServerConfig } from './usePhotosData';
|
||||
|
||||
// Creating and editing one shared link.
|
||||
//
|
||||
// A shared link is the only thing /photos makes that anybody on the internet can open, so this form states
|
||||
// every consequence rather than relying on a default the owner cannot see. Immich's own defaults are
|
||||
// download-allowed, metadata-shown and no expiry; those are reproduced here as visible switches, not
|
||||
// inherited silently.
|
||||
//
|
||||
// After a successful create the dialog does NOT close. Making a link is never the goal — sending it is —
|
||||
// so it stays open holding the URL and a copy button. Closing on success would hand back a link the owner
|
||||
// then has to go and find.
|
||||
|
||||
export type ShareTarget =
|
||||
| { kind: 'album'; albumId: string; albumName: string }
|
||||
| { kind: 'assets'; assetIds: string[] };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Create mode: what the new link points at. */
|
||||
target?: ShareTarget | null;
|
||||
/** Edit mode: the link being changed. Takes precedence over `target`. */
|
||||
link?: SharedLink | null;
|
||||
};
|
||||
|
||||
/** Offsets in minutes. `0` is "never", which Immich stores as a null expiry rather than a far-off date. */
|
||||
const EXPIRY_CHOICES = [
|
||||
{ value: '0', label: 'Never' },
|
||||
{ value: '30', label: '30 minutes' },
|
||||
{ value: '60', label: '1 hour' },
|
||||
{ value: '1440', label: '1 day' },
|
||||
{ value: '10080', label: '7 days' },
|
||||
{ value: '43200', label: '30 days' },
|
||||
{ value: '129600', label: '3 months' },
|
||||
{ value: '525600', label: '1 year' },
|
||||
] as const;
|
||||
|
||||
const expiryFromChoice = (minutes: string): string | null =>
|
||||
minutes === '0' ? null : new Date(Date.now() + Number(minutes) * 60_000).toISOString();
|
||||
|
||||
export const ShareLinkDialog = ({ open, onOpenChange, target, link }: Props) => {
|
||||
const editing = !!link;
|
||||
const { createSharedLink, updateSharedLink } = useAssetActions();
|
||||
const { data: config } = useServerConfig();
|
||||
|
||||
const [description, setDescription] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [expiry, setExpiry] = useState<string>('0');
|
||||
const [allowDownload, setAllowDownload] = useState(true);
|
||||
const [allowUpload, setAllowUpload] = useState(false);
|
||||
const [showMetadata, setShowMetadata] = useState(true);
|
||||
/** The link this dialog is currently showing a URL for — the one just created, or the one being edited. */
|
||||
const [made, setMade] = useState<SharedLink | null>(null);
|
||||
|
||||
// Reset on every open so a second link never inherits the first one's settings. An edit seeds from the
|
||||
// link; a create starts from Immich's defaults, spelled out above.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDescription(link?.description ?? '');
|
||||
setPassword(link?.password ?? '');
|
||||
setExpiry('0');
|
||||
setAllowDownload(link?.allowDownload ?? true);
|
||||
setAllowUpload(link?.allowUpload ?? false);
|
||||
setShowMetadata(link?.showMetadata ?? true);
|
||||
setMade(link ?? null);
|
||||
}, [open, link]);
|
||||
|
||||
const url = made ? shareUrl(config?.externalDomain, made) : null;
|
||||
|
||||
const submit = async () => {
|
||||
const shared = {
|
||||
description: description.trim() || null,
|
||||
password: password.trim() || null,
|
||||
allowDownload,
|
||||
allowUpload,
|
||||
showMetadata,
|
||||
};
|
||||
|
||||
if (editing && link) {
|
||||
// An untouched expiry select must not silently extend a link that already expires, so `expiresAt` is
|
||||
// only sent when the owner actually picked something.
|
||||
const result = await updateSharedLink.mutateAsync({
|
||||
id: link.id,
|
||||
...shared,
|
||||
...(expiry === '0' ? {} : { expiresAt: expiryFromChoice(expiry) }),
|
||||
});
|
||||
setMade(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) return;
|
||||
const result = await createSharedLink.mutateAsync({
|
||||
...shared,
|
||||
expiresAt: expiryFromChoice(expiry),
|
||||
...(target.kind === 'album'
|
||||
? { type: 'ALBUM' as const, albumId: target.albumId }
|
||||
: { type: 'INDIVIDUAL' as const, assetIds: target.assetIds }),
|
||||
});
|
||||
setMade(result);
|
||||
toast.success('Link created');
|
||||
};
|
||||
|
||||
const what = editing
|
||||
? (link?.album?.albumName ?? link?.description ?? `${link?.assets.length ?? 0} items`)
|
||||
: target?.kind === 'album'
|
||||
? target.albumName
|
||||
: `${target?.assetIds.length ?? 0} items`;
|
||||
|
||||
const busy = createSharedLink.isPending || updateSharedLink.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit shared link' : 'Share'}</DialogTitle>
|
||||
<DialogDescription className="truncate">{what}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-description">Description</Label>
|
||||
<Input
|
||||
id="share-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional — only you see this"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-password">Password</Label>
|
||||
<Input
|
||||
id="share-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Optional — anyone with the link must type this"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Expires</Label>
|
||||
<Select value={expiry} onValueChange={setExpiry}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_CHOICES.map((choice) => (
|
||||
<SelectItem key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editing && link?.expiresAt && expiry === '0' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently expires {new Date(link.expiresAt).toLocaleString()}. Leave as Never to keep that date.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border p-3">
|
||||
<Toggle
|
||||
id="share-download"
|
||||
label="Allow downloading"
|
||||
hint="Originals, and a zip of the whole set"
|
||||
checked={allowDownload}
|
||||
onChange={setAllowDownload}
|
||||
/>
|
||||
<Toggle
|
||||
id="share-upload"
|
||||
label="Allow uploading"
|
||||
hint="Anyone with the link can add photos"
|
||||
checked={allowUpload}
|
||||
onChange={setAllowUpload}
|
||||
/>
|
||||
<Toggle
|
||||
id="share-metadata"
|
||||
label="Show metadata"
|
||||
hint="Dates, camera and location"
|
||||
checked={showMetadata}
|
||||
onChange={setShowMetadata}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{made && (
|
||||
<div className="space-y-1.5 rounded-lg border p-3">
|
||||
<Label>Link</Label>
|
||||
{url ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input readOnly value={url} className="font-mono text-xs" onFocus={(e) => e.target.select()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy link"
|
||||
onClick={() => {
|
||||
void copyToClipboard(url);
|
||||
toast.success('Link copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" title="Open" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
/*
|
||||
No externalDomain on the instance. The key is real and the link works — we simply cannot say
|
||||
at which address, and guessing one would produce a URL that fails for whoever it was sent to.
|
||||
*/
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input readOnly value={made.key} className="font-mono text-xs" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy key"
|
||||
onClick={() => {
|
||||
void copyToClipboard(made.key);
|
||||
toast.success('Key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Immich has no public address set, so this is the key rather than a URL. Set{' '}
|
||||
<span className="font-medium">External Domain</span> in Immich’s admin settings and
|
||||
links will appear here in full.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{made ? 'Done' : 'Cancel'}
|
||||
</Button>
|
||||
<Button onClick={() => void submit()} disabled={busy}>
|
||||
{editing ? 'Save' : made ? 'Create another' : 'Create link'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Toggle = ({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
+152
-35
@@ -1,29 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Copy, ImageOff, Link2 } from 'lucide-react';
|
||||
import { Copy, ExternalLink, ImageOff, Link2, MoreVertical, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
import type { SharedLink } from './shared';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, shareUrl, thumbUrl } from './shared';
|
||||
import { ShareLinkDialog, type ShareTarget } from './ShareLinkDialog';
|
||||
import { useAlbums, useServerConfig, useSharedLinks } from './usePhotosData';
|
||||
import { useAssetActions } from './useAssetActions';
|
||||
|
||||
// Sharing: shared albums and shared links, read-mostly.
|
||||
// Sharing: shared albums, and the links that make them public.
|
||||
//
|
||||
// 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.
|
||||
// The link column shows a full URL, not a key. The address is Immich's own `externalDomain` read from
|
||||
// `/server/config` — this plugin still stores no public address of its own, which is what the previous
|
||||
// version of this file was protecting when it copied a bare key instead. The protection was never the
|
||||
// point; the missing address was, and Immich already knows it.
|
||||
|
||||
export const SharingSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: links, isLoading } = useSharedLinks();
|
||||
const { data: albums } = useAlbums();
|
||||
const { data: config } = useServerConfig();
|
||||
const { deleteSharedLink } = useAssetActions();
|
||||
|
||||
const [target, setTarget] = useState<ShareTarget | null>(null);
|
||||
const [editing, setEditing] = useState<SharedLink | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
|
||||
|
||||
const share = (next: ShareTarget) => {
|
||||
setEditing(null);
|
||||
setTarget(next);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const edit = (link: SharedLink) => {
|
||||
setTarget(null);
|
||||
setEditing(link);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex-1" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={(albums ?? []).length === 0}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
Share an album
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{albums?.map((album) => (
|
||||
<DropdownMenuItem
|
||||
key={album.id}
|
||||
onClick={() => share({ kind: 'album', albumId: album.id, albumName: album.albumName })}
|
||||
>
|
||||
{album.albumName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
||||
@@ -66,36 +114,105 @@ export const SharingSection = () => {
|
||||
<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>
|
||||
{!isLoading && (links ?? []).length > 0 && !config?.externalDomain && (
|
||||
/*
|
||||
Worth saying once, at the top, rather than per row: every link here works, and none of them can
|
||||
be written as a URL until Immich knows the address it answers on.
|
||||
*/
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Immich has no <span className="font-medium">External Domain</span> set, so these show as keys
|
||||
rather than URLs. Set it in Immich’s admin settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(links ?? []).length > 0 && (
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{(links ?? []).map((link) => (
|
||||
<LinkRow
|
||||
key={link.id}
|
||||
link={link}
|
||||
externalDomain={config?.externalDomain}
|
||||
onEdit={() => edit(link)}
|
||||
onRevoke={() => deleteSharedLink.mutate(link.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ShareLinkDialog open={open} onOpenChange={setOpen} target={target} link={editing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LinkRow = ({
|
||||
link,
|
||||
externalDomain,
|
||||
onEdit,
|
||||
onRevoke,
|
||||
}: {
|
||||
link: SharedLink;
|
||||
externalDomain: string | undefined;
|
||||
onEdit: () => void;
|
||||
onRevoke: () => void;
|
||||
}) => {
|
||||
const url = shareUrl(externalDomain, link);
|
||||
const expired = !!link.expiresAt && new Date(link.expiresAt).getTime() < Date.now();
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
<Link2 className={`h-4 w-4 shrink-0 ${expired ? 'text-destructive' : '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="truncate text-xs text-muted-foreground">
|
||||
{link.expiresAt
|
||||
? `${expired ? 'Expired' : 'Expires'} ${formatShortDate(link.expiresAt)}`
|
||||
: 'No expiry'}
|
||||
{link.password ? ' · password' : ''}
|
||||
{link.allowDownload ? ' · download' : ''}
|
||||
{link.allowUpload ? ' · upload' : ''}
|
||||
{link.showMetadata ? '' : ' · no metadata'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={url ? 'Copy link' : 'Copy share key — Immich has no public address set'}
|
||||
onClick={() => {
|
||||
void copyToClipboard(url ?? link.key);
|
||||
toast.success(url ? 'Link copied' : 'Share key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{url && (
|
||||
<Button variant="ghost" size="icon" title="Open" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="More">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => window.confirm('Revoke this link? Anyone holding it loses access.') && onRevoke()}
|
||||
>
|
||||
Revoke
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
+68
-2
@@ -128,17 +128,74 @@ export type SearchResponse = {
|
||||
export type ExploreItem = { value: string; data: Asset };
|
||||
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
|
||||
|
||||
export type SharedLinkType = 'ALBUM' | 'INDIVIDUAL';
|
||||
|
||||
/**
|
||||
* `GET /shared-links` — Immich's `SharedLinkResponseDto`.
|
||||
*
|
||||
* `password` really is returned in the clear. That is Immich's choice, not ours, and the edit form uses it:
|
||||
* showing the owner the password they set beats making them reset it blind to find out what it was.
|
||||
*/
|
||||
export type SharedLink = {
|
||||
id: string;
|
||||
key: string;
|
||||
type: string;
|
||||
type: SharedLinkType;
|
||||
album?: Album | null;
|
||||
assets: Asset[];
|
||||
description?: string | null;
|
||||
password: string | null;
|
||||
slug: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
showMetadata: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* `POST /shared-links`. `type` decides which of the next two fields is read: `ALBUM` takes `albumId`,
|
||||
* `INDIVIDUAL` takes `assetIds`.
|
||||
*/
|
||||
export type SharedLinkCreate = {
|
||||
type: SharedLinkType;
|
||||
albumId?: string;
|
||||
assetIds?: string[];
|
||||
description?: string | null;
|
||||
password?: string | null;
|
||||
expiresAt?: string | null;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
};
|
||||
|
||||
/** `PATCH /shared-links/{id}`. Every field optional; omitting one leaves it as it was. */
|
||||
export type SharedLinkEdit = {
|
||||
description?: string | null;
|
||||
password?: string | null;
|
||||
expiresAt?: string | null;
|
||||
allowUpload?: boolean;
|
||||
allowDownload?: boolean;
|
||||
showMetadata?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The URL to hand somebody, or null when Immich does not know its own public address.
|
||||
*
|
||||
* The host is Immich's `externalDomain`, not Officer's. A shared link is opened by someone with no Officer
|
||||
* account, so it has to resolve to the instance itself — and reading it from `/server/config` means Officer
|
||||
* stores no second copy that could disagree with the one Immich's own copy-link button uses.
|
||||
*
|
||||
* Empty is a real state: nobody has set it in Immich's admin settings yet, and the UI says exactly that
|
||||
* instead of inventing a hostname. `/s/<slug>` when a slug is set, `/share/<key>` otherwise — both are real
|
||||
* routes on the instance, verified against 3.1.0.
|
||||
*/
|
||||
export const shareUrl = (
|
||||
externalDomain: string | undefined,
|
||||
link: Pick<SharedLink, 'key' | 'slug'>,
|
||||
): string | null => {
|
||||
const base = externalDomain?.trim().replace(/\/+$/, '');
|
||||
if (!base) return null;
|
||||
return link.slug ? `${base}/s/${link.slug}` : `${base}/share/${link.key}`;
|
||||
};
|
||||
|
||||
export type TimeBucket = { timeBucket: string; count: number };
|
||||
@@ -153,7 +210,16 @@ export type MapMarker = { id: string; lat: number; lon: number; city: string | n
|
||||
* 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 };
|
||||
export type ServerConfig = {
|
||||
mapDarkStyleUrl: string;
|
||||
mapLightStyleUrl: string;
|
||||
version?: string;
|
||||
/**
|
||||
* Immich's own public address, from its admin settings — the one thing standing between a shared link and
|
||||
* a URL somebody can open. See `shareUrl`.
|
||||
*/
|
||||
externalDomain?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `GET /timeline/bucket` is COLUMNAR — parallel arrays, not a list of objects. Immich went this way in 1.133
|
||||
|
||||
+37
-1
@@ -1,4 +1,4 @@
|
||||
import type { Album } from './shared';
|
||||
import type { Album, SharedLink, SharedLinkCreate, SharedLinkEdit } from './shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { toast } from 'sonner';
|
||||
@@ -120,6 +120,39 @@ export function useAssetActions() {
|
||||
onError: fail('rename the album'),
|
||||
});
|
||||
|
||||
// ── Shared links ──
|
||||
//
|
||||
// The only thing in this file that produces a PUBLIC address, which is why the create dialog sends every
|
||||
// flag explicitly instead of letting Immich's defaults apply. Immich defaults `allowDownload` and
|
||||
// `showMetadata` to true and the expiry to never; a form that silently inherits those is a form that
|
||||
// shares more than it appears to.
|
||||
|
||||
const createSharedLink = useMutation({
|
||||
mutationFn: (body: SharedLinkCreate) => post<SharedLink>('/photos/_officer/shared-links', body),
|
||||
onSuccess: invalidate,
|
||||
onError: fail('create the link'),
|
||||
});
|
||||
|
||||
const updateSharedLink = useMutation({
|
||||
mutationFn: ({ id, ...body }: SharedLinkEdit & { id: string }) =>
|
||||
patch<SharedLink>(`/photos/_officer/shared-links/${id}`, body),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Link updated');
|
||||
},
|
||||
onError: fail('update the link'),
|
||||
});
|
||||
|
||||
// Revoke, not delete: the link stops working for everybody holding it, and nothing about the photos changes.
|
||||
const deleteSharedLink = useMutation({
|
||||
mutationFn: (id: string) => del(`/photos/_officer/shared-links/${id}`),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success('Link revoked');
|
||||
},
|
||||
onError: fail('revoke the link'),
|
||||
});
|
||||
|
||||
return {
|
||||
setFavorite,
|
||||
setArchived,
|
||||
@@ -132,5 +165,8 @@ export function useAssetActions() {
|
||||
createAlbum,
|
||||
deleteAlbum,
|
||||
renameAlbum,
|
||||
createSharedLink,
|
||||
updateSharedLink,
|
||||
deleteSharedLink,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user