photos, extracted from the platform into its own repository
The fifth plugin out, and the cheapest so far — offscale's shape rather than
music's or vault's. Nothing stayed behind and nothing had to be redesigned,
for three structural reasons recorded in PLUGIN.md:
• No reverse dependency. Nothing in src/ imported anything of photos'.
• Configuration was already right. The URL and API key live in photos_config,
a per-user registry encrypted at rest and driven from the app. Vault had to
move VAULTWARDEN_URL out of .env and invent a settings panel; here the
sidecar already read the database, and its own comment already said why
nothing reads process.env.
• The frontend was already panels. The screen was a WorkspaceView around
exactly photos-nav and photos-view, so web/panels.ts and web/layout.ts are
the screen's contents with the wrapper removed.
The router's prefix is now derived from mountPrefix rather than the literal
'/api/photos', which was correct only by coincidence — a third-party publisher
mounts at /api/p/<publisher>/photos and the literal would forward the wrong
path to the sidecar.
Palette debt paid down as the runbook requires: 6 classes (it says 47; someone
had already done most of it), all of them state colours wearing palette
numbers, now `success` and `warning` from the closed tone set so they follow
the theme.
Immich itself is still provisioned by the app store rather than by this plugin
— the `provisioned` half of its old catalogue entry has no home until Docker
provisioning for plugins exists. Point it at an instance you already run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { Link } from 'react-router';
|
||||
import { Copy, ImageOff, Link2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { ALBUM_PARAM, formatShortDate, photosSectionPath, thumbUrl } from './shared';
|
||||
import { useAlbums, useSharedLinks } from './usePhotosData';
|
||||
import { copyToClipboard } from 'helpers/clipboard';
|
||||
|
||||
// Sharing: shared albums and shared links, read-mostly.
|
||||
//
|
||||
// The link URL is Immich's own public URL, not an Officer route — a shared link is meant to be opened by
|
||||
// someone who has no Officer account, so it must point at the Immich instance directly. The proxy deliberately
|
||||
// never learns that public URL, so this copies the key and the owner pairs it with their own instance address.
|
||||
|
||||
export const SharingSection = () => {
|
||||
const { token } = useClient();
|
||||
const { data: links, isLoading } = useSharedLinks();
|
||||
const { data: albums } = useAlbums();
|
||||
|
||||
const sharedAlbums = (albums ?? []).filter((album) => album.shared || album.hasSharedLink);
|
||||
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<ul className="divide-y rounded-lg border">
|
||||
{(links ?? []).map((link) => (
|
||||
<li key={link.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<Link2 className="h-4 w-4 shrink-0 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="text-xs text-muted-foreground">
|
||||
{link.expiresAt ? `Expires ${formatShortDate(link.expiresAt)}` : 'No expiry'}
|
||||
{link.allowDownload ? ' · download' : ''}
|
||||
{link.allowUpload ? ' · upload' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Copy share key — append it to your instance's /share/ URL"
|
||||
onClick={() => {
|
||||
void copyToClipboard(link.key);
|
||||
toast.success('Share key copied');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user