Update package dependencies, enhance README for clarity, and implement new features in the admin panel and script detail pages. Added support for collections, improved script submission previews, and refactored comment handling in the script detail view.

This commit is contained in:
2025-08-15 20:29:02 +01:00
parent 5fdfe3e790
commit ef211ebe0a
27 changed files with 3457 additions and 353 deletions

View File

@ -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

211
package-lock.json generated
View File

@ -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": {

View File

@ -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",

View File

@ -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 = () => (
<Route path="/my-scripts" element={<MyScripts />} />
<Route path="/profile" element={<Profile />} />
<Route path="/edit-script/:id" element={<EditScript />} />
<Route path="/collections" element={<Collections />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/admin/script/:id" element={<AdminScriptReview />} />
<Route path="/about" element={<About />} />

View File

@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
</div>
<div className="text-center py-12">
<Activity className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-2xl font-bold mb-2">Loading Analytics...</h2>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
<div>
<h1 className="text-2xl font-bold">Analytics Dashboard</h1>
<p className="text-muted-foreground">Platform performance overview</p>
</div>
</div>
<div className="flex items-center gap-2">
{periods.map((period) => (
<Button
key={period.value}
variant={selectedPeriod === period.value ? "default" : "outline"}
size="sm"
onClick={() => setSelectedPeriod(period.value)}
>
{period.label}
</Button>
))}
</div>
</div>
{/* Overview Cards */}
{analytics && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Scripts</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{analytics.totals.totalScripts}</div>
<p className="text-xs text-muted-foreground">
{analytics.totals.approvedScripts} approved, {analytics.totals.pendingScripts} pending
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Views</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.activityByType.find(a => a.eventType === 'view')?.count || 0}
</div>
<p className="text-xs text-muted-foreground">
Last {selectedPeriod} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Downloads</CardTitle>
<Download className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analytics.activityByType.find(a => a.eventType === 'download')?.count || 0}
</div>
<p className="text-xs text-muted-foreground">
Last {selectedPeriod} days
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Period</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{selectedPeriod}</div>
<p className="text-xs text-muted-foreground">days</p>
</CardContent>
</Card>
</div>
{/* Popular Scripts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Most Popular Scripts
</CardTitle>
<CardDescription>
Top performing scripts in the last {selectedPeriod} days
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{analytics.popularScripts.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No data available for the selected period
</p>
) : (
analytics.popularScripts.map((script, index) => (
<div key={script.scriptId} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Badge variant="secondary" className="w-8 h-8 rounded-full flex items-center justify-center p-0">
{index + 1}
</Badge>
<div>
<p className="font-medium">{script.scriptName}</p>
<p className="text-sm text-muted-foreground">{script.views} views</p>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{script.views}</span>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
{/* Activity Trends */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Activity Trends
</CardTitle>
<CardDescription>
Daily activity over the last {selectedPeriod} days
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{analytics.dailyTrends.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No activity data available for the selected period
</p>
) : (
<div className="space-y-2">
{analytics.dailyTrends.slice(-7).map((day) => (
<div key={day.date} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{day.date}</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-blue-500" />
<span className="text-sm">{day.views} views</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-green-500" />
<span className="text-sm">{day.downloads} downloads</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
</div>
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-2">Loading scripts...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
<div>
<h1 className="text-2xl font-bold">Script Review</h1>
<p className="text-muted-foreground">Review and moderate submitted scripts</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={filter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('pending')}
className="flex items-center gap-2"
>
<Clock className="h-4 w-4" />
Pending ({pendingCount})
</Button>
<Button
variant={filter === 'approved' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('approved')}
className="flex items-center gap-2"
>
<CheckCircle className="h-4 w-4" />
Approved ({approvedCount})
</Button>
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
All Scripts
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending Review</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{pendingCount}</div>
<p className="text-xs text-muted-foreground">
Scripts awaiting approval
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Approved</CardTitle>
<CheckCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{approvedCount}</div>
<p className="text-xs text-muted-foreground">
Scripts approved
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Scripts</CardTitle>
<FileText className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scripts.length}</div>
<p className="text-xs text-muted-foreground">
All submitted scripts
</p>
</CardContent>
</Card>
</div>
{/* Scripts List */}
<div className="space-y-4">
{scripts.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No scripts found</h3>
<p className="text-muted-foreground">
{filter === 'pending'
? 'No scripts pending review.'
: filter === 'approved'
? 'No approved scripts.'
: 'No scripts available.'
}
</p>
</CardContent>
</Card>
) : (
scripts.map((script) => (
<Card key={script.id} className="hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1 space-y-3">
{/* Script Header */}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Link to={`/script/${script.id}`}>
<h3 className="text-lg font-semibold hover:text-primary transition-colors">
{script.name}
</h3>
</Link>
<Badge variant={script.isApproved ? 'default' : 'secondary'}>
{script.isApproved ? 'Approved' : 'Pending'}
</Badge>
</div>
<p className="text-muted-foreground text-sm line-clamp-2">
{script.description}
</p>
</div>
</div>
{/* Script Details */}
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
{script.authorName}
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(script.createdAt).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{script.viewCount} views
</div>
<div className="flex items-center gap-1">
<Download className="h-4 w-4" />
{script.downloadCount} downloads
</div>
</div>
{/* Categories and OS */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Categories:</span>
{script.categories.map((category) => (
<Badge key={category} variant="outline" className="text-xs">
{category}
</Badge>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">OS:</span>
{script.compatibleOs.map((os) => (
<Badge key={os} variant="outline" className="text-xs">
{os}
</Badge>
))}
</div>
</div>
{/* Action Buttons */}
{!script.isApproved && (
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
onClick={() => handleApprove(script.id)}
disabled={moderateScript.isPending}
className="flex items-center gap-1"
>
<CheckCircle className="h-4 w-4" />
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleReject(script.id)}
disabled={moderateScript.isPending}
className="flex items-center gap-1"
>
<XCircle className="h-4 w-4" />
Reject
</Button>
<Link to={`/admin/script/${script.id}`}>
<Button size="sm" variant="outline" className="flex items-center gap-1">
<Eye className="h-4 w-4" />
Review
</Button>
</Link>
</div>
)}
</div>
{/* Author Avatar */}
<div className="flex flex-col items-center gap-2 ml-6">
<Avatar className="h-10 w-10">
<AvatarImage src="" />
<AvatarFallback>{script.authorName[0]}</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground text-center">
v{script.version}
</span>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}

View File

@ -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');

57
src/hooks/useAnalytics.ts Normal file
View File

@ -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,
});
}

94
src/hooks/useAuth.ts Normal file
View File

@ -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');
},
});
}

149
src/hooks/useCollections.ts Normal file
View File

@ -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,
});
}

88
src/hooks/useRatings.ts Normal file
View File

@ -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');
},
});
}

139
src/hooks/useScripts.ts Normal file
View File

@ -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!');
},
});
}

95
src/hooks/useUsers.ts Normal file
View File

@ -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');
},
});
}

274
src/lib/api/analytics.ts Normal file
View File

@ -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<string>`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<number>`SUM(CASE WHEN ${scripts.isApproved} = 1 THEN 1 ELSE 0 END)`,
pendingScripts: sql<number>`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<string>`DATE(${scriptAnalytics.createdAt})`,
views: sql<number>`SUM(CASE WHEN ${scriptAnalytics.eventType} = 'view' THEN 1 ELSE 0 END)`,
downloads: sql<number>`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);
}
}

217
src/lib/api/auth.ts Normal file
View File

@ -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<string> {
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<boolean> {
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<AuthToken> {
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<AuthToken> {
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<AuthToken> {
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<boolean> {
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);
}
}

261
src/lib/api/collections.ts Normal file
View File

@ -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);
}
}

23
src/lib/api/index.ts Normal file
View File

@ -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';

45
src/lib/api/mock.ts Normal file
View File

@ -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(),
}),
};

184
src/lib/api/ratings.ts Normal file
View File

@ -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);
}
}

361
src/lib/api/scripts.ts Normal file
View File

@ -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);
}
}

168
src/lib/api/users.ts Normal file
View File

@ -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);
}
}

View File

@ -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, {

View File

@ -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() {
</div>
);
case 'scripts':
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={() => setCurrentView('dashboard')}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
</div>
<div className="text-center py-12">
<FileText className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-2xl font-bold mb-2">Script Review</h2>
<p className="text-muted-foreground">
Script review functionality coming soon!
</p>
</div>
</div>
);
return <ScriptReviewDashboard onBack={() => setCurrentView('dashboard')} />;
case 'analytics':
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={() => setCurrentView('dashboard')}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Button>
</div>
<div className="text-center py-12">
<BarChart3 className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-2xl font-bold mb-2">Analytics Dashboard</h2>
<p className="text-muted-foreground">
Analytics functionality coming soon!
</p>
</div>
</div>
);
return <AnalyticsDashboard onBack={() => setCurrentView('dashboard')} />;
default:
return null;
}

342
src/pages/Collections.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
<Header onSearch={setSearchQuery} />
<main className="flex-1 container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Script Collections</h1>
<p className="text-muted-foreground">
Discover and manage curated collections of scripts
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
{user && (
<Button onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Collection
</Button>
)}
</div>
</div>
{/* Search */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search collections..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Create Collection Form */}
{showCreateForm && user && (
<Card>
<CardHeader>
<CardTitle>Create New Collection</CardTitle>
<CardDescription>
Create a collection to organize and share your favorite scripts
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateCollection} className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">Collection Name</label>
<Input
value={newCollection.name}
onChange={(e) => setNewCollection(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter collection name..."
required
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Description (Optional)</label>
<Textarea
value={newCollection.description}
onChange={(e) => setNewCollection(prev => ({ ...prev, description: e.target.value }))}
placeholder="Describe your collection..."
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isPublic"
checked={newCollection.isPublic}
onChange={(e) => setNewCollection(prev => ({ ...prev, isPublic: e.target.checked }))}
/>
<label htmlFor="isPublic" className="text-sm">
Make this collection public
</label>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={createCollection.isPending}>
{createCollection.isPending ? 'Creating...' : 'Create Collection'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowCreateForm(false)}
>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* User Collections */}
{user && userCollections && userCollections.length > 0 && (
<div className="space-y-4">
<h2 className="text-xl font-semibold">My Collections</h2>
<div className={viewMode === 'grid'
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
: "space-y-4"
}>
{userCollections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
viewMode={viewMode}
isOwner={true}
/>
))}
</div>
</div>
)}
{/* Public Collections */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Public Collections</h2>
{publicLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-2">Loading collections...</p>
</div>
) : filteredPublicCollections.length === 0 ? (
<Card>
<CardContent className="text-center py-8">
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No collections found</h3>
<p className="text-muted-foreground">
{searchQuery ? 'No collections match your search.' : 'No public collections available yet.'}
</p>
</CardContent>
</Card>
) : (
<div className={viewMode === 'grid'
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
: "space-y-4"
}>
{filteredPublicCollections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
viewMode={viewMode}
isOwner={collection.authorId === user?.id}
/>
))}
</div>
)}
</div>
</div>
</main>
<Footer />
</div>
);
}
interface CollectionCardProps {
collection: any;
viewMode: 'grid' | 'list';
isOwner: boolean;
}
function CollectionCard({ collection, viewMode, isOwner }: CollectionCardProps) {
if (viewMode === 'list') {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Link to={`/collections/${collection.id}`}>
<h3 className="text-lg font-semibold hover:text-primary transition-colors">
{collection.name}
</h3>
</Link>
{!collection.isPublic && <Lock className="h-4 w-4 text-muted-foreground" />}
{isOwner && <Badge variant="secondary">Owner</Badge>}
</div>
{collection.description && (
<p className="text-muted-foreground mb-3">{collection.description}</p>
)}
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{collection.scripts?.length || 0} scripts
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(collection.createdAt).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<Avatar className="h-8 w-8">
<AvatarImage src={collection.author?.avatarUrl} />
<AvatarFallback>{collection.author?.displayName?.[0]}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{collection.author?.displayName}</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-lg">
<Link to={`/collections/${collection.id}`} className="hover:text-primary transition-colors">
{collection.name}
</Link>
</CardTitle>
{!collection.isPublic && <Lock className="h-4 w-4 text-muted-foreground" />}
</div>
{isOwner && <Badge variant="secondary" className="w-fit">Owner</Badge>}
</div>
</div>
{collection.description && (
<CardDescription className="line-clamp-2">
{collection.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{collection.scripts?.length || 0} scripts
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{new Date(collection.createdAt).toLocaleDateString()}
</div>
</div>
<Separator />
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={collection.author?.avatarUrl} />
<AvatarFallback>{collection.author?.displayName?.[0]}</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{collection.author?.displayName}</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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() {
<span className="mx-2">/</span>
<Link to="/search" className="hover:text-foreground">Scripts</Link>
<span className="mx-2">/</span>
<span className="text-foreground">{script.name}</span>
<span className="text-foreground">{displayScript.name}</span>
</nav>
{/* Script Header */}
@ -286,18 +256,18 @@ export default function ScriptDetail() {
<div className="flex items-center gap-3">
<Code2 className="h-8 w-8 text-primary" />
<div>
<CardTitle className="text-3xl">{script.name}</CardTitle>
<CardTitle className="text-3xl">{displayScript.name}</CardTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>v{script.version}</span>
<span>v{displayScript.version}</span>
<span></span>
<span>{script.license} License</span>
<span>{displayScript.license || 'MIT'} License</span>
<span></span>
<span>{script.size}</span>
<span>{displayScript.size || 'N/A'}</span>
</div>
</div>
</div>
<CardDescription className="text-lg max-w-3xl">
{script.description}
{displayScript.description}
</CardDescription>
</div>
@ -319,30 +289,30 @@ export default function ScriptDetail() {
<div className="flex items-center gap-6 text-sm">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<span>{script.viewCount.toLocaleString()} views</span>
<span>{displayScript.viewCount.toLocaleString()} views</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-muted-foreground" />
<span>{script.downloadCount.toLocaleString()} downloads</span>
<span>{displayScript.downloadCount.toLocaleString()} downloads</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-400 fill-current" />
<span>{script.rating} ({script.ratingCount} ratings)</span>
<span>{displayScript.rating} ({displayScript.ratingCount} ratings)</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>Updated {formatRelativeTime(script.lastUpdated)}</span>
<span>Updated {formatDate(displayScript.updatedAt || displayScript.lastUpdated)}</span>
</div>
</div>
{/* Tags and Categories */}
<div className="flex flex-wrap gap-2">
{script.categories.map(category => (
{displayScript.categories.map(category => (
<Badge key={category} variant="secondary">
{category}
</Badge>
))}
{script.tags.map(tag => (
{displayScript.tags?.map(tag => (
<Badge key={tag} variant="outline">
<Tag className="h-3 w-3 mr-1" />
{tag}
@ -363,7 +333,7 @@ export default function ScriptDetail() {
<CardContent className="space-y-4">
<div className="prose prose-sm max-w-none">
<p className="whitespace-pre-line">
{showFullDescription ? script.longDescription : script.longDescription.slice(0, 300) + '...'}
{showFullDescription ? displayScript.longDescription : displayScript.longDescription.slice(0, 300) + '...'}
</p>
</div>
<Button
@ -387,18 +357,32 @@ export default function ScriptDetail() {
</div>
</CardHeader>
<CardContent>
<div className="bg-muted rounded-lg p-4 font-mono text-sm overflow-x-auto">
<pre className="whitespace-pre-wrap">
{showFullCode ? script.code : script.code.slice(0, 500) + '...'}
</pre>
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '14px',
lineHeight: '1.5',
}}
showLineNumbers={true}
wrapLongLines={true}
>
{showFullCode
? (displayScript.content || displayScript.code || '')
: (displayScript.content || displayScript.code || '').slice(0, 1000) + (((displayScript.content || displayScript.code || '').length > 1000) ? '\n...' : '')
}
</SyntaxHighlighter>
</div>
{!showFullCode && (
{!showFullCode && (displayScript.content || displayScript.code || '').length > 1000 && (
<Button
variant="ghost"
className="mt-2"
onClick={() => setShowFullCode(true)}
>
Show full code
Show full code ({((displayScript.content || displayScript.code || '').length / 1000).toFixed(1)}k characters)
</Button>
)}
</CardContent>
@ -412,20 +396,44 @@ export default function ScriptDetail() {
<CardContent className="space-y-6">
<div>
<h4 className="font-semibold mb-2">Requirements</h4>
<p className="text-sm text-muted-foreground">{script.requirements}</p>
<p className="text-sm text-muted-foreground">{displayScript.requirements}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Installation</h4>
<div className="bg-muted rounded-lg p-3 font-mono text-sm">
<pre className="whitespace-pre-line">{script.installation}</pre>
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '13px',
lineHeight: '1.4',
}}
wrapLongLines={true}
>
{displayScript.installation || ''}
</SyntaxHighlighter>
</div>
</div>
<div>
<h4 className="font-semibold mb-2">Usage</h4>
<div className="bg-muted rounded-lg p-3 font-mono text-sm">
<pre className="whitespace-pre-line">{script.usage}</pre>
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '13px',
lineHeight: '1.4',
}}
wrapLongLines={true}
>
{displayScript.usage || ''}
</SyntaxHighlighter>
</div>
</div>
</CardContent>
@ -438,7 +446,7 @@ export default function ScriptDetail() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{script.changelog.map((change, index) => (
{displayScript.changelog.map((change, index) => (
<div key={index} className="border-l-2 border-primary/20 pl-4">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">v{change.version}</Badge>
@ -460,116 +468,7 @@ export default function ScriptDetail() {
</CardContent>
</Card>
{/* Comments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Comments ({script.comments.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Add Comment */}
{user && (
<div className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback>{user.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<form onSubmit={handleCommentSubmit}>
<Input
placeholder="Add a comment..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
disabled={isSubmittingComment}
/>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Rate this script:</span>
{renderStars(userRating, true, handleRating)}
</div>
<Button type="submit" size="sm" disabled={isSubmittingComment || !newComment.trim()}>
{isSubmittingComment ? 'Posting...' : 'Post Comment'}
</Button>
</div>
</form>
</div>
</div>
</div>
)}
<Separator />
{/* Comments List */}
<div className="space-y-4">
{script.comments.map((comment) => (
<div key={comment.id} className="space-y-3">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={comment.author.avatarUrl} />
<AvatarFallback>{comment.author.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="font-medium">{comment.author.displayName}</span>
<span className="text-sm text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
{comment.rating > 0 && (
<div className="flex items-center gap-1">
{renderStars(comment.rating)}
</div>
)}
</div>
<p className="text-sm">{comment.content}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<button className="flex items-center gap-1 hover:text-foreground">
<ThumbsUp className="h-3 w-3" />
{comment.likes}
</button>
<button className="flex items-center gap-1 hover:text-foreground">
<MessageCircle className="h-3 w-3" />
Reply
</button>
</div>
</div>
</div>
{/* Replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="ml-11 space-y-3">
{comment.replies.map((reply) => (
<div key={reply.id} className="flex items-start gap-3">
<Avatar className="h-6 w-6">
<AvatarImage src={reply.author.avatarUrl} />
<AvatarFallback>{reply.author.displayName[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{reply.author.displayName}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(reply.createdAt)}
</span>
</div>
<p className="text-sm">{reply.content}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<button className="flex items-center gap-1 hover:text-foreground">
<ThumbsUp className="h-3 w-3" />
{reply.likes}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
@ -582,15 +481,15 @@ export default function ScriptDetail() {
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12">
<AvatarImage src={script.author.avatarUrl} />
<AvatarFallback>{script.author.displayName[0]}</AvatarFallback>
<AvatarImage src={displayScript.author.avatarUrl} />
<AvatarFallback>{displayScript.author.displayName[0]}</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{script.author.displayName}</div>
<div className="text-sm text-muted-foreground">@{script.author.username}</div>
<div className="font-medium">{displayScript.author.displayName}</div>
<div className="text-sm text-muted-foreground">@{displayScript.author.username}</div>
</div>
</div>
{script.author.isVerified && (
{displayScript.author.isVerified && (
<Badge variant="outline" className="w-fit">
<User className="h-3 w-3 mr-1" />
Verified Author
@ -607,23 +506,23 @@ export default function ScriptDetail() {
<CardContent className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Version</span>
<span className="font-medium">{script.version}</span>
<span className="font-medium">{displayScript.version}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">License</span>
<span className="font-medium">{script.license}</span>
<span className="font-medium">{displayScript.license}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Size</span>
<span className="font-medium">{script.size}</span>
<span className="font-medium">{displayScript.size}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span className="font-medium">{formatDate(script.createdAt)}</span>
<span className="font-medium">{formatDate(displayScript.createdAt)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Updated</span>
<span className="font-medium">{formatDate(script.lastUpdated)}</span>
<span className="font-medium">{formatDate(displayScript.lastUpdated)}</span>
</div>
</CardContent>
</Card>
@ -635,7 +534,7 @@ export default function ScriptDetail() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{script.compatibleOs.map(os => (
{displayScript.compatibleOs.map(os => (
<Badge key={os} variant="outline" className="w-full justify-center">
{os}
</Badge>
@ -651,7 +550,7 @@ export default function ScriptDetail() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{script.dependencies.map(dep => (
{displayScript.dependencies.map(dep => (
<Badge key={dep} variant="secondary" className="w-full justify-center">
{dep}
</Badge>

View File

@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Search as SearchIcon, Filter, X, Code2 } from 'lucide-react';
import { ScriptFilters } from '@/components/ScriptFilters';
import { ScriptGrid } from '@/components/ScriptGrid';
import { useScripts } from '@/hooks/useScripts';
// Mock search results - in a real app, this would come from an API
const mockSearchResults = [
@ -56,8 +57,6 @@ const mockSearchResults = [
export default function Search() {
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || '');
const [searchResults, setSearchResults] = useState(mockSearchResults);
const [isLoading, setIsLoading] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState({
os: [] as string[],
@ -66,42 +65,24 @@ export default function Search() {
recentlyUpdated: 'all',
});
// API call with real filters
const { data: scriptsData, isLoading } = useScripts({
search: searchQuery || undefined,
compatibleOs: filters.os.length > 0 ? filters.os : undefined,
categories: filters.categories.length > 0 ? filters.categories : undefined,
sortBy: 'newest',
limit: 50,
isApproved: true, // Only show approved scripts in search
});
const searchResults = scriptsData?.scripts || [];
useEffect(() => {
// Update search query when URL params change
const query = searchParams.get('q') || '';
setSearchQuery(query);
if (query) {
performSearch(query);
}
}, [searchParams]);
const performSearch = async (query: string) => {
setIsLoading(true);
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 1000));
// Filter results based on query and filters
let filtered = mockSearchResults.filter(script => {
const matchesQuery = query === '' ||
script.name.toLowerCase().includes(query.toLowerCase()) ||
script.description.toLowerCase().includes(query.toLowerCase());
const matchesOs = filters.os.length === 0 ||
filters.os.some(os => script.compatible_os.includes(os));
const matchesCategories = filters.categories.length === 0 ||
filters.categories.some(cat => script.categories.includes(cat));
return matchesQuery && matchesOs && matchesCategories;
});
setSearchResults(filtered);
setIsLoading(false);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
@ -111,9 +92,6 @@ export default function Search() {
const handleFilterChange = (newFilters: typeof filters) => {
setFilters(newFilters);
if (searchQuery) {
performSearch(searchQuery);
}
};
const clearFilters = () => {
@ -123,9 +101,6 @@ export default function Search() {
dateAdded: 'all',
recentlyUpdated: 'all',
});
if (searchQuery) {
performSearch(searchQuery);
}
};
const hasActiveFilters = filters.os.length > 0 ||

View File

@ -12,6 +12,9 @@ import { Separator } from '@/components/ui/separator';
import { useAuth } from '@/contexts/AuthContext';
import { showSuccess, showError } from '@/utils/toast';
import { generateId } from '@/lib/utils';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useTheme } from '@/contexts/ThemeContext';
import {
Code2,
FileText,
@ -26,6 +29,7 @@ import {
export default function SubmitScript() {
const { user } = useAuth();
const navigate = useNavigate();
const { theme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [formData, setFormData] = useState({
@ -48,6 +52,10 @@ export default function SubmitScript() {
const operatingSystems = ['Linux', 'macOS', 'Windows', 'BSD', 'Android'];
const availableCategories = ['DevOps', 'Automation', 'System Admin', 'Development', 'Security', 'Networking', 'Data Processing', 'Web Development', 'Mobile Development', 'Game Development'];
// State for preview toggles
const [showInstallationPreview, setShowInstallationPreview] = useState(false);
const [showUsagePreview, setShowUsagePreview] = useState(false);
const licenses = ['MIT', 'Apache 2.0', 'GPL v3', 'GPL v2', 'BSD 3-Clause', 'BSD 2-Clause', 'Unlicense', 'Custom'];
const validateForm = () => {
@ -323,11 +331,24 @@ export default function SubmitScript() {
</div>
{showPreview && formData.code && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="font-medium mb-2">Code Preview:</h4>
<pre className="text-sm overflow-x-auto">
<code>{formData.code}</code>
</pre>
<div className="space-y-2">
<h4 className="font-medium">Code Preview:</h4>
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '14px',
lineHeight: '1.5',
}}
showLineNumbers={true}
wrapLongLines={true}
>
{formData.code}
</SyntaxHighlighter>
</div>
</div>
)}
</div>
@ -438,7 +459,18 @@ export default function SubmitScript() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="installation">Installation Instructions</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowInstallationPreview(!showInstallationPreview)}
>
<Eye className="h-4 w-4 mr-1" />
{showInstallationPreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
<Textarea
id="installation"
name="installation"
@ -446,11 +478,40 @@ export default function SubmitScript() {
onChange={handleInputChange}
placeholder="How to install or set up the script..."
rows={4}
className="font-mono text-sm"
/>
{showInstallationPreview && formData.installation && (
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '13px',
lineHeight: '1.4',
}}
wrapLongLines={true}
>
{formData.installation}
</SyntaxHighlighter>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="usage">Usage Instructions</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowUsagePreview(!showUsagePreview)}
>
<Eye className="h-4 w-4 mr-1" />
{showUsagePreview ? 'Hide' : 'Show'} Preview
</Button>
</div>
<Textarea
id="usage"
name="usage"
@ -458,7 +519,25 @@ export default function SubmitScript() {
onChange={handleInputChange}
placeholder="How to use the script, examples, parameters..."
rows={4}
className="font-mono text-sm"
/>
{showUsagePreview && formData.usage && (
<div className="rounded-lg overflow-hidden border">
<SyntaxHighlighter
language="bash"
style={theme === 'dark' ? vscDarkPlus : vs}
customStyle={{
margin: 0,
borderRadius: 0,
fontSize: '13px',
lineHeight: '1.4',
}}
wrapLongLines={true}
>
{formData.usage}
</SyntaxHighlighter>
</div>
)}
</div>
</div>