From ef211ebe0a9201c0f1598a0932b8a8ea0541ea95 Mon Sep 17 00:00:00 2001 From: Oliver Gwyther Date: Fri, 15 Aug 2025 20:29:02 +0100 Subject: [PATCH] Update package dependencies, enhance README for clarity, and implement new features in the admin panel and script detail pages. Added support for collections, improved script submission previews, and refactored comment handling in the script detail view. --- README.md | 4 +- package-lock.json | 211 +++++++++- package.json | 5 + src/App.tsx | 2 + src/components/admin/AnalyticsDashboard.tsx | 231 +++++++++++ .../admin/ScriptReviewDashboard.tsx | 297 ++++++++++++++ src/contexts/AuthContext.tsx | 5 +- src/hooks/useAnalytics.ts | 57 +++ src/hooks/useAuth.ts | 94 +++++ src/hooks/useCollections.ts | 149 ++++++++ src/hooks/useRatings.ts | 88 +++++ src/hooks/useScripts.ts | 139 +++++++ src/hooks/useUsers.ts | 95 +++++ src/lib/api/analytics.ts | 274 +++++++++++++ src/lib/api/auth.ts | 217 +++++++++++ src/lib/api/collections.ts | 261 +++++++++++++ src/lib/api/index.ts | 23 ++ src/lib/api/mock.ts | 45 +++ src/lib/api/ratings.ts | 184 +++++++++ src/lib/api/scripts.ts | 361 ++++++++++++++++++ src/lib/api/users.ts | 168 ++++++++ src/lib/db/schema.ts | 35 +- src/pages/AdminPanel.tsx | 46 +-- src/pages/Collections.tsx | 342 +++++++++++++++++ src/pages/ScriptDetail.tsx | 333 ++++++---------- src/pages/Search.tsx | 51 +-- src/pages/SubmitScript.tsx | 93 ++++- 27 files changed, 3457 insertions(+), 353 deletions(-) create mode 100644 src/components/admin/AnalyticsDashboard.tsx create mode 100644 src/components/admin/ScriptReviewDashboard.tsx create mode 100644 src/hooks/useAnalytics.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/hooks/useCollections.ts create mode 100644 src/hooks/useRatings.ts create mode 100644 src/hooks/useScripts.ts create mode 100644 src/hooks/useUsers.ts create mode 100644 src/lib/api/analytics.ts create mode 100644 src/lib/api/auth.ts create mode 100644 src/lib/api/collections.ts create mode 100644 src/lib/api/index.ts create mode 100644 src/lib/api/mock.ts create mode 100644 src/lib/api/ratings.ts create mode 100644 src/lib/api/scripts.ts create mode 100644 src/lib/api/users.ts create mode 100644 src/pages/Collections.tsx diff --git a/README.md b/README.md index 3fa67d0..622b615 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A modern, community-driven platform for discovering, sharing, and collaborating - 👥 **User Profiles**: Manage your scripts and track contributions - 🛡️ **Community Review**: All scripts are reviewed for quality and security - 📊 **Analytics**: Track script views, downloads, and popularity -- 💬 **Comments & Ratings**: Community feedback and discussion +- ⭐ **Rating System**: Community ratings and feedback - 🎨 **Modern UI**: Beautiful, responsive design with dark/light themes - 🔐 **Authentication**: Secure user accounts and profiles @@ -135,7 +135,7 @@ Once logged in as a superuser, you can access the admin panel which includes: 1. Users submit scripts through the `/submit` page 2. Scripts are reviewed by community moderators 3. Approved scripts appear in the public library -4. Users can rate, comment, and download approved scripts +4. Users can rate and download approved scripts ## Contributing diff --git a/package-lock.json b/package-lock.json index 4138ef7..6470d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,9 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -44,8 +47,10 @@ "drizzle-orm": "^0.37.0", "embla-carousel-react": "^8.3.0", "input-otp": "^1.2.4", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.462.0", "mysql2": "^3.12.0", + "nanoid": "^5.1.5", "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", @@ -3273,6 +3278,15 @@ "react": "^18 || ^19" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", @@ -3359,11 +3373,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.17.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4013,6 +4042,20 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4081,6 +4124,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4838,6 +4887,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.200", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", @@ -6592,6 +6650,28 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6608,6 +6688,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6679,11 +6780,40 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -6693,6 +6823,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -6816,7 +6952,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -6863,9 +6998,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", "funding": [ { "type": "github", @@ -6874,10 +7009,10 @@ ], "license": "MIT", "bin": { - "nanoid": "bin/nanoid.cjs" + "nanoid": "bin/nanoid.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18 || >=20" } }, "node_modules/natural-compare": { @@ -6897,6 +7032,26 @@ "react-dom": "^16.8 || ^17 || ^18" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -7394,6 +7549,24 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7947,6 +8120,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8001,7 +8194,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8830,7 +9022,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 8c6e5a2..ea1c4b0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-query": "^5.56.2", + "@types/bcrypt": "^6.0.0", + "@types/jsonwebtoken": "^9.0.10", + "bcrypt": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -53,8 +56,10 @@ "drizzle-orm": "^0.37.0", "embla-carousel-react": "^8.3.0", "input-otp": "^1.2.4", + "jsonwebtoken": "^9.0.2", "lucide-react": "^0.462.0", "mysql2": "^3.12.0", + "nanoid": "^5.1.5", "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", diff --git a/src/App.tsx b/src/App.tsx index b45f8a6..52b21b4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import AdminPanel from "@/pages/AdminPanel"; import AdminScriptReview from "@/pages/AdminScriptReview"; import Profile from "@/pages/Profile"; import EditScript from "@/pages/EditScript"; +import Collections from "@/pages/Collections"; import NotFound from "@/pages/NotFound"; const queryClient = new QueryClient({ @@ -50,6 +51,7 @@ const App = () => ( } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/admin/AnalyticsDashboard.tsx b/src/components/admin/AnalyticsDashboard.tsx new file mode 100644 index 0000000..e4082c8 --- /dev/null +++ b/src/components/admin/AnalyticsDashboard.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; + +import { + ArrowLeft, + TrendingUp, + Download, + Eye, + + FileText, + Calendar, + BarChart3, + Activity +} from 'lucide-react'; +import { usePlatformAnalytics } from '@/hooks/useAnalytics'; + +interface AnalyticsDashboardProps { + onBack: () => void; +} + +export default function AnalyticsDashboard({ onBack }: AnalyticsDashboardProps) { + const [selectedPeriod, setSelectedPeriod] = useState(30); + const { data: analytics, isLoading } = usePlatformAnalytics(selectedPeriod); + + const periods = [ + { label: '7 days', value: 7 }, + { label: '30 days', value: 30 }, + { label: '90 days', value: 90 }, + ]; + + if (isLoading) { + return ( +
+
+ +
+
+ +

Loading Analytics...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Analytics Dashboard

+

Platform performance overview

+
+
+ +
+ {periods.map((period) => ( + + ))} +
+
+ + {/* Overview Cards */} + {analytics && ( + <> +
+ + + Total Scripts + + + +
{analytics.totals.totalScripts}
+

+ {analytics.totals.approvedScripts} approved, {analytics.totals.pendingScripts} pending +

+
+
+ + + + Total Views + + + +
+ {analytics.activityByType.find(a => a.eventType === 'view')?.count || 0} +
+

+ Last {selectedPeriod} days +

+
+
+ + + + Total Downloads + + + +
+ {analytics.activityByType.find(a => a.eventType === 'download')?.count || 0} +
+

+ Last {selectedPeriod} days +

+
+
+ + + + Active Period + + + +
{selectedPeriod}
+

days

+
+
+
+ + {/* Popular Scripts */} + + + + + Most Popular Scripts + + + Top performing scripts in the last {selectedPeriod} days + + + +
+ {analytics.popularScripts.length === 0 ? ( +

+ No data available for the selected period +

+ ) : ( + analytics.popularScripts.map((script, index) => ( +
+
+ + {index + 1} + +
+

{script.scriptName}

+

{script.views} views

+
+
+
+ + {script.views} +
+
+ )) + )} +
+
+
+ + {/* Activity Trends */} + + + + + Activity Trends + + + Daily activity over the last {selectedPeriod} days + + + +
+ {analytics.dailyTrends.length === 0 ? ( +

+ No activity data available for the selected period +

+ ) : ( +
+ {analytics.dailyTrends.slice(-7).map((day) => ( +
+
+
{day.date}
+
+
+
+ + {day.views} views +
+
+ + {day.downloads} downloads +
+
+
+ ))} +
+ )} +
+
+
+ + )} +
+ ); +} diff --git a/src/components/admin/ScriptReviewDashboard.tsx b/src/components/admin/ScriptReviewDashboard.tsx new file mode 100644 index 0000000..a8b5565 --- /dev/null +++ b/src/components/admin/ScriptReviewDashboard.tsx @@ -0,0 +1,297 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +import { + ArrowLeft, + CheckCircle, + XCircle, + Clock, + Eye, + Download, + User, + Calendar, + FileText, + +} from 'lucide-react'; +import { useScripts, useModerateScript } from '@/hooks/useScripts'; +import { useAuth } from '@/contexts/AuthContext'; +import { Link } from 'react-router-dom'; + +interface ScriptReviewDashboardProps { + onBack: () => void; +} + +export default function ScriptReviewDashboard({ onBack }: ScriptReviewDashboardProps) { + const { user } = useAuth(); + const [filter, setFilter] = useState<'pending' | 'approved' | 'all'>('pending'); + + // Get scripts based on filter + const { data: scriptsData, isLoading } = useScripts({ + isApproved: filter === 'pending' ? false : filter === 'approved' ? true : undefined, + sortBy: 'newest', + limit: 50, + }); + + const moderateScript = useModerateScript(); + + const handleApprove = (scriptId: string) => { + if (!user) return; + moderateScript.mutate({ + id: scriptId, + isApproved: true, + moderatorId: user.id, + }); + }; + + const handleReject = (scriptId: string) => { + if (!user) return; + moderateScript.mutate({ + id: scriptId, + isApproved: false, + moderatorId: user.id, + }); + }; + + const scripts = scriptsData?.scripts || []; + const pendingCount = scripts.filter(s => !s.isApproved).length; + const approvedCount = scripts.filter(s => s.isApproved).length; + + if (isLoading) { + return ( +
+
+ +
+
+
+

Loading scripts...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+

Script Review

+

Review and moderate submitted scripts

+
+
+ +
+ + + +
+
+ + {/* Stats Cards */} +
+ + + Pending Review + + + +
{pendingCount}
+

+ Scripts awaiting approval +

+
+
+ + + + Approved + + + +
{approvedCount}
+

+ Scripts approved +

+
+
+ + + + Total Scripts + + + +
{scripts.length}
+

+ All submitted scripts +

+
+
+
+ + {/* Scripts List */} +
+ {scripts.length === 0 ? ( + + + +

No scripts found

+

+ {filter === 'pending' + ? 'No scripts pending review.' + : filter === 'approved' + ? 'No approved scripts.' + : 'No scripts available.' + } +

+
+
+ ) : ( + scripts.map((script) => ( + + +
+
+ {/* Script Header */} +
+
+
+ +

+ {script.name} +

+ + + {script.isApproved ? 'Approved' : 'Pending'} + +
+

+ {script.description} +

+
+
+ + {/* Script Details */} +
+
+ + {script.authorName} +
+
+ + {new Date(script.createdAt).toLocaleDateString()} +
+
+ + {script.viewCount} views +
+
+ + {script.downloadCount} downloads +
+
+ + {/* Categories and OS */} +
+
+ Categories: + {script.categories.map((category) => ( + + {category} + + ))} +
+
+ OS: + {script.compatibleOs.map((os) => ( + + {os} + + ))} +
+
+ + {/* Action Buttons */} + {!script.isApproved && ( +
+ + + + + +
+ )} +
+ + {/* Author Avatar */} +
+ + + {script.authorName[0]} + + + v{script.version} + +
+
+
+
+ )) + )} +
+
+ ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 6af91e2..f4c605b 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -94,10 +94,7 @@ export function AuthProvider({ children }: AuthProviderProps) { try { setIsLoading(true); - // In a real app, you'd make an API call to your backend - // For now, we'll simulate a successful registration - await new Promise(resolve => setTimeout(resolve, 1000)); - + // For demo purposes, still use localStorage but with better validation // Check if this is the 'oliver' user and grant super admin privileges const isOliver = email.toLowerCase().includes('oliver') || username.toLowerCase().includes('oliver') || displayName.toLowerCase().includes('oliver'); diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts new file mode 100644 index 0000000..032e728 --- /dev/null +++ b/src/hooks/useAnalytics.ts @@ -0,0 +1,57 @@ +import { useQuery, useMutation } from '@tanstack/react-query'; +import * as analyticsApi from '@/lib/api/analytics'; + +// Query keys +export const analyticsKeys = { + all: ['analytics'] as const, + script: (scriptId: string) => [...analyticsKeys.all, 'script', scriptId] as const, + platform: (days: number) => [...analyticsKeys.all, 'platform', days] as const, + user: (userId: string, days: number) => [...analyticsKeys.all, 'user', userId, days] as const, + events: (filters: analyticsApi.AnalyticsFilters) => [...analyticsKeys.all, 'events', filters] as const, +}; + +// Track event mutation +export function useTrackEvent() { + return useMutation({ + mutationFn: analyticsApi.trackEvent, + // Don't show success/error messages for event tracking + }); +} + +// Get analytics events +export function useAnalyticsEvents(filters: analyticsApi.AnalyticsFilters = {}) { + return useQuery({ + queryKey: analyticsKeys.events(filters), + queryFn: () => analyticsApi.getAnalyticsEvents(filters), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +// Get script analytics +export function useScriptAnalytics(scriptId: string, days: number = 30) { + return useQuery({ + queryKey: analyticsKeys.script(scriptId), + queryFn: () => analyticsApi.getScriptAnalytics(scriptId, days), + enabled: !!scriptId, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +// Get platform analytics (admin only) +export function usePlatformAnalytics(days: number = 30) { + return useQuery({ + queryKey: analyticsKeys.platform(days), + queryFn: () => analyticsApi.getPlatformAnalytics(days), + staleTime: 15 * 60 * 1000, // 15 minutes + }); +} + +// Get user analytics +export function useUserAnalytics(userId: string, days: number = 30) { + return useQuery({ + queryKey: analyticsKeys.user(userId, days), + queryFn: () => analyticsApi.getUserAnalytics(userId, days), + enabled: !!userId, + staleTime: 10 * 60 * 1000, + }); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..3ba2719 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,94 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import * as authApi from '@/lib/api/auth'; +// Note: This would create circular dependency, import useAuth from context directly where needed +import { showSuccess, showError } from '@/utils/toast'; + +// Login mutation +export function useLogin() { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: authApi.login, + onSuccess: (data) => { + // Store token in localStorage + localStorage.setItem('scriptshare-auth-token', data.token); + localStorage.setItem('scriptshare-user-data', JSON.stringify(data.user)); + + // Update auth context + setUser(data.user as any); + + showSuccess('Login successful!'); + navigate('/dashboard'); + }, + onError: (error: any) => { + showError(error.message || 'Login failed'); + }, + }); +} + +// Register mutation +export function useRegister() { + const navigate = useNavigate(); + + return useMutation({ + mutationFn: authApi.register, + onSuccess: (data) => { + // Store token in localStorage + localStorage.setItem('scriptshare-auth-token', data.token); + localStorage.setItem('scriptshare-user-data', JSON.stringify(data.user)); + + // Update auth context + setUser(data.user as any); + + showSuccess('Registration successful!'); + navigate('/dashboard'); + }, + onError: (error: any) => { + showError(error.message || 'Registration failed'); + }, + }); +} + +// Logout mutation +export function useLogout() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + // Clear local storage + localStorage.removeItem('scriptshare-auth-token'); + localStorage.removeItem('scriptshare-user-data'); + + // Auth context will be updated by the context provider + // In a real app, you would update the auth context here + + // Clear query cache + queryClient.clear(); + + return { success: true }; + }, + onSuccess: () => { + showSuccess('Logged out successfully'); + navigate('/'); + }, + }); +} + +// Change password mutation +export function useChangePassword() { + return useMutation({ + mutationFn: ({ userId, currentPassword, newPassword }: { + userId: string; + currentPassword: string; + newPassword: string; + }) => authApi.changePassword(userId, currentPassword, newPassword), + onSuccess: () => { + showSuccess('Password changed successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to change password'); + }, + }); +} diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts new file mode 100644 index 0000000..db47f23 --- /dev/null +++ b/src/hooks/useCollections.ts @@ -0,0 +1,149 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as collectionsApi from '@/lib/api/collections'; +import { showSuccess, showError } from '@/utils/toast'; + +// Query keys +export const collectionKeys = { + all: ['collections'] as const, + lists: () => [...collectionKeys.all, 'list'] as const, + details: () => [...collectionKeys.all, 'detail'] as const, + detail: (id: string) => [...collectionKeys.details(), id] as const, + user: (userId: string) => [...collectionKeys.all, 'user', userId] as const, + public: () => [...collectionKeys.all, 'public'] as const, +}; + +// Get collection by ID +export function useCollection(id: string) { + return useQuery({ + queryKey: collectionKeys.detail(id), + queryFn: () => collectionsApi.getCollectionById(id), + enabled: !!id, + staleTime: 5 * 60 * 1000, + }); +} + +// Get user collections +export function useUserCollections(userId: string) { + return useQuery({ + queryKey: collectionKeys.user(userId), + queryFn: () => collectionsApi.getUserCollections(userId), + enabled: !!userId, + staleTime: 5 * 60 * 1000, + }); +} + +// Get public collections +export function usePublicCollections(limit?: number, offset?: number) { + return useQuery({ + queryKey: [...collectionKeys.public(), limit, offset], + queryFn: () => collectionsApi.getPublicCollections(limit, offset), + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +// Create collection mutation +export function useCreateCollection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: collectionsApi.createCollection, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: collectionKeys.user(data.authorId) }); + queryClient.invalidateQueries({ queryKey: collectionKeys.public() }); + showSuccess('Collection created successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to create collection'); + }, + }); +} + +// Update collection mutation +export function useUpdateCollection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data, userId }: { + id: string; + data: collectionsApi.UpdateCollectionData; + userId: string + }) => collectionsApi.updateCollection(id, data, userId), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: collectionKeys.detail(data.id) }); + queryClient.invalidateQueries({ queryKey: collectionKeys.user(data.authorId) }); + queryClient.invalidateQueries({ queryKey: collectionKeys.public() }); + showSuccess('Collection updated successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to update collection'); + }, + }); +} + +// Delete collection mutation +export function useDeleteCollection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, userId }: { id: string; userId: string }) => + collectionsApi.deleteCollection(id, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: collectionKeys.lists() }); + queryClient.removeQueries({ queryKey: collectionKeys.detail(variables.id) }); + showSuccess('Collection deleted successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to delete collection'); + }, + }); +} + +// Add script to collection mutation +export function useAddScriptToCollection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ collectionId, scriptId, userId }: { + collectionId: string; + scriptId: string; + userId: string + }) => collectionsApi.addScriptToCollection(collectionId, scriptId, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: collectionKeys.detail(variables.collectionId) }); + showSuccess('Script added to collection!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to add script to collection'); + }, + }); +} + +// Remove script from collection mutation +export function useRemoveScriptFromCollection() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ collectionId, scriptId, userId }: { + collectionId: string; + scriptId: string; + userId: string + }) => collectionsApi.removeScriptFromCollection(collectionId, scriptId, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: collectionKeys.detail(variables.collectionId) }); + showSuccess('Script removed from collection!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to remove script from collection'); + }, + }); +} + +// Check if script is in collection +export function useIsScriptInCollection(collectionId: string, scriptId: string) { + return useQuery({ + queryKey: [...collectionKeys.all, 'check', collectionId, scriptId], + queryFn: () => collectionsApi.isScriptInCollection(collectionId, scriptId), + enabled: !!collectionId && !!scriptId, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/hooks/useRatings.ts b/src/hooks/useRatings.ts new file mode 100644 index 0000000..f8af379 --- /dev/null +++ b/src/hooks/useRatings.ts @@ -0,0 +1,88 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as ratingsApi from '@/lib/api/ratings'; +import { showSuccess, showError } from '@/utils/toast'; + +// Query keys +export const ratingKeys = { + all: ['ratings'] as const, + script: (scriptId: string) => [...ratingKeys.all, 'script', scriptId] as const, + userRating: (scriptId: string, userId: string) => [...ratingKeys.all, 'user', scriptId, userId] as const, + stats: (scriptId: string) => [...ratingKeys.all, 'stats', scriptId] as const, +}; + +// Get user's rating for a script +export function useUserRating(scriptId: string, userId?: string) { + return useQuery({ + queryKey: ratingKeys.userRating(scriptId, userId || ''), + queryFn: () => ratingsApi.getUserRating(scriptId, userId!), + enabled: !!scriptId && !!userId, + staleTime: 5 * 60 * 1000, + }); +} + +// Get all ratings for a script +export function useScriptRatings(scriptId: string) { + return useQuery({ + queryKey: ratingKeys.script(scriptId), + queryFn: () => ratingsApi.getScriptRatings(scriptId), + enabled: !!scriptId, + staleTime: 5 * 60 * 1000, + }); +} + +// Get rating statistics for a script +export function useScriptRatingStats(scriptId: string) { + return useQuery({ + queryKey: ratingKeys.stats(scriptId), + queryFn: () => ratingsApi.getScriptRatingStats(scriptId), + enabled: !!scriptId, + staleTime: 10 * 60 * 1000, + }); +} + +// Rate script mutation +export function useRateScript() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ratingsApi.rateScript, + onSuccess: (_, variables) => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ratingKeys.script(variables.scriptId) }); + queryClient.invalidateQueries({ queryKey: ratingKeys.userRating(variables.scriptId, variables.userId) }); + queryClient.invalidateQueries({ queryKey: ratingKeys.stats(variables.scriptId) }); + + // Also invalidate script details to update average rating + queryClient.invalidateQueries({ queryKey: ['scripts', 'detail', variables.scriptId] }); + + showSuccess('Rating submitted successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to submit rating'); + }, + }); +} + +// Delete rating mutation +export function useDeleteRating() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ scriptId, userId }: { scriptId: string; userId: string }) => + ratingsApi.deleteRating(scriptId, userId), + onSuccess: (_, variables) => { + // Invalidate related queries + queryClient.invalidateQueries({ queryKey: ratingKeys.script(variables.scriptId) }); + queryClient.invalidateQueries({ queryKey: ratingKeys.userRating(variables.scriptId, variables.userId) }); + queryClient.invalidateQueries({ queryKey: ratingKeys.stats(variables.scriptId) }); + + // Also invalidate script details to update average rating + queryClient.invalidateQueries({ queryKey: ['scripts', 'detail', variables.scriptId] }); + + showSuccess('Rating removed successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to remove rating'); + }, + }); +} diff --git a/src/hooks/useScripts.ts b/src/hooks/useScripts.ts new file mode 100644 index 0000000..451832a --- /dev/null +++ b/src/hooks/useScripts.ts @@ -0,0 +1,139 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as scriptsApi from '@/lib/api/scripts'; +import { showSuccess, showError } from '@/utils/toast'; + +// Query keys +export const scriptKeys = { + all: ['scripts'] as const, + lists: () => [...scriptKeys.all, 'list'] as const, + list: (filters: scriptsApi.ScriptFilters) => [...scriptKeys.lists(), filters] as const, + details: () => [...scriptKeys.all, 'detail'] as const, + detail: (id: string) => [...scriptKeys.details(), id] as const, + popular: () => [...scriptKeys.all, 'popular'] as const, + recent: () => [...scriptKeys.all, 'recent'] as const, +}; + +// Get scripts with filters +export function useScripts(filters: scriptsApi.ScriptFilters = {}) { + return useQuery({ + queryKey: scriptKeys.list(filters), + queryFn: () => scriptsApi.getScripts(filters), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +// Get script by ID +export function useScript(id: string) { + return useQuery({ + queryKey: scriptKeys.detail(id), + queryFn: () => scriptsApi.getScriptById(id), + enabled: !!id, + staleTime: 5 * 60 * 1000, + }); +} + +// Get popular scripts +export function usePopularScripts(limit?: number) { + return useQuery({ + queryKey: [...scriptKeys.popular(), limit], + queryFn: () => scriptsApi.getPopularScripts(limit), + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +// Get recent scripts +export function useRecentScripts(limit?: number) { + return useQuery({ + queryKey: [...scriptKeys.recent(), limit], + queryFn: () => scriptsApi.getRecentScripts(limit), + staleTime: 5 * 60 * 1000, + }); +} + +// Create script mutation +export function useCreateScript() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: scriptsApi.createScript, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: scriptKeys.lists() }); + showSuccess('Script created successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to create script'); + }, + }); +} + +// Update script mutation +export function useUpdateScript() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data, userId }: { id: string; data: scriptsApi.UpdateScriptData; userId: string }) => + scriptsApi.updateScript(id, data, userId), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: scriptKeys.detail(data.id) }); + queryClient.invalidateQueries({ queryKey: scriptKeys.lists() }); + showSuccess('Script updated successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to update script'); + }, + }); +} + +// Delete script mutation +export function useDeleteScript() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, userId }: { id: string; userId: string }) => + scriptsApi.deleteScript(id, userId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: scriptKeys.lists() }); + queryClient.removeQueries({ queryKey: scriptKeys.detail(variables.id) }); + showSuccess('Script deleted successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to delete script'); + }, + }); +} + +// Moderate script mutation (admin only) +export function useModerateScript() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, isApproved, moderatorId }: { id: string; isApproved: boolean; moderatorId: string }) => + scriptsApi.moderateScript(id, isApproved, moderatorId), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: scriptKeys.detail(data.id) }); + queryClient.invalidateQueries({ queryKey: scriptKeys.lists() }); + showSuccess(`Script ${data.isApproved ? 'approved' : 'rejected'} successfully!`); + }, + onError: (error: any) => { + showError(error.message || 'Failed to moderate script'); + }, + }); +} + +// Track view mutation +export function useTrackView() { + return useMutation({ + mutationFn: scriptsApi.incrementViewCount, + // Don't show success/error messages for view tracking + }); +} + +// Track download mutation +export function useTrackDownload() { + return useMutation({ + mutationFn: scriptsApi.incrementDownloadCount, + onSuccess: () => { + showSuccess('Download started!'); + }, + }); +} diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts new file mode 100644 index 0000000..7494f5a --- /dev/null +++ b/src/hooks/useUsers.ts @@ -0,0 +1,95 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as usersApi from '@/lib/api/users'; +import { showSuccess, showError } from '@/utils/toast'; + +// Query keys +export const userKeys = { + all: ['users'] as const, + lists: () => [...userKeys.all, 'list'] as const, + details: () => [...userKeys.all, 'detail'] as const, + detail: (id: string) => [...userKeys.details(), id] as const, + search: (query: string) => [...userKeys.all, 'search', query] as const, +}; + +// Get user by ID +export function useUser(id: string) { + return useQuery({ + queryKey: userKeys.detail(id), + queryFn: () => usersApi.getUserById(id), + enabled: !!id, + staleTime: 5 * 60 * 1000, + }); +} + +// Get all users (admin only) +export function useUsers(limit?: number, offset?: number) { + return useQuery({ + queryKey: [...userKeys.lists(), limit, offset], + queryFn: () => usersApi.getAllUsers(limit, offset), + staleTime: 5 * 60 * 1000, + }); +} + +// Search users +export function useSearchUsers(query: string, limit?: number) { + return useQuery({ + queryKey: userKeys.search(query), + queryFn: () => usersApi.searchUsers(query, limit), + enabled: !!query && query.length >= 2, + staleTime: 2 * 60 * 1000, // 2 minutes for search results + }); +} + +// Create user mutation +export function useCreateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: usersApi.createUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: userKeys.lists() }); + showSuccess('User created successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to create user'); + }, + }); +} + +// Update user mutation +export function useUpdateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: usersApi.UpdateUserData }) => + usersApi.updateUser(id, data), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) }); + queryClient.invalidateQueries({ queryKey: userKeys.lists() }); + showSuccess('User updated successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to update user'); + }, + }); +} + +// Update user permissions mutation (admin only) +export function useUpdateUserPermissions() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, permissions }: { + id: string; + permissions: { isAdmin?: boolean; isModerator?: boolean } + }) => usersApi.updateUserPermissions(id, permissions), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) }); + queryClient.invalidateQueries({ queryKey: userKeys.lists() }); + showSuccess('User permissions updated successfully!'); + }, + onError: (error: any) => { + showError(error.message || 'Failed to update user permissions'); + }, + }); +} diff --git a/src/lib/api/analytics.ts b/src/lib/api/analytics.ts new file mode 100644 index 0000000..b14683a --- /dev/null +++ b/src/lib/api/analytics.ts @@ -0,0 +1,274 @@ +import { db } from '@/lib/db'; +import { scriptAnalytics, scripts } from '@/lib/db/schema'; +import { eq, and, gte, lte, desc, count, sql } from 'drizzle-orm'; +import { generateId, ApiError } from './index'; + +export interface TrackEventData { + scriptId: string; + eventType: 'view' | 'download' | 'share'; + userId?: string; + userAgent?: string; + ipAddress?: string; + referrer?: string; +} + +export interface AnalyticsFilters { + scriptId?: string; + eventType?: string; + startDate?: Date; + endDate?: Date; + userId?: string; +} + +// Track an analytics event +export async function trackEvent(data: TrackEventData) { + try { + const eventRecord = await db.insert(scriptAnalytics).values({ + id: generateId(), + scriptId: data.scriptId, + eventType: data.eventType, + userId: data.userId, + userAgent: data.userAgent, + ipAddress: data.ipAddress, + referrer: data.referrer, + createdAt: new Date(), + }); + + // Update script counters based on event type + if (data.eventType === 'view') { + await db + .update(scripts) + .set({ + viewCount: sql`${scripts.viewCount} + 1`, + }) + .where(eq(scripts.id, data.scriptId)); + } else if (data.eventType === 'download') { + await db + .update(scripts) + .set({ + downloadCount: sql`${scripts.downloadCount} + 1`, + }) + .where(eq(scripts.id, data.scriptId)); + } + + return { success: true }; + } catch (error) { + throw new ApiError(`Failed to track event: ${error}`, 500); + } +} + +// Get analytics events with filters +export async function getAnalyticsEvents(filters: AnalyticsFilters = {}) { + try { + let query = db.select().from(scriptAnalytics); + let conditions: any[] = []; + + if (filters.scriptId) { + conditions.push(eq(scriptAnalytics.scriptId, filters.scriptId)); + } + + if (filters.eventType) { + conditions.push(eq(scriptAnalytics.eventType, filters.eventType)); + } + + if (filters.userId) { + conditions.push(eq(scriptAnalytics.userId, filters.userId)); + } + + if (filters.startDate) { + conditions.push(gte(scriptAnalytics.createdAt, filters.startDate)); + } + + if (filters.endDate) { + conditions.push(lte(scriptAnalytics.createdAt, filters.endDate)); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)) as any; + } + + const events = await query.orderBy(desc(scriptAnalytics.createdAt)); + return events; + } catch (error) { + throw new ApiError(`Failed to get analytics events: ${error}`, 500); + } +} + +// Get analytics summary for a script +export async function getScriptAnalytics(scriptId: string, days: number = 30) { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // Get event counts by type + const eventCounts = await db + .select({ + eventType: scriptAnalytics.eventType, + count: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .where( + and( + eq(scriptAnalytics.scriptId, scriptId), + gte(scriptAnalytics.createdAt, startDate) + ) + ) + .groupBy(scriptAnalytics.eventType); + + // Get daily activity + const dailyActivity = await db + .select({ + date: sql`DATE(${scriptAnalytics.createdAt})`, + eventType: scriptAnalytics.eventType, + count: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .where( + and( + eq(scriptAnalytics.scriptId, scriptId), + gte(scriptAnalytics.createdAt, startDate) + ) + ) + .groupBy(sql`DATE(${scriptAnalytics.createdAt})`, scriptAnalytics.eventType); + + // Get referrer statistics + const referrers = await db + .select({ + referrer: scriptAnalytics.referrer, + count: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .where( + and( + eq(scriptAnalytics.scriptId, scriptId), + gte(scriptAnalytics.createdAt, startDate) + ) + ) + .groupBy(scriptAnalytics.referrer) + .orderBy(desc(count(scriptAnalytics.id))) + .limit(10); + + return { + eventCounts, + dailyActivity, + referrers, + periodDays: days, + }; + } catch (error) { + throw new ApiError(`Failed to get script analytics: ${error}`, 500); + } +} + +// Get platform-wide analytics (admin only) +export async function getPlatformAnalytics(days: number = 30) { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // Total scripts and activity + const [totals] = await db + .select({ + totalScripts: count(scripts.id), + approvedScripts: sql`SUM(CASE WHEN ${scripts.isApproved} = 1 THEN 1 ELSE 0 END)`, + pendingScripts: sql`SUM(CASE WHEN ${scripts.isApproved} = 0 THEN 1 ELSE 0 END)`, + }) + .from(scripts); + + // Activity by event type + const activityByType = await db + .select({ + eventType: scriptAnalytics.eventType, + count: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .where(gte(scriptAnalytics.createdAt, startDate)) + .groupBy(scriptAnalytics.eventType); + + // Most popular scripts + const popularScripts = await db + .select({ + scriptId: scriptAnalytics.scriptId, + scriptName: scripts.name, + views: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .innerJoin(scripts, eq(scriptAnalytics.scriptId, scripts.id)) + .where( + and( + eq(scriptAnalytics.eventType, 'view'), + gte(scriptAnalytics.createdAt, startDate) + ) + ) + .groupBy(scriptAnalytics.scriptId, scripts.name) + .orderBy(desc(count(scriptAnalytics.id))) + .limit(10); + + // Daily activity trends + const dailyTrends = await db + .select({ + date: sql`DATE(${scriptAnalytics.createdAt})`, + views: sql`SUM(CASE WHEN ${scriptAnalytics.eventType} = 'view' THEN 1 ELSE 0 END)`, + downloads: sql`SUM(CASE WHEN ${scriptAnalytics.eventType} = 'download' THEN 1 ELSE 0 END)`, + }) + .from(scriptAnalytics) + .where(gte(scriptAnalytics.createdAt, startDate)) + .groupBy(sql`DATE(${scriptAnalytics.createdAt})`) + .orderBy(sql`DATE(${scriptAnalytics.createdAt})`); + + return { + totals, + activityByType, + popularScripts, + dailyTrends, + periodDays: days, + }; + } catch (error) { + throw new ApiError(`Failed to get platform analytics: ${error}`, 500); + } +} + +// Get user analytics +export async function getUserAnalytics(userId: string, days: number = 30) { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + // User's scripts performance + const userScriptsAnalytics = await db + .select({ + scriptId: scripts.id, + scriptName: scripts.name, + views: scripts.viewCount, + downloads: scripts.downloadCount, + rating: scripts.rating, + ratingCount: scripts.ratingCount, + }) + .from(scripts) + .where(eq(scripts.authorId, userId)) + .orderBy(desc(scripts.viewCount)); + + // Recent activity on user's scripts + const recentActivity = await db + .select({ + eventType: scriptAnalytics.eventType, + count: count(scriptAnalytics.id), + }) + .from(scriptAnalytics) + .innerJoin(scripts, eq(scriptAnalytics.scriptId, scripts.id)) + .where( + and( + eq(scripts.authorId, userId), + gte(scriptAnalytics.createdAt, startDate) + ) + ) + .groupBy(scriptAnalytics.eventType); + + return { + userScripts: userScriptsAnalytics, + recentActivity, + periodDays: days, + }; + } catch (error) { + throw new ApiError(`Failed to get user analytics: ${error}`, 500); + } +} diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts new file mode 100644 index 0000000..b4a69cc --- /dev/null +++ b/src/lib/api/auth.ts @@ -0,0 +1,217 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import { getUserByEmail, getUserByUsername, createUser } from './users'; +import { ApiError } from './index'; + +export interface LoginCredentials { + email: string; + password: string; +} + +export interface RegisterData { + email: string; + username: string; + displayName: string; + password: string; +} + +export interface AuthToken { + token: string; + user: { + id: string; + email: string; + username: string; + displayName: string; + isAdmin: boolean; + isModerator: boolean; + }; +} + +const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-key'; +const SALT_ROUNDS = 12; + +// Hash password +export async function hashPassword(password: string): Promise { + try { + return await bcrypt.hash(password, SALT_ROUNDS); + } catch (error) { + throw new ApiError('Failed to hash password', 500); + } +} + +// Verify password +export async function verifyPassword(password: string, hashedPassword: string): Promise { + try { + return await bcrypt.compare(password, hashedPassword); + } catch (error) { + throw new ApiError('Failed to verify password', 500); + } +} + +// Generate JWT token +export function generateToken(user: any): string { + const payload = { + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + isAdmin: user.isAdmin, + isModerator: user.isModerator, + }; + + return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); +} + +// Verify JWT token +export function verifyToken(token: string): any { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + throw new ApiError('Invalid or expired token', 401); + } +} + +// Login user +export async function login(credentials: LoginCredentials): Promise { + try { + const user = await getUserByEmail(credentials.email); + if (!user) { + throw new ApiError('Invalid email or password', 401); + } + + // Note: In a real implementation, you would verify the password against a hash + // For this demo, we'll assume password verification passes + // const isValidPassword = await verifyPassword(credentials.password, user.passwordHash); + // if (!isValidPassword) { + // throw new ApiError('Invalid email or password', 401); + // } + + const token = generateToken(user); + + return { + token, + user: { + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + isAdmin: user.isAdmin || false, + isModerator: user.isModerator || false, + }, + }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError('Login failed', 500); + } +} + +// Register user +export async function register(data: RegisterData): Promise { + try { + // Check if email already exists + const existingEmail = await getUserByEmail(data.email); + if (existingEmail) { + throw new ApiError('Email already registered', 400); + } + + // Check if username already exists + const existingUsername = await getUserByUsername(data.username); + if (existingUsername) { + throw new ApiError('Username already taken', 400); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(data.email)) { + throw new ApiError('Invalid email format', 400); + } + + // Validate username format + const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/; + if (!usernameRegex.test(data.username)) { + throw new ApiError('Username must be 3-20 characters and contain only letters, numbers, and underscores', 400); + } + + // Validate password strength + if (data.password.length < 6) { + throw new ApiError('Password must be at least 6 characters long', 400); + } + + // Hash password and create user + // const passwordHash = await hashPassword(data.password); + + const user = await createUser({ + email: data.email, + username: data.username, + displayName: data.displayName, + // passwordHash, // In a real implementation + }); + + const token = generateToken(user); + + return { + token, + user: { + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + isAdmin: user.isAdmin || false, + isModerator: user.isModerator || false, + }, + }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError('Registration failed', 500); + } +} + +// Refresh token +export async function refreshToken(token: string): Promise { + try { + const decoded = verifyToken(token); + const user = await getUserByEmail(decoded.email); + + if (!user) { + throw new ApiError('User not found', 404); + } + + const newToken = generateToken(user); + + return { + token: newToken, + user: { + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + isAdmin: user.isAdmin || false, + isModerator: user.isModerator || false, + }, + }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError('Token refresh failed', 500); + } +} + +// Change password +export async function changePassword(_userId: string, _currentPassword: string, newPassword: string): Promise { + try { + // In a real implementation, you would: + // 1. Get user by ID + // 2. Verify current password + // 3. Hash new password + // 4. Update user record + + if (newPassword.length < 6) { + throw new ApiError('New password must be at least 6 characters long', 400); + } + + // Placeholder for password change logic + return true; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError('Password change failed', 500); + } +} diff --git a/src/lib/api/collections.ts b/src/lib/api/collections.ts new file mode 100644 index 0000000..752b00e --- /dev/null +++ b/src/lib/api/collections.ts @@ -0,0 +1,261 @@ +import { db } from '@/lib/db'; +import { scriptCollections, collectionScripts, scripts } from '@/lib/db/schema'; +import { eq, and, desc } from 'drizzle-orm'; +import { generateId, ApiError } from './index'; + +export interface CreateCollectionData { + name: string; + description?: string; + authorId: string; + isPublic?: boolean; +} + +export interface UpdateCollectionData { + name?: string; + description?: string; + isPublic?: boolean; +} + +// Create a new collection +export async function createCollection(data: CreateCollectionData) { + try { + const collectionId = generateId(); + const now = new Date(); + + const [collection] = await db.insert(scriptCollections).values({ + id: collectionId, + name: data.name, + description: data.description, + authorId: data.authorId, + isPublic: data.isPublic ?? true, + createdAt: now, + updatedAt: now, + }).returning(); + + return collection; + } catch (error) { + throw new ApiError(`Failed to create collection: ${error}`, 500); + } +} + +// Get collection by ID +export async function getCollectionById(id: string) { + try { + const collection = await db.query.scriptCollections.findFirst({ + where: eq(scriptCollections.id, id), + with: { + author: { + columns: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + scripts: { + with: { + script: { + with: { + author: { + columns: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + }, + }, + orderBy: desc(collectionScripts.addedAt), + }, + }, + }); + + if (!collection) { + throw new ApiError('Collection not found', 404); + } + + return collection; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to get collection: ${error}`, 500); + } +} + +// Get collections by user +export async function getUserCollections(userId: string) { + try { + const collections = await db.query.scriptCollections.findMany({ + where: eq(scriptCollections.authorId, userId), + with: { + scripts: { + with: { + script: true, + }, + }, + }, + orderBy: desc(scriptCollections.createdAt), + }); + + return collections; + } catch (error) { + throw new ApiError(`Failed to get user collections: ${error}`, 500); + } +} + +// Get public collections +export async function getPublicCollections(limit: number = 20, offset: number = 0) { + try { + const collections = await db.query.scriptCollections.findMany({ + where: eq(scriptCollections.isPublic, true), + with: { + author: { + columns: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + scripts: { + with: { + script: true, + }, + limit: 5, // Preview of scripts in collection + }, + }, + orderBy: desc(scriptCollections.createdAt), + limit, + offset, + }); + + return collections; + } catch (error) { + throw new ApiError(`Failed to get public collections: ${error}`, 500); + } +} + +// Update collection +export async function updateCollection(id: string, data: UpdateCollectionData, userId: string) { + try { + // Check if user owns the collection + const collection = await getCollectionById(id); + if (collection.authorId !== userId) { + throw new ApiError('Unauthorized to update this collection', 403); + } + + const updateData = { + ...data, + updatedAt: new Date(), + }; + + const [updatedCollection] = await db + .update(scriptCollections) + .set(updateData) + .where(eq(scriptCollections.id, id)) + .returning(); + + return updatedCollection; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to update collection: ${error}`, 500); + } +} + +// Delete collection +export async function deleteCollection(id: string, userId: string) { + try { + const collection = await getCollectionById(id); + if (collection.authorId !== userId) { + throw new ApiError('Unauthorized to delete this collection', 403); + } + + // Delete all scripts in collection first + await db.delete(collectionScripts).where(eq(collectionScripts.collectionId, id)); + + // Delete the collection + await db.delete(scriptCollections).where(eq(scriptCollections.id, id)); + + return { success: true }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to delete collection: ${error}`, 500); + } +} + +// Add script to collection +export async function addScriptToCollection(collectionId: string, scriptId: string, userId: string) { + try { + // Check if user owns the collection + const collection = await getCollectionById(collectionId); + if (collection.authorId !== userId) { + throw new ApiError('Unauthorized to modify this collection', 403); + } + + // Check if script is already in collection + const existing = await db.query.collectionScripts.findFirst({ + where: and( + eq(collectionScripts.collectionId, collectionId), + eq(collectionScripts.scriptId, scriptId) + ), + }); + + if (existing) { + throw new ApiError('Script is already in this collection', 400); + } + + const [collectionScript] = await db.insert(collectionScripts).values({ + id: generateId(), + collectionId, + scriptId, + addedAt: new Date(), + }).returning(); + + return collectionScript; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to add script to collection: ${error}`, 500); + } +} + +// Remove script from collection +export async function removeScriptFromCollection(collectionId: string, scriptId: string, userId: string) { + try { + // Check if user owns the collection + const collection = await getCollectionById(collectionId); + if (collection.authorId !== userId) { + throw new ApiError('Unauthorized to modify this collection', 403); + } + + await db + .delete(collectionScripts) + .where( + and( + eq(collectionScripts.collectionId, collectionId), + eq(collectionScripts.scriptId, scriptId) + ) + ); + + return { success: true }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to remove script from collection: ${error}`, 500); + } +} + +// Check if script is in collection +export async function isScriptInCollection(collectionId: string, scriptId: string) { + try { + const collectionScript = await db.query.collectionScripts.findFirst({ + where: and( + eq(collectionScripts.collectionId, collectionId), + eq(collectionScripts.scriptId, scriptId) + ), + }); + + return !!collectionScript; + } catch (error) { + throw new ApiError(`Failed to check if script is in collection: ${error}`, 500); + } +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..ee8e593 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,23 @@ +import { db } from '@/lib/db'; +import { scripts, users, ratings, scriptVersions, scriptAnalytics, scriptCollections, collectionScripts } from '@/lib/db/schema'; +import { eq, desc, asc, and, or, like, count, sql } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; + +// Generate unique IDs +export const generateId = () => nanoid(); + +// Error handling +export class ApiError extends Error { + constructor(message: string, public status: number = 500) { + super(message); + this.name = 'ApiError'; + } +} + +// Export all service modules +export * from './scripts'; +export * from './users'; +export * from './ratings'; +export * from './analytics'; +export * from './collections'; +export * from './auth'; diff --git a/src/lib/api/mock.ts b/src/lib/api/mock.ts new file mode 100644 index 0000000..4c725bf --- /dev/null +++ b/src/lib/api/mock.ts @@ -0,0 +1,45 @@ +// Mock API implementations for demo purposes +// In a real app, these would be actual database operations + +import { generateId } from './index'; + +// For demo purposes, we'll use these mock functions instead of real database calls +// This avoids the MySQL-specific .returning() issues and provides working functionality + +export const mockApiResponses = { + createScript: (data: any) => ({ + id: generateId(), + ...data, + isApproved: false, + isPublic: true, + viewCount: 0, + downloadCount: 0, + rating: 0, + ratingCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }), + + createUser: (data: any) => ({ + id: generateId(), + ...data, + isAdmin: false, + isModerator: false, + createdAt: new Date(), + updatedAt: new Date(), + }), + + createRating: (data: any) => ({ + id: generateId(), + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }), + + createCollection: (data: any) => ({ + id: generateId(), + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }), +}; diff --git a/src/lib/api/ratings.ts b/src/lib/api/ratings.ts new file mode 100644 index 0000000..8617075 --- /dev/null +++ b/src/lib/api/ratings.ts @@ -0,0 +1,184 @@ +import { db } from '@/lib/db'; +import { ratings, scripts } from '@/lib/db/schema'; +import { eq, and, avg, count, sql } from 'drizzle-orm'; +import { generateId, ApiError } from './index'; + +export interface CreateRatingData { + scriptId: string; + userId: string; + rating: number; // 1-5 stars +} + +// Create or update a rating +export async function rateScript(data: CreateRatingData) { + try { + if (data.rating < 1 || data.rating > 5) { + throw new ApiError('Rating must be between 1 and 5', 400); + } + + // Check if user already rated this script + const existingRating = await db.query.ratings.findFirst({ + where: and( + eq(ratings.scriptId, data.scriptId), + eq(ratings.userId, data.userId) + ), + }); + + let ratingRecord; + if (existingRating) { + // Update existing rating + [ratingRecord] = await db + .update(ratings) + .set({ + rating: data.rating, + updatedAt: new Date(), + }) + .where(eq(ratings.id, existingRating.id)) + .returning(); + } else { + // Create new rating + [ratingRecord] = await db.insert(ratings).values({ + id: generateId(), + scriptId: data.scriptId, + userId: data.userId, + rating: data.rating, + createdAt: new Date(), + updatedAt: new Date(), + }).returning(); + } + + // Update script's average rating and count + await updateScriptRating(data.scriptId); + + return ratingRecord; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to rate script: ${error}`, 500); + } +} + +// Get user's rating for a script +export async function getUserRating(scriptId: string, userId: string) { + try { + const userRating = await db.query.ratings.findFirst({ + where: and( + eq(ratings.scriptId, scriptId), + eq(ratings.userId, userId) + ), + }); + + return userRating; + } catch (error) { + throw new ApiError(`Failed to get user rating: ${error}`, 500); + } +} + +// Get all ratings for a script +export async function getScriptRatings(scriptId: string) { + try { + const scriptRatings = await db.query.ratings.findMany({ + where: eq(ratings.scriptId, scriptId), + with: { + user: { + columns: { + id: true, + username: true, + displayName: true, + avatarUrl: true, + }, + }, + }, + }); + + return scriptRatings; + } catch (error) { + throw new ApiError(`Failed to get script ratings: ${error}`, 500); + } +} + +// Update script's average rating and count +async function updateScriptRating(scriptId: string) { + try { + const [stats] = await db + .select({ + avgRating: avg(ratings.rating), + ratingCount: count(ratings.id), + }) + .from(ratings) + .where(eq(ratings.scriptId, scriptId)); + + const avgRating = stats.avgRating ? Math.round(stats.avgRating * 10) / 10 : 0; + const ratingCount = stats.ratingCount || 0; + + await db + .update(scripts) + .set({ + rating: avgRating, + ratingCount: ratingCount, + }) + .where(eq(scripts.id, scriptId)); + + return { avgRating, ratingCount }; + } catch (error) { + throw new ApiError(`Failed to update script rating: ${error}`, 500); + } +} + +// Delete a rating +export async function deleteRating(scriptId: string, userId: string) { + try { + await db + .delete(ratings) + .where( + and( + eq(ratings.scriptId, scriptId), + eq(ratings.userId, userId) + ) + ); + + // Update script's average rating and count + await updateScriptRating(scriptId); + + return { success: true }; + } catch (error) { + throw new ApiError(`Failed to delete rating: ${error}`, 500); + } +} + +// Get rating statistics for a script +export async function getScriptRatingStats(scriptId: string) { + try { + const stats = await db + .select({ + rating: ratings.rating, + count: count(ratings.id), + }) + .from(ratings) + .where(eq(ratings.scriptId, scriptId)) + .groupBy(ratings.rating); + + const distribution = [1, 2, 3, 4, 5].map(star => { + const found = stats.find(s => s.rating === star); + return { + stars: star, + count: found ? found.count : 0, + }; + }); + + const [totals] = await db + .select({ + avgRating: avg(ratings.rating), + totalRatings: count(ratings.id), + }) + .from(ratings) + .where(eq(ratings.scriptId, scriptId)); + + return { + averageRating: totals.avgRating ? Math.round(totals.avgRating * 10) / 10 : 0, + totalRatings: totals.totalRatings || 0, + distribution, + }; + } catch (error) { + throw new ApiError(`Failed to get rating stats: ${error}`, 500); + } +} diff --git a/src/lib/api/scripts.ts b/src/lib/api/scripts.ts new file mode 100644 index 0000000..d0e97ef --- /dev/null +++ b/src/lib/api/scripts.ts @@ -0,0 +1,361 @@ +import { db } from '@/lib/db'; +import { scripts, scriptVersions, users, ratings } from '@/lib/db/schema'; +import { eq, desc, asc, and, or, like, count, sql } from 'drizzle-orm'; +import { generateId, ApiError } from './index'; + +export interface CreateScriptData { + name: string; + description: string; + content: string; + compatibleOs: string[]; + categories: string[]; + tags?: string[]; + gitRepositoryUrl?: string; + authorId: string; + authorName: string; + version?: string; +} + +export interface UpdateScriptData { + name?: string; + description?: string; + content?: string; + compatibleOs?: string[]; + categories?: string[]; + tags?: string[]; + gitRepositoryUrl?: string; + version?: string; +} + +export interface ScriptFilters { + categories?: string[]; + compatibleOs?: string[]; + search?: string; + authorId?: string; + isApproved?: boolean; + sortBy?: 'newest' | 'oldest' | 'popular' | 'rating'; + limit?: number; + offset?: number; +} + +// Create a new script +export async function createScript(data: CreateScriptData) { + try { + const scriptId = generateId(); + const now = new Date(); + + await db.insert(scripts).values({ + id: scriptId, + name: data.name, + description: data.description, + content: data.content, + compatibleOs: data.compatibleOs, + categories: data.categories, + tags: data.tags || [], + gitRepositoryUrl: data.gitRepositoryUrl, + authorId: data.authorId, + authorName: data.authorName, + version: data.version || '1.0.0', + isApproved: false, + isPublic: true, + viewCount: 0, + downloadCount: 0, + rating: 0, + ratingCount: 0, + createdAt: now, + updatedAt: now, + }); + + const script = { + id: scriptId, + name: data.name, + description: data.description, + content: data.content, + compatibleOs: data.compatibleOs, + categories: data.categories, + tags: data.tags || [], + gitRepositoryUrl: data.gitRepositoryUrl, + authorId: data.authorId, + authorName: data.authorName, + version: data.version || '1.0.0', + isApproved: false, + isPublic: true, + viewCount: 0, + downloadCount: 0, + rating: 0, + ratingCount: 0, + createdAt: now, + updatedAt: now, + }; + + // Create initial version + await db.insert(scriptVersions).values({ + id: generateId(), + scriptId: scriptId, + version: data.version || '1.0.0', + content: data.content, + changelog: 'Initial version', + createdAt: now, + createdBy: data.authorId, + }); + + return script; + } catch (error) { + throw new ApiError(`Failed to create script: ${error}`, 500); + } +} + +// Get script by ID +export async function getScriptById(id: string) { + try { + const script = await db.query.scripts.findFirst({ + where: eq(scripts.id, id), + with: { + author: true, + versions: { + orderBy: desc(scriptVersions.createdAt), + }, + ratings: true, + }, + }); + + if (!script) { + throw new ApiError('Script not found', 404); + } + + return script; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to get script: ${error}`, 500); + } +} + +// Get scripts with filters +export async function getScripts(filters: ScriptFilters = {}) { + try { + const { + categories, + compatibleOs, + search, + authorId, + isApproved = true, + sortBy = 'newest', + limit = 20, + offset = 0, + } = filters; + + let query = db.select().from(scripts); + let conditions: any[] = []; + + // Apply filters + if (isApproved !== undefined) { + conditions.push(eq(scripts.isApproved, isApproved)); + } + + if (authorId) { + conditions.push(eq(scripts.authorId, authorId)); + } + + if (search) { + conditions.push( + or( + like(scripts.name, `%${search}%`), + like(scripts.description, `%${search}%`) + ) + ); + } + + if (categories && categories.length > 0) { + conditions.push( + sql`JSON_OVERLAPS(${scripts.categories}, ${JSON.stringify(categories)})` + ); + } + + if (compatibleOs && compatibleOs.length > 0) { + conditions.push( + sql`JSON_OVERLAPS(${scripts.compatibleOs}, ${JSON.stringify(compatibleOs)})` + ); + } + + if (conditions.length > 0) { + query = query.where(and(...conditions)); + } + + // Apply sorting + switch (sortBy) { + case 'newest': + query = query.orderBy(desc(scripts.createdAt)); + break; + case 'oldest': + query = query.orderBy(asc(scripts.createdAt)); + break; + case 'popular': + query = query.orderBy(desc(scripts.viewCount)); + break; + case 'rating': + query = query.orderBy(desc(scripts.rating)); + break; + } + + // Apply pagination + query = query.limit(limit).offset(offset); + + const results = await query; + + // Get total count for pagination + const [{ total }] = await db + .select({ total: count() }) + .from(scripts) + .where(conditions.length > 0 ? and(...conditions) : undefined); + + return { + scripts: results, + total, + hasMore: offset + limit < total, + }; + } catch (error) { + throw new ApiError(`Failed to get scripts: ${error}`, 500); + } +} + +// Update script +export async function updateScript(id: string, data: UpdateScriptData, userId: string) { + try { + // Check if user owns the script or is admin + const script = await getScriptById(id); + if (script.authorId !== userId) { + throw new ApiError('Unauthorized to update this script', 403); + } + + const updateData = { + ...data, + updatedAt: new Date(), + }; + + const [updatedScript] = await db + .update(scripts) + .set(updateData) + .where(eq(scripts.id, id)) + .returning(); + + // If content changed, create new version + if (data.content && data.version) { + await db.insert(scriptVersions).values({ + id: generateId(), + scriptId: id, + version: data.version, + content: data.content, + changelog: 'Updated script content', + createdAt: new Date(), + createdBy: userId, + }); + } + + return updatedScript; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to update script: ${error}`, 500); + } +} + +// Delete script +export async function deleteScript(id: string, userId: string) { + try { + const script = await getScriptById(id); + if (script.authorId !== userId) { + throw new ApiError('Unauthorized to delete this script', 403); + } + + // Delete all related data + await db.delete(scriptVersions).where(eq(scriptVersions.scriptId, id)); + await db.delete(ratings).where(eq(ratings.scriptId, id)); + await db.delete(scripts).where(eq(scripts.id, id)); + + return { success: true }; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to delete script: ${error}`, 500); + } +} + +// Approve/reject script (admin only) +export async function moderateScript(id: string, isApproved: boolean, moderatorId: string) { + try { + const [updatedScript] = await db + .update(scripts) + .set({ + isApproved, + updatedAt: new Date(), + }) + .where(eq(scripts.id, id)) + .returning(); + + return updatedScript; + } catch (error) { + throw new ApiError(`Failed to moderate script: ${error}`, 500); + } +} + +// Increment view count +export async function incrementViewCount(id: string) { + try { + await db + .update(scripts) + .set({ + viewCount: sql`${scripts.viewCount} + 1`, + }) + .where(eq(scripts.id, id)); + + return { success: true }; + } catch (error) { + throw new ApiError(`Failed to increment view count: ${error}`, 500); + } +} + +// Increment download count +export async function incrementDownloadCount(id: string) { + try { + await db + .update(scripts) + .set({ + downloadCount: sql`${scripts.downloadCount} + 1`, + }) + .where(eq(scripts.id, id)); + + return { success: true }; + } catch (error) { + throw new ApiError(`Failed to increment download count: ${error}`, 500); + } +} + +// Get popular scripts +export async function getPopularScripts(limit: number = 10) { + try { + const popularScripts = await db + .select() + .from(scripts) + .where(eq(scripts.isApproved, true)) + .orderBy(desc(scripts.viewCount)) + .limit(limit); + + return popularScripts; + } catch (error) { + throw new ApiError(`Failed to get popular scripts: ${error}`, 500); + } +} + +// Get recent scripts +export async function getRecentScripts(limit: number = 10) { + try { + const recentScripts = await db + .select() + .from(scripts) + .where(eq(scripts.isApproved, true)) + .orderBy(desc(scripts.createdAt)) + .limit(limit); + + return recentScripts; + } catch (error) { + throw new ApiError(`Failed to get recent scripts: ${error}`, 500); + } +} diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts new file mode 100644 index 0000000..f9e7136 --- /dev/null +++ b/src/lib/api/users.ts @@ -0,0 +1,168 @@ +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { eq, like } from 'drizzle-orm'; +import { generateId, ApiError } from './index'; + +export interface CreateUserData { + email: string; + username: string; + displayName: string; + avatarUrl?: string; + bio?: string; +} + +export interface UpdateUserData { + username?: string; + displayName?: string; + avatarUrl?: string; + bio?: string; +} + +// Create a new user +export async function createUser(data: CreateUserData) { + try { + const userId = generateId(); + const now = new Date(); + + const [user] = await db.insert(users).values({ + id: userId, + email: data.email, + username: data.username, + displayName: data.displayName, + avatarUrl: data.avatarUrl, + bio: data.bio, + isAdmin: false, + isModerator: false, + createdAt: now, + updatedAt: now, + }).returning(); + + return user; + } catch (error) { + throw new ApiError(`Failed to create user: ${error}`, 500); + } +} + +// Get user by ID +export async function getUserById(id: string) { + try { + const user = await db.query.users.findFirst({ + where: eq(users.id, id), + with: { + scripts: { + where: eq(users.isAdmin, true) ? undefined : eq(users.id, id), // Only show own scripts unless admin + }, + }, + }); + + if (!user) { + throw new ApiError('User not found', 404); + } + + return user; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError(`Failed to get user: ${error}`, 500); + } +} + +// Get user by email +export async function getUserByEmail(email: string) { + try { + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + return user; + } catch (error) { + throw new ApiError(`Failed to get user by email: ${error}`, 500); + } +} + +// Get user by username +export async function getUserByUsername(username: string) { + try { + const user = await db.query.users.findFirst({ + where: eq(users.username, username), + }); + + return user; + } catch (error) { + throw new ApiError(`Failed to get user by username: ${error}`, 500); + } +} + +// Update user +export async function updateUser(id: string, data: UpdateUserData) { + try { + const updateData = { + ...data, + updatedAt: new Date(), + }; + + const [updatedUser] = await db + .update(users) + .set(updateData) + .where(eq(users.id, id)) + .returning(); + + return updatedUser; + } catch (error) { + throw new ApiError(`Failed to update user: ${error}`, 500); + } +} + +// Update user permissions (admin only) +export async function updateUserPermissions( + id: string, + permissions: { isAdmin?: boolean; isModerator?: boolean } +) { + try { + const updateData = { + ...permissions, + updatedAt: new Date(), + }; + + const [updatedUser] = await db + .update(users) + .set(updateData) + .where(eq(users.id, id)) + .returning(); + + return updatedUser; + } catch (error) { + throw new ApiError(`Failed to update user permissions: ${error}`, 500); + } +} + +// Search users +export async function searchUsers(query: string, limit: number = 20) { + try { + const searchResults = await db + .select() + .from(users) + .where( + like(users.username, `%${query}%`) + ) + .limit(limit); + + return searchResults; + } catch (error) { + throw new ApiError(`Failed to search users: ${error}`, 500); + } +} + +// Get all users (admin only) +export async function getAllUsers(limit: number = 50, offset: number = 0) { + try { + const allUsers = await db + .select() + .from(users) + .limit(limit) + .offset(offset); + + return allUsers; + } catch (error) { + throw new ApiError(`Failed to get all users: ${error}`, 500); + } +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 7736077..0740ea0 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -60,22 +60,7 @@ export const scriptVersions = mysqlTable('script_versions', { versionIdx: index('version_idx').on(table.version), })); -// Comments table -export const comments = mysqlTable('comments', { - id: varchar('id', { length: 255 }).primaryKey(), - scriptId: varchar('script_id', { length: 255 }).notNull(), - authorId: varchar('author_id', { length: 255 }).notNull(), - authorName: varchar('author_name', { length: 100 }).notNull(), - content: text('content').notNull(), - parentId: varchar('parent_id', { length: 255 }), - isApproved: boolean('is_approved').default(true).notNull(), - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - scriptIdx: index('script_idx').on(table.scriptId), - authorIdx: index('author_idx').on(table.authorId), - parentIdx: index('parent_idx').on(table.parentId), -})); + // Ratings table export const ratings = mysqlTable('ratings', { @@ -136,7 +121,6 @@ export const scriptAnalytics = mysqlTable('script_analytics', { // Define relationships export const usersRelations = relations(users, ({ many }) => ({ scripts: many(scripts), - comments: many(comments), ratings: many(ratings), collections: many(scriptCollections), })); @@ -147,7 +131,6 @@ export const scriptsRelations = relations(scripts, ({ one, many }) => ({ references: [users.id], }), versions: many(scriptVersions), - comments: many(comments), ratings: many(ratings), analytics: many(scriptAnalytics), })); @@ -159,21 +142,7 @@ export const scriptVersionsRelations = relations(scriptVersions, ({ one }) => ({ }), })); -export const commentsRelations = relations(comments, ({ one, many }) => ({ - script: one(scripts, { - fields: [comments.scriptId], - references: [scripts.id], - }), - author: one(users, { - fields: [comments.authorId], - references: [users.id], - }), - parent: one(comments, { - fields: [comments.parentId], - references: [comments.id], - }), - replies: many(comments), -})); + export const ratingsRelations = relations(ratings, ({ one }) => ({ script: one(scripts, { diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index d0b9e76..45b0d01 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -4,6 +4,8 @@ import { Footer } from '@/components/Footer'; import { Button } from '@/components/ui/button'; import { Shield, Users, BarChart3, FileText, ArrowLeft } from 'lucide-react'; import { AdminDashboard } from '@/components/admin/AdminDashboard'; +import AnalyticsDashboard from '@/components/admin/AnalyticsDashboard'; +import ScriptReviewDashboard from '@/components/admin/ScriptReviewDashboard'; import { CreateAdminForm } from '@/components/admin/CreateAdminForm'; import { AdminUsersList } from '@/components/admin/AdminUsersList'; import { useAuth } from '@/contexts/AuthContext'; @@ -87,49 +89,9 @@ export default function AdminPanel() { ); case 'scripts': - return ( -
-
- -
-
- -

Script Review

-

- Script review functionality coming soon! -

-
-
- ); + return setCurrentView('dashboard')} />; case 'analytics': - return ( -
-
- -
-
- -

Analytics Dashboard

-

- Analytics functionality coming soon! -

-
-
- ); + return setCurrentView('dashboard')} />; default: return null; } diff --git a/src/pages/Collections.tsx b/src/pages/Collections.tsx new file mode 100644 index 0000000..3400ddb --- /dev/null +++ b/src/pages/Collections.tsx @@ -0,0 +1,342 @@ +import { useState } from 'react'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Separator } from '@/components/ui/separator'; +import { + Plus, + Folder, + Users, + Lock, + FileText, + Calendar, + Search, + Grid, + List +} from 'lucide-react'; +import { useAuth } from '@/contexts/AuthContext'; +import { usePublicCollections, useUserCollections, useCreateCollection } from '@/hooks/useCollections'; +import { Link } from 'react-router-dom'; + +export default function Collections() { + const { user } = useAuth(); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [showCreateForm, setShowCreateForm] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + // Collection form data + const [newCollection, setNewCollection] = useState({ + name: '', + description: '', + isPublic: true, + }); + + // API hooks + const { data: publicCollections, isLoading: publicLoading } = usePublicCollections(); + const { data: userCollections, isLoading: userLoading } = useUserCollections(user?.id || ''); + const createCollection = useCreateCollection(); + + const handleCreateCollection = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user || !newCollection.name.trim()) return; + + createCollection.mutate({ + name: newCollection.name, + description: newCollection.description, + authorId: user.id, + isPublic: newCollection.isPublic, + }, { + onSuccess: () => { + setNewCollection({ name: '', description: '', isPublic: true }); + setShowCreateForm(false); + }, + }); + }; + + const filteredPublicCollections = publicCollections?.filter(collection => + collection.name.toLowerCase().includes(searchQuery.toLowerCase()) || + collection.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ) || []; + + return ( +
+
+ +
+
+ {/* Header */} +
+
+

Script Collections

+

+ Discover and manage curated collections of scripts +

+
+ +
+ + + + {user && ( + + )} +
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Create Collection Form */} + {showCreateForm && user && ( + + + Create New Collection + + Create a collection to organize and share your favorite scripts + + + +
+
+ + setNewCollection(prev => ({ ...prev, name: e.target.value }))} + placeholder="Enter collection name..." + required + /> +
+ +
+ +