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 */}

{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 */}
+

+
+ {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 },