Working ImageSortGallery
This commit is contained in:
		@ -1,10 +0,0 @@
 | 
			
		||||
'use server';
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function getLatestTos(): Promise<string | null> {
 | 
			
		||||
  const tos = await prisma.termsOfService.findFirst({
 | 
			
		||||
    orderBy: { createdAt: 'desc' },
 | 
			
		||||
  });
 | 
			
		||||
  return tos?.markdown ?? null;
 | 
			
		||||
}
 | 
			
		||||
@ -1,11 +0,0 @@
 | 
			
		||||
'use server';
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function saveTosAction(markdown: string) {
 | 
			
		||||
  await prisma.termsOfService.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      markdown,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@ -1,19 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma"
 | 
			
		||||
 | 
			
		||||
export async function deleteCommissionType(typeId: string) {
 | 
			
		||||
 | 
			
		||||
  await prisma.commissionTypeOption.deleteMany({
 | 
			
		||||
    where: { typeId },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await prisma.commissionTypeExtra.deleteMany({
 | 
			
		||||
    where: { typeId },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  await prisma.commissionType.delete({
 | 
			
		||||
    where: { id: typeId },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@ -1,81 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
 | 
			
		||||
 | 
			
		||||
export async function createCommissionOption(data: { name: string }) {
 | 
			
		||||
  return await prisma.commissionOption.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      description: "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createCommissionExtra(data: { name: string }) {
 | 
			
		||||
  return await prisma.commissionExtra.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      description: "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createCommissionCustomInput(data: {
 | 
			
		||||
  name: string
 | 
			
		||||
  fieldId: string
 | 
			
		||||
}) {
 | 
			
		||||
  return await prisma.commissionCustomInput.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      fieldId: data.fieldId,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createCommissionType(formData: commissionTypeSchema) {
 | 
			
		||||
  const parsed = commissionTypeSchema.safeParse(formData)
 | 
			
		||||
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    console.error("Validation failed", parsed.error)
 | 
			
		||||
    throw new Error("Invalid input")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = parsed.data
 | 
			
		||||
 | 
			
		||||
  const created = await prisma.commissionType.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      description: data.description,
 | 
			
		||||
      options: {
 | 
			
		||||
        create: data.options?.map((opt, index) => ({
 | 
			
		||||
          option: { connect: { id: opt.optionId } },
 | 
			
		||||
          price: opt.price,
 | 
			
		||||
          pricePercent: opt.pricePercent,
 | 
			
		||||
          priceRange: opt.priceRange,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })) || [],
 | 
			
		||||
      },
 | 
			
		||||
      extras: {
 | 
			
		||||
        create: data.extras?.map((ext, index) => ({
 | 
			
		||||
          extra: { connect: { id: ext.extraId } },
 | 
			
		||||
          price: ext.price,
 | 
			
		||||
          pricePercent: ext.pricePercent,
 | 
			
		||||
          priceRange: ext.priceRange,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })) || [],
 | 
			
		||||
      },
 | 
			
		||||
      customInputs: {
 | 
			
		||||
        create: data.customInputs?.map((c, index) => ({
 | 
			
		||||
          customInput: { connect: { id: c.customInputId } },
 | 
			
		||||
          label: c.label,
 | 
			
		||||
          inputType: c.inputType,
 | 
			
		||||
          required: c.required,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })) || [],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return created
 | 
			
		||||
}
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function updateCommissionTypeSortOrder(
 | 
			
		||||
  ordered: { id: string; sortIndex: number }[]
 | 
			
		||||
) {
 | 
			
		||||
  const updates = ordered.map(({ id, sortIndex }) =>
 | 
			
		||||
    prisma.commissionType.update({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      data: { sortIndex },
 | 
			
		||||
    })
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  await Promise.all(updates)
 | 
			
		||||
}
 | 
			
		||||
@ -1,57 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma"
 | 
			
		||||
import { commissionTypeSchema } from "@/schemas/commissions/commissionType"
 | 
			
		||||
import * as z from "zod/v4"
 | 
			
		||||
 | 
			
		||||
export async function updateCommissionType(
 | 
			
		||||
  id: string,
 | 
			
		||||
  rawData: z.infer<typeof commissionTypeSchema>
 | 
			
		||||
) {
 | 
			
		||||
  const data = commissionTypeSchema.parse(rawData)
 | 
			
		||||
 | 
			
		||||
  const updated = await prisma.commissionType.update({
 | 
			
		||||
    where: { id },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      description: data.description,
 | 
			
		||||
      options: {
 | 
			
		||||
        deleteMany: {},
 | 
			
		||||
        create: data.options?.map((opt, index) => ({
 | 
			
		||||
          option: { connect: { id: opt.optionId } },
 | 
			
		||||
          price: opt.price ?? null,
 | 
			
		||||
          pricePercent: opt.pricePercent ?? null,
 | 
			
		||||
          priceRange: opt.priceRange ?? null,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })),
 | 
			
		||||
      },
 | 
			
		||||
      extras: {
 | 
			
		||||
        deleteMany: {},
 | 
			
		||||
        create: data.extras?.map((ext, index) => ({
 | 
			
		||||
          extra: { connect: { id: ext.extraId } },
 | 
			
		||||
          price: ext.price ?? null,
 | 
			
		||||
          pricePercent: ext.pricePercent ?? null,
 | 
			
		||||
          priceRange: ext.priceRange ?? null,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })),
 | 
			
		||||
      },
 | 
			
		||||
      customInputs: {
 | 
			
		||||
        deleteMany: {},
 | 
			
		||||
        create: data.customInputs?.map((c, index) => ({
 | 
			
		||||
          customInput: { connect: { id: c.customInputId } },
 | 
			
		||||
          label: c.label,
 | 
			
		||||
          inputType: c.inputType,
 | 
			
		||||
          required: c.required,
 | 
			
		||||
          sortIndex: index,
 | 
			
		||||
        })) || [],
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      options: true,
 | 
			
		||||
      extras: true,
 | 
			
		||||
      customInputs: true,
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return updated
 | 
			
		||||
}
 | 
			
		||||
@ -1,25 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from '@/lib/prisma';
 | 
			
		||||
import { artTypeSchema } from '@/schemas/artTypeSchema';
 | 
			
		||||
 | 
			
		||||
export async function createArtType(formData: artTypeSchema) {
 | 
			
		||||
  const parsed = artTypeSchema.safeParse(formData)
 | 
			
		||||
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    console.error("Validation failed", parsed.error)
 | 
			
		||||
    throw new Error("Invalid input")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = parsed.data
 | 
			
		||||
 | 
			
		||||
  const created = await prisma.portfolioArtType.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      slug: data.slug,
 | 
			
		||||
      description: data.description
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return created
 | 
			
		||||
}
 | 
			
		||||
@ -1,28 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from '@/lib/prisma';
 | 
			
		||||
import { artTypeSchema } from '@/schemas/artTypeSchema';
 | 
			
		||||
import { z } from 'zod/v4';
 | 
			
		||||
 | 
			
		||||
export async function updateArtType(id: string,
 | 
			
		||||
  rawData: z.infer<typeof artTypeSchema>) {
 | 
			
		||||
  const parsed = artTypeSchema.safeParse(rawData)
 | 
			
		||||
 | 
			
		||||
  if (!parsed.success) {
 | 
			
		||||
    console.error("Validation failed", parsed.error)
 | 
			
		||||
    throw new Error("Invalid input")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = parsed.data
 | 
			
		||||
 | 
			
		||||
  const updated = await prisma.portfolioArtType.update({
 | 
			
		||||
    where: { id },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: data.name,
 | 
			
		||||
      slug: data.slug,
 | 
			
		||||
      description: data.description
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return updated
 | 
			
		||||
}
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
'use server';
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { SortableItem } from "@/types/SortableItem";
 | 
			
		||||
 | 
			
		||||
export async function updateArtTypeSortOrder(items: SortableItem[]) {
 | 
			
		||||
  await Promise.all(
 | 
			
		||||
    items.map(item =>
 | 
			
		||||
      prisma.portfolioArtType.update({
 | 
			
		||||
        where: { id: item.id },
 | 
			
		||||
        data: { sortIndex: item.sortIndex },
 | 
			
		||||
      })
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,67 +0,0 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { s3 } from "@/lib/s3";
 | 
			
		||||
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
 | 
			
		||||
export async function deleteImage(imageId: string) {
 | 
			
		||||
  const image = await prisma.portfolioImage.findUnique({
 | 
			
		||||
    where: { id: imageId },
 | 
			
		||||
    include: {
 | 
			
		||||
      variants: true,
 | 
			
		||||
      colors: true,
 | 
			
		||||
      metadata: true,
 | 
			
		||||
      tags: true,
 | 
			
		||||
      categories: true,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (!image) throw new Error("Image not found");
 | 
			
		||||
 | 
			
		||||
  // Delete S3 objects
 | 
			
		||||
  for (const variant of image.variants) {
 | 
			
		||||
    try {
 | 
			
		||||
      await s3.send(
 | 
			
		||||
        new DeleteObjectCommand({
 | 
			
		||||
          Bucket: "gaertan",
 | 
			
		||||
          Key: variant.s3Key,
 | 
			
		||||
        })
 | 
			
		||||
      );
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.warn("Failed to delete S3 object: " + variant.s3Key + ". " + err);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Step 1: Delete join entries
 | 
			
		||||
  await prisma.imageColor.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Colors
 | 
			
		||||
  for (const color of image.colors) {
 | 
			
		||||
    const count = await prisma.imageColor.count({
 | 
			
		||||
      where: { colorId: color.colorId },
 | 
			
		||||
    });
 | 
			
		||||
    if (count === 0) {
 | 
			
		||||
      await prisma.color.delete({ where: { id: color.colorId } });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Delete variants
 | 
			
		||||
  await prisma.imageVariant.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Delete metadata
 | 
			
		||||
  await prisma.imageMetadata.deleteMany({ where: { imageId } });
 | 
			
		||||
 | 
			
		||||
  // Clean many-to-many tag/category joins
 | 
			
		||||
  await prisma.portfolioImage.update({
 | 
			
		||||
    where: { id: imageId },
 | 
			
		||||
    data: {
 | 
			
		||||
      tags: { set: [] },
 | 
			
		||||
      categories: { set: [] },
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Finally delete the image
 | 
			
		||||
  await prisma.portfolioImage.delete({ where: { id: imageId } });
 | 
			
		||||
 | 
			
		||||
  return { success: true };
 | 
			
		||||
}
 | 
			
		||||
@ -1,66 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
			
		||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
 | 
			
		||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
 | 
			
		||||
import { Vibrant } from "node-vibrant/node";
 | 
			
		||||
 | 
			
		||||
export async function generateImageColors(imageId: string, fileKey: string, fileType?: string) {
 | 
			
		||||
  const buffer = await getImageBufferFromS3(fileKey, fileType);
 | 
			
		||||
  const palette = await Vibrant.from(buffer).getPalette();
 | 
			
		||||
 | 
			
		||||
  const vibrantHexes = Object.entries(palette).map(([key, swatch]) => {
 | 
			
		||||
    const castSwatch = swatch as VibrantSwatch | null;
 | 
			
		||||
    const rgb = castSwatch?._rgb;
 | 
			
		||||
    const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined);
 | 
			
		||||
    return { type: key, hex };
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  for (const { type, hex } of vibrantHexes) {
 | 
			
		||||
    if (!hex) continue;
 | 
			
		||||
 | 
			
		||||
    const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16));
 | 
			
		||||
    const name = generateColorName(hex);
 | 
			
		||||
 | 
			
		||||
    const color = await prisma.color.upsert({
 | 
			
		||||
      where: { name },
 | 
			
		||||
      create: {
 | 
			
		||||
        name,
 | 
			
		||||
        type,
 | 
			
		||||
        hex,
 | 
			
		||||
        red: r,
 | 
			
		||||
        green: g,
 | 
			
		||||
        blue: b,
 | 
			
		||||
      },
 | 
			
		||||
      update: {
 | 
			
		||||
        hex,
 | 
			
		||||
        red: r,
 | 
			
		||||
        green: g,
 | 
			
		||||
        blue: b,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await prisma.imageColor.upsert({
 | 
			
		||||
      where: {
 | 
			
		||||
        imageId_type: {
 | 
			
		||||
          imageId,
 | 
			
		||||
          type,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      create: {
 | 
			
		||||
        imageId,
 | 
			
		||||
        colorId: color.id,
 | 
			
		||||
        type,
 | 
			
		||||
      },
 | 
			
		||||
      update: {
 | 
			
		||||
        colorId: color.id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
  }  
 | 
			
		||||
 | 
			
		||||
  return await prisma.imageColor.findMany({
 | 
			
		||||
    where: { imageId },
 | 
			
		||||
    include: { color: true },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
@ -1,74 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { imageSchema } from "@/schemas/imageSchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function updateImage(
 | 
			
		||||
  values: z.infer<typeof imageSchema>, 
 | 
			
		||||
  id: string
 | 
			
		||||
) {
 | 
			
		||||
  const validated = imageSchema.safeParse(values);
 | 
			
		||||
  if (!validated.success) {
 | 
			
		||||
    throw new Error("Invalid image data");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    fileKey,
 | 
			
		||||
    originalFile,
 | 
			
		||||
    nsfw,
 | 
			
		||||
    published,
 | 
			
		||||
    altText,
 | 
			
		||||
    description,
 | 
			
		||||
    fileType,
 | 
			
		||||
    name,
 | 
			
		||||
    slug,
 | 
			
		||||
    type,
 | 
			
		||||
    fileSize,
 | 
			
		||||
    creationDate,
 | 
			
		||||
    tagIds,
 | 
			
		||||
    categoryIds
 | 
			
		||||
  } = validated.data;
 | 
			
		||||
 | 
			
		||||
  const updatedImage = await prisma.portfolioImage.update({
 | 
			
		||||
    where: { id: id },
 | 
			
		||||
    data: {
 | 
			
		||||
      fileKey,
 | 
			
		||||
      originalFile,
 | 
			
		||||
      nsfw,
 | 
			
		||||
      published,
 | 
			
		||||
      altText,
 | 
			
		||||
      description,
 | 
			
		||||
      fileType,
 | 
			
		||||
      name,
 | 
			
		||||
      slug,
 | 
			
		||||
      type,
 | 
			
		||||
      fileSize,
 | 
			
		||||
      creationDate,
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (tagIds) {
 | 
			
		||||
    await prisma.portfolioImage.update({
 | 
			
		||||
      where: { id: id },
 | 
			
		||||
      data: {
 | 
			
		||||
        tags: {
 | 
			
		||||
          set: tagIds.map(id => ({ id }))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (categoryIds) {
 | 
			
		||||
    await prisma.portfolioImage.update({
 | 
			
		||||
      where: { id: id },
 | 
			
		||||
      data: {
 | 
			
		||||
        categories: {
 | 
			
		||||
          set: categoryIds.map(id => ({ id }))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return updatedImage
 | 
			
		||||
}
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
'use server';
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { SortableItem } from "@/types/SortableItem";
 | 
			
		||||
 | 
			
		||||
export async function updateImageSortOrder(items: SortableItem[]) {
 | 
			
		||||
  await Promise.all(
 | 
			
		||||
    items.map(item =>
 | 
			
		||||
      prisma.portfolioImage.update({
 | 
			
		||||
        where: { id: item.id },
 | 
			
		||||
        data: { sortIndex: item.sortIndex },
 | 
			
		||||
      })
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -1,190 +0,0 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { s3 } from "@/lib/s3";
 | 
			
		||||
import { imageUploadSchema } from "@/schemas/imageSchema";
 | 
			
		||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
 | 
			
		||||
import sharp from "sharp";
 | 
			
		||||
import { v4 as uuidv4 } from 'uuid';
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
 | 
			
		||||
  const imageFile = values.file[0];
 | 
			
		||||
 | 
			
		||||
  if (!(imageFile instanceof File)) {
 | 
			
		||||
    console.log("No image or invalid type");
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const fileName = imageFile.name;
 | 
			
		||||
  const fileType = imageFile.type;
 | 
			
		||||
  const fileSize = imageFile.size;
 | 
			
		||||
  const lastModified = new Date(imageFile.lastModified);
 | 
			
		||||
 | 
			
		||||
  const fileKey = uuidv4();
 | 
			
		||||
 | 
			
		||||
  const arrayBuffer = await imageFile.arrayBuffer();
 | 
			
		||||
  const buffer = Buffer.from(arrayBuffer);
 | 
			
		||||
 | 
			
		||||
  const realFileType = fileType.split("/")[1];
 | 
			
		||||
  const originalKey = `original/${fileKey}.${realFileType}`;
 | 
			
		||||
  const modifiedKey = `modified/${fileKey}.webp`;
 | 
			
		||||
  const resizedKey = `resized/${fileKey}.webp`;
 | 
			
		||||
  const thumbnailKey = `thumbnail/${fileKey}.webp`;
 | 
			
		||||
 | 
			
		||||
  const sharpData = sharp(buffer);
 | 
			
		||||
  const metadata = await sharpData.metadata();
 | 
			
		||||
  
 | 
			
		||||
  //--- Original file
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "gaertan",
 | 
			
		||||
      Key: originalKey,
 | 
			
		||||
      Body: buffer,
 | 
			
		||||
      ContentType: "image/" + metadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Modified file
 | 
			
		||||
  const modifiedBuffer = await sharp(buffer)
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer()
 | 
			
		||||
  const modifiedMetadata = await sharp(modifiedBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "gaertan",
 | 
			
		||||
      Key: modifiedKey,
 | 
			
		||||
      Body: modifiedBuffer,
 | 
			
		||||
      ContentType: "image/" + modifiedMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Resized file
 | 
			
		||||
  const { width, height } = modifiedMetadata;
 | 
			
		||||
  const targetSize = 400;
 | 
			
		||||
  let resizeOptions;
 | 
			
		||||
  if (width && height) {
 | 
			
		||||
    if (height < width) {
 | 
			
		||||
      resizeOptions = { height: targetSize };
 | 
			
		||||
    } else {
 | 
			
		||||
      resizeOptions = { width: targetSize };
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    resizeOptions = { height: targetSize };
 | 
			
		||||
  }
 | 
			
		||||
  const resizedBuffer = await sharp(modifiedBuffer)
 | 
			
		||||
    .resize({ ...resizeOptions, withoutEnlargement: true })
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer();
 | 
			
		||||
  const resizedMetadata = await sharp(resizedBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "gaertan",
 | 
			
		||||
      Key: resizedKey,
 | 
			
		||||
      Body: resizedBuffer,
 | 
			
		||||
      ContentType: "image/" + resizedMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
  //--- Thumbnail file
 | 
			
		||||
  // const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
 | 
			
		||||
  const thumbnailTargetSize = 160;
 | 
			
		||||
  let thumbnailOptions;
 | 
			
		||||
  if (width && height) {
 | 
			
		||||
    if (height < width) {
 | 
			
		||||
      thumbnailOptions = { height: thumbnailTargetSize };
 | 
			
		||||
    } else {
 | 
			
		||||
      thumbnailOptions = { width: thumbnailTargetSize };
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    thumbnailOptions = { height: thumbnailTargetSize };
 | 
			
		||||
  }
 | 
			
		||||
  const thumbnailBuffer = await sharp(modifiedBuffer)
 | 
			
		||||
    .resize({ ...thumbnailOptions, withoutEnlargement: true })
 | 
			
		||||
    .toFormat('webp')
 | 
			
		||||
    .toBuffer();
 | 
			
		||||
  const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
 | 
			
		||||
  await s3.send(
 | 
			
		||||
    new PutObjectCommand({
 | 
			
		||||
      Bucket: "gaertan",
 | 
			
		||||
      Key: thumbnailKey,
 | 
			
		||||
      Body: thumbnailBuffer,
 | 
			
		||||
      ContentType: "image/" + thumbnailMetadata.format,
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const image = await prisma.portfolioImage.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: fileName,
 | 
			
		||||
      fileKey,
 | 
			
		||||
      originalFile: fileName,
 | 
			
		||||
      creationDate: lastModified,
 | 
			
		||||
      fileType: fileType,
 | 
			
		||||
      fileSize: fileSize
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await prisma.imageMetadata.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      imageId: image.id,
 | 
			
		||||
      format: metadata.format || "unknown",
 | 
			
		||||
      width: metadata.width || 0,
 | 
			
		||||
      height: metadata.height || 0,
 | 
			
		||||
      space: metadata.space || "unknown",
 | 
			
		||||
      channels: metadata.channels || 0,
 | 
			
		||||
      depth: metadata.depth || "unknown",
 | 
			
		||||
      density: metadata.density ?? undefined,
 | 
			
		||||
      bitsPerSample: metadata.bitsPerSample ?? undefined,
 | 
			
		||||
      isProgressive: metadata.isProgressive ?? undefined,
 | 
			
		||||
      isPalette: metadata.isPalette ?? undefined,
 | 
			
		||||
      hasProfile: metadata.hasProfile ?? undefined,
 | 
			
		||||
      hasAlpha: metadata.hasAlpha ?? undefined,
 | 
			
		||||
      autoOrientW: metadata.autoOrient?.width ?? undefined,
 | 
			
		||||
      autoOrientH: metadata.autoOrient?.height ?? undefined,
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  await prisma.imageVariant.createMany({
 | 
			
		||||
    data: [
 | 
			
		||||
      {
 | 
			
		||||
        s3Key: originalKey,
 | 
			
		||||
        type: "original",
 | 
			
		||||
        height: metadata.height,
 | 
			
		||||
        width: metadata.width,
 | 
			
		||||
        fileExtension: metadata.format,
 | 
			
		||||
        mimeType: "image/" + metadata.format,
 | 
			
		||||
        sizeBytes: metadata.size,
 | 
			
		||||
        imageId: image.id
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        s3Key: modifiedKey,
 | 
			
		||||
        type: "modified",
 | 
			
		||||
        height: modifiedMetadata.height,
 | 
			
		||||
        width: modifiedMetadata.width,
 | 
			
		||||
        fileExtension: modifiedMetadata.format,
 | 
			
		||||
        mimeType: "image/" + modifiedMetadata.format,
 | 
			
		||||
        sizeBytes: modifiedMetadata.size,
 | 
			
		||||
        imageId: image.id
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        s3Key: resizedKey,
 | 
			
		||||
        type: "resized",
 | 
			
		||||
        height: resizedMetadata.height,
 | 
			
		||||
        width: resizedMetadata.width,
 | 
			
		||||
        fileExtension: resizedMetadata.format,
 | 
			
		||||
        mimeType: "image/" + resizedMetadata.format,
 | 
			
		||||
        sizeBytes: resizedMetadata.size,
 | 
			
		||||
        imageId: image.id
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        s3Key: thumbnailKey,
 | 
			
		||||
        type: "thumbnail",
 | 
			
		||||
        height: thumbnailMetadata.height,
 | 
			
		||||
        width: thumbnailMetadata.width,
 | 
			
		||||
        fileExtension: thumbnailMetadata.format,
 | 
			
		||||
        mimeType: "image/" + thumbnailMetadata.format,
 | 
			
		||||
        sizeBytes: thumbnailMetadata.size,
 | 
			
		||||
        imageId: image.id
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return image
 | 
			
		||||
}
 | 
			
		||||
@ -16,14 +16,16 @@ export async function updateImage(
 | 
			
		||||
  const {
 | 
			
		||||
    fileKey,
 | 
			
		||||
    originalFile,
 | 
			
		||||
    name,
 | 
			
		||||
    nsfw,
 | 
			
		||||
    published,
 | 
			
		||||
    setAsHeader,
 | 
			
		||||
    altText,
 | 
			
		||||
    description,
 | 
			
		||||
    fileType,
 | 
			
		||||
    name,
 | 
			
		||||
    fileSize,
 | 
			
		||||
    month,
 | 
			
		||||
    year,
 | 
			
		||||
    creationDate,
 | 
			
		||||
    typeId,
 | 
			
		||||
    tagIds,
 | 
			
		||||
@ -43,14 +45,16 @@ export async function updateImage(
 | 
			
		||||
    data: {
 | 
			
		||||
      fileKey,
 | 
			
		||||
      originalFile,
 | 
			
		||||
      name,
 | 
			
		||||
      nsfw,
 | 
			
		||||
      published,
 | 
			
		||||
      setAsHeader,
 | 
			
		||||
      altText,
 | 
			
		||||
      description,
 | 
			
		||||
      fileType,
 | 
			
		||||
      name,
 | 
			
		||||
      fileSize,
 | 
			
		||||
      month,
 | 
			
		||||
      year,
 | 
			
		||||
      creationDate,
 | 
			
		||||
      typeId
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { AdvancedMosaicGallery } from "@/components/portfolio/images/AdvancedMosaicGallery";
 | 
			
		||||
import FilterBar from "@/components/portfolio/images/FilterBar";
 | 
			
		||||
import ImageGallery from "@/components/portfolio/images/ImageGallery";
 | 
			
		||||
import { Prisma } from "@/generated/prisma";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { PlusCircleIcon } from "lucide-react";
 | 
			
		||||
@ -79,7 +79,6 @@ export default async function PortfolioImagesPage({
 | 
			
		||||
          <PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <FilterBar
 | 
			
		||||
        types={types}
 | 
			
		||||
        albums={albums}
 | 
			
		||||
@ -90,14 +89,19 @@ export default async function PortfolioImagesPage({
 | 
			
		||||
        groupId={groupId}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="mt-6">
 | 
			
		||||
        {images && images.length > 0 ? <AdvancedMosaicGallery
 | 
			
		||||
        {/* {images && images.length > 0 ? <MosaicGallery
 | 
			
		||||
          images={images.map((img) => ({
 | 
			
		||||
            ...img,
 | 
			
		||||
            width: 400,
 | 
			
		||||
            height: 300,
 | 
			
		||||
          }))}
 | 
			
		||||
        /> : <p className="text-muted-foreground italic">No images found.</p>}
 | 
			
		||||
        /> : <p className="text-muted-foreground italic">No images found.</p>} */}
 | 
			
		||||
        {images && images.length > 0 ?
 | 
			
		||||
          <ImageGallery images={images} />
 | 
			
		||||
          :
 | 
			
		||||
          <p className="text-muted-foreground italic">No images found.</p>
 | 
			
		||||
        }
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    </div >
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								src/app/portfolio/images/sort/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/app/portfolio/images/sort/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,73 @@
 | 
			
		||||
import ImageSortGallery from "@/components/portfolio/images/ImageSortGallery";
 | 
			
		||||
import { Prisma } from "@/generated/prisma";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function PortfolioImagesSortPage({
 | 
			
		||||
  searchParams
 | 
			
		||||
}: {
 | 
			
		||||
  searchParams?: {
 | 
			
		||||
    type?: string;
 | 
			
		||||
    published?: string;
 | 
			
		||||
    groupBy?: string;
 | 
			
		||||
    year?: string;
 | 
			
		||||
    album?: string;
 | 
			
		||||
  }
 | 
			
		||||
}) {
 | 
			
		||||
  const {
 | 
			
		||||
    type = "all",
 | 
			
		||||
    published = "all",
 | 
			
		||||
    groupBy = "year",
 | 
			
		||||
    year,
 | 
			
		||||
    album,
 | 
			
		||||
  } = await searchParams ?? {};
 | 
			
		||||
 | 
			
		||||
  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' }],
 | 
			
		||||
    }
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -128,7 +128,14 @@ export default function EditImageForm({ image, categories, tags, types }:
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Creation Month</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input {...field} type="number" />
 | 
			
		||||
                  <Input
 | 
			
		||||
                    {...field}
 | 
			
		||||
                    type="number"
 | 
			
		||||
                    value={field.value ?? ''}
 | 
			
		||||
                    onChange={(e) =>
 | 
			
		||||
                      field.onChange(e.target.value === '' ? undefined : +e.target.value)
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
@ -141,7 +148,14 @@ export default function EditImageForm({ image, categories, tags, types }:
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Creation Year</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input {...field} type="number" />
 | 
			
		||||
                  <Input
 | 
			
		||||
                    {...field}
 | 
			
		||||
                    type="number"
 | 
			
		||||
                    value={field.value ?? ''}
 | 
			
		||||
                    onChange={(e) =>
 | 
			
		||||
                      field.onChange(e.target.value === '' ? undefined : +e.target.value)
 | 
			
		||||
                    }
 | 
			
		||||
                  />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
"use client";
 | 
			
		||||
 | 
			
		||||
import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
 | 
			
		||||
import { SortAscIcon } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
 | 
			
		||||
 | 
			
		||||
type FilterBarProps = {
 | 
			
		||||
@ -25,10 +27,9 @@ export default function FilterBar({
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const pathname = usePathname();
 | 
			
		||||
  const searchParams = useSearchParams();
 | 
			
		||||
  const params = new URLSearchParams(searchParams);
 | 
			
		||||
 | 
			
		||||
  const setFilter = (key: string, value: string) => {
 | 
			
		||||
    const params = new URLSearchParams(searchParams);
 | 
			
		||||
 | 
			
		||||
    if (value !== "all") {
 | 
			
		||||
      params.set(key, value);
 | 
			
		||||
    } else {
 | 
			
		||||
@ -44,96 +45,108 @@ export default function FilterBar({
 | 
			
		||||
    router.push(`${pathname}?${params.toString()}`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const sortHref = `${pathname}/sort?${params.toString()}`;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-6 border-b pb-6">
 | 
			
		||||
      {/* GroupBy Toggle */}
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">Group by:</span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={groupBy === "year"}
 | 
			
		||||
          label="Year"
 | 
			
		||||
          onClick={() => setFilter("groupBy", "year")}
 | 
			
		||||
        />
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={groupBy === "album"}
 | 
			
		||||
          label="Album"
 | 
			
		||||
          onClick={() => setFilter("groupBy", "album")}
 | 
			
		||||
        />
 | 
			
		||||
    <div>
 | 
			
		||||
      <div>
 | 
			
		||||
        <div className="flex justify-end">
 | 
			
		||||
          <Link href={sortHref} className="flex gap-2 items-center cursor-pointer bg-secondary hover:bg-secondary/90 text-secondary-foreground px-4 py-2 rounded">
 | 
			
		||||
            <SortAscIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Sort images
 | 
			
		||||
          </Link>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Subnavigation for Year or Album */}
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">
 | 
			
		||||
          {groupBy === "year" ? "Year:" : "Album:"}
 | 
			
		||||
        </span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={groupId === "all"}
 | 
			
		||||
          label="All"
 | 
			
		||||
          onClick={() => setFilter(groupBy, "all")}
 | 
			
		||||
        />
 | 
			
		||||
        {groupBy === "year" &&
 | 
			
		||||
          years.map((year) => (
 | 
			
		||||
            <FilterButton
 | 
			
		||||
              key={year}
 | 
			
		||||
              active={groupId === String(year)}
 | 
			
		||||
              label={String(year)}
 | 
			
		||||
              onClick={() => setFilter("year", String(year))}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
        {groupBy === "album" &&
 | 
			
		||||
          albums.map((album) => (
 | 
			
		||||
            <FilterButton
 | 
			
		||||
              key={album.id}
 | 
			
		||||
              active={groupId === album.id}
 | 
			
		||||
              label={album.name}
 | 
			
		||||
              onClick={() => setFilter("album", album.id)}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* Type Filter */}
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">Type:</span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={currentType === "all"}
 | 
			
		||||
          label="All"
 | 
			
		||||
          onClick={() => setFilter("type", "all")}
 | 
			
		||||
        />
 | 
			
		||||
        {types.map((type) => (
 | 
			
		||||
      <div className="flex gap-6 pb-6">
 | 
			
		||||
        {/* GroupBy Toggle */}
 | 
			
		||||
        <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
          <span className="text-sm font-medium text-muted-foreground">Group by:</span>
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            key={type.id}
 | 
			
		||||
            active={currentType === type.id}
 | 
			
		||||
            label={type.name}
 | 
			
		||||
            onClick={() => setFilter("type", type.id)}
 | 
			
		||||
            active={groupBy === "year"}
 | 
			
		||||
            label="Year"
 | 
			
		||||
            onClick={() => setFilter("groupBy", "year")}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={currentType === "none"}
 | 
			
		||||
          label="No Type"
 | 
			
		||||
          onClick={() => setFilter("type", "none")}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={groupBy === "album"}
 | 
			
		||||
            label="Album"
 | 
			
		||||
            onClick={() => setFilter("groupBy", "album")}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        {/* Type Filter */}
 | 
			
		||||
        <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
          <span className="text-sm font-medium text-muted-foreground">Type:</span>
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={currentType === "all"}
 | 
			
		||||
            label="All"
 | 
			
		||||
            onClick={() => setFilter("type", "all")}
 | 
			
		||||
          />
 | 
			
		||||
          {types.map((type) => (
 | 
			
		||||
            <FilterButton
 | 
			
		||||
              key={type.id}
 | 
			
		||||
              active={currentType === type.id}
 | 
			
		||||
              label={type.name}
 | 
			
		||||
              onClick={() => setFilter("type", type.id)}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={currentType === "none"}
 | 
			
		||||
            label="No Type"
 | 
			
		||||
            onClick={() => setFilter("type", "none")}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      {/* Published Filter */}
 | 
			
		||||
      <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
        <span className="text-sm font-medium text-muted-foreground">Status:</span>
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={currentPublished === "all"}
 | 
			
		||||
          label="All"
 | 
			
		||||
          onClick={() => setFilter("published", "all")}
 | 
			
		||||
        />
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={currentPublished === "published"}
 | 
			
		||||
          label="Published"
 | 
			
		||||
          onClick={() => setFilter("published", "published")}
 | 
			
		||||
        />
 | 
			
		||||
        <FilterButton
 | 
			
		||||
          active={currentPublished === "unpublished"}
 | 
			
		||||
          label="Unpublished"
 | 
			
		||||
          onClick={() => setFilter("published", "unpublished")}
 | 
			
		||||
        />
 | 
			
		||||
        {/* Published Filter */}
 | 
			
		||||
        <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
          <span className="text-sm font-medium text-muted-foreground">Status:</span>
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={currentPublished === "all"}
 | 
			
		||||
            label="All"
 | 
			
		||||
            onClick={() => setFilter("published", "all")}
 | 
			
		||||
          />
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={currentPublished === "published"}
 | 
			
		||||
            label="Published"
 | 
			
		||||
            onClick={() => setFilter("published", "published")}
 | 
			
		||||
          />
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={currentPublished === "unpublished"}
 | 
			
		||||
            label="Unpublished"
 | 
			
		||||
            onClick={() => setFilter("published", "unpublished")}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className="flex gap-6 border-b pb-6">
 | 
			
		||||
        {/* Subnavigation for Year or Album */}
 | 
			
		||||
        <div className="flex gap-2 items-center flex-wrap">
 | 
			
		||||
          <span className="text-sm font-medium text-muted-foreground">
 | 
			
		||||
            {groupBy === "year" ? "Year:" : "Album:"}
 | 
			
		||||
          </span>
 | 
			
		||||
          <FilterButton
 | 
			
		||||
            active={groupId === "all"}
 | 
			
		||||
            label="All"
 | 
			
		||||
            onClick={() => setFilter(groupBy, "all")}
 | 
			
		||||
          />
 | 
			
		||||
          {groupBy === "year" &&
 | 
			
		||||
            years.map((year) => (
 | 
			
		||||
              <FilterButton
 | 
			
		||||
                key={year}
 | 
			
		||||
                active={groupId === String(year)}
 | 
			
		||||
                label={String(year)}
 | 
			
		||||
                onClick={() => setFilter("year", String(year))}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          {groupBy === "album" &&
 | 
			
		||||
            albums.map((album) => (
 | 
			
		||||
              <FilterButton
 | 
			
		||||
                key={album.id}
 | 
			
		||||
                active={groupId === album.id}
 | 
			
		||||
                label={album.name}
 | 
			
		||||
                onClick={() => setFilter("album", album.id)}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -150,8 +163,8 @@ function FilterButton({
 | 
			
		||||
    <button
 | 
			
		||||
      onClick={onClick}
 | 
			
		||||
      className={`px-3 py-1 rounded text-sm border transition ${active
 | 
			
		||||
          ? "bg-primary text-white border-primary"
 | 
			
		||||
          : "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
 | 
			
		||||
        ? "bg-primary text-white border-primary"
 | 
			
		||||
        : "bg-muted text-muted-foreground hover:bg-muted/70 border-muted"
 | 
			
		||||
        }`}
 | 
			
		||||
    >
 | 
			
		||||
      {label}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										49
									
								
								src/components/portfolio/images/ImageGallery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/components/portfolio/images/ImageGallery.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,49 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { PortfolioImage } from "@/generated/prisma";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
import Image from "next/image";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function ImageGallery({ images }: { images: PortfolioImage[] }) {
 | 
			
		||||
  console.log(images);
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full flex flex-col gap-4">
 | 
			
		||||
      <div
 | 
			
		||||
        className="flex flex-wrap gap-4"
 | 
			
		||||
      >
 | 
			
		||||
        {images.map((image) => (
 | 
			
		||||
          <div key={image.id} style={{ width: 200, height: 200 }}>
 | 
			
		||||
            <Link href={`/portfolio/images/${image.id}`}>
 | 
			
		||||
              <div
 | 
			
		||||
                className={cn(
 | 
			
		||||
                  "overflow-hidden transition-all duration-100",
 | 
			
		||||
                  "w-full h-full",
 | 
			
		||||
                  "hover:border-2 border-transparent"
 | 
			
		||||
                )}
 | 
			
		||||
                style={{
 | 
			
		||||
                  '--tw-border-opacity': 1,
 | 
			
		||||
                } as React.CSSProperties}
 | 
			
		||||
              >
 | 
			
		||||
                <div
 | 
			
		||||
                  className={cn(
 | 
			
		||||
                    "relative w-full h-full"
 | 
			
		||||
                  )}
 | 
			
		||||
                >
 | 
			
		||||
                  <Image
 | 
			
		||||
                    src={`/api/image/thumbnail/${image.fileKey}.webp`}
 | 
			
		||||
                    alt={image.altText ?? image.name ?? "Image"}
 | 
			
		||||
                    fill
 | 
			
		||||
                    className={cn("object-cover"
 | 
			
		||||
                    )}
 | 
			
		||||
                    loading="lazy"
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </Link>
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										207
									
								
								src/components/portfolio/images/ImageSortGallery.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										207
									
								
								src/components/portfolio/images/ImageSortGallery.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,207 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { PortfolioImage } from "@/generated/prisma"
 | 
			
		||||
import {
 | 
			
		||||
  closestCenter,
 | 
			
		||||
  DndContext,
 | 
			
		||||
  DragEndEvent,
 | 
			
		||||
  useDroppable,
 | 
			
		||||
} from "@dnd-kit/core"
 | 
			
		||||
import {
 | 
			
		||||
  arrayMove,
 | 
			
		||||
  rectSortingStrategy,
 | 
			
		||||
  SortableContext,
 | 
			
		||||
  useSortable
 | 
			
		||||
} from "@dnd-kit/sortable"
 | 
			
		||||
import { CSS } from "@dnd-kit/utilities"
 | 
			
		||||
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: [],
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  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])
 | 
			
		||||
 | 
			
		||||
  function handleDragEnd(event: DragEndEvent) {
 | 
			
		||||
    const { active, over } = event;
 | 
			
		||||
    if (!over) return;
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
    // 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;
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
      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
 | 
			
		||||
        }];
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const savePositions = async () => {
 | 
			
		||||
    await fetch("/api/images", {
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: { "Content-Type": "application/json" },
 | 
			
		||||
      body: JSON.stringify(items),
 | 
			
		||||
    })
 | 
			
		||||
    alert("Positions saved successfully!")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
            <SortableContext
 | 
			
		||||
              items={items[group].map((i) => i.id)}
 | 
			
		||||
              strategy={rectSortingStrategy}
 | 
			
		||||
            >
 | 
			
		||||
              <DroplayoutGroup id={group}>
 | 
			
		||||
                {items[group].map((item) => (
 | 
			
		||||
                  <DraggableImage key={item.id} id={item.id} fileKey={item.fileKey} />
 | 
			
		||||
                ))}
 | 
			
		||||
              </DroplayoutGroup>
 | 
			
		||||
            </SortableContext>
 | 
			
		||||
          </div>
 | 
			
		||||
        ))}
 | 
			
		||||
      </DndContext>
 | 
			
		||||
 | 
			
		||||
      <button
 | 
			
		||||
        onClick={savePositions}
 | 
			
		||||
        className="mt-4 px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700"
 | 
			
		||||
      >
 | 
			
		||||
        Save Positions
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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' : ''}`}
 | 
			
		||||
    >
 | 
			
		||||
      {React.Children.count(children) === 0 ? (
 | 
			
		||||
        <p className="text-gray-400">Drop images here</p>
 | 
			
		||||
      ) : (
 | 
			
		||||
        children
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DraggableImage({ id, fileKey }: { id: string; fileKey: string }) {
 | 
			
		||||
  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}
 | 
			
		||||
      style={style}
 | 
			
		||||
      {...attributes}
 | 
			
		||||
      {...listeners}
 | 
			
		||||
      className="w-[100px] h-[100px] border rounded overflow-hidden"
 | 
			
		||||
    >
 | 
			
		||||
      <Image
 | 
			
		||||
        src={`/api/image/thumbnail/${fileKey}.webp`}
 | 
			
		||||
        alt=""
 | 
			
		||||
        className="w-full h-full object-cover"
 | 
			
		||||
        width={100}
 | 
			
		||||
        height={100}
 | 
			
		||||
        draggable={false}
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user