Files
appforge/src/components/Navbar.tsx
T

504 lines
21 KiB
TypeScript
Raw Normal View History

import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Smartphone, Menu, X, LogOut, Settings, Coins, Shield, Plus, ChevronDown, ChevronLeft, ChevronRight, FolderOpen, Trash2, Wrench, HelpCircle } from "lucide-react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { useAdminAuth } from "@/hooks/useAdminAuth";
import { useAdminExists } from "@/hooks/useAdminExists";
import { ThemeToggle } from "@/components/ThemeToggle";
import { userApi, projectsApi } from "@/lib/api";
import { useSystemSettings } from "@/hooks/useSystemSettings";
import ThemeAwareLogo from "@/components/ThemeAwareLogo";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
const navSections = ['features', 'how-it-works', 'pricing', 'platforms'] as const;
const PROJECTS_PER_PAGE = 5;
const Navbar = () => {
const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState<{ id: string; name: string } | null>(null);
const [projectPage, setProjectPage] = useState(0);
const { user, signOut } = useAuth();
const { isAdmin } = useAdminAuth();
const { hasAdmin } = useAdminExists();
const { settings } = useSystemSettings();
const navigate = useNavigate();
const location = useLocation();
const queryClient = useQueryClient();
// Track active section with IntersectionObserver
useEffect(() => {
if (location.pathname !== '/') {
setActiveSection(null);
return;
}
const observers: IntersectionObserver[] = [];
navSections.forEach((sectionId) => {
const element = document.getElementById(sectionId);
if (element) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(sectionId);
}
});
},
{ rootMargin: '-20% 0px -60% 0px', threshold: 0 }
);
observer.observe(element);
observers.push(observer);
}
});
return () => {
observers.forEach((observer) => observer.disconnect());
};
}, [location.pathname]);
const handleSmoothScroll = useCallback((e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
e.preventDefault();
const scrollToElement = () => {
const element = document.getElementById(targetId);
if (element) {
const navbarHeight = 80;
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - navbarHeight,
behavior: 'smooth'
});
}
};
// If we're not on the home page, navigate first then scroll
if (location.pathname !== '/') {
navigate('/');
setTimeout(scrollToElement, 100);
} else {
scrollToElement();
}
setIsOpen(false);
}, [location.pathname, navigate]);
const getNavLinkClass = (sectionId: string) =>
cn(
"transition-colors cursor-pointer relative",
activeSection === sectionId
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
);
// Fetch user profile for avatar
const { data: profile } = useQuery({
queryKey: ["navbar-profile", user?.id],
queryFn: async () => {
if (!user?.id) return null;
const { data, error } = await userApi.getProfile();
if (error) throw error;
return data;
},
enabled: !!user?.id,
});
// Fetch all projects for builder dropdown
const { data: allProjects } = useQuery({
queryKey: ["recent-projects", user?.id],
queryFn: async () => {
if (!user?.id) return [];
const { data, error } = await projectsApi.list();
if (error) throw error;
return data || [];
},
enabled: !!user?.id,
});
const totalProjectPages = Math.max(1, Math.ceil((allProjects?.length || 0) / PROJECTS_PER_PAGE));
const paginatedProjects = useMemo(() => {
if (!allProjects) return [];
const start = projectPage * PROJECTS_PER_PAGE;
return allProjects.slice(start, start + PROJECTS_PER_PAGE);
}, [allProjects, projectPage]);
// Delete project mutation
const deleteProjectMutation = useMutation({
mutationFn: async (projectId: string) => {
const { error } = await projectsApi.delete(projectId);
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["recent-projects"] });
toast.success("Project deleted successfully");
setDeleteDialogOpen(false);
setProjectToDelete(null);
},
onError: (error) => {
console.error("Delete error:", error);
toast.error("Failed to delete project");
},
});
const handleDeleteClick = (e: React.MouseEvent, project: { id: string; app_name: string }) => {
e.preventDefault();
e.stopPropagation();
setProjectToDelete({ id: project.id, name: project.app_name });
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (projectToDelete) {
deleteProjectMutation.mutate(projectToDelete.id);
}
};
const getInitials = (name?: string | null) => {
if (!name) return user?.email?.[0]?.toUpperCase() || "U";
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const displayName = profile?.display_name || user?.email?.split("@")[0] || "User";
const handleSignOut = async () => {
await signOut();
navigate("/");
};
return (
<nav className="fixed top-0 left-0 right-0 z-50 glass-card border-b border-border/50">
<div className="container mx-auto px-3 sm:px-6 py-3 sm:py-4">
<div className="flex items-center justify-between gap-2">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 sm:gap-3 min-w-0">
<ThemeAwareLogo />
<span className="font-display text-base sm:text-xl font-bold text-foreground truncate">{settings.app_name}</span>
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-6 xl:gap-8">
<a href="/#features" onClick={(e) => handleSmoothScroll(e, 'features')} className={getNavLinkClass('features')}>Features</a>
<a href="/#how-it-works" onClick={(e) => handleSmoothScroll(e, 'how-it-works')} className={getNavLinkClass('how-it-works')}>How It Works</a>
<a href="/#pricing" onClick={(e) => handleSmoothScroll(e, 'pricing')} className={getNavLinkClass('pricing')}>Pricing</a>
<a href="/#platforms" onClick={(e) => handleSmoothScroll(e, 'platforms')} className={getNavLinkClass('platforms')}>Platforms</a>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
location.pathname === '/builder'
? "bg-primary text-primary-foreground"
: "bg-primary/10 text-primary hover:bg-primary/20"
)}
>
Try to Build
<ChevronDown className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem asChild>
<Link to="/builder" className="cursor-pointer flex items-center gap-2 font-medium">
<Plus className="w-4 h-4" />
New App
</Link>
</DropdownMenuItem>
{allProjects && allProjects.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal flex items-center justify-between">
<span>Recent Projects ({allProjects.length})</span>
{totalProjectPages > 1 && (
<span className="flex items-center gap-0.5">
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectPage(p => Math.max(0, p - 1)); }}
disabled={projectPage === 0}
className="p-0.5 rounded hover:bg-muted disabled:opacity-30 transition-colors"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<span className="text-[10px] tabular-nums">{projectPage + 1}/{totalProjectPages}</span>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setProjectPage(p => Math.min(totalProjectPages - 1, p + 1)); }}
disabled={projectPage >= totalProjectPages - 1}
className="p-0.5 rounded hover:bg-muted disabled:opacity-30 transition-colors"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</span>
)}
</DropdownMenuLabel>
{paginatedProjects.map((project) => (
<DropdownMenuItem key={project.id} className="p-0">
<div className="flex items-center justify-between w-full">
<Link
to={`/builder`}
className="flex-1 px-2 py-1.5 cursor-pointer flex flex-col items-start gap-0.5"
>
<span className="font-medium truncate max-w-[160px]">{project.app_name}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(project.updated_at), { addSuffix: true })}
</span>
</Link>
<button
onClick={(e) => handleDeleteClick(e, project)}
className="p-1.5 mr-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete project"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/dashboard" className="cursor-pointer flex items-center gap-2 text-muted-foreground">
<FolderOpen className="w-4 h-4" />
View All Projects
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* CTA Buttons */}
<div className="hidden lg:flex items-center gap-2 xl:gap-3">
<ThemeToggle />
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="glass" className="gap-2">
<Avatar className="w-7 h-7 border border-primary/20">
<AvatarImage src={profile?.avatar_url || undefined} alt="Avatar" />
<AvatarFallback className="bg-gradient-to-br from-primary to-accent text-primary-foreground text-xs">
{getInitials(profile?.display_name)}
</AvatarFallback>
</Avatar>
<span className="max-w-[100px] truncate">
{displayName}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem asChild>
<Link to="/dashboard" className="cursor-pointer">
<Smartphone className="w-4 h-4 mr-2" />
My Projects
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/subscription" className="cursor-pointer">
<Coins className="w-4 h-4 mr-2" />
Plans & Credits
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings" className="cursor-pointer">
<Settings className="w-4 h-4 mr-2" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/help" className="cursor-pointer">
<HelpCircle className="w-4 h-4 mr-2" />
Help Center
</Link>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem asChild>
<Link to="/admin" className="cursor-pointer flex items-center justify-between w-full">
<span className="flex items-center">
<Shield className="w-4 h-4 mr-2" />
Admin Panel
</span>
<Badge variant="destructive" className="ml-2 text-[10px] px-1.5 py-0 h-4">
Admin
</Badge>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-destructive">
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{hasAdmin === false && (
<Button variant="outline" size="sm" asChild className="border-destructive/50 text-destructive hover:bg-destructive/10">
<Link to="/setup">
<Wrench className="w-4 h-4 mr-2" />
Setup Admin
</Link>
</Button>
)}
<Button variant="ghost" asChild>
<Link to="/auth">Sign In</Link>
</Button>
<Button variant="hero" size="lg" asChild>
<Link to="/auth">Start Free</Link>
</Button>
</>
)}
</div>
{/* Mobile Menu Toggle */}
<button
className="lg:hidden text-foreground"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Mobile Menu */}
{isOpen && (
<div className="lg:hidden pt-4 pb-2 space-y-4 animate-fade-in">
<a href="/#features" onClick={(e) => handleSmoothScroll(e, 'features')} className={cn("block py-2 cursor-pointer", getNavLinkClass('features'))}>Features</a>
<a href="/#how-it-works" onClick={(e) => handleSmoothScroll(e, 'how-it-works')} className={cn("block py-2 cursor-pointer", getNavLinkClass('how-it-works'))}>How It Works</a>
<a href="/#pricing" onClick={(e) => handleSmoothScroll(e, 'pricing')} className={cn("block py-2 cursor-pointer", getNavLinkClass('pricing'))}>Pricing</a>
<a href="/#platforms" onClick={(e) => handleSmoothScroll(e, 'platforms')} className={cn("block py-2 cursor-pointer", getNavLinkClass('platforms'))}>Platforms</a>
<div className="py-2">
<ThemeToggle />
</div>
<Link
to="/builder"
onClick={() => setIsOpen(false)}
className={cn(
"flex items-center gap-2 py-2 cursor-pointer",
location.pathname === '/builder'
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
)}
>
<Plus className="w-4 h-4" />
New App
</Link>
<Link
to="/dashboard"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2 py-2 cursor-pointer text-muted-foreground hover:text-foreground"
>
<FolderOpen className="w-4 h-4" />
My Projects
</Link>
<div className="flex flex-col gap-2 pt-4">
{user ? (
<>
<Button variant="glass" className="w-full" asChild>
<Link to="/dashboard">My Projects</Link>
</Button>
<Button variant="glass" className="w-full" asChild>
<Link to="/subscription">Plans & Credits</Link>
</Button>
<Button variant="glass" className="w-full" asChild>
<Link to="/settings">Settings</Link>
</Button>
<Button variant="glass" className="w-full" asChild>
<Link to="/help">
<HelpCircle className="w-4 h-4 mr-2" />
Help Center
</Link>
</Button>
{isAdmin && (
<Button variant="glass" className="w-full justify-between" asChild>
<Link to="/admin">
<span className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Admin Panel
</span>
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
Admin
</Badge>
</Link>
</Button>
)}
<Button variant="ghost" className="w-full" onClick={handleSignOut}>
Sign Out
</Button>
</>
) : (
<>
{hasAdmin === false && (
<Button variant="outline" className="w-full border-destructive/50 text-destructive hover:bg-destructive/10" asChild>
<Link to="/setup">
<Wrench className="w-4 h-4 mr-2" />
Setup Admin
</Link>
</Button>
)}
<Button variant="ghost" className="w-full" asChild>
<Link to="/auth">Sign In</Link>
</Button>
<Button variant="hero" className="w-full" asChild>
<Link to="/auth">Start Free</Link>
</Button>
</>
)}
</div>
</div>
)}
</div>
{/* Delete Project Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{projectToDelete?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</nav>
);
};
export default Navbar;