Working sorting kinda?
This commit is contained in:
@ -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"
|
||||
|
@ -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">
|
||||
|
@ -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`}
|
||||
|
48
src/components/portfolio/images/SortableImageItem.tsx
Normal file
48
src/components/portfolio/images/SortableImageItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user