owpengram-server/cmd/telesrv-admin/web/src/components/Layout.tsx
2026-08-26 03:06:57 +03:00

212 lines
8.5 KiB
TypeScript

import {
AtSign,
BadgeCheck,
Bot,
ChevronDown,
Database,
Film,
LayoutDashboard,
LogOut,
Megaphone,
MessageSquareText,
Settings,
ShieldAlert,
ShieldCheck,
Smile,
Stamp,
Users,
Sticker
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink";
export function BootScreen() {
return (
<div className="boot-screen">
<div className="brand compact brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span>
<strong>OwpenGram</strong>
<small>{"Admin Console"}</small>
</span>
</div>
<div className="loader-bar" />
</div>
);
}
export function Shell({
actor,
build,
route,
navigate,
onLogout,
children
}: {
actor: string;
build?: { commit: string; short_commit: string; dirty: boolean; build_time: string };
route: RouteState;
navigate: Navigate;
onLogout: () => void;
children: ReactNode;
}) {
// The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview);
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
const canManageServer = useCan(permissionServerManage);
// Server identity (name/icon) is admin-editable per Server Settings ->
// Server identity, and takes over the sidebar branding when set -- the
// operator's own server should look like their server, not like the
// "OwpenGram" reference build, once they've bothered to configure it.
// Only fetched for sessions that can even see Server Settings; a session
// without that permission just gets the default branding.
const [identity, setIdentity] = useState<{ name: string; iconExt?: string } | null>(null);
useEffect(() => {
if (!canManageServer) return;
api.serverIdentity()
.then((info) => setIdentity({ name: info.name, iconExt: info.icon_ext }))
.catch(() => undefined);
}, [canManageServer]);
const [brandIconFailed, setBrandIconFailed] = useState(false);
const brandName = identity?.name?.trim() || "OwpenGram";
const brandIconSrc = identity?.iconExt && !brandIconFailed ? api.serverIconURL() : "/logo.png";
// Third-party verification is additionally hidden by default (not fully
// finished) regardless of what the session was granted -- see permissions.tsx.
const thirdPartyVerificationHidden = useThirdPartyVerificationHidden();
const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
useEffect(() => {
if (messagesActive) {
setMessagesOpen(true);
}
}, [messagesActive]);
async function logout() {
await api.logout().catch(() => undefined);
onLogout();
}
return (
<div className="shell">
<aside className="sidebar">
<AppLink className="brand" href="/" navigate={navigate}>
<span className="brand-mark"><img src={brandIconSrc} alt={brandName} onError={() => setBrandIconFailed(true)} /></span>
<span>
<strong>{brandName}</strong>
<small>{"Admin Console"}</small>
</span>
</AppLink>
<div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}
{canReviewBotVerification && !thirdPartyVerificationHidden && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
type="button"
aria-expanded={messagesOpen}
onClick={() => setMessagesOpen((open) => !open)}
>
<MessageSquareText size={16} />
<span>{"Messages"}</span>
<ChevronDown className="nav-section-chevron" size={15} />
</button>
{messagesOpen && (
<div className="nav-children">
<NavLink
href="/messages/private"
route={route}
navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
>
{"Private"}
</NavLink>
<NavLink
href="/messages/groups"
route={route}
navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")}
>
{"Groups"}
</NavLink>
</div>
)}
</div>
{canManageServer && (
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
)}
</nav>
<div className="sidebar-status">
<span className="sidebar-label">{"Version: O7"}</span>
{build?.short_commit && (
<span className="sidebar-label sidebar-build" title={build.commit + (build.dirty ? " (uncommitted changes)" : "")}>
{`Build: ${build.short_commit}${build.dirty ? "+" : ""}`}
</span>
)}
</div>
</aside>
<div className="workspace">
<header className="topbar">
<div>
<h1>{routeTitle(route.path)}</h1>
</div>
<div className="topbar-actions">
<ThemeSwitch />
<span className="actor-pill">{`Actor: ${actor}`}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {"Log out"}
</button>
</div>
</header>
<main className="content">{children}</main>
</div>
</div>
);
}
function NavLink({
href,
route,
navigate,
icon,
children,
activeWhen
}: {
href: string;
route: RouteState;
navigate: Navigate;
icon?: ReactNode;
children: ReactNode;
activeWhen?: (path: string) => boolean;
}) {
const active = activeWhen ? activeWhen(route.path) : href === "/" ? route.path === "/" : route.path.startsWith(href);
return (
<AppLink className={`nav-item ${active ? "active" : ""}`} href={href} navigate={navigate}>
{icon ?? <span aria-hidden="true" className="nav-dot" />}
<span>{children}</span>
</AppLink>
);
}