From fcddd59a685768596011ad41b7b1dd10e493e382 Mon Sep 17 00:00:00 2001 From: vishalsoni Date: Sun, 21 Jun 2026 15:02:53 +0530 Subject: [PATCH] add reputation system --- README.md | 7 +- package-lock.json | 196 +--- package.json | 1 - src/App.jsx | 34 + src/components/answers/AnswerCard.jsx | 150 ++- src/components/answers/AnswerList.jsx | 14 +- src/components/layout/Layout.jsx | 3 - src/components/layout/Navbar.jsx | 46 +- src/components/questions/DuplicateWarning.jsx | 52 +- src/components/questions/QuestionCard.jsx | 156 ++-- src/components/ui/BadgeUnlockModal.jsx | 183 ++++ src/contexts/AuthContext.jsx | 81 ++ src/hooks/useAdmin.js | 237 +++++ src/hooks/useAnswers.js | 18 +- src/hooks/useUpvote.js | 62 +- src/lib/duplicateDetector.js | 71 +- src/pages/AdminDashboard.jsx | 880 ++++++++++++++++-- src/pages/AskQuestionPage.jsx | 106 +-- src/pages/FaqPage.jsx | 201 ++-- src/pages/LeaderboardPage.jsx | 329 +++++++ src/pages/ProfilePage.jsx | 613 ++++++++---- src/pages/QuestionDetailPage.jsx | 197 ++-- supabase/migration.sql | 847 +++++++++++++++++ vercel.json | 8 + 24 files changed, 3519 insertions(+), 973 deletions(-) create mode 100644 src/components/ui/BadgeUnlockModal.jsx create mode 100644 src/pages/LeaderboardPage.jsx create mode 100644 supabase/migration.sql create mode 100644 vercel.json diff --git a/README.md b/README.md index 19ebe9c..f5215b9 100644 --- a/README.md +++ b/README.md @@ -513,8 +513,9 @@ npm install 1. Create a new project on the [Supabase Dashboard](https://supabase.com/dashboard) 2. Navigate to **SQL Editor** → **New Query** → **Blank Query** -3. Copy the entire contents of [`supabase/schema.sql`](supabase/schema.sql) and paste into the editor -4. Click **Run** — this creates all tables, triggers, RLS policies, indexes, and seed data +3. Copy the entire contents of [`supabase/schema.sql`](supabase/schema.sql) and paste into the editor, then click **Run** to set up the core Q&A platform schema, triggers, RLS policies, indexes, and seed data. +4. Open another new query tab, copy the entire contents of [`supabase/migration.sql`](supabase/migration.sql) and click **Run** to set up the Reputation, Badge, and Leaderboard triggers, RPC functions, and achievement definitions. +5. In your Supabase Dashboard, go to **Database** → **Replication** → **Source** (select tables), and enable **Realtime** for `reputation_logs` and `user_badges` so points notifications and badge unlock popups trigger instantly in real-time. **3. Storage Configuration** @@ -571,7 +572,7 @@ To activate the admin moderation dashboard: | 🚩 **Flag Review Queue** | Review and resolve community-flagged content | | 📝 **Admin Notes** | Attach feedback notes to reviewed answers | | 🔔 **Auto-Notifications** | Authors are notified of verification decisions in real-time | -| 🏆 **Reputation Management** | View and manage user reputation and badge assignments | +| 🏆 **Reputation & Badges** | Perform manual point adjustments (+/-), manually award or revoke badges for any member via an interactive management modal, and view live Leaderboard Analytics and recent reputation log feeds. | --- diff --git a/package-lock.json b/package-lock.json index bb03d26..94749b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.106.2", - "@vitalets/google-translate-api": "^9.2.1", "framer-motion": "^12.40.0", "fuse.js": "^7.3.0", "lucide-react": "^1.17.0", @@ -63,6 +62,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -272,33 +272,10 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -802,40 +779,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", @@ -1258,12 +1201,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/http-errors": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.2.tgz", - "integrity": "sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==", - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1277,6 +1214,7 @@ "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1291,20 +1229,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@vitalets/google-translate-api": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/@vitalets/google-translate-api/-/google-translate-api-9.2.1.tgz", - "integrity": "sha512-zlwQWSjXUZhbZQ6qwtIQ7GdYXFQmJ4wYqzcrYJUxtvzQQwUP+uKUb/SRJaBOQuBntjBjzcdcJoLFrpCKUbIkOg==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "^1.8.2", - "http-errors": "^2.0.0", - "node-fetch": "^2.6.7" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -1337,6 +1261,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1427,6 +1352,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1529,15 +1455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1598,6 +1515,7 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -1979,26 +1897,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -2028,12 +1926,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2070,6 +1962,7 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2522,26 +2415,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.46", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", @@ -2635,6 +2508,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2696,6 +2570,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2705,6 +2580,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2822,12 +2698,6 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2861,15 +2731,6 @@ "node": ">=0.10.0" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/tailwindcss": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", @@ -2908,21 +2769,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2989,6 +2835,7 @@ "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3061,22 +2908,6 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3129,6 +2960,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 473df98..a1c598a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ }, "dependencies": { "@supabase/supabase-js": "^2.106.2", - "@vitalets/google-translate-api": "^9.2.1", "framer-motion": "^12.40.0", "fuse.js": "^7.3.0", "lucide-react": "^1.17.0", diff --git a/src/App.jsx b/src/App.jsx index 34506c0..ccff891 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,8 @@ import { Routes, Route, useLocation, Navigate } from 'react-router-dom' import { AnimatePresence } from 'framer-motion' import { useAuth } from './hooks/useAuth' +import { useToast } from './components/ui/Toast' +import { useEffect } from 'react' import Layout from './components/layout/Layout' import ProtectedRoute from './components/auth/ProtectedRoute' @@ -14,8 +16,10 @@ import LoginPage from './pages/LoginPage' import SignupPage from './pages/SignupPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import ProfilePage from './pages/ProfilePage' +import LeaderboardPage from './pages/LeaderboardPage' import AdminDashboard from './pages/AdminDashboard' import NotFoundPage from './pages/NotFoundPage' +import BadgeUnlockModal from './components/ui/BadgeUnlockModal' function GuestRoute({ children }) { const { user, loading } = useAuth() @@ -26,9 +30,38 @@ function GuestRoute({ children }) { export default function App() { const location = useLocation() + const { showToast } = useToast() + + useEffect(() => { + const handleReputationChange = (e) => { + const { points, action } = e.detail + const sign = points >= 0 ? '+' : '' + + const formatAction = (act) => { + switch (act) { + case 'ask_question': return 'Asked a Question' + case 'post_answer': return 'Posted an Answer' + case 'question_upvote': return 'Question Upvoted' + case 'question_downvote': return 'Question Downvoted' + case 'answer_upvote': return 'Answer Upvoted' + case 'answer_downvote': return 'Answer Downvoted' + case 'answer_accepted': return 'Answer Accepted' + case 'daily_login': return 'Daily Login Bonus' + case 'admin_adjustment': return 'Admin Adjustment' + default: return act.replace('_', ' ') + } + } + + showToast(`Reputation: ${sign}${points} points (${formatAction(action)})`, points >= 0 ? 'success' : 'warning') + } + + window.addEventListener('reputation-change', handleReputationChange) + return () => window.removeEventListener('reputation-change', handleReputationChange) + }, [showToast]) return ( + } /> @@ -36,6 +69,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/components/answers/AnswerCard.jsx b/src/components/answers/AnswerCard.jsx index 35b9f6f..b7ba025 100644 --- a/src/components/answers/AnswerCard.jsx +++ b/src/components/answers/AnswerCard.jsx @@ -1,15 +1,13 @@ import { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { ChevronUp, Clock, CheckCircle, XCircle, AlertTriangle, Shield, Trash2, Flag, MessageSquare, Sparkles } from 'lucide-react' +import { ChevronUp, ChevronDown, Clock, CheckCircle, XCircle, AlertTriangle, Shield, Trash2, Flag, MessageSquare, Sparkles, Check } from 'lucide-react' import Badge from '@/components/ui/Badge' import Avatar from '@/components/ui/Avatar' import Button from '@/components/ui/Button' import FilePreview from '@/components/ui/FilePreview' import { useUpvote } from '@/hooks/useUpvote' import { useAuth } from '@/hooks/useAuth' -import { useTranslation } from '@/hooks/useTranslation' -import TranslationButton from '@/components/translation/TranslationButton' -import TranslationBadge from '@/components/translation/TranslationBadge' +import { useToast } from '@/components/ui/Toast' function timeAgo(dateString) { const now = new Date() @@ -98,35 +96,76 @@ const statusConfig = { spam: { icon: AlertTriangle, variant: 'danger', label: 'Spam' }, } -export default function AnswerCard({ answer, isOwner, isAdmin, onVerify, onReject, onDelete, onSpam, onFlag }) { - const { toggleAnswerUpvote, hasUpvotedAnswer } = useUpvote() + +export default function AnswerCard({ answer, isOwner, isAdmin, isQuestionOwner, onVerify, onReject, onDelete, onSpam, onFlag, onAccept }) { + const { toggleAnswerVote, hasUpvotedAnswer, hasDownvotedAnswer } = useUpvote() const { user } = useAuth() + const { showToast } = useToast() + const [upvoted, setUpvoted] = useState(false) - const [localUpvotes, setLocalUpvotes] = useState(answer.upvotes || 0) + const [downvoted, setDownvoted] = useState(false) + const [localScore, setLocalScore] = useState((answer.upvotes || 0) - (answer.downvotes || 0)) const [showAiSummary, setShowAiSummary] = useState(false) - const preferredLanguage = user?.preferred_language || 'en' - const answerTranslation = useTranslation({ - contentId: `answer-${answer.id}`, - content: answer.content, - autoTargetLanguage: preferredLanguage, - autoTranslate: Boolean(user?.preferred_language), - }) + useEffect(() => { + setLocalScore((answer.upvotes || 0) - (answer.downvotes || 0)) + }, [answer.upvotes, answer.downvotes]) useEffect(() => { if (user) { hasUpvotedAnswer(answer.id).then(setUpvoted) + hasDownvotedAnswer(answer.id).then(setDownvoted) + } else { + setUpvoted(false) + setDownvoted(false) } - }, [answer.id, user, hasUpvotedAnswer]) + }, [answer.id, user, hasUpvotedAnswer, hasDownvotedAnswer]) const handleUpvote = async () => { - if (!user) return + if (!user) { + showToast('Please sign in to vote', 'info') + return + } + try { + await toggleAnswerVote(answer.id, true) + if (upvoted) { + setUpvoted(false) + setLocalScore(prev => prev - 1) + } else { + setUpvoted(true) + if (downvoted) { + setDownvoted(false) + setLocalScore(prev => prev + 2) + } else { + setLocalScore(prev => prev + 1) + } + } + } catch (err) { + showToast(err.message || 'Failed to upvote', 'error') + } + } + + const handleDownvote = async () => { + if (!user) { + showToast('Please sign in to vote', 'info') + return + } try { - await toggleAnswerUpvote(answer.id) - setUpvoted(!upvoted) - setLocalUpvotes(prev => upvoted ? prev - 1 : prev + 1) + await toggleAnswerVote(answer.id, false) + if (downvoted) { + setDownvoted(false) + setLocalScore(prev => prev + 1) + } else { + setDownvoted(true) + if (upvoted) { + setUpvoted(false) + setLocalScore(prev => prev - 2) + } else { + setLocalScore(prev => prev - 1) + } + } } catch (err) { - console.error('Upvote error:', err) + showToast(err.message || 'Failed to downvote', 'error') } } @@ -137,46 +176,65 @@ export default function AnswerCard({ answer, isOwner, isAdmin, onVerify, onRejec - {/* Upvote section */} -
+ {/* Upvote/Downvote & Acceptance Column */} +
- - {localUpvotes} + + + {localScore} -
- {/* Content */} -
-
- - -
+ + {/* Accepted solution checkmark */} + {(isQuestionOwner || answer.is_accepted) && ( + + )} +
+
-

{answerTranslation.displayText}

+

{answer.content}

{answer.content && answer.content.length > 350 && ( diff --git a/src/components/answers/AnswerList.jsx b/src/components/answers/AnswerList.jsx index 2524db8..b4dc9bc 100644 --- a/src/components/answers/AnswerList.jsx +++ b/src/components/answers/AnswerList.jsx @@ -5,7 +5,7 @@ import AnswerCard from './AnswerCard' import { AnswerSkeleton } from '@/components/ui/Skeleton' import EmptyState from '@/components/ui/EmptyState' -export default function AnswerList({ answers, loading, isAdmin, userId, onVerify, onReject, onDelete, onSpam }) { +export default function AnswerList({ answers, loading, isAdmin, userId, isQuestionOwner, onVerify, onReject, onDelete, onSpam, onAccept }) { const [sortBy, setSortBy] = useState('newest') if (loading) { @@ -19,9 +19,17 @@ export default function AnswerList({ answers, loading, isAdmin, userId, onVerify } const sorted = [...(answers || [])].sort((a, b) => { + // Accepted answer always floats to the top + if (a.is_accepted && !b.is_accepted) return -1 + if (!a.is_accepted && b.is_accepted) return 1 + if (sortBy === 'newest') return new Date(b.created_at) - new Date(a.created_at) if (sortBy === 'oldest') return new Date(a.created_at) - new Date(b.created_at) - if (sortBy === 'upvoted') return (b.upvotes || 0) - (a.upvotes || 0) + if (sortBy === 'upvoted') { + const scoreA = (a.upvotes || 0) - (a.downvotes || 0) + const scoreB = (b.upvotes || 0) - (b.downvotes || 0) + return scoreB - scoreA + } return 0 }) @@ -82,10 +90,12 @@ export default function AnswerList({ answers, loading, isAdmin, userId, onVerify answer={answer} isOwner={userId === answer.user_id} isAdmin={isAdmin} + isQuestionOwner={isQuestionOwner} onVerify={onVerify} onReject={onReject} onDelete={onDelete} onSpam={onSpam} + onAccept={onAccept} /> ))} diff --git a/src/components/layout/Layout.jsx b/src/components/layout/Layout.jsx index b80b9c8..d26dc07 100644 --- a/src/components/layout/Layout.jsx +++ b/src/components/layout/Layout.jsx @@ -2,7 +2,6 @@ import { motion } from 'framer-motion'; import Navbar from '@/components/layout/Navbar'; import Footer from '@/components/layout/Footer'; import Sidebar from '@/components/layout/Sidebar'; -import BackToTop from '@/components/ui/BackToTop'; // Premium background particle configuration const particles = Array.from({ length: 12 }, (_, i) => ({ @@ -87,8 +86,6 @@ export default function Layout({ children, showSidebar = false }) {
-
); } - diff --git a/src/components/layout/Navbar.jsx b/src/components/layout/Navbar.jsx index 8216e49..1d4b99b 100644 --- a/src/components/layout/Navbar.jsx +++ b/src/components/layout/Navbar.jsx @@ -13,6 +13,7 @@ import { User, LogOut, LayoutDashboard, + Trophy, } from 'lucide-react' import { useAuth } from '@/hooks/useAuth' import ThemeToggle from '@/components/ui/ThemeToggle' @@ -49,16 +50,26 @@ export default function Navbar() {