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,145 @@
|
||||
import type { GridAsset, TimeBucket } from './shared';
|
||||
import type { Selection } from './useSelection';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { AssetTile } from './AssetTile';
|
||||
import { estimateBucketHeight, formatBucket, justifyRows } from './shared';
|
||||
import { useContainerWidth } from './useContainerWidth';
|
||||
|
||||
// The month-bucketed timeline.
|
||||
//
|
||||
// Every month in the library is rendered from the moment the screen opens, but only as a correctly-sized empty
|
||||
// box: the bucket index gives a count, and `estimateBucketHeight` turns a count into a height. That is what
|
||||
// makes the scrollbar honest before anything is downloaded — the page is already its true length, so dragging
|
||||
// to the bottom lands on the oldest month instead of on a spinner that then pushes the content away.
|
||||
//
|
||||
// An IntersectionObserver with a tall rootMargin turns a box into a real request just before it reaches the
|
||||
// viewport. Loaded months are never unloaded; React Query holds them and scrolling back up is instant.
|
||||
|
||||
const ROW_HEIGHT = 210;
|
||||
const GAP = 4;
|
||||
|
||||
type TimelineGridProps = {
|
||||
buckets: TimeBucket[];
|
||||
assetsByBucket: Record<string, GridAsset[]>;
|
||||
onBucketVisible: (bucket: string) => void;
|
||||
token: string | null;
|
||||
selection: Selection;
|
||||
/** The scroll container the observer should watch. Panels scroll their own body, not the window. */
|
||||
scrollRef: React.RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
export const TimelineGrid = ({
|
||||
buckets,
|
||||
assetsByBucket,
|
||||
onBucketVisible,
|
||||
token,
|
||||
selection,
|
||||
scrollRef,
|
||||
}: TimelineGridProps) => {
|
||||
const [ref, width] = useContainerWidth<HTMLDivElement>();
|
||||
const nodes = useRef(new Map<string, HTMLElement>());
|
||||
const notify = useRef(onBucketVisible);
|
||||
notify.current = onBucketVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (!width) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const bucket = entry.target.getAttribute('data-bucket');
|
||||
if (entry.isIntersecting && bucket) notify.current(bucket);
|
||||
}
|
||||
},
|
||||
// Two viewports of lead time in each direction: enough that a fast scroll rarely outruns the fetch,
|
||||
// small enough that opening the screen doesn't request the entire library.
|
||||
{ root: scrollRef.current ?? null, rootMargin: '200% 0px' },
|
||||
);
|
||||
|
||||
for (const node of nodes.current.values()) observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [width, buckets, scrollRef]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{buckets.map((bucket) => (
|
||||
<BucketSection
|
||||
key={bucket.timeBucket}
|
||||
bucket={bucket}
|
||||
assets={assetsByBucket[bucket.timeBucket]}
|
||||
width={width}
|
||||
token={token}
|
||||
selection={selection}
|
||||
register={(node) => {
|
||||
if (node) nodes.current.set(bucket.timeBucket, node);
|
||||
else nodes.current.delete(bucket.timeBucket);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BucketSectionProps = {
|
||||
bucket: TimeBucket;
|
||||
assets: GridAsset[] | undefined;
|
||||
width: number;
|
||||
token: string | null;
|
||||
selection: Selection;
|
||||
register: (node: HTMLElement | null) => void;
|
||||
};
|
||||
|
||||
const BucketSection = ({ bucket, assets, width, token, selection, register }: BucketSectionProps) => {
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
assets && width > 0
|
||||
? justifyRows({
|
||||
items: assets,
|
||||
ratioOf: (a) => a.ratio,
|
||||
containerWidth: width,
|
||||
targetHeight: ROW_HEIGHT,
|
||||
gap: GAP,
|
||||
})
|
||||
: [],
|
||||
[assets, width],
|
||||
);
|
||||
|
||||
// Until the month arrives, hold its place with the height it will occupy. The estimate uses the same row
|
||||
// height and gap the real layout will, so the jump when it loads is a few pixels rather than a page.
|
||||
const placeholder =
|
||||
!assets && width > 0
|
||||
? estimateBucketHeight({ count: bucket.count, containerWidth: width, rowHeight: ROW_HEIGHT, gap: GAP })
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<section ref={register} data-bucket={bucket.timeBucket} className="mb-6">
|
||||
<h2 className="sticky top-0 z-10 -mx-1 mb-2 bg-background/85 px-1 py-1.5 text-sm font-semibold backdrop-blur">
|
||||
{formatBucket(bucket.timeBucket)}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">{bucket.count}</span>
|
||||
</h2>
|
||||
|
||||
{assets ? (
|
||||
<div className="flex flex-col" style={{ gap: GAP }}>
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="flex" style={{ gap: GAP }}>
|
||||
{row.tiles.map(({ item, width: w, height: h }) => (
|
||||
<AssetTile
|
||||
key={item.id}
|
||||
asset={item}
|
||||
token={token}
|
||||
width={w}
|
||||
height={h}
|
||||
selected={selection.selected.has(item.id)}
|
||||
selecting={selection.isSelecting}
|
||||
onToggle={(id, ev) => selection.toggle(id, ev)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: placeholder }} className="w-full rounded-sm bg-white/[0.03]" />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user