Working ImageSortGallery

This commit is contained in:
2025-07-26 12:20:44 +02:00
parent 7a8c495f60
commit 3c0e191cd9
21 changed files with 460 additions and 770 deletions

View File

@ -128,7 +128,14 @@ export default function EditImageForm({ image, categories, tags, types }:
<FormItem>
<FormLabel>Creation Month</FormLabel>
<FormControl>
<Input {...field} type="number" />
<Input
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -141,7 +148,14 @@ export default function EditImageForm({ image, categories, tags, types }:
<FormItem>
<FormLabel>Creation Year</FormLabel>
<FormControl>
<Input {...field} type="number" />
<Input
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -1,6 +1,8 @@
"use client";
import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
import { SortAscIcon } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
type FilterBarProps = {
@ -25,10 +27,9 @@ export default function FilterBar({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const params = new URLSearchParams(searchParams);
const setFilter = (key: string, value: string) => {
const params = new URLSearchParams(searchParams);
if (value !== "all") {
params.set(key, value);
} else {
@ -44,96 +45,108 @@ export default function FilterBar({
router.push(`${pathname}?${params.toString()}`);
};
const sortHref = `${pathname}/sort?${params.toString()}`;
return (
<div className="flex flex-col gap-6 border-b pb-6">
{/* GroupBy Toggle */}
<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"}
label="Year"
onClick={() => setFilter("groupBy", "year")}
/>
<FilterButton
active={groupBy === "album"}
label="Album"
onClick={() => setFilter("groupBy", "album")}
/>
<div>
<div>
<div className="flex justify-end">
<Link href={sortHref} className="flex gap-2 items-center cursor-pointer bg-secondary hover:bg-secondary/90 text-secondary-foreground px-4 py-2 rounded">
<SortAscIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Sort images
</Link>
</div>
</div>
{/* Subnavigation for Year or Album */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{groupBy === "year" ? "Year:" : "Album:"}
</span>
<FilterButton
active={groupId === "all"}
label="All"
onClick={() => setFilter(groupBy, "all")}
/>
{groupBy === "year" &&
years.map((year) => (
<FilterButton
key={year}
active={groupId === String(year)}
label={String(year)}
onClick={() => setFilter("year", String(year))}
/>
))}
{groupBy === "album" &&
albums.map((album) => (
<FilterButton
key={album.id}
active={groupId === album.id}
label={album.name}
onClick={() => setFilter("album", album.id)}
/>
))}
</div>
{/* Type Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Type:</span>
<FilterButton
active={currentType === "all"}
label="All"
onClick={() => setFilter("type", "all")}
/>
{types.map((type) => (
<div className="flex gap-6 pb-6">
{/* GroupBy Toggle */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Group by:</span>
<FilterButton
key={type.id}
active={currentType === type.id}
label={type.name}
onClick={() => setFilter("type", type.id)}
active={groupBy === "year"}
label="Year"
onClick={() => setFilter("groupBy", "year")}
/>
))}
<FilterButton
active={currentType === "none"}
label="No Type"
onClick={() => setFilter("type", "none")}
/>
</div>
<FilterButton
active={groupBy === "album"}
label="Album"
onClick={() => setFilter("groupBy", "album")}
/>
</div>
{/* Type Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Type:</span>
<FilterButton
active={currentType === "all"}
label="All"
onClick={() => setFilter("type", "all")}
/>
{types.map((type) => (
<FilterButton
key={type.id}
active={currentType === type.id}
label={type.name}
onClick={() => setFilter("type", type.id)}
/>
))}
<FilterButton
active={currentType === "none"}
label="No Type"
onClick={() => setFilter("type", "none")}
/>
</div>
{/* Published Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Status:</span>
<FilterButton
active={currentPublished === "all"}
label="All"
onClick={() => setFilter("published", "all")}
/>
<FilterButton
active={currentPublished === "published"}
label="Published"
onClick={() => setFilter("published", "published")}
/>
<FilterButton
active={currentPublished === "unpublished"}
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
{/* Published Filter */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">Status:</span>
<FilterButton
active={currentPublished === "all"}
label="All"
onClick={() => setFilter("published", "all")}
/>
<FilterButton
active={currentPublished === "published"}
label="Published"
onClick={() => setFilter("published", "published")}
/>
<FilterButton
active={currentPublished === "unpublished"}
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
</div>
</div>
<div className="flex gap-6 border-b pb-6">
{/* Subnavigation for Year or Album */}
<div className="flex gap-2 items-center flex-wrap">
<span className="text-sm font-medium text-muted-foreground">
{groupBy === "year" ? "Year:" : "Album:"}
</span>
<FilterButton
active={groupId === "all"}
label="All"
onClick={() => setFilter(groupBy, "all")}
/>
{groupBy === "year" &&
years.map((year) => (
<FilterButton
key={year}
active={groupId === String(year)}
label={String(year)}
onClick={() => setFilter("year", String(year))}
/>
))}
{groupBy === "album" &&
albums.map((album) => (
<FilterButton
key={album.id}
active={groupId === album.id}
label={album.name}
onClick={() => setFilter("album", album.id)}
/>
))}
</div>
</div>
</div>
);
}
@ -150,8 +163,8 @@ function FilterButton({
<button
onClick={onClick}
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"
? "bg-primary text-white border-primary"
: "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
}`}
>
{label}

View File

@ -0,0 +1,49 @@
"use client"
import { PortfolioImage } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
export default function ImageGallery({ images }: { images: PortfolioImage[] }) {
console.log(images);
return (
<div className="w-full flex flex-col gap-4">
<div
className="flex flex-wrap gap-4"
>
{images.map((image) => (
<div key={image.id} style={{ width: 200, height: 200 }}>
<Link href={`/portfolio/images/${image.id}`}>
<div
className={cn(
"overflow-hidden transition-all duration-100",
"w-full h-full",
"hover:border-2 border-transparent"
)}
style={{
'--tw-border-opacity': 1,
} as React.CSSProperties}
>
<div
className={cn(
"relative w-full h-full"
)}
>
<Image
src={`/api/image/thumbnail/${image.fileKey}.webp`}
alt={image.altText ?? image.name ?? "Image"}
fill
className={cn("object-cover"
)}
loading="lazy"
/>
</div>
</div>
</Link>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,207 @@
"use client"
import { PortfolioImage } from "@/generated/prisma"
import {
closestCenter,
DndContext,
DragEndEvent,
useDroppable,
} from "@dnd-kit/core"
import {
arrayMove,
rectSortingStrategy,
SortableContext,
useSortable
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import Image from "next/image"
import React, { useEffect, useState } from "react"
type LayoutGroup = "highlighted" | "featured" | "default"
type GroupedImages = Record<LayoutGroup, PortfolioImage[]>
export default function ImageSortGallery({ images }: { images: PortfolioImage[] }) {
const [items, setItems] = useState<GroupedImages>({
highlighted: [],
featured: [],
default: [],
})
useEffect(() => {
setItems({
highlighted: images
.filter((img) => img.layoutGroup === "highlighted")
.sort((a, b) => a.sortIndex - b.sortIndex),
featured: images
.filter((img) => img.layoutGroup === "featured")
.sort((a, b) => a.sortIndex - b.sortIndex),
default: images
.filter((img) => !img.layoutGroup || img.layoutGroup === "default")
.sort((a, b) => a.sortIndex - b.sortIndex),
})
}, [images])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Find source group (where the item is coming from)
const sourceGroup = findGroupOfItem(activeId);
if (!sourceGroup) return;
// Determine target group (where the item is going to)
let targetGroup: LayoutGroup;
// Check if we're dropping onto an item (then use its group)
const overGroup = findGroupOfItem(overId);
if (overGroup) {
targetGroup = overGroup;
} else {
// Otherwise, we're dropping onto a zone (use the zone's id)
targetGroup = overId as LayoutGroup;
}
// If dropping onto the same item, do nothing
if (sourceGroup === targetGroup && activeId === overId) return;
// Find the active item
const activeItem = items[sourceGroup].find((i) => i.id === activeId);
if (!activeItem) return;
if (sourceGroup === targetGroup) {
// Intra-group movement
const oldIndex = items[sourceGroup].findIndex((i) => i.id === activeId);
const newIndex = items[targetGroup].findIndex((i) => i.id === overId);
if (oldIndex === -1 || newIndex === -1) return;
setItems((prev) => ({
...prev,
[sourceGroup]: arrayMove(prev[sourceGroup], oldIndex, newIndex),
}));
} else {
// Inter-group movement
setItems((prev) => {
// Remove from source group
const updatedSource = prev[sourceGroup].filter((i) => i.id !== activeId);
// Add to target group at the end (or you could insert at a specific position)
const updatedTarget = [...prev[targetGroup], {
...activeItem,
layoutGroup: targetGroup,
sortIndex: prev[targetGroup].length // Set new sort index
}];
return {
...prev,
[sourceGroup]: updatedSource,
[targetGroup]: updatedTarget,
};
});
}
}
const findGroupOfItem = (id: string): LayoutGroup | undefined => {
for (const group of ['highlighted', 'featured', 'default'] as LayoutGroup[]) {
if (items[group].some((img) => img.id === id)) {
return group;
}
}
return undefined;
};
const savePositions = async () => {
await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(items),
})
alert("Positions saved successfully!")
}
return (
<div className="space-y-6">
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
{(["highlighted", "featured", "default"] as LayoutGroup[]).map((group) => (
<div key={group}>
<h2 className="text-xl font-bold capitalize mb-2">{group}</h2>
<SortableContext
items={items[group].map((i) => i.id)}
strategy={rectSortingStrategy}
>
<DroplayoutGroup id={group}>
{items[group].map((item) => (
<DraggableImage key={item.id} id={item.id} fileKey={item.fileKey} />
))}
</DroplayoutGroup>
</SortableContext>
</div>
))}
</DndContext>
<button
onClick={savePositions}
className="mt-4 px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700"
>
Save Positions
</button>
</div>
)
}
function DroplayoutGroup({ id, children }: { id: string; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={`min-h-[200px] border-2 border-dashed rounded p-4 flex flex-wrap gap-4 transition-colors ${isOver ? 'bg-blue-100 border-blue-500' : 'bg-gray-50'
} ${React.Children.count(children) === 0 ? 'items-center justify-center' : ''}`}
>
{React.Children.count(children) === 0 ? (
<p className="text-gray-400">Drop images here</p>
) : (
children
)}
</div>
);
}
function DraggableImage({ id, fileKey }: { id: string; fileKey: string }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="w-[100px] h-[100px] border rounded overflow-hidden"
>
<Image
src={`/api/image/thumbnail/${fileKey}.webp`}
alt=""
className="w-full h-full object-cover"
width={100}
height={100}
draggable={false}
/>
</div>
)
}