Refactor and add NSFW boolean

This commit is contained in:
2025-06-28 18:13:21 +02:00
parent 50617e5578
commit 8856ffb71f
21 changed files with 444 additions and 17 deletions

30
package-lock.json generated
View File

@ -20,6 +20,7 @@
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -2792,6 +2793,35 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz",
"integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",

View File

@ -21,6 +21,7 @@
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "nsfw" BOOLEAN NOT NULL DEFAULT false;

View File

@ -112,6 +112,7 @@ model Image {
imageName String
originalFile String
uploadDate DateTime @default(now())
nsfw Boolean @default(false)
altText String?
description String?

View File

@ -17,10 +17,11 @@ export async function updateImage(
imageName,
originalFile,
uploadDate,
nsfw,
altText,
description,
fileType,
imageData,
source,
creationMonth,
creationYear,
fileSize,
@ -37,10 +38,11 @@ export async function updateImage(
imageName,
originalFile,
uploadDate,
nsfw,
altText,
description,
fileType,
imageData,
source,
creationMonth,
creationYear,
fileSize,

View File

@ -0,0 +1,30 @@
import DisplayColor from "@/components/colors/single/DisplayColor";
import prisma from "@/lib/prisma";
export default async function ColorsPage({ params }: { params: { id: string } }) {
const { id } = await params;
const colorData = await prisma.color.findUnique({
where: {
id,
},
include: {
images: {
include: {
image: {
include: {
variants: { where: { type: "thumbnail" } }
}
}
}
}
}
});
return (
<div>
<h1 className="text-2xl font-bold mb-4">Show color</h1>
{colorData ? <DisplayColor color={colorData} /> : 'Color not found...'}
</div>
);
}

22
src/app/colors/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import ListColors from "@/components/colors/list/ListColors";
import prisma from "@/lib/prisma";
export default async function ColorsPage() {
const colors = await prisma.color.findMany(
{
orderBy: { name: "asc" },
include: {
images: { select: { id: true } }
}
}
);
return (
<div>
<div className="flex gap-4 justify-between">
<h1 className="text-2xl font-bold mb-4">Colors</h1>
</div>
{colors.length > 0 ? <ListColors colors={colors} /> : <p className="text-muted-foreground italic">No colors found.</p>}
</div>
);
}

View File

@ -0,0 +1,30 @@
import DisplayExtractColor from "@/components/extract/single/DisplayExtractColor";
import prisma from "@/lib/prisma";
export default async function ColorsPage({ params }: { params: { id: string } }) {
const { id } = await params;
const colorData = await prisma.extractColor.findUnique({
where: {
id,
},
include: {
images: {
include: {
image: {
include: {
variants: { where: { type: "thumbnail" } }
}
}
}
}
}
});
return (
<div>
<h1 className="text-2xl font-bold mb-4">Show color</h1>
{colorData ? <DisplayExtractColor color={colorData} /> : 'Color not found...'}
</div>
);
}

22
src/app/extract/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import ListExtractColors from "@/components/extract/list/ListExtractColors";
import prisma from "@/lib/prisma";
export default async function ExtractColorsPage() {
const extractColors = await prisma.extractColor.findMany(
{
orderBy: { name: "asc" },
include: {
images: { select: { id: true } }
}
}
);
return (
<div>
<div className="flex gap-4 justify-between">
<h1 className="text-2xl font-bold mb-4">Extract colors</h1>
</div>
{extractColors.length > 0 ? <ListExtractColors colors={extractColors} /> : <p className="text-muted-foreground italic">No colors found.</p>}
</div>
);
}

View File

@ -4,11 +4,11 @@ import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function ImagesPage() {
const images = await prisma.image.findMany({ orderBy: { createdAt: "asc" } });
const images = await prisma.image.findMany({ orderBy: { imageName: "asc" } });
return (
<div>
<div className="flex gap-4 justify-between">
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Images</h1>
<Link href="/images/upload" className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded">
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Upload new image

View File

@ -0,0 +1,35 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Color } from "@/generated/prisma";
import Link from "next/link";
type ColorsWithItems = Color & {
images: { id: string }[]
}
export default function ListColors({ colors }: { colors: ColorsWithItems[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{colors.map((col) => (
<Link href={`/colors/${col.id}`} key={col.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{col.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<div
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: col.hex ?? "#ccc" }}
title={col.hex ?? "n/a"}
/>
</div>
</CardContent>
<CardFooter>
Used by {col.images.length} image{col.images.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,41 @@
import ImageColorCard from "@/components/images/ImageColorCard";
import { Color, Image, ImageColor, ImageVariant } from "@/generated/prisma";
type ColorWithItems = Color & {
images: (ImageColor & {
image: Image & {
variants: ImageVariant[]
}
})[]
}
export default function DisplayColor({ color }: { color: ColorWithItems }) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{color.name}</h1>
</div>
<div className="flex flex-wrap gap-2">
<div
key={color.id}
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: color.hex ?? "#ccc" }}
title={color.hex ?? "n/a"}
/>
</div>
<div>
<h2 className="text-xl font-semibold">Used by {color.images.length} image(s)</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{color.images.map((image) => (
<ImageColorCard key={image.id} image={image} />
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { ExtractColor } from "@/generated/prisma";
import Link from "next/link";
type ColorsWithItems = ExtractColor & {
images: { id: string }[]
}
export default function ListExtractColors({ colors }: { colors: ColorsWithItems[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{colors.map((col) => (
<Link href={`/extract/${col.id}`} key={col.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{col.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<div
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: col.hex ?? "#ccc" }}
title={col.hex ?? "n/a"}
/>
</div>
</CardContent>
<CardFooter>
Used by {col.images.length} image{col.images.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
);
}

View File

@ -0,0 +1,41 @@
import ImageExtractColorCard from "@/components/images/ImageExtractColorCard";
import { ExtractColor, Image, ImageExtractColor, ImageVariant } from "@/generated/prisma";
type ColorWithItems = ExtractColor & {
images: (ImageExtractColor & {
image: Image & {
variants: ImageVariant[]
}
})[]
}
export default function DisplayExtractColor({ color }: { color: ColorWithItems }) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{color.name}</h1>
</div>
<div className="flex flex-wrap gap-2">
<div
key={color.id}
className="w-10 h-10 rounded-full border"
style={{ backgroundColor: color.hex ?? "#ccc" }}
title={color.hex ?? "n/a"}
/>
</div>
<div>
<h2 className="text-xl font-semibold">Used by {color.images.length} image(s)</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-2">
{color.images.map((image) => (
<ImageExtractColorCard key={image.id} image={image} />
))}
</div>
</div>
</div>
);
}

View File

@ -47,6 +47,11 @@ export default function TopNav() {
<Link href="/colors">Colors</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/extract">Extract-Colors</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link href="/images">Images</Link>

View File

@ -0,0 +1,39 @@
"use client";
import { Image, ImageColor, ImageVariant } from "@/generated/prisma";
import ImageComponent from "next/image";
import Link from "next/link";
type ImagePaletteWithItems = {
image: ImageColor & {
image: Image & {
variants: ImageVariant[];
}
};
};
export default function ImageColorCard({ image }: ImagePaletteWithItems) {
const thumbnail = image.image.variants.find((v) => v.type === "thumbnail");
return (
<Link
href={`/images/${image.image.id}`}
className="block overflow-hidden rounded-md border shadow-sm hover:shadow-md transition-shadow"
>
{thumbnail?.s3Key ? (
<ImageComponent
src={`/api/image/${thumbnail.s3Key}`}
alt={image.image.altText || image.image.imageName}
width={thumbnail.width}
height={thumbnail.height}
className="w-full h-auto object-cover"
/>
) : (
<div className="w-full h-48 bg-gray-100 flex items-center justify-center text-muted-foreground text-sm">
No Thumbnail
</div>
)}
<div className="p-2 text-sm truncate">{image.image.imageName} ({image.type})</div>
</Link>
);
}

View File

@ -0,0 +1,39 @@
"use client";
import { Image, ImageExtractColor, ImageVariant } from "@/generated/prisma";
import ImageComponent from "next/image";
import Link from "next/link";
type ImagePaletteWithItems = {
image: ImageExtractColor & {
image: Image & {
variants: ImageVariant[];
}
};
};
export default function ImageExtractColorCard({ image }: ImagePaletteWithItems) {
const thumbnail = image.image.variants.find((v) => v.type === "thumbnail");
return (
<Link
href={`/images/${image.image.id}`}
className="block overflow-hidden rounded-md border shadow-sm hover:shadow-md transition-shadow"
>
{thumbnail?.s3Key ? (
<ImageComponent
src={`/api/image/${thumbnail.s3Key}`}
alt={image.image.altText || image.image.imageName}
width={thumbnail.width}
height={thumbnail.height}
className="w-full h-auto object-cover"
/>
) : (
<div className="w-full h-48 bg-gray-100 flex items-center justify-center text-muted-foreground text-sm">
No Thumbnail
</div>
)}
<div className="p-2 text-sm truncate">{image.image.imageName} ({image.type})</div>
</Link>
);
}

View File

@ -3,11 +3,12 @@
import { updateImage } from "@/actions/images/updateImage";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect";
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 { Album, Artist, Category, Color, ColorPalette, ColorPaletteItem, ExtractColor, Image, ImageColor, ImageExtractColor, ImageMetadata, ImagePalette, ImageStats, ImageVariant, Tag } from "@/generated/prisma";
import { cn } from "@/lib/utils";
@ -68,6 +69,7 @@ export default function EditImageForm({ image, albums, artists, categories, tags
imageName: image.imageName,
originalFile: image.originalFile,
uploadDate: image.uploadDate,
nsfw: image.nsfw ?? false,
altText: image.altText || "",
description: image.description || "",
@ -82,7 +84,7 @@ export default function EditImageForm({ image, albums, artists, categories, tags
albumId: image.album?.id || undefined,
tagIds: image.tags?.map(tag => tag.id) ?? [],
categoryIds: image.categories?.map(cat => cat.id) ?? [],
},
}
})
// const watchCreationDate = form.watch("creationDate");
@ -167,6 +169,22 @@ export default function EditImageForm({ image, albums, artists, categories, tags
)}
/>
<FormField
control={form.control}
name="nsfw"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="altText"

View File

@ -7,18 +7,20 @@ import Link from "next/link";
export default function ListImages({ images }: { images: Image[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
<div className="columns-1 sm:columns-2 md:columns-3 xl:columns-4 gap-4 space-y-4">
{images.map((image) => (
<div key={image.id} className="break-inside-avoid">
<Link href={`/images/edit/${image.id}`} key={image.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{image.imageName}</CardTitle>
</CardHeader>
<CardContent>
<NetImage src={`/api/image/thumbnails/${image.fileKey}.webp`} alt={image.altText ? image.altText : "Image"} width={200} height={200} />
<CardContent className="flex justify-center items-center">
<NetImage src={`/api/image/thumbnails/${image.fileKey}.webp`} alt={image.altText ? image.altText : "Image"} width={200} height={200} className="rounded max-w-full h-auto object-contain" />
</CardContent>
</Card>
</Link>
</div>
))}
</div>
);

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@ -14,6 +14,7 @@ export const imageSchema = z.object({
imageName: z.string().min(1, "Image name is required"),
originalFile: z.string().min(1, "Original file is required"),
uploadDate: z.date(),
nsfw: z.boolean(),
altText: z.string().optional(),
description: z.string().optional(),