Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd39db6f76 | ||
|
|
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
|
- **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).
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
|
|||||||
*/
|
*/
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
version: '2026.08.27.01',
|
version: '2026.09.03.02',
|
||||||
platform: '>=1.0.0',
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
|
|||||||
+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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+9
-4
@@ -10,6 +10,11 @@ import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
|
|||||||
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
|
// into a tab, linked to, and closed with the back button. The select checkbox is a sibling <button> layered
|
||||||
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
|
// over it, never nested inside the anchor: a button inside an anchor is invalid HTML and swallows the
|
||||||
// keyboard behaviour of both.
|
// keyboard behaviour of both.
|
||||||
|
//
|
||||||
|
// That sibling relationship is why `group` belongs on the WRAPPER. It sat on the overlay link instead, which
|
||||||
|
// the checkbox is not inside, so `group-hover` could never have reached it — and the checkbox fell back to
|
||||||
|
// its own `hover`, revealing itself only when the pointer was already over the invisible 20px circle. The
|
||||||
|
// affordance existed and was unfindable.
|
||||||
|
|
||||||
type AssetTileProps = {
|
type AssetTileProps = {
|
||||||
asset: GridAsset;
|
asset: GridAsset;
|
||||||
@@ -69,19 +74,19 @@ export const AssetTile = ({ asset, token, width, height, selected, selecting, on
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
|
<div className="group relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
|
||||||
{selecting ? (
|
{selecting ? (
|
||||||
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
|
// Once a selection exists, a plain click extends it rather than navigating — matching Immich, where
|
||||||
// you can rubber-band through a month without the lightbox opening on every tile.
|
// you can rubber-band through a month without the lightbox opening on every tile.
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(ev) => onToggle(asset.id, ev)}
|
onClick={(ev) => onToggle(asset.id, ev)}
|
||||||
className="group absolute inset-0 block h-full w-full cursor-pointer"
|
className="absolute inset-0 block h-full w-full cursor-pointer"
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link to={href} className="group absolute inset-0 block h-full w-full">
|
<Link to={href} className="absolute inset-0 block h-full w-full">
|
||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -91,7 +96,7 @@ export const AssetTile = ({ asset, token, width, height, selected, selecting, on
|
|||||||
onClick={(ev) => onToggle(asset.id, ev)}
|
onClick={(ev) => onToggle(asset.id, ev)}
|
||||||
title={selected ? 'Deselect' : 'Select'}
|
title={selected ? 'Deselect' : 'Select'}
|
||||||
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
|
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
|
||||||
selected ? 'opacity-100' : 'opacity-0 hover:opacity-100 focus-visible:opacity-100'
|
selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
|
||||||
} ${selecting ? 'opacity-100' : ''}`}
|
} ${selecting ? 'opacity-100' : ''}`}
|
||||||
>
|
>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
|
|||||||
+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>
|
||||||
|
);
|
||||||
+152
-35
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="divide-y rounded-lg border">
|
{!isLoading && (links ?? []).length > 0 && !config?.externalDomain && (
|
||||||
{(links ?? []).map((link) => (
|
/*
|
||||||
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
Worth saying once, at the top, rather than per row: every link here works, and none of them can
|
||||||
<Link2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
be written as a URL until Immich knows the address it answers on.
|
||||||
<div className="min-w-0 flex-1">
|
*/
|
||||||
<p className="truncate text-sm">
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
{link.album?.albumName ?? link.description ?? `${link.assets.length} items`}
|
Immich has no <span className="font-medium">External Domain</span> set, so these show as keys
|
||||||
</p>
|
rather than URLs. Set it in Immich’s admin settings.
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
)}
|
||||||
{link.allowDownload ? ' · download' : ''}
|
|
||||||
{link.allowUpload ? ' · upload' : ''}
|
{(links ?? []).length > 0 && (
|
||||||
</p>
|
<ul className="divide-y rounded-lg border">
|
||||||
</div>
|
{(links ?? []).map((link) => (
|
||||||
<Button
|
<LinkRow
|
||||||
variant="ghost"
|
key={link.id}
|
||||||
size="icon"
|
link={link}
|
||||||
title="Copy share key — append it to your instance's /share/ URL"
|
externalDomain={config?.externalDomain}
|
||||||
onClick={() => {
|
onEdit={() => edit(link)}
|
||||||
void copyToClipboard(link.key);
|
onRevoke={() => deleteSharedLink.mutate(link.id)}
|
||||||
toast.success('Share key copied');
|
/>
|
||||||
}}
|
))}
|
||||||
>
|
</ul>
|
||||||
<Copy className="h-4 w-4" />
|
)}
|
||||||
</Button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShareLinkDialog open={open} onOpenChange={setOpen} target={target} link={editing} />
|
||||||
</div>
|
</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 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