Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { brandColor } from "@/lib/siteConfig";
|
||||
|
||||
export const BottomNav = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { settings } = useSiteSettings();
|
||||
const accentColor = settings.accent_color || brandColor;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href.includes("#")) return false;
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname.startsWith(href);
|
||||
};
|
||||
|
||||
type NavItem = { href: string; label: string; icon: React.ElementType; onClick?: () => void };
|
||||
|
||||
// Auth items are only known once the token has been validated — omitting
|
||||
// them entirely while loading avoids a Login/Register -> Dashboard/Logout
|
||||
// flash on every load (see Navbar for the same pattern).
|
||||
const items: NavItem[] = [
|
||||
{ href: "/", label: "Home", icon: Home },
|
||||
{ href: "/events", label: "Events", icon: Calendar },
|
||||
{ href: "/#contact", label: "Contact", icon: Phone },
|
||||
...(authLoading
|
||||
? []
|
||||
: user
|
||||
? [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "#", label: "Logout", icon: LogOut, onClick: handleLogout },
|
||||
]
|
||||
: [
|
||||
{ href: "/login", label: "Login", icon: LogIn },
|
||||
{ href: "/register", label: "Register", icon: UserPlus },
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="md:hidden fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<div className="flex items-stretch h-16">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.href);
|
||||
const color = active ? accentColor : "#9ca3af";
|
||||
|
||||
if (item.onClick) {
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={item.onClick}
|
||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
|
||||
style={{ color }}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
|
||||
style={{ color }}
|
||||
>
|
||||
<Icon size={22} strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
|
||||
export const Footer = () => {
|
||||
const { settings } = useSiteSettings();
|
||||
const displayName = settings.org_name || appName;
|
||||
|
||||
return (
|
||||
<>
|
||||
<footer className="bg-gray-900 text-white py-6 text-center text-sm">
|
||||
<p>© {new Date().getFullYear()} {displayName}. All rights reserved.</p>
|
||||
<div className="mt-2 space-x-4">
|
||||
<Link href="/legal/terms" className="hover:underline text-gray-300">
|
||||
Terms of Use
|
||||
</Link>
|
||||
<Link href="/legal/privacy" className="hover:underline text-gray-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
|
||||
<div className="h-16 md:hidden" aria-hidden="true" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import churchLogo from "@/app/church_logo.jpg";
|
||||
import { appName, brandColor } from "@/lib/siteConfig";
|
||||
import { resolveToApiOrigin } from "@/lib/api";
|
||||
|
||||
type NavLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { settings, loading: settingsLoading } = useSiteSettings();
|
||||
const displayName = settings.org_name || appName;
|
||||
const displayColor = settings.accent_color || brandColor;
|
||||
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const navLinks: NavLink[] = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/events", label: "Events" },
|
||||
{ href: "/#contact", label: "Contact" },
|
||||
];
|
||||
|
||||
const authLinks: NavLink[] = user
|
||||
? [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{
|
||||
href: "#",
|
||||
label: "Logout",
|
||||
onClick: handleLogout,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ href: "/login", label: "Login" },
|
||||
{ href: "/register", label: "Register" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bg-white shadow-md sticky top-0 z-50">
|
||||
<nav className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
{settingsLoading ? (
|
||||
<div className="h-10 w-10 rounded-sm bg-gray-100 animate-pulse" />
|
||||
) : logoSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoSrc} alt="Logo" className="h-10 w-auto object-contain rounded-sm" />
|
||||
) : (
|
||||
<Image
|
||||
src={churchLogo}
|
||||
alt="Church logo"
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded-sm object-contain"
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
{!settingsLoading && (
|
||||
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
{navLinks.map(link => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Auth links are only known once the token has been validated —
|
||||
rendering nothing here (instead of guessing "logged out") avoids
|
||||
a Login/Register -> Dashboard/Logout flash on every load. */}
|
||||
{!authLoading && authLinks.map(link =>
|
||||
link.onClick ? (
|
||||
<button
|
||||
key={link.label}
|
||||
onClick={link.onClick}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-gray-700 hover:text-blue-600"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<BottomNav />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { RoleBadge } from "@/components/shared/RoleBadge";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
const navLinks: { href: string; label: string; roles?: string[] }[] = [
|
||||
{ href: "/dashboard/user", label: "My Events", roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/user/profile", label: "Profile & Security", roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", roles: ["supervisor"] },
|
||||
{ href: "/dashboard/admin", label: "Admin", roles: ["admin"] },
|
||||
{ href: "/dashboard/admin/settings", label: "Site Settings", roles: ["admin"] }
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { user } = useAuth();
|
||||
const role = user?.role || "user";
|
||||
|
||||
return (
|
||||
<aside className="w-60 shrink-0 border-r bg-white p-4">
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold">Dashboard</div>
|
||||
{user && (
|
||||
<div className="mt-1 text-xs text-gray-600 flex items-center space-x-2">
|
||||
<span>{user.name}</span>
|
||||
<RoleBadge role={role as any} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{navLinks
|
||||
.filter((l) => !l.roles || l.roles.includes(role))
|
||||
.map((l) => (
|
||||
<Link key={l.href} className="block rounded px-3 py-2 text-sm hover:bg-gray-50" href={l.href}>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar() {
|
||||
const { user } = useAuth();
|
||||
const router = useRouter();
|
||||
const role = user?.role || "user";
|
||||
const pathname = usePathname();
|
||||
|
||||
const options = navLinks.filter((l) => !l.roles || l.roles.includes(role));
|
||||
|
||||
// Determine selected value based on current path or default by role
|
||||
// Use longest matching href to handle nested paths (e.g. /dashboard/user/profile over /dashboard/user)
|
||||
const selectedFromPath = options
|
||||
.filter((o) => pathname?.startsWith(o.href))
|
||||
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
|
||||
const roleDefault: Record<string, string> = {
|
||||
admin: "/dashboard/admin",
|
||||
supervisor: "/dashboard/supervisor",
|
||||
staff: "/dashboard/staff",
|
||||
user: "/dashboard/user",
|
||||
};
|
||||
const selectedValue = selectedFromPath || roleDefault[role] || options[0]?.href || "";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value && value !== selectedValue) router.push(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">Dashboard</div>
|
||||
{user && (
|
||||
<div className="mt-0.5 text-xs text-gray-600 flex items-center space-x-2">
|
||||
<span>{user.name}</span>
|
||||
<RoleBadge role={role as any} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="mobile-dashboard-nav">Navigate dashboard</label>
|
||||
<select
|
||||
id="mobile-dashboard-nav"
|
||||
className="mt-3 w-full rounded-md border-gray-300 text-sm py-2 px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{/* Keep placeholder hidden if a value is selected */}
|
||||
{!selectedValue && (
|
||||
<option value="" disabled>
|
||||
Select section...
|
||||
</option>
|
||||
)}
|
||||
{options.map((l) => (
|
||||
<option key={l.href} value={l.href}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user