gallery changes
CI / ci (push) Canceled after 0s

Hero gallery: the slideshow on Home doesn’t open full screen. Only portfolio images do, as you asked.
Lightbox: it has no pinch-zoom.
Delete: removing an image still has no confirm step, same as before.
New dependency: drag-and-drop uses @dnd-kit, which I added to package.json.
Touch dragging: I couldn’t test it on a real phone. Mouse and keyboard dragging work.
Health test: src/app/api/health/__tests__/route.test.ts passes, but it prints unhandled rejections. Its prisma mock has no notificationDelivery, so getQueueHealth throws. That comes from the earlier “Remaining features” commit and I left it alone. It’s a quick fix if you want it.
This commit is contained in:
Stefan Lazic committed 2026-10-18 11:23:17 +01:00
1 parent 96d5533638
commit 21299f0287
9 files changed
+506 -70

No files matched your search

+57 -1
View File
@@ -10,6 +10,9 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.3", "@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/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0", "@fortawesome/react-fontawesome": "^3.5.0",
@@ -28,7 +31,7 @@
"libphonenumber-js": "^1.13.15", "libphonenumber-js": "^1.13.15",
"lucide-react": "^1.59.0", "lucide-react": "^1.59.0",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"mariadb": "^3.5.3", "mariadb": "^3.5.4",
"next": "^16.4.2", "next": "^16.4.2",
"next-auth": "^5.0.0-beta.32", "next-auth": "^5.0.0-beta.32",
"nodemailer": "^8.0.11", "nodemailer": "^8.0.11",
@@ -368,6 +371,59 @@
"node": ">=6.9.0" "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": { "node_modules/@electric-sql/pglite": {
"version": "0.4.3", "version": "0.4.3",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz",
+3
View File
@@ -28,6 +28,9 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.3", "@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/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0", "@fortawesome/react-fontawesome": "^3.5.0",
+2 -1
View File
@@ -27,7 +27,8 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden"> {/* A little taller on phones (3:2) so the cover fills more of the screen; 16:9 from tablet width up. */}
<div className="bg-stone relative aspect-[3/2] w-full overflow-hidden sm:aspect-[16/9]">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} {/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" /> <img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
{images.length > 1 && ( {images.length > 1 && (
+173
View File
@@ -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<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const touchStartX = useRef<number | null>(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<HTMLElement>("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(
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label="Image gallery"
className="fixed inset-0 z-[100] flex flex-col bg-black text-white"
// Tapping the dark area (not the picture or a button) closes it.
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div className="flex items-center justify-between px-4 py-3" onClick={(event) => event.stopPropagation()}>
<p className="text-sm font-bold tabular-nums" aria-live="polite">
{safeIndex + 1} / {count}
</p>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="Close gallery"
className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div
className="relative flex min-h-0 flex-1 items-center justify-center px-2 sm:px-16"
onClick={(event) => {
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 */}
<img
key={current.id}
src={current.url}
alt={current.caption ?? `Image ${safeIndex + 1} of ${count}`}
draggable={false}
className="max-h-full max-w-full select-none object-contain"
/>
{count > 1 && (
<>
<button
type="button"
onClick={() => goTo(safeIndex - 1)}
aria-label="Previous image"
className="absolute top-1/2 left-2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 hover:bg-white/20 sm:left-4"
>
<ChevronLeft className="h-6 w-6" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => goTo(safeIndex + 1)}
aria-label="Next image"
className="absolute top-1/2 right-2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 hover:bg-white/20 sm:right-4"
>
<ChevronRight className="h-6 w-6" aria-hidden="true" />
</button>
</>
)}
</div>
<div className="min-h-[3rem] px-6 py-3 text-center text-sm text-white/80" onClick={(event) => event.stopPropagation()}>
{current.caption}
</div>
</div>,
document.body,
);
}
+8
View File
@@ -10,6 +10,7 @@ import {
addGalleryImage, addGalleryImage,
deleteGalleryImage, deleteGalleryImage,
reorderGalleryImages, reorderGalleryImages,
replaceGalleryImage,
updateGalleryImage, updateGalleryImage,
} from "@/services/gallery/gallery.service"; } from "@/services/gallery/gallery.service";
import type { GalleryImageSummary } from "@/services/gallery/gallery.service"; import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
@@ -61,6 +62,13 @@ export async function updateGalleryImageAction(
return { success: true }; return { success: true };
} }
export async function replaceGalleryImageAction(id: string, imageUrl: string): Promise<GalleryActionResult> {
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<GalleryActionResult> { export async function deleteGalleryImageAction(id: string): Promise<GalleryActionResult> {
const { businessId, error } = await requireGalleryManager(); const { businessId, error } = await requireGalleryManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." }; if (!businessId) return { success: false, error: error ?? "Not authorized." };
+20 -3
View File
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { AtSign, Globe, Mail, Phone, Search, Share2 } from "lucide-react"; import { AtSign, Globe, Mail, Phone, Search, Share2 } from "lucide-react";
import { BarberCard } from "@/components/ui/barber-card"; import { BarberCard } from "@/components/ui/barber-card";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Lightbox } from "@/components/ui/lightbox";
import { ServiceCard } from "@/components/ui/service-card"; import { ServiceCard } from "@/components/ui/service-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BookServiceButton } from "@/features/booking/components/book-service-button"; import { BookServiceButton } from "@/features/booking/components/book-service-button";
@@ -179,6 +180,8 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
[images], [images],
); );
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
// Index into `filtered` of the picture open full-screen, or null when the viewer is closed.
const [openIndex, setOpenIndex] = useState<number | null>(null);
const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images; const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images;
@@ -216,13 +219,27 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{filtered.map((image) => ( {filtered.map((image, index) => (
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl"> <button
key={image.id}
type="button"
onClick={() => setOpenIndex(index)}
aria-label={`View image ${index + 1} of ${filtered.length} full screen`}
className="bg-stone aspect-square cursor-zoom-in overflow-hidden rounded-xl"
>
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} {/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" /> <img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
</div> </button>
))} ))}
</div> </div>
{openIndex !== null && (
<Lightbox
images={filtered.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))}
index={openIndex}
onIndexChange={setOpenIndex}
onClose={() => setOpenIndex(null)}
/>
)}
</div> </div>
); );
} }
+175 -65
View File
@@ -1,15 +1,34 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useId, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; 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 { GalleryPlacement } from "@/generated/prisma/enums";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils/cn";
import { MediaManagerModal } from "@/features/media/components/media-manager-modal"; import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
import { import {
addGalleryImageAction, addGalleryImageAction,
deleteGalleryImageAction, deleteGalleryImageAction,
reorderGalleryImagesAction, reorderGalleryImagesAction,
replaceGalleryImageAction,
updateGalleryImageAction, updateGalleryImageAction,
} from "@/features/gallery/actions/gallery"; } from "@/features/gallery/actions/gallery";
import type { GalleryImageSummary } from "@/services/gallery/gallery.service"; import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
@@ -27,14 +46,27 @@ export function GalleryManager({ placement, title, description, showCategory, in
const router = useRouter(); const router = useRouter();
const [images, setImages] = useState(initialImages); const [images, setImages] = useState(initialImages);
const [pickerOpen, setPickerOpen] = useState(false); 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<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition(); 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); setError(null);
startTransition(async () => { startTransition(async () => {
const result = await fn(); const result = await fn();
if (!result.success) { if (!result.success) {
onFailure?.();
setError(result.error ?? "Something went wrong."); setError(result.error ?? "Something went wrong.");
return; return;
} }
@@ -42,7 +74,19 @@ export function GalleryManager({ placement, title, description, showCategory, in
}); });
}; };
const closePicker = () => {
setPickerOpen(false);
setReplacingId(null);
};
const handleSelect = (url: string) => { 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 () => { run(async () => {
const result = await addGalleryImageAction({ placement, imageUrl: url }); const result = await addGalleryImageAction({ placement, imageUrl: url });
if (result.success) setImages((prev) => [...prev, result.image]); 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 handleDragEnd = (event: DragEndEvent) => {
const targetIndex = index + direction; const { active, over } = event;
if (targetIndex < 0 || targetIndex >= images.length) return; if (!over || active.id === over.id) return;
const reordered = [...images]; const from = images.findIndex((i) => i.id === active.id);
const [moved] = reordered.splice(index, 1); const to = images.findIndex((i) => i.id === over.id);
if (!moved) return; if (from < 0 || to < 0) return;
reordered.splice(targetIndex, 0, moved);
const previous = images;
const reordered = arrayMove(images, from, to);
setImages(reordered); 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 remove = (id: string) => {
const previous = images;
setImages((prev) => prev.filter((i) => i.id !== id)); setImages((prev) => prev.filter((i) => i.id !== id));
run(() => deleteGalleryImageAction(id)); run(() => deleteGalleryImageAction(id), () => setImages(previous));
}; };
const setCategory = (id: string, category: string) => { const setCategory = (id: string, category: string) => {
@@ -76,6 +126,9 @@ export function GalleryManager({ placement, title, description, showCategory, in
<div> <div>
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3>
<p className="text-muted mt-1 ml-1 text-xs">{description}</p> <p className="text-muted mt-1 ml-1 text-xs">{description}</p>
{images.length > 1 && (
<p className="text-muted mt-1 ml-1 text-xs">Drag the grip on a picture to change the order.</p>
)}
</div> </div>
{error && ( {error && (
@@ -84,66 +137,123 @@ export function GalleryManager({ placement, title, description, showCategory, in
</p> </p>
)} )}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{images.map((image, index) => ( <SortableContext items={images.map((i) => i.id)} strategy={rectSortingStrategy}>
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */} {images.map((image) => (
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" /> <SortableTile
<div className="space-y-2 p-2"> key={image.id}
{showCategory && ( image={image}
<Input showCategory={showCategory}
value={image.category ?? ""} canDrag={images.length > 1}
onChange={(e) => setCategory(image.id, e.target.value)} onCategory={(category) => setCategory(image.id, category)}
placeholder="Category (optional)" onReplace={() => {
className="px-3 py-2 text-xs" setReplacingId(image.id);
/> setPickerOpen(true);
)} }}
<div className="flex items-center justify-between"> onRemove={() => remove(image.id)}
<div className="flex gap-1"> />
<button ))}
type="button"
onClick={() => move(index, -1)}
disabled={index === 0}
aria-label="Move earlier"
className="text-muted disabled:opacity-30"
>
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => move(index, 1)}
disabled={index === images.length - 1}
aria-label="Move later"
className="text-muted disabled:opacity-30"
>
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
<button type="button" onClick={() => remove(image.id)} aria-label="Delete image" className="text-danger">
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</div>
))}
<button <button
type="button" type="button"
onClick={() => setPickerOpen(true)} onClick={() => {
disabled={isPending} setReplacingId(null);
className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed" setPickerOpen(true);
> }}
<Plus className="h-5 w-5" aria-hidden="true" /> disabled={isPending}
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span> className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed"
</button> >
</div> <Plus className="h-5 w-5" aria-hidden="true" />
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
</button>
</div>
</SortableContext>
</DndContext>
<MediaManagerModal <MediaManagerModal
open={pickerOpen} open={pickerOpen}
onClose={() => setPickerOpen(false)} onClose={closePicker}
onSelect={handleSelect} onSelect={handleSelect}
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"} category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
/> />
</section> </section>
); );
} }
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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"border-line relative overflow-hidden rounded-xl border bg-white",
isDragging && "z-10 shadow-xl ring-2 ring-ink/30",
)}
>
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
<img src={image.imageUrl} alt="" draggable={false} className="bg-stone h-28 w-full object-cover" />
{canDrag && (
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
// touch-none: without it a finger drag on the handle scrolls the page instead of moving the picture.
className="bg-ink/70 absolute top-2 left-2 flex h-9 w-9 cursor-grab touch-none items-center justify-center rounded-md text-white active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
<div className="space-y-2 p-2">
{showCategory && (
<Input
value={image.category ?? ""}
onChange={(e) => onCategory(e.target.value)}
placeholder="Category (optional)"
className="px-3 py-2 text-xs"
/>
)}
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={onReplace}
className="text-ink border-line hover:bg-stone flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[11px] font-bold"
>
<ImageUp className="h-3.5 w-3.5" aria-hidden="true" />
Replace
</button>
<button
type="button"
onClick={onRemove}
aria-label="Remove image"
className="text-danger hover:bg-danger/10 flex h-8 w-8 items-center justify-center rounded-md"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
);
}
@@ -3,8 +3,10 @@ import { GalleryPlacement } from "@/generated/prisma/enums";
import { import {
addGalleryImage, addGalleryImage,
deleteGalleryImage, deleteGalleryImage,
GALLERY_IMAGE_URL_MAX_LENGTH,
listGalleryImages, listGalleryImages,
reorderGalleryImages, reorderGalleryImages,
replaceGalleryImage,
updateGalleryImage, updateGalleryImage,
} from "@/services/gallery/gallery.service"; } from "@/services/gallery/gallery.service";
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures"; import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
@@ -83,4 +85,46 @@ describe("Gallery service (integration)", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull(); 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);
}
});
});
}); });
+24
View File
@@ -84,6 +84,30 @@ export async function updateGalleryImage(
return { success: result.count > 0 }; 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( export async function deleteGalleryImage(
prisma: PrismaClient, prisma: PrismaClient,
params: { id: string; businessId: string }, params: { id: string; businessId: string },