mirror of
https://github.com/NicholaiVogel/dashore-incubator.git
synced 2026-03-30 22:38:56 +00:00
Merge pull request #3 from NicholaiVogel/feat/item-upvote-downvote
feat(wishlist): add reddit-style upvote/downvote for items
This commit is contained in:
commit
186c1c21b8
48
bun.lock
48
bun.lock
@ -47,6 +47,8 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jspdf": "^4.0.0",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -791,8 +793,12 @@
|
|||||||
|
|
||||||
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||||
|
|
||||||
|
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
|
||||||
|
|
||||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||||
|
|
||||||
|
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
|
||||||
|
|
||||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||||
|
|
||||||
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
|
||||||
@ -803,6 +809,8 @@
|
|||||||
|
|
||||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="],
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="],
|
||||||
|
|
||||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="],
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="],
|
||||||
@ -923,6 +931,8 @@
|
|||||||
|
|
||||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||||
@ -951,6 +961,8 @@
|
|||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
|
||||||
|
|
||||||
|
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||||
@ -983,8 +995,12 @@
|
|||||||
|
|
||||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||||
|
|
||||||
|
"core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||||
@ -1043,6 +1059,8 @@
|
|||||||
|
|
||||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||||
|
|
||||||
|
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
|
||||||
@ -1153,12 +1171,16 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["fast-xml-parser@4.2.5", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g=="],
|
"fast-xml-parser": ["fast-xml-parser@4.2.5", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-B9/wizE4WngqQftFPmdaMYlXoJlJOYxGQOanC77fq9k8+Z0v5dDSVh+3glErdIROP//s/jgb7ZuxKfB8nVyo0g=="],
|
||||||
|
|
||||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||||
@ -1241,6 +1263,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
|
||||||
|
|
||||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||||
|
|
||||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||||
@ -1265,6 +1289,8 @@
|
|||||||
|
|
||||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||||
|
|
||||||
|
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||||
|
|
||||||
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
|
"iron-session": ["iron-session@8.0.4", "", { "dependencies": { "cookie": "^0.7.2", "iron-webcrypto": "^1.2.1", "uncrypto": "^0.1.3" } }, "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA=="],
|
||||||
@ -1353,6 +1379,10 @@
|
|||||||
|
|
||||||
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
|
||||||
|
|
||||||
|
"jspdf": ["jspdf@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.2.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-w12U97Z6edKd2tXDn3LzTLg7C7QLJlx0BPfM3ecjK2BckUl9/81vZ+r5gK4/3KQdhAcEZhENUxRhtgYBj75MqQ=="],
|
||||||
|
|
||||||
|
"jspdf-autotable": ["jspdf-autotable@5.0.7", "", { "peerDependencies": { "jspdf": "^2 || ^3 || ^4" } }, "sha512-2wr7H6liNDBYNwt25hMQwXkEWFOEopgKIvR1Eukuw6Zmprm/ZcnmLTQEjW7Xx3FCbD3v7pflLcnMAv/h1jFDQw=="],
|
||||||
|
|
||||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
@ -1491,6 +1521,8 @@
|
|||||||
|
|
||||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
|
"pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
@ -1507,6 +1539,8 @@
|
|||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
@ -1531,6 +1565,8 @@
|
|||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
|
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
|
||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
@ -1563,6 +1599,8 @@
|
|||||||
|
|
||||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||||
|
|
||||||
|
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
|
||||||
|
|
||||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
@ -1573,6 +1611,8 @@
|
|||||||
|
|
||||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||||
|
|
||||||
|
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
|
||||||
|
|
||||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
@ -1627,6 +1667,8 @@
|
|||||||
|
|
||||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||||
|
|
||||||
|
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
@ -1665,6 +1707,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
@ -1673,6 +1717,8 @@
|
|||||||
|
|
||||||
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
|
||||||
|
|
||||||
|
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
|
||||||
|
|
||||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
@ -1731,6 +1777,8 @@
|
|||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||||
|
|
||||||
|
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
|
||||||
|
|
||||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||||
|
|
||||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|||||||
4
drizzle/0002_curly_spectrum.sql
Normal file
4
drizzle/0002_curly_spectrum.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
-- SQLite doesn't support ADD COLUMN with NOT NULL without a default
|
||||||
|
-- So we need to set a default and then remove it, or use a temp table approach
|
||||||
|
-- Using default approach since SQLite allows adding column with default
|
||||||
|
ALTER TABLE `wishlist_votes` ADD `vote_type` text NOT NULL DEFAULT 'up';
|
||||||
289
drizzle/meta/0002_snapshot.json
Normal file
289
drizzle/meta/0002_snapshot.json
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "b77d7c90-2258-4123-9d06-632970d59286",
|
||||||
|
"prevId": "3dcddf65-76e4-48ab-94a5-fe7cbd75a846",
|
||||||
|
"tables": {
|
||||||
|
"wishlist_comment_votes": {
|
||||||
|
"name": "wishlist_comment_votes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"comment_id": {
|
||||||
|
"name": "comment_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vote_type": {
|
||||||
|
"name": "vote_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"wishlist_comment_votes_comment_id_wishlist_comments_id_fk": {
|
||||||
|
"name": "wishlist_comment_votes_comment_id_wishlist_comments_id_fk",
|
||||||
|
"tableFrom": "wishlist_comment_votes",
|
||||||
|
"tableTo": "wishlist_comments",
|
||||||
|
"columnsFrom": [
|
||||||
|
"comment_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"wishlist_comments": {
|
||||||
|
"name": "wishlist_comments",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"name": "parent_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_name": {
|
||||||
|
"name": "user_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"wishlist_comments_item_id_wishlist_items_id_fk": {
|
||||||
|
"name": "wishlist_comments_item_id_wishlist_items_id_fk",
|
||||||
|
"tableFrom": "wishlist_comments",
|
||||||
|
"tableTo": "wishlist_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"wishlist_items": {
|
||||||
|
"name": "wishlist_items",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"name": "category",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"estimated_cost": {
|
||||||
|
"name": "estimated_cost",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"name": "link",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"submitted_by": {
|
||||||
|
"name": "submitted_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"submitted_by_name": {
|
||||||
|
"name": "submitted_by_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"wishlist_votes": {
|
||||||
|
"name": "wishlist_votes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vote_type": {
|
||||||
|
"name": "vote_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"wishlist_votes_item_id_wishlist_items_id_fk": {
|
||||||
|
"name": "wishlist_votes_item_id_wishlist_items_id_fk",
|
||||||
|
"tableFrom": "wishlist_votes",
|
||||||
|
"tableTo": "wishlist_items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,13 @@
|
|||||||
"when": 1769076224750,
|
"when": 1769076224750,
|
||||||
"tag": "0001_modern_mad_thinker",
|
"tag": "0001_modern_mad_thinker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1769077551375,
|
||||||
|
"tag": "0002_curly_spectrum",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -58,6 +58,8 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jspdf": "^4.0.0",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@ -18,7 +18,8 @@ export type WishlistCategory =
|
|||||||
| "storage"
|
| "storage"
|
||||||
| "other"
|
| "other"
|
||||||
export type WishlistPriority = "critical" | "high" | "medium" | "low"
|
export type WishlistPriority = "critical" | "high" | "medium" | "low"
|
||||||
export type SortOption = "newest" | "oldest" | "votes" | "priority"
|
export type SortOption = "newest" | "oldest" | "score" | "priority"
|
||||||
|
export type VoteType = "up" | "down"
|
||||||
|
|
||||||
export interface WishlistItemInput {
|
export interface WishlistItemInput {
|
||||||
name: string
|
name: string
|
||||||
@ -40,9 +41,11 @@ export interface WishlistItemWithMeta {
|
|||||||
submittedBy: string
|
submittedBy: string
|
||||||
submittedByName: string
|
submittedByName: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
voteCount: number
|
upvotes: number
|
||||||
|
downvotes: number
|
||||||
|
score: number
|
||||||
commentCount: number
|
commentCount: number
|
||||||
hasVoted: boolean
|
userVote: VoteType | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
@ -52,7 +55,7 @@ function generateId(): string {
|
|||||||
export async function getWishlistItems(
|
export async function getWishlistItems(
|
||||||
userId: string,
|
userId: string,
|
||||||
category?: string,
|
category?: string,
|
||||||
sortBy: SortOption = "newest"
|
sortBy: SortOption = "score"
|
||||||
): Promise<WishlistItemWithMeta[]> {
|
): Promise<WishlistItemWithMeta[]> {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
@ -83,11 +86,16 @@ export async function getWishlistItems(
|
|||||||
const itemsWithMeta = items.map((item) => {
|
const itemsWithMeta = items.map((item) => {
|
||||||
const itemVotes = votes.filter((v) => v.itemId === item.id)
|
const itemVotes = votes.filter((v) => v.itemId === item.id)
|
||||||
const itemComments = comments.filter((c) => c.itemId === item.id)
|
const itemComments = comments.filter((c) => c.itemId === item.id)
|
||||||
|
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
|
||||||
|
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
|
||||||
|
const userVoteRecord = itemVotes.find((v) => v.userId === userId)
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
voteCount: itemVotes.length,
|
upvotes,
|
||||||
|
downvotes,
|
||||||
|
score: upvotes - downvotes,
|
||||||
commentCount: itemComments.length,
|
commentCount: itemComments.length,
|
||||||
hasVoted: itemVotes.some((v) => v.userId === userId),
|
userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -104,8 +112,11 @@ export async function getWishlistItems(
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
)
|
)
|
||||||
case "votes":
|
case "score":
|
||||||
return itemsWithMeta.sort((a, b) => b.voteCount - a.voteCount)
|
return itemsWithMeta.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
})
|
||||||
case "priority":
|
case "priority":
|
||||||
return itemsWithMeta.sort(
|
return itemsWithMeta.sort(
|
||||||
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
|
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
|
||||||
@ -149,10 +160,11 @@ export async function addWishlistItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleVote(
|
export async function toggleItemVote(
|
||||||
itemId: string,
|
itemId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
): Promise<{ success: boolean; voted: boolean; error?: string }> {
|
voteType: VoteType
|
||||||
|
): Promise<ToggleVoteResult> {
|
||||||
try {
|
try {
|
||||||
const { env } = await getCloudflareContext()
|
const { env } = await getCloudflareContext()
|
||||||
const db = getDb(env.DB)
|
const db = getDb(env.DB)
|
||||||
@ -166,24 +178,51 @@ export async function toggleVote(
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (existingVote.length > 0) {
|
if (existingVote.length > 0) {
|
||||||
await db
|
if (existingVote[0].voteType === voteType) {
|
||||||
.delete(wishlistVotes)
|
await db
|
||||||
.where(eq(wishlistVotes.id, existingVote[0].id))
|
.delete(wishlistVotes)
|
||||||
revalidatePath("/dashboard/wishlist")
|
.where(eq(wishlistVotes.id, existingVote[0].id))
|
||||||
return { success: true, voted: false }
|
} else {
|
||||||
|
await db
|
||||||
|
.update(wishlistVotes)
|
||||||
|
.set({ voteType })
|
||||||
|
.where(eq(wishlistVotes.id, existingVote[0].id))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await db.insert(wishlistVotes).values({
|
await db.insert(wishlistVotes).values({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
itemId,
|
itemId,
|
||||||
userId,
|
userId,
|
||||||
|
voteType,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
revalidatePath("/dashboard/wishlist")
|
}
|
||||||
return { success: true, voted: true }
|
|
||||||
|
const allVotes = await db
|
||||||
|
.select()
|
||||||
|
.from(wishlistVotes)
|
||||||
|
.where(eq(wishlistVotes.itemId, itemId))
|
||||||
|
|
||||||
|
const upvotes = allVotes.filter((v) => v.voteType === "up").length
|
||||||
|
const downvotes = allVotes.filter((v) => v.voteType === "down").length
|
||||||
|
const currentUserVote = allVotes.find((v) => v.userId === userId)
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/wishlist")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
upvotes,
|
||||||
|
downvotes,
|
||||||
|
userVote: currentUserVote ? (currentUserVote.voteType as VoteType) : null,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle vote:", error)
|
console.error("Failed to toggle item vote:", error)
|
||||||
return { success: false, voted: false, error: "Failed to toggle vote" }
|
return {
|
||||||
|
success: false,
|
||||||
|
upvotes: 0,
|
||||||
|
downvotes: 0,
|
||||||
|
userVote: null,
|
||||||
|
error: "Failed to toggle vote",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,8 +305,6 @@ export async function deleteComment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VoteType = "up" | "down"
|
|
||||||
|
|
||||||
export interface ToggleVoteResult {
|
export interface ToggleVoteResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
upvotes: number
|
upvotes: number
|
||||||
@ -378,6 +415,10 @@ export async function getItemWithComments(itemId: string, userId: string) {
|
|||||||
.from(wishlistVotes)
|
.from(wishlistVotes)
|
||||||
.where(eq(wishlistVotes.itemId, itemId))
|
.where(eq(wishlistVotes.itemId, itemId))
|
||||||
|
|
||||||
|
const upvotes = votes.filter((v) => v.voteType === "up").length
|
||||||
|
const downvotes = votes.filter((v) => v.voteType === "down").length
|
||||||
|
const userVoteRecord = votes.find((v) => v.userId === userId)
|
||||||
|
|
||||||
const rawComments = await db
|
const rawComments = await db
|
||||||
.select()
|
.select()
|
||||||
.from(wishlistComments)
|
.from(wishlistComments)
|
||||||
@ -394,15 +435,23 @@ export async function getItemWithComments(itemId: string, userId: string) {
|
|||||||
const votesForComment = commentVotes.filter(
|
const votesForComment = commentVotes.filter(
|
||||||
(v) => v.commentId === comment.id
|
(v) => v.commentId === comment.id
|
||||||
)
|
)
|
||||||
const upvotes = votesForComment.filter((v) => v.voteType === "up").length
|
const commentUpvotes = votesForComment.filter(
|
||||||
const downvotes = votesForComment.filter((v) => v.voteType === "down").length
|
(v) => v.voteType === "up"
|
||||||
const userVoteRecord = votesForComment.find((v) => v.userId === userId)
|
).length
|
||||||
|
const commentDownvotes = votesForComment.filter(
|
||||||
|
(v) => v.voteType === "down"
|
||||||
|
).length
|
||||||
|
const commentUserVoteRecord = votesForComment.find(
|
||||||
|
(v) => v.userId === userId
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...comment,
|
...comment,
|
||||||
upvotes,
|
upvotes: commentUpvotes,
|
||||||
downvotes,
|
downvotes: commentDownvotes,
|
||||||
userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null,
|
userVote: commentUserVoteRecord
|
||||||
|
? (commentUserVoteRecord.voteType as VoteType)
|
||||||
|
: null,
|
||||||
replies: [],
|
replies: [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -419,8 +468,10 @@ export async function getItemWithComments(itemId: string, userId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...item[0],
|
...item[0],
|
||||||
voteCount: votes.length,
|
upvotes,
|
||||||
hasVoted: votes.some((v) => v.userId === userId),
|
downvotes,
|
||||||
|
score: upvotes - downvotes,
|
||||||
|
userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null,
|
||||||
comments: topLevel,
|
comments: topLevel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -437,13 +488,18 @@ export async function getWishlistStats(userId: string) {
|
|||||||
|
|
||||||
let mostWanted = null
|
let mostWanted = null
|
||||||
if (allItems.length > 0) {
|
if (allItems.length > 0) {
|
||||||
const itemVoteCounts = allItems.map((item) => ({
|
const itemScores = allItems.map((item) => {
|
||||||
item,
|
const itemVotes = allVotes.filter((v) => v.itemId === item.id)
|
||||||
votes: allVotes.filter((v) => v.itemId === item.id).length,
|
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
|
||||||
}))
|
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
|
||||||
const topItem = itemVoteCounts.sort((a, b) => b.votes - a.votes)[0]
|
return {
|
||||||
if (topItem.votes > 0) {
|
item,
|
||||||
mostWanted = { name: topItem.item.name, votes: topItem.votes }
|
score: upvotes - downvotes,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const topItem = itemScores.sort((a, b) => b.score - a.score)[0]
|
||||||
|
if (topItem.score > 0) {
|
||||||
|
mostWanted = { name: topItem.item.name, score: topItem.score }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,6 +53,7 @@ interface WishlistAddDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
userId: string
|
userId: string
|
||||||
userName: string
|
userName: string
|
||||||
|
onSuccess?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WishlistAddDialog({
|
export function WishlistAddDialog({
|
||||||
@ -60,6 +61,7 @@ export function WishlistAddDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
userId,
|
userId,
|
||||||
userName,
|
userName,
|
||||||
|
onSuccess,
|
||||||
}: WishlistAddDialogProps) {
|
}: WishlistAddDialogProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
@ -92,6 +94,7 @@ export function WishlistAddDialog({
|
|||||||
toast.success("Item added to wishlist")
|
toast.success("Item added to wishlist")
|
||||||
form.reset()
|
form.reset()
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
|
onSuccess?.()
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.error || "Failed to add item")
|
toast.error(result.error || "Failed to add item")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,9 +47,9 @@ export function WishlistFilters({
|
|||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="score">Top Score</SelectItem>
|
||||||
<SelectItem value="newest">Newest</SelectItem>
|
<SelectItem value="newest">Newest</SelectItem>
|
||||||
<SelectItem value="oldest">Oldest</SelectItem>
|
<SelectItem value="oldest">Oldest</SelectItem>
|
||||||
<SelectItem value="votes">Most Votes</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
<SelectItem value="priority">Priority</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react"
|
import { useState, useTransition } from "react"
|
||||||
import {
|
import {
|
||||||
IconThumbUp,
|
IconArrowBigDown,
|
||||||
IconThumbUpFilled,
|
IconArrowBigDownFilled,
|
||||||
|
IconArrowBigUp,
|
||||||
|
IconArrowBigUpFilled,
|
||||||
IconMessageCircle,
|
IconMessageCircle,
|
||||||
IconServer,
|
IconServer,
|
||||||
IconCode,
|
IconCode,
|
||||||
@ -25,9 +27,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
toggleVote,
|
toggleItemVote,
|
||||||
deleteWishlistItem,
|
deleteWishlistItem,
|
||||||
type WishlistItemWithMeta,
|
type WishlistItemWithMeta,
|
||||||
|
type VoteType,
|
||||||
} from "@/app/actions/wishlist"
|
} from "@/app/actions/wishlist"
|
||||||
|
|
||||||
const categoryIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
const categoryIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
@ -57,27 +60,49 @@ export function WishlistItemCard({
|
|||||||
onViewDetails,
|
onViewDetails,
|
||||||
}: WishlistItemCardProps) {
|
}: WishlistItemCardProps) {
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [optimisticVote, setOptimisticVote] = useState({
|
const [voteState, setVoteState] = useState({
|
||||||
hasVoted: item.hasVoted,
|
upvotes: item.upvotes,
|
||||||
voteCount: item.voteCount,
|
downvotes: item.downvotes,
|
||||||
|
userVote: item.userVote,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const score = voteState.upvotes - voteState.downvotes
|
||||||
const CategoryIcon = categoryIcons[item.category] || IconDots
|
const CategoryIcon = categoryIcons[item.category] || IconDots
|
||||||
const isOwner = item.submittedBy === userId
|
const isOwner = item.submittedBy === userId
|
||||||
|
|
||||||
const handleVote = () => {
|
const handleVote = (voteType: VoteType) => {
|
||||||
const newVoted = !optimisticVote.hasVoted
|
const prevState = { ...voteState }
|
||||||
const newCount = newVoted
|
let newUpvotes = voteState.upvotes
|
||||||
? optimisticVote.voteCount + 1
|
let newDownvotes = voteState.downvotes
|
||||||
: optimisticVote.voteCount - 1
|
let newUserVote: VoteType | null = voteType
|
||||||
|
|
||||||
setOptimisticVote({ hasVoted: newVoted, voteCount: newCount })
|
if (voteState.userVote === voteType) {
|
||||||
|
if (voteType === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
newUserVote = null
|
||||||
|
} else if (voteState.userVote) {
|
||||||
|
if (voteState.userVote === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
} else {
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoteState({ upvotes: newUpvotes, downvotes: newDownvotes, userVote: newUserVote })
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await toggleVote(item.id, userId)
|
const result = await toggleItemVote(item.id, userId, voteType)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setOptimisticVote({ hasVoted: item.hasVoted, voteCount: item.voteCount })
|
setVoteState(prevState)
|
||||||
toast.error(result.error || "Failed to vote")
|
toast.error(result.error || "Failed to vote")
|
||||||
|
} else {
|
||||||
|
setVoteState({
|
||||||
|
upvotes: result.upvotes,
|
||||||
|
downvotes: result.downvotes,
|
||||||
|
userVote: result.userVote,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -172,20 +197,45 @@ export function WishlistItemCard({
|
|||||||
<IconMessageCircle className="size-3.5" />
|
<IconMessageCircle className="size-3.5" />
|
||||||
<span className="text-xs">{item.commentCount}</span>
|
<span className="text-xs">{item.commentCount}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="flex items-center gap-0.5">
|
||||||
variant={optimisticVote.hasVoted ? "default" : "ghost"}
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-7 gap-1 px-2"
|
size="sm"
|
||||||
onClick={handleVote}
|
className="h-7 w-7 p-0"
|
||||||
disabled={isPending}
|
onClick={() => handleVote("up")}
|
||||||
>
|
disabled={isPending}
|
||||||
{optimisticVote.hasVoted ? (
|
>
|
||||||
<IconThumbUpFilled className="size-3.5" />
|
{voteState.userVote === "up" ? (
|
||||||
) : (
|
<IconArrowBigUpFilled className="size-4 text-green-500" />
|
||||||
<IconThumbUp className="size-3.5" />
|
) : (
|
||||||
)}
|
<IconArrowBigUp className="size-4" />
|
||||||
<span className="text-xs">{optimisticVote.voteCount}</span>
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span
|
||||||
|
className={`min-w-[1.5rem] text-center text-xs font-medium ${
|
||||||
|
score > 0
|
||||||
|
? "text-green-500"
|
||||||
|
: score < 0
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => handleVote("down")}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{voteState.userVote === "down" ? (
|
||||||
|
<IconArrowBigDownFilled className="size-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<IconArrowBigDown className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useTransition, useEffect } from "react"
|
import { useState, useTransition, useEffect } from "react"
|
||||||
import {
|
import {
|
||||||
|
IconArrowBigDown,
|
||||||
|
IconArrowBigDownFilled,
|
||||||
|
IconArrowBigUp,
|
||||||
|
IconArrowBigUpFilled,
|
||||||
IconThumbUp,
|
IconThumbUp,
|
||||||
IconThumbUpFilled,
|
IconThumbUpFilled,
|
||||||
IconThumbDown,
|
IconThumbDown,
|
||||||
@ -29,7 +33,7 @@ import { Separator } from "@/components/ui/separator"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import {
|
import {
|
||||||
getItemWithComments,
|
getItemWithComments,
|
||||||
toggleVote,
|
toggleItemVote,
|
||||||
addComment,
|
addComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
toggleCommentVote,
|
toggleCommentVote,
|
||||||
@ -194,18 +198,29 @@ export function WishlistItemDetail({
|
|||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
const [replyingTo, setReplyingTo] = useState<CommentWithMeta | null>(null)
|
const [replyingTo, setReplyingTo] = useState<CommentWithMeta | null>(null)
|
||||||
const [voteState, setVoteState] = useState({
|
const [voteState, setVoteState] = useState({
|
||||||
hasVoted: item?.hasVoted ?? false,
|
upvotes: item?.upvotes ?? 0,
|
||||||
voteCount: item?.voteCount ?? 0,
|
downvotes: item?.downvotes ?? 0,
|
||||||
|
userVote: item?.userVote ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const score = voteState.upvotes - voteState.downvotes
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item && open) {
|
if (item && open) {
|
||||||
setVoteState({ hasVoted: item.hasVoted, voteCount: item.voteCount })
|
setVoteState({
|
||||||
|
upvotes: item.upvotes,
|
||||||
|
downvotes: item.downvotes,
|
||||||
|
userVote: item.userVote,
|
||||||
|
})
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const data = await getItemWithComments(item.id, userId)
|
const data = await getItemWithComments(item.id, userId)
|
||||||
if (data) {
|
if (data) {
|
||||||
setComments(data.comments)
|
setComments(data.comments)
|
||||||
setVoteState({ hasVoted: data.hasVoted, voteCount: data.voteCount })
|
setVoteState({
|
||||||
|
upvotes: data.upvotes,
|
||||||
|
downvotes: data.downvotes,
|
||||||
|
userVote: data.userVote,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -213,16 +228,39 @@ export function WishlistItemDetail({
|
|||||||
|
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
const handleVote = () => {
|
const handleVote = (voteType: VoteType) => {
|
||||||
const newVoted = !voteState.hasVoted
|
const prevState = { ...voteState }
|
||||||
const newCount = newVoted ? voteState.voteCount + 1 : voteState.voteCount - 1
|
let newUpvotes = voteState.upvotes
|
||||||
setVoteState({ hasVoted: newVoted, voteCount: newCount })
|
let newDownvotes = voteState.downvotes
|
||||||
|
let newUserVote: VoteType | null = voteType
|
||||||
|
|
||||||
|
if (voteState.userVote === voteType) {
|
||||||
|
if (voteType === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
newUserVote = null
|
||||||
|
} else if (voteState.userVote) {
|
||||||
|
if (voteState.userVote === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
} else {
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoteState({ upvotes: newUpvotes, downvotes: newDownvotes, userVote: newUserVote })
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await toggleVote(item.id, userId)
|
const result = await toggleItemVote(item.id, userId, voteType)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setVoteState({ hasVoted: item.hasVoted, voteCount: item.voteCount })
|
setVoteState(prevState)
|
||||||
toast.error(result.error || "Failed to vote")
|
toast.error(result.error || "Failed to vote")
|
||||||
|
} else {
|
||||||
|
setVoteState({
|
||||||
|
upvotes: result.upvotes,
|
||||||
|
downvotes: result.downvotes,
|
||||||
|
userVote: result.userVote,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -368,20 +406,45 @@ export function WishlistItemDetail({
|
|||||||
Submitted by {item.submittedByName} ·{" "}
|
Submitted by {item.submittedByName} ·{" "}
|
||||||
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div className="flex items-center gap-0.5">
|
||||||
variant={voteState.hasVoted ? "default" : "outline"}
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="gap-1"
|
size="sm"
|
||||||
onClick={handleVote}
|
className="h-8 w-8 p-0"
|
||||||
disabled={isPending}
|
onClick={() => handleVote("up")}
|
||||||
>
|
disabled={isPending}
|
||||||
{voteState.hasVoted ? (
|
>
|
||||||
<IconThumbUpFilled className="size-4" />
|
{voteState.userVote === "up" ? (
|
||||||
) : (
|
<IconArrowBigUpFilled className="size-5 text-green-500" />
|
||||||
<IconThumbUp className="size-4" />
|
) : (
|
||||||
)}
|
<IconArrowBigUp className="size-5" />
|
||||||
{voteState.voteCount}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<span
|
||||||
|
className={`min-w-[1.5rem] text-center text-sm font-medium ${
|
||||||
|
score > 0
|
||||||
|
? "text-green-500"
|
||||||
|
: score < 0
|
||||||
|
? "text-red-500"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => handleVote("down")}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{voteState.userVote === "down" ? (
|
||||||
|
<IconArrowBigDownFilled className="size-5 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<IconArrowBigDown className="size-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@ -12,7 +12,7 @@ interface WishlistStatsProps {
|
|||||||
stats: {
|
stats: {
|
||||||
totalItems: number
|
totalItems: number
|
||||||
yourItems: number
|
yourItems: number
|
||||||
mostWanted: { name: string; votes: number } | null
|
mostWanted: { name: string; score: number } | null
|
||||||
recentItems: number
|
recentItems: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -40,7 +40,7 @@ export function WishlistStats({ stats }: WishlistStatsProps) {
|
|||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate">{stats.mostWanted.name}</span>
|
<span className="truncate">{stats.mostWanted.name}</span>
|
||||||
<span className="text-muted-foreground text-sm font-normal">
|
<span className="text-muted-foreground text-sm font-normal">
|
||||||
({stats.mostWanted.votes} votes)
|
(score: {stats.mostWanted.score})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,20 +1,63 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useTransition, useCallback } from "react"
|
import { useState, useEffect, useTransition, useCallback } from "react"
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import {
|
||||||
|
IconArrowBigDown,
|
||||||
|
IconArrowBigDownFilled,
|
||||||
|
IconArrowBigUp,
|
||||||
|
IconArrowBigUpFilled,
|
||||||
|
IconClipboardList,
|
||||||
|
IconDownload,
|
||||||
|
IconDots,
|
||||||
|
IconExternalLink,
|
||||||
|
IconFileSpreadsheet,
|
||||||
|
IconFileTypePdf,
|
||||||
|
IconLoader2,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { jsPDF } from "jspdf"
|
||||||
|
import autoTable from "jspdf-autotable"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
import { WishlistStats } from "./wishlist-stats"
|
import { WishlistStats } from "./wishlist-stats"
|
||||||
import { WishlistFilters } from "./wishlist-filters"
|
import { WishlistFilters } from "./wishlist-filters"
|
||||||
import { WishlistItemCard } from "./wishlist-item-card"
|
|
||||||
import { WishlistAddDialog } from "./wishlist-add-dialog"
|
import { WishlistAddDialog } from "./wishlist-add-dialog"
|
||||||
import { WishlistItemDetail } from "./wishlist-item-detail"
|
import { WishlistItemDetail } from "./wishlist-item-detail"
|
||||||
import {
|
import {
|
||||||
getWishlistItems,
|
getWishlistItems,
|
||||||
getWishlistStats,
|
getWishlistStats,
|
||||||
|
toggleItemVote,
|
||||||
|
deleteWishlistItem,
|
||||||
type WishlistItemWithMeta,
|
type WishlistItemWithMeta,
|
||||||
type SortOption,
|
type SortOption,
|
||||||
|
type VoteType,
|
||||||
} from "@/app/actions/wishlist"
|
} from "@/app/actions/wishlist"
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
critical: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||||
|
high: "bg-orange-500/10 text-orange-500 border-orange-500/20",
|
||||||
|
medium: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||||
|
low: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||||
|
}
|
||||||
|
|
||||||
interface WishlistTableProps {
|
interface WishlistTableProps {
|
||||||
userId: string
|
userId: string
|
||||||
userName: string
|
userName: string
|
||||||
@ -26,11 +69,11 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
yourItems: 0,
|
yourItems: 0,
|
||||||
mostWanted: null as { name: string; votes: number } | null,
|
mostWanted: null as { name: string; score: number } | null,
|
||||||
recentItems: 0,
|
recentItems: 0,
|
||||||
})
|
})
|
||||||
const [category, setCategory] = useState("all")
|
const [category, setCategory] = useState("all")
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("newest")
|
const [sortBy, setSortBy] = useState<SortOption>("score")
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
|
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
@ -55,8 +98,117 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
setDetailOpen(true)
|
setDetailOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getExportData = () => {
|
||||||
|
const headers = ["Name", "Description", "Category", "Priority", "Est. Cost", "Score", "Submitted By", "Created"]
|
||||||
|
const rows = items.map((item) => [
|
||||||
|
item.name,
|
||||||
|
item.description,
|
||||||
|
item.category,
|
||||||
|
item.priority,
|
||||||
|
item.estimatedCost ? `$${item.estimatedCost}` : "—",
|
||||||
|
item.score.toString(),
|
||||||
|
item.submittedByName,
|
||||||
|
new Date(item.createdAt).toLocaleDateString(),
|
||||||
|
])
|
||||||
|
return { headers, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportCSV = () => {
|
||||||
|
const { headers, rows } = getExportData()
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(","),
|
||||||
|
...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")),
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = `wishlist-export-${new Date().toISOString().split("T")[0]}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success("Exported as CSV")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExportPDF = () => {
|
||||||
|
const { headers, rows } = getExportData()
|
||||||
|
const doc = new jsPDF()
|
||||||
|
|
||||||
|
doc.setFontSize(18)
|
||||||
|
doc.text("Infrastructure Wishlist", 14, 22)
|
||||||
|
|
||||||
|
doc.setFontSize(10)
|
||||||
|
doc.setTextColor(100)
|
||||||
|
doc.text(`Exported on ${new Date().toLocaleDateString()}`, 14, 30)
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
head: [headers],
|
||||||
|
body: rows,
|
||||||
|
startY: 38,
|
||||||
|
styles: { fontSize: 8 },
|
||||||
|
headStyles: { fillColor: [41, 37, 36] },
|
||||||
|
columnStyles: {
|
||||||
|
1: { cellWidth: 50 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
doc.save(`wishlist-export-${new Date().toISOString().split("T")[0]}.pdf`)
|
||||||
|
toast.success("Exported as PDF")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||||
|
<div className="flex items-center justify-between px-4 lg:px-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="bg-primary/10 flex size-10 items-center justify-center rounded-lg">
|
||||||
|
<IconClipboardList className="text-primary size-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
Infrastructure Wishlist
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Request and vote on infrastructure items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={items.length === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<IconDownload className="size-4" />
|
||||||
|
<span className="hidden sm:inline">Export</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleExportCSV}>
|
||||||
|
<IconFileSpreadsheet className="size-4" />
|
||||||
|
Export as CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleExportPDF}>
|
||||||
|
<IconFileTypePdf className="size-4" />
|
||||||
|
Export as PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
|
||||||
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<WishlistStats stats={stats} />
|
<WishlistStats stats={stats} />
|
||||||
|
|
||||||
<WishlistFilters
|
<WishlistFilters
|
||||||
@ -82,15 +234,31 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="rounded-lg border">
|
||||||
{items.map((item) => (
|
<Table>
|
||||||
<WishlistItemCard
|
<TableHeader>
|
||||||
key={item.id}
|
<TableRow>
|
||||||
item={item}
|
<TableHead>Name</TableHead>
|
||||||
userId={userId}
|
<TableHead className="hidden md:table-cell">Category</TableHead>
|
||||||
onViewDetails={handleViewDetails}
|
<TableHead>Priority</TableHead>
|
||||||
/>
|
<TableHead className="hidden lg:table-cell">Est. Cost</TableHead>
|
||||||
))}
|
<TableHead className="text-center">Score</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">Submitted By</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<WishlistTableRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
userId={userId}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -100,6 +268,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
onOpenChange={setAddDialogOpen}
|
onOpenChange={setAddDialogOpen}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
|
onSuccess={fetchData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WishlistItemDetail
|
<WishlistItemDetail
|
||||||
@ -112,3 +281,185 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WishlistTableRowProps {
|
||||||
|
item: WishlistItemWithMeta
|
||||||
|
userId: string
|
||||||
|
onViewDetails: (item: WishlistItemWithMeta) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WishlistTableRow({ item, userId, onViewDetails, onRefresh }: WishlistTableRowProps) {
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const [voteState, setVoteState] = useState({
|
||||||
|
upvotes: item.upvotes,
|
||||||
|
downvotes: item.downvotes,
|
||||||
|
userVote: item.userVote,
|
||||||
|
})
|
||||||
|
|
||||||
|
const score = voteState.upvotes - voteState.downvotes
|
||||||
|
const isOwner = item.submittedBy === userId
|
||||||
|
|
||||||
|
const handleVote = (voteType: VoteType) => {
|
||||||
|
const prevState = { ...voteState }
|
||||||
|
let newUpvotes = voteState.upvotes
|
||||||
|
let newDownvotes = voteState.downvotes
|
||||||
|
let newUserVote: VoteType | null = voteType
|
||||||
|
|
||||||
|
if (voteState.userVote === voteType) {
|
||||||
|
if (voteType === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
newUserVote = null
|
||||||
|
} else if (voteState.userVote) {
|
||||||
|
if (voteState.userVote === "up") newUpvotes--
|
||||||
|
else newDownvotes--
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
} else {
|
||||||
|
if (voteType === "up") newUpvotes++
|
||||||
|
else newDownvotes++
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoteState({ upvotes: newUpvotes, downvotes: newDownvotes, userVote: newUserVote })
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await toggleItemVote(item.id, userId, voteType)
|
||||||
|
if (!result.success) {
|
||||||
|
setVoteState(prevState)
|
||||||
|
toast.error(result.error || "Failed to vote")
|
||||||
|
} else {
|
||||||
|
setVoteState({
|
||||||
|
upvotes: result.upvotes,
|
||||||
|
downvotes: result.downvotes,
|
||||||
|
userVote: result.userVote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteWishlistItem(item.id, userId)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Item deleted")
|
||||||
|
onRefresh()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Failed to delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow className="group">
|
||||||
|
<TableCell>
|
||||||
|
<button
|
||||||
|
className="text-left font-medium hover:underline"
|
||||||
|
onClick={() => onViewDetails(item)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
<p className="text-muted-foreground line-clamp-1 text-xs md:hidden">
|
||||||
|
{item.category}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{item.category}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className={cn("text-xs", priorityColors[item.priority])}>
|
||||||
|
{item.priority}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{item.estimatedCost ? (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
${item.estimatedCost.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => handleVote("up")}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{voteState.userVote === "up" ? (
|
||||||
|
<IconArrowBigUpFilled className="size-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<IconArrowBigUp className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"min-w-[1.5rem] text-center text-xs font-medium",
|
||||||
|
score > 0 && "text-green-500",
|
||||||
|
score < 0 && "text-red-500",
|
||||||
|
score === 0 && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => handleVote("down")}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{voteState.userVote === "down" ? (
|
||||||
|
<IconArrowBigDownFilled className="size-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<IconArrowBigDown className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">
|
||||||
|
<span className="text-muted-foreground text-sm">{item.submittedByName}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<IconDots className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onViewDetails(item)}>
|
||||||
|
View Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{item.link && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<IconExternalLink className="size-4" />
|
||||||
|
Open Link
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
<IconTrash className="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const wishlistVotes = sqliteTable("wishlist_votes", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => wishlistItems.id, { onDelete: "cascade" }),
|
.references(() => wishlistItems.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id").notNull(),
|
userId: text("user_id").notNull(),
|
||||||
|
voteType: text("vote_type").notNull(), // "up" | "down"
|
||||||
createdAt: text("created_at").notNull(),
|
createdAt: text("created_at").notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user