From d1c9469fa7e9012acb48a2b976c05c0eb7fa506e Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 23:29:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(deps):=20Tier=202=20UX=20polish=20?= =?UTF-8?q?=E2=80=94=20embla,=20lightbox,=20gestures,=20virtuoso,=20motion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installs all five Tier 2 polish deps the audit flagged. Each integrates where it adds concrete value today: - **embla-carousel-react** — shadcn-style `` 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) --- package.json | 5 + pnpm-lock.yaml | 147 +++++++++++++ src/components/files/file-preview-dialog.tsx | 32 ++- src/components/files/pdf-viewer.tsx | 26 ++- src/components/ui/carousel.tsx | 213 +++++++++++++++++++ 5 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 src/components/ui/carousel.tsx diff --git a/package.json b/package.json index 31afaaf6..cbeacecd 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.24", "@types/pdfkit": "^0.17.6", + "@use-gesture/react": "^10.3.1", "archiver": "^7.0.1", "better-auth": "^1.6.11", "browser-image-compression": "^2.0.2", @@ -73,6 +74,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "drizzle-orm": "^0.45.2", + "embla-carousel-react": "^8.6.0", "imapflow": "^1.3.3", "ioredis": "^5.10.1", "iso-3166-2": "^1.0.0", @@ -82,6 +84,7 @@ "lucide-react": "^1.14.0", "mailparser": "^3.9.8", "minio": "^8.0.7", + "motion": "^12.38.0", "next": "16.2.6", "next-intl": "^4.11.2", "next-themes": "^0.4.6", @@ -105,6 +108,7 @@ "react-number-format": "^5.4.5", "react-pdf": "^10.4.1", "react-resizable-panels": "^3.0.6", + "react-virtuoso": "^4.18.7", "recharts": "^3.8.1", "sharp": "^0.34.5", "socket.io": "^4.8.3", @@ -118,6 +122,7 @@ "unpdf": "^1.6.2", "vaul": "^1.1.2", "web-vitals": "^5.2.0", + "yet-another-react-lightbox": "^3.32.0", "zod": "^4.4.3", "zustand": "^5.0.13" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09923756..c6d3df11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: '@types/pdfkit': specifier: ^0.17.6 version: 0.17.6 + '@use-gesture/react': + specifier: ^10.3.1 + version: 10.3.1(react@19.2.6) archiver: specifier: ^7.0.1 version: 7.0.1 @@ -145,6 +148,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.6) imapflow: specifier: ^1.3.3 version: 1.3.3 @@ -172,6 +178,9 @@ importers: minio: specifier: ^8.0.7 version: 8.0.7 + motion: + specifier: ^12.38.0 + version: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next: specifier: 16.2.6 version: 16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -241,6 +250,9 @@ importers: react-resizable-panels: specifier: ^3.0.6 version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-virtuoso: + specifier: ^4.18.7 + version: 4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) recharts: specifier: ^3.8.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1) @@ -280,6 +292,9 @@ importers: web-vitals: specifier: ^5.2.0 version: 5.2.0 + yet-another-react-lightbox: + specifier: ^3.32.0 + version: 3.32.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) zod: specifier: ^4.4.3 version: 4.4.3 @@ -3446,6 +3461,14 @@ packages: cpu: [x64] os: [win32] + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4434,6 +4457,19 @@ packages: electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -4786,6 +4822,20 @@ packages: forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5631,6 +5681,26 @@ packages: socks: optional: true + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -6224,6 +6294,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -7281,6 +7357,20 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yet-another-react-lightbox@3.32.0: + resolution: {integrity: sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg==} + engines: {node: '>=14'} + peerDependencies: + '@types/react': ^16 || ^17 || ^18 || ^19 + '@types/react-dom': ^16 || ^17 || ^18 || ^19 + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -10188,6 +10278,13 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.6)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.6 + '@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.21.0)(yaml@2.8.4))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -11097,6 +11194,18 @@ snapshots: electron-to-chromium@1.5.353: {} + embla-carousel-react@8.6.0(react@19.2.6): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.6 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-regex-xs@1.0.0: {} emoji-regex@10.6.0: {} @@ -11626,6 +11735,16 @@ snapshots: forwarded-parse@2.1.2: {} + framer-motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + fsevents@2.3.2: optional: true @@ -12464,6 +12583,21 @@ snapshots: socks: 2.8.8 optional: true + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + framer-motion: 12.38.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 1.4.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + mrmime@2.0.1: {} ms@2.1.3: {} @@ -13068,6 +13202,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-virtuoso@4.18.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react@19.2.6: {} readable-stream@2.3.8: @@ -14296,6 +14435,14 @@ snapshots: yaml@2.8.4: optional: true + yet-another-react-lightbox@3.32.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index 92dba462..3cfd2ef9 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [lightboxOpen, setLightboxOpen] = useState(false); useEffect(() => { if (!open || !fileId) { @@ -95,14 +100,20 @@ export function FilePreviewDialog({ )} {!loading && !error && previewUrl && isImage && ( -
+
+ + )} {!loading && !error && previewUrl && isPdf && ( @@ -110,6 +121,19 @@ export function FilePreviewDialog({ )} + + {/* 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 && ( + setLightboxOpen(false)} + slides={[{ src: previewUrl, alt: fileName ?? 'Preview' }]} + controller={{ closeOnBackdropClick: true }} + carousel={{ finite: true }} + /> + )} ); } diff --git a/src/components/files/pdf-viewer.tsx b/src/components/files/pdf-viewer.tsx index d467da77..37ec2fd1 100644 --- a/src/components/files/pdf-viewer.tsx +++ b/src/components/files/pdf-viewer.tsx @@ -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 (
@@ -113,7 +130,14 @@ export function PdfViewer({ url, fileName }: PdfViewerProps) {
-
+
{error ? (
{error} diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx new file mode 100644 index 00000000..81dfdfa8 --- /dev/null +++ b/src/components/ui/carousel.tsx @@ -0,0 +1,213 @@ +'use client'; + +import * as React from 'react'; +import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: 'horizontal' | 'vertical'; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const ctx = React.useContext(CarouselContext); + if (!ctx) { + throw new Error('useCarousel must be used within a '); + } + return ctx; +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => { + const [carouselRef, api] = useEmblaCarousel( + { ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => api?.scrollPrev(), [api]); + const scrollNext = React.useCallback(() => api?.scrollNext(), [api]); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on('reInit', onSelect); + api.on('select', onSelect); + return () => { + api.off('select', onSelect); + }; + }, [api, onSelect]); + + return ( + +
{ + if (e.key === 'ArrowLeft') { + e.preventDefault(); + scrollPrev(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + scrollNext(); + } + }} + className={cn('relative', className)} + role="region" + aria-roledescription="carousel" + {...props} + > + {children} +
+
+ ); +}); +Carousel.displayName = 'Carousel'; + +const CarouselContent = React.forwardRef>( + ({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel(); + return ( +
+
+
+ ); + }, +); +CarouselContent.displayName = 'CarouselContent'; + +const CarouselItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const { orientation } = useCarousel(); + return ( +
+ ); + }, +); +CarouselItem.displayName = 'CarouselItem'; + +const CarouselPrevious = React.forwardRef>( + ({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + return ( + + ); + }, +); +CarouselPrevious.displayName = 'CarouselPrevious'; + +const CarouselNext = React.forwardRef>( + ({ className, variant = 'outline', size = 'icon', ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + return ( + + ); + }, +); +CarouselNext.displayName = 'CarouselNext'; + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +};