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('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(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 ( {editing ? 'Edit shared link' : 'Share'} {what}
setDescription(e.target.value)} placeholder="Optional — only you see this" />
setPassword(e.target.value)} placeholder="Optional — anyone with the link must type this" />
{editing && link?.expiresAt && expiry === '0' && (

Currently expires {new Date(link.expiresAt).toLocaleString()}. Leave as Never to keep that date.

)}
{made && (
{url ? (
e.target.select()} />
) : ( /* 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. */ <>

Immich has no public address set, so this is the key rather than a URL. Set{' '} External Domain in Immich’s admin settings and links will appear here in full.

)}
)}
); }; const Toggle = ({ id, label, hint, checked, onChange, }: { id: string; label: string; hint: string; checked: boolean; onChange: (value: boolean) => void; }) => (

{hint}

);