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

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "ImageSortContext" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"key" TEXT NOT NULL,
"index" INTEGER NOT NULL,
"imageId" TEXT NOT NULL,
CONSTRAINT "ImageSortContext_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImageSortContext_key_index_idx" ON "ImageSortContext"("key", "index");
-- CreateIndex
CREATE UNIQUE INDEX "ImageSortContext_key_imageId_key" ON "ImageSortContext"("key", "imageId");
-- AddForeignKey
ALTER TABLE "ImageSortContext" ADD CONSTRAINT "ImageSortContext_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,32 @@
/*
Warnings:
- You are about to drop the `ImageSortContext` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ImageSortContext" DROP CONSTRAINT "ImageSortContext_imageId_fkey";
-- DropTable
DROP TABLE "ImageSortContext";
-- CreateTable
CREATE TABLE "PortfolioSortContext" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"key" TEXT NOT NULL,
"index" INTEGER NOT NULL,
"imageId" TEXT NOT NULL,
CONSTRAINT "PortfolioSortContext_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PortfolioSortContext_key_index_idx" ON "PortfolioSortContext"("key", "index");
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioSortContext_key_imageId_key" ON "PortfolioSortContext"("key", "imageId");
-- AddForeignKey
ALTER TABLE "PortfolioSortContext" ADD CONSTRAINT "PortfolioSortContext_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "PortfolioImage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `index` on the `PortfolioSortContext` table. All the data in the column will be lost.
- You are about to drop the column `key` on the `PortfolioSortContext` table. All the data in the column will be lost.
- A unique constraint covering the columns `[imageId,year,albumId,type,group]` on the table `PortfolioSortContext` will be added. If there are existing duplicate values, this will fail.
- Added the required column `albumId` to the `PortfolioSortContext` table without a default value. This is not possible if the table is not empty.
- Added the required column `group` to the `PortfolioSortContext` table without a default value. This is not possible if the table is not empty.
- Added the required column `sortOrder` to the `PortfolioSortContext` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `PortfolioSortContext` table without a default value. This is not possible if the table is not empty.
- Added the required column `year` to the `PortfolioSortContext` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "PortfolioSortContext_key_imageId_key";
-- DropIndex
DROP INDEX "PortfolioSortContext_key_index_idx";
-- AlterTable
ALTER TABLE "PortfolioSortContext" DROP COLUMN "index",
DROP COLUMN "key",
ADD COLUMN "albumId" TEXT NOT NULL,
ADD COLUMN "group" TEXT NOT NULL,
ADD COLUMN "sortOrder" INTEGER NOT NULL,
ADD COLUMN "type" TEXT NOT NULL,
ADD COLUMN "year" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "PortfolioSortContext_imageId_year_albumId_type_group_key" ON "PortfolioSortContext"("imageId", "year", "albumId", "type", "group");

View File

@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `layoutGroup` on the `PortfolioImage` table. All the data in the column will be lost.
- You are about to drop the column `layoutOrder` on the `PortfolioImage` table. All the data in the column will be lost.
- Made the column `fileType` on table `PortfolioImage` required. This step will fail if there are existing NULL values in that column.
- Made the column `fileSize` on table `PortfolioImage` required. This step will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "PortfolioImage_albumId_layoutGroup_layoutOrder_idx";
-- DropIndex
DROP INDEX "PortfolioImage_typeId_year_layoutGroup_layoutOrder_idx";
-- AlterTable
ALTER TABLE "PortfolioImage" DROP COLUMN "layoutGroup",
DROP COLUMN "layoutOrder",
ALTER COLUMN "fileType" SET NOT NULL,
ALTER COLUMN "fileSize" SET NOT NULL;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PortfolioImage" ALTER COLUMN "needsWork" SET DEFAULT true;

View File

@ -23,26 +23,19 @@ model PortfolioImage {
fileKey String @unique
originalFile String @unique
fileType String
name String
fileSize Int
needsWork Boolean @default(true)
nsfw Boolean @default(false)
published Boolean @default(false)
setAsHeader Boolean @default(false)
needsWork Boolean @default(false)
altText String?
description String?
fileType String?
layoutGroup String?
fileSize Int?
layoutOrder Int?
month Int?
year Int?
creationDate DateTime?
// group String?
// kind String?
// series String?
// slug String?
// fileSize Int?
albumId String?
typeId String?
@ -51,13 +44,11 @@ model PortfolioImage {
metadata ImageMetadata?
categories PortfolioCategory[]
colors ImageColor[]
tags PortfolioTag[]
variants ImageVariant[]
@@index([typeId, year, layoutGroup, layoutOrder])
@@index([albumId, layoutGroup, layoutOrder])
categories PortfolioCategory[]
colors ImageColor[]
sortContexts PortfolioSortContext[]
tags PortfolioTag[]
variants ImageVariant[]
}
model PortfolioAlbum {
@ -116,6 +107,23 @@ model PortfolioTag {
images PortfolioImage[]
}
model PortfolioSortContext {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
year String
albumId String
type String
group String
sortOrder Int
imageId String
image PortfolioImage @relation(fields: [imageId], references: [id])
@@unique([imageId, year, albumId, type, group])
}
model Color {
id String @id @default(cuid())
createdAt DateTime @default(now())

View File

@ -14,6 +14,9 @@ export async function deleteItems(itemId: string, type: string) {
case "types":
await prisma.portfolioType.delete({ where: { id: itemId } });
break;
case "albums":
await prisma.portfolioAlbum.delete({ where: { id: itemId } });
break;
}
return { success: true };

View File

@ -0,0 +1,89 @@
'use server'
import { Prisma } from "@/generated/prisma"
import prisma from "@/lib/prisma"
type LayoutGroup = "highlighted" | "featured" | "default"
type GroupedImages = Record<LayoutGroup, ImageWithSortContext[]>
type ImageWithSortContext = Prisma.PortfolioImageGetPayload<{
include: { sortContexts: true }
}>
const isValidGroup = (group: string): group is LayoutGroup =>
["highlighted", "featured", "default"].includes(group)
export async function getImageSort({
type,
published,
groupMode,
groupId,
}: {
type: string
published: string
groupMode: "year" | "album"
groupId?: string
}): Promise<GroupedImages> {
const where: Prisma.PortfolioImageWhereInput = {}
// fallback-safe values
const resolvedGroupId = groupId ?? "all"
const resolvedYear = groupMode === "year" ? resolvedGroupId : "all"
const resolvedAlbumId = groupMode === "album" ? resolvedGroupId : "all"
const resolvedType = type ?? "all"
if (type !== "all") {
where.typeId = type === "none" ? null : type
}
if (published === "published") {
where.published = true
} else if (published === "unpublished") {
where.published = false
}
if (groupMode === "year" && resolvedGroupId !== "all") {
where.year = parseInt(resolvedGroupId)
} else if (groupMode === "album" && resolvedGroupId !== "all") {
where.albumId = resolvedGroupId
}
const images = await prisma.portfolioImage.findMany({
where,
include: {
sortContexts: true,
},
})
const groups: GroupedImages = {
highlighted: [],
featured: [],
default: [],
}
for (const image of images) {
const context = image.sortContexts.find((ctx) =>
ctx.year === resolvedYear &&
ctx.albumId === resolvedAlbumId &&
ctx.type === resolvedType &&
isValidGroup(ctx.group)
)
const group: LayoutGroup = context?.group && isValidGroup(context.group)
? context.group
: "default"
groups[group].push(image)
}
for (const group of Object.keys(groups) as LayoutGroup[]) {
groups[group].sort((a, b) => {
const aOrder = a.sortContexts.find((ctx) => ctx.group === group)?.sortOrder ?? 0
const bOrder = b.sortContexts.find((ctx) => ctx.group === group)?.sortOrder ?? 0
return aOrder - bOrder
})
}
return groups
}

View File

@ -0,0 +1,41 @@
'use server'
import prisma from "@/lib/prisma"
type SortPayload = {
imageId: string
group: string
sortOrder: number
year?: string
albumId?: string
type?: string
}
export async function saveImageSort(
updates: SortPayload[]
) {
for (const { imageId, group, sortOrder, year = "all", albumId = "all", type = "all" } of updates) {
await prisma.portfolioSortContext.upsert({
where: {
imageId_year_albumId_type_group: {
imageId,
year,
albumId,
type,
group,
},
},
create: {
imageId,
year,
albumId,
type,
group,
sortOrder,
},
update: {
sortOrder,
},
})
}
}

View File

@ -0,0 +1,35 @@
import prisma from "@/lib/prisma"
interface SaveSortParams {
year: string
albumId: string
type: string
groups: {
group: "highlighted" | "featured" | "default"
imageIds: string[]
}[]
}
export async function saveImageSortForSubset({
year,
albumId,
type,
groups,
}: SaveSortParams) {
await prisma.portfolioSortContext.deleteMany({
where: { year, albumId, type },
})
const data = groups.flatMap(({ group, imageIds }) =>
imageIds.map((id, index) => ({
year,
albumId,
type,
group,
imageId: id,
sortOrder: index,
}))
)
await prisma.portfolioSortContext.createMany({ data })
}

View File

@ -0,0 +1,40 @@
"use server";
import prisma from "@/lib/prisma";
interface SaveSortOrderParams {
year: string;
albumId: string;
type: string;
group: "highlighted" | "featured" | "default" | string;
imageOrder: { imageId: string; sortOrder: number }[];
}
export async function saveSortOrder({
year,
albumId,
type,
group,
imageOrder,
}: SaveSortOrderParams) {
const contextFilter = {
year,
albumId,
type,
group,
};
// Delete previous context entries for this group/subset
await prisma.portfolioSortContext.deleteMany({
where: contextFilter,
});
// Recreate with new order
await prisma.portfolioSortContext.createMany({
data: imageOrder.map(({ imageId, sortOrder }) => ({
...contextFilter,
imageId,
sortOrder,
})),
});
}

View File

@ -9,6 +9,7 @@ export async function updateImage(
id: string
) {
const validated = imageSchema.safeParse(values);
// console.log(validated)
if (!validated.success) {
throw new Error("Invalid image data");
}
@ -16,17 +17,19 @@ export async function updateImage(
const {
fileKey,
originalFile,
fileType,
name,
fileSize,
needsWork,
nsfw,
published,
setAsHeader,
altText,
description,
fileType,
fileSize,
month,
year,
creationDate,
albumId,
typeId,
tagIds,
categoryIds
@ -45,17 +48,19 @@ export async function updateImage(
data: {
fileKey,
originalFile,
fileType,
name,
fileSize,
needsWork,
nsfw,
published,
setAsHeader,
altText,
description,
fileType,
fileSize,
month,
year,
creationDate,
albumId,
typeId
}
});

View File

@ -9,15 +9,18 @@ export default async function PortfolioImagesEditPage({ params }: { params: { id
const image = await prisma.portfolioImage.findUnique({
where: { id },
include: {
album: true,
type: true,
metadata: true,
categories: true,
colors: { include: { color: true } },
sortContexts: true,
tags: true,
variants: true
}
})
const albums = await prisma.portfolioAlbum.findMany({ orderBy: { sortIndex: "asc" } });
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" } });
@ -29,7 +32,7 @@ export default async function PortfolioImagesEditPage({ params }: { params: { id
<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...'}
{image ? <EditImageForm image={image} albums={albums} tags={tags} categories={categories} types={types} /> : 'Image not found...'}
<div className="mt-6">
{image && <DeleteImageButton imageId={image.id} />}
</div>

View File

@ -40,6 +40,8 @@ export default async function PortfolioImagesPage({
where.published = true;
} else if (published === "unpublished") {
where.published = false;
} else if (published === "needsWork") {
where.needsWork = true;
}
// Filter by group (year or album)

View File

@ -1,6 +1,5 @@
import { getImageSort } from "@/actions/portfolio/images/getImageSort";
import ImageSortGallery from "@/components/portfolio/images/ImageSortGallery";
import { Prisma } from "@/generated/prisma";
import prisma from "@/lib/prisma";
export default async function PortfolioImagesSortPage({
searchParams
@ -24,50 +23,15 @@ export default async function PortfolioImagesSortPage({
const groupMode = groupBy === "album" ? "album" : "year";
const groupId = groupMode === "album" ? album ?? "all" : year ?? "all";
const where: Prisma.PortfolioImageWhereInput = {};
// Filter by type
if (type !== "all") {
where.typeId = type === "none" ? null : type;
}
// Filter by published status
if (published === "published") {
where.published = true;
} else if (published === "unpublished") {
where.published = false;
}
// Filter by group (year or album)
if (groupMode === "year" && groupId !== "all") {
where.year = parseInt(groupId);
} else if (groupMode === "album" && groupId !== "all") {
where.albumId = groupId;
}
const images = await prisma.portfolioImage.findMany(
{
where,
orderBy: [{ sortIndex: 'asc' }],
}
)
const imageGroups = await getImageSort({ type, published, groupMode, groupId });
return (
<div>
<div className="mt-6">
{/* {images && images.length > 0 ? <MosaicGallery
images={images.map((img) => ({
...img,
width: 400,
height: 300,
}))}
/> : <p className="text-muted-foreground italic">No images found.</p>} */}
{images && images.length > 0 ?
<ImageSortGallery images={images} />
:
<p className="text-muted-foreground italic">No images found.</p>
}
</div>
<div className="mt-6">
{Object.values(imageGroups).flat().length > 0 ? (
<ImageSortGallery images={imageGroups} />
) : (
<p className="text-muted-foreground italic">No images found.</p>
)}
</div>
);
}
}

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>
);
}

View File

@ -11,29 +11,28 @@ export const imageUploadSchema = z.object({
export const imageSchema = z.object({
fileKey: z.string().min(1, "File key is required"),
originalFile: z.string().min(1, "Original file is required"),
fileType: z.string().min(1, "File type is required"),
name: z.string().min(1, "Name is required"),
fileSize: z.number().min(1, "File size is required"),
needsWork: z.boolean(),
nsfw: z.boolean(),
published: z.boolean(),
setAsHeader: z.boolean(),
altText: z.string().optional(),
description: z.string().optional(),
fileType: z.string().optional(),
layoutGroup: z.string().optional(),
fileSize: z.number().optional(),
layoutOrder: z.number().optional(),
month: z.number().optional(),
year: z.number().optional(),
creationDate: z.date().optional(),
// group: z.string().optional(),
// kind: z.string().optional(),
// series: z.string().optional(),
// slug: z.string().optional(),
// fileSize: z.number().optional(),
albumId: z.string().optional(),
typeId: z.string().optional(),
colorIds: z.array(z.string()).optional(),
metadataId: z.string().optional(),
categoryIds: z.array(z.string()).optional(),
colorIds: z.array(z.string()).optional(),
sortContextIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
variantIds: z.array(z.string()).optional(),
})

13
src/utils/getSortKey.ts Normal file
View File

@ -0,0 +1,13 @@
export function getSortKey({
type,
published,
groupBy,
groupId,
}: {
type: string
published: string
groupBy: "year" | "album"
groupId: string
}) {
return `groupBy:${groupBy}|group:${groupId}|type:${type}|published:${published}`;
}