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.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 (
+
+
+
+
+
+
+ );
+ }
+
+ 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
+
+
+
+
+
+
+ )}
+
+ {/* User Collections */}
+ {user && userCollections && userCollections.length > 0 && (
+
+
My Collections
+
+ {userCollections.map((collection) => (
+
+ ))}
+
+
+ )}
+
+ {/* Public Collections */}
+
+
Public Collections
+
+ {publicLoading ? (
+
+
+
Loading collections...
+
+ ) : filteredPublicCollections.length === 0 ? (
+
+
+
+ No collections found
+
+ {searchQuery ? 'No collections match your search.' : 'No public collections available yet.'}
+
+
+
+ ) : (
+
+ {filteredPublicCollections.map((collection) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+interface CollectionCardProps {
+ collection: any;
+ viewMode: 'grid' | 'list';
+ isOwner: boolean;
+}
+
+function CollectionCard({ collection, viewMode, isOwner }: CollectionCardProps) {
+ if (viewMode === 'list') {
+ return (
+
+
+
+
+
+
+
+ {collection.name}
+
+
+ {!collection.isPublic && }
+ {isOwner && Owner}
+
+
+ {collection.description && (
+
{collection.description}
+ )}
+
+
+
+
+ {collection.scripts?.length || 0} scripts
+
+
+
+ {new Date(collection.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+ {collection.author?.displayName?.[0]}
+
+
{collection.author?.displayName}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {collection.name}
+
+
+ {!collection.isPublic && }
+
+ {isOwner &&
Owner}
+
+
+
+ {collection.description && (
+
+ {collection.description}
+
+ )}
+
+
+
+
+
+
+
+ {collection.scripts?.length || 0} scripts
+
+
+
+ {new Date(collection.createdAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+ {collection.author?.displayName?.[0]}
+
+
{collection.author?.displayName}
+
+
+
+
+ );
+}
diff --git a/src/pages/ScriptDetail.tsx b/src/pages/ScriptDetail.tsx
index b22c8dd..835c338 100644
--- a/src/pages/ScriptDetail.tsx
+++ b/src/pages/ScriptDetail.tsx
@@ -3,7 +3,6 @@ import { useParams, Link } from 'react-router-dom';
import { Header } from '@/components/Header';
import { Footer } from '@/components/Footer';
import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -12,7 +11,7 @@ import {
Download,
Star,
Eye,
- MessageCircle,
+
Share2,
Bookmark,
Code2,
@@ -21,11 +20,16 @@ import {
Tag,
Copy,
Check,
- ThumbsUp
+
} from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { showSuccess } from '@/utils/toast';
-import { formatDate, formatRelativeTime, copyToClipboard } from '@/lib/utils';
+import { formatDate, copyToClipboard } from '@/lib/utils';
+import { useScript, useTrackView, useTrackDownload } from '@/hooks/useScripts';
+import { useUserRating, useRateScript, useScriptRatingStats } from '@/hooks/useRatings';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
+import { useTheme } from '@/contexts/ThemeContext';
// Mock script data - in a real app, this would come from an API
const mockScript = {
@@ -128,69 +132,43 @@ echo "Setting up Docker environment for $PROJECT_NAME..."
changes: ['Initial release', 'Basic Docker setup functionality']
}
],
- comments: [
- {
- id: 'comment1',
- author: {
- username: 'jane_smith',
- displayName: 'Jane Smith',
- avatarUrl: '',
- },
- content: 'This script saved me hours of setup time! Works perfectly on Ubuntu 22.04.',
- rating: 5,
- createdAt: '2024-01-14T15:30:00Z',
- likes: 8,
- replies: []
- },
- {
- id: 'comment2',
- author: {
- username: 'dev_mike',
- displayName: 'Mike Developer',
- avatarUrl: '',
- },
- content: 'Great script! I had to modify it slightly for my specific use case, but the structure is excellent.',
- rating: 4,
- createdAt: '2024-01-13T09:15:00Z',
- likes: 3,
- replies: [
- {
- id: 'reply1',
- author: {
- username: 'john_doe',
- displayName: 'John Doe',
- avatarUrl: '',
- },
- content: 'Thanks! What modifications did you make? I\'d love to incorporate them in the next version.',
- createdAt: '2024-01-13T10:00:00Z',
- likes: 2
- }
- ]
- }
- ]
+
};
export default function ScriptDetail() {
const { scriptId } = useParams();
const { user } = useAuth();
- const [script] = useState(mockScript);
+ const { theme } = useTheme();
+
+ // API hooks
+ const { data: script, isLoading: scriptLoading } = useScript(scriptId || '');
+ const { data: userRatingData } = useUserRating(scriptId || '', user?.id);
+ const { data: ratingStats } = useScriptRatingStats(scriptId || '');
+ const trackView = useTrackView();
+ const trackDownload = useTrackDownload();
+ const rateScript = useRateScript();
+ // Local state
const [isBookmarked, setIsBookmarked] = useState(false);
- const [userRating, setUserRating] = useState(0);
const [showFullDescription, setShowFullDescription] = useState(false);
const [showFullCode, setShowFullCode] = useState(false);
- const [newComment, setNewComment] = useState('');
- const [isSubmittingComment, setIsSubmittingComment] = useState(false);
const [copied, setCopied] = useState(false);
+ // Use mock data for now, but ready for real API
+ const displayScript = script || mockScript;
+ const userRating = userRatingData?.rating || 0;
+
useEffect(() => {
- // In a real app, fetch script data based on scriptId
- console.log('Fetching script:', scriptId);
- }, [scriptId]);
+ if (scriptId) {
+ // Track view when component mounts
+ trackView.mutate(scriptId);
+ }
+ }, [scriptId, trackView]);
const handleDownload = () => {
- // In a real app, this would trigger a download
- showSuccess('Download started!');
+ if (scriptId) {
+ trackDownload.mutate(scriptId);
+ }
};
@@ -201,15 +179,20 @@ export default function ScriptDetail() {
};
const handleRating = (rating: number) => {
- setUserRating(rating);
- showSuccess(`Rated ${rating} stars`);
+ if (!user || !scriptId) return;
+
+ rateScript.mutate({
+ scriptId,
+ userId: user.id,
+ rating,
+ });
};
const handleShare = async () => {
try {
await navigator.share({
- title: script.name,
- text: script.description,
+ title: displayScript.name,
+ text: displayScript.description,
url: window.location.href,
});
} catch (error) {
@@ -220,26 +203,13 @@ export default function ScriptDetail() {
};
const handleCopyCode = async () => {
- await copyToClipboard(script.code);
+ await copyToClipboard(displayScript.content || displayScript.code || '');
setCopied(true);
showSuccess('Code copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
};
- const handleCommentSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!newComment.trim()) return;
- setIsSubmittingComment(true);
-
- // Simulate API call
- await new Promise(resolve => setTimeout(resolve, 1000));
-
- // In a real app, submit comment to API
- showSuccess('Comment submitted successfully!');
- setNewComment('');
- setIsSubmittingComment(false);
- };
const renderStars = (rating: number, interactive = false, onRatingChange?: (rating: number) => void) => {
return (
@@ -275,7 +245,7 @@ export default function ScriptDetail() {
/
Scripts
/
- {script.name}
+ {displayScript.name}
{/* Script Header */}
@@ -286,18 +256,18 @@ export default function ScriptDetail() {
-
{script.name}
+
{displayScript.name}
- v{script.version}
+ v{displayScript.version}
•
- {script.license} License
+ {displayScript.license || 'MIT'} License
•
- {script.size}
+ {displayScript.size || 'N/A'}
- {script.description}
+ {displayScript.description}
@@ -319,30 +289,30 @@ export default function ScriptDetail() {
- {script.viewCount.toLocaleString()} views
+ {displayScript.viewCount.toLocaleString()} views
- {script.downloadCount.toLocaleString()} downloads
+ {displayScript.downloadCount.toLocaleString()} downloads
- {script.rating} ({script.ratingCount} ratings)
+ {displayScript.rating} ({displayScript.ratingCount} ratings)
- Updated {formatRelativeTime(script.lastUpdated)}
+ Updated {formatDate(displayScript.updatedAt || displayScript.lastUpdated)}
{/* Tags and Categories */}
- {script.categories.map(category => (
+ {displayScript.categories.map(category => (
{category}
))}
- {script.tags.map(tag => (
+ {displayScript.tags?.map(tag => (
{tag}
@@ -363,7 +333,7 @@ export default function ScriptDetail() {
- {showFullDescription ? script.longDescription : script.longDescription.slice(0, 300) + '...'}
+ {showFullDescription ? displayScript.longDescription : displayScript.longDescription.slice(0, 300) + '...'}