Add albums
This commit is contained in:
189
src/components/portfolio/images/AdvancedMosaicGallery.tsx
Normal file
189
src/components/portfolio/images/AdvancedMosaicGallery.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { saveImageLayoutOrder } from '@/actions/portfolio/images/saveImageLayoutOrder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LayoutImage } from '@/utils/justifyPortfolioImages';
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
import { GripVertical, Trash2Icon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
images: LayoutImage[];
|
||||
};
|
||||
|
||||
type GroupKey = 'highlighted' | 'featured' | 'default';
|
||||
|
||||
export function AdvancedMosaicGallery({ images }: Props) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor));
|
||||
|
||||
const [columns, setColumns] = useState<Record<GroupKey, LayoutImage[]>>(() => {
|
||||
const groups: Record<GroupKey, LayoutImage[]> = {
|
||||
highlighted: [],
|
||||
featured: [],
|
||||
default: [],
|
||||
};
|
||||
|
||||
images.forEach((img) => {
|
||||
const group = (img.layoutGroup ?? 'default') as GroupKey;
|
||||
groups[group].push(img);
|
||||
});
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const findColumn = (id: string) => {
|
||||
return (Object.keys(columns) as (keyof typeof columns)[]).find((col) =>
|
||||
columns[col].some((img) => img.id === id)
|
||||
);
|
||||
};
|
||||
|
||||
const fromCol = findColumn(String(active.id));
|
||||
const toCol = findColumn(String(over.id));
|
||||
|
||||
if (!fromCol || !toCol) return;
|
||||
|
||||
const activeIndex = columns[fromCol].findIndex((i) => i.id === active.id);
|
||||
const overIndex = columns[toCol].findIndex((i) => i.id === over.id);
|
||||
|
||||
if (fromCol === toCol) {
|
||||
const updated = arrayMove(columns[fromCol], activeIndex, overIndex);
|
||||
setColumns((prev) => ({
|
||||
...prev,
|
||||
[fromCol]: updated,
|
||||
}));
|
||||
} else {
|
||||
const moved = columns[fromCol][activeIndex];
|
||||
const updatedFrom = [...columns[fromCol]];
|
||||
updatedFrom.splice(activeIndex, 1);
|
||||
|
||||
const updatedTo = [...columns[toCol]];
|
||||
updatedTo.splice(overIndex, 0, { ...moved, layoutGroup: toCol });
|
||||
|
||||
setColumns((prev) => ({
|
||||
...prev,
|
||||
[fromCol]: updatedFrom,
|
||||
[toCol]: updatedTo,
|
||||
}));
|
||||
}
|
||||
|
||||
await saveImageLayoutOrder({
|
||||
highlighted: columns.highlighted.map((i) => i.id),
|
||||
featured: columns.featured.map((i) => i.id),
|
||||
default: columns.default.map((i) => i.id),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-8">
|
||||
{(['highlighted', 'featured', 'default'] as const).map((group) => (
|
||||
<SortableContext
|
||||
key={group}
|
||||
items={columns[group].map((i) => String(i.id))}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold capitalize mb-2">{group}</h2>
|
||||
<div className={clsx('flex gap-2 flex-wrap min-h-[80px] border rounded-md p-2')}>
|
||||
{columns[group].map((image) => (
|
||||
<SortableImageCard key={image.id} image={image} />
|
||||
))}
|
||||
{columns[group].length === 0 && (
|
||||
<div className="text-muted-foreground text-sm italic">Drop images here</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="w-28 h-28 bg-white shadow-md flex items-center justify-center text-xs rounded border">
|
||||
Dragging…
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableImageCard({ image }: { image: LayoutImage }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: image.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative w-[120px] h-[120px] rounded overflow-hidden border shadow bg-white"
|
||||
>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="absolute top-1 left-1 bg-white/70 text-muted-foreground p-1 rounded-full cursor-grab"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
<Image
|
||||
src={`/api/image/thumbnail/${image.fileKey}.webp`}
|
||||
alt={image.altText ?? image.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1">
|
||||
<Button size="icon" variant="ghost" className="bg-white/70">
|
||||
<Trash2Icon className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,33 +1,33 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
type FilterBarProps = {
|
||||
types: PortfolioType[];
|
||||
albums: PortfolioAlbum[];
|
||||
years: number[];
|
||||
currentType: string;
|
||||
currentPublished: string;
|
||||
groupBy: "year" | "album";
|
||||
groupId: string;
|
||||
years: number[];
|
||||
albums: PortfolioAlbum[];
|
||||
};
|
||||
|
||||
export default function FilterBar({
|
||||
types,
|
||||
albums,
|
||||
years,
|
||||
currentType,
|
||||
currentPublished,
|
||||
groupBy,
|
||||
groupId,
|
||||
years,
|
||||
albums
|
||||
}: FilterBarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = new URLSearchParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const setFilter = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (value !== "all") {
|
||||
params.set(key, value);
|
||||
@ -35,6 +35,7 @@ export default function FilterBar({
|
||||
params.delete(key);
|
||||
}
|
||||
|
||||
// Reset groupId when switching groupBy
|
||||
if (key === "groupBy") {
|
||||
params.delete("year");
|
||||
params.delete("album");
|
||||
@ -44,9 +45,9 @@ export default function FilterBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4 border-b pb-4">
|
||||
<div className="flex flex-col gap-6 border-b pb-6">
|
||||
{/* GroupBy Toggle */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<span className="text-sm font-medium text-muted-foreground">Group by:</span>
|
||||
<FilterButton
|
||||
active={groupBy === "year"}
|
||||
@ -60,9 +61,11 @@ export default function FilterBar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subnavigation */}
|
||||
{/* Subnavigation for Year or Album */}
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<span className="text-sm font-medium text-muted-foreground">Filter:</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{groupBy === "year" ? "Year:" : "Album:"}
|
||||
</span>
|
||||
<FilterButton
|
||||
active={groupId === "all"}
|
||||
label="All"
|
||||
@ -146,12 +149,12 @@ function FilterButton({
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-3 py-1 rounded text-sm border ${active
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
|
||||
className={`px-3 py-1 rounded text-sm border transition ${active
|
||||
? "bg-primary text-white border-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
275
src/components/portfolio/images/MosaicGallery.tsx
Normal file
275
src/components/portfolio/images/MosaicGallery.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import { deleteImage } from '@/actions/portfolio/images/deleteImage';
|
||||
import { saveImageLayoutOrder } from '@/actions/portfolio/images/saveImageLayoutOrder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LayoutImage, justifyPortfolioImages } from '@/utils/justifyPortfolioImages';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, PencilIcon, RefreshCw, Trash2Icon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
images: LayoutImage[];
|
||||
};
|
||||
|
||||
export function MosaicGallery({ images }: Props) {
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [groups, setGroups] = useState<{
|
||||
highlighted: LayoutImage[];
|
||||
featured: LayoutImage[];
|
||||
default: LayoutImage[];
|
||||
}>({ highlighted: [], featured: [], default: [] });
|
||||
|
||||
const [layout, setLayout] = useState<{
|
||||
highlighted: LayoutImage[][];
|
||||
featured: LayoutImage[][];
|
||||
default: LayoutImage[][];
|
||||
}>({ highlighted: [], featured: [], default: [] });
|
||||
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Resize observer to track container width
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Split incoming images into layout groups
|
||||
useEffect(() => {
|
||||
const split = {
|
||||
highlighted: [] as LayoutImage[],
|
||||
featured: [] as LayoutImage[],
|
||||
default: [] as LayoutImage[],
|
||||
};
|
||||
|
||||
images.forEach((img) => {
|
||||
const group = img.layoutGroup;
|
||||
if (group === 'highlighted') split.highlighted.push(img);
|
||||
else if (group === 'featured') split.featured.push(img);
|
||||
else split.default.push(img);
|
||||
});
|
||||
|
||||
setGroups(split);
|
||||
}, [images]);
|
||||
|
||||
const triggerJustify = useCallback(() => {
|
||||
setLayout({
|
||||
highlighted: justifyPortfolioImages(groups.highlighted, containerWidth, 360, 12),
|
||||
featured: justifyPortfolioImages(groups.featured, containerWidth, 300, 12),
|
||||
default: justifyPortfolioImages(groups.default, containerWidth, 240, 12),
|
||||
});
|
||||
}, [groups, containerWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
triggerJustify();
|
||||
}, [triggerJustify]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteImage(id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete image', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setOverId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const allItems = [...groups.highlighted, ...groups.featured, ...groups.default];
|
||||
const activeItem = allItems.find((img) => img.id === active.id);
|
||||
const overItem = allItems.find((img) => img.id === over.id);
|
||||
if (!activeItem || !overItem) return;
|
||||
|
||||
const groupOf = (id: string) => {
|
||||
if (groups.highlighted.some((img) => img.id === id)) return 'highlighted';
|
||||
if (groups.featured.some((img) => img.id === id)) return 'featured';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
|
||||
const from = groupOf(activeId);
|
||||
const to = groupOf(overId);
|
||||
|
||||
const reorderedFrom = [...groups[from]];
|
||||
const reorderedTo = [...groups[to]];
|
||||
|
||||
const oldIndex = reorderedFrom.findIndex((i) => i.id === active.id);
|
||||
const newIndex = reorderedTo.findIndex((i) => i.id === over.id);
|
||||
|
||||
if (from === to) {
|
||||
reorderedTo.splice(newIndex, 0, reorderedTo.splice(oldIndex, 1)[0]);
|
||||
} else {
|
||||
const [moved] = reorderedFrom.splice(oldIndex, 1);
|
||||
moved.layoutGroup = to;
|
||||
reorderedTo.splice(newIndex, 0, moved);
|
||||
}
|
||||
|
||||
const newGroups = {
|
||||
...groups,
|
||||
[from]: reorderedFrom,
|
||||
[to]: reorderedTo,
|
||||
};
|
||||
|
||||
setGroups(newGroups);
|
||||
triggerJustify();
|
||||
|
||||
await saveImageLayoutOrder({
|
||||
highlighted: newGroups.highlighted.map((i) => i.id),
|
||||
featured: newGroups.featured.map((i) => i.id),
|
||||
default: newGroups.default.map((i) => i.id),
|
||||
});
|
||||
};
|
||||
|
||||
const allIds = [
|
||||
...groups.highlighted,
|
||||
...groups.featured,
|
||||
...groups.default,
|
||||
].map((i) => String(i.id));
|
||||
|
||||
const renderGroup = (label: string, group: keyof typeof layout, items: LayoutImage[][]) => (
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-2 capitalize">{label}</h3>
|
||||
{items.length === 0 ? (
|
||||
<div className="h-[120px] flex items-center justify-center border-2 border-dashed border-muted-foreground rounded mb-4">
|
||||
<p className="text-muted-foreground text-sm">Drop images here</p>
|
||||
</div>
|
||||
) : (
|
||||
items.map((row, i) => (
|
||||
<div key={i} className="flex gap-3 mb-3">
|
||||
{row.map((img) => (
|
||||
<div key={img.id} style={{ width: img.width, height: img.height }}>
|
||||
<SortableImageCard
|
||||
image={img}
|
||||
onDelete={handleDelete}
|
||||
isOver={overId === img.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{images.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-16">No images to display</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button variant="outline" onClick={triggerJustify}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Recalculate layout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragOver={(event) => {
|
||||
if (event.over?.id) setOverId(String(event.over.id));
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={allIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-10">
|
||||
{renderGroup('highlighted', 'highlighted', layout.highlighted)}
|
||||
{renderGroup('featured', 'featured', layout.featured)}
|
||||
{renderGroup('default', 'default', layout.default)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableImageCard({
|
||||
image,
|
||||
onDelete,
|
||||
isOver,
|
||||
}: {
|
||||
image: LayoutImage;
|
||||
onDelete?: (id: string) => void;
|
||||
isOver?: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: image.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
outline: isOver ? '3px solid #3b82f6' : undefined,
|
||||
outlineOffset: isOver ? '2px' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="relative rounded overflow-hidden border shadow"
|
||||
>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="absolute top-1 left-1 bg-white/70 text-muted-foreground p-1 rounded-full cursor-grab"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
<Image
|
||||
src={`/api/image/thumbnail/${image.fileKey}.webp`}
|
||||
alt={image.altText ?? image.name}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 flex gap-1">
|
||||
<button
|
||||
title="Edit"
|
||||
className="bg-white/80 text-muted-foreground hover:bg-white p-1 rounded-full"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete"
|
||||
onClick={() => onDelete?.(image.id)}
|
||||
className="bg-white/80 text-destructive hover:bg-white p-1 rounded-full"
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import EditImageForm from "@/components/portfolio/images/EditImageForm";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export default async function PortfolioImagesEditPage({ params }: { params: { id: string } }) {
|
||||
const { id } = await params;
|
||||
const image = await prisma.portfolioImage.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
type: true,
|
||||
metadata: true,
|
||||
categories: true,
|
||||
colors: { include: { color: true } },
|
||||
tags: true,
|
||||
variants: true
|
||||
}
|
||||
})
|
||||
|
||||
const categories = await prisma.portfolioCategory.findMany({ orderBy: { sortIndex: "asc" } });
|
||||
const tags = await prisma.portfolioTag.findMany({ orderBy: { sortIndex: "asc" } });
|
||||
const types = await prisma.portfolioType.findMany({ orderBy: { sortIndex: "asc" } });
|
||||
|
||||
if (!image) return <div>Image not found</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Edit image</h1>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
{image ? <EditImageForm image={image} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
|
||||
<div className="mt-6">
|
||||
{image && <DeleteImageButton imageId={image.id} />}
|
||||
</div>
|
||||
<div>
|
||||
{image && <ImageVariants variants={image.variants} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{image && <ImageColors colors={image.colors} imageId={image.id} fileKey={image.fileKey} fileType={image.fileType || ""} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user