feat(deps): Tier 2 UX polish — embla, lightbox, gestures, virtuoso, motion

Installs all five Tier 2 polish deps the audit flagged. Each integrates
where it adds concrete value today:

- **embla-carousel-react** — shadcn-style `<Carousel>` primitive in
  `src/components/ui/carousel.tsx`. Available for future berth/yacht
  photo galleries; no current call site beyond the primitive.
- **yet-another-react-lightbox** — wired into the image branch of
  `file-preview-dialog.tsx`. Clicking the preview image now opens a
  fullscreen lightbox with zoom/pan/keyboard nav. Lazy-loaded so the
  ~50kb only ships when a user actually previews an image.
- **@use-gesture/react** — `usePinch` on the PdfViewer's content
  pane for native pinch-zoom on tablets/phones. Clamped to the
  same [50%, 300%] range as the +/- buttons; desktop wheel still
  scrolls.
- **react-virtuoso** — installed but NOT wired. Inbox is naturally
  bounded by recent-notifications filter at ~10-20 items; ScrollArea
  handles it fine. Reserve for actual scale issues (admin audit log
  archive, etc.).
- **motion** — installed but NOT wired. Pipeline kanban uses
  dnd-kit's own transforms and conflicts with motion's layout
  animation. @formkit/auto-animate already handles list-mutation
  animations elsewhere. Available for opportunistic adoption when
  a polish surface emerges that the existing libraries don't cover.

Verified: tsc clean, vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:29:22 +02:00
parent 4329db7fc3
commit d1c9469fa7
5 changed files with 418 additions and 5 deletions

View File

@@ -2,11 +2,15 @@
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, ZoomIn } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
// yet-another-react-lightbox is ~50kb, lazy-load it.
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
import 'yet-another-react-lightbox/styles.css';
// pdfjs-dist is ~150kb gzip — lazy-load so routes that never preview
// PDFs don't ship it. ssr:false because the worker setup needs window.
const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), {
@@ -36,6 +40,7 @@ export function FilePreviewDialog({
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
if (!open || !fileId) {
@@ -95,14 +100,20 @@ export function FilePreviewDialog({
)}
{!loading && !error && previewUrl && isImage && (
<div className="flex h-full items-center justify-center p-4">
<button
type="button"
onClick={() => setLightboxOpen(true)}
className="flex h-full w-full items-center justify-center p-4 group"
aria-label="Open in lightbox"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={fileName ?? 'Preview'}
className="max-h-full max-w-full object-contain rounded"
className="max-h-full max-w-full object-contain rounded transition-transform group-hover:scale-[1.02]"
/>
</div>
<ZoomIn className="absolute right-6 bottom-6 h-6 w-6 rounded-full bg-background/80 p-1 text-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
{!loading && !error && previewUrl && isPdf && (
@@ -110,6 +121,19 @@ export function FilePreviewDialog({
)}
</div>
</DialogContent>
{/* Lightbox renders OUTSIDE the parent Dialog so the dialog's own
* bounds don't clip the fullscreen overlay. yet-another-react-
* lightbox handles zoom/pan/keyboard nav out of the box. */}
{previewUrl && isImage && (
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
slides={[{ src: previewUrl, alt: fileName ?? 'Preview' }]}
controller={{ closeOnBackdropClick: true }}
carousel={{ finite: true }}
/>
)}
</Dialog>
);
}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { usePinch } from '@use-gesture/react';
import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -61,6 +62,22 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
setError(null);
}, [url]);
// Pinch-zoom on touch devices. usePinch's `offset` already maps
// gesture distance to a smoothly-changing scalar; we clamp it to
// the same [0.5, 3] range as the +/- buttons.
const pinchBindings = usePinch(
({ offset: [s] }) => {
setScale(Math.max(0.5, Math.min(3, s)));
},
{
scaleBounds: { min: 0.5, max: 3 },
from: () => [scale, 0],
// Don't hijack wheel events — desktop users zoom via buttons,
// wheel still scrolls the page.
eventOptions: { passive: false },
},
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2 text-sm">
@@ -113,7 +130,14 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
</div>
</div>
<div className="flex-1 overflow-auto bg-muted/30 p-4">
<div
className="flex-1 overflow-auto bg-muted/30 p-4 touch-pan-y"
// Pinch-zoom on touch devices (tablets, phones). On desktop the
// +/- buttons stay primary; the gesture handler ignores wheel
// events so it doesn't fight scroll-zoom. Range matches the
// button zoom (50%300%).
{...pinchBindings()}
>
{error ? (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}