Files
photos/web/ShareLinkDialog.tsx
T
pastilhasandClaude Opus 5 70fc2b08c6 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>
2026-09-02 18:16:02 +01:00

294 lines
11 KiB
TypeScript

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&rsquo;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>
);