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:
9 files changed
+506
-70
No files matched your search
Generated
+57
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,7 +27,8 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
|
||||
{images.length > 1 && (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<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> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
@@ -216,13 +219,27 @@ function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{filtered.map((image) => (
|
||||
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl">
|
||||
{filtered.map((image, index) => (
|
||||
<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 */}
|
||||
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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
|
||||
<div>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{error && (
|
||||
@@ -84,66 +137,123 @@ export function GalleryManager({ placement, title, description, showCategory, in
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" />
|
||||
<div className="space-y-2 p-2">
|
||||
{showCategory && (
|
||||
<Input
|
||||
value={image.category ?? ""}
|
||||
onChange={(e) => setCategory(image.id, e.target.value)}
|
||||
placeholder="Category (optional)"
|
||||
className="px-3 py-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
))}
|
||||
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={images.map((i) => i.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<SortableTile
|
||||
key={image.id}
|
||||
image={image}
|
||||
showCategory={showCategory}
|
||||
canDrag={images.length > 1}
|
||||
onCategory={(category) => setCategory(image.id, category)}
|
||||
onReplace={() => {
|
||||
setReplacingId(image.id);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
onRemove={() => remove(image.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
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"
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setReplacingId(null);
|
||||
setPickerOpen(true);
|
||||
}}
|
||||
disabled={isPending}
|
||||
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"
|
||||
>
|
||||
<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
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onClose={closePicker}
|
||||
onSelect={handleSelect}
|
||||
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
|
||||
/>
|
||||
</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 {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user