diff --git a/package-lock.json b/package-lock.json index 55b1e12..7467f95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "license": "ISC", "dependencies": { "@auth/prisma-adapter": "^2.11.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^3.5.0", @@ -28,7 +31,7 @@ "libphonenumber-js": "^1.13.15", "lucide-react": "^1.59.0", "luxon": "^3.7.2", - "mariadb": "^3.5.3", + "mariadb": "^3.5.4", "next": "^16.4.2", "next-auth": "^5.0.0-beta.32", "nodemailer": "^8.0.11", @@ -368,6 +371,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", diff --git a/package.json b/package.json index 66764ad..2f582b0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "license": "ISC", "dependencies": { "@auth/prisma-adapter": "^2.11.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fortawesome/fontawesome-svg-core": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/react-fontawesome": "^3.5.0", diff --git a/src/components/ui/image-gallery.tsx b/src/components/ui/image-gallery.tsx index 7764c36..72b2dca 100644 --- a/src/components/ui/image-gallery.tsx +++ b/src/components/ui/image-gallery.tsx @@ -27,7 +27,8 @@ export function ImageGallery({ images, className }: ImageGalleryProps) { return (
-
+ {/* A little taller on phones (3:2) so the cover fills more of the screen; 16:9 from tablet width up. */} +
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} {active.caption {images.length > 1 && ( diff --git a/src/components/ui/lightbox.tsx b/src/components/ui/lightbox.tsx new file mode 100644 index 0000000..0749340 --- /dev/null +++ b/src/components/ui/lightbox.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; + +export interface LightboxImage { + id: string; + url: string; + caption?: string | null; +} + +export interface LightboxProps { + images: LightboxImage[]; + /** The image shown first / currently shown. */ + index: number; + onIndexChange: (index: number) => void; + onClose: () => void; +} + +const SWIPE_DISTANCE_PX = 50; + +/** + * A full-screen image viewer: previous/next buttons, swipe on a phone, + * arrow keys and Escape on a keyboard, a counter, and the caption when there + * is one. Rendered in a portal so no sticky or transformed ancestor on the page + * can clip or mis-position it. Locks page scroll while open, keeps keyboard + * focus inside it, and hands focus back to whatever opened it on close. + */ +export function Lightbox({ images, index, onIndexChange, onClose }: LightboxProps) { + const containerRef = useRef(null); + const closeButtonRef = useRef(null); + const touchStartX = useRef(null); + const count = images.length; + const safeIndex = Math.min(Math.max(index, 0), Math.max(count - 1, 0)); + const current = images[safeIndex]; + + const goTo = useCallback( + (next: number) => { + if (count === 0) return; + onIndexChange((next + count) % count); + }, + [count, onIndexChange], + ); + + // Page scroll lock + focus handoff. + useEffect(() => { + const opener = document.activeElement as HTMLElement | null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeButtonRef.current?.focus(); + return () => { + document.body.style.overflow = previousOverflow; + opener?.focus?.(); + }; + }, []); + + // Warm the neighbours so Next / swipe is instant. + useEffect(() => { + if (count < 2) return; + for (const neighbour of [images[(safeIndex + 1) % count], images[(safeIndex - 1 + count) % count]]) { + if (neighbour) new window.Image().src = neighbour.url; + } + }, [images, safeIndex, count]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowLeft") goTo(safeIndex - 1); + else if (event.key === "ArrowRight") goTo(safeIndex + 1); + else if (event.key === "Tab") { + // Keep focus inside the dialog. + const focusable = containerRef.current?.querySelectorAll("button:not([disabled])"); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [goTo, onClose, safeIndex]); + + if (!current || typeof document === "undefined") return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()}> +

+ {safeIndex + 1} / {count} +

+ +
+ +
{ + if (event.target === event.currentTarget) onClose(); + }} + onTouchStart={(event) => { + touchStartX.current = event.touches[0]?.clientX ?? null; + }} + onTouchEnd={(event) => { + const start = touchStartX.current; + touchStartX.current = null; + const end = event.changedTouches[0]?.clientX; + if (start === null || end === undefined) return; + const delta = end - start; + if (Math.abs(delta) >= SWIPE_DISTANCE_PX) goTo(safeIndex + (delta < 0 ? 1 : -1)); + }} + > + {/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} + {current.caption + + {count > 1 && ( + <> + + + + )} +
+ +
event.stopPropagation()}> + {current.caption} +
+
, + document.body, + ); +} diff --git a/src/features/gallery/actions/gallery.ts b/src/features/gallery/actions/gallery.ts index 68b4226..3db855e 100644 --- a/src/features/gallery/actions/gallery.ts +++ b/src/features/gallery/actions/gallery.ts @@ -10,6 +10,7 @@ import { addGalleryImage, deleteGalleryImage, reorderGalleryImages, + replaceGalleryImage, updateGalleryImage, } from "@/services/gallery/gallery.service"; import type { GalleryImageSummary } from "@/services/gallery/gallery.service"; @@ -61,6 +62,13 @@ export async function updateGalleryImageAction( return { success: true }; } +export async function replaceGalleryImageAction(id: string, imageUrl: string): Promise { + const { businessId, error } = await requireGalleryManager(); + if (!businessId) return { success: false, error: error ?? "Not authorized." }; + + return replaceGalleryImage(prisma, { id, businessId, imageUrl }); +} + export async function deleteGalleryImageAction(id: string): Promise { const { businessId, error } = await requireGalleryManager(); if (!businessId) return { success: false, error: error ?? "Not authorized." }; diff --git a/src/features/home/components/home-tabs.tsx b/src/features/home/components/home-tabs.tsx index 5035deb..8c3561d 100644 --- a/src/features/home/components/home-tabs.tsx +++ b/src/features/home/components/home-tabs.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { AtSign, Globe, Mail, Phone, Search, Share2 } from "lucide-react"; import { BarberCard } from "@/components/ui/barber-card"; import { Card } from "@/components/ui/card"; +import { Lightbox } from "@/components/ui/lightbox"; import { ServiceCard } from "@/components/ui/service-card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { BookServiceButton } from "@/features/booking/components/book-service-button"; @@ -179,6 +180,8 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) { [images], ); const [activeCategory, setActiveCategory] = useState(null); + // Index into `filtered` of the picture open full-screen, or null when the viewer is closed. + const [openIndex, setOpenIndex] = useState(null); const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images; @@ -216,13 +219,27 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
)}
- {filtered.map((image) => ( -
+ {filtered.map((image, index) => ( +
+ ))}
+ {openIndex !== null && ( + ({ id: i.id, url: i.imageUrl, caption: i.caption }))} + index={openIndex} + onIndexChange={setOpenIndex} + onClose={() => setOpenIndex(null)} + /> + )}
); } diff --git a/src/features/owner/components/gallery-manager.tsx b/src/features/owner/components/gallery-manager.tsx index 4a09e18..3af5145 100644 --- a/src/features/owner/components/gallery-manager.tsx +++ b/src/features/owner/components/gallery-manager.tsx @@ -1,15 +1,34 @@ "use client"; -import { useState, useTransition } from "react"; +import { useId, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical, ImageUp, Plus, Trash2 } from "lucide-react"; import { GalleryPlacement } from "@/generated/prisma/enums"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils/cn"; import { MediaManagerModal } from "@/features/media/components/media-manager-modal"; import { addGalleryImageAction, deleteGalleryImageAction, reorderGalleryImagesAction, + replaceGalleryImageAction, updateGalleryImageAction, } from "@/features/gallery/actions/gallery"; import type { GalleryImageSummary } from "@/services/gallery/gallery.service"; @@ -27,14 +46,27 @@ export function GalleryManager({ placement, title, description, showCategory, in const router = useRouter(); const [images, setImages] = useState(initialImages); const [pickerOpen, setPickerOpen] = useState(false); + /** Set while the picker is choosing a replacement for this image; null means it's adding a new one. */ + const [replacingId, setReplacingId] = useState(null); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); + // dnd-kit numbers its accessibility ids from a global counter, which differs between server and client renders + // (a hydration mismatch); a stable id from React keeps them identical. + const dndId = useId(); - const run = (fn: () => Promise<{ success: boolean; error?: string }>) => { + // A small movement threshold so tapping the handle (or scrolling past it) never starts a drag; the keyboard + // sensor makes the same handle reorderable with Space + arrow keys. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const run = (fn: () => Promise<{ success: boolean; error?: string }>, onFailure?: () => void) => { setError(null); startTransition(async () => { const result = await fn(); if (!result.success) { + onFailure?.(); setError(result.error ?? "Something went wrong."); return; } @@ -42,7 +74,19 @@ export function GalleryManager({ placement, title, description, showCategory, in }); }; + const closePicker = () => { + setPickerOpen(false); + setReplacingId(null); + }; + const handleSelect = (url: string) => { + const targetId = replacingId; + if (targetId) { + const previous = images; + setImages((prev) => prev.map((i) => (i.id === targetId ? { ...i, imageUrl: url } : i))); + run(() => replaceGalleryImageAction(targetId, url), () => setImages(previous)); + return; + } run(async () => { const result = await addGalleryImageAction({ placement, imageUrl: url }); if (result.success) setImages((prev) => [...prev, result.image]); @@ -50,20 +94,26 @@ export function GalleryManager({ placement, title, description, showCategory, in }); }; - const move = (index: number, direction: -1 | 1) => { - const targetIndex = index + direction; - if (targetIndex < 0 || targetIndex >= images.length) return; - const reordered = [...images]; - const [moved] = reordered.splice(index, 1); - if (!moved) return; - reordered.splice(targetIndex, 0, moved); + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const from = images.findIndex((i) => i.id === active.id); + const to = images.findIndex((i) => i.id === over.id); + if (from < 0 || to < 0) return; + + const previous = images; + const reordered = arrayMove(images, from, to); setImages(reordered); - run(() => reorderGalleryImagesAction(placement, reordered.map((i) => i.id))); + run( + () => reorderGalleryImagesAction(placement, reordered.map((i) => i.id)), + () => setImages(previous), + ); }; const remove = (id: string) => { + const previous = images; setImages((prev) => prev.filter((i) => i.id !== id)); - run(() => deleteGalleryImageAction(id)); + run(() => deleteGalleryImageAction(id), () => setImages(previous)); }; const setCategory = (id: string, category: string) => { @@ -76,6 +126,9 @@ export function GalleryManager({ placement, title, description, showCategory, in

{title}

{description}

+ {images.length > 1 && ( +

Drag the grip on a picture to change the order.

+ )}
{error && ( @@ -84,66 +137,123 @@ export function GalleryManager({ placement, title, description, showCategory, in

)} -
- {images.map((image, index) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} - -
- {showCategory && ( - setCategory(image.id, e.target.value)} - placeholder="Category (optional)" - className="px-3 py-2 text-xs" - /> - )} -
-
- - -
- -
-
-
- ))} + + i.id)} strategy={rectSortingStrategy}> +
+ {images.map((image) => ( + 1} + onCategory={(category) => setCategory(image.id, category)} + onReplace={() => { + setReplacingId(image.id); + setPickerOpen(true); + }} + onRemove={() => remove(image.id)} + /> + ))} - -
+ +
+ + setPickerOpen(false)} + onClose={closePicker} onSelect={handleSelect} category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"} /> ); } + +function SortableTile({ + image, + showCategory, + canDrag, + onCategory, + onReplace, + onRemove, +}: { + image: GalleryImageSummary; + showCategory: boolean; + canDrag: boolean; + onCategory: (category: string) => void; + onReplace: () => void; + onRemove: () => void; +}) { + const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ + id: image.id, + }); + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} + + {canDrag && ( + + )} +
+
+ {showCategory && ( + onCategory(e.target.value)} + placeholder="Category (optional)" + className="px-3 py-2 text-xs" + /> + )} +
+ + +
+
+
+ ); +} diff --git a/src/services/gallery/__tests__/gallery.service.integration.test.ts b/src/services/gallery/__tests__/gallery.service.integration.test.ts index 6a584dc..3eae8a2 100644 --- a/src/services/gallery/__tests__/gallery.service.integration.test.ts +++ b/src/services/gallery/__tests__/gallery.service.integration.test.ts @@ -3,8 +3,10 @@ import { GalleryPlacement } from "@/generated/prisma/enums"; import { addGalleryImage, deleteGalleryImage, + GALLERY_IMAGE_URL_MAX_LENGTH, listGalleryImages, reorderGalleryImages, + replaceGalleryImage, updateGalleryImage, } from "@/services/gallery/gallery.service"; import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures"; @@ -83,4 +85,46 @@ describe("Gallery service (integration)", () => { expect(result.success).toBe(true); expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull(); }); + + describe("replacing an image", () => { + const add = (url: string, extra: { category?: string } = {}) => + addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: url, ...extra }); + + it("swaps the picture but keeps its position, category, caption and visibility", async () => { + const first = await add("/media/first.png"); + const middle = await add("/media/middle.png", { category: "Fades" }); + const last = await add("/media/last.png"); + await updateGalleryImage(prisma, { id: middle.id, businessId: business.businessId, caption: "Client X", isActive: false }); + + const result = await replaceGalleryImage(prisma, { id: middle.id, businessId: business.businessId, imageUrl: "/media/new.png" }); + + expect(result).toEqual({ success: true }); + const all = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO }); + expect(all.map((i) => i.id)).toEqual([first.id, middle.id, last.id]); + expect(all[1]).toMatchObject({ imageUrl: "/media/new.png", category: "Fades", caption: "Client X", isActive: false }); + }); + + it("refuses an empty or over-long address, leaving the picture as it was", async () => { + const image = await add("/media/keep.png"); + + for (const imageUrl of [" ", "x".repeat(GALLERY_IMAGE_URL_MAX_LENGTH + 1)]) { + const result = await replaceGalleryImage(prisma, { id: image.id, businessId: business.businessId, imageUrl }); + expect(result.success).toBe(false); + } + expect((await prisma.businessGalleryImage.findUniqueOrThrow({ where: { id: image.id } })).imageUrl).toBe("/media/keep.png"); + }); + + it("can't replace another business's image", async () => { + const otherBusiness = await createTestBusiness(); + try { + const image = await add("/media/mine.png"); + const result = await replaceGalleryImage(prisma, { id: image.id, businessId: otherBusiness.businessId, imageUrl: "/media/hijack.png" }); + + expect(result.success).toBe(false); + expect((await prisma.businessGalleryImage.findUniqueOrThrow({ where: { id: image.id } })).imageUrl).toBe("/media/mine.png"); + } finally { + await cleanupTestBusiness(otherBusiness); + } + }); + }); }); diff --git a/src/services/gallery/gallery.service.ts b/src/services/gallery/gallery.service.ts index ba18cc8..232c49c 100644 --- a/src/services/gallery/gallery.service.ts +++ b/src/services/gallery/gallery.service.ts @@ -84,6 +84,30 @@ export async function updateGalleryImage( return { success: result.count > 0 }; } +/** imageUrl is a VarChar(500) column. */ +export const GALLERY_IMAGE_URL_MAX_LENGTH = 500; + +/** + * Swaps the picture in an existing slot, keeping everything else about it: its + * position, its category and caption, and whether it is shown. (Delete + add + * would put the new picture at the end and lose the category.) + */ +export async function replaceGalleryImage( + prisma: PrismaClient, + params: { id: string; businessId: string; imageUrl: string }, +): Promise<{ success: boolean; error?: string }> { + const imageUrl = params.imageUrl.trim(); + if (!imageUrl || imageUrl.length > GALLERY_IMAGE_URL_MAX_LENGTH) { + return { success: false, error: "That image address isn't valid." }; + } + const result = await prisma.businessGalleryImage.updateMany({ + where: { id: params.id, businessId: params.businessId }, + // The old media-library link no longer describes this picture. + data: { imageUrl, mediaAssetId: null }, + }); + return result.count > 0 ? { success: true } : { success: false, error: "Image not found." }; +} + export async function deleteGalleryImage( prisma: PrismaClient, params: { id: string; businessId: string },