Select Git revision
postcss.config.js
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
DefaultLayout.tsx 28.61 KiB
import Link from "next/link";
import { useRouter } from "next/router";
import { MouseEvent, startTransition, useEffect, useState } from "react";
import { basePath } from "@/../basepath";
import { useBackendContext } from "./BackendProvider";
import { ApiError } from "@/api/ApiError";
import { useUserContext } from "./UserDataProvider";
import { useEditMode } from "./EditModeProvider";
import { useLanguage } from "./LanguageProvider";
import OverlayTrigger from "react-bootstrap/OverlayTrigger";
import Popover from "react-bootstrap/Popover";
import AnnouncementComponent from "./AnnouncementComponent";
import { storageGetOrDefault, storageSet } from "@/misc/Storage";
import { TooltipButton } from "@/misc/Util";
import { showError, showErrorToast, ErrorPage } from "@/misc/ErrorHandlers";
import { FallbackErrorBoundary } from "./FallbackErrorBoundary";
import { ReloadBoundary } from "@/components/ReloadBoundary";
import type React from "react";
import Collapse from "react-bootstrap/Collapse";
import Dropdown from "react-bootstrap/Dropdown";
import { StylizedText } from "./StylizedText";
import type { GetStatusResponse } from "@/api/api_v1_types";
function NavBarIcon({
children,
iconlib,
icon,
activeIcon,
url,
className,
}: {
children?: React.ReactNode;
iconlib: string;
icon: string;
activeIcon?: string;
url: string;
className?: string;
}) {
const router = useRouter();
const ENDPOINT_IS_ACTIVE = router.pathname.toLowerCase() === url.toLowerCase();
const effectiveIcon = ENDPOINT_IS_ACTIVE && activeIcon ? activeIcon : icon;
return (
<Link
className={
"nav-link p-2 rounded d-flex align-items-center justify-content-center " +
(ENDPOINT_IS_ACTIVE ? "active bg-primary " : "") +
className
}
href={url}
>
{iconlib === "bootstrap" ? (
<span aria-hidden="true" className={"me-1 bi bi-" + effectiveIcon} />
) : (
<></>
)}
{iconlib === "fa" ? (
<span aria-hidden="true" className={"me-1 fa fa-" + effectiveIcon} />
) : (
<></>
)}
{children}
</Link>
);
}
function UserField({ isUnavailable }: { isUnavailable: boolean }) {
const api = useBackendContext();
const [loginError, setLoginError] = useState("");
const userContext = useUserContext();
const [showPop, setShowPop] = useState(false);
const [forceReload, setForceReload] = useState(0);
const [triedRelogin, setTriedRelogin] = useState(false);
const { editMode, setEditMode } = useEditMode();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const { language } = useLanguage();
useEffect(() => {
if (!isUnavailable && triedRelogin === false && userContext.hasUserInfo() === false) {
api.getAuthenticationStatus()
.then((resp) => {
startTransition(() => {
setTriedRelogin(true);
if (resp.user_info) {
userContext.replace(resp.user_info);
setForceReload((f) => f + 1);
setShowPop(false);
} else {
userContext.replace(null);
}
});
})
.catch((e) => {
startTransition(() => {
setTriedRelogin(true);
userContext.replace(null);
console.error("Failed to get authentication status", e);
});
});
}
}, [api, triedRelogin, userContext, isUnavailable]);
const onFsmpiLogin = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const name = form.username.value;
const password = form.password.value;
setIsLoggingIn(true);
api.authenticateFsmpi({ username: name, password })
.then(
(resp) => {
userContext.replace(resp.user!);
setForceReload(forceReload + 1);
setShowPop(false);
setLoginError("");
},
(err) => {
if (err instanceof ApiError) {
setLoginError("Error: " + err.api_message);
} else {
setLoginError("Login failed " + err.message);
}
userContext.replace(null);
},
)
.then(() => {
setIsLoggingIn(false);
});
};
const onLogout = () => {
api.logout()
.then(() => {
userContext.replace(null);
setEditMode(false);
setForceReload(forceReload + 1);
})
.catch((e) => {
console.error(e);
showError(e, "Unable to log out");
});
};
if (isUnavailable) {
return (
<TooltipButton iconClassName="bi-box-arrow-in-right">
{language.get("ui.login.unavailable_message")}
</TooltipButton>
);
}
if (triedRelogin === false) {
return <div className="spinner-border text-primary me-2" role="status" />;
}
if (userContext.hasUserInfo() === false) {
const loginPop = (
<Popover>
<Popover.Header as="h3">Login für FSMPI</Popover.Header>
<Popover.Body>
<form onSubmit={onFsmpiLogin}>
<input
placeholder="User"
name="username"
type="text"
className="form-control mb-2"
/>
<input
placeholder="Password"
name="password"
type="password"
className="form-control mb-3"
/>
{loginError !== "" ? (
<div className="alert alert-danger" role="alert">
{loginError}
</div>
) : (
<></>
)}
<div className="d-flex align-items-center">
<input
type="submit"
value="Login"
className="btn btn-primary"
disabled={isLoggingIn}
/>
{isLoggingIn && <div className="spinner-border ms-3" />}
</div>
</form>
</Popover.Body>
</Popover>
);
return (
<OverlayTrigger trigger="click" placement="bottom" overlay={loginPop}>
<button className="btn" type="button" onClick={() => setShowPop(!showPop)}>
<span className="bi bi-box-arrow-in-right" />
</button>
</OverlayTrigger>
);
}
return (
<>
{userContext.canEditStuff() && (
<>
<div
className="form-check form-switch me-2"
title="Press 'e' to toggle edit mode"
>
<input
className="form-check-input"
type="checkbox"
role="switch"
id="flexSwitchCheckDefault"
onChange={() => {
setEditMode(!editMode);
}}
checked={editMode}
/>
<label className="form-check-label" htmlFor="flexSwitchCheckDefault">
Edit Mode
</label>
</div>
<div className="vr d-none d-lg-inline-block" />
</>
)}
<Dropdown className="ms-1">
<Dropdown.Toggle variant="" style={{ padding: "10px 6px" }}>
{userContext.getUserInfo()!.name} <span className="caret" />
</Dropdown.Toggle>
<Dropdown.Menu className="me-2">
<li>
<Dropdown.Item as={Link} href="/internal/dbstatus">
DB-Status
</Dropdown.Item>
</li>
<li>
<Dropdown.Item as={Link} href="/internal/sort/log">
Sortierlog
</Dropdown.Item>
</li>
<li>
<Dropdown.Item as={Link} href="/internal/changelog">
Changelog
</Dropdown.Item>
</li>
<li>
<Dropdown.Item as={Link} href="/internal/jobs/overview">
Jobs
</Dropdown.Item>
</li>
<li>
<Dropdown.Item as={Link} href="/internal/feedback">
Feedback
</Dropdown.Item>
</li>
<li className="dropdown-divider" />
<li>
<Dropdown.Item as={Link} href="/internal/user">
Settings
</Dropdown.Item>
</li>
<li className="dropdown-divider" />
<li>
<Dropdown.Item onClick={onLogout}>Logout</Dropdown.Item>
</li>
</Dropdown.Menu>
</Dropdown>
</>
);
}
function Search({ className }: { className?: string }) {
const router = useRouter();
const [query, setQuery] = useState("");
const { language } = useLanguage();
const onSearchSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
let form = e.currentTarget;
let query = form.query.value;
if (query === "") router.push("/search");
router.push("/search?q=" + encodeURIComponent(query));
};
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
return (
<form role="search" className={"d-flex " + (className ?? "")} onSubmit={onSearchSubmit}>
<div className="input-group">
<input
className="form-control"
type="text"
name="query"
placeholder={language.get("ui.generic.search")}
value={query}
onChange={onChange}
/>
<button className="btn btn-primary" type="submit">
<i className="bi bi-search" />
</button>
</div>
</form>
);
}
function NavBar({ status }: { status?: GetStatusResponse }) {
const [navbarOpen, setNavbarOpen] = useState(false);
const { language, setLanguageCode } = useLanguage();
useEffect(() => {
try {
let currentTheme = document.documentElement.getAttribute("data-bs-theme");
if (storageGetOrDefault("theme", currentTheme) !== currentTheme) {
document.documentElement.classList.add("no-transition");
document.documentElement.setAttribute(
"data-bs-theme",
storageGetOrDefault("theme", currentTheme),
);
document.documentElement.classList.remove("no-transition");
}
} catch (e) {
console.error("Could not load theme from local storage", e);
}
}, []);
const switchTheme = () => {
const theme = document.documentElement.getAttribute("data-bs-theme");
if (theme === "dark") {
document.documentElement.setAttribute("data-bs-theme", "light");
} else {
document.documentElement.setAttribute("data-bs-theme", "dark");
}
try {
storageSet("theme", theme === "dark" ? "light" : "dark");
} catch (e) {
console.error("Could not save theme to local storage", e);
}
};
const switchLanguage = () => {
setLanguageCode(language.getCode() === "de" ? "en" : "de");
};
const toggleNavbar = () => {
setNavbarOpen((o) => !o);
};
const switchToOldSite = (event: MouseEvent) => {
if (window.location.pathname.indexOf(basePath) !== -1) {
event.preventDefault();
let newPath = window.location.pathname;
if (!newPath.startsWith("/")) newPath = "/" + newPath;
window.location.href = "https://video.fsmpi.rwth-aachen.de" + newPath;
}
};
return (
<nav className={"hidden-print navbar navbar-expand-lg mb-3 bg-body-tertiary"}>
<div className="container-fluid">
<div className="d-flex d-lg-none flex-grow-1">
<Link className="navbar-brand" href="/" style={{ padding: "3px" }}>
<img
alt="VideoAG"
src={`${basePath}/static/logo.png`}
width={44}
height={44}
/>
</Link>
<NavBarIcon
iconlib="bootstrap"
icon="house-door-fill"
url="/"
className="flex-grow-1 text-center"
/>
<NavBarIcon
iconlib="bootstrap"
icon="film"
url="/courses"
className="flex-grow-1 text-center"
/>
<NavBarIcon
iconlib="bootstrap"
icon="question-circle-fill"
activeIcon="question-circle"
url="/faq"
className="flex-grow-1 text-center"
/>
<NavBarIcon
iconlib="bootstrap"
icon="envelope-fill"
activeIcon="envelope"
url="/feedback"
className="flex-grow-1 text-center"
/>
<a
className={
"nav-link p-2 rounded flex-grow-1 text-center d-flex align-items-center justify-content-center"
}
href={"/"}
onClick={switchToOldSite}
style={{ color: "#c66" }}
>
<span
aria-hidden="true"
className={"me-1 bi bi-arrow-down-left-square"}
></span>
</a>
</div>
<button
className="navbar-toggler ms-2"
type="button"
onClick={toggleNavbar}
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<Collapse in={navbarOpen} className={"navbar-collapse"}>
<div className="">
<div className="d-flex align-items-center w-100 flex-column flex-sm-row mt-3 mt-lg-0">
<ul className="navbar-nav align-items-center d-none d-lg-flex">
<Link
className="navbar-brand d-none d-lg-block"
href="/"
style={{ padding: "3px" }}
>
<img
alt="VideoAG"
src={`${basePath}/static/logo.png`}
width={44}
height={44}
/>
</Link>
<li className="nav-item">
<NavBarIcon
iconlib="bootstrap"
icon="house-door-fill"
activeIcon="house-door"
url="/"
className="mx-1"
>
Home
</NavBarIcon>
</li>
<li className="nav-item">
<NavBarIcon
iconlib="bootstrap"
icon="film"
url="/courses"
className="mx-1"
>
Videos
</NavBarIcon>
</li>
<li className="nav-item">
<NavBarIcon
iconlib="bootstrap"
icon="question-circle-fill"
activeIcon="question-circle"
url="/faq"
className="mx-1"
>
FAQ
</NavBarIcon>
</li>
<li className="nav-item">
<NavBarIcon
iconlib="bootstrap"
icon="envelope-fill"
activeIcon="envelope"
url="/feedback"
className="mx-1"
>
Feedback
</NavBarIcon>
</li>
<a
className={"nav-link p-2 rounded mx-1"}
href={"https://video.fsmpi.rwth-aachen.de"}
onClick={switchToOldSite}
style={{ color: "#c66" }}
>
<span
aria-hidden="true"
className={"me-1 bi bi-arrow-down-left-square"}
></span>
Zur alten Seite
</a>
</ul>
<div className="flex-fill d-none d-lg-block" />
<Search className="me-1 flex-sm-fill w-sm-auto" />
<div className="d-flex align-items-center">
<UserField isUnavailable={status?.status === "unavailable"} />
<div>
<button className={"btn"} type="button" onClick={switchTheme}>
<span aria-hidden="true" className={"bi bi-moon-stars"} />
</button>
</div>
<div>
<button
className={"btn"}
type="button"
onClick={switchLanguage}
>
<span aria-hidden="true" className={"bi bi-globe"} />
<small className="text-muted ms-1">
{language.getCode()}
</small>
</button>
</div>
</div>
</div>
</div>
</Collapse>
</div>
</nav>
);
}
function Footer({ status }: { status?: GetStatusResponse }) {
const { language } = useLanguage();
const userContext = useUserContext();
const hasUserInfo = userContext.hasUserInfo();
return (
<footer className={"footer bg-body-tertiary"}>
<div className="px-2 d-flex align-items-center">
<ul className="list-inline py-2 mb-0">
<li className="list-inline-item">
<Link href="/imprint">{language.get("ui.footer.imprint")}</Link>
</li>
<li className="list-inline-item">
<a href="http://www.vampir.rwth-aachen.de/">Vampir e.V.</a>
</li>
<li className="list-inline-item">
<a href="https://www.youtube.com/channel/UCxh5snRN7yZyBsytNbGNuEQ">
YouTube
</a>
</li>
<li className="list-inline-item">
<a href="https://www.facebook.com/videoag">Facebook</a>
</li>
<li className="list-inline-item">
<a href="https://twitter.com/rwthvideo">
{language.get("ui.footer.twitter")}
</a>
</li>
<li className="list-inline-item">
<Link href="/licenses">Licenses</Link>
</li>
</ul>
<div className="flex-fill" />
{hasUserInfo ? (
<>
<div className="d-block">
Front:{" "}
<a
href={
"https://git.fsmpi.rwth-aachen.de/videoag/frontend/-/tree/" +
(process.env.NEXT_PUBLIC_GIT_COMMIT_HASH ?? "live_production")
}
>
{process.env.NEXT_PUBLIC_GIT_COMMIT_HASH ?? "unknown"}
</a>
; Back:{" "}
<a
href={
"https://git.fsmpi.rwth-aachen.de/videoag/backend_api/-/tree/" +
(status?.git_commit_hash ?? "live_production")
}
>
{status?.git_commit_hash ?? "unknown"}
</a>
, {status?.status ?? "unknown"}
</div>
</>
) : (
<>
<div className="d-none d-md-block">
<StylizedText markdown mapParagraphToSpan>
{language.get("ui.footer.advertisement")}
</StylizedText>
</div>
<div className="d-md-none">
<StylizedText markdown mapParagraphToSpan>
{language.get("ui.footer.advertisement_short")}
</StylizedText>
</div>
</>
)}
</div>
</footer>
);
}
export default function DefaultLayout({ children }: { children: React.ReactNode }) {
const route = useRouter().pathname;
const api = useBackendContext();
const userContext = useUserContext();
const hasUserInfo = userContext.hasUserInfo();
const { setEditMode } = useEditMode();
const { language } = useLanguage();
const [status, setStatus] = useState<GetStatusResponse>();
const [forceReload, setForceReload] = useState(0);
const isEmbed = route === "/dynamic_embed";
useEffect(() => {
if (isEmbed) return;
const onKeydown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
return;
if (e.key === "e" && userContext.canEditStuff()) {
setEditMode((e) => !e);
}
};
document.addEventListener("keydown", onKeydown);
return () => {
document.removeEventListener("keydown", onKeydown);
};
}, [isEmbed, setEditMode, userContext]);
useEffect(() => {
api.getStatus()
.then(setStatus)
.catch((error) => {
//TODO
console.error("Error while fetching status", error);
});
}, [api, hasUserInfo, forceReload]);
const isUnavailable = status?.status === "unavailable";
if (isEmbed) {
//TODO announcements
if (isUnavailable) {
return (
<div
className={"hidden-print alert d-flex align-items-center alert-info"}
role="alert"
>
{language.get("site.unavailable_message")}
</div>
);
}
return <div style={{ width: "100vw", height: "100vh" }}>{children}</div>;
}
return (
<div className="d-flex flex-column" style={{ minHeight: "100vh" }}>
<ReloadBoundary reloadFunc={() => setForceReload((v) => v + 1)}>
<NavBar status={status} />
<div className="container-fluid mb-2 flex-grow-1">
<div className="row">
<div className="col-12 offset-lg-1 col-lg-10">
<AnnouncementComponent
announcements={status?.announcements ?? []}
forceShowHidden={isUnavailable}
/>
{status?.status === "unavailable" ? (
<div
className={
"hidden-print alert d-flex align-items-center alert-info"
}
role="alert"
>
{language.get("site.unavailable_message")}
</div>
) : (
<ReloadBoundary
reloadFunc={() => {
if (process.env.NODE_ENV === "development") {
showErrorToast(
"Reload boundary without reload! (Application is now in inconsistent state, you should reload)",
);
} else {
window.location.reload();
}
}}
>
<FallbackErrorBoundary
fallback={(e: any) => (
<ErrorPage error={e} objectName="Seite" />
)}
>
{children}
</FallbackErrorBoundary>
</ReloadBoundary>
)}
</div>
</div>
</div>
<Footer status={status} />
{
status?.is_debug === true && (
<div className="watermark">debug mode</div>
) /* You can disable debug mode in the configuration file of the backend api */
}
</ReloadBoundary>
</div>
);
}