Update README.md to provide a comprehensive overview of the ScriptShare platform, including features, tech stack, setup instructions, admin capabilities, and contribution guidelines.
This commit is contained in:
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
@ -0,0 +1,91 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database
|
||||
drizzle/
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Build outputs
|
||||
build/
|
||||
out/
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
128
README.md
128
README.md
@ -1,3 +1,127 @@
|
||||
# scriptshare-cursor
|
||||
# ScriptShare - Bash Script Library
|
||||
|
||||
Cursor Scriptshare Development.
|
||||
A modern, community-driven platform for discovering, sharing, and collaborating on bash scripts for system administration, automation, and development.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **Browse & Search**: Find scripts by category, OS compatibility, and other filters
|
||||
- 📝 **Submit Scripts**: Share your scripts with the community
|
||||
- 🔍 **Advanced Filtering**: Filter by OS, categories, date added, and more
|
||||
- 👥 **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
|
||||
- 🎨 **Modern UI**: Beautiful, responsive design with dark/light themes
|
||||
- 🔐 **Authentication**: Secure user accounts and profiles
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React 18 + TypeScript + Vite
|
||||
- **Styling**: Tailwind CSS + shadcn/ui components
|
||||
- **Database**: MySQL (Digital Ocean Managed Database)
|
||||
- **ORM**: Drizzle ORM
|
||||
- **State Management**: React Query + React Context
|
||||
- **Routing**: React Router DOM
|
||||
- **Forms**: React Hook Form + Zod validation
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Set up environment variables**:
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
# Edit .env.local with your database credentials
|
||||
```
|
||||
|
||||
3. **Set up the database**:
|
||||
```bash
|
||||
npm run db:generate
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
4. **Start development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. **Open your browser**:
|
||||
Navigate to `http://localhost:5173`
|
||||
|
||||
## Database Setup
|
||||
|
||||
This app uses a MySQL database hosted on Digital Ocean. You'll need to:
|
||||
|
||||
1. Create a managed MySQL database on Digital Ocean
|
||||
2. Get your connection credentials
|
||||
3. Update the `.env.local` file with your database details
|
||||
|
||||
## Superuser Admin Setup
|
||||
|
||||
ScriptShare includes a comprehensive admin system with superuser capabilities:
|
||||
|
||||
### Creating Your First Superuser
|
||||
|
||||
You have two options for creating your first superuser:
|
||||
|
||||
#### Option 1: Quick Setup (Recommended for Development)
|
||||
```bash
|
||||
npm run create-default-superuser
|
||||
```
|
||||
|
||||
This creates a default superuser with these credentials:
|
||||
- **Email**: admin@scriptshare.com
|
||||
- **Password**: admin123
|
||||
- **Username**: admin
|
||||
- **Display Name**: System Administrator
|
||||
|
||||
⚠️ **Important**: Change the password immediately after first login!
|
||||
|
||||
#### Option 2: Interactive Setup
|
||||
```bash
|
||||
npm run create-superuser
|
||||
```
|
||||
|
||||
This prompts you to enter custom details for your admin account.
|
||||
|
||||
3. **Both scripts will output** your superuser credentials and save them to JSON files
|
||||
|
||||
### Admin Panel Features
|
||||
|
||||
- **Dashboard**: Overview of platform statistics and recent activity
|
||||
- **User Management**: Create and manage admin users and moderators
|
||||
- **Content Moderation**: Review and approve submitted scripts
|
||||
- **System Monitoring**: Track platform health and performance
|
||||
- **Analytics**: View usage statistics and trends
|
||||
|
||||
### Permission Levels
|
||||
|
||||
- **Super User**: Full system access including user management and system configuration
|
||||
- **Admin User**: Content moderation and user management capabilities
|
||||
- **Moderator**: Basic content review and approval permissions
|
||||
|
||||
### Accessing the Admin Panel
|
||||
|
||||
1. Log in with your superuser account
|
||||
2. Navigate to `/admin` or click "Super Admin" in the user menu
|
||||
3. Use the dashboard to manage the platform
|
||||
|
||||
**Note**: Only users with admin privileges can access the admin panel.
|
||||
|
||||
## Script Submission Process
|
||||
|
||||
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
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! Please see our contributing guidelines for more details.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
28
default-superuser-account.json
Normal file
28
default-superuser-account.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "92acee3490facf4eef9d2a5695397ca6",
|
||||
"email": "admin@scriptshare.com",
|
||||
"username": "admin",
|
||||
"displayName": "System Administrator",
|
||||
"avatarUrl": "",
|
||||
"bio": "Default system administrator account",
|
||||
"isAdmin": true,
|
||||
"isModerator": true,
|
||||
"isSuperUser": true,
|
||||
"permissions": [
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete",
|
||||
"user:promote",
|
||||
"script:approve",
|
||||
"script:reject",
|
||||
"script:delete",
|
||||
"comment:moderate",
|
||||
"system:configure",
|
||||
"analytics:view",
|
||||
"backup:manage"
|
||||
],
|
||||
"createdAt": "2025-08-12T21:28:52.080Z",
|
||||
"updatedAt": "2025-08-12T21:28:52.080Z",
|
||||
"passwordHash": "240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9"
|
||||
}
|
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/lib/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'mysql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
})
|
12
env.example
Normal file
12
env.example
Normal file
@ -0,0 +1,12 @@
|
||||
# Database Configuration (Digital Ocean Managed MySQL)
|
||||
DATABASE_URL="mysql://username:password@host:port/database_name"
|
||||
|
||||
# JWT Secret for authentication
|
||||
JWT_SECRET="your-super-secret-jwt-key-here"
|
||||
|
||||
# App Configuration
|
||||
VITE_APP_NAME="ScriptShare"
|
||||
VITE_APP_URL="http://localhost:5173"
|
||||
|
||||
# Optional: Analytics
|
||||
VITE_ANALYTICS_ENABLED="false"
|
35
eslint.config.js
Normal file
35
eslint.config.js
Normal file
@ -0,0 +1,35 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2020,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
30
index.html
Normal file
30
index.html
Normal file
@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ScriptShare - Bash Script Library</title>
|
||||
<meta name="description" content="A community-driven platform for discovering, sharing, and collaborating on powerful bash scripts for system administration, automation, and development." />
|
||||
<meta name="keywords" content="bash scripts, shell scripts, automation, system administration, devops, linux, unix" />
|
||||
<meta name="author" content="ScriptShare Community" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://scriptshare.com/" />
|
||||
<meta property="og:title" content="ScriptShare - Bash Script Library" />
|
||||
<meta property="og:description" content="A community-driven platform for discovering, sharing, and collaborating on powerful bash scripts." />
|
||||
<meta property="og:image" content="https://scriptshare.com/og-image.jpg" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://scriptshare.com/" />
|
||||
<meta property="twitter:title" content="ScriptShare - Bash Script Library" />
|
||||
<meta property="twitter:description" content="A community-driven platform for discovering, sharing, and collaborating on powerful bash scripts." />
|
||||
<meta property="twitter:image" content="https://scriptshare.com/og-image.jpg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
7918
package-lock.json
generated
Normal file
7918
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
92
package.json
Normal file
92
package.json
Normal file
@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "scriptshare",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:dev": "tsc && vite build --mode development",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"create-superuser": "node scripts/create-superuser.js",
|
||||
"create-default-superuser": "node scripts/create-default-superuser.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.0",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.1.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@radix-ui/react-context-menu": "^2.2.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-menubar": "^1.1.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slider": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"drizzle-orm": "^0.37.0",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"mysql2": "^3.12.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-resizable-panels": "^2.1.3",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"recharts": "^2.12.7",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.3",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.25.0",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.11",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^6.3.4"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
125
scripts/create-default-superuser.js
Normal file
125
scripts/create-default-superuser.js
Normal file
@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to create a default superuser account
|
||||
* This script creates a superuser without requiring interactive input
|
||||
*
|
||||
* Usage: node scripts/create-default-superuser.js
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
function generateId() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function createSuperUser(userData) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
displayName: userData.displayName,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
bio: userData.bio,
|
||||
isAdmin: true,
|
||||
isModerator: true,
|
||||
isSuperUser: true,
|
||||
permissions: [
|
||||
'user:create',
|
||||
'user:read',
|
||||
'user:update',
|
||||
'user:delete',
|
||||
'user:promote',
|
||||
'script:approve',
|
||||
'script:reject',
|
||||
'script:delete',
|
||||
'comment:moderate',
|
||||
'system:configure',
|
||||
'analytics:view',
|
||||
'backup:manage'
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// In a real app, this would be hashed
|
||||
passwordHash: crypto.createHash('sha256').update(userData.password).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('🚀 ScriptShare Default Super User Creation Tool');
|
||||
console.log('==============================================\n');
|
||||
|
||||
try {
|
||||
// Default superuser data
|
||||
const defaultSuperUser = createSuperUser({
|
||||
email: 'admin@scriptshare.com',
|
||||
username: 'admin',
|
||||
displayName: 'System Administrator',
|
||||
password: 'admin123',
|
||||
bio: 'Default system administrator account',
|
||||
avatarUrl: ''
|
||||
});
|
||||
|
||||
console.log('✅ Default Super User Created Successfully!');
|
||||
console.log('===========================================');
|
||||
console.log(`ID: ${defaultSuperUser.id}`);
|
||||
console.log(`Email: ${defaultSuperUser.email}`);
|
||||
console.log(`Username: @${defaultSuperUser.username}`);
|
||||
console.log(`Display Name: ${defaultSuperUser.displayName}`);
|
||||
console.log(`Password: admin123 (CHANGE THIS IN PRODUCTION!)`);
|
||||
console.log(`Super User: ${defaultSuperUser.isSuperUser ? 'Yes' : 'No'}`);
|
||||
console.log(`Admin: ${defaultSuperUser.isAdmin ? 'Yes' : 'No'}`);
|
||||
console.log(`Moderator: ${defaultSuperUser.isModerator ? 'Yes' : 'No'}`);
|
||||
console.log(`Permissions: ${defaultSuperUser.permissions.length} total`);
|
||||
console.log(`Created: ${defaultSuperUser.createdAt}`);
|
||||
|
||||
console.log('\n📋 Next Steps:');
|
||||
console.log('1. Save this user data to your database');
|
||||
console.log('2. Use these credentials to log into the admin panel:');
|
||||
console.log(' - Email: admin@scriptshare.com');
|
||||
console.log(' - Password: admin123');
|
||||
console.log('3. CHANGE THE PASSWORD IMMEDIATELY after first login');
|
||||
console.log('4. Create additional admin users through the web interface');
|
||||
|
||||
// Save to a JSON file for reference
|
||||
const outputPath = './default-superuser-account.json';
|
||||
fs.writeFileSync(outputPath, JSON.stringify(defaultSuperUser, null, 2));
|
||||
console.log(`\n💾 User data saved to: ${outputPath}`);
|
||||
|
||||
// Also save to localStorage format for the web app
|
||||
const webAppData = {
|
||||
id: defaultSuperUser.id,
|
||||
email: defaultSuperUser.email,
|
||||
username: defaultSuperUser.username,
|
||||
displayName: defaultSuperUser.displayName,
|
||||
avatarUrl: defaultSuperUser.avatarUrl,
|
||||
bio: defaultSuperUser.bio,
|
||||
isAdmin: defaultSuperUser.isAdmin,
|
||||
isModerator: defaultSuperUser.isModerator,
|
||||
isSuperUser: defaultSuperUser.isSuperUser,
|
||||
permissions: defaultSuperUser.permissions,
|
||||
createdAt: defaultSuperUser.createdAt,
|
||||
updatedAt: defaultSuperUser.updatedAt
|
||||
};
|
||||
|
||||
const webAppPath = './webapp-superuser.json';
|
||||
fs.writeFileSync(webAppPath, JSON.stringify(webAppData, null, 2));
|
||||
console.log(`💾 Web app user data saved to: ${webAppPath}`);
|
||||
|
||||
console.log('\n⚠️ IMPORTANT SECURITY NOTES:');
|
||||
console.log('- This is a default account for development only');
|
||||
console.log('- Change the password immediately after first login');
|
||||
console.log('- Delete this script in production environments');
|
||||
console.log('- Use strong, unique passwords in production');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating super user:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
main();
|
126
scripts/create-superuser.js
Normal file
126
scripts/create-superuser.js
Normal file
@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to create the first superuser account
|
||||
* Run this script to set up the initial admin user for ScriptShare
|
||||
*
|
||||
* Usage: node scripts/create-superuser.js
|
||||
*/
|
||||
|
||||
import readline from 'readline';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(prompt) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
function createSuperUser(userData) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
displayName: userData.displayName,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
bio: userData.bio,
|
||||
isAdmin: true,
|
||||
isModerator: true,
|
||||
isSuperUser: true,
|
||||
permissions: [
|
||||
'user:create',
|
||||
'user:read',
|
||||
'user:update',
|
||||
'user:delete',
|
||||
'user:promote',
|
||||
'script:approve',
|
||||
'script:reject',
|
||||
'script:delete',
|
||||
'comment:moderate',
|
||||
'system:configure',
|
||||
'analytics:view',
|
||||
'backup:manage'
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
// In a real app, this would be hashed
|
||||
passwordHash: crypto.createHash('sha256').update(userData.password).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 ScriptShare Super User Creation Tool');
|
||||
console.log('=====================================\n');
|
||||
|
||||
try {
|
||||
const email = await question('Email address: ');
|
||||
const username = await question('Username: ');
|
||||
const displayName = await question('Display Name: ');
|
||||
const password = await question('Password: ');
|
||||
const bio = await question('Bio (optional): ') || '';
|
||||
const avatarUrl = await question('Avatar URL (optional): ') || '';
|
||||
|
||||
if (!email || !username || !displayName || !password) {
|
||||
console.error('❌ All required fields must be provided');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const superUser = createSuperUser({
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
password,
|
||||
bio,
|
||||
avatarUrl
|
||||
});
|
||||
|
||||
console.log('\n✅ Super User Created Successfully!');
|
||||
console.log('=====================================');
|
||||
console.log(`ID: ${superUser.id}`);
|
||||
console.log(`Email: ${superUser.email}`);
|
||||
console.log(`Username: @${superUser.username}`);
|
||||
console.log(`Display Name: ${superUser.displayName}`);
|
||||
console.log(`Super User: ${superUser.isSuperUser ? 'Yes' : 'No'}`);
|
||||
console.log(`Admin: ${superUser.isAdmin ? 'Yes' : 'No'}`);
|
||||
console.log(`Moderator: ${superUser.isModerator ? 'Yes' : 'No'}`);
|
||||
console.log(`Permissions: ${superUser.permissions.length} total`);
|
||||
console.log(`Created: ${superUser.createdAt}`);
|
||||
|
||||
console.log('\n📋 Next Steps:');
|
||||
console.log('1. Save this user data to your database');
|
||||
console.log('2. Use these credentials to log into the admin panel');
|
||||
console.log('3. Create additional admin users through the web interface');
|
||||
|
||||
// Save to a JSON file for reference
|
||||
const outputPath = './superuser-account.json';
|
||||
fs.writeFileSync(outputPath, JSON.stringify(superUser, null, 2));
|
||||
console.log(`\n💾 User data saved to: ${outputPath}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating super user:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ctrl+C gracefully
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n❌ Operation cancelled by user');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Run the main function
|
||||
main();
|
66
src/App.tsx
Normal file
66
src/App.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import Index from "@/pages/Index";
|
||||
import ScriptDetail from "@/pages/ScriptDetail";
|
||||
import Search from "@/pages/Search";
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import About from "@/pages/About";
|
||||
import Privacy from "@/pages/Privacy";
|
||||
import Terms from "@/pages/Terms";
|
||||
import Login from "@/pages/Login";
|
||||
import SubmitScript from "@/pages/SubmitScript";
|
||||
import MyScripts from "@/pages/MyScripts";
|
||||
import AdminPanel from "@/pages/AdminPanel";
|
||||
import AdminScriptReview from "@/pages/AdminScriptReview";
|
||||
import Profile from "@/pages/Profile";
|
||||
import EditScript from "@/pages/EditScript";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="scriptshare-theme">
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/script/:id" element={<ScriptDetail />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/submit" element={<SubmitScript />} />
|
||||
<Route path="/my-scripts" element={<MyScripts />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/edit-script/:id" element={<EditScript />} />
|
||||
<Route path="/admin" element={<AdminPanel />} />
|
||||
<Route path="/admin/script/:id" element={<AdminScriptReview />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default App;
|
162
src/components/Footer.tsx
Normal file
162
src/components/Footer.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Code2, Github, Twitter, Mail, Heart } from 'lucide-react';
|
||||
|
||||
export function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Brand Section */}
|
||||
<div className="space-y-4">
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<Code2 className="h-8 w-8 text-primary" />
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
ScriptShare
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
A community-driven platform for discovering, sharing, and collaborating on powerful bash scripts.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Github className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Twitter className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:contact@scriptshare.com"
|
||||
className="text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Links */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Platform</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/search" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Browse Scripts
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/submit" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Submit Script
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Community Links */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Community</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/scriptshare/contributing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contributing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/scriptshare/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Report Issues
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support Links */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold">Support</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.scriptshare.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://discord.gg/scriptshare"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Discord Community
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:support@scriptshare.com"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact Support
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="border-t mt-8 pt-8 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
© {currentYear} ScriptShare. All rights reserved.
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center space-x-1">
|
||||
<span>Made with</span>
|
||||
<Heart className="h-4 w-4 text-red-500 fill-current" />
|
||||
<span>by the community</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
211
src/components/Header.tsx
Normal file
211
src/components/Header.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Search, Menu, X, Code2 } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
}
|
||||
|
||||
export function Header({ onSearch }: HeaderProps) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
onSearch(searchQuery.trim());
|
||||
navigate('/search');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center space-x-2">
|
||||
<Code2 className="h-8 w-8 text-primary" />
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
ScriptShare
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-6">
|
||||
<Link
|
||||
to="/"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/search') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
<Link
|
||||
to="/submit"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/submit') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
Submit
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/about') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="hidden md:flex flex-1 max-w-md mx-8">
|
||||
<form onSubmit={handleSearch} className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search scripts..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchInput}
|
||||
className="pl-10 pr-4 w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
|
||||
{user ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<div className="hidden md:flex items-center space-x-2">
|
||||
<Button variant="ghost" onClick={() => navigate('/login')}>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/login')}>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
onClick={toggleMobileMenu}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-5 w-5" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Search Bar */}
|
||||
<div className="md:hidden pb-4">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search scripts..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchInput}
|
||||
className="pl-10 pr-4 w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-t py-4">
|
||||
<nav className="flex flex-col space-y-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/search') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
Browse
|
||||
</Link>
|
||||
<Link
|
||||
to="/submit"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/submit') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
Submit
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${
|
||||
isActive('/about') ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={closeMobileMenu}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
|
||||
{!user && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button variant="ghost" className="w-full justify-start" onClick={() => { navigate('/login'); closeMobileMenu(); }}>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button className="w-full justify-start mt-2" onClick={() => { navigate('/login'); closeMobileMenu(); }}>
|
||||
Get Started
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
148
src/components/ScriptCard.tsx
Normal file
148
src/components/ScriptCard.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Eye, Calendar, User, ExternalLink, Star, TrendingUp } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatDate, formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export interface Script {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
compatible_os: string[];
|
||||
categories: string[];
|
||||
git_repository_url?: string;
|
||||
author_name: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_approved: boolean;
|
||||
}
|
||||
|
||||
interface ScriptCardProps {
|
||||
script: Script;
|
||||
}
|
||||
|
||||
export function ScriptCard({ script }: ScriptCardProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isPopular = script.view_count > 500;
|
||||
const isTrending = script.view_count > 200;
|
||||
|
||||
return (
|
||||
<Card className="group h-full hover:shadow-2xl hover:shadow-primary/20 transition-all duration-500 cursor-pointer border-muted/50 hover:border-primary/30 bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm hover:scale-[1.02] relative overflow-hidden">
|
||||
{/* Animated background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
||||
|
||||
{/* Popular/Trending indicators */}
|
||||
{isPopular && (
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<Badge variant="secondary" className="bg-gradient-to-r from-yellow-500/20 to-orange-500/20 text-yellow-600 border-yellow-500/30 animate-pulse">
|
||||
<Star className="h-3 w-3 mr-1 fill-current" />
|
||||
Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{isTrending && !isPopular && (
|
||||
<div className="absolute top-3 right-3 z-10">
|
||||
<Badge variant="secondary" className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 text-green-600 border-green-500/30">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Trending
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader onClick={() => navigate(`/script/${script.id}`)} className="pb-3 relative z-10">
|
||||
<CardTitle className="line-clamp-2 group-hover:text-transparent group-hover:bg-gradient-to-r group-hover:from-primary group-hover:to-accent group-hover:bg-clip-text transition-all duration-300 text-lg font-bold">
|
||||
{script.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="line-clamp-3 text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
|
||||
{script.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 relative z-10">
|
||||
{/* OS Badges with enhanced styling */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{script.compatible_os.map(os => (
|
||||
<Badge
|
||||
key={os}
|
||||
variant="outline"
|
||||
className="text-xs border-primary/40 text-primary/90 hover:bg-primary/15 transition-all duration-200 rounded-full px-2 py-1 font-medium shadow-sm"
|
||||
>
|
||||
{os}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories with gradient backgrounds */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{script.categories.slice(0, 2).map((category, index) => (
|
||||
<Badge
|
||||
key={category}
|
||||
variant="secondary"
|
||||
className={`text-xs rounded-full px-3 py-1 font-medium shadow-sm transition-all duration-200 hover:scale-105 ${
|
||||
index === 0
|
||||
? 'bg-gradient-to-r from-accent/20 to-accent/30 text-accent border-accent/30'
|
||||
: 'bg-gradient-to-r from-primary/20 to-primary/30 text-primary border-primary/30'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
{script.categories.length > 2 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs bg-gradient-to-r from-muted to-muted/80 text-muted-foreground border-muted rounded-full px-2 py-1"
|
||||
>
|
||||
+{script.categories.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Script metadata */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="h-3 w-3" />
|
||||
<span>{script.author_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{script.view_count.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span title={formatDate(script.updated_at)}>
|
||||
{formatRelativeTime(script.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex space-x-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
onClick={() => navigate(`/script/${script.id}`)}
|
||||
>
|
||||
View Script
|
||||
</Button>
|
||||
{script.git_repository_url && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(script.git_repository_url, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
242
src/components/ScriptFilters.tsx
Normal file
242
src/components/ScriptFilters.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Filter, ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface FilterOptions {
|
||||
os: string[];
|
||||
categories: string[];
|
||||
dateAdded: string;
|
||||
recentlyUpdated: string;
|
||||
}
|
||||
|
||||
interface ScriptFiltersProps {
|
||||
onFiltersChange: (filters: FilterOptions) => void;
|
||||
}
|
||||
|
||||
const availableOS = ['Linux', 'Ubuntu', 'CentOS', 'Debian', 'macOS', 'Windows', 'FreeBSD'];
|
||||
const availableCategories = [
|
||||
'System Administration',
|
||||
'Backup',
|
||||
'DevOps',
|
||||
'Docker',
|
||||
'Development',
|
||||
'Git',
|
||||
'Networking',
|
||||
'Monitoring',
|
||||
'Security',
|
||||
'Automation',
|
||||
'Database',
|
||||
'Web Development'
|
||||
];
|
||||
|
||||
export function ScriptFilters({ onFiltersChange }: ScriptFiltersProps) {
|
||||
const [filters, setFilters] = useState<FilterOptions>({
|
||||
os: [],
|
||||
categories: [],
|
||||
dateAdded: 'all',
|
||||
recentlyUpdated: 'all',
|
||||
});
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleFilterChange = (newFilters: Partial<FilterOptions>) => {
|
||||
const updatedFilters = { ...filters, ...newFilters };
|
||||
setFilters(updatedFilters);
|
||||
onFiltersChange(updatedFilters);
|
||||
};
|
||||
|
||||
const toggleOS = (os: string) => {
|
||||
const newOS = filters.os.includes(os)
|
||||
? filters.os.filter(o => o !== os)
|
||||
: [...filters.os, os];
|
||||
handleFilterChange({ os: newOS });
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
const newCategories = filters.categories.includes(category)
|
||||
? filters.categories.filter(c => c !== category)
|
||||
: [...filters.categories, category];
|
||||
handleFilterChange({ categories: newCategories });
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
const clearedFilters: FilterOptions = {
|
||||
os: [],
|
||||
categories: [],
|
||||
dateAdded: 'all',
|
||||
recentlyUpdated: 'all',
|
||||
};
|
||||
setFilters(clearedFilters);
|
||||
onFiltersChange(clearedFilters);
|
||||
};
|
||||
|
||||
const hasActiveFilters = filters.os.length > 0 ||
|
||||
filters.categories.length > 0 ||
|
||||
filters.dateAdded !== 'all' ||
|
||||
filters.recentlyUpdated !== 'all';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">Filters</h3>
|
||||
{hasActiveFilters && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{filters.os.length + filters.categories.length +
|
||||
(filters.dateAdded !== 'all' ? 1 : 0) +
|
||||
(filters.recentlyUpdated !== 'all' ? 1 : 0)} active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 mr-1 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
{isExpanded ? 'Hide' : 'Show'} Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.os.map(os => (
|
||||
<Badge
|
||||
key={os}
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive/20 hover:text-destructive"
|
||||
onClick={() => toggleOS(os)}
|
||||
>
|
||||
OS: {os}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
))}
|
||||
{filters.categories.map(category => (
|
||||
<Badge
|
||||
key={category}
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive/20 hover:text-destructive"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{category}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
))}
|
||||
{filters.dateAdded !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive/20 hover:text-destructive"
|
||||
onClick={() => handleFilterChange({ dateAdded: 'all' })}
|
||||
>
|
||||
Added: {filters.dateAdded}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
{filters.recentlyUpdated !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive/20 hover:text-destructive"
|
||||
onClick={() => handleFilterChange({ recentlyUpdated: 'all' })}
|
||||
>
|
||||
Updated: {filters.recentlyUpdated}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable Filter Options */}
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 p-4 border rounded-lg bg-muted/30">
|
||||
{/* Operating Systems */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Operating Systems</h4>
|
||||
<div className="space-y-2">
|
||||
{availableOS.map(os => (
|
||||
<label key={os} className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.os.includes(os)}
|
||||
onChange={() => toggleOS(os)}
|
||||
className="rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm">{os}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Categories</h4>
|
||||
<div className="space-y-2">
|
||||
{availableCategories.map(category => (
|
||||
<label key={category} className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.categories.includes(category)}
|
||||
onChange={() => toggleCategory(category)}
|
||||
className="rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm">{category}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Added */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Date Added</h4>
|
||||
<select
|
||||
value={filters.dateAdded}
|
||||
onChange={(e) => handleFilterChange({ dateAdded: e.target.value })}
|
||||
className="w-full p-2 text-sm border rounded-md bg-background"
|
||||
>
|
||||
<option value="all">All Time</option>
|
||||
<option value="1d">Last 24 hours</option>
|
||||
<option value="1w">Last week</option>
|
||||
<option value="1m">Last month</option>
|
||||
<option value="1y">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recently Updated */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Recently Updated</h4>
|
||||
<select
|
||||
value={filters.recentlyUpdated}
|
||||
onChange={(e) => handleFilterChange({ recentlyUpdated: e.target.value })}
|
||||
className="w-full p-2 text-sm border rounded-md bg-background"
|
||||
>
|
||||
<option value="all">All Time</option>
|
||||
<option value="1d">Last 24 hours</option>
|
||||
<option value="1w">Last week</option>
|
||||
<option value="1m">Last month</option>
|
||||
<option value="1y">Last year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
72
src/components/ScriptGrid.tsx
Normal file
72
src/components/ScriptGrid.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { ScriptCard, Script } from './ScriptCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface ScriptGridProps {
|
||||
scripts: Script[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ScriptGrid({ scripts, isLoading }: ScriptGridProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<ScriptCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (scripts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground text-lg">
|
||||
No scripts found matching your criteria.
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Try adjusting your filters or search terms.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{scripts.map((script) => (
|
||||
<ScriptCard key={script.id} script={script} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptCardSkeleton() {
|
||||
return (
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Skeleton className="h-6 w-24 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 pt-2">
|
||||
<Skeleton className="h-9 flex-1" />
|
||||
<Skeleton className="h-9 w-9" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
24
src/components/ThemeToggle.tsx
Normal file
24
src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
132
src/components/UserMenu.tsx
Normal file
132
src/components/UserMenu.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { User, Settings, LogOut, Code2, Shield, Crown } from 'lucide-react';
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleMenu}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatarUrl} alt={user.displayName} />
|
||||
<AvatarFallback>
|
||||
{user.displayName.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden md:block">{user.displayName}</span>
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={closeMenu}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border bg-background shadow-lg z-50">
|
||||
<div className="p-2">
|
||||
{/* User Info */}
|
||||
<div className="px-3 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{user.displayName}</span>
|
||||
{user.isSuperUser && (
|
||||
<Crown className="h-3 w-3 text-amber-600" title="Super User" />
|
||||
)}
|
||||
{user.isAdmin && !user.isSuperUser && (
|
||||
<Shield className="h-3 w-3 text-blue-600" title="Admin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="flex items-center px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-scripts"
|
||||
className="flex items-center px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<Code2 className="mr-2 h-4 w-4" />
|
||||
My Scripts
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
{user.isAdmin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex items-center px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm"
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{user.isSuperUser ? (
|
||||
<Crown className="mr-2 h-4 w-4 text-amber-600" />
|
||||
) : (
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{user.isSuperUser ? 'Super Admin' : 'Admin Panel'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="border-t pt-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
238
src/components/admin/AdminDashboard.tsx
Normal file
238
src/components/admin/AdminDashboard.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Users,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AdminDashboardProps {
|
||||
onCreateUser: () => void;
|
||||
onViewScripts: () => void;
|
||||
onViewAnalytics: () => void;
|
||||
}
|
||||
|
||||
export function AdminDashboard({ onCreateUser, onViewScripts, onViewAnalytics }: AdminDashboardProps) {
|
||||
// Mock data - in a real app, this would come from API calls
|
||||
const stats = {
|
||||
totalUsers: 1247,
|
||||
totalScripts: 89,
|
||||
pendingApprovals: 12,
|
||||
totalComments: 456,
|
||||
activeUsers: 234,
|
||||
scriptsThisWeek: 8,
|
||||
commentsThisWeek: 23,
|
||||
systemHealth: 'healthy'
|
||||
};
|
||||
|
||||
const recentActivity = [
|
||||
{ id: 1, type: 'script', action: 'submitted', title: 'Docker Setup Script', user: 'john_doe', time: '2 hours ago' },
|
||||
{ id: 2, type: 'comment', action: 'reported', title: 'Comment on Backup Script', user: 'jane_smith', time: '4 hours ago' },
|
||||
{ id: 3, type: 'user', action: 'registered', title: 'New user account', user: 'alex_wilson', time: '6 hours ago' },
|
||||
{ id: 4, type: 'script', action: 'approved', title: 'Network Monitor', user: 'admin', time: '1 day ago' },
|
||||
];
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'script': return <FileText className="h-4 w-4" />;
|
||||
case 'comment': return <MessageSquare className="h-4 w-4" />;
|
||||
case 'user': return <Users className="h-4 w-4" />;
|
||||
default: return <BarChart3 className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'script': return 'text-blue-600';
|
||||
case 'comment': return 'text-green-600';
|
||||
case 'user': return 'text-purple-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalUsers.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{stats.activeUsers} active this month
|
||||
</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-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalScripts}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
+{stats.scriptsThisWeek} this week
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Pending Approvals</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-600">{stats.pendingApprovals}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Require attention
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">System Health</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 capitalize">{stats.systemHealth}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All systems operational
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>
|
||||
Common administrative tasks and shortcuts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button
|
||||
onClick={onCreateUser}
|
||||
className="h-20 flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<Users className="h-6 w-6" />
|
||||
<span>Create Admin User</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onViewScripts}
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<FileText className="h-6 w-6" />
|
||||
<span>Review Scripts</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onViewAnalytics}
|
||||
variant="outline"
|
||||
className="h-20 flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<BarChart3 className="h-6 w-6" />
|
||||
<span>View Analytics</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Latest platform activities requiring attention.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className={`p-2 rounded-full bg-muted ${getActivityColor(activity.type)}`}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{activity.title}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{activity.action}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
by @{activity.user} • {activity.time}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Status</CardTitle>
|
||||
<CardDescription>
|
||||
Current platform status and alerts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium text-green-800 dark:text-green-200">Database</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Operational
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium text-green-800 dark:text-green-200">File Storage</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Operational
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="font-medium text-green-800 dark:text-green-200">Email Service</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||
Operational
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
171
src/components/admin/AdminUsersList.tsx
Normal file
171
src/components/admin/AdminUsersList.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Crown, Shield, User, Trash2, Edit, MoreHorizontal } from 'lucide-react';
|
||||
import { AdminUser } from '@/lib/admin';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
export function AdminUsersList() {
|
||||
const [adminUsers, setAdminUsers] = useState<AdminUser[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Load admin users from localStorage (in a real app, this would be an API call)
|
||||
const loadAdminUsers = () => {
|
||||
try {
|
||||
const users = JSON.parse(localStorage.getItem('scriptshare-admin-users') || '[]');
|
||||
setAdminUsers(users);
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin users:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAdminUsers();
|
||||
}, []);
|
||||
|
||||
const handleDeleteUser = (userId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this admin user? This action cannot be undone.')) {
|
||||
const updatedUsers = adminUsers.filter(user => user.id !== userId);
|
||||
setAdminUsers(updatedUsers);
|
||||
localStorage.setItem('scriptshare-admin-users', JSON.stringify(updatedUsers));
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionBadges = (user: AdminUser) => {
|
||||
const badges = [];
|
||||
|
||||
if (user.isSuperUser) {
|
||||
badges.push(
|
||||
<Badge key="super" variant="default" className="bg-amber-600 hover:bg-amber-700">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Super User
|
||||
</Badge>
|
||||
);
|
||||
} else if (user.isAdmin) {
|
||||
badges.push(
|
||||
<Badge key="admin" variant="secondary">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Admin
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (user.isModerator) {
|
||||
badges.push(
|
||||
<Badge key="mod" variant="outline">
|
||||
<User className="h-3 w-3 mr-1" />
|
||||
Moderator
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const getInitials = (displayName: string) => {
|
||||
return displayName
|
||||
.split(' ')
|
||||
.map(name => name[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
Loading admin users...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (adminUsers.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Shield className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">No Admin Users Found</h3>
|
||||
<p className="text-sm">
|
||||
Create your first admin user to get started with platform management.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Admin Users ({adminUsers.length})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage platform administrators and moderators.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{adminUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.avatarUrl} alt={user.displayName} />
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{getInitials(user.displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{user.displayName}</h4>
|
||||
{getPermissionBadges(user)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div>@{user.username} • {user.email}</div>
|
||||
{user.bio && <div className="max-w-md truncate">{user.bio}</div>}
|
||||
<div className="text-xs">
|
||||
Created {formatDate(user.createdAt)} • Last updated {formatDate(user.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
225
src/components/admin/CreateAdminForm.tsx
Normal file
225
src/components/admin/CreateAdminForm.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import { useState } from 'react';
|
||||
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 { Shield, Crown, UserPlus } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { showSuccess, showError } from '@/utils/toast';
|
||||
|
||||
interface CreateAdminFormProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function CreateAdminForm({ onSuccess }: CreateAdminFormProps) {
|
||||
const { createSuperUser, createAdminUser } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuperUser, setIsSuperUser] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
password: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const success = isSuperUser
|
||||
? await createSuperUser(formData)
|
||||
: await createAdminUser(formData);
|
||||
|
||||
if (success) {
|
||||
showSuccess(
|
||||
isSuperUser
|
||||
? 'Super user created successfully!'
|
||||
: 'Admin user created successfully!'
|
||||
);
|
||||
setFormData({
|
||||
email: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
password: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
});
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError('Failed to create user. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('An error occurred while creating the user.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Create {isSuperUser ? 'Super User' : 'Admin User'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Create a new {isSuperUser ? 'super user' : 'admin user'} with elevated permissions.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isSuperUser ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsSuperUser(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
Super User
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={!isSuperUser ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsSuperUser(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSuperUser && (
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-200">
|
||||
<Crown className="h-4 w-4" />
|
||||
<span className="font-medium">Super User Permissions</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
Super users have full system access including user management, system configuration, and all moderation capabilities.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email *
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="text-sm font-medium">
|
||||
Username *
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder="admin_user"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="displayName" className="text-sm font-medium">
|
||||
Display Name *
|
||||
</label>
|
||||
<Input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.displayName}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Admin User"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password *
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="bio" className="text-sm font-medium">
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
id="bio"
|
||||
name="bio"
|
||||
rows={3}
|
||||
value={formData.bio}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Brief description about this user..."
|
||||
className="w-full px-3 py-2 border border-input bg-background rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="avatarUrl" className="text-sm font-medium">
|
||||
Avatar URL
|
||||
</label>
|
||||
<Input
|
||||
id="avatarUrl"
|
||||
name="avatarUrl"
|
||||
type="url"
|
||||
value={formData.avatarUrl}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isSuperUser ? "default" : "secondary"}>
|
||||
{isSuperUser ? "Super User" : "Admin User"}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isSuperUser ? "Full system access" : "Moderation access"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Creating...' : `Create ${isSuperUser ? 'Super User' : 'Admin User'}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
25
src/components/ui/input.tsx
Normal file
25
src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
212
src/contexts/AuthContext.tsx
Normal file
212
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,212 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { generateId } from '@/lib/utils';
|
||||
import { AdminUser, createSuperUser, createAdminUser, hasPermission } from '@/lib/admin';
|
||||
|
||||
interface User extends AdminUser {}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<boolean>;
|
||||
register: (email: string, username: string, displayName: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
updateProfile: (updates: Partial<User>) => Promise<boolean>;
|
||||
createSuperUser: (userData: { email: string; username: string; displayName: string; password: string; bio?: string; avatarUrl?: string }) => Promise<boolean>;
|
||||
createAdminUser: (userData: { email: string; username: string; displayName: string; password: string; bio?: string; avatarUrl?: string }) => Promise<boolean>;
|
||||
hasPermission: (permission: string) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session on mount
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('scriptshare-auth-token');
|
||||
if (token) {
|
||||
// In a real app, you'd validate the token with your backend
|
||||
// For now, we'll just check if it exists
|
||||
const userData = localStorage.getItem('scriptshare-user-data');
|
||||
if (userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, _password: string): Promise<boolean> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// In a real app, you'd make an API call to your backend
|
||||
// For now, we'll simulate a successful login
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Simulate user data
|
||||
const mockUser: User = {
|
||||
id: generateId(),
|
||||
email,
|
||||
username: email.split('@')[0],
|
||||
displayName: email.split('@')[0],
|
||||
isAdmin: false,
|
||||
isModerator: false,
|
||||
isSuperUser: false,
|
||||
permissions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const token = generateId();
|
||||
localStorage.setItem('scriptshare-auth-token', token);
|
||||
localStorage.setItem('scriptshare-user-data', JSON.stringify(mockUser));
|
||||
|
||||
setUser(mockUser);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, username: string, displayName: string, _password: string): Promise<boolean> => {
|
||||
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));
|
||||
|
||||
const mockUser: User = {
|
||||
id: generateId(),
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
isAdmin: false,
|
||||
isModerator: false,
|
||||
isSuperUser: false,
|
||||
permissions: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const token = generateId();
|
||||
localStorage.setItem('scriptshare-auth-token', token);
|
||||
localStorage.setItem('scriptshare-user-data', JSON.stringify(mockUser));
|
||||
|
||||
setUser(mockUser);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('scriptshare-auth-token');
|
||||
localStorage.removeItem('scriptshare-user-data');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateProfile = async (updates: Partial<User>): Promise<boolean> => {
|
||||
try {
|
||||
if (!user) return false;
|
||||
|
||||
const updatedUser = { ...user, ...updates, updatedAt: new Date().toISOString() };
|
||||
localStorage.setItem('scriptshare-user-data', JSON.stringify(updatedUser));
|
||||
setUser(updatedUser);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createSuperUserLocal = async (userData: { email: string; username: string; displayName: string; password: string; bio?: string; avatarUrl?: string }): Promise<boolean> => {
|
||||
try {
|
||||
// In a real app, you'd make an API call to your backend
|
||||
// For now, we'll simulate creating a superuser
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const newSuperUser = createSuperUser(userData);
|
||||
|
||||
// Store in localStorage for demo purposes
|
||||
const existingUsers = JSON.parse(localStorage.getItem('scriptshare-admin-users') || '[]');
|
||||
existingUsers.push(newSuperUser);
|
||||
localStorage.setItem('scriptshare-admin-users', JSON.stringify(existingUsers));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Super user creation failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const createAdminUserLocal = async (userData: { email: string; username: string; displayName: string; password: string; bio?: string; avatarUrl?: string }): Promise<boolean> => {
|
||||
try {
|
||||
// In a real app, you'd make an API call to your backend
|
||||
// For now, we'll simulate creating an admin user
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const newAdminUser = createAdminUser(userData);
|
||||
|
||||
// Store in localStorage for demo purposes
|
||||
const existingUsers = JSON.parse(localStorage.getItem('scriptshare-admin-users') || '[]');
|
||||
existingUsers.push(newAdminUser);
|
||||
localStorage.setItem('scriptshare-admin-users', JSON.stringify(existingUsers));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Admin user creation failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = (permission: string): boolean => {
|
||||
return hasPermission(user, permission);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
createSuperUser: createSuperUserLocal,
|
||||
createAdminUser: createAdminUserLocal,
|
||||
hasPermission,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
89
src/contexts/ThemeContext.tsx
Normal file
89
src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'dark' | 'light';
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'scriptshare-theme',
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return (stored as Theme) || defaultTheme;
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>(() => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
root.classList.add(systemTheme);
|
||||
setResolvedTheme(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
setResolvedTheme(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
}, [theme, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
||||
setResolvedTheme(systemTheme);
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(systemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
191
src/hooks/use-toast.ts
Normal file
191
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
75
src/index.css
Normal file
75
src/index.css
Normal file
@ -0,0 +1,75 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 84% 4.9%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 94.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
103
src/lib/admin.ts
Normal file
103
src/lib/admin.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { generateId } from './utils';
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
isAdmin: boolean;
|
||||
isModerator: boolean;
|
||||
isSuperUser: boolean;
|
||||
permissions: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSuperUserData {
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export const SUPER_USER_PERMISSIONS = [
|
||||
'user:create',
|
||||
'user:read',
|
||||
'user:update',
|
||||
'user:delete',
|
||||
'user:promote',
|
||||
'script:approve',
|
||||
'script:reject',
|
||||
'script:delete',
|
||||
'comment:moderate',
|
||||
'system:configure',
|
||||
'analytics:view',
|
||||
'backup:manage'
|
||||
] as const;
|
||||
|
||||
export const MODERATOR_PERMISSIONS = [
|
||||
'script:approve',
|
||||
'script:reject',
|
||||
'comment:moderate',
|
||||
'user:read'
|
||||
] as const;
|
||||
|
||||
export function createSuperUser(userData: CreateSuperUserData): AdminUser {
|
||||
return {
|
||||
id: generateId(),
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
displayName: userData.displayName,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
bio: userData.bio,
|
||||
isAdmin: true,
|
||||
isModerator: true,
|
||||
isSuperUser: true,
|
||||
permissions: [...SUPER_USER_PERMISSIONS],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdminUser(userData: CreateSuperUserData): AdminUser {
|
||||
return {
|
||||
id: generateId(),
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
displayName: userData.displayName,
|
||||
avatarUrl: userData.avatarUrl,
|
||||
bio: userData.bio,
|
||||
isAdmin: true,
|
||||
isModerator: true,
|
||||
isSuperUser: false,
|
||||
permissions: [...MODERATOR_PERMISSIONS],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPermission(user: AdminUser | null, permission: string): boolean {
|
||||
if (!user) return false;
|
||||
if (user.isSuperUser) return true;
|
||||
return user.permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function canManageUsers(user: AdminUser | null): boolean {
|
||||
return hasPermission(user, 'user:create') || hasPermission(user, 'user:promote');
|
||||
}
|
||||
|
||||
export function canModerateContent(user: AdminUser | null): boolean {
|
||||
return hasPermission(user, 'script:approve') || hasPermission(user, 'comment:moderate');
|
||||
}
|
||||
|
||||
export function canViewAnalytics(user: AdminUser | null): boolean {
|
||||
return hasPermission(user, 'analytics:view');
|
||||
}
|
||||
|
||||
export function canConfigureSystem(user: AdminUser | null): boolean {
|
||||
return hasPermission(user, 'system:configure');
|
||||
}
|
26
src/lib/db/index.ts
Normal file
26
src/lib/db/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { drizzle } from 'drizzle-orm/mysql2';
|
||||
import mysql from 'mysql2/promise';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Create the connection pool
|
||||
const connection = await mysql.createConnection({
|
||||
uri: process.env.DATABASE_URL!,
|
||||
});
|
||||
|
||||
// Create the drizzle database instance
|
||||
export const db = drizzle(connection, { schema, mode: 'default' });
|
||||
|
||||
// Export the schema for use in other parts of the app
|
||||
export * from './schema';
|
||||
|
||||
// Test the connection
|
||||
export const testConnection = async () => {
|
||||
try {
|
||||
await connection.ping();
|
||||
console.log('✅ Database connection successful');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
217
src/lib/db/schema.ts
Normal file
217
src/lib/db/schema.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { mysqlTable, varchar, text, timestamp, int, boolean, json, index } from 'drizzle-orm/mysql-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// Users table
|
||||
export const users = mysqlTable('users', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
username: varchar('username', { length: 100 }).notNull().unique(),
|
||||
displayName: varchar('display_name', { length: 100 }).notNull(),
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
bio: text('bio'),
|
||||
isAdmin: boolean('is_admin').default(false),
|
||||
isModerator: boolean('is_moderator').default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
emailIdx: index('email_idx').on(table.email),
|
||||
usernameIdx: index('username_idx').on(table.username),
|
||||
}));
|
||||
|
||||
// Scripts table
|
||||
export const scripts = mysqlTable('scripts', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
description: text('description').notNull(),
|
||||
content: text('content').notNull(),
|
||||
compatibleOs: json('compatible_os').$type<string[]>().notNull(),
|
||||
categories: json('categories').$type<string[]>().notNull(),
|
||||
tags: json('tags').$type<string[]>(),
|
||||
gitRepositoryUrl: varchar('git_repository_url', { length: 500 }),
|
||||
authorId: varchar('author_id', { length: 255 }).notNull(),
|
||||
authorName: varchar('author_name', { length: 100 }).notNull(),
|
||||
viewCount: int('view_count').default(0).notNull(),
|
||||
downloadCount: int('download_count').default(0).notNull(),
|
||||
rating: int('rating').default(0).notNull(),
|
||||
ratingCount: int('rating_count').default(0).notNull(),
|
||||
isApproved: boolean('is_approved').default(false).notNull(),
|
||||
isPublic: boolean('is_public').default(true).notNull(),
|
||||
version: varchar('version', { length: 20 }).default('1.0.0').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
authorIdx: index('author_idx').on(table.authorId),
|
||||
approvedIdx: index('approved_idx').on(table.isApproved),
|
||||
publicIdx: index('public_idx').on(table.isPublic),
|
||||
createdAtIdx: index('created_at_idx').on(table.createdAt),
|
||||
}));
|
||||
|
||||
// Script versions table
|
||||
export const scriptVersions = mysqlTable('script_versions', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
scriptId: varchar('script_id', { length: 255 }).notNull(),
|
||||
version: varchar('version', { length: 20 }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
changelog: text('changelog'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
createdBy: varchar('created_by', { length: 255 }).notNull(),
|
||||
}, (table) => ({
|
||||
scriptIdx: index('script_idx').on(table.scriptId),
|
||||
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', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
scriptId: varchar('script_id', { length: 255 }).notNull(),
|
||||
userId: varchar('user_id', { length: 255 }).notNull(),
|
||||
rating: int('rating').notNull(), // 1-5 stars
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
scriptIdx: index('script_idx').on(table.scriptId),
|
||||
userIdx: index('user_idx').on(table.userId),
|
||||
uniqueRating: index('unique_rating').on(table.scriptId, table.userId),
|
||||
}));
|
||||
|
||||
// Script collections table
|
||||
export const scriptCollections = mysqlTable('script_collections', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
description: text('description'),
|
||||
authorId: varchar('author_id', { length: 255 }).notNull(),
|
||||
isPublic: boolean('is_public').default(true).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
authorIdx: index('author_idx').on(table.authorId),
|
||||
publicIdx: index('public_idx').on(table.isPublic),
|
||||
}));
|
||||
|
||||
// Collection scripts junction table
|
||||
export const collectionScripts = mysqlTable('collection_scripts', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
collectionId: varchar('collection_id', { length: 255 }).notNull(),
|
||||
scriptId: varchar('script_id', { length: 255 }).notNull(),
|
||||
addedAt: timestamp('added_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
collectionIdx: index('collection_idx').on(table.collectionId),
|
||||
scriptIdx: index('script_idx').on(table.scriptId),
|
||||
}));
|
||||
|
||||
// Script analytics table
|
||||
export const scriptAnalytics = mysqlTable('script_analytics', {
|
||||
id: varchar('id', { length: 255 }).primaryKey(),
|
||||
scriptId: varchar('script_id', { length: 255 }).notNull(),
|
||||
eventType: varchar('event_type', { length: 50 }).notNull(), // view, download, share
|
||||
userId: varchar('user_id', { length: 255 }),
|
||||
userAgent: text('user_agent'),
|
||||
ipAddress: varchar('ip_address', { length: 45 }),
|
||||
referrer: varchar('referrer', { length: 500 }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
scriptIdx: index('script_idx').on(table.scriptId),
|
||||
eventIdx: index('event_idx').on(table.eventType),
|
||||
userIdx: index('user_idx').on(table.userId),
|
||||
createdAtIdx: index('created_at_idx').on(table.createdAt),
|
||||
}));
|
||||
|
||||
// Define relationships
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
scripts: many(scripts),
|
||||
comments: many(comments),
|
||||
ratings: many(ratings),
|
||||
collections: many(scriptCollections),
|
||||
}));
|
||||
|
||||
export const scriptsRelations = relations(scripts, ({ one, many }) => ({
|
||||
author: one(users, {
|
||||
fields: [scripts.authorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
versions: many(scriptVersions),
|
||||
comments: many(comments),
|
||||
ratings: many(ratings),
|
||||
analytics: many(scriptAnalytics),
|
||||
}));
|
||||
|
||||
export const scriptVersionsRelations = relations(scriptVersions, ({ one }) => ({
|
||||
script: one(scripts, {
|
||||
fields: [scriptVersions.scriptId],
|
||||
references: [scripts.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
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, {
|
||||
fields: [ratings.scriptId],
|
||||
references: [scripts.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [ratings.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const scriptCollectionsRelations = relations(scriptCollections, ({ one, many }) => ({
|
||||
author: one(users, {
|
||||
fields: [scriptCollections.authorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
scripts: many(collectionScripts),
|
||||
}));
|
||||
|
||||
export const collectionScriptsRelations = relations(collectionScripts, ({ one }) => ({
|
||||
collection: one(scriptCollections, {
|
||||
fields: [collectionScripts.collectionId],
|
||||
references: [scriptCollections.id],
|
||||
}),
|
||||
script: one(scripts, {
|
||||
fields: [collectionScripts.scriptId],
|
||||
references: [scripts.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const scriptAnalyticsRelations = relations(scriptAnalytics, ({ one }) => ({
|
||||
script: one(scripts, {
|
||||
fields: [scriptAnalytics.scriptId],
|
||||
references: [scripts.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [scriptAnalytics.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
115
src/lib/utils.ts
Normal file
115
src/lib/utils.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date();
|
||||
const d = new Date(date);
|
||||
const diffInSeconds = Math.floor((now.getTime() - d.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||
if (diffInSeconds < 31536000) return `${Math.floor(diffInSeconds / 2592000)}mo ago`;
|
||||
return `${Math.floor(diffInSeconds / 31536000)}y ago`;
|
||||
}
|
||||
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function isValidUsername(username: string): boolean {
|
||||
const usernameRegex = /^[a-zA-Z0-9_-]{3,20}$/;
|
||||
return usernameRegex.test(username);
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
textArea.remove();
|
||||
return Promise.resolve(true);
|
||||
} catch (err) {
|
||||
textArea.remove();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
34
src/pages/About.tsx
Normal file
34
src/pages/About.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Code2 } from 'lucide-react';
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-6">
|
||||
<Code2 className="h-20 w-20 text-primary mx-auto" />
|
||||
<h1 className="text-5xl font-bold">About ScriptShare</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
|
||||
A community-driven platform for discovering, sharing, and collaborating on powerful bash scripts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
About page content coming soon! This will include our mission, team, and community information.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
165
src/pages/AdminPanel.tsx
Normal file
165
src/pages/AdminPanel.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/Header';
|
||||
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 { CreateAdminForm } from '@/components/admin/CreateAdminForm';
|
||||
import { AdminUsersList } from '@/components/admin/AdminUsersList';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
type AdminView = 'dashboard' | 'create-user' | 'users' | 'scripts' | 'analytics';
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { user } = useAuth();
|
||||
const [currentView, setCurrentView] = useState<AdminView>('dashboard');
|
||||
|
||||
// Check if user has admin access
|
||||
if (!user?.isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-2xl mx-auto text-center space-y-6">
|
||||
<Shield className="h-16 w-16 text-muted-foreground mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Access Denied</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
You don't have permission to access the admin panel.
|
||||
</p>
|
||||
<Button onClick={() => window.history.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'dashboard':
|
||||
return (
|
||||
<AdminDashboard
|
||||
onCreateUser={() => setCurrentView('create-user')}
|
||||
onViewScripts={() => setCurrentView('scripts')}
|
||||
onViewAnalytics={() => setCurrentView('analytics')}
|
||||
/>
|
||||
);
|
||||
case 'create-user':
|
||||
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>
|
||||
<CreateAdminForm onSuccess={() => setCurrentView('users')} />
|
||||
</div>
|
||||
);
|
||||
case 'users':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<Button onClick={() => setCurrentView('create-user')}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Create Admin User
|
||||
</Button>
|
||||
</div>
|
||||
<AdminUsersList />
|
||||
</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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<Shield className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Admin Panel</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Manage the platform and moderate content.
|
||||
</p>
|
||||
{user.isSuperUser && (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-full text-sm font-medium">
|
||||
<Shield className="h-4 w-4" />
|
||||
Super User Access
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderView()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/AdminScriptReview.tsx
Normal file
34
src/pages/AdminScriptReview.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FileCheck } from 'lucide-react';
|
||||
|
||||
export default function AdminScriptReview() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<FileCheck className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Script Review</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Review and approve submitted scripts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Script Review functionality coming soon! This will include script evaluation, approval/rejection, and feedback.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/Dashboard.tsx
Normal file
34
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<BarChart3 className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Dashboard</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Manage your scripts and track your contributions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Dashboard functionality coming soon! This will include user statistics, script management, and analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/EditScript.tsx
Normal file
34
src/pages/EditScript.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Edit } from 'lucide-react';
|
||||
|
||||
export default function EditScript() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<Edit className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Edit Script</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Update your script content and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Edit Script functionality coming soon! This will include script editing forms and version management.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
349
src/pages/Index.tsx
Normal file
349
src/pages/Index.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ScriptFilters, FilterOptions } from '@/components/ScriptFilters';
|
||||
import { ScriptGrid } from '@/components/ScriptGrid';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Script } from '@/components/ScriptCard';
|
||||
import { showError } from '@/utils/toast';
|
||||
import { Code2, Users, FileText, Star, ArrowRight, Sparkles, Zap, Shield } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Mock data for development
|
||||
const mockScripts: Script[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'System Backup Automation',
|
||||
description: 'Automated backup script for Linux systems with compression and encryption support.',
|
||||
compatible_os: ['Linux', 'Ubuntu', 'CentOS'],
|
||||
categories: ['System Administration', 'Backup'],
|
||||
git_repository_url: 'https://github.com/user/backup-script',
|
||||
author_name: 'John Doe',
|
||||
view_count: 1250,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
updated_at: '2024-01-20T14:30:00Z',
|
||||
is_approved: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Docker Cleanup Utility',
|
||||
description: 'Clean up unused Docker containers, images, and volumes to free up disk space.',
|
||||
compatible_os: ['Linux', 'macOS', 'Windows'],
|
||||
categories: ['DevOps', 'Docker'],
|
||||
git_repository_url: 'https://github.com/user/docker-cleanup',
|
||||
author_name: 'Jane Smith',
|
||||
view_count: 890,
|
||||
created_at: '2024-01-10T09:00:00Z',
|
||||
updated_at: '2024-01-18T16:45:00Z',
|
||||
is_approved: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Git Repository Manager',
|
||||
description: 'Manage multiple Git repositories with status checking and batch operations.',
|
||||
compatible_os: ['Linux', 'macOS', 'Windows'],
|
||||
categories: ['Development', 'Git'],
|
||||
git_repository_url: 'https://github.com/user/git-manager',
|
||||
author_name: 'Mike Johnson',
|
||||
view_count: 567,
|
||||
created_at: '2024-01-05T11:00:00Z',
|
||||
updated_at: '2024-01-12T13:20:00Z',
|
||||
is_approved: true,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Network Monitor',
|
||||
description: 'Monitor network connectivity and performance with detailed reporting.',
|
||||
compatible_os: ['Linux', 'Ubuntu'],
|
||||
categories: ['Networking', 'Monitoring'],
|
||||
git_repository_url: 'https://github.com/user/network-monitor',
|
||||
author_name: 'Sarah Wilson',
|
||||
view_count: 432,
|
||||
created_at: '2024-01-01T08:00:00Z',
|
||||
updated_at: '2024-01-08T10:15:00Z',
|
||||
is_approved: true,
|
||||
},
|
||||
];
|
||||
|
||||
const Index = () => {
|
||||
const navigate = useNavigate();
|
||||
const [scripts, setScripts] = useState<Script[]>([]);
|
||||
const [filteredScripts, setFilteredScripts] = useState<Script[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load mock data on mount
|
||||
useEffect(() => {
|
||||
console.log('📜 Index component mounted at:', new Date().toISOString());
|
||||
loadScripts();
|
||||
}, []);
|
||||
|
||||
const loadScripts = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('✅ Successfully loaded scripts:', mockScripts.length);
|
||||
setScripts(mockScripts);
|
||||
setFilteredScripts(mockScripts);
|
||||
setError(null);
|
||||
} catch (error: any) {
|
||||
console.error('❌ Error in loadScripts:', error);
|
||||
const errorMessage = error.message || 'Failed to load scripts';
|
||||
setError(errorMessage);
|
||||
showError(`Failed to load scripts: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersChange = (filters: FilterOptions) => {
|
||||
console.log('🔍 Applying filters:', filters);
|
||||
let filtered = [...scripts];
|
||||
|
||||
// Apply search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(script =>
|
||||
script.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
script.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
script.author_name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Apply OS filter
|
||||
if (filters.os.length > 0) {
|
||||
filtered = filtered.filter(script =>
|
||||
filters.os.some(os => script.compatible_os.includes(os))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (filters.categories.length > 0) {
|
||||
filtered = filtered.filter(script =>
|
||||
filters.categories.some(category => script.categories.includes(category))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply date filters
|
||||
const now = new Date();
|
||||
if (filters.dateAdded !== 'all') {
|
||||
const days = getDateFilterDays(filters.dateAdded);
|
||||
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
filtered = filtered.filter(script => new Date(script.created_at) >= cutoff);
|
||||
}
|
||||
|
||||
if (filters.recentlyUpdated !== 'all') {
|
||||
const days = getDateFilterDays(filters.recentlyUpdated);
|
||||
const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
filtered = filtered.filter(script => new Date(script.updated_at) >= cutoff);
|
||||
}
|
||||
|
||||
console.log('🔍 Filtered scripts:', filtered.length);
|
||||
setFilteredScripts(filtered);
|
||||
};
|
||||
|
||||
const getDateFilterDays = (filter: string): number => {
|
||||
switch (filter) {
|
||||
case '1d': return 1;
|
||||
case '1w': return 7;
|
||||
case '1m': return 30;
|
||||
case '1y': return 365;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
console.log('🔍 Search query:', query);
|
||||
setSearchQuery(query);
|
||||
// Re-apply filters with new search query
|
||||
handleFiltersChange({
|
||||
os: [],
|
||||
categories: [],
|
||||
dateAdded: 'all',
|
||||
recentlyUpdated: 'all',
|
||||
});
|
||||
};
|
||||
|
||||
const totalViews = scripts.reduce((sum, script) => sum + script.view_count, 0);
|
||||
const totalAuthors = new Set(scripts.map(script => script.author_name)).size;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={handleSearch} />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-primary/20 to-accent/20 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-accent/20 to-primary/20 rounded-full blur-3xl animate-pulse delay-1000" />
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 py-16 relative z-10">
|
||||
<div className="text-center space-y-8 max-w-4xl mx-auto">
|
||||
{/* Main heading with enhanced styling */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Sparkles className="h-6 w-6 text-accent animate-pulse" />
|
||||
<span className="text-sm font-semibold text-muted-foreground tracking-wider uppercase">
|
||||
Community Driven
|
||||
</span>
|
||||
<Sparkles className="h-6 w-6 text-primary animate-pulse delay-500" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-bold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent animate-gradient-x leading-tight">
|
||||
Bash Script Library
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
Discover, share, and collaborate on powerful bash scripts for
|
||||
<span className="text-primary font-semibold"> system administration</span>,
|
||||
<span className="text-accent font-semibold"> automation</span>, and
|
||||
<span className="text-primary font-semibold"> development</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white shadow-2xl hover:shadow-primary/30 transition-all duration-300 rounded-xl px-8 py-3 text-lg font-semibold group"
|
||||
onClick={() => navigate('/search')}
|
||||
>
|
||||
Explore Scripts
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="border-primary/30 hover:border-primary hover:bg-gradient-to-r hover:from-primary/10 hover:to-accent/10 transition-all duration-300 rounded-xl px-8 py-3 text-lg font-semibold shadow-lg"
|
||||
onClick={() => navigate('/submit')}
|
||||
>
|
||||
<Code2 className="mr-2 h-5 w-5" />
|
||||
Share Your Script
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16">
|
||||
<Card className="bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm border-primary/20 hover:border-primary/40 transition-all duration-300 hover:scale-105">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-primary/20 to-primary/30 rounded-full mb-4">
|
||||
<FileText className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
{scripts.length}
|
||||
</div>
|
||||
<div className="text-muted-foreground font-medium">Quality Scripts</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm border-accent/20 hover:border-accent/40 transition-all duration-300 hover:scale-105">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-accent/20 to-accent/30 rounded-full mb-4">
|
||||
<Users className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-accent to-primary bg-clip-text text-transparent">
|
||||
{totalAuthors}
|
||||
</div>
|
||||
<div className="text-muted-foreground font-medium">Contributors</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-card/80 to-card/40 backdrop-blur-sm border-primary/20 hover:border-primary/40 transition-all duration-300 hover:scale-105">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-primary/20 to-accent/20 rounded-full mb-4">
|
||||
<Star className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
{totalViews.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-muted-foreground font-medium">Total Views</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-16">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-primary/10 to-primary/20 rounded-2xl">
|
||||
<Zap className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Lightning Fast</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Find the perfect script in seconds with our advanced search and filtering system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-accent/10 to-accent/20 rounded-2xl">
|
||||
<Shield className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Community Reviewed</h3>
|
||||
<p className="text-muted-foreground">
|
||||
All scripts are reviewed by our community to ensure quality and security.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-primary/10 to-accent/10 rounded-2xl">
|
||||
<Code2 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">Ready to Use</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Copy, download, or fork scripts directly. No setup required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Scripts Section */}
|
||||
<section className="container mx-auto px-4 py-16 space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
|
||||
Featured Scripts
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
Explore our curated collection of bash scripts, crafted by developers for developers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Card className="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-red-800 dark:text-red-200">
|
||||
<strong>Error loading scripts:</strong> {error}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadScripts}
|
||||
className="ml-4"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ScriptFilters onFiltersChange={handleFiltersChange} />
|
||||
<ScriptGrid scripts={filteredScripts} isLoading={isLoading} />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
34
src/pages/Login.tsx
Normal file
34
src/pages/Login.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-md mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<LogIn className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-3xl font-bold">Sign In</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Access your account to manage scripts and contribute to the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Login functionality coming soon! This will include authentication forms and user management.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/MyScripts.tsx
Normal file
34
src/pages/MyScripts.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
|
||||
export default function MyScripts() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-6xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<FolderOpen className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">My Scripts</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Manage and track your submitted scripts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
My Scripts functionality coming soon! This will include script management, editing, and status tracking.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
37
src/pages/NotFound.tsx
Normal file
37
src/pages/NotFound.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Code2, Home, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<div className="text-center space-y-6 max-w-md mx-auto px-4">
|
||||
<div className="flex justify-center">
|
||||
<Code2 className="h-24 w-24 text-primary/50" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-6xl font-bold text-muted-foreground">404</h1>
|
||||
<h2 className="text-2xl font-semibold">Page Not Found</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button asChild>
|
||||
<Link to="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go Home
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/Privacy.tsx
Normal file
34
src/pages/Privacy.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<Shield className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Privacy Policy</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
How we protect and handle your data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Privacy Policy content coming soon! This will include detailed information about data handling and privacy practices.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/Profile.tsx
Normal file
34
src/pages/Profile.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { User } from 'lucide-react';
|
||||
|
||||
export default function Profile() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<User className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Profile</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Profile functionality coming soon! This will include user settings, account management, and preferences.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/ScriptDetail.tsx
Normal file
34
src/pages/ScriptDetail.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FileCode } from 'lucide-react';
|
||||
|
||||
export default function ScriptDetail() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<FileCode className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Script Details</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
View and interact with bash scripts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Script detail functionality coming soon! This will include script content, comments, ratings, and download options.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/Search.tsx
Normal file
34
src/pages/Search.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
|
||||
export default function Search() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<SearchIcon className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Search Scripts</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Find the perfect bash script for your needs using our advanced search and filtering system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardContent className="p-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Search functionality coming soon! This page will include advanced search, filters, and results.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/SubmitScript.tsx
Normal file
34
src/pages/SubmitScript.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Upload } from 'lucide-react';
|
||||
|
||||
export default function SubmitScript() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<Upload className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Submit Script</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Share your bash script with the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Script submission functionality coming soon! This will include forms for script details, content, and metadata.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
34
src/pages/Terms.tsx
Normal file
34
src/pages/Terms.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Header } from '@/components/Header';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export default function Terms() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-br from-background via-background to-primary/5">
|
||||
<Header onSearch={() => {}} />
|
||||
|
||||
<main className="flex-1 container mx-auto px-4 py-16">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="text-center space-y-4">
|
||||
<FileText className="h-16 w-16 text-primary mx-auto" />
|
||||
<h1 className="text-4xl font-bold">Terms of Service</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Our terms and conditions for using the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Terms of Service content coming soon! This will include detailed terms and conditions for platform usage.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
39
src/utils/toast.ts
Normal file
39
src/utils/toast.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const showSuccess = (message: string) => {
|
||||
toast.success(message, {
|
||||
duration: 4000,
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
export const showError = (message: string) => {
|
||||
toast.error(message, {
|
||||
duration: 6000,
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
export const showInfo = (message: string) => {
|
||||
toast.info(message, {
|
||||
duration: 4000,
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
export const showWarning = (message: string) => {
|
||||
toast.warning(message, {
|
||||
duration: 5000,
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
export const showLoading = (message: string) => {
|
||||
return toast.loading(message, {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissToast = (toastId: string | number) => {
|
||||
toast.dismiss(toastId);
|
||||
};
|
91
tailwind.config.ts
Normal file
91
tailwind.config.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"gradient-x": {
|
||||
"0%, 100%": {
|
||||
"background-size": "200% 200%",
|
||||
"background-position": "left center",
|
||||
},
|
||||
"50%": {
|
||||
"background-size": "200% 200%",
|
||||
"background-position": "right center",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"gradient-x": "gradient-x 15s ease infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
|
||||
} satisfies Config
|
||||
|
||||
export default config
|
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
21
vite.config.ts
Normal file
21
vite.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
27
webapp-superuser.json
Normal file
27
webapp-superuser.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "92acee3490facf4eef9d2a5695397ca6",
|
||||
"email": "admin@scriptshare.com",
|
||||
"username": "admin",
|
||||
"displayName": "System Administrator",
|
||||
"avatarUrl": "",
|
||||
"bio": "Default system administrator account",
|
||||
"isAdmin": true,
|
||||
"isModerator": true,
|
||||
"isSuperUser": true,
|
||||
"permissions": [
|
||||
"user:create",
|
||||
"user:read",
|
||||
"user:update",
|
||||
"user:delete",
|
||||
"user:promote",
|
||||
"script:approve",
|
||||
"script:reject",
|
||||
"script:delete",
|
||||
"comment:moderate",
|
||||
"system:configure",
|
||||
"analytics:view",
|
||||
"backup:manage"
|
||||
],
|
||||
"createdAt": "2025-08-12T21:28:52.080Z",
|
||||
"updatedAt": "2025-08-12T21:28:52.080Z"
|
||||
}
|
Reference in New Issue
Block a user