Merge pull request #3 from NicholaiVogel/feat/item-upvote-downvote

feat(wishlist): add reddit-style upvote/downvote for items
This commit is contained in:
Nicholai 2026-01-22 03:35:24 -07:00 committed by GitHub
commit 186c1c21b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 980 additions and 106 deletions

View File

@ -47,6 +47,8 @@
"drizzle-orm": "^0.45.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.562.0",
"next": "15.5.9",
"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/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@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/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/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/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=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"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=="],
@ -951,6 +961,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"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=="],
@ -1153,12 +1171,16 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"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=="],
@ -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=="],
"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=="],
"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=="],
"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=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
@ -1507,6 +1539,8 @@
"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=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@ -1531,6 +1565,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"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=="],
@ -1665,6 +1707,8 @@
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],

View 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';

View 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": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1769076224750,
"tag": "0001_modern_mad_thinker",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1769077551375,
"tag": "0002_curly_spectrum",
"breakpoints": true
}
]
}

View File

@ -58,6 +58,8 @@
"drizzle-orm": "^0.45.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.562.0",
"next": "15.5.9",
"next-themes": "^0.4.6",

View File

@ -18,7 +18,8 @@ export type WishlistCategory =
| "storage"
| "other"
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 {
name: string
@ -40,9 +41,11 @@ export interface WishlistItemWithMeta {
submittedBy: string
submittedByName: string
createdAt: string
voteCount: number
upvotes: number
downvotes: number
score: number
commentCount: number
hasVoted: boolean
userVote: VoteType | null
}
function generateId(): string {
@ -52,7 +55,7 @@ function generateId(): string {
export async function getWishlistItems(
userId: string,
category?: string,
sortBy: SortOption = "newest"
sortBy: SortOption = "score"
): Promise<WishlistItemWithMeta[]> {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
@ -83,11 +86,16 @@ export async function getWishlistItems(
const itemsWithMeta = items.map((item) => {
const itemVotes = votes.filter((v) => v.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 {
...item,
voteCount: itemVotes.length,
upvotes,
downvotes,
score: upvotes - downvotes,
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) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
case "votes":
return itemsWithMeta.sort((a, b) => b.voteCount - a.voteCount)
case "score":
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":
return itemsWithMeta.sort(
(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,
userId: string
): Promise<{ success: boolean; voted: boolean; error?: string }> {
userId: string,
voteType: VoteType
): Promise<ToggleVoteResult> {
try {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
@ -166,24 +178,51 @@ export async function toggleVote(
.limit(1)
if (existingVote.length > 0) {
await db
.delete(wishlistVotes)
.where(eq(wishlistVotes.id, existingVote[0].id))
revalidatePath("/dashboard/wishlist")
return { success: true, voted: false }
if (existingVote[0].voteType === voteType) {
await db
.delete(wishlistVotes)
.where(eq(wishlistVotes.id, existingVote[0].id))
} else {
await db
.update(wishlistVotes)
.set({ voteType })
.where(eq(wishlistVotes.id, existingVote[0].id))
}
} else {
await db.insert(wishlistVotes).values({
id: generateId(),
itemId,
userId,
voteType,
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) {
console.error("Failed to toggle vote:", error)
return { success: false, voted: false, error: "Failed to toggle vote" }
console.error("Failed to toggle item vote:", error)
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 {
success: boolean
upvotes: number
@ -378,6 +415,10 @@ export async function getItemWithComments(itemId: string, userId: string) {
.from(wishlistVotes)
.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
.select()
.from(wishlistComments)
@ -394,15 +435,23 @@ export async function getItemWithComments(itemId: string, userId: string) {
const votesForComment = commentVotes.filter(
(v) => v.commentId === comment.id
)
const upvotes = votesForComment.filter((v) => v.voteType === "up").length
const downvotes = votesForComment.filter((v) => v.voteType === "down").length
const userVoteRecord = votesForComment.find((v) => v.userId === userId)
const commentUpvotes = votesForComment.filter(
(v) => v.voteType === "up"
).length
const commentDownvotes = votesForComment.filter(
(v) => v.voteType === "down"
).length
const commentUserVoteRecord = votesForComment.find(
(v) => v.userId === userId
)
return {
...comment,
upvotes,
downvotes,
userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null,
upvotes: commentUpvotes,
downvotes: commentDownvotes,
userVote: commentUserVoteRecord
? (commentUserVoteRecord.voteType as VoteType)
: null,
replies: [],
}
})
@ -419,8 +468,10 @@ export async function getItemWithComments(itemId: string, userId: string) {
return {
...item[0],
voteCount: votes.length,
hasVoted: votes.some((v) => v.userId === userId),
upvotes,
downvotes,
score: upvotes - downvotes,
userVote: userVoteRecord ? (userVoteRecord.voteType as VoteType) : null,
comments: topLevel,
}
}
@ -437,13 +488,18 @@ export async function getWishlistStats(userId: string) {
let mostWanted = null
if (allItems.length > 0) {
const itemVoteCounts = allItems.map((item) => ({
item,
votes: allVotes.filter((v) => v.itemId === item.id).length,
}))
const topItem = itemVoteCounts.sort((a, b) => b.votes - a.votes)[0]
if (topItem.votes > 0) {
mostWanted = { name: topItem.item.name, votes: topItem.votes }
const itemScores = allItems.map((item) => {
const itemVotes = allVotes.filter((v) => v.itemId === item.id)
const upvotes = itemVotes.filter((v) => v.voteType === "up").length
const downvotes = itemVotes.filter((v) => v.voteType === "down").length
return {
item,
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 }
}
}

View File

@ -53,6 +53,7 @@ interface WishlistAddDialogProps {
onOpenChange: (open: boolean) => void
userId: string
userName: string
onSuccess?: () => void
}
export function WishlistAddDialog({
@ -60,6 +61,7 @@ export function WishlistAddDialog({
onOpenChange,
userId,
userName,
onSuccess,
}: WishlistAddDialogProps) {
const isMobile = useIsMobile()
const [isPending, startTransition] = useTransition()
@ -92,6 +94,7 @@ export function WishlistAddDialog({
toast.success("Item added to wishlist")
form.reset()
onOpenChange(false)
onSuccess?.()
} else {
toast.error(result.error || "Failed to add item")
}

View File

@ -47,9 +47,9 @@ export function WishlistFilters({
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="score">Top Score</SelectItem>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
<SelectItem value="votes">Most Votes</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>

View File

@ -2,8 +2,10 @@
import { useState, useTransition } from "react"
import {
IconThumbUp,
IconThumbUpFilled,
IconArrowBigDown,
IconArrowBigDownFilled,
IconArrowBigUp,
IconArrowBigUpFilled,
IconMessageCircle,
IconServer,
IconCode,
@ -25,9 +27,10 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
toggleVote,
toggleItemVote,
deleteWishlistItem,
type WishlistItemWithMeta,
type VoteType,
} from "@/app/actions/wishlist"
const categoryIcons: Record<string, React.ComponentType<{ className?: string }>> = {
@ -57,27 +60,49 @@ export function WishlistItemCard({
onViewDetails,
}: WishlistItemCardProps) {
const [isPending, startTransition] = useTransition()
const [optimisticVote, setOptimisticVote] = useState({
hasVoted: item.hasVoted,
voteCount: item.voteCount,
const [voteState, setVoteState] = useState({
upvotes: item.upvotes,
downvotes: item.downvotes,
userVote: item.userVote,
})
const score = voteState.upvotes - voteState.downvotes
const CategoryIcon = categoryIcons[item.category] || IconDots
const isOwner = item.submittedBy === userId
const handleVote = () => {
const newVoted = !optimisticVote.hasVoted
const newCount = newVoted
? optimisticVote.voteCount + 1
: optimisticVote.voteCount - 1
const handleVote = (voteType: VoteType) => {
const prevState = { ...voteState }
let newUpvotes = voteState.upvotes
let newDownvotes = voteState.downvotes
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 () => {
const result = await toggleVote(item.id, userId)
const result = await toggleItemVote(item.id, userId, voteType)
if (!result.success) {
setOptimisticVote({ hasVoted: item.hasVoted, voteCount: item.voteCount })
setVoteState(prevState)
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" />
<span className="text-xs">{item.commentCount}</span>
</Button>
<Button
variant={optimisticVote.hasVoted ? "default" : "ghost"}
size="sm"
className="h-7 gap-1 px-2"
onClick={handleVote}
disabled={isPending}
>
{optimisticVote.hasVoted ? (
<IconThumbUpFilled className="size-3.5" />
) : (
<IconThumbUp className="size-3.5" />
)}
<span className="text-xs">{optimisticVote.voteCount}</span>
</Button>
<div className="flex items-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={`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 className="text-muted-foreground text-xs">

View File

@ -2,6 +2,10 @@
import { useState, useTransition, useEffect } from "react"
import {
IconArrowBigDown,
IconArrowBigDownFilled,
IconArrowBigUp,
IconArrowBigUpFilled,
IconThumbUp,
IconThumbUpFilled,
IconThumbDown,
@ -29,7 +33,7 @@ import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import {
getItemWithComments,
toggleVote,
toggleItemVote,
addComment,
deleteComment,
toggleCommentVote,
@ -194,18 +198,29 @@ export function WishlistItemDetail({
const [newComment, setNewComment] = useState("")
const [replyingTo, setReplyingTo] = useState<CommentWithMeta | null>(null)
const [voteState, setVoteState] = useState({
hasVoted: item?.hasVoted ?? false,
voteCount: item?.voteCount ?? 0,
upvotes: item?.upvotes ?? 0,
downvotes: item?.downvotes ?? 0,
userVote: item?.userVote ?? null,
})
const score = voteState.upvotes - voteState.downvotes
useEffect(() => {
if (item && open) {
setVoteState({ hasVoted: item.hasVoted, voteCount: item.voteCount })
setVoteState({
upvotes: item.upvotes,
downvotes: item.downvotes,
userVote: item.userVote,
})
startTransition(async () => {
const data = await getItemWithComments(item.id, userId)
if (data) {
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
const handleVote = () => {
const newVoted = !voteState.hasVoted
const newCount = newVoted ? voteState.voteCount + 1 : voteState.voteCount - 1
setVoteState({ hasVoted: newVoted, voteCount: newCount })
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 toggleVote(item.id, userId)
const result = await toggleItemVote(item.id, userId, voteType)
if (!result.success) {
setVoteState({ hasVoted: item.hasVoted, voteCount: item.voteCount })
setVoteState(prevState)
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} &middot;{" "}
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</span>
<Button
variant={voteState.hasVoted ? "default" : "outline"}
size="sm"
className="gap-1"
onClick={handleVote}
disabled={isPending}
>
{voteState.hasVoted ? (
<IconThumbUpFilled className="size-4" />
) : (
<IconThumbUp className="size-4" />
)}
{voteState.voteCount}
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleVote("up")}
disabled={isPending}
>
{voteState.userVote === "up" ? (
<IconArrowBigUpFilled className="size-5 text-green-500" />
) : (
<IconArrowBigUp className="size-5" />
)}
</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>
<Separator />

View File

@ -12,7 +12,7 @@ interface WishlistStatsProps {
stats: {
totalItems: number
yourItems: number
mostWanted: { name: string; votes: number } | null
mostWanted: { name: string; score: number } | null
recentItems: number
}
}
@ -40,7 +40,7 @@ export function WishlistStats({ stats }: WishlistStatsProps) {
<span className="flex items-center gap-2">
<span className="truncate">{stats.mostWanted.name}</span>
<span className="text-muted-foreground text-sm font-normal">
({stats.mostWanted.votes} votes)
(score: {stats.mostWanted.score})
</span>
</span>
) : (

View File

@ -1,20 +1,63 @@
"use client"
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 { WishlistFilters } from "./wishlist-filters"
import { WishlistItemCard } from "./wishlist-item-card"
import { WishlistAddDialog } from "./wishlist-add-dialog"
import { WishlistItemDetail } from "./wishlist-item-detail"
import {
getWishlistItems,
getWishlistStats,
toggleItemVote,
deleteWishlistItem,
type WishlistItemWithMeta,
type SortOption,
type VoteType,
} 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 {
userId: string
userName: string
@ -26,11 +69,11 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
const [stats, setStats] = useState({
totalItems: 0,
yourItems: 0,
mostWanted: null as { name: string; votes: number } | null,
mostWanted: null as { name: string; score: number } | null,
recentItems: 0,
})
const [category, setCategory] = useState("all")
const [sortBy, setSortBy] = useState<SortOption>("newest")
const [sortBy, setSortBy] = useState<SortOption>("score")
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<WishlistItemWithMeta | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
@ -55,8 +98,117 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
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 (
<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} />
<WishlistFilters
@ -82,15 +234,31 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<WishlistItemCard
key={item.id}
item={item}
userId={userId}
onViewDetails={handleViewDetails}
/>
))}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden md:table-cell">Category</TableHead>
<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>
@ -100,6 +268,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
onOpenChange={setAddDialogOpen}
userId={userId}
userName={userName}
onSuccess={fetchData}
/>
<WishlistItemDetail
@ -112,3 +281,185 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) {
</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>
)
}

View File

@ -19,6 +19,7 @@ export const wishlistVotes = sqliteTable("wishlist_votes", {
.notNull()
.references(() => wishlistItems.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
voteType: text("vote_type").notNull(), // "up" | "down"
createdAt: text("created_at").notNull(),
})