AppForge: migrate off Supabase to external Postgres + Better Auth + local storage
- New Express + TypeScript backend (server/) with pg, Better Auth, local file storage - De-Supabased Postgres schema (server/db) and TS reimplementations of DB functions - Frontend data layer rewired to REST (rest-client + backend-client compat shim) - Removed all Supabase references (code, config, deps, docs) - New brand assets: gradient favicon/app icons + dark/white wordmark logos Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user