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