update admin pages for models and parts

This commit is contained in:
2025-11-25 09:18:59 +00:00
parent 5412abe0fe
commit 0a938d5a6c
31 changed files with 3215 additions and 522 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": false,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+905 -3
View File
@@ -9,12 +9,22 @@
"version": "0.1.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@supabase/supabase-js": "^2.84.0", "@supabase/supabase-js": "^2.84.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"lucide-react": "^0.554.0",
"next": "16.0.3", "next": "16.0.3",
"path": "^0.12.7", "path": "^0.12.7",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -426,6 +436,40 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1459,6 +1503,690 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"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=="
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.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-collection/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"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-dialog/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "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-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "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-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"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-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"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-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "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-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "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-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "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-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"dependencies": {
"@radix-ui/react-slot": "1.2.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-primitive/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@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.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@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-select/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",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"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-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"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.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"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/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2444,6 +3172,17 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true "dev": true
}, },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
@@ -2866,11 +3605,30 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3038,6 +3796,11 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3909,6 +4672,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/get-proto": { "node_modules/get-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -5008,6 +5779,14 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.554.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
"integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -5579,6 +6358,72 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true "dev": true
}, },
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -6187,11 +7032,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.17", "version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="
"dev": true },
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
@@ -6523,6 +7384,47 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util": { "node_modules/util": {
"version": "0.10.4", "version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
+11 -1
View File
@@ -9,12 +9,22 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@supabase/supabase-js": "^2.84.0", "@supabase/supabase-js": "^2.84.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fs": "^0.0.1-security", "fs": "^0.0.1-security",
"lucide-react": "^0.554.0",
"next": "16.0.3", "next": "16.0.3",
"path": "^0.12.7", "path": "^0.12.7",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
export default function AdminHeader() {
const pathname = usePathname();
// Convert pathname into readable title:
const title =
pathname.replace("/admin", "").replace("/", " ").trim() || "Dashboard";
return (
<header className="h-16 bg-white border-b flex items-center px-6">
<h1 className="text-xl font-semibold capitalize">
{title === "" ? "Dashboard" : title}
</h1>
</header>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export default function AdminSidebar() {
const pathname = usePathname();
const nav = [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin/models", label: "Models" },
{ href: "/admin/parts", label: "Parts" },
];
return (
<aside className="w-64 bg-white border-r min-h-screen p-4 flex flex-col">
<div className="text-2xl font-bold mb-8 pl-2">RC Admin</div>
<nav className="space-y-2">
{nav.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"block px-3 py-2 rounded text-sm font-medium",
active
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
)}
>
{item.label}
</Link>
);
})}
</nav>
<div className="flex-1" />
<div className="text-xs text-gray-400 pl-3 pb-4">
RC Compatibility Admin
</div>
</aside>
);
}
+11 -24
View File
@@ -1,33 +1,20 @@
import React from "react"; import AdminSidebar from "./components/AdminSidebar";
import Link from "next/link"; import AdminHeader from "./components/AdminHeader";
import "../globals.css";
export default function AdminLayout({ children }) { export default function AdminLayout({ children }) {
return ( return (
<div className="min-h-screen flex bg-gray-100"> <div className="min-h-screen flex bg-gray-100">
<aside className="w-64 bg-white shadow-md p-6"> {/* SIDEBAR */}
<h2 className="text-2xl font-bold mb-6 text-gray-900">Admin</h2> <AdminSidebar />
<nav className="space-y-4"> {/* MAIN COLUMN */}
<Link href="/admin" className="block hover:text-black text-gray-900"> <div className="flex-1 flex flex-col">
Dashboard {/* TOP HEADER */}
</Link> <AdminHeader />
<Link
href="/admin/models"
className="block hover:text-gray-300 text-gray-900"
>
Models
</Link>
<Link
href="/admin/parts"
className="block hover:text-gray-300 text-gray-900"
>
Parts
</Link>
</nav>
</aside>
<main className="flex-1 p-10 admin-text text-gray-900">{children}</main> {/* PAGE CONTENT */}
<main className="p-6">{children}</main>
</div>
</div> </div>
); );
} }
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddPartToModelPage({ params }) {
const { id: modelId } = use(params); // React 19 unwrap
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedPart, setSelectedPart] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// Search parts live
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
async function save() {
if (!selectedPart) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: selectedPart.id,
model_id: modelId,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/models/${modelId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH */}
<div className="space-y-2">
<Label>Search Parts</Label>
<Input
placeholder="Enter part name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedPart && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((p) => (
<button
key={p.id}
onClick={() => setSelectedPart(p)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{p.name}</div>
<div className="text-sm text-gray-500">
{p.manufacturer || "-"} {p.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED PART CARD */}
{selectedPart && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedPart.name}</div>
<div className="text-sm text-gray-500">
{selectedPart.manufacturer || "-"} {" "}
{selectedPart.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedPart(null)}
>
Change
</Button>
</div>
)}
{/* FIT TYPE + NOTES */}
{selectedPart && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose Fit Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="min-h-[100px]"
/>
</div>
{/* SAVE */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+288 -44
View File
@@ -1,55 +1,299 @@
"use client";
import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer"; import { supabaseBrowser } from "@/lib/supabaseClient";
export default async function ModelDetail({ params }) { import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
const { id } = params; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
const { data: model } = await supabaseServer import {
.from("models") Dialog,
.select("*") DialogTrigger,
.eq("id", id) DialogContent,
.single(); DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
const { data: compat } = await supabaseServer import {
.from("parts_compatible_models") Table,
.select("*, parts(*)") TableBody,
.eq("model_id", id); TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function ModelDetailPage({ params }) {
const { id } = use(params); // React 19: unwrap params
const router = useRouter();
const [model, setModel] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// Load model + compatibility
useEffect(() => {
async function load() {
setLoading(true);
const { data: modelData } = await supabaseBrowser
.from("models")
.select("*")
.eq("id", id)
.single();
setModel(modelData || null);
const { data: compatData } = await supabaseBrowser
.from("parts_compatible_models")
.select("*, parts(*)")
.eq("model_id", id);
setCompat(compatData || []);
setLoading(false);
}
load();
}, [id]);
// Save model changes
async function saveModel() {
if (!model) return;
setSaving(true);
let metadataObj = null;
if (typeof model.metadata === "string" && model.metadata.trim() !== "") {
try {
metadataObj = JSON.parse(model.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
const updatePayload = {
name: model.name,
category: model.category || null,
manufacturer: model.manufacturer || null,
year: model.year || null,
description: model.description || null,
metadata: metadataObj,
};
await supabaseBrowser.from("models").update(updatePayload).eq("id", id);
setSaving(false);
}
async function deleteModel() {
await supabaseBrowser.from("models").delete().eq("id", id);
router.push("/admin/models");
}
async function removeCompatibility(compatId) {
await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
}
// PREVENT NULL CRASHES
if (loading) return <p className="p-6">Loading model</p>;
if (!model) return <p className="p-6 text-red-600">Model not found.</p>;
return ( return (
<div> <div className="space-y-10">
<h1 className="text-3xl font-bold mb-4">{model?.name}</h1> {/* MODEL EDIT CARD */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Model</CardTitle>
</CardHeader>
<div className="bg-white p-4 shadow rounded mb-6"> <CardContent className="space-y-6">
<div className="text-gray-700">Brand: {model?.brand}</div> {/* NAME */}
<div className="text-gray-700">Scale: {model?.scale}</div> <div className="space-y-2">
<div className="text-gray-700">Chassis: {model?.chassis_type}</div> <Label>Name</Label>
</div> <Input
value={model?.name || ""}
<h2 className="text-2xl font-bold mb-2">Compatible Parts</h2> onChange={(e) => setModel({ ...model, name: e.target.value })}
/>
<Link
href={`/admin/models/${id}/add-part`}
className="inline-block mb-4 px-4 py-2 bg-black text-white rounded"
>
+ Add Compatible Part
</Link>
<div className="bg-white shadow rounded divide-y">
{compat?.map((c) => (
<div key={c.id} className="p-4 flex justify-between">
<div>
<div className="font-semibold">{c.parts.name}</div>
<div className="text-sm text-gray-500">{c.fit_type}</div>
</div>
<Link
href={`/admin/parts/${c.parts.id}`}
className="text-blue-600 underline"
>
View Part
</Link>
</div> </div>
))}
</div> {/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={model?.manufacturer || ""}
onChange={(e) =>
setModel({ ...model, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={model?.category || ""}
onChange={(e) =>
setModel({ ...model, category: e.target.value })
}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={model?.year || ""}
onChange={(e) => setModel({ ...model, year: e.target.value })}
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={model?.description || ""}
onChange={(e) =>
setModel({ ...model, description: e.target.value })
}
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[120px]"
value={
typeof model.metadata === "object"
? JSON.stringify(model.metadata, null, 2)
: model?.metadata || ""
}
onChange={(e) => setModel({ ...model, metadata: e.target.value })}
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex items-center justify-between pt-4">
<Button onClick={saveModel} disabled={saving}>
{saving ? "Saving…" : "Save Changes"}
</Button>
{/* DELETE WITH DIALOG */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Model</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This action cannot be undone. This will permanently delete the
model and all compatibility links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deleteModel}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
{/* COMPATIBILITY TABLE */}
<Card>
<CardHeader className="flex justify-between items-center">
<CardTitle className="text-2xl">Compatible Parts</CardTitle>
<Button asChild>
<Link href={`/admin/models/${id}/add-part`}>
+ Add Compatible Part
</Link>
</Button>
</CardHeader>
<Separator />
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Part</TableHead>
<TableHead>Fit Type</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compat.map((c) => (
<TableRow key={c.id}>
<TableCell>{c.parts?.name}</TableCell>
<TableCell>
<Badge variant="secondary">
{c.fit_type?.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>{c.notes || "-"}</TableCell>
<TableCell className="text-right">
<Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No compatible parts found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div> </div>
); );
} }
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreateModelPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
year: "",
description: "",
metadata: "",
});
async function save() {
let metadataObj = null;
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON");
return;
}
}
const { error } = await supabaseBrowser.from("models").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
year: form.year || null,
description: form.description || null,
metadata: metadataObj,
});
if (!error) {
router.push("/admin/models");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
</div>
<div>
<Label>Year</Label>
<Input
type="number"
value={form.year}
onChange={(e) => setForm({ ...form, year: e.target.value })}
/>
</div>
</div>
<div>
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
/>
</div>
<div className="flex justify-between">
<Button onClick={save}>Create Model</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/models")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { useState } from "react";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { useRouter } from "next/navigation";
export default function NewModelPage() {
const [form, setForm] = useState({
name: "",
brand: "",
scale: "",
chassis_type: "",
notes: "",
});
const router = useRouter();
async function save() {
const { error } = await supabaseBrowser.from("models").insert(form);
if (!error) router.push("/admin/models");
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Add New Model</h1>
<div className="space-y-4 max-w-lg">
{Object.entries(form).map(([key, value]) => (
<div key={key}>
<label className="block mb-1 capitalize">{key.replace("_", " ")}</label>
<input
className="w-full p-2 border rounded"
value={value}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
/>
</div>
))}
<button
onClick={save}
className="px-4 py-2 bg-black text-white rounded"
>
Save
</button>
</div>
</div>
);
}
+203 -35
View File
@@ -1,43 +1,211 @@
import Link from "next/link"; "use client";
import { supabaseServer } from "@/lib/supabaseServer";
export default async function ModelsPage() { import { useEffect, useState, useMemo } from "react";
const { data: models } = await supabaseServer import Link from "next/link";
.from("models") import { supabaseBrowser } from "@/lib/supabaseClient";
.select("*")
.order("name"); import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function ModelsListPage() {
const [models, setModels] = useState([]);
const [loading, setLoading] = useState(true);
// Search / Filter / Sort states
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc | newest | oldest
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("models")
.select("*")
.order("name", { ascending: true });
setModels(data || []);
setLoading(false);
}
load();
}, []);
// ----------------------------------------------
// 🎯 FILTERING + SEARCH + SORTING (client-side)
// ----------------------------------------------
const filteredModels = useMemo(() => {
let list = [...models];
// SEARCH
if (search.trim()) {
list = list.filter((m) =>
m.name.toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((m) => m.manufacturer === manufacturerFilter);
}
// SORTING
switch (sort) {
case "asc":
list.sort((a, b) => a.name.localeCompare(b.name));
break;
case "desc":
list.sort((a, b) => b.name.localeCompare(a.name));
break;
case "newest":
list.sort((a, b) => (b.year || 0) - (a.year || 0));
break;
case "oldest":
list.sort((a, b) => (a.year || 0) - (b.year || 0));
break;
}
return list;
}, [models, search, manufacturerFilter, sort]);
// SKELETON
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
}
// Collect manufacturers for filter dropdown
const manufacturers = Array.from(
new Set(models.map((m) => m.manufacturer).filter(Boolean))
);
return ( return (
<div> <Card>
<div className="flex justify-between mb-6"> <CardHeader className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Models</h1> <CardTitle className="text-2xl">Models</CardTitle>
<Link
href="/admin/models/new"
className="px-4 py-2 bg-black text-white rounded"
>
+ Add Model
</Link>
</div>
{(!models || models.length === 0) && ( <Button asChild>
<p className="text-gray-700"> <Link href="/admin/models/create">+ New Model</Link>
No models found yet. Click &ldquo;Add Model&rdquo; to create your </Button>
first one. </CardHeader>
</p>
)}
<div className="bg-white shadow rounded-lg divide-y"> <CardContent className="space-y-4">
{models?.map((m) => ( {/* SEARCH + FILTERS + SORT BAR */}
<Link <div className="flex flex-col md:flex-row gap-4 items-center">
key={m.id} {/* SEARCH */}
href={`/admin/models/${m.id}`} <Input
className="block p-4 hover:bg-gray-50" placeholder="Search models…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{/* MANUFACTURER FILTER */}
<Select
value={manufacturerFilter}
onValueChange={setManufacturerFilter}
> >
<div className="font-semibold">{m.name}</div> <SelectTrigger className="w-48">
<div className="text-sm text-gray-500">{m.brand}</div> <SelectValue placeholder="Manufacturer" />
</Link> </SelectTrigger>
))}
</div> <SelectContent>
</div> <SelectItem value="all">All Manufacturers</SelectItem>
{manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
<SelectItem value="newest">Year (Newest)</SelectItem>
<SelectItem value="oldest">Year (Oldest)</SelectItem>
</SelectContent>
</Select>
</div>
{/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>Year</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredModels.map((m) => (
<TableRow key={m.id}>
<TableCell>{m.name}</TableCell>
<TableCell>{m.manufacturer || "-"}</TableCell>
<TableCell>{m.category || "-"}</TableCell>
<TableCell>{m.year || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/models/${m.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredModels.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No models found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
); );
} }
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { use, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
export default function AddModelToPartPage({ params }) {
const { id: partId } = use(params); // React 19 unwrapping
const router = useRouter();
const [search, setSearch] = useState("");
const [results, setResults] = useState([]);
const [selectedModel, setSelectedModel] = useState(null);
const [fitType, setFitType] = useState("direct");
const [notes, setNotes] = useState("");
// 🔍 Live search for models
useEffect(() => {
async function load() {
if (!search.trim()) {
setResults([]);
return;
}
const { data } = await supabaseBrowser
.from("models")
.select("*")
.ilike("name", `%${search}%`)
.limit(20);
setResults(data || []);
}
load();
}, [search]);
// 💾 Save compatibility entry
async function save() {
if (!selectedModel) return;
await supabaseBrowser.from("parts_compatible_models").insert({
part_id: partId,
model_id: selectedModel.id,
fit_type: fitType,
notes: notes || null,
});
router.push(`/admin/parts/${partId}`);
}
return (
<Card className="max-w-2xl mx-auto p-4">
<CardHeader>
<CardTitle className="text-2xl">Add Compatible Model</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* SEARCH FIELD */}
<div className="space-y-2">
<Label>Search Models</Label>
<Input
placeholder="Enter model name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* SEARCH RESULTS */}
{!selectedModel && results.length > 0 && (
<div className="border rounded divide-y">
{results.map((m) => (
<button
key={m.id}
onClick={() => setSelectedModel(m)}
className="w-full text-left p-3 hover:bg-gray-50"
>
<div className="font-medium">{m.name}</div>
<div className="text-sm text-gray-500">
{m.manufacturer || "-"} {m.category || "-"}
</div>
</button>
))}
</div>
)}
{/* SELECTED MODEL CARD */}
{selectedModel && (
<div className="p-4 border rounded bg-gray-50">
<div className="font-semibold">{selectedModel.name}</div>
<div className="text-sm text-gray-500">
{selectedModel.manufacturer || "-"} {" "}
{selectedModel.category || "-"}
</div>
<Button
variant="secondary"
size="sm"
className="mt-2"
onClick={() => setSelectedModel(null)}
>
Change
</Button>
</div>
)}
{/* FIT-TYPE + NOTES */}
{selectedModel && (
<>
<div className="space-y-2">
<Label>Fit Type</Label>
<Select value={fitType} onValueChange={setFitType}>
<SelectTrigger>
<SelectValue placeholder="Choose fit type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct">Direct Fit</SelectItem>
<SelectItem value="mod_required">
Modification Required
</SelectItem>
<SelectItem value="not_recommended">
Not Recommended
</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Notes (optional)</Label>
<Textarea
className="min-h-[100px]"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
{/* SAVE BUTTON */}
<Button onClick={save} className="mt-4">
Save Compatibility
</Button>
</>
)}
{/* CANCEL BUTTON */}
<Button
variant="secondary"
className="mt-4"
onClick={() => router.back()}
>
Cancel
</Button>
</CardContent>
</Card>
);
}
+226 -155
View File
@@ -1,221 +1,292 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { use, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabaseBrowser } from "@/lib/supabaseClient"; import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function PartDetailPage({ params }) { export default function PartDetailPage({ params }) {
const { id } = params; const { id } = use(params); // React 19 unwrap
const router = useRouter(); const router = useRouter();
const [part, setPart] = useState(null);
const [compat, setCompat] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [form, setForm] = useState({ const [deleteOpen, setDeleteOpen] = useState(false);
name: "",
manufacturer: "",
sku: "",
category: "",
description: "",
metadata: "",
});
// Load part data + compatibility
useEffect(() => { useEffect(() => {
async function fetchPart() { async function load() {
setLoading(true); setLoading(true);
setErrorMsg("");
const { data, error } = await supabaseBrowser const { data: partData } = await supabaseBrowser
.from("parts") .from("parts")
.select("*") .select("*")
.eq("id", id) .eq("id", id)
.single(); .single();
if (error) { setPart(partData || null);
console.error(error);
setErrorMsg("Failed to load part.");
setLoading(false);
return;
}
setForm({ const { data: compatData } = await supabaseBrowser
name: data.name || "", .from("parts_compatible_models")
manufacturer: data.manufacturer || "", .select("*, models(*)")
sku: data.sku || "", .eq("part_id", id);
category: data.category || "",
description: data.description || "",
metadata: data.metadata ? JSON.stringify(data.metadata, null, 2) : "",
});
setCompat(compatData || []);
setLoading(false); setLoading(false);
} }
fetchPart(); load();
}, [id]); }, [id]);
function updateField(field, value) { // Save part edits
setForm((prev) => ({ ...prev, [field]: value })); async function savePart() {
} if (!part) return;
async function handleSave() {
setSaving(true); setSaving(true);
setErrorMsg("");
let metadata = null; let metadataObj = null;
if (form.metadata.trim()) {
if (typeof part.metadata === "string" && part.metadata.trim() !== "") {
try { try {
metadata = JSON.parse(form.metadata); metadataObj = JSON.parse(part.metadata);
} catch (err) { } catch {
alert("Metadata must be valid JSON.");
setSaving(false); setSaving(false);
setErrorMsg("Metadata must be valid JSON.");
return; return;
} }
} }
const payload = { const updatePayload = {
name: form.name.trim(), name: part.name,
manufacturer: form.manufacturer || null, manufacturer: part.manufacturer || null,
sku: form.sku || null, sku: part.sku || null,
category: form.category || null, category: part.category || null,
description: form.description || null, description: part.description || null,
metadata, metadata: metadataObj,
}; };
const { error } = await supabaseBrowser await supabaseBrowser.from("parts").update(updatePayload).eq("id", id);
.from("parts")
.update(payload)
.eq("id", id);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to save changes.");
setSaving(false);
return;
}
setSaving(false); setSaving(false);
} }
async function handleDelete() { async function deletePart() {
const ok = window.confirm( await supabaseBrowser.from("parts").delete().eq("id", id);
"Are you sure you want to delete this part? This cannot be undone."
);
if (!ok) return;
setDeleting(true);
setErrorMsg("");
const { error } = await supabaseBrowser.from("parts").delete().eq("id", id);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to delete part.");
setDeleting(false);
return;
}
router.push("/admin/parts"); router.push("/admin/parts");
} }
if (loading) { async function removeCompatibility(compatId) {
return <p>Loading part...</p>; await supabaseBrowser
.from("parts_compatible_models")
.delete()
.eq("id", compatId);
setCompat((prev) => prev.filter((c) => c.id !== compatId));
} }
// Prevent render crashes
if (loading) return <p className="p-6">Loading part</p>;
if (!part) return <p className="p-6 text-red-600">Part not found.</p>;
return ( return (
<div> <div className="space-y-10">
<h1 className="text-2xl font-bold mb-6">Edit Part</h1> {/* PART EDIT FORM */}
<Card>
<CardHeader>
<CardTitle className="text-3xl">Edit Part</CardTitle>
</CardHeader>
<div className="space-y-4 max-w-2xl bg-white p-6 rounded shadow"> <CardContent className="space-y-6">
<div> <div className="space-y-2">
<label className="block mb-1 font-medium">Name *</label> <Label>Name</Label>
<input <Input
className="w-full p-2 border rounded" value={part?.name || ""}
value={form.name} onChange={(e) => setPart({ ...part, name: e.target.value })}
onChange={(e) => updateField("name", e.target.value)} />
/> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={part?.manufacturer || ""}
onChange={(e) =>
setPart({ ...part, manufacturer: e.target.value })
}
/>
</div>
<div>
<Label>Category</Label>
<Input
value={part?.category || ""}
onChange={(e) => setPart({ ...part, category: e.target.value })}
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={part?.sku || ""}
onChange={(e) => setPart({ ...part, sku: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block mb-1 font-medium">Manufacturer</label> <Label>Description</Label>
<input <Textarea
className="w-full p-2 border rounded" className="min-h-[100px]"
value={form.manufacturer} value={part?.description || ""}
onChange={(e) => updateField("manufacturer", e.target.value)} onChange={(e) =>
setPart({ ...part, description: e.target.value })
}
/> />
</div> </div>
<div> <div>
<label className="block mb-1 font-medium">SKU</label> <Label>Metadata (JSON)</Label>
<input <Textarea
className="w-full p-2 border rounded" className="font-mono text-sm min-h-[100px]"
value={form.sku} value={
onChange={(e) => updateField("sku", e.target.value)} typeof part.metadata === "object"
? JSON.stringify(part.metadata, null, 2)
: part?.metadata || ""
}
onChange={(e) => setPart({ ...part, metadata: e.target.value })}
/> />
</div> </div>
<div> {/* SAVE + DELETE */}
<label className="block mb-1 font-medium">Category</label> <div className="flex items-center justify-between pt-4">
<input <Button onClick={savePart} disabled={saving}>
className="w-full p-2 border rounded" {saving ? "Saving…" : "Save Changes"}
value={form.category} </Button>
onChange={(e) => updateField("category", e.target.value)}
/> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild>
<Button variant="destructive">Delete Part</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm deletion</DialogTitle>
</DialogHeader>
<p className="text-gray-600 mb-4">
This will permanently delete the part and all compatibility
links.
</p>
<DialogFooter>
<Button
variant="secondary"
onClick={() => setDeleteOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={deletePart}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </CardContent>
</Card>
<div> {/* COMPATIBLE MODELS TABLE */}
<label className="block mb-1 font-medium">Description</label> <Card>
<textarea <CardHeader className="flex justify-between items-center">
className="w-full p-2 border rounded min-h-[80px]" <CardTitle className="text-2xl">Compatible Models</CardTitle>
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
<div> <Button asChild>
<label className="block mb-1 font-medium"> <Link href={`/admin/parts/${id}/add-model`}>
Metadata (JSON){" "} + Add Compatible Model
<span className="text-xs text-gray-500">(optional)</span> </Link>
</label> </Button>
<textarea </CardHeader>
className="w-full p-2 border rounded font-mono text-sm min-h-[120px]"
value={form.metadata}
onChange={(e) => updateField("metadata", e.target.value)}
/>
</div>
{errorMsg && <p className="text-red-600 text-sm">{errorMsg}</p>} <Separator />
<div className="flex gap-3 items-center"> <CardContent>
<button <Table>
onClick={handleSave} <TableHeader>
disabled={saving || !form.name.trim()} <TableRow>
className="px-4 py-2 bg-black text-white rounded disabled:opacity-50" <TableHead>Model</TableHead>
> <TableHead>Fit Type</TableHead>
{saving ? "Saving..." : "Save Changes"} <TableHead>Notes</TableHead>
</button> <TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<button <TableBody>
type="button" {compat.map((c) => (
onClick={() => router.push("/admin/parts")} <TableRow key={c.id}>
className="px-4 py-2 border rounded" <TableCell>{c.models?.name}</TableCell>
>
Back to Parts
</button>
<button <TableCell>
type="button" <Badge variant="secondary">
onClick={handleDelete} {c.fit_type?.replace("_", " ")}
disabled={deleting} </Badge>
className="ml-auto px-4 py-2 bg-red-600 text-white rounded disabled:opacity-50" </TableCell>
>
{deleting ? "Deleting..." : "Delete Part"} <TableCell>{c.notes || "-"}</TableCell>
</button>
</div> <TableCell className="text-right">
</div> <Button
variant="destructive"
size="sm"
onClick={() => removeCompatibility(c.id)}
>
Remove
</Button>
</TableCell>
</TableRow>
))}
{compat.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-gray-500 py-6"
>
No models linked to this part.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div> </div>
); );
} }
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function CreatePartPage() {
const router = useRouter();
const [form, setForm] = useState({
name: "",
manufacturer: "",
category: "",
sku: "",
description: "",
metadata: "",
});
const [saving, setSaving] = useState(false);
async function save() {
setSaving(true);
let metadataObj = null;
// Parse metadata if provided
if (form.metadata.trim()) {
try {
metadataObj = JSON.parse(form.metadata);
} catch {
alert("Metadata must be valid JSON.");
setSaving(false);
return;
}
}
// Insert into Supabase
const { error } = await supabaseBrowser.from("parts").insert({
name: form.name,
manufacturer: form.manufacturer || null,
category: form.category || null,
sku: form.sku || null,
description: form.description || null,
metadata: metadataObj,
});
setSaving(false);
if (!error) {
router.push("/admin/parts");
} else {
alert(error.message);
}
}
return (
<Card className="max-w-2xl mx-auto p-6">
<CardHeader>
<CardTitle className="text-2xl">Create New Part</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* NAME */}
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Tamiya Hotshot Gearbox"
/>
</div>
{/* GRID FIELDS */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label>Manufacturer</Label>
<Input
value={form.manufacturer}
onChange={(e) =>
setForm({ ...form, manufacturer: e.target.value })
}
placeholder="e.g., Tamiya"
/>
</div>
<div>
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
placeholder="e.g., drivetrain"
/>
</div>
<div>
<Label>SKU</Label>
<Input
value={form.sku}
onChange={(e) => setForm({ ...form, sku: e.target.value })}
placeholder="e.g., TAM-198055"
/>
</div>
</div>
{/* DESCRIPTION */}
<div>
<Label>Description</Label>
<Textarea
className="min-h-[100px]"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Optional part description..."
/>
</div>
{/* METADATA */}
<div>
<Label>Metadata (JSON)</Label>
<Textarea
className="font-mono text-sm min-h-[100px]"
value={form.metadata}
onChange={(e) => setForm({ ...form, metadata: e.target.value })}
placeholder='{ "color": "red", "material": "plastic" }'
/>
</div>
{/* ACTION BUTTONS */}
<div className="flex justify-between pt-4">
<Button onClick={save} disabled={saving}>
{saving ? "Creating…" : "Create Part"}
</Button>
<Button
variant="secondary"
onClick={() => router.push("/admin/parts")}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
);
}
-151
View File
@@ -1,151 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabaseClient";
export default function NewPartPage() {
const [form, setForm] = useState({
name: "",
manufacturer: "",
sku: "",
category: "",
description: "",
metadata: "", // JSON as text
});
const [saving, setSaving] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const router = useRouter();
function updateField(field, value) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSave() {
setSaving(true);
setErrorMsg("");
let metadata = null;
if (form.metadata.trim()) {
try {
metadata = JSON.parse(form.metadata);
} catch (err) {
setSaving(false);
setErrorMsg("Metadata must be valid JSON.");
return;
}
}
const payload = {
name: form.name.trim(),
manufacturer: form.manufacturer || null,
sku: form.sku || null,
category: form.category || null,
description: form.description || null,
metadata,
};
const { error } = await supabaseBrowser.from("parts").insert(payload);
if (error) {
console.error(error);
setErrorMsg(error.message || "Failed to save part.");
setSaving(false);
return;
}
router.push("/admin/parts");
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Add New Part</h1>
<div className="space-y-4 max-w-2xl bg-white p-6 rounded shadow">
<div>
<label className="block mb-1 font-medium">Name *</label>
<input
className="w-full p-2 border rounded"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="e.g. Alloy Suspension Arm Set"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block mb-1 font-medium">Manufacturer</label>
<input
className="w-full p-2 border rounded"
value={form.manufacturer}
onChange={(e) => updateField("manufacturer", e.target.value)}
/>
</div>
<div>
<label className="block mb-1 font-medium">SKU</label>
<input
className="w-full p-2 border rounded"
value={form.sku}
onChange={(e) => updateField("sku", e.target.value)}
placeholder="e.g. 53620"
/>
</div>
<div>
<label className="block mb-1 font-medium">Category</label>
<input
className="w-full p-2 border rounded"
value={form.category}
onChange={(e) => updateField("category", e.target.value)}
placeholder="e.g. suspension, drivetrain"
/>
</div>
</div>
<div>
<label className="block mb-1 font-medium">Description</label>
<textarea
className="w-full p-2 border rounded min-h-[80px]"
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
<div>
<label className="block mb-1 font-medium">
Metadata (JSON){" "}
<span className="text-xs text-gray-500">(optional)</span>
</label>
<textarea
className="w-full p-2 border rounded font-mono text-sm min-h-[100px]"
placeholder='e.g. { "material": "aluminium", "color": "blue" }'
value={form.metadata}
onChange={(e) => updateField("metadata", e.target.value)}
/>
</div>
{errorMsg && <p className="text-red-600 text-sm">{errorMsg}</p>}
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving || !form.name.trim()}
className="px-4 py-2 bg-black text-white rounded disabled:opacity-50"
>
{saving ? "Saving..." : "Save Part"}
</button>
<button
type="button"
onClick={() => router.push("/admin/parts")}
className="px-4 py-2 border rounded"
>
Cancel
</button>
</div>
</div>
</div>
);
}
+223 -47
View File
@@ -1,60 +1,236 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { supabaseServer } from "@/lib/supabaseServer"; import { supabaseBrowser } from "@/lib/supabaseClient";
export default async function PartsPage() { import { Button } from "@/components/ui/button";
const { data: parts, error } = await supabaseServer import { Input } from "@/components/ui/input";
.from("parts")
.select("*")
.order("name", { ascending: true });
console.log("here"); import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
if (error) { import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
console.error("Error loading parts:", error);
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell,
} from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
export default function PartsListPage() {
const [parts, setParts] = useState([]);
const [loading, setLoading] = useState(true);
// UI state: Search, filters, sorting
const [search, setSearch] = useState("");
const [manufacturerFilter, setManufacturerFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [sort, setSort] = useState("asc"); // asc | desc
useEffect(() => {
async function load() {
setLoading(true);
const { data } = await supabaseBrowser
.from("parts")
.select("*")
.order("name", { ascending: true });
setParts(data || []);
setLoading(false);
}
load();
}, []);
// --------------------------------------------------------
// 🎯 FILTER + SEARCH + SORT (client-side, fast)
// --------------------------------------------------------
const filteredParts = useMemo(() => {
let list = [...parts];
// SEARCH
if (search.trim()) {
list = list.filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.sku || "").toLowerCase().includes(search.toLowerCase())
);
}
// MANUFACTURER FILTER
if (manufacturerFilter !== "all") {
list = list.filter((p) => p.manufacturer === manufacturerFilter);
}
// CATEGORY FILTER
if (categoryFilter !== "all") {
list = list.filter((p) => p.category === categoryFilter);
}
// SORTING
if (sort === "asc") {
list.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort === "desc") {
list.sort((a, b) => b.name.localeCompare(a.name));
}
return list;
}, [parts, search, manufacturerFilter, categoryFilter, sort]);
// --------------------------------------------------------
// 🦴 SKELETON LOADING
// --------------------------------------------------------
if (loading) {
return (
<Card>
<CardHeader className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-32" />
</CardHeader>
<CardContent>
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full mb-2" />
))}
</CardContent>
</Card>
);
} }
// Collect filter options
const manufacturers = Array.from(
new Set(parts.map((p) => p.manufacturer).filter(Boolean))
);
const categories = Array.from(
new Set(parts.map((p) => p.category).filter(Boolean))
);
// --------------------------------------------------------
// 🌟 RENDER UI
// --------------------------------------------------------
return ( return (
<div> <Card>
<div className="flex justify-between mb-6 items-center"> <CardHeader className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Parts</h1> <CardTitle className="text-2xl">Parts</CardTitle>
<Link
href="/admin/parts/new"
className="px-4 py-2 bg-black text-white rounded hover:bg-gray-800"
>
+ Add Part
</Link>
</div>
{(!parts || parts.length === 0) && ( <Button asChild>
<p className="text-gray-700"> <Link href="/admin/parts/create">+ New Part</Link>
No parts found yet. Click &ldquo;Add Part&rdquo; to create your first </Button>
one. </CardHeader>
</p>
)}
{parts && parts.length > 0 && ( <CardContent className="space-y-4">
<div className="bg-white shadow rounded-lg overflow-hidden"> {/* SEARCH + FILTERS + SORT BAR */}
<div className="grid grid-cols-4 gap-4 px-4 py-2 border-b bg-gray-50 text-sm font-semibold"> <div className="flex flex-col md:flex-row gap-4 items-center">
<div>Name</div> {/* SEARCH */}
<div>Manufacturer</div> <Input
<div>SKU</div> placeholder="Search parts…"
<div>Category</div> value={search}
</div> onChange={(e) => setSearch(e.target.value)}
className="md:w-1/3"
/>
{parts.map((p) => ( {/* MANUFACTURER FILTER */}
<Link <Select
key={p.id} value={manufacturerFilter}
href={`/admin/parts/${p.id}`} onValueChange={setManufacturerFilter}
className="grid grid-cols-4 gap-4 px-4 py-3 border-b hover:bg-gray-50 text-sm text-gray-900" >
> <SelectTrigger className="w-48">
<div className="font-semibold">{p.name}</div> <SelectValue placeholder="Manufacturer" />
<div>{p.manufacturer || "-"}</div> </SelectTrigger>
<div>{p.sku || "-"}</div>
<div>{p.category || "-"}</div> <SelectContent>
</Link> <SelectItem value="all">All Manufacturers</SelectItem>
))} {manufacturers.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{/* CATEGORY FILTER */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
</SelectContent>
</Select>
{/* SORTING */}
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc">Name (AZ)</SelectItem>
<SelectItem value="desc">Name (ZA)</SelectItem>
</SelectContent>
</Select>
</div> </div>
)}
</div> {/* TABLE */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Category</TableHead>
<TableHead>SKU</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredParts.map((p) => (
<TableRow key={p.id}>
<TableCell>{p.name}</TableCell>
<TableCell>{p.manufacturer || "-"}</TableCell>
<TableCell>{p.category || "-"}</TableCell>
<TableCell>{p.sku || "-"}</TableCell>
<TableCell className="text-right">
<Button asChild variant="secondary" size="sm">
<Link href={`/admin/parts/${p.id}`}>View</Link>
</Button>
</TableCell>
</TableRow>
))}
{filteredParts.length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-10 text-gray-500"
>
No parts match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
); );
} }
+112 -14
View File
@@ -1,26 +1,124 @@
@import "tailwindcss"; @import "tailwindcss";
:root { @plugin "tailwindcss-animate";
--background: #ffffff; @use "tw-animate-css";
--foreground: #171717;
} @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
} }
@media (prefers-color-scheme: dark) { :root {
:root { --radius: 0.625rem;
--background: #0a0a0a; --background: oklch(1 0 0);
--foreground: #ededed; --foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
} }
} }
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+34
View File
@@ -0,0 +1,34 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
}
export { Badge, badgeVariants }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp suppressHydrationWarning
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />
);
})
Button.displayName = "Button"
export { Button, buttonVariants }
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+96
View File
@@ -0,0 +1,96 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props} />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props} />
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props} />
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props} />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input suppressHydrationWarning
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Input.displayName = "Input"
export { Input }
+18
View File
@@ -0,0 +1,18 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+121
View File
@@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
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)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+14
View File
@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props} />
);
}
export { Skeleton }
+86
View File
@@ -0,0 +1,86 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props} />
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
<textarea suppressHydrationWarning
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props} />
);
})
Textarea.displayName = "Textarea"
export { Textarea }
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}