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:
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);
|
||||
};
|
Reference in New Issue
Block a user