415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
import { useEffect, useRef, useState, type SVGProps } from "react";
|
|
import { useI18n } from "./I18nProvider";
|
|
import logo from "../../img/logo.png";
|
|
|
|
type SessionUser = { id: string; email: string; role: string; status: string };
|
|
|
|
function Icon({ name }: { name: string }) {
|
|
const common: SVGProps<SVGSVGElement> = {
|
|
width: 16,
|
|
height: 16,
|
|
stroke: "currentColor",
|
|
fill: "none",
|
|
strokeWidth: 1.6,
|
|
strokeLinecap: "round",
|
|
strokeLinejoin: "round",
|
|
};
|
|
switch (name) {
|
|
case "profile":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<circle cx="12" cy="8" r="4" />
|
|
<path d="M5 20c0-3.3 3.1-6 7-6s7 2.7 7 6" />
|
|
</svg>
|
|
);
|
|
case "list":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M9 6h11" />
|
|
<path d="M9 12h11" />
|
|
<path d="M9 18h11" />
|
|
<path d="M4 6h0.01" />
|
|
<path d="M4 12h0.01" />
|
|
<path d="M4 18h0.01" />
|
|
</svg>
|
|
);
|
|
case "plus":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M12 5v14" />
|
|
<path d="M5 12h14" />
|
|
</svg>
|
|
);
|
|
case "logout":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3" />
|
|
<path d="M16 17l5-5-5-5" />
|
|
<path d="M21 12H9" />
|
|
</svg>
|
|
);
|
|
case "login":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M15 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3" />
|
|
<path d="M10 17l5-5-5-5" />
|
|
<path d="M15 12H3" />
|
|
</svg>
|
|
);
|
|
case "users":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
);
|
|
case "check":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M20 6L9 17l-5-5" />
|
|
</svg>
|
|
);
|
|
case "globe":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M3 12h18" />
|
|
<path d="M12 3a15.3 15.3 0 0 1 4 9 15.3 15.3 0 0 1-4 9 15.3 15.3 0 0 1-4-9 15.3 15.3 0 0 1 4-9z" />
|
|
</svg>
|
|
);
|
|
case "monitor":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<rect x="3" y="4" width="18" height="14" rx="2" ry="2" />
|
|
<path d="M9 20h6" />
|
|
<path d="M9 14l2-3 2 2 2-4" />
|
|
</svg>
|
|
);
|
|
case "settings":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l0.06 0.06a2 2 0 1 1-2.83 2.83l-0.06-0.06A1.65 1.65 0 0 0 15 19.4a1.65 1.65 0 0 0-1 .6 1.65 1.65 0 0 0-.33 1.82l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 8.6 15a1.65 1.65 0 0 0-1.82-.33l-0.06 0.06a2 2 0 1 1-2.83-2.83l0.06-0.06A1.65 1.65 0 0 0 5 9.4a1.65 1.65 0 0 0-.6-1 1.65 1.65 0 0 0-1.82-.33l-0.06-0.06a2 2 0 1 1 2.83-2.83l0.06 0.06A1.65 1.65 0 0 0 9 5a1.65 1.65 0 0 0 .33-1.82l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 15 8.6a1.65 1.65 0 0 0 1 .6 1.65 1.65 0 0 0 1.82-.33l0.06-0.06a2 2 0 1 1 2.83 2.83l-0.06 0.06A1.65 1.65 0 0 0 19.4 15z" />
|
|
</svg>
|
|
);
|
|
case "admin":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M12 2l7 4v6c0 5-3 9-7 10-4-1-7-5-7-10V6l7-4z" />
|
|
<path d="M9.5 12.5l1.8 1.8 3.7-3.7" />
|
|
</svg>
|
|
);
|
|
case "chevron-down":
|
|
return (
|
|
<svg {...common} viewBox="0 0 24 24" aria-hidden>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default function NavBar() {
|
|
const { t, locale, setLocale } = useI18n();
|
|
const [user, setUser] = useState<SessionUser | null>(null);
|
|
const [pendingCount, setPendingCount] = useState<number>(0);
|
|
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
|
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
const adminMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
async function loadUser() {
|
|
try {
|
|
const res = await fetch("/api/auth/me", { cache: "no-store" });
|
|
const data = await res.json();
|
|
if (data.user) setUser(data.user);
|
|
else setUser(null);
|
|
} catch (e) {
|
|
setUser(null);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadUser();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!user) {
|
|
setAdminMenuOpen(false);
|
|
setUserMenuOpen(false);
|
|
}
|
|
}, [user]);
|
|
|
|
useEffect(() => {
|
|
const role = user?.role;
|
|
const canSeeApprovals =
|
|
role === "ADMIN" ||
|
|
role === "LISTING_MODERATOR" ||
|
|
role === "USER_MODERATOR";
|
|
if (!canSeeApprovals) {
|
|
setPendingCount(0);
|
|
return;
|
|
}
|
|
fetch("/api/admin/pending/count", { cache: "no-store" })
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
if (data && typeof data.total === "number") setPendingCount(data.total);
|
|
})
|
|
.catch(() => setPendingCount(0));
|
|
}, [user]);
|
|
|
|
async function logout() {
|
|
try {
|
|
await fetch("/api/auth/logout", { method: "POST" });
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
setUser(null);
|
|
}
|
|
|
|
const isAdmin = user?.role === "ADMIN";
|
|
const isListingMod = user?.role === "LISTING_MODERATOR";
|
|
const isUserMod = user?.role === "USER_MODERATOR";
|
|
const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod));
|
|
const showAdminMenu = Boolean(user && (showApprovals || isAdmin));
|
|
|
|
useEffect(() => {
|
|
if (!userMenuOpen && !adminMenuOpen) return;
|
|
const onMouseDown = (e: MouseEvent) => {
|
|
const target = e.target as Node | null;
|
|
const insideAdmin =
|
|
adminMenuRef.current && target && adminMenuRef.current.contains(target);
|
|
const insideUser =
|
|
userMenuRef.current && target && userMenuRef.current.contains(target);
|
|
if (!insideAdmin) setAdminMenuOpen(false);
|
|
if (!insideUser) setUserMenuOpen(false);
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setAdminMenuOpen(false);
|
|
setUserMenuOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onMouseDown);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onMouseDown);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [adminMenuOpen, userMenuOpen]);
|
|
|
|
return (
|
|
<header
|
|
style={{
|
|
padding: "12px 20px",
|
|
borderBottom: "1px solid #eee",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
|
<Link href="/" className="brand">
|
|
<Image
|
|
src={logo}
|
|
alt="Lomavuokraus.fi logo"
|
|
width={34}
|
|
height={48}
|
|
priority
|
|
style={{ width: 34, height: "auto" }}
|
|
/>
|
|
<span className="brand-text">{t("brand")}</span>
|
|
</Link>
|
|
<Link href="/listings" className="button secondary">
|
|
<Icon name="list" /> {t("navBrowse")}
|
|
</Link>
|
|
</div>
|
|
<nav style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
|
{user ? (
|
|
<>
|
|
<div className="nav-admin" ref={userMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
aria-haspopup="menu"
|
|
aria-expanded={userMenuOpen}
|
|
onClick={() => setUserMenuOpen((v) => !v)}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div style={{ display: "grid", gap: 2, textAlign: "left" }}>
|
|
<span style={{ fontWeight: 600 }}>{user.email}</span>
|
|
<span style={{ fontSize: 12, opacity: 0.7 }}>
|
|
{user.role}
|
|
</span>
|
|
</div>
|
|
<Icon name="chevron-down" />
|
|
</button>
|
|
{userMenuOpen ? (
|
|
<div
|
|
className="nav-admin-menu"
|
|
role="menu"
|
|
aria-label={t("navProfile")}
|
|
>
|
|
<Link
|
|
href="/me"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setUserMenuOpen(false)}
|
|
>
|
|
<Icon name="profile" /> {t("navProfile")}
|
|
</Link>
|
|
<Link
|
|
href="/listings/mine"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setUserMenuOpen(false)}
|
|
>
|
|
<Icon name="list" /> {t("navMyListings")}
|
|
</Link>
|
|
<Link
|
|
href="/listings/new"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setUserMenuOpen(false)}
|
|
>
|
|
<Icon name="plus" /> {t("navNewListing")}
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
setUserMenuOpen(false);
|
|
logout();
|
|
}}
|
|
>
|
|
<Icon name="logout" /> {t("navLogout")}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{showAdminMenu ? (
|
|
<div className="nav-admin" ref={adminMenuRef}>
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
aria-haspopup="menu"
|
|
aria-expanded={adminMenuOpen}
|
|
onClick={() => setAdminMenuOpen((v) => !v)}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<Icon name="admin" /> {t("navAdmin")}
|
|
{showApprovals && pendingCount > 0 ? (
|
|
<span
|
|
className="nav-admin-badge"
|
|
aria-label={t("approvalsPending", {
|
|
count: pendingCount,
|
|
})}
|
|
>
|
|
{t("approvalsBadge", { count: pendingCount })}
|
|
</span>
|
|
) : null}
|
|
<Icon name="chevron-down" />
|
|
</button>
|
|
{adminMenuOpen ? (
|
|
<div
|
|
className="nav-admin-menu"
|
|
role="menu"
|
|
aria-label={t("navAdmin")}
|
|
>
|
|
{showApprovals ? (
|
|
<Link
|
|
href="/admin/pending"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setAdminMenuOpen(false)}
|
|
>
|
|
<Icon name="check" /> {t("navApprovals")}
|
|
{pendingCount > 0 ? (
|
|
<span
|
|
className="nav-admin-badge"
|
|
aria-label={t("approvalsPending", {
|
|
count: pendingCount,
|
|
})}
|
|
>
|
|
{t("approvalsBadge", { count: pendingCount })}
|
|
</span>
|
|
) : null}
|
|
</Link>
|
|
) : null}
|
|
{isAdmin ? (
|
|
<Link
|
|
href="/admin/users"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setAdminMenuOpen(false)}
|
|
>
|
|
<Icon name="users" /> {t("navUsers")}
|
|
</Link>
|
|
) : null}
|
|
{isAdmin ? (
|
|
<Link
|
|
href="/admin/monitor"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setAdminMenuOpen(false)}
|
|
>
|
|
<Icon name="monitor" /> {t("navMonitoring")}
|
|
</Link>
|
|
) : null}
|
|
{isAdmin ? (
|
|
<Link
|
|
href="/admin/settings"
|
|
className="nav-admin-item button secondary"
|
|
role="menuitem"
|
|
onClick={() => setAdminMenuOpen(false)}
|
|
>
|
|
<Icon name="settings" /> {t("navSettings")}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Link href="/auth/login" className="button secondary">
|
|
<Icon name="login" /> {t("navLogin")}
|
|
</Link>
|
|
<Link href="/auth/register" className="button">
|
|
<Icon name="plus" /> {t("navSignup")}
|
|
</Link>
|
|
</>
|
|
)}
|
|
<label className="language-wrapper">
|
|
<select
|
|
aria-label={t("navLanguage")}
|
|
value={locale}
|
|
onChange={(e) => setLocale(e.target.value as any)}
|
|
className="language-select"
|
|
>
|
|
<option value="fi">🇫🇮 Suomi</option>
|
|
<option value="sv">🇸🇪 Svenska</option>
|
|
<option value="en">🇬🇧 English</option>
|
|
</select>
|
|
</label>
|
|
</nav>
|
|
</header>
|
|
);
|
|
}
|