Working sorting kinda?

This commit is contained in:
2025-07-26 19:00:19 +02:00
parent 3c0e191cd9
commit ef281ef70f
21 changed files with 586 additions and 169 deletions

View File

@ -10,7 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioCategory, PortfolioImage, PortfolioTag, PortfolioType } from "@/generated/prisma";
import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioAlbum, PortfolioCategory, PortfolioImage, PortfolioSortContext, PortfolioTag, PortfolioType } from "@/generated/prisma";
import { cn } from "@/lib/utils";
import { imageSchema } from "@/schemas/portfolio/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
@ -21,22 +21,25 @@ import { toast } from "sonner";
import { z } from "zod/v4";
type ImageWithItems = PortfolioImage & {
album: PortfolioAlbum | null,
type: PortfolioType | null,
metadata: ImageMetadata | null,
categories: PortfolioCategory[],
colors: (
ImageColor & {
color: Color
}
)[],
variants: ImageVariant[],
categories: PortfolioCategory[],
sortContexts: PortfolioSortContext[],
tags: PortfolioTag[],
type: PortfolioType | null,
variants: ImageVariant[],
};
export default function EditImageForm({ image, categories, tags, types }:
export default function EditImageForm({ image, albums, categories, tags, types }:
{
image: ImageWithItems,
albums: PortfolioAlbum[],
categories: PortfolioCategory[]
tags: PortfolioTag[],
types: PortfolioType[]
@ -47,24 +50,28 @@ export default function EditImageForm({ image, categories, tags, types }:
defaultValues: {
fileKey: image.fileKey,
originalFile: image.originalFile,
fileType: image.fileType,
name: image.name,
fileSize: image.fileSize,
needsWork: image.needsWork ?? true,
nsfw: image.nsfw ?? false,
published: image.published ?? false,
setAsHeader: image.setAsHeader ?? false,
altText: image.altText || "",
description: image.description || "",
fileType: image.fileType || "",
layoutGroup: image.layoutGroup || "",
fileSize: image.fileSize || undefined,
layoutOrder: image.layoutOrder || undefined,
month: image.month || undefined,
year: image.year || undefined,
creationDate: image.creationDate ? new Date(image.creationDate) : undefined,
albumId: image.albumId ?? undefined,
typeId: image.typeId ?? undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
metadataId: image.metadata?.id ?? undefined,
categoryIds: image.categories?.map(cat => cat.id) ?? [],
colorIds: image.colors?.map(color => color.id) ?? [],
sortContextIds: image.sortContexts?.map(sortContext => sortContext.id) ?? [],
tagIds: image.tags?.map(tag => tag.id) ?? [],
variantIds: image.variants?.map(variant => variant.id) ?? [],
}
})
@ -203,6 +210,33 @@ export default function EditImageForm({ image, categories, tags, types }:
)}
/>
{/* Select */}
<FormField
control={form.control}
name="albumId"
render={({ field }) => (
<FormItem>
<FormLabel>Album</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
value={field.value ?? ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an album" />
</SelectTrigger>
</FormControl>
<SelectContent>
{albums.map((album) => (
<SelectItem key={album.id} value={album.id}>
{album.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="typeId"
@ -293,6 +327,21 @@ export default function EditImageForm({ image, categories, tags, types }:
}}
/>
{/* Boolean */}
<FormField
control={form.control}
name="needsWork"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Needs some work</FormLabel>
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="nsfw"

View File

@ -112,6 +112,11 @@ export default function FilterBar({
label="Unpublished"
onClick={() => setFilter("published", "unpublished")}
/>
<FilterButton
active={currentPublished === "needsWork"}
label="Needs work"
onClick={() => setFilter("published", "needsWork")}
/>
</div>
</div>
<div className="flex gap-6 border-b pb-6">

View File

@ -1,5 +1,6 @@
"use client"
import { saveImageSort } from "@/actions/portfolio/images/saveImageSort"
import { PortfolioImage } from "@/generated/prisma"
import {
closestCenter,
@ -14,120 +15,101 @@ import {
useSortable
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ImageIcon, Sparkles, Star } from "lucide-react"
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: [],
})
export default function ImageSortGallery({ images }: { images: GroupedImages }) {
const [items, setItems] = useState<GroupedImages>(images)
const [mounted, setMounted] = useState(false)
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])
setMounted(true)
}, [])
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) return;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over) return
const activeId = active.id as string;
const overId = over.id as string;
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;
const sourceGroup = findGroupOfItem(activeId)
const targetGroup = findGroupOfItem(overId) ?? (over.id as LayoutGroup)
if (!sourceGroup || !targetGroup) 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;
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;
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
}];
const updatedSource = prev[sourceGroup].filter((i) => i.id !== activeId)
const updatedTarget = [...prev[targetGroup], activeItem]
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;
};
return (["highlighted", "featured", "default"] as LayoutGroup[]).find(
(group) => items[group].some((img) => img.id === id)
)
}
const savePositions = async () => {
await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(items),
})
alert("Positions saved successfully!")
const allUpdates = (["highlighted", "featured", "default"] as LayoutGroup[]).flatMap((group) =>
items[group].map((img, index) => ({
imageId: img.id,
group,
sortOrder: index,
year: img.year?.toString() ?? "all",
albumId: img.albumId ?? "all",
type: img.typeId ?? "all",
}))
)
await saveImageSort(allUpdates)
alert("Positions saved.")
}
const groupColors = {
highlighted: "text-pink-500",
featured: "text-yellow-500",
default: "text-gray-500",
}
const groupIcons = {
highlighted: <Sparkles className="inline-block w-4 h-4 text-pink-500 mr-1" />,
featured: <Star className="inline-block w-4 h-4 text-yellow-500 mr-1" />,
default: <ImageIcon className="inline-block w-4 h-4 text-gray-500 mr-1" />,
}
if (!mounted) return null
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>
<h2 className={`text-lg font-semibold tracking-tight mb-2 capitalize ${groupColors[group]}`}>
{groupIcons[group]} {group}
</h2>
<SortableContext
items={items[group].map((i) => i.id)}
strategy={rectSortingStrategy}
@ -141,7 +123,6 @@ export default function ImageSortGallery({ images }: { images: PortfolioImage[]
</div>
))}
</DndContext>
<button
onClick={savePositions}
className="mt-4 px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700"
@ -152,17 +133,32 @@ export default function ImageSortGallery({ images }: { images: PortfolioImage[]
)
}
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' : ''}`}
className={`
min-h-[200px]
rounded-xl
p-4
flex flex-wrap gap-4
border border-muted
shadow-sm
transition-colors
duration-200
bg-background
${isOver
? 'ring-2 ring-ring ring-offset-2 ring-offset-background'
: 'hover:ring-1 hover:ring-muted-foreground/40'
}
${React.Children.count(children) === 0 ? 'items-center justify-center' : ''}
`}
>
{React.Children.count(children) === 0 ? (
<p className="text-gray-400">Drop images here</p>
<p className="text-muted-foreground text-sm">Drop images here</p>
) : (
children
)}
@ -170,6 +166,7 @@ function DroplayoutGroup({ id, children }: { id: string; children: React.ReactNo
);
}
function DraggableImage({ id, fileKey }: { id: string; fileKey: string }) {
const {
attributes,
@ -192,7 +189,19 @@ function DraggableImage({ id, fileKey }: { id: string; fileKey: string }) {
style={style}
{...attributes}
{...listeners}
className="w-[100px] h-[100px] border rounded overflow-hidden"
className={`
w-[100px] h-[100px]
rounded-lg
overflow-hidden
border
bg-muted
transition
duration-200
shadow-sm
hover:shadow-md
hover:ring-2 hover:ring-ring
${isDragging ? 'opacity-50' : ''}
`}
>
<Image
src={`/api/image/thumbnail/${fileKey}.webp`}

View File

@ -0,0 +1,48 @@
// src/components/portfolio/SortableImageItem.tsx
"use client";
import { PortfolioImage } from "@/generated/prisma";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Image from "next/image";
interface Props {
id: string;
image: PortfolioImage;
}
export default function SortableImageItem({ id, image }: Props) {
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}
{...attributes}
{...listeners}
style={style}
className="bg-white rounded border overflow-hidden shadow hover:shadow-md transition"
>
<Image
src={`/api/image/thumbnail/${image.fileKey}.wepb`}
alt={image.altText ?? image.name}
width={300}
height={200}
className="object-cover w-full h-48"
/>
<div className="px-2 py-1 text-sm text-center truncate">{image.name}</div>
</div>
);
}