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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user