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 + /> +
+ +
+ +