Working sorting kinda?
This commit is contained in:
		@ -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;
 | 
			
		||||
@ -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;
 | 
			
		||||
@ -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");
 | 
			
		||||
@ -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;
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
-- AlterTable
 | 
			
		||||
ALTER TABLE "PortfolioImage" ALTER COLUMN "needsWork" SET DEFAULT true;
 | 
			
		||||
@ -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())
 | 
			
		||||
 | 
			
		||||
@ -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 };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										89
									
								
								src/actions/portfolio/images/getImageSort.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/actions/portfolio/images/getImageSort.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										41
									
								
								src/actions/portfolio/images/saveImageSort.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/actions/portfolio/images/saveImageSort.ts
									
									
									
									
									
										Normal 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,
 | 
			
		||||
      },
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								src/actions/portfolio/images/saveImageSortForSubset.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/actions/portfolio/images/saveImageSortForSubset.ts
									
									
									
									
									
										Normal 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 })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										40
									
								
								src/actions/portfolio/images/saveSortOrder.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/actions/portfolio/images/saveSortOrder.ts
									
									
									
									
									
										Normal 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,
 | 
			
		||||
    })),
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@ -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
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										13
									
								
								src/utils/getSortKey.ts
									
									
									
									
									
										Normal 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}`;
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user