diff --git a/bun.lock b/bun.lock index 320e840..7f8041d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/drizzle/0002_curly_spectrum.sql b/drizzle/0002_curly_spectrum.sql new file mode 100644 index 0000000..5fecde4 --- /dev/null +++ b/drizzle/0002_curly_spectrum.sql @@ -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'; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..bee887c --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4de8810..b7669e0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/package.json b/package.json index f63c7da..26b85f1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/actions/wishlist.ts b/src/app/actions/wishlist.ts index da08791..f06e027 100644 --- a/src/app/actions/wishlist.ts +++ b/src/app/actions/wishlist.ts @@ -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 { 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 { 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 } } } diff --git a/src/components/wishlist/wishlist-add-dialog.tsx b/src/components/wishlist/wishlist-add-dialog.tsx index 27991b4..41a55de 100644 --- a/src/components/wishlist/wishlist-add-dialog.tsx +++ b/src/components/wishlist/wishlist-add-dialog.tsx @@ -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") } diff --git a/src/components/wishlist/wishlist-filters.tsx b/src/components/wishlist/wishlist-filters.tsx index c772cce..7e79b90 100644 --- a/src/components/wishlist/wishlist-filters.tsx +++ b/src/components/wishlist/wishlist-filters.tsx @@ -47,9 +47,9 @@ export function WishlistFilters({ + Top Score Newest Oldest - Most Votes Priority diff --git a/src/components/wishlist/wishlist-item-card.tsx b/src/components/wishlist/wishlist-item-card.tsx index 9c520c4..c14a6a6 100644 --- a/src/components/wishlist/wishlist-item-card.tsx +++ b/src/components/wishlist/wishlist-item-card.tsx @@ -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> = { @@ -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({ {item.commentCount} - +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-muted-foreground" + }`} + > + {score} + + +
diff --git a/src/components/wishlist/wishlist-item-detail.tsx b/src/components/wishlist/wishlist-item-detail.tsx index c7a6df0..73d261d 100644 --- a/src/components/wishlist/wishlist-item-detail.tsx +++ b/src/components/wishlist/wishlist-item-detail.tsx @@ -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(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} ·{" "} {formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })} - +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-muted-foreground" + }`} + > + {score} + + +
diff --git a/src/components/wishlist/wishlist-stats.tsx b/src/components/wishlist/wishlist-stats.tsx index 4fa2906..e989ae0 100644 --- a/src/components/wishlist/wishlist-stats.tsx +++ b/src/components/wishlist/wishlist-stats.tsx @@ -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) { {stats.mostWanted.name} - ({stats.mostWanted.votes} votes) + (score: {stats.mostWanted.score}) ) : ( diff --git a/src/components/wishlist/wishlist-table.tsx b/src/components/wishlist/wishlist-table.tsx index e9bffc0..0c4dda3 100644 --- a/src/components/wishlist/wishlist-table.tsx +++ b/src/components/wishlist/wishlist-table.tsx @@ -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 = { + 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("newest") + const [sortBy, setSortBy] = useState("score") const [addDialogOpen, setAddDialogOpen] = useState(false) const [selectedItem, setSelectedItem] = useState(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 (
+
+
+
+ +
+
+

+ Infrastructure Wishlist +

+

+ Request and vote on infrastructure items +

+
+
+
+ + + + + + + + Export as CSV + + + + Export as PDF + + + + +
+
+
) : ( -
- {items.map((item) => ( - - ))} +
+ + + + Name + Category + Priority + Est. Cost + Score + Submitted By + + + + + {items.map((item) => ( + + ))} + +
)}
@@ -100,6 +268,7 @@ export function WishlistTable({ userId, userName }: WishlistTableProps) { onOpenChange={setAddDialogOpen} userId={userId} userName={userName} + onSuccess={fetchData} /> ) } + +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 ( + + + +

+ {item.category} +

+
+ + + {item.category} + + + + + {item.priority} + + + + {item.estimatedCost ? ( + + ${item.estimatedCost.toLocaleString()} + + ) : ( + + )} + + +
+ + 0 && "text-green-500", + score < 0 && "text-red-500", + score === 0 && "text-muted-foreground" + )} + > + {score} + + +
+
+ + {item.submittedByName} + + + + + + + + onViewDetails(item)}> + View Details + + {item.link && ( + + + + Open Link + + + )} + {isOwner && ( + + + Delete + + )} + + + +
+ ) +} diff --git a/src/db/schema.ts b/src/db/schema.ts index eda10fc..f70d355 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), })