Refactor and add alt text generator

This commit is contained in:
2025-07-03 11:54:46 +02:00
parent 8856ffb71f
commit 7cb9fa6320
38 changed files with 1747 additions and 233 deletions

732
package-lock.json generated
View File

@ -10,9 +10,14 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.837.0",
"@aws-sdk/s3-request-presigner": "^3.837.0",
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.1.1",
"@material/material-color-utilities": "^0.3.0",
"@prisma/client": "^6.10.1",
"@prisma/client": "^6.11.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@ -37,6 +42,7 @@
"react-dom": "^19.0.0",
"react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"replicate": "^1.0.1",
"sharp": "^0.34.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
@ -55,8 +61,9 @@
"eslint-config-next": "15.3.4",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.0",
"prisma": "^6.10.1",
"prisma": "^6.11.0",
"tailwindcss": "^4",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}
@ -998,6 +1005,73 @@
"integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@ -1031,6 +1105,431 @@
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -2125,9 +2624,9 @@
}
},
"node_modules/@prisma/client": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.10.1.tgz",
"integrity": "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.0.tgz",
"integrity": "sha512-K9TkKepOYvCOg3qCuKz7ZHf6rf58BFKi08plKjU4qVv9y7/UxO6tLz7PlWcgODUZKURLPmRHjHERffIx/8az4w==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -2147,9 +2646,9 @@
}
},
"node_modules/@prisma/config": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.10.1.tgz",
"integrity": "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.0.tgz",
"integrity": "sha512-icBfutMpdrwSf2ggo012zhQ4oianijXL/UPbv4PNVK3WUWbB3/F5Ltq8ZfElGrtwKC6XuFFPxU5qDC9x7vh8zQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -2157,53 +2656,53 @@
}
},
"node_modules/@prisma/debug": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.10.1.tgz",
"integrity": "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.0.tgz",
"integrity": "sha512-zo4oEZMWMt0BFWl+4NK9FUpaEOmjGR3y2/r0lkW/DK4BUBRgMj90s8QqK2K+vXG3xn0nAGg2kOSu+Swn60CFLg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.10.1.tgz",
"integrity": "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.0.tgz",
"integrity": "sha512-uqnYxvPKZPvYZA7F0q4gTR+fVWUJSY5bif7JAKBIOD5SoRRy0qEIaPy4Nna5WDLQaFGshaY/Bh8dLOQMfxhJJw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.10.1",
"@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"@prisma/fetch-engine": "6.10.1",
"@prisma/get-platform": "6.10.1"
"@prisma/debug": "6.11.0",
"@prisma/engines-version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173",
"@prisma/fetch-engine": "6.11.0",
"@prisma/get-platform": "6.11.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c.tgz",
"integrity": "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA==",
"version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173.tgz",
"integrity": "sha512-M3vbyDICFIA1oJl0cFkM0omD4HsJZjFi0hu0f0UxyPABH8KEcZyUd5BToCrNl4B8lUeQn+L5+gfaQleOKp6Lrg==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.10.1.tgz",
"integrity": "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.0.tgz",
"integrity": "sha512-ZHHSP7vJFo5hePH+MNovxhqXabIg38ZpCwQfUBON29kwPX3f1pjYnzGpgJLCJy4k7mKGOzTgrXPqH8+nJvq2fw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.10.1",
"@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c",
"@prisma/get-platform": "6.10.1"
"@prisma/debug": "6.11.0",
"@prisma/engines-version": "6.11.0-18.9c30299f5a0ea26a96790e13f796dc6094db3173",
"@prisma/get-platform": "6.11.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.10.1.tgz",
"integrity": "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.0.tgz",
"integrity": "sha512-yspBGvOfJQwuoApk5B4aBlHDy6YDXAOe4Ml8U2eZ+M2b7fDd10YDomS3Q4qrYHUUVYF3TJyN86NcnRMOvCMUrA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.10.1"
"@prisma/debug": "6.11.0"
}
},
"node_modules/@radix-ui/number": {
@ -2241,6 +2740,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz",
"integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==",
"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-presence": "1.1.4",
"@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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@ -5958,6 +6487,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -6696,6 +7266,21 @@
"node": ">= 0.12"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -7592,6 +8177,26 @@
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/isomorphic-fetch/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@ -8357,26 +8962,6 @@
"node": ">=v0.6.5"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-vibrant": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/node-vibrant/-/node-vibrant-4.0.3.tgz",
@ -8810,15 +9395,15 @@
}
},
"node_modules/prisma": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz",
"integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==",
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.0.tgz",
"integrity": "sha512-gI69E7fusgk32XALpXzdgR10xUx2aFnHiu/JaUo4O07G4JvFT0xNtD0Iy81p37iBLTYFEhWa9VrHKXaiyZ5fLQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "6.10.1",
"@prisma/engines": "6.10.1"
"@prisma/config": "6.11.0",
"@prisma/engines": "6.11.0"
},
"bin": {
"prisma": "build/index.js"
@ -9156,6 +9741,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/replicate": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/replicate/-/replicate-1.0.1.tgz",
"integrity": "sha512-EY+rK1YR5bKHcM9pd6WyaIbv6m2aRIvHfHDh51j/LahlHTLKemTYXF6ptif2sLa+YospupAsIoxw8Ndt5nI3vg==",
"license": "Apache-2.0",
"engines": {
"git": ">=2.11.0",
"node": ">=18.0.0",
"npm": ">=7.19.0",
"yarn": ">=1.7.0"
},
"optionalDependencies": {
"readable-stream": ">=4.0.0"
}
},
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
@ -10072,6 +10672,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.20.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@ -11,9 +11,14 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.837.0",
"@aws-sdk/s3-request-presigner": "^3.837.0",
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.1.1",
"@material/material-color-utilities": "^0.3.0",
"@prisma/client": "^6.10.1",
"@prisma/client": "^6.11.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@ -38,6 +43,7 @@
"react-dom": "^19.0.0",
"react-hook-form": "^7.58.1",
"react-icons": "^5.5.0",
"replicate": "^1.0.1",
"sharp": "^0.34.2",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
@ -56,8 +62,9 @@
"eslint-config-next": "15.3.4",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.0",
"prisma": "^6.10.1",
"prisma": "^6.11.0",
"tailwindcss": "^4",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.4",
"typescript": "^5"
}

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[imageId,type]` on the table `ImageVariant` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ImageVariant_imageId_type_key" ON "ImageVariant"("imageId", "type");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Artist" ADD COLUMN "description" TEXT;

View File

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Artist" ADD COLUMN "source" TEXT;
-- AlterTable
ALTER TABLE "Social" ADD COLUMN "isVisible" BOOLEAN NOT NULL DEFAULT true,
ALTER COLUMN "isPrimary" SET DEFAULT false;

View File

@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Artist" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Gallery" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Social" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0;

View File

@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[fileKey]` on the table `Image` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[originalFile]` on the table `Image` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Image_fileKey_key" ON "Image"("fileKey");
-- CreateIndex
CREATE UNIQUE INDEX "Image_originalFile_key" ON "Image"("originalFile");

View File

@ -18,6 +18,7 @@ model Gallery {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
slug String @unique
name String
@ -34,6 +35,7 @@ model Album {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
slug String
name String
@ -54,11 +56,14 @@ model Artist {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
slug String @unique
displayName String
nickname String?
nickname String?
description String?
source String?
socials Social[]
images Image[]
@ -68,10 +73,12 @@ model Social {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
handle String
platform String
isPrimary Boolean
isPrimary Boolean @default(false)
isVisible Boolean @default(true)
link String?
@ -83,6 +90,7 @@ model Category {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
@ -95,6 +103,7 @@ model Tag {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
@ -107,10 +116,11 @@ model Image {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
fileKey String
fileKey String @unique
originalFile String @unique
imageName String
originalFile String
uploadDate DateTime @default(now())
nsfw Boolean @default(false)
@ -204,6 +214,8 @@ model ImageVariant {
sizeBytes Int?
image Image @relation(fields: [imageId], references: [id])
@@unique([imageId, type])
}
model ColorPalette {

View File

@ -0,0 +1,111 @@
// import prisma from "@/lib/prisma"; // adjust to correct path
// import { s3 } from "@/lib/s3";
// import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
// import sharp from "sharp";
// import { Readable } from "stream";
// // const s3 = new S3Client({ region: "eu-central-1" }); // or use env config
// const BUCKET = "felliesartapp";
// async function streamToBuffer(stream: Readable): Promise<Buffer> {
// return new Promise((resolve, reject) => {
// const chunks: any[] = [];
// stream.on("data", (chunk) => chunks.push(chunk));
// stream.on("end", () => resolve(Buffer.concat(chunks)));
// stream.on("error", reject);
// });
// }
// async function resizeShorterSideTo400(buffer: Buffer) {
// const meta = await sharp(buffer).metadata();
// let resizeOptions;
// if (meta.width && meta.height) {
// resizeOptions = meta.width < meta.height ? { width: 400 } : { height: 400 };
// } else {
// resizeOptions = { width: 400 };
// }
// return sharp(buffer)
// .resize({ ...resizeOptions, withoutEnlargement: true })
// .toFormat("webp")
// .toBuffer();
// }
// async function processAllImages() {
// const images = await prisma.image.findMany({
// include: {
// variants: true,
// },
// });
// for (const image of images) {
// const originalVariant = image.variants.find(v => v.type === "original");
// const resizedVariant = image.variants.find(v => v.type === "resized");
// if (!originalVariant || !originalVariant.s3Key) {
// console.log(`❌ Skipping image ${image.id}: no original variant`);
// continue;
// }
// try {
// // Fetch original image
// const originalData = await s3.send(new GetObjectCommand({
// Bucket: BUCKET,
// Key: originalVariant.s3Key,
// }));
// const originalBuffer = await streamToBuffer(originalData.Body as Readable);
// // Resize
// const resizedBuffer = await resizeShorterSideTo400(originalBuffer);
// const resizedMeta = await sharp(resizedBuffer).metadata();
// // Upload (overwrite existing resized)
// const resizedKey = resizedVariant?.s3Key || `variants/${image.id}/resized.webp`;
// await s3.send(new PutObjectCommand({
// Bucket: BUCKET,
// Key: resizedKey,
// Body: resizedBuffer,
// ContentType: "image/webp",
// }));
// // Update or create the variant
// await prisma.imageVariant.upsert({
// where: resizedVariant ? { id: resizedVariant.id } : {
// imageId_type: {
// imageId: image.id,
// type: "resized",
// }
// },
// update: {
// s3Key: resizedKey,
// width: resizedMeta.width || 0,
// height: resizedMeta.height || 0,
// mimeType: "image/webp",
// fileExtension: "webp",
// sizeBytes: resizedBuffer.length,
// },
// create: {
// imageId: image.id,
// type: "resized",
// s3Key: resizedKey,
// width: resizedMeta.width || 0,
// height: resizedMeta.height || 0,
// mimeType: "image/webp",
// fileExtension: "webp",
// sizeBytes: resizedBuffer.length,
// },
// });
// console.log(`✅ Resized image ${image.id}`);
// } catch (err) {
// console.error(`❌ Failed to process image ${image.id}:`, err);
// }
// }
// console.log("🎉 Done.");
// }
// processAllImages();

View File

@ -0,0 +1,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateAlbumSortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.album.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -26,6 +26,8 @@ export async function updateArtist(
displayName: values.displayName,
slug: values.slug,
nickname: values.nickname,
source: values.source,
description: values.description,
socials: {
deleteMany: {
id: { in: removedSocials.map(s => s.id) },
@ -39,6 +41,7 @@ export async function updateArtist(
handle: s.handle,
link: s.link,
isPrimary: s.isPrimary,
isVisible: s.isVisible
},
})),
create: (values.socials ?? [])
@ -48,6 +51,7 @@ export async function updateArtist(
handle: s.handle,
link: s.link,
isPrimary: s.isPrimary,
isVisible: s.isVisible
})),
},
}

View File

@ -0,0 +1,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateArtistSortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.artist.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -0,0 +1,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateCategorySortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.category.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -0,0 +1,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateGallerySortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.gallery.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -9,13 +9,15 @@ export async function deleteImage(imageId: string) {
where: { id: imageId },
include: {
variants: true,
palettes: { include: { items: true } },
palettes: true,
colors: true,
extractColors: true,
theme: true,
metadata: true,
pixels: true,
stats: true,
albumCover: true,
galleryCover: true,
tags: true,
categories: true,
},
});
@ -23,39 +25,80 @@ export async function deleteImage(imageId: string) {
// Delete S3 objects
for (const variant of image.variants) {
await s3.send(new DeleteObjectCommand({
Bucket: "felliesartapp",
Key: variant.s3Key,
}));
}
// Delete image variants
await prisma.imageVariant.deleteMany({ where: { imageId } });
// Delete extract colors
await prisma.extractColor.deleteMany({ where: { imageId } });
// Delete image colors
await prisma.imageColor.deleteMany({ where: { imageId } });
// Delete palettes (and items only if no other image uses them)
const palettes = await prisma.colorPalette.findMany({
where: { images: { some: { id: imageId } } },
include: { images: { select: { id: true } }, items: true }
});
for (const palette of palettes) {
if (palette.images.length === 1 && palette.images[0].id === imageId) {
await prisma.colorPaletteItem.deleteMany({ where: { colorPaletteId: palette.id } });
await prisma.colorPalette.delete({ where: { id: palette.id } });
try {
await s3.send(
new DeleteObjectCommand({
Bucket: "felliesartapp",
Key: variant.s3Key,
})
);
} catch (err) {
console.warn("Failed to delete S3 object: " + variant.s3Key + ". " + err);
}
}
// Delete metadata-related entries
// Step 1: Delete join entries
await prisma.imagePalette.deleteMany({ where: { imageId } });
await prisma.imageColor.deleteMany({ where: { imageId } });
await prisma.imageExtractColor.deleteMany({ where: { imageId } });
// ColorPalettes
const connectedPalettes = image.palettes;
for (const palette of connectedPalettes) {
const count = await prisma.imagePalette.count({
where: { paletteId: palette.id },
});
if (count === 0) {
await prisma.colorPaletteItem.deleteMany({ where: { colorPaletteId: palette.id } });
await prisma.colorPalette.deleteMany({ where: { id: palette.id } });
}
}
// ExtractColors
for (const extract of image.extractColors) {
const count = await prisma.imageExtractColor.count({
where: { extractId: extract.extractId },
});
if (count === 0) {
await prisma.extractColor.delete({ where: { id: extract.extractId } });
}
}
// 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 } });
await prisma.imageStats.deleteMany({ where: { imageId } });
await prisma.pixelSummary.deleteMany({ where: { imageId } });
await prisma.themeSeed.deleteMany({ where: { imageId } });
// Clean many-to-many tag/category joins
await prisma.image.update({
where: { id: imageId },
data: {
tags: { set: [] },
categories: { set: [] },
},
});
// Delete possible coverImage relation
await prisma.album.updateMany({
where: { coverImageId: imageId },
data: { coverImageId: null },
});
await prisma.gallery.updateMany({
where: { coverImageId: imageId },
data: { coverImageId: null },
});
// Finally delete the image
await prisma.image.delete({ where: { id: imageId } });

View File

@ -76,9 +76,21 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
})
);
//--- Resized file
const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
// const resizedWidth = Math.min(watermarkedMetadata.width || 400, 400);
const { width, height } = watermarkedMetadata;
const targetSize = 400;
let resizeOptions;
if (width && height) {
if (width < height) {
resizeOptions = { width: targetSize };
} else {
resizeOptions = { height: targetSize };
}
} else {
resizeOptions = { width: targetSize };
}
const resizedBuffer = await sharp(watermarkedBuffer)
.resize({ width: resizedWidth, withoutEnlargement: true })
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
@ -91,9 +103,20 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
})
);
//--- Thumbnail file
const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
// const thumbnailWidth = Math.min(watermarkedMetadata.width || 200, 200);
const thumbnailTargetSize = 200;
let thumbnailOptions;
if (width && height) {
if (width < height) {
thumbnailOptions = { width: thumbnailTargetSize };
} else {
thumbnailOptions = { height: thumbnailTargetSize };
}
} else {
thumbnailOptions = { width: thumbnailTargetSize };
}
const thumbnailBuffer = await sharp(watermarkedBuffer)
.resize({ width: thumbnailWidth, withoutEnlargement: true })
.resize({ ...thumbnailOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
@ -112,7 +135,6 @@ export async function uploadImage(values: z.infer<typeof imageUploadSchema>) {
fileKey,
originalFile: fileName,
uploadDate: new Date(),
creationDate: lastModified,
creationMonth: month,
creationYear: year,

View File

@ -0,0 +1,15 @@
'use server';
import prisma from "@/lib/prisma";
import { SortableItem } from "@/types/SortableItem";
export async function updateTagSortOrder(items: SortableItem[]) {
await Promise.all(
items.map(item =>
prisma.tag.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -6,14 +6,14 @@ import Link from "next/link";
export default async function AlbumsPage() {
const albums = await prisma.album.findMany(
{
include: { gallery: true, images: { select: { id: true } } },
orderBy: { name: "asc" }
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
include: { gallery: true, images: { select: { id: true } }, coverImage: true },
}
);
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">Albums</h1>
<Link href="/albums/new" 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" /> Add new Album

View File

@ -5,13 +5,13 @@ import Link from "next/link";
export default async function ArtistsPage() {
const artists = await prisma.artist.findMany({
orderBy: { createdAt: "asc" },
orderBy: [{ sortIndex: "asc" }, { displayName: "asc" }],
include: { images: { select: { id: true } } }
});
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">Artists</h1>
<Link href="/artists/new" 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" /> Add new Artist

View File

@ -6,14 +6,14 @@ import Link from "next/link";
export default async function CategoriesPage() {
const categories = await prisma.category.findMany(
{
orderBy: { createdAt: "asc" },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
include: { images: { select: { id: true } } }
}
);
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">Categories</h1>
<Link href="/categories/new" 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" /> Add new Category

View File

@ -5,15 +5,16 @@ import Link from "next/link";
export default async function GalleriesPage() {
const galleries = await prisma.gallery.findMany({
orderBy: { createdAt: "asc" },
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
include: {
albums: { select: { id: true } }
albums: { select: { id: true } },
coverImage: true
}
});
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">Galleries</h1>
<Link href="/galleries/new" 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" /> Add new gallery

View File

@ -48,7 +48,7 @@ export default async function ImagesEditPage({ params }: { params: { id: string
const categories = await prisma.category.findMany({ orderBy: { name: "asc" } });
const tags = await prisma.tag.findMany({ orderBy: { name: "asc" } });
console.log(image)
// console.log(image)
return (
<div>

View File

@ -17,8 +17,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Fellies Art Admin",
description: "Admin page for the fellies.art artworks",
};
export default function RootLayout({

View File

@ -6,13 +6,14 @@ import Link from "next/link";
export default async function TagsPage() {
const tags = await prisma.tag.findMany(
{
orderBy: { createdAt: "asc" }
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
include: { images: { select: { id: true } } }
}
);
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">Tags</h1>
<Link href="/tags/new" 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" /> Add new Tag

View File

@ -1,43 +1,66 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Album, Gallery } from "@/generated/prisma";
import { TriangleAlert } from "lucide-react";
import Link from "next/link";
"use client";
import { updateAlbumSortOrder } from "@/actions/albums/updateAlbumSortOrder";
import { SortableCardItem } from "@/components/sort/SortableItem";
import { SortableList } from "@/components/sort/SortableList";
import { Album, Gallery, Image } from "@/generated/prisma";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
type AlbumWithGallery = Album & {
gallery: Gallery | null;
images: { id: string }[];
coverImage?: (Image) | null
};
export default function ListAlbums({ albums }: { albums: AlbumWithGallery[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = albums.map(album => ({
id: album.id,
sortIndex: album.sortIndex,
label: album.name,
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateAlbumSortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateAlbumSortOrder(items);
};
if (!isMounted) return null;
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{albums.map((album) => (
<Link href={`/albums/edit/${album.id}`} key={album.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{album.name}</CardTitle>
</CardHeader>
<CardContent>
{album.description && <p className="text-sm text-muted-foreground">{album.description}</p>}
</CardContent>
<CardFooter>
<div className="flex flex-col gap-4">
{album.gallery ? (
<>Gallery: {album.gallery.name}</>
) : (
<div className="flex items-center text-destructive">
<TriangleAlert className="mr-2 h-4 w-4" />
No gallery
</div>
)}
<p className="text-sm text-muted-foreground">
Total images in this album: <span className="font-semibold">{album.images.length}</span>
</p>
</div>
</CardFooter>
</Card>
</Link>
))}
</div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by name"
renderItem={(item) => {
const album = albums.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={album.id}
item={{
id: album.id,
name: album.name,
type: "album",
coverImage: album.coverImage,
count: album.images.length,
textLabel: "Gallery: " + album.gallery?.name
}}
/>
);
}}
/>
);
}

View File

@ -2,6 +2,7 @@
import { updateArtist } from "@/actions/artists/updateArtist";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Artist, Social } from "@/generated/prisma";
@ -21,12 +22,15 @@ export default function EditAlbumForm({ artist }: { artist: Artist & { socials:
displayName: artist.displayName,
slug: artist.slug,
nickname: artist.nickname || "",
source: artist.source || "",
description: artist.description || "",
socials: artist.socials.map((s) => ({
id: s.id,
platform: s.platform,
handle: s.handle,
link: s.link ?? "", // Convert null to empty string
isPrimary: s.isPrimary,
isVisible: s.isVisible
})),
},
})
@ -98,7 +102,38 @@ export default function EditAlbumForm({ artist }: { artist: Artist & { socials:
</FormItem>
)}
/>
<FormField
control={form.control}
name="source"
render={({ field }) => (
<FormItem>
<FormLabel>Artist source (optional)</FormLabel>
<FormControl>
<Input placeholder="Artist source" {...field} />
</FormControl>
<FormDescription>
Where did the artist come from?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Artist description (optional)</FormLabel>
<FormControl>
<Input placeholder="Artist description" {...field} />
</FormControl>
<FormDescription>
Description of the Artist.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Socials section */}
<div className="space-y-4">
<FormLabel>Social Links</FormLabel>
@ -169,6 +204,21 @@ export default function EditAlbumForm({ artist }: { artist: Artist & { socials:
</FormItem>
)}
/>
<FormField
control={form.control}
name={`socials.${index}.isVisible`}
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(!!checked)}
/>
</FormControl>
<FormLabel className="m-0 text-sm">Visible</FormLabel>
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
@ -181,7 +231,7 @@ export default function EditAlbumForm({ artist }: { artist: Artist & { socials:
<Button
type="button"
variant="outline"
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false })}
onClick={() => append({ platform: "", handle: "", link: "", isPrimary: false, isVisible: true })}
>
+ Add Social
</Button>

View File

@ -1,28 +1,62 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
"use client";
import { updateArtistSortOrder } from "@/actions/artists/updateArtistSortOrder";
import { SortableCardItem } from "@/components/sort/SortableItem";
import { SortableList } from "@/components/sort/SortableList";
import { Artist } from "@/generated/prisma";
import Link from "next/link";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
type ArtistsWithItems = Artist & {
images: { id: string }[]
}
export default function ListArtists({ artists }: { artists: ArtistsWithItems[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = artists.map(artist => ({
id: artist.id,
sortIndex: artist.sortIndex,
label: artist.displayName,
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateArtistSortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateArtistSortOrder(items);
};
if (!isMounted) return null;
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{artists.map((artist) => (
<Link href={`/artists/edit/${artist.id}`} key={artist.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{artist.displayName}</CardTitle>
</CardHeader>
<CardContent>
</CardContent>
<CardFooter>
Connected to {artist.images.length} image{artist.images.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by displayName"
renderItem={(item) => {
const artist = artists.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={artist.id}
item={{
id: artist.id,
name: artist.displayName,
type: "artist",
count: artist.images.length
}}
/>
);
}}
/>
);
}

View File

@ -1,29 +1,62 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
"use client";
import { updateCategorySortOrder } from "@/actions/categories/updateCategorySortOrder";
import { SortableCardItem } from "@/components/sort/SortableItem";
import { SortableList } from "@/components/sort/SortableList";
import { Category } from "@/generated/prisma";
import Link from "next/link";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
type CategoriesWithItems = Category & {
images: { id: string }[]
}
export default function ListCategories({ categories }: { categories: CategoriesWithItems[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = categories.map(cat => ({
id: cat.id,
sortIndex: cat.sortIndex,
label: cat.name,
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateCategorySortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateCategorySortOrder(items);
};
if (!isMounted) return null;
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{categories.map((cat) => (
<Link href={`/categories/edit/${cat.id}`} key={cat.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{cat.name}</CardTitle>
</CardHeader>
<CardContent>
{cat.description && <p className="text-sm text-muted-foreground">{cat.description}</p>}
</CardContent>
<CardFooter>
Connected to {cat.images.length} image{cat.images.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by name"
renderItem={(item) => {
const cat = categories.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={cat.id}
item={{
id: cat.id,
name: cat.name,
type: "category",
count: cat.images.length
}}
/>
);
}}
/>
);
}

View File

@ -1,31 +1,64 @@
// "use client"
"use client";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Gallery } from "@/generated/prisma";
import Link from "next/link";
import { updateGallerySortOrder } from "@/actions/galleries/updateGallerySortOrder";
import { SortableCardItem } from "@/components/sort/SortableItem";
import { SortableList } from "@/components/sort/SortableList";
import { Gallery, Image } from "@/generated/prisma";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
type GalleriesWithItems = Gallery & {
albums: { id: string }[]
}
albums: { id: string }[],
coverImage?: (Image) | null
};
export default function ListGalleries({ galleries }: { galleries: GalleriesWithItems[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = galleries.map(gallery => ({
id: gallery.id,
sortIndex: gallery.sortIndex,
label: gallery.name,
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateGallerySortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateGallerySortOrder(items);
};
if (!isMounted) return null;
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{galleries.map((gallery) => (
<Link href={`/galleries/edit/${gallery.id}`} key={gallery.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{gallery.name}</CardTitle>
</CardHeader>
<CardContent>
{gallery.description && <p className="text-sm text-muted-foreground">{gallery.description}</p>}
</CardContent>
<CardFooter>
Connected to {gallery.albums.length} album{gallery.albums.length !== 1 ? "s" : ""}
</CardFooter>
</Card>
</Link>
))}
</div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by name"
renderItem={(item) => {
const gallery = galleries.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={gallery.id}
item={{
id: gallery.id,
name: gallery.name,
type: "gallery",
coverImage: gallery.coverImage,
count: gallery.albums.length,
}}
/>
);
}}
/>
);
}
}

View File

@ -13,6 +13,7 @@ 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";
import { imageSchema } from "@/schemas/images/imageSchema";
import { generateImageText } from "@/utils/generateImageText";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { useRouter } from "next/navigation";
@ -207,6 +208,19 @@ export default function EditImageForm({ image, albums, artists, categories, tags
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
onClick={async () => {
const result = await generateImageText(image.fileKey);
if (result) {
form.setValue("altText", result.alt);
form.setValue("description", result.description);
}
}}
>
Autofill Alt + Description
</Button>
<FormField
control={form.control}
name="fileType"
@ -224,12 +238,11 @@ export default function EditImageForm({ image, albums, artists, categories, tags
render={({ field }) => (
<FormItem>
<FormLabel>source</FormLabel>
<FormControl><Input {...field} disabled /></FormControl>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="creationDate"
@ -263,6 +276,9 @@ export default function EditImageForm({ image, albums, artists, categories, tags
}
}}
initialFocus
fromYear={1900}
toYear={2100}
captionLayout="dropdown"
/>
</PopoverContent>
</Popover>
@ -287,6 +303,7 @@ export default function EditImageForm({ image, albums, artists, categories, tags
)}
/>
{/* Year/Month fallback number inputs */}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
@ -299,8 +316,12 @@ export default function EditImageForm({ image, albums, artists, categories, tags
type="number"
min={1}
max={12}
disabled={!!form.watch("creationDate")}
{...field}
onChange={(e) => {
const val = parseInt(e.target.value);
field.onChange(isNaN(val) ? undefined : val);
}}
disabled={!!form.watch("creationDate")}
/>
</FormControl>
<FormMessage />
@ -318,8 +339,12 @@ export default function EditImageForm({ image, albums, artists, categories, tags
type="number"
min={1900}
max={2100}
disabled={!!form.watch("creationDate")}
{...field}
onChange={(e) => {
const val = parseInt(e.target.value);
field.onChange(isNaN(val) ? undefined : val);
}}
disabled={!!form.watch("creationDate")}
/>
</FormControl>
<FormMessage />

View File

@ -0,0 +1,103 @@
'use client';
import type { Color, Image, ImageColor } from '@/generated/prisma';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import clsx from 'clsx';
import { GripVertical } from 'lucide-react';
import NextImage from 'next/image';
import Link from 'next/link';
type SupportedTypes = 'gallery' | 'album' | 'artist' | 'category' | 'tag';
const pluralMap: Record<SupportedTypes, string> = {
gallery: 'galleries',
album: 'albums',
artist: 'artists',
category: 'categories',
tag: 'tags',
};
type SortableCardItemProps = {
id: string;
item: {
id: string;
name: string;
type: 'gallery' | 'album' | 'artist' | 'category' | 'tag';
coverImage?: (Image & { colors?: (ImageColor & { color: Color })[] }) | null;
count?: number;
textLabel?: string;
};
};
export function SortableCardItem({ id, item }: SortableCardItemProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const href = `/${pluralMap[item.type]}/edit/${item.id}`;
const isVisualType = item.type === 'gallery' || item.type === 'album';
let countLabel = '';
if (item.count !== undefined) {
if (item.type === 'gallery') {
countLabel = `${item.count} album${item.count !== 1 ? 's' : ''}`;
} else {
countLabel = `${item.count} image${item.count !== 1 ? 's' : ''}`;
}
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className="relative cursor-grab active:cursor-grabbing"
>
<div
{...listeners}
className="absolute top-2 left-2 z-20 text-muted-foreground bg-white/70 rounded-full p-1"
title="Drag to reorder"
>
<GripVertical className="w-4 h-4" />
</div>
<Link href={href}>
<div className="group rounded-lg border overflow-hidden hover:shadow-md transition-shadow bg-background relative">
{isVisualType ? (
<div className="relative aspect-[4/3] w-full bg-muted items-center justify-center">
{item.coverImage?.fileKey ? (
<NextImage
src={`/api/image/thumbnails/${item.coverImage.fileKey}.webp`}
alt={item.coverImage.imageName}
fill
className={clsx("object-cover transition duration-300")}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No cover image
</div>
)}
</div>
) : null}
<div className={clsx("p-4", !isVisualType && "text-center")}>
<h2 className={clsx("text-lg font-semibold truncate", isVisualType && "text-center")}>
{item.name}
</h2>
{countLabel && (
<p className="text-sm text-muted-foreground mt-1">{countLabel}</p>
)}
{item.textLabel && (
<p className="text-sm text-muted-foreground mt-1">{item.textLabel}</p>
)}
</div>
</div>
</Link>
</div>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState } from 'react';
import type { SortableItem as ItemType } from '@/types/SortableItem';
interface Props {
items: ItemType[];
onReorder: (items: ItemType[]) => void;
onSortDefault?: () => void;
defaultSortLabel?: string;
renderItem: (item: ItemType) => React.ReactNode;
}
export function SortableList({
items,
onReorder,
onSortDefault,
defaultSortLabel = 'Sort by Name',
renderItem,
}: Props) {
const [localItems, setLocalItems] = useState(items);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = localItems.findIndex(item => item.id === active.id);
const newIndex = localItems.findIndex(item => item.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(localItems, oldIndex, newIndex).map((item, index) => ({
...item,
sortIndex: index * 10,
}));
setLocalItems(reordered);
onReorder(reordered);
};
return (
<div className="space-y-4">
{onSortDefault && (
<button
onClick={onSortDefault}
className="px-4 py-2 text-sm font-medium bg-gray-200 rounded hover:bg-gray-300 text-primary-foreground"
>
{defaultSortLabel}
</button>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={localItems.map(i => i.id)} strategy={verticalListSortingStrategy}>
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{localItems.map(item => (
<div key={item.id}>{renderItem(item)}</div>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@ -1,24 +1,61 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Tag } from "@/generated/prisma";
import Link from "next/link";
"use client";
import { updateTagSortOrder } from "@/actions/tags/updateTagSortOrder";
import { SortableCardItem } from "@/components/sort/SortableItem";
import { SortableList } from "@/components/sort/SortableList";
import { Tag } from "@/generated/prisma";
import { SortableItem } from "@/types/SortableItem";
import { useEffect, useState } from "react";
type TagsWithItems = Tag & {
images: { id: string }[]
}
export default function ListTags({ tags }: { tags: TagsWithItems[] }) {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const sortableItems: SortableItem[] = tags.map(tag => ({
id: tag.id,
sortIndex: tag.sortIndex,
label: tag.name,
}));
const handleSortDefault = async () => {
const sorted = [...sortableItems]
.sort((a, b) => a.label.localeCompare(b.label))
.map((item, index) => ({ ...item, sortIndex: index * 10 }));
await updateTagSortOrder(sorted);
};
const handleReorder = async (items: SortableItem[]) => {
await updateTagSortOrder(items);
};
if (!isMounted) return null;
export default function ListTags({ tags }: { tags: Tag[] }) {
return (
<div className="grid gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{tags.map((tag) => (
<Link href={`/tags/edit/${tag.id}`} key={tag.id}>
<Card className="overflow-hidden">
<CardHeader>
<CardTitle className="text-base truncate">{tag.name}</CardTitle>
</CardHeader>
<CardContent>
{tag.description && <p className="text-sm text-muted-foreground">{tag.description}</p>}
</CardContent>
<CardFooter>
</CardFooter>
</Card>
</Link>
))}
</div>
<SortableList
items={sortableItems}
onReorder={handleReorder}
onSortDefault={handleSortDefault}
defaultSortLabel="Sort by name"
renderItem={(item) => {
const tag = tags.find(g => g.id === item.id)!;
return (
<SortableCardItem
id={tag.id}
item={{
id: tag.id,
name: tag.name,
type: "tag",
count: tag.images.length
}}
/>
);
}}
/>
);
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -5,6 +5,7 @@ export const socialSchema = z.object({
platform: z.string().min(1, "Platform is required"),
handle: z.string().min(1, "Handle is required"),
isPrimary: z.boolean(),
isVisible: z.boolean(),
link: z.string().optional(),
});
@ -12,6 +13,8 @@ export const artistSchema = z.object({
displayName: z.string().min(3, "Name is required. Min 3 characters."),
slug: z.string().min(3, "Slug is required. Min 3 characters.").regex(/^[a-z]+$/, "Only lowercase letters are allowed (no numbers, spaces, or uppercase)"),
nickname: z.string().optional(),
source: z.string().optional(),
description: z.string().optional(),
socials: z.array(socialSchema).optional(),
}).refine(
(data) => {

View File

@ -0,0 +1,6 @@
export interface SortableItem {
id: string;
sortIndex: number;
label: string; // e.g., name, displayName, or handle
secondary?: string | boolean; // optional (e.g. isPrimary)
}

View File

@ -0,0 +1,79 @@
"use server"
import { getSignedImageUrl } from "@/lib/s3";
// const OpenAIImageSchema = z.object({
// description: z.string(),
// alt: z.string(),
// })
export async function generateImageText(fileKey: string) {
const imageUrl = await getSignedImageUrl(`original/${fileKey}.webp`);
console.log("Generating image text...")
try {
const imageRes = await fetch(imageUrl);
if (!imageRes.ok) throw new Error("Failed to fetch image");
const arrayBuffer = await imageRes.arrayBuffer();
const base64 = arrayBufferToBase64(arrayBuffer);
const resDescription = await fetch("http://10.0.20.11:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "moondream",
prompt: "Describe this image in detail",
// format: "json",
stream: false,
images: [base64],
}),
})
const resAlt = await fetch("http://10.0.20.11:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "moondream",
prompt: "Write a short alt text describing this image.",
// format: "json",
stream: false,
images: [base64],
}),
})
const descJson = await resDescription.json();
const altJson = await resAlt.json();
console.log("Response:", descJson.response)
console.log("Response:", altJson.response)
// const json = await res.json();
// const responseText = json.response?.trim();
// const data = await res.json();
// const text = data.res?.trim();
// // const text = await res.text()
// console.log("Response:", data)
// console.log("Response:", text)
// const parsed = JSON.parse(text)
// return parsed.response || "(no response)"
return {
description: descJson.response,
alt: altJson.response,
}
} catch (err) {
console.error("error:", err)
return null
}
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = "";
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}

27
src/utils/setSortIndex.ts Normal file
View File

@ -0,0 +1,27 @@
import prisma from "@/lib/prisma"
async function updateSortIndices() {
const galleries = await prisma.gallery.findMany({ orderBy: { createdAt: "asc" } })
const albums = await prisma.album.findMany({ orderBy: { createdAt: "asc" } })
for (let i = 0; i < galleries.length; i++) {
await prisma.gallery.update({
where: { id: galleries[i].id },
data: { sortIndex: i },
})
}
for (let i = 0; i < albums.length; i++) {
await prisma.album.update({
where: { id: albums[i].id },
data: { sortIndex: i },
})
}
console.log("✅ sortIndex updated for all galleries and albums")
}
updateSortIndices().catch((e) => {
console.error(e)
process.exit(1)
})