110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
import React from "react";
|
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
|
import { useAuth } from "../hooks/useAuth";
|
|
import { ThemePreference, useTheme } from "../hooks/useTheme";
|
|
import { api } from "../services/api";
|
|
import { Rocket, Sun, Moon, Monitor } from "lucide-react";
|
|
|
|
const themeOptions: { value: ThemePreference; label: string; Icon: typeof Sun }[] = [
|
|
{ value: "light", label: "Light", Icon: Sun },
|
|
{ value: "dark", label: "Dark", Icon: Moon },
|
|
{ value: "system", label: "System", Icon: Monitor },
|
|
];
|
|
|
|
export function Layout({ children }: { children: React.ReactNode }) {
|
|
const { user, refresh } = useAuth();
|
|
const { theme, setTheme } = useTheme();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
const handleLogout = async () => {
|
|
await api.auth.logout();
|
|
await refresh();
|
|
navigate("/login");
|
|
};
|
|
|
|
const navLink = (to: string, label: string) => (
|
|
<Link
|
|
to={to}
|
|
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
location.pathname === to || location.pathname.startsWith(to + "/")
|
|
? "bg-blue-600 text-white"
|
|
: "text-gray-700 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
{label}
|
|
</Link>
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col">
|
|
<header className="border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 sticky top-0 z-50">
|
|
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Link to="/" className="font-bold text-lg text-blue-600 dark:text-blue-400 hover:opacity-80 inline-flex items-center gap-1.5">
|
|
<Rocket size={20} /> PR Previews
|
|
</Link>
|
|
</div>
|
|
|
|
{user && (
|
|
<nav className="flex items-center gap-1 overflow-x-auto">
|
|
{navLink("/", "Overview")}
|
|
{navLink("/previews", "Previews")}
|
|
{navLink("/repos", "Repos")}
|
|
{navLink("/settings", "Settings")}
|
|
{user.isAdmin && navLink("/admin", "Admin")}
|
|
</nav>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div
|
|
className="inline-flex items-center rounded-lg border border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800 p-1"
|
|
role="group"
|
|
aria-label="Theme preference"
|
|
>
|
|
{themeOptions.map(({ value, label, Icon }) => {
|
|
const selected = theme === value;
|
|
return (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
onClick={() => setTheme(value)}
|
|
aria-label={`${label} theme`}
|
|
aria-pressed={selected}
|
|
title={`${label} theme`}
|
|
className={`inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-1 dark:focus-visible:ring-offset-slate-900 ${
|
|
selected
|
|
? "bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-300 shadow-sm"
|
|
: "text-gray-500 dark:text-slate-400 hover:text-gray-900 dark:hover:text-slate-100"
|
|
}`}
|
|
>
|
|
<Icon size={15} aria-hidden="true" />
|
|
<span className="hidden lg:inline">{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{user && (
|
|
<button
|
|
onClick={handleLogout}
|
|
className="text-sm px-3 py-1.5 rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors"
|
|
>
|
|
Logout
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
|
{children}
|
|
</main>
|
|
|
|
<footer className="border-t border-gray-200 dark:border-slate-700 py-4 text-center text-xs text-gray-500 dark:text-slate-400">
|
|
PR Previews — self-hosted preview environments for every pull request. {" "}
|
|
<Link to="/privacy" className="underline hover:text-gray-700 dark:hover:text-slate-200">Privacy Policy</Link>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|