Add CRUD for galleries
This commit is contained in:
		
							
								
								
									
										163
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										163
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -8,8 +8,12 @@
 | 
			
		||||
      "name": "admin.fellies.art",
 | 
			
		||||
      "version": "0.1.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@hookform/resolvers": "^5.1.1",
 | 
			
		||||
        "@prisma/client": "^6.10.1",
 | 
			
		||||
        "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
        "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
        "@radix-ui/react-navigation-menu": "^1.2.13",
 | 
			
		||||
        "@radix-ui/react-select": "^2.2.5",
 | 
			
		||||
        "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
        "class-variance-authority": "^0.7.1",
 | 
			
		||||
        "clsx": "^2.1.1",
 | 
			
		||||
@ -18,8 +22,10 @@
 | 
			
		||||
        "next-themes": "^0.4.6",
 | 
			
		||||
        "react": "^19.0.0",
 | 
			
		||||
        "react-dom": "^19.0.0",
 | 
			
		||||
        "react-hook-form": "^7.58.1",
 | 
			
		||||
        "sonner": "^2.0.5",
 | 
			
		||||
        "tailwind-merge": "^3.3.1"
 | 
			
		||||
        "tailwind-merge": "^3.3.1",
 | 
			
		||||
        "zod": "^3.25.67"
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
        "@eslint/eslintrc": "^3",
 | 
			
		||||
@ -289,6 +295,18 @@
 | 
			
		||||
      "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@hookform/resolvers": {
 | 
			
		||||
      "version": "5.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-J/NVING3LMAEvexJkyTLjruSm7aOFx7QX21pzkiJfMoNG0wl5aFEjLTl7ay7IQb9EWY6AkrBy7tHL2Alijpdcg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@standard-schema/utils": "^0.3.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react-hook-form": "^7.55.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@humanfs/core": {
 | 
			
		||||
      "version": "0.19.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
 | 
			
		||||
@ -1035,11 +1053,33 @@
 | 
			
		||||
        "url": "https://opencollective.com/pkgr"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@prisma/client": {
 | 
			
		||||
      "version": "6.10.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.10.1.tgz",
 | 
			
		||||
      "integrity": "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w==",
 | 
			
		||||
      "hasInstallScript": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=18.18"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "prisma": "*",
 | 
			
		||||
        "typescript": ">=5.1.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "prisma": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "typescript": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "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==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "jiti": "2.4.2"
 | 
			
		||||
@ -1049,14 +1089,14 @@
 | 
			
		||||
      "version": "6.10.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.10.1.tgz",
 | 
			
		||||
      "integrity": "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "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==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "hasInstallScript": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
@ -1070,14 +1110,14 @@
 | 
			
		||||
      "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==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "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==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@prisma/debug": "6.10.1",
 | 
			
		||||
@ -1089,12 +1129,18 @@
 | 
			
		||||
      "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==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@prisma/debug": "6.10.1"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/number": {
 | 
			
		||||
      "version": "1.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/primitive": {
 | 
			
		||||
      "version": "1.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
 | 
			
		||||
@ -1309,6 +1355,29 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-label": {
 | 
			
		||||
      "version": "2.1.7",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
 | 
			
		||||
      "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/react-primitive": "2.1.3"
 | 
			
		||||
      },
 | 
			
		||||
      "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-menu": {
 | 
			
		||||
      "version": "2.1.15",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz",
 | 
			
		||||
@ -1519,6 +1588,49 @@
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@radix-ui/react-select": {
 | 
			
		||||
      "version": "2.2.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
 | 
			
		||||
      "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@radix-ui/number": "1.1.1",
 | 
			
		||||
        "@radix-ui/primitive": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-collection": "1.1.7",
 | 
			
		||||
        "@radix-ui/react-compose-refs": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-context": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-direction": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-dismissable-layer": "1.1.10",
 | 
			
		||||
        "@radix-ui/react-focus-guards": "1.1.2",
 | 
			
		||||
        "@radix-ui/react-focus-scope": "1.1.7",
 | 
			
		||||
        "@radix-ui/react-id": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-popper": "1.2.7",
 | 
			
		||||
        "@radix-ui/react-portal": "1.1.9",
 | 
			
		||||
        "@radix-ui/react-primitive": "2.1.3",
 | 
			
		||||
        "@radix-ui/react-slot": "1.2.3",
 | 
			
		||||
        "@radix-ui/react-use-callback-ref": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-use-controllable-state": "1.2.2",
 | 
			
		||||
        "@radix-ui/react-use-layout-effect": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-use-previous": "1.1.1",
 | 
			
		||||
        "@radix-ui/react-visually-hidden": "1.2.3",
 | 
			
		||||
        "aria-hidden": "^1.2.4",
 | 
			
		||||
        "react-remove-scroll": "^2.6.3"
 | 
			
		||||
      },
 | 
			
		||||
      "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-slot": {
 | 
			
		||||
      "version": "1.2.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
 | 
			
		||||
@ -1716,6 +1828,12 @@
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@standard-schema/utils": {
 | 
			
		||||
      "version": "0.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@swc/counter": {
 | 
			
		||||
      "version": "0.1.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
 | 
			
		||||
@ -4951,7 +5069,7 @@
 | 
			
		||||
      "version": "2.4.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
 | 
			
		||||
      "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "jiti": "lib/jiti-cli.mjs"
 | 
			
		||||
@ -5934,7 +6052,7 @@
 | 
			
		||||
      "version": "6.10.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz",
 | 
			
		||||
      "integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "hasInstallScript": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
@ -6020,6 +6138,22 @@
 | 
			
		||||
        "react": "^19.1.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-hook-form": {
 | 
			
		||||
      "version": "7.58.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz",
 | 
			
		||||
      "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=18.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "opencollective",
 | 
			
		||||
        "url": "https://opencollective.com/react-hook-form"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "react": "^16.8.0 || ^17 || ^18 || ^19"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/react-is": {
 | 
			
		||||
      "version": "16.13.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
 | 
			
		||||
@ -6979,7 +7113,7 @@
 | 
			
		||||
      "version": "5.8.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
 | 
			
		||||
      "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
 | 
			
		||||
      "dev": true,
 | 
			
		||||
      "devOptional": true,
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "tsc": "bin/tsc",
 | 
			
		||||
@ -7240,6 +7374,15 @@
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "url": "https://github.com/sponsors/sindresorhus"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/zod": {
 | 
			
		||||
      "version": "3.25.67",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
 | 
			
		||||
      "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "url": "https://github.com/sponsors/colinhacks"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -9,8 +9,12 @@
 | 
			
		||||
    "lint": "next lint"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@hookform/resolvers": "^5.1.1",
 | 
			
		||||
    "@prisma/client": "^6.10.1",
 | 
			
		||||
    "@radix-ui/react-dropdown-menu": "^2.1.15",
 | 
			
		||||
    "@radix-ui/react-label": "^2.1.7",
 | 
			
		||||
    "@radix-ui/react-navigation-menu": "^1.2.13",
 | 
			
		||||
    "@radix-ui/react-select": "^2.2.5",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "class-variance-authority": "^0.7.1",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
@ -19,8 +23,10 @@
 | 
			
		||||
    "next-themes": "^0.4.6",
 | 
			
		||||
    "react": "^19.0.0",
 | 
			
		||||
    "react-dom": "^19.0.0",
 | 
			
		||||
    "react-hook-form": "^7.58.1",
 | 
			
		||||
    "sonner": "^2.0.5",
 | 
			
		||||
    "tailwind-merge": "^3.3.1"
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
    "zod": "^3.25.67"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@eslint/eslintrc": "^3",
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								prisma/migrations/20250624201728_init_gallery/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								prisma/migrations/20250624201728_init_gallery/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "Gallery" (
 | 
			
		||||
    "id" TEXT NOT NULL,
 | 
			
		||||
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
    "updatedAt" TIMESTAMP(3) NOT NULL,
 | 
			
		||||
    "name" TEXT NOT NULL,
 | 
			
		||||
    "slug" TEXT NOT NULL,
 | 
			
		||||
    "description" TEXT,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "Gallery_pkey" PRIMARY KEY ("id")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- CreateIndex
 | 
			
		||||
CREATE UNIQUE INDEX "Gallery_slug_key" ON "Gallery"("slug");
 | 
			
		||||
							
								
								
									
										15
									
								
								prisma/migrations/20250624212636_add_albums/migration.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								prisma/migrations/20250624212636_add_albums/migration.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
-- CreateTable
 | 
			
		||||
CREATE TABLE "Album" (
 | 
			
		||||
    "id" TEXT NOT NULL,
 | 
			
		||||
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
 | 
			
		||||
    "updatedAt" TIMESTAMP(3) NOT NULL,
 | 
			
		||||
    "name" TEXT NOT NULL,
 | 
			
		||||
    "slug" TEXT NOT NULL,
 | 
			
		||||
    "description" TEXT,
 | 
			
		||||
    "galleryId" TEXT,
 | 
			
		||||
 | 
			
		||||
    CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
-- AddForeignKey
 | 
			
		||||
ALTER TABLE "Album" ADD CONSTRAINT "Album_galleryId_fkey" FOREIGN KEY ("galleryId") REFERENCES "Gallery"("id") ON DELETE SET NULL ON UPDATE CASCADE;
 | 
			
		||||
							
								
								
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
# Please do not edit this file manually
 | 
			
		||||
# It should be added in your version-control system (e.g., Git)
 | 
			
		||||
provider = "postgresql"
 | 
			
		||||
@ -13,3 +13,37 @@ datasource db {
 | 
			
		||||
  provider = "postgresql"
 | 
			
		||||
  url      = env("DATABASE_URL")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Gallery {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String
 | 
			
		||||
  slug String @unique
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  // coverImageId String?
 | 
			
		||||
  // coverImage   Image?  @relation("GalleryCoverImage", fields: [coverImageId], references: [id])
 | 
			
		||||
 | 
			
		||||
  albums Album[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Album {
 | 
			
		||||
  id        String   @id @default(cuid())
 | 
			
		||||
  createdAt DateTime @default(now())
 | 
			
		||||
  updatedAt DateTime @updatedAt
 | 
			
		||||
 | 
			
		||||
  name String
 | 
			
		||||
  slug String
 | 
			
		||||
 | 
			
		||||
  description String?
 | 
			
		||||
 | 
			
		||||
  // coverImageId String?
 | 
			
		||||
  galleryId String?
 | 
			
		||||
  // coverImage   Image?   @relation("AlbumCoverImage", fields: [coverImageId], references: [id])
 | 
			
		||||
  gallery   Gallery? @relation(fields: [galleryId], references: [id])
 | 
			
		||||
 | 
			
		||||
  // images Image[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								src/actions/galleries/createGallery.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/actions/galleries/createGallery.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { gallerySchema } from "@/schemas/galleries/gallerySchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function createGallery(values: z.infer<typeof gallerySchema>) {
 | 
			
		||||
  return await prisma.gallery.create({
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,
 | 
			
		||||
      slug: values.slug,
 | 
			
		||||
      description: values.description,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								src/actions/galleries/deleteGallery.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/actions/galleries/deleteGallery.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
"use server";
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export async function deleteGallery(id: string) {
 | 
			
		||||
  await prisma.gallery.delete({ where: { id } });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								src/actions/galleries/updateGallery.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/actions/galleries/updateGallery.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
"use server"
 | 
			
		||||
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { gallerySchema } from "@/schemas/galleries/gallerySchema";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export async function updateGallery(
 | 
			
		||||
  values: z.infer<typeof gallerySchema>, 
 | 
			
		||||
  id: string
 | 
			
		||||
) {
 | 
			
		||||
  return await prisma.gallery.update({
 | 
			
		||||
    where: {
 | 
			
		||||
      id: id
 | 
			
		||||
    },
 | 
			
		||||
    data: {
 | 
			
		||||
      name: values.name,
 | 
			
		||||
      slug: values.slug,
 | 
			
		||||
      description: values.description,
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										22
									
								
								src/app/galleries/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/app/galleries/edit/[id]/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
import EditGalleryForm from "@/components/galleries/edit/EditGalleryForm";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
 | 
			
		||||
export default async function GalleriesEditPage({ params }: { params: { id: string } }) {
 | 
			
		||||
  const { id } = await params;
 | 
			
		||||
 | 
			
		||||
  const gallery = await prisma.gallery.findUnique({
 | 
			
		||||
    where: {
 | 
			
		||||
      id,
 | 
			
		||||
    },
 | 
			
		||||
    include: {
 | 
			
		||||
      albums: true
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">Edit gallery</h1>
 | 
			
		||||
      {gallery ? <EditGalleryForm gallery={gallery} /> : 'Gallery not found...'}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								src/app/galleries/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/galleries/new/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
import CreateGalleryForm from "@/components/galleries/new/CreateGalleryForm";
 | 
			
		||||
 | 
			
		||||
export default function GalleriesNewPage() {
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <h1 className="text-2xl font-bold mb-4">New gallery</h1>
 | 
			
		||||
      <CreateGalleryForm />
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								src/app/galleries/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/app/galleries/page.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
import ListGalleries from "@/components/galleries/list/ListGalleries";
 | 
			
		||||
import prisma from "@/lib/prisma";
 | 
			
		||||
import { PlusCircleIcon } from "lucide-react";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default async function GalleriesPage() {
 | 
			
		||||
  const galleries = await prisma.gallery.findMany({ orderBy: { createdAt: "asc" } });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div>
 | 
			
		||||
      <div className="flex gap-4 justify-between">
 | 
			
		||||
        <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
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
      {galleries.length > 0 ? <ListGalleries galleries={galleries} /> : <p className="text-muted-foreground italic">No galleries found.</p>}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										152
									
								
								src/components/galleries/edit/EditGalleryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								src/components/galleries/edit/EditGalleryForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,152 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { deleteGallery } from "@/actions/galleries/deleteGallery";
 | 
			
		||||
import { updateGallery } from "@/actions/galleries/updateGallery";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Album, Gallery } from "@/generated/prisma";
 | 
			
		||||
import { gallerySchema } from "@/schemas/galleries/gallerySchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function EditGalleryForm({ gallery }: { gallery: Gallery & { albums: Album[] } }) {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof gallerySchema>>({
 | 
			
		||||
    resolver: zodResolver(gallerySchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: gallery.name,
 | 
			
		||||
      slug: gallery.slug,
 | 
			
		||||
      description: gallery.description || "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof gallerySchema>) {
 | 
			
		||||
    var updatedGallery = await updateGallery(values, gallery.id)
 | 
			
		||||
    if (updatedGallery) {
 | 
			
		||||
      toast.success("Gallery updated")
 | 
			
		||||
      router.push(`/galleries`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex flex-col gap-8">
 | 
			
		||||
      <Form {...form}>
 | 
			
		||||
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="name"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Gallery name</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Gallery name" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  This is your public display name.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="slug"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Gallery slug</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Gallery slug" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  Will be used for the navigation.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
          <FormField
 | 
			
		||||
            control={form.control}
 | 
			
		||||
            name="description"
 | 
			
		||||
            render={({ field }) => (
 | 
			
		||||
              <FormItem>
 | 
			
		||||
                <FormLabel>Gallery description</FormLabel>
 | 
			
		||||
                <FormControl>
 | 
			
		||||
                  <Input placeholder="Gallery description" {...field} />
 | 
			
		||||
                </FormControl>
 | 
			
		||||
                <FormDescription>
 | 
			
		||||
                  Description of the gallery.
 | 
			
		||||
                </FormDescription>
 | 
			
		||||
                <FormMessage />
 | 
			
		||||
              </FormItem>
 | 
			
		||||
            )}
 | 
			
		||||
          />
 | 
			
		||||
          <div className="flex flex-col gap-4">
 | 
			
		||||
            <Button type="submit">Submit</Button>
 | 
			
		||||
            <Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      </Form>
 | 
			
		||||
      <div className="pt-10">
 | 
			
		||||
        <h2 className="text-lg font-semibold mb-2">Albums in this Gallery</h2>
 | 
			
		||||
        {gallery.albums.length === 0 ? (
 | 
			
		||||
          <p className="text-muted-foreground italic">No albums yet.</p>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <ul className="space-y-2">
 | 
			
		||||
            {gallery.albums.map((album) => (
 | 
			
		||||
              <li key={album.id} className="flex items-center justify-between border rounded px-4 py-2">
 | 
			
		||||
                <div className="space-y-1">
 | 
			
		||||
                  <div className="font-medium">{album.name}</div>
 | 
			
		||||
                  <div className="text-sm text-muted-foreground">Slug: {album.slug}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="text-sm text-right">
 | 
			
		||||
                  {/* Replace this with actual image count later */}
 | 
			
		||||
                  <span className="font-mono">Images: 0</span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </li>
 | 
			
		||||
            ))}
 | 
			
		||||
          </ul>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
      <p className="text-sm text-muted-foreground">
 | 
			
		||||
        Total images in this gallery: <span className="font-semibold">0</span>
 | 
			
		||||
      </p>
 | 
			
		||||
      <div>
 | 
			
		||||
        {gallery.albums.length === 0 ? (
 | 
			
		||||
          <Button
 | 
			
		||||
            type="button"
 | 
			
		||||
            variant="destructive"
 | 
			
		||||
            onClick={async () => {
 | 
			
		||||
              await deleteGallery(gallery.id);
 | 
			
		||||
              toast.success("Gallery deleted");
 | 
			
		||||
              router.push("/galleries");
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            Delete Gallery
 | 
			
		||||
          </Button>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <>
 | 
			
		||||
            <Button
 | 
			
		||||
              type="button"
 | 
			
		||||
              variant="destructive"
 | 
			
		||||
              disabled
 | 
			
		||||
              onClick={async () => {
 | 
			
		||||
                await deleteGallery(gallery.id);
 | 
			
		||||
                toast.success("Gallery deleted");
 | 
			
		||||
                router.push("/galleries");
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              Delete Gallery
 | 
			
		||||
            </Button>
 | 
			
		||||
            <p className="text-sm text-muted-foreground italic">
 | 
			
		||||
              You must remove all albums before deleting this gallery.
 | 
			
		||||
            </p>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/galleries/list/ListGalleries.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/galleries/list/ListGalleries.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
// "use client"
 | 
			
		||||
 | 
			
		||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { Gallery } from "@/generated/prisma";
 | 
			
		||||
import Link from "next/link";
 | 
			
		||||
 | 
			
		||||
export default function ListGalleries({ galleries }: { galleries: Gallery[] }) {
 | 
			
		||||
  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>
 | 
			
		||||
          </Card>
 | 
			
		||||
        </Link>
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								src/components/galleries/new/CreateGalleryForm.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/components/galleries/new/CreateGalleryForm.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,88 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import { createGallery } from "@/actions/galleries/createGallery";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { gallerySchema } from "@/schemas/galleries/gallerySchema";
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
import { useForm } from "react-hook-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export default function CreateGalleryForm() {
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
  const form = useForm<z.infer<typeof gallerySchema>>({
 | 
			
		||||
    resolver: zodResolver(gallerySchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      name: "",
 | 
			
		||||
      slug: "",
 | 
			
		||||
      description: "",
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  async function onSubmit(values: z.infer<typeof gallerySchema>) {
 | 
			
		||||
    var gallery = await createGallery(values)
 | 
			
		||||
    if (gallery) {
 | 
			
		||||
      toast.success("Gallery created")
 | 
			
		||||
      router.push(`/galleries`)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Form {...form}>
 | 
			
		||||
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="name"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Gallery name</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Gallery name" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                This is your public display name.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="slug"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Gallery slug</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Gallery slug" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                Will be used for the navigation.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <FormField
 | 
			
		||||
          control={form.control}
 | 
			
		||||
          name="description"
 | 
			
		||||
          render={({ field }) => (
 | 
			
		||||
            <FormItem>
 | 
			
		||||
              <FormLabel>Gallery description</FormLabel>
 | 
			
		||||
              <FormControl>
 | 
			
		||||
                <Input placeholder="Gallery description" {...field} />
 | 
			
		||||
              </FormControl>
 | 
			
		||||
              <FormDescription>
 | 
			
		||||
                Description of the gallery.
 | 
			
		||||
              </FormDescription>
 | 
			
		||||
              <FormMessage />
 | 
			
		||||
            </FormItem>
 | 
			
		||||
          )}
 | 
			
		||||
        />
 | 
			
		||||
        <Button type="submit">Submit</Button>
 | 
			
		||||
      </form>
 | 
			
		||||
    </Form>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@ -14,7 +14,7 @@ export default function TopNav() {
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
        <NavigationMenuItem>
 | 
			
		||||
          <NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
 | 
			
		||||
            <Link href="/admin/tracks/upload">Uploads</Link>
 | 
			
		||||
            <Link href="/galleries">Galleries</Link>
 | 
			
		||||
          </NavigationMenuLink>
 | 
			
		||||
        </NavigationMenuItem>
 | 
			
		||||
      </NavigationMenuList>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/ui/card.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,92 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-header"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-title"
 | 
			
		||||
      className={cn("leading-none font-semibold", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-description"
 | 
			
		||||
      className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-action"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-content"
 | 
			
		||||
      className={cn("px-6", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="card-footer"
 | 
			
		||||
      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardFooter,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
  CardAction,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardContent,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										167
									
								
								src/components/ui/form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										167
									
								
								src/components/ui/form.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,167 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label"
 | 
			
		||||
import { Slot } from "@radix-ui/react-slot"
 | 
			
		||||
import {
 | 
			
		||||
  Controller,
 | 
			
		||||
  FormProvider,
 | 
			
		||||
  useFormContext,
 | 
			
		||||
  useFormState,
 | 
			
		||||
  type ControllerProps,
 | 
			
		||||
  type FieldPath,
 | 
			
		||||
  type FieldValues,
 | 
			
		||||
} from "react-hook-form"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
import { Label } from "@/components/ui/label"
 | 
			
		||||
 | 
			
		||||
const Form = FormProvider
 | 
			
		||||
 | 
			
		||||
type FormFieldContextValue<
 | 
			
		||||
  TFieldValues extends FieldValues = FieldValues,
 | 
			
		||||
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 | 
			
		||||
> = {
 | 
			
		||||
  name: TName
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
 | 
			
		||||
  {} as FormFieldContextValue
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const FormField = <
 | 
			
		||||
  TFieldValues extends FieldValues = FieldValues,
 | 
			
		||||
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
 | 
			
		||||
>({
 | 
			
		||||
  ...props
 | 
			
		||||
}: ControllerProps<TFieldValues, TName>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <FormFieldContext.Provider value={{ name: props.name }}>
 | 
			
		||||
      <Controller {...props} />
 | 
			
		||||
    </FormFieldContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const useFormField = () => {
 | 
			
		||||
  const fieldContext = React.useContext(FormFieldContext)
 | 
			
		||||
  const itemContext = React.useContext(FormItemContext)
 | 
			
		||||
  const { getFieldState } = useFormContext()
 | 
			
		||||
  const formState = useFormState({ name: fieldContext.name })
 | 
			
		||||
  const fieldState = getFieldState(fieldContext.name, formState)
 | 
			
		||||
 | 
			
		||||
  if (!fieldContext) {
 | 
			
		||||
    throw new Error("useFormField should be used within <FormField>")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { id } = itemContext
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    id,
 | 
			
		||||
    name: fieldContext.name,
 | 
			
		||||
    formItemId: `${id}-form-item`,
 | 
			
		||||
    formDescriptionId: `${id}-form-item-description`,
 | 
			
		||||
    formMessageId: `${id}-form-item-message`,
 | 
			
		||||
    ...fieldState,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type FormItemContextValue = {
 | 
			
		||||
  id: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const FormItemContext = React.createContext<FormItemContextValue>(
 | 
			
		||||
  {} as FormItemContextValue
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  const id = React.useId()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <FormItemContext.Provider value={{ id }}>
 | 
			
		||||
      <div
 | 
			
		||||
        data-slot="form-item"
 | 
			
		||||
        className={cn("grid gap-2", className)}
 | 
			
		||||
        {...props}
 | 
			
		||||
      />
 | 
			
		||||
    </FormItemContext.Provider>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  const { error, formItemId } = useFormField()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Label
 | 
			
		||||
      data-slot="form-label"
 | 
			
		||||
      data-error={!!error}
 | 
			
		||||
      className={cn("data-[error=true]:text-destructive", className)}
 | 
			
		||||
      htmlFor={formItemId}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
 | 
			
		||||
  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Slot
 | 
			
		||||
      data-slot="form-control"
 | 
			
		||||
      id={formItemId}
 | 
			
		||||
      aria-describedby={
 | 
			
		||||
        !error
 | 
			
		||||
          ? `${formDescriptionId}`
 | 
			
		||||
          : `${formDescriptionId} ${formMessageId}`
 | 
			
		||||
      }
 | 
			
		||||
      aria-invalid={!!error}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
 | 
			
		||||
  const { formDescriptionId } = useFormField()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <p
 | 
			
		||||
      data-slot="form-description"
 | 
			
		||||
      id={formDescriptionId}
 | 
			
		||||
      className={cn("text-muted-foreground text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
 | 
			
		||||
  const { error, formMessageId } = useFormField()
 | 
			
		||||
  const body = error ? String(error?.message ?? "") : props.children
 | 
			
		||||
 | 
			
		||||
  if (!body) {
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <p
 | 
			
		||||
      data-slot="form-message"
 | 
			
		||||
      id={formMessageId}
 | 
			
		||||
      className={cn("text-destructive text-sm", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {body}
 | 
			
		||||
    </p>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  useFormField,
 | 
			
		||||
  Form,
 | 
			
		||||
  FormItem,
 | 
			
		||||
  FormLabel,
 | 
			
		||||
  FormControl,
 | 
			
		||||
  FormDescription,
 | 
			
		||||
  FormMessage,
 | 
			
		||||
  FormField,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										21
									
								
								src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,21 @@
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <input
 | 
			
		||||
      type={type}
 | 
			
		||||
      data-slot="input"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
 | 
			
		||||
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
 | 
			
		||||
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Input }
 | 
			
		||||
							
								
								
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/ui/label.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Label({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <LabelPrimitive.Root
 | 
			
		||||
      data-slot="label"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Label }
 | 
			
		||||
							
								
								
									
										185
									
								
								src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								src/components/ui/select.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,185 @@
 | 
			
		||||
"use client"
 | 
			
		||||
 | 
			
		||||
import * as React from "react"
 | 
			
		||||
import * as SelectPrimitive from "@radix-ui/react-select"
 | 
			
		||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Select({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
 | 
			
		||||
  return <SelectPrimitive.Root data-slot="select" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectGroup({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
 | 
			
		||||
  return <SelectPrimitive.Group data-slot="select-group" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectValue({
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
 | 
			
		||||
  return <SelectPrimitive.Value data-slot="select-value" {...props} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectTrigger({
 | 
			
		||||
  className,
 | 
			
		||||
  size = "default",
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
 | 
			
		||||
  size?: "sm" | "default"
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Trigger
 | 
			
		||||
      data-slot="select-trigger"
 | 
			
		||||
      data-size={size}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
      <SelectPrimitive.Icon asChild>
 | 
			
		||||
        <ChevronDownIcon className="size-4 opacity-50" />
 | 
			
		||||
      </SelectPrimitive.Icon>
 | 
			
		||||
    </SelectPrimitive.Trigger>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectContent({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  position = "popper",
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Portal>
 | 
			
		||||
      <SelectPrimitive.Content
 | 
			
		||||
        data-slot="select-content"
 | 
			
		||||
        className={cn(
 | 
			
		||||
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
 | 
			
		||||
          position === "popper" &&
 | 
			
		||||
            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
 | 
			
		||||
          className
 | 
			
		||||
        )}
 | 
			
		||||
        position={position}
 | 
			
		||||
        {...props}
 | 
			
		||||
      >
 | 
			
		||||
        <SelectScrollUpButton />
 | 
			
		||||
        <SelectPrimitive.Viewport
 | 
			
		||||
          className={cn(
 | 
			
		||||
            "p-1",
 | 
			
		||||
            position === "popper" &&
 | 
			
		||||
              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
 | 
			
		||||
          )}
 | 
			
		||||
        >
 | 
			
		||||
          {children}
 | 
			
		||||
        </SelectPrimitive.Viewport>
 | 
			
		||||
        <SelectScrollDownButton />
 | 
			
		||||
      </SelectPrimitive.Content>
 | 
			
		||||
    </SelectPrimitive.Portal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectLabel({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Label
 | 
			
		||||
      data-slot="select-label"
 | 
			
		||||
      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectItem({
 | 
			
		||||
  className,
 | 
			
		||||
  children,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Item
 | 
			
		||||
      data-slot="select-item"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
 | 
			
		||||
        <SelectPrimitive.ItemIndicator>
 | 
			
		||||
          <CheckIcon className="size-4" />
 | 
			
		||||
        </SelectPrimitive.ItemIndicator>
 | 
			
		||||
      </span>
 | 
			
		||||
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
 | 
			
		||||
    </SelectPrimitive.Item>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectSeparator({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.Separator
 | 
			
		||||
      data-slot="select-separator"
 | 
			
		||||
      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollUpButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollUpButton
 | 
			
		||||
      data-slot="select-scroll-up-button"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronUpIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollUpButton>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectScrollDownButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <SelectPrimitive.ScrollDownButton
 | 
			
		||||
      data-slot="select-scroll-down-button"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "flex cursor-default items-center justify-center py-1",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <ChevronDownIcon className="size-4" />
 | 
			
		||||
    </SelectPrimitive.ScrollDownButton>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {
 | 
			
		||||
  Select,
 | 
			
		||||
  SelectContent,
 | 
			
		||||
  SelectGroup,
 | 
			
		||||
  SelectItem,
 | 
			
		||||
  SelectLabel,
 | 
			
		||||
  SelectScrollDownButton,
 | 
			
		||||
  SelectScrollUpButton,
 | 
			
		||||
  SelectSeparator,
 | 
			
		||||
  SelectTrigger,
 | 
			
		||||
  SelectValue,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								src/lib/prisma.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/lib/prisma.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
// import { PrismaClient } from '@/types/prisma'
 | 
			
		||||
// import { withAccelerate } from '@prisma/extension-accelerate'
 | 
			
		||||
 | 
			
		||||
import { PrismaClient } from "@/generated/prisma"
 | 
			
		||||
 | 
			
		||||
const globalForPrisma = global as unknown as { 
 | 
			
		||||
    prisma: PrismaClient
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const prisma = globalForPrisma.prisma || new PrismaClient()
 | 
			
		||||
 | 
			
		||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
 | 
			
		||||
 | 
			
		||||
export default prisma
 | 
			
		||||
							
								
								
									
										7
									
								
								src/schemas/galleries/gallerySchema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/schemas/galleries/gallerySchema.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
import * as z from "zod/v4";
 | 
			
		||||
 | 
			
		||||
export const gallerySchema = z.object({
 | 
			
		||||
  name: 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)"),
 | 
			
		||||
  description: z.string().optional(),
 | 
			
		||||
})
 | 
			
		||||
		Reference in New Issue
	
	Block a user