a shared link is a URL, not a key
Create, edit and revoke shared links from the album header, the selection bar and the Sharing panel. Password, expiry, and the three flags that decide what the person opening it can do. The panel showed a bare key because this plugin never learned the instance's public address. It does not need to store one: Immich has `externalDomain` in its own admin settings, `/server/config` already returns it, and it is the same field Immich's own copy-link button reads, so the two cannot disagree. Empty is a real state and the UI says so rather than inventing a host. Payload shapes came from the live instance's spec.json, not from memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- **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.
|
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
|
## What you need
|
||||||
|
|
||||||
**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys).
|
**An Immich you already run**, and an API key from it (Immich → Account Settings → API Keys).
|
||||||
|
|||||||
+15
-2
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -12,6 +12,7 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { AssetViewer } from './AssetViewer';
|
import { AssetViewer } from './AssetViewer';
|
||||||
import { PhotoGrid } from './PhotoGrid';
|
import { PhotoGrid } from './PhotoGrid';
|
||||||
import { SelectionBar } from './SelectionBar';
|
import { SelectionBar } from './SelectionBar';
|
||||||
|
import { ShareLinkDialog } from './ShareLinkDialog';
|
||||||
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
import { ALBUM_PARAM, formatDateRange, thumbUrl, toGridAsset } from './shared';
|
||||||
import { useAlbum, useAlbums } from './usePhotosData';
|
import { useAlbum, useAlbums } from './usePhotosData';
|
||||||
import { useAssetActions } from './useAssetActions';
|
import { useAssetActions } from './useAssetActions';
|
||||||
@@ -123,6 +124,7 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
|||||||
|
|
||||||
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
const assets = useMemo(() => (album?.assets ?? []).map(toGridAsset), [album]);
|
||||||
const selection = useSelection(assets.map((asset) => asset.id));
|
const selection = useSelection(assets.map((asset) => asset.id));
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
|
||||||
const back = (() => {
|
const back = (() => {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
@@ -150,6 +152,11 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
|||||||
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
{album?.assetCount ?? 0} items{range ? ` · ${range}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -167,6 +174,12 @@ const AlbumDetailView = ({ albumId }: { albumId: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetViewer ids={assets.map((asset) => asset.id)} token={token} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+20
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { Selection } from './useSelection';
|
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 { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { ShareLinkDialog } from './ShareLinkDialog';
|
||||||
import { useAlbums } from './usePhotosData';
|
import { useAlbums } from './usePhotosData';
|
||||||
import { useAssetActions } from './useAssetActions';
|
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 } =
|
const { setFavorite, setArchived, trash, restore, deleteForever, addToAlbum, removeFromAlbum, createAlbum } =
|
||||||
useAssetActions();
|
useAssetActions();
|
||||||
const { data: albums } = useAlbums();
|
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) => {
|
const run = (action: () => void) => {
|
||||||
action();
|
action();
|
||||||
@@ -95,6 +103,11 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSharing(ids)}>
|
||||||
|
<Share2 className="mr-1.5 h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -116,6 +129,12 @@ export const SelectionBar = ({ selection, inTrash = false, albumId, archived = f
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ShareLinkDialog
|
||||||
|
open={!!sharing}
|
||||||
|
onOpenChange={(next) => !next && setSharing(null)}
|
||||||
|
target={sharing ? { kind: 'assets', assetIds: sharing } : null}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
+136
-19
@@ -1,29 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
|
||||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
|
||||||
import { copyToClipboard } from 'helpers/clipboard';
|
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
|
// The link column shows a full URL, not a key. The address is Immich's own `externalDomain` read from
|
||||||
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
|
// `/server/config` — this plugin still stores no public address of its own, which is what the previous
|
||||||
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
|
// 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 = () => {
|
export const SharingSection = () => {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
const { data: links, isLoading } = useSharedLinks();
|
const { data: links, isLoading } = useSharedLinks();
|
||||||
const { data: albums } = useAlbums();
|
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 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 (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="flex items-center gap-2 border-b px-3 py-2">
|
<header className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
<h1 className="text-sm font-semibold">Sharing</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-3">
|
<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>
|
<p className="text-sm text-muted-foreground">No shared links.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!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">
|
<ul className="divide-y rounded-lg border">
|
||||||
{(links ?? []).map((link) => (
|
{(links ?? []).map((link) => (
|
||||||
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
<LinkRow
|
||||||
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm">
|
<p className="truncate text-sm">
|
||||||
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
{link.expiresAt
|
||||||
|
? `${expired ? 'Expired' : 'Expires'} ${formatShortDate(link.expiresAt)}`
|
||||||
|
: 'No expiry'}
|
||||||
|
{link.password ? ' · password' : ''}
|
||||||
{link.allowDownload ? ' · download' : ''}
|
{link.allowDownload ? ' · download' : ''}
|
||||||
{link.allowUpload ? ' · upload' : ''}
|
{link.allowUpload ? ' · upload' : ''}
|
||||||
|
{link.showMetadata ? '' : ' · no metadata'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Copy share key — append it to your instance's /share/ URL"
|
title={url ? 'Copy link' : 'Copy share key — Immich has no public address set'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void copyToClipboard(link.key);
|
void copyToClipboard(url ?? link.key);
|
||||||
toast.success('Share key copied');
|
toast.success(url ? 'Link copied' : 'Share key copied');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</li>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+68
-2
@@ -128,17 +128,74 @@ export type SearchResponse = {
|
|||||||
export type ExploreItem = { value: string; data: Asset };
|
export type ExploreItem = { value: string; data: Asset };
|
||||||
export type ExploreSection = { fieldName: string; items: ExploreItem[] };
|
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 = {
|
export type SharedLink = {
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
type: string;
|
type: SharedLinkType;
|
||||||
album?: Album | null;
|
album?: Album | null;
|
||||||
assets: Asset[];
|
assets: Asset[];
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
password: string | null;
|
||||||
|
slug: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
allowDownload: boolean;
|
allowDownload: boolean;
|
||||||
allowUpload: 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 };
|
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
|
* 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.
|
* 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
|
* `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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -120,6 +120,39 @@ export function useAssetActions() {
|
|||||||
onError: fail('rename the album'),
|
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 {
|
return {
|
||||||
setFavorite,
|
setFavorite,
|
||||||
setArchived,
|
setArchived,
|
||||||
@@ -132,5 +165,8 @@ export function useAssetActions() {
|
|||||||
createAlbum,
|
createAlbum,
|
||||||
deleteAlbum,
|
deleteAlbum,
|
||||||
renameAlbum,
|
renameAlbum,
|
||||||
|
createSharedLink,
|
||||||
|
updateSharedLink,
|
||||||
|
deleteSharedLink,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user