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>
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router';
|
|
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 { 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 the links that make them public.
|
|
//
|
|
// 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">
|
|
<section>
|
|
<h2 className="mb-2 text-sm font-semibold">Shared albums</h2>
|
|
{sharedAlbums.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No shared albums.</p>
|
|
) : (
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
|
{sharedAlbums.map((album) => (
|
|
<Link key={album.id} to={`${photosSectionPath('albums')}?${ALBUM_PARAM}=${album.id}`}>
|
|
<div className="aspect-square overflow-hidden rounded-lg bg-white/5">
|
|
{album.albumThumbnailAssetId ? (
|
|
<img
|
|
src={thumbUrl(album.albumThumbnailAssetId, token)}
|
|
alt=""
|
|
loading="lazy"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<ImageOff className="h-8 w-8" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="mt-2 truncate text-sm font-medium">{album.albumName}</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{album.assetCount} items · {album.albumUsers.length} people
|
|
</p>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="mb-2 text-sm font-semibold">Shared links</h2>
|
|
{isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
|
|
{!isLoading && (links ?? []).length === 0 && (
|
|
<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">
|
|
{(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>
|
|
);
|
|
};
|