Files
photos/web/AssetTile.tsx
pastilhasandClaude Opus 5 fd39db6f76 the select circle was unreachable by hover
`group` sat on the overlay <Link>. The checkbox is that link's SIBLING, not
its child, so `group-hover` could never have matched — and nothing in the
file used a `group-*` utility at all, so the class was inert.

The checkbox therefore fell back to its own `hover:opacity-100`, which
fires only once the pointer is already over an invisible 20px circle. The
affordance was there and unfindable, which reads as "there is no way to
select a photo".

`group` moves to the wrapper, the checkbox reads `group-hover`, and the
two dead `group` classes on the overlays are dropped.

Touch is still unserved: no hover means no way to begin a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:42:39 +01:00

113 lines
4.3 KiB
TypeScript

import type { GridAsset } from './shared';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { CheckCircle2, Circle, Heart, Layers, Play } from 'lucide-react';
import { ASSET_PARAM, formatDuration, thumbUrl } from './shared';
// One tile in the justified grid.
//
// The tile is a real <Link> to `?asset=<id>` — the lightbox is addressable, so a photo can be cmd-clicked
// 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
// 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 = {
asset: GridAsset;
token: string | null;
width: number;
height: number;
selected: boolean;
/** True once anything is selected — the checkbox stops being hover-only and the click selects, not opens. */
selecting: boolean;
onToggle: (id: string, ev: React.MouseEvent) => void;
};
export const AssetTile = ({ asset, token, width, height, selected, selecting, onToggle }: AssetTileProps) => {
const [params] = useSearchParams();
const [loaded, setLoaded] = useState(false);
const duration = formatDuration(asset.duration);
const href = (() => {
const next = new URLSearchParams(params);
next.set(ASSET_PARAM, asset.id);
return `?${next.toString()}`;
})();
const badges = (
<>
{asset.isFavorite && <Heart className="absolute bottom-1.5 left-1.5 h-4 w-4 fill-white text-white drop-shadow" />}
{asset.stackCount > 1 && (
<span className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded bg-black/60 px-1 text-[10px] font-medium text-white">
<Layers className="h-3 w-3" />
{asset.stackCount}
</span>
)}
{!asset.isImage && (
<span className="absolute bottom-1.5 right-1.5 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
<Play className="h-2.5 w-2.5 fill-white" />
{duration ?? ''}
</span>
)}
</>
);
const inner = (
<>
{/* A neutral block until the thumbnail decodes, so the row doesn't flash white on a dark page. */}
<div className={`absolute inset-0 bg-white/5 transition-opacity ${loaded ? 'opacity-0' : 'opacity-100'}`} />
<img
src={thumbUrl(asset.id, token)}
alt=""
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
className={`h-full w-full object-cover transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
/>
{badges}
</>
);
return (
<div className="group relative shrink-0 overflow-hidden rounded-sm" style={{ width, height }}>
{selecting ? (
// 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.
<button
type="button"
onClick={(ev) => onToggle(asset.id, ev)}
className="absolute inset-0 block h-full w-full cursor-pointer"
>
{inner}
</button>
) : (
<Link to={href} className="absolute inset-0 block h-full w-full">
{inner}
</Link>
)}
<button
type="button"
onClick={(ev) => onToggle(asset.id, ev)}
title={selected ? 'Deselect' : 'Select'}
className={`absolute left-1.5 top-1.5 rounded-full text-white transition-opacity ${
selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
} ${selecting ? 'opacity-100' : ''}`}
>
{selected ? (
<CheckCircle2 className="h-5 w-5 fill-primary text-white" />
) : (
<Circle className="h-5 w-5 drop-shadow" />
)}
</button>
{selected && <span className="pointer-events-none absolute inset-0 rounded-sm ring-2 ring-inset ring-primary" />}
</div>
);
};