diff --git a/lang/de.slf b/lang/de.slf index 9e333944cc0d124a9a14f0001622f0ce0858d93c..d977c90cd12d6ba74c7e040190d6f837e0f30093 100644 --- a/lang/de.slf +++ b/lang/de.slf @@ -185,8 +185,6 @@ ui.download_manager.quality = "Qualität" // ui.download_manager.close = #ui.generic.close ui.video_player.back_to_course = "Zur Veranstaltungsseite" -ui.video_player.suggest_chapter = "Kapitelmarker vorschlagen" -ui.video_player.add_chapter = "Neues Kapitel" ui.video_player.embed = "Einbetten" ui.video_player.login_required = "Anmeldung erforderlich" // ui.video_player.login = #ui.generic.login @@ -197,10 +195,28 @@ ui.video_player.login_rwth.description = "Für RWTH-Angehörige und aus dem RWTH ui.video_player.login_moodle.description = "Für Teilnehmer der Veranstaltung verfügbar" ui.video_player.login_moodle.not_in_course = "Du bist kein Teilnehmer des Moodle-Kurses!" ui.video_player.login_moodle.refresh_course = "Kurse aktualisieren" +ui.video_player.login_oauth.finished_no_access = "OAuth erfolgreich allerdings hast Du keine Berechtigung für diese Vorlesung" +ui.video_player.login_oauth.unfinished_popup_closed = "Popup wurde geschlossen bevor der OAuth fertig war" +ui.video_player.login_oauth.waiting_for_finish = """ + Bitte schließe den OAuth in dem neuen Fenster ab, welches sich geöffnet hat, und schließe es dann. + """ +ui.video_player.login_oauth.error_start = "Fehler beim Starten des OAuth" +ui.video_player.login_oauth.error_finish = "Fehler beim Beenden des OAuth" +ui.video_player.login_oauth.error_no_popup = """ + Popup Fenster kann nicht geöffnet werden, OAuth kann nicht gestartet werden. Bitte sende einen Bugreport an \ + video@fsmpi.rwth-aachen.de + """ ui.video_player.student_council_only = "Nur für Fachschaftler verfügbar." ui.video_player.next_lecture = "Nächste Vorlesung" ui.video_player.previous_lecture = "Vorherige Vorlesung" +ui.chapter_suggestion.suggest = "Kapitel vorschlagen" +ui.chapter_suggestion.create = "Kapitel erstellen" +ui.chapter_suggestion.popup.send_suggestion = "Vorschlagen" +ui.chapter_suggestion.popup.send_create = "Erstellen" +ui.chapter_suggestion.popup.invalid_time_message = "Muss im Format 'hh:mm:ss' oder 'mm:ss' sein" +ui.chapter_suggestion.popup.received_suggestion = "Wir haben deinen Kapitelvorschlag erfolgreich erhalten. Vielen Dank!" + ui.feedback.title = "Feedback" ui.feedback.sent_successfully = "Nachricht wurde erfolgreich gesendet. Vielen Dank!" ui.feedback.info_text = """ diff --git a/lang/en.slf b/lang/en.slf index a8ddcc87086456685f2525e93c50ad4a0db4306b..d3536438b354e2e2b3632476335902d2cb26e1f0 100644 --- a/lang/en.slf +++ b/lang/en.slf @@ -184,8 +184,6 @@ ui.download_manager.download_now = #ui.generic.download ui.download_manager.close = #ui.generic.close ui.video_player.back_to_course = "Back to course" -ui.video_player.suggest_chapter = "Suggest chapter" -ui.video_player.add_chapter = "Add chapter" ui.video_player.embed = "Embed" ui.video_player.login_required = "Login required" ui.video_player.login = #ui.generic.login @@ -196,10 +194,25 @@ ui.video_player.login_rwth.description = "Available to RWTH members and in the R ui.video_player.login_moodle.description = "Available to participants of the Moodle course" ui.video_player.login_moodle.not_in_course = "You are not enrolled in the Moodle course" ui.video_player.login_moodle.refresh_course = "Refresh course" +ui.video_player.login_oauth.finished_no_access = "OAuth finished but you don't have permissions to access this lecture" +ui.video_player.login_oauth.unfinished_popup_closed = "Popup closed without finishing OAuth" +ui.video_player.login_oauth.waiting_for_finish = "Please finish the OAuth in the new window which has opened, then close it." +ui.video_player.login_oauth.error_start = "Error while starting OAuth" +ui.video_player.login_oauth.error_finish = "Error while finishing OAuth" +ui.video_player.login_oauth.error_no_popup = """ + Cannot open popup window, cannot start OAuth. Please send a bug report to video@fsmpi.rwth-aachen.de + """ ui.video_player.student_council_only = "Only available to student council members" ui.video_player.next_lecture = "Next lecture" ui.video_player.previous_lecture = "Previous lecture" +ui.chapter_suggestion.suggest = "Suggest Chapter" +ui.chapter_suggestion.create = "Create Chapter" +ui.chapter_suggestion.popup.send_suggestion = "Suggest" +ui.chapter_suggestion.popup.send_create = "Create" +ui.chapter_suggestion.popup.invalid_time_message = "Must have format 'hh:mm:ss' or 'mm:ss'" +ui.chapter_suggestion.popup.received_suggestion = "We successfully received your chapter suggestion. Thank You!" + ui.feedback.title = "Feedback" ui.feedback.sent_successfully = "Message was sent successfully. Thank you!" ui.feedback.info_text = """ diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index bc0550fef1f9ec7729645b20413b86de92ed2cd4..cb2267b678db2b9b46f6452fea8f32c2b68dc627 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,7 +3,7 @@ import { ToastContainer } from "react-toastify"; import Head from "next/head"; import "react-toastify/dist/ReactToastify.css"; -import { RealUserProvider } from "@/videoag/authentication/UserDataProvider"; +import { AuthStatusProvider } from "@/videoag/authentication/AuthStatus"; import { showErrorToast } from "@/videoag/error/ErrorDisplay"; import { LanguageProvider } from "@/videoag/localization/LanguageProvider"; import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; @@ -38,7 +38,7 @@ export default function App({ Component, pageProps }: AppProps) { }} > <RealBackendProvider> - <RealUserProvider> + <AuthStatusProvider> <EditModeProvider> <LanguageProvider> <ToastContainer /> @@ -47,7 +47,7 @@ export default function App({ Component, pageProps }: AppProps) { </DefaultLayout> </LanguageProvider> </EditModeProvider> - </RealUserProvider> + </AuthStatusProvider> </RealBackendProvider> </ReloadBoundary> </> diff --git a/src/pages/courses.tsx b/src/pages/courses.tsx index 179866c0f960173969b99d5f8b7e9a12bef4617d..aa83c34142ab16507f264b516c19a940a5b8cbb8 100644 --- a/src/pages/courses.tsx +++ b/src/pages/courses.tsx @@ -5,7 +5,7 @@ import DropdownItem from "react-bootstrap/DropdownItem"; import type { GetCoursesResponse, course } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import { ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; @@ -182,8 +182,8 @@ function CourseList({ export default function Courses() { const api = useApi(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); const { editMode } = useEditMode(); const [courseData, setCourseData] = useState<ResourceType<GetCoursesResponse>>(); const nextCoursesDataPromise = useRef<Promise<GetCoursesResponse> | null>(null); diff --git a/src/pages/dynamic_course_listing.tsx b/src/pages/dynamic_course_listing.tsx index 3fa6428fe4d3abb2ec6fc9031493e46cfda55355..e1955070fc84fbd0d894b742c6aba127cfe33ded 100644 --- a/src/pages/dynamic_course_listing.tsx +++ b/src/pages/dynamic_course_listing.tsx @@ -3,7 +3,7 @@ import { usePathname } from "next/navigation"; import { GetCourseResponse } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import CourseListing from "@/videoag/course/CourseListing"; import { Four04, ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; @@ -12,8 +12,8 @@ import { ResourceType, fetchDataWrapper } from "@/videoag/miscellaneous/PromiseH function ActualRedirector() { const api = useApi(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); const pathname = usePathname(); const matchedParams = pathname.match(/^\/([a-z0-9_-]+)\/?$/); const [courseData, setCourseData] = useState<ResourceType<GetCourseResponse>>(); diff --git a/src/pages/dynamic_player.tsx b/src/pages/dynamic_player.tsx index ee9181a2a6f63531766deab302847abc72f5148a..9d8a347abd862509ec5c67d7d8da85079f5ce886 100644 --- a/src/pages/dynamic_player.tsx +++ b/src/pages/dynamic_player.tsx @@ -1,10 +1,10 @@ import { Suspense, useEffect, useRef, useState } from "react"; import { usePathname } from "next/navigation"; -import { AuthenticationStatusResponse, GetCourseResponse } from "@/videoag/api/types"; +import { course, lecture } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; import { Backend } from "@/videoag/api/Backend"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import Player, { EmbeddedPlayer } from "@/videoag/course/Player"; import { Four04, ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; @@ -12,49 +12,38 @@ import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import { ResourceType, fetchDataWrapper } from "@/videoag/miscellaneous/PromiseHelpers"; export interface PlayerData { - course: GetCourseResponse; - perms: AuthenticationStatusResponse; - loaderHadUserInfo: boolean; - lectureId: number; + course: course; + lecture: lecture; } async function dataLoader( api: Backend, course_handle: string, lecture_id: string, - hasUserInfo: boolean, ): Promise<PlayerData> { - let course: GetCourseResponse = await api.getCourse(course_handle, true); + let course: course = await api.getCourse(course_handle, true); let lecture = course.lectures!.find((l) => l.id === parseInt(lecture_id)); if (lecture === undefined) { throw new Error("Lecture not found"); } - let perms: AuthenticationStatusResponse = { - authenticated_methods: ["public"], - is_lecture_authenticated: true, - }; - if ( - lecture.authentication_methods.includes("public") === false && - lecture.authentication_methods.length > 0 - ) { - perms = await api.getAuthenticationStatus({ lecture_id: lecture.id }); - } - return { course, perms, loaderHadUserInfo: hasUserInfo, lectureId: parseInt(lecture_id) }; + return { course, lecture }; } // This redirector handles both the regular player and the embedded player export function ActualRedirector() { const api = useApi(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); + const currentlyAuthenticatedLectureId = authStatus.getCurrentlyAuthenticatedLectureId(); const pathname = usePathname(); - const matchedParamsPlayer = pathname.match(/^\/([a-z0-9_-]+)\/([a-z0-9_-]+)$/); - const matchedParamsEmbed = pathname.match(/^\/([a-z0-9_-]+)\/([a-z0-9_-]+)\/embed$/); const [playerData, setPlayerData] = useState<ResourceType<PlayerData>>(); const nextPlayerDataPromise = useRef<Promise<PlayerData> | null>(null); const [isReloading, setIsReloading] = useState(false); + const matchedParamsPlayer = pathname.match(/^\/([a-z0-9_-]+)\/([a-z0-9_-]+)$/); + const matchedParamsEmbed = pathname.match(/^\/([a-z0-9_-]+)\/([a-z0-9_-]+)\/embed$/); + const reloadData = () => { if (!matchedParamsPlayer && !matchedParamsEmbed) return; if (isReloading) { @@ -63,7 +52,7 @@ export function ActualRedirector() { const matched = (matchedParamsPlayer ?? matchedParamsEmbed)!; const courseId = matched[1]; const lectureId = matched[2]; - const prom = dataLoader(api, courseId, lectureId, userContext.hasUserInfo()); + const prom = dataLoader(api, courseId, lectureId); nextPlayerDataPromise.current = prom; if (!playerData) { setPlayerData(fetchDataWrapper(prom)); @@ -81,7 +70,7 @@ export function ActualRedirector() { }; // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(reloadData, [api, hasUserInfo, pathname]); + useEffect(reloadData, [api, hasUserInfo, currentlyAuthenticatedLectureId, pathname]); if (!matchedParamsPlayer && !matchedParamsEmbed) return <Four04 title="Unbekannter Pfad" text="dynamic_player" />; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ba3ce608de70528ee27aaf32fd57c663bb9a2c18..bdaae65688f02ab1e75b6a02f916a4c851efffa5 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import type { GetHomepageResponse, featured, int } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import { LectureCard } from "@/videoag/course/Lecture"; import { LectureLiveLabel } from "@/videoag/course/LiveLabel"; import { ErrorComponent, showError, showWarningToast } from "@/videoag/error/ErrorDisplay"; @@ -260,13 +260,13 @@ function ModButtons() { export default function Home() { const api = useApi(); - const userContext = useUserContext(); + const authStatus = useAuthStatus(); const [homepageData, setHomepageData] = useState<ResourceType<GetHomepageResponse>>(); const [query, setQuery] = useState(""); const [updateQueryDeferred, _] = useDebounceWithArgument(setQuery, 500); - const hasUserInfo = userContext.hasUserInfo(); + const hasUserInfo = authStatus.hasUserInfo(); const { editMode } = useEditMode(); const { language } = useLanguage(); diff --git a/src/videoag/authentication/AuthStatus.tsx b/src/videoag/authentication/AuthStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d2ab43cce0b28d0bfaaae9d3e836c8c46694ac51 --- /dev/null +++ b/src/videoag/authentication/AuthStatus.tsx @@ -0,0 +1,140 @@ +import type React from "react"; +import { createContext, useContext, useState, useEffect, startTransition } from "react"; + +import { int, user } from "@/videoag/api/types"; +import { useApi } from "@/videoag/api/ApiProvider"; + +interface AuthStatusData { + user: user | null; + authenticatedMethods: string[]; + currentlyAuthenticatedLectureId: int | null; +} + +export class AuthStatus { + public _updateStatus: (fn: (old: AuthStatus) => AuthStatus) => void; + private _isLoading: boolean; + private data: AuthStatusData; + + constructor( + updateStatus: (fn: (old: AuthStatus) => AuthStatus) => void, + isLoading: boolean, + data: AuthStatusData, + ) { + this._updateStatus = updateStatus; + this._isLoading = isLoading; + this.data = data; + } + + _setInitialStatus(data: AuthStatusData) { + this._updateStatus((oldStatus) => new AuthStatus(oldStatus._updateStatus, false, data)); + } + + public isLoading() { + return this._isLoading; + } + + public update(fn: (old: AuthStatusData) => AuthStatusData) { + this._updateStatus( + (oldStatus) => + new AuthStatus(oldStatus._updateStatus, oldStatus.isLoading(), fn(oldStatus.data)), + ); + } + + public setUser(user: user | null) { + this.update((old) => ({ + ...old, + user: user, + })); + } + + public addAuthenticatedMethod(method: string) { + this.update((old) => ({ + ...old, + authenticatedMethods: old.authenticatedMethods.concat(method), + })); + } + + public getAuthenticatedMethods(): string[] { + return this.data.authenticatedMethods; + } + + public isMethodAuthenticated(method: string) { + return this.getAuthenticatedMethods().includes(method); + } + + public setCurrentlyAuthenticatedLectureId(lectureId: int) { + this.update((old) => ({ + ...old, + currentlyAuthenticatedLectureId: lectureId, + })); + } + + public getCurrentlyAuthenticatedLectureId() { + return this.data.currentlyAuthenticatedLectureId; + } + + // TODO rename + + public getUserInfo(): user | null { + return this.data.user; + } + + public hasUserInfo(): boolean { + return this.getUserInfo() !== null; + } + + public canEditStuff(): boolean { + return this.hasUserInfo(); // TODO + } +} + +function newEmptyAuthStatus() { + return new AuthStatus((old) => old, true, { + user: null, + authenticatedMethods: [], + currentlyAuthenticatedLectureId: null, + }); +} + +const AuthStatusContext = createContext<AuthStatus>(newEmptyAuthStatus()); + +export function AuthStatusProvider({ children }: { children: React.ReactNode }) { + const api = useApi(); + const [status, setStatus] = useState<AuthStatus>(newEmptyAuthStatus()); + status._updateStatus = setStatus; + + const [loadedStatus, setLoadedStatus] = useState<boolean>(false); + useEffect(() => { + if (loadedStatus) return; + api.getAuthenticationStatus() + .then((resp) => { + startTransition(() => { + setLoadedStatus(true); + status._setInitialStatus({ + user: resp.user ?? null, + authenticatedMethods: resp.authenticated_methods, + currentlyAuthenticatedLectureId: null, + }); + }); + }) + .catch((e) => { + startTransition(() => { + setLoadedStatus(true); + status._setInitialStatus({ + user: null, + authenticatedMethods: [], + currentlyAuthenticatedLectureId: null, + }); + console.error("Failed to get authentication status", e); + }); + }); + }, [api, status, loadedStatus]); + + api.isPrivileged = status.canEditStuff(); + + return <AuthStatusContext.Provider value={status}>{children}</AuthStatusContext.Provider>; +} + +export function useAuthStatus() { + return useContext(AuthStatusContext); +} diff --git a/src/videoag/authentication/ModeratorBarrier.tsx b/src/videoag/authentication/ModeratorBarrier.tsx index 64e490da428876181bbe7bb4840660905007494e..83435b8428b3c2ae5ee4aa923791ba66b7a014a1 100644 --- a/src/videoag/authentication/ModeratorBarrier.tsx +++ b/src/videoag/authentication/ModeratorBarrier.tsx @@ -1,15 +1,13 @@ import { Four04 } from "@/videoag/error/ErrorDisplay"; -import { useUserContext } from "./UserDataProvider"; +import { useAuthStatus } from "./AuthStatus"; import type React from "react"; export default function ModeratorBarrier({ children }: { children: React.ReactNode }) { - const userContext = useUserContext(); + const authStatus = useAuthStatus(); - if (userContext.canEditStuff() === true) { + if (authStatus.canEditStuff() === true) { return <>{children} </>; - } else if (userContext.isLoggingIn()) { - return <div className="spinner-border" role="status" />; } else { return ( <Four04 diff --git a/src/videoag/authentication/UserData.tsx b/src/videoag/authentication/UserData.tsx deleted file mode 100644 index 89a0c11e66c4800efe6e3b04d4384339ab7ff4c6..0000000000000000000000000000000000000000 --- a/src/videoag/authentication/UserData.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { user } from "@/videoag/api/types"; - -export class UserData { - public setUserData: (data: UserData) => void; - private isLoggingIn_: boolean; - private userInfo: user | null; - - constructor(set: (data: UserData) => void, userInfo: user | null, isLoggingIn: boolean) { - this.setUserData = set; - this.userInfo = userInfo; - this.isLoggingIn_ = isLoggingIn; - } - - replace(data: user | null, isLoggingIn: boolean = false): void { - this.setUserData(new UserData(this.setUserData, data, isLoggingIn)); - } - - getUserInfo(): user | null { - return this.userInfo; - } - - hasUserInfo(): boolean { - return this.getUserInfo() !== null; - } - - canEditStuff(): boolean { - return this.hasUserInfo(); // TODO - } - - isLoggingIn(): boolean { - return this.isLoggingIn_; - } -} diff --git a/src/videoag/authentication/UserDataProvider.tsx b/src/videoag/authentication/UserDataProvider.tsx deleted file mode 100644 index 430bce9e9073d880e00b07163dee5e65049cb82d..0000000000000000000000000000000000000000 --- a/src/videoag/authentication/UserDataProvider.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type React from "react"; -import { createContext, useContext, useState } from "react"; - -import { useApi } from "@/videoag/api/ApiProvider"; - -import { UserData } from "./UserData"; - -const UserContext = createContext<UserData>(new UserData(() => {}, null, true)); - -export function RealUserProvider({ children }: { children: React.ReactNode }) { - const api = useApi(); - let [user, setUser] = useState<UserData>(() => new UserData(() => {}, null, true)); - user.setUserData = setUser; - - api.isPrivileged = user.canEditStuff(); - - return <UserContext.Provider value={user}>{children}</UserContext.Provider>; -} -export function useUserContext() { - return useContext(UserContext); -} diff --git a/src/videoag/authentication/UserField.tsx b/src/videoag/authentication/UserField.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d92fbc3684b7d4fed0c700f267708388081a9b5 --- /dev/null +++ b/src/videoag/authentication/UserField.tsx @@ -0,0 +1,176 @@ +import Link from "next/link"; +import type React from "react"; +import { useState } from "react"; +import { Dropdown, Popover, OverlayTrigger } from "react-bootstrap"; + +import { useApi } from "@/videoag/api/ApiProvider"; +import { ApiError } from "@/videoag/api/ApiError"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; +import { showError } from "@/videoag/error/ErrorDisplay"; +import { useLanguage } from "@/videoag/localization/LanguageProvider"; +import { TooltipButton } from "@/videoag/miscellaneous/Util"; +import { useEditMode } from "@/videoag/object_management/EditModeProvider"; + +export default function UserField({ isUnavailable }: { isUnavailable: boolean }) { + const api = useApi(); + const authStatus = useAuthStatus(); + + const [loginError, setLoginError] = useState(""); + const [showPop, setShowPop] = useState(false); + const { editMode, setEditMode } = useEditMode(); + + const [isLoggingIn, setIsLoggingIn] = useState(false); + const { language } = useLanguage(); + + const doFsmpiLogin = (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) => { + authStatus.setUser(resp.user!); + setShowPop(false); + setLoginError(""); + }) + .catch((err) => { + authStatus.setUser(null); + if (err instanceof ApiError) { + setLoginError("Error: " + err.api_message); + } else { + setLoginError("Login failed " + err.message); + } + }) + .finally(() => { + setIsLoggingIn(false); + }); + }; + + const doLogout = () => { + api.logout() + .then(() => { + authStatus.setUser(null); + setEditMode(false); + }) + .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 (authStatus.isLoading()) { + return <div className="spinner-border text-primary me-2" role="status" />; + } + + if (!authStatus.hasUserInfo()) { + const loginPop = ( + <Popover> + <Popover.Header as="h3">Login für FSMPI</Popover.Header> + <Popover.Body> + <form onSubmit={doFsmpiLogin}> + <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 ( + <> + {authStatus.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" }}> + {authStatus.getUserInfo()!.display_name} <span className="caret" /> + </Dropdown.Toggle> + <Dropdown.Menu className="me-2"> + <li> + <Dropdown.Item as={Link} href="/internal/changelog"> + Changelog + </Dropdown.Item> + </li> + <li> + <Dropdown.Item as={Link} href="/internal/timetable"> + Drehplan + </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={doLogout}>Logout</Dropdown.Item> + </li> + </Dropdown.Menu> + </Dropdown> + </> + ); +} diff --git a/src/videoag/authentication/ViewPermissions.tsx b/src/videoag/authentication/ViewPermissions.tsx index fa574e6df39223d3213a738176410035be6000c0..e572693864ab9a639847598ad0bc92e312c7ed9f 100644 --- a/src/videoag/authentication/ViewPermissions.tsx +++ b/src/videoag/authentication/ViewPermissions.tsx @@ -1,3 +1,16 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; + +import { course, lecture } from "@/videoag/api/types"; +import { ApiError } from "@/videoag/api/ApiError"; +import { useApi } from "@/videoag/api/ApiProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; +import { showError, showErrorToast } from "@/videoag/error/ErrorDisplay"; +import { useLanguage } from "@/videoag/localization/LanguageProvider"; +import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; +import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; +import { useDebounceUpdateData } from "@/videoag/miscellaneous/PromiseHelpers"; + export function AuthenticationMethodIcons({ authentication_methods, }: { @@ -61,3 +74,296 @@ export function AuthenticationMethodIcons({ </span> ); } + +function PasswordAuthComponent({ course, lecture }: { course: course; lecture: lecture }) { + const api = useApi(); + const reloadFunc = useReloadBoundary(); + const authStatus = useAuthStatus(); + const { language } = useLanguage(); + + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [loginException, setLoginException] = useState<any>(); + + const handleUserPass = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + let form = e.currentTarget; + let user = form.username.value; + let pass = form.password.value; + setIsLoggingIn(true); + api.authenticatePassword({ username: user, password: pass, lecture_id: lecture.id }) + .then(() => { + authStatus.addAuthenticatedMethod("password"); + authStatus.setCurrentlyAuthenticatedLectureId(lecture.id); + reloadFunc(); + }) + .catch(setLoginException) + .finally(() => setIsLoggingIn(false)); + return false; + }; + let errorElement = <></>; + if (loginException) { + if ( + loginException instanceof ApiError && + loginException.error_code === "authentication_failed" + ) { + errorElement = ( + <p className="alert alert-warning"> + {language.get("ui.video_player.login_user_password.user_or_pass_incorrect")} + </p> + ); + } else { + errorElement = <p className="alert alert-warning">{loginException + ""}</p>; + } + } else if (authStatus.isMethodAuthenticated("password")) { + errorElement = ( + <p className="alert alert-warning"> + {language.get("ui.video_player.login_user_password.current_password_incorrect")} + </p> + ); + } + + return ( + <> + <h4 className="text-center"> + <span className="bi bi-lock" aria-hidden="true" />{" "} + {language.get("ui.video_player.login_user_password.description")} + </h4> + {errorElement} + <form onSubmit={handleUserPass}> + <div className="mb-3"> + <label htmlFor="exampleInputEmail1" className="form-label"> + {language.get("object.permission.username")} + </label> + <input + type="text" + className="form-control" + id="username" + name="username" + placeholder="" + /> + </div> + <div className="mb-3"> + <label htmlFor="exampleInputPassword1" className="form-label"> + {language.get("object.permission.password")} + </label> + <input + type="password" + className="form-control" + id="password" + name="password" + placeholder="" + /> + </div> + <button type="submit" className="btn btn-secondary" disabled={isLoggingIn}> + {language.get("ui.video_player.login")} + </button> + {isLoggingIn && <div className="ms-2 spinner-border text-primary" role="status" />} + </form> + </> + ); +} + +class DummyObject {} + +function OAuthComponent({ + type, + course, + lecture, +}: { + type: "moodle" | "rwth"; + course: course; + lecture: lecture; +}) { + const api = useApi(); + const reloadFunc = useReloadBoundary(); + const authStatus = useAuthStatus(); + const { language } = useLanguage(); + + const [isOAuthRunning, setIsOAuthRunning] = useState(false); + const [waitingForFinish, setWaitingForFinish] = useState(false); + const remainingFinishAttempts = useRef<number>(0); + const popupClosed = useRef<boolean>(false); + + const [oauthInfo, setOauthInfo] = useState<any>(undefined); + + // this will prevent the automatic fetching from running after the component is unmounted + useEffect(() => { + return () => { + remainingFinishAttempts.current = 0; + }; + }, []); + + // useDebounceUpdateData is not quite intended for this, but works perfectly in ensuring we only do one fetch at a time + const [fetchStatusDeferred, fetchStatusNow] = useDebounceUpdateData<DummyObject, void>( + (dummy) => + api + .getAuthenticationStatus({ lecture_id: lecture.id }) + .then((res) => { + if (res.is_lecture_authenticated) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + authStatus.addAuthenticatedMethod(type); + authStatus.setCurrentlyAuthenticatedLectureId(lecture.id); + } else if (res.in_progress_authentication !== type) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + setOauthInfo( + <span className="text-warning"> + {language.get("ui.video_player.login_oauth.finished_no_access")} + </span>, + ); + } else { + if (remainingFinishAttempts.current > 0) { + remainingFinishAttempts.current--; + fetchStatusDeferred(new DummyObject()); + } else if (popupClosed.current) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + setOauthInfo( + <span className="text-warning"> + {language.get( + "ui.video_player.login_oauth.unfinished_popup_closed", + )} + </span>, + ); + } else if (remainingFinishAttempts.current <= 0) { + console.log("Stopping automatic authentication status fetching"); + } + } + }) + .catch((e) => { + showError(e, language.get("ui.video_player.login_oauth.error_finish")); + remainingFinishAttempts.current = 0; + if (popupClosed.current) { + setIsOAuthRunning(false); + setWaitingForFinish(false); + setOauthInfo(undefined); + } + }), + 1000, + ); + + const startFinishListener = (popup: any) => { + setWaitingForFinish(true); + setOauthInfo(undefined); + popupClosed.current = false; + remainingFinishAttempts.current = 120; + fetchStatusDeferred(new DummyObject()); + + let popInterval = setInterval(() => { + if (popup.closed) { + remainingFinishAttempts.current = 1; + popupClosed.current = true; + fetchStatusNow(new DummyObject()); + clearInterval(popInterval); + } + }, 100); + }; + + const startOauth = () => { + if (isOAuthRunning) return; + setIsOAuthRunning(true); + const popup = window.open(undefined, "_blank"); // Open popup here already, so the browser doesn't block it (safari gets confused by the async code below) + if (!popup) { + showErrorToast(language.get("ui.video_player.login_oauth.error_no_popup")); + return; + } + api.startAuthentication({ type: type }) + .then((res) => { + popup.location = res.verification_url; + popup.focus(); + startFinishListener(popup); + }) + .catch((e) => { + setIsOAuthRunning(false); + showError(e, language.get("ui.video_player.login_oauth.error_start")); + }); + }; + + const isRefreshMoodle = type == "moodle" && authStatus.isMethodAuthenticated("moodle"); + + return ( + <> + <h4 className="text-center"> + <span className="bi bi-person-fill" aria-hidden="true" /> + {type === "rwth" ? "RWTH" : "Moodle"} + </h4> + <p>{language.get(`ui.video_player.login_${type}.description`)}</p> + {isRefreshMoodle && ( + <p className="alert alert-info"> + {language.get("ui.video_player.login_moodle.not_in_course")} + </p> + )} + <div className="d-flex gap-2 justify-content-start align-items-center"> + <button + type="button" + onClick={() => startOauth()} + className="btn btn-secondary" + disabled={isOAuthRunning} + > + {language.get( + isRefreshMoodle + ? "ui.video_player.login_moodle.refresh_course" + : "ui.video_player.login", + )} + </button> + {isOAuthRunning && ( + <div className="flex-shrink-0 spinner-border text-primary" role="status" /> + )} + {waitingForFinish && ( + <span>{language.get("ui.video_player.login_oauth.waiting_for_finish")}</span> + )} + {oauthInfo && <span>{oauthInfo}</span>} + </div> + </> + ); +} + +export function ViewPermissionAuthorization({ + course, + lecture, +}: { + course: course; + lecture: lecture; +}) { + const { language } = useLanguage(); + return ( + <div + className="col-12 rounded-2 h-100" + style={{ padding: "10px", backgroundColor: "rgba(0, 0, 0, 0.3)" }} + > + <h3 className="text-center my-2">{language.get("ui.video_player.login_required")}</h3> + {course.authentication_information !== undefined && ( + <span className="text-center"> + <StylizedText markdown>{course.authentication_information}</StylizedText> + </span> + )} + <div style={{ paddingBottom: "20px" }} className="container-fluid"> + <div className="row"> + {lecture.authentication_methods.includes("password") && ( + <div className="col-sm-4"> + <PasswordAuthComponent course={course} lecture={lecture} /> + </div> + )} + {lecture.authentication_methods.includes("rwth") && ( + <div className="col-sm-4"> + <OAuthComponent type="rwth" course={course} lecture={lecture} /> + </div> + )} + {lecture.authentication_methods.includes("moodle") && ( + <div className="col-sm-4"> + <OAuthComponent type="moodle" course={course} lecture={lecture} /> + </div> + )} + </div> + {!lecture.authentication_methods.includes("rwth") && + !lecture.authentication_methods.includes("moodle") && + !lecture.authentication_methods.includes("password") && ( + <p className="alert alert-info" style={{ marginTop: "2em" }}> + {language.get("ui.video_player.student_council_only")} + </p> + )} + </div> + </div> + ); +} diff --git a/src/videoag/course/Chapter.tsx b/src/videoag/course/Chapter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e397aa2426ac7ed87cb4c1bce26c1664afb27bd9 --- /dev/null +++ b/src/videoag/course/Chapter.tsx @@ -0,0 +1,268 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Popover, OverlayTrigger } from "react-bootstrap"; + +import { int, chapter } from "@/videoag/api/types"; +import { useApi } from "@/videoag/api/ApiProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; +import { showError } from "@/videoag/error/ErrorDisplay"; +import { StringEditor } from "@/videoag/form/TypeEditor"; +import { useLanguage } from "@/videoag/localization/LanguageProvider"; +import { timestampToString } from "@/videoag/miscellaneous/Formatting"; +import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; +import { showInfoToast } from "@/videoag/miscellaneous/Util"; +import { useEditMode } from "@/videoag/object_management/EditModeProvider"; +import { + OMDelete, + OMEdit, + OMHistory, + EmbeddedOMFieldComponent, +} from "@/videoag/object_management/OMConfigComponent"; + +export function Chapters({ + chapters, + seekTo, +}: { + chapters?: chapter[]; + seekTo?: (time: number) => void; +}) { + const { editMode } = useEditMode(); + + if (!chapters || chapters.length == 0) return <></>; + + return ( + <div className="col-12 table-responsive" style={{ paddingTop: "10px" }}> + <h5>Kapitel:</h5> + <table className="table"> + <tbody> + {chapters + .sort((a, b) => a.start_time - b.start_time) + .map((c) => ( + <tr + key={`${c.start_time}+${c.name}`} + className={ + "w-100 " + + (c.visible === false ? "bg-danger-subtle" : "bg-body") + } + > + <td style={{ width: "130px" }}> + {seekTo === undefined ? ( + timestampToString(c.start_time) + ) : ( + <a + className="chapterlink" + onClick={() => { + seekTo(c.start_time); + }} + href="#" + > + {timestampToString(c.start_time)} + </a> + )} + </td> + <td> + <EmbeddedOMFieldComponent + object_type="chapter" + object_id={c.id!} + field_id="name" + field_type="string" + initialValue={c.name} + /> + </td> + {editMode && ( + <td className="d-flex"> + <div className="flex-fill" /> + <EmbeddedOMFieldComponent + object_type="chapter" + object_id={c.id!} + field_id="visible" + field_type="boolean" + initialValue={c.visible} + className="me-2 align-self-center" + changeIndicator="background" + /> + <OMHistory object_type="chapter" object_id={c.id!} /> + <OMEdit object_type="chapter" object_id={c.id!} /> + <OMDelete object_type="chapter" object_id={c.id!} /> + </td> + )} + </tr> + ))} + </tbody> + </table> + </div> + ); +} + +function AddChapterPopover({ + courseHandle, + lectureId, + reloadTimestampState, + closePopup, +}: { + courseHandle: string; + lectureId: int; + reloadTimestampState: int; + closePopup: () => void; +}) { + const api = useApi(); + const authStatus = useAuthStatus(); + const { language } = useLanguage(); + const reloadFunc = useReloadBoundary(); + const knownTimestampState = useRef(-1); + + const [timestampString, setTimestampString] = useState<string | undefined>(undefined); + const [name, setName] = useState<string>(""); + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); + + useEffect(() => { + if (reloadTimestampState == knownTimestampState.current) return; + knownTimestampState.current = reloadTimestampState; + setTimestampString(undefined); + setName(""); + + import("video.js").then((videojs) => { + const timestamp = Math.floor(videojs.default("videoplayer").currentTime()!); + setTimestampString(timestampToString(timestamp)); + }); + }, [reloadTimestampState, knownTimestampState]); + + const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => { + e.preventDefault(); + if (isSubmitting || timestampString === undefined) return; + setIsSubmitting(true); + + let timestamp = 0; + const timeSplit = timestampString.split(":"); + for (let i = 0; i < timeSplit.length; i++) { + timestamp += + Math.max(0, Math.min(60, parseInt(timeSplit[i]))) * + Math.pow(60, timeSplit.length - i - 1); + } + let promise; + if (authStatus.canEditStuff()) { + promise = api.createOMObject("chapter", { + parent_type: "lecture", + parent_id: lectureId, + values: { + start_time: timestamp, + name: name, + visible: true, + }, + }); + } else { + promise = api.suggestChapter(courseHandle, lectureId, { + start_time: timestamp, + name: name, + }); + } + promise + .then(() => { + if (!authStatus.canEditStuff()) + showInfoToast(language.get("ui.chapter_suggestion.popup.received_suggestion")); + closePopup(); + reloadFunc(); + }) + .catch((e) => { + showError(e, "Unable to create chapter"); + }) + .finally(() => setIsSubmitting(false)); + + return false; + }; + + return ( + <> + {timestampString === undefined || isSubmitting ? ( + <div className="spinner-border ms-2" role="status" /> + ) : ( + <> + <ul className="list-unstyled m-0"> + <li className="p-1"> + <StringEditor + value={timestampString} + updateValue={setTimestampString} + maxLength={100 /* For small input */} + regex="^(|[0-9]{1,2}:)[0-9]{1,2}:[0-9]{1,2}$" + regexInvalidMessage={language.get( + "ui.chapter_suggestion.popup.invalid_time_message", + )} + placeholder={language.get("object.chapter.start_time")} + /> + </li> + <li className="p-1"> + <StringEditor + value={name} + updateValue={setName} + minLength={1} + maxLength={250} + placeholder={language.get("object.chapter.name")} + /> + </li> + <li className="p-1"> + <button className="btn btn-primary" onClick={handleSubmit}> + {authStatus.canEditStuff() + ? language.get("ui.chapter_suggestion.popup.send_create") + : language.get("ui.chapter_suggestion.popup.send_suggestion")} + </button> + </li> + </ul> + </> + )} + </> + ); +} + +export function AddChapterButton({ + courseHandle, + lectureId, +}: { + courseHandle: string; + lectureId: int; +}) { + const authStatus = useAuthStatus(); + const { language } = useLanguage(); + + const [showPop, setShowPop] = useState(false); + const [reloadTimestampState, setReloadTimestampState] = useState(0); + + const buttonPress = () => { + if (showPop) { + setShowPop(false); + return; + } + setShowPop(true); + setReloadTimestampState((old) => old + 1); + }; + + return ( + <OverlayTrigger + trigger="click" + placement="top" + overlay={ + <Popover> + <Popover.Header as="h3"> + {authStatus.canEditStuff() + ? language.get("ui.chapter_suggestion.create") + : language.get("ui.chapter_suggestion.suggest")} + </Popover.Header> + <Popover.Body> + <AddChapterPopover + courseHandle={courseHandle} + lectureId={lectureId} + reloadTimestampState={reloadTimestampState} + closePopup={() => setShowPop(false)} + /> + </Popover.Body> + </Popover> + } + show={showPop} + > + <button className="btn btn-primary" onClick={buttonPress}> + {authStatus.canEditStuff() + ? language.get("ui.chapter_suggestion.create") + : language.get("ui.chapter_suggestion.suggest")} + </button> + </OverlayTrigger> + ); +} diff --git a/src/videoag/course/CourseListing.tsx b/src/videoag/course/CourseListing.tsx index 95ab384c08e7f98220b36f1cd20d4bd0c4d0cdbc..710226c757c1cc57b5925fc0c46fc6b1fcc70bda 100644 --- a/src/videoag/course/CourseListing.tsx +++ b/src/videoag/course/CourseListing.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import type { GetCourseResponse } from "@/videoag/api/types"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import { AuthenticationMethodIcons } from "@/videoag/authentication/ViewPermissions"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { ResourceType } from "@/videoag/miscellaneous/PromiseHelpers"; @@ -20,8 +20,8 @@ import { LectureListItem } from "./Lecture"; import DownloadAllModal from "./DownloadManager"; function ListingHeader({ course }: { course: GetCourseResponse }) { - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); const { editMode } = useEditMode(); const { language } = useLanguage(); diff --git a/src/videoag/course/Lecture.tsx b/src/videoag/course/Lecture.tsx index 2f9975e9678523077ba53776ee9d8826d9d512e1..6f137290af41983ac294482c2e925e353ccdb453 100644 --- a/src/videoag/course/Lecture.tsx +++ b/src/videoag/course/Lecture.tsx @@ -21,7 +21,7 @@ export function urlForLecture(course_id: string, lecture_id: string | number) { return `${course_id}/${lecture_id}`; } -export function getLectureThumbnailUrl(lecture: lecture): string | undefined { +export function getLectureThumbnailUrlNoPlaceholder(lecture: lecture): string | undefined { if (lecture.is_authenticated) { for (let medium of lecture.publish_media ?? []) { if (medium.medium_metadata.type === "thumbnail") { @@ -29,7 +29,11 @@ export function getLectureThumbnailUrl(lecture: lecture): string | undefined { } } } - return "/static/empty_thumbnail.png"; + return undefined; +} + +export function getLectureThumbnailUrl(lecture: lecture): string | undefined { + return getLectureThumbnailUrlNoPlaceholder(lecture) ?? "/static/empty_thumbnail.png"; } export function LectureListItem({ @@ -65,7 +69,10 @@ export function LectureListItem({ > <Link href={`/${course_handle}/${lecture.id}`}> <span className="centered-overlay-container" aria-hidden="true"> - <span className={"bi bi-play-circle fs-1"} style={{ color: "lightgrey" }} /> + <span + className={"bi bi-play-circle fs-1"} + style={{ color: "lightgrey" }} + /> </span> </Link> </div> @@ -112,9 +119,7 @@ export function LectureListItem({ {editMode && ( <OverlayTrigger overlay={ - <Tooltip> - {language.get("object.lecture.description")} - </Tooltip> + <Tooltip>{language.get("object.lecture.description")}</Tooltip> } > <span className="bi bi-chat-left-quote ms-1 me-1" /> @@ -132,7 +137,10 @@ export function LectureListItem({ </span> </li> {(editMode || lecture.internal_comment) && ( - <li className="text-muted bg-danger-subtle d-flex mb-1" style={{ borderRadius: "0.3em" }}> + <li + className="text-muted bg-danger-subtle d-flex mb-1" + style={{ borderRadius: "0.3em" }} + > <OverlayTrigger overlay={ <Tooltip> @@ -161,12 +169,17 @@ export function LectureListItem({ key={chapter.name} className={ "d-flex " + - (chapter.visible === false ? "bg-danger-subtle rounded" : "") + (chapter.visible === false + ? "bg-danger-subtle rounded" + : "") } > <span className="bi bi-play" /> <Link - href={`/${course_handle}/${lecture.id}?t=` + chapter.start_time} + href={ + `/${course_handle}/${lecture.id}?t=` + + chapter.start_time + } > {chapter.name} </Link> @@ -197,12 +210,11 @@ export function LectureListItem({ <ul className="list-unstyled col-md-4 col-12 pe-0 row align-content-start"> <li className="col-xl-7 col-12 p-0"> {editMode ? ( - <PublishMediumList publish_media={lecture.publish_media} /> + <PublishMediumList publish_media={lecture.publish_media!} /> ) : ( <PublishMediumDownloadButton publish_media={lecture.publish_media ?? []} direction="down" - tabIndex="-1" className="pl-1" /> )} @@ -263,10 +275,13 @@ export function LectureCard({ {/* display width >= sz */} <div className={`d-none d-${sz}-block`}> <div className="row"> - <div className="col-4 position-relative" style={{ - maxHeight: "6em", - maxWidth: "11em", - }}> + <div + className="col-4 position-relative" + style={{ + maxHeight: "6em", + maxWidth: "11em", + }} + > <img style={{ height: "100%", @@ -277,7 +292,10 @@ export function LectureCard({ alt="Vorschaubild" /> <span className="centered-overlay-container" aria-hidden="true"> - <span className={"bi bi-play-circle fs-3"} style={{ color: "lightgrey" }} /> + <span + className={"bi bi-play-circle fs-3"} + style={{ color: "lightgrey" }} + /> </span> </div> <div className="col-5 p-0"> @@ -316,7 +334,10 @@ export function LectureCard({ alt="Vorschaubild" /> <span className="centered-overlay-container" aria-hidden="true"> - <span className={"bi bi-play-circle fs-3"} style={{ color: "lightgrey" }} /> + <span + className={"bi bi-play-circle fs-3"} + style={{ color: "lightgrey" }} + /> </span> </li> <li> diff --git a/src/videoag/course/Medium.tsx b/src/videoag/course/Medium.tsx index 3a7a195ae5d9332c6030885724a398e2cf320652..8694cd1ecc9fcbe2aa47ece7932988580121f18b 100644 --- a/src/videoag/course/Medium.tsx +++ b/src/videoag/course/Medium.tsx @@ -68,6 +68,10 @@ export function getSortedDownloadablePublishMedia(media: publish_medium[]): publ return sortPublishMediaByQuality(media.filter((m) => m.allow_download)); } +export function getSortedPlayerPublishMedia(media: publish_medium[]): publish_medium[] { + return sortPublishMediaByQuality(media.filter((m) => m.include_in_player)); +} + export function PublishMediumList({ publish_media }: { publish_media: publish_medium[] }) { const { editMode } = useEditMode(); diff --git a/src/videoag/course/Player.tsx b/src/videoag/course/Player.tsx index a48e8a34b4784aef09257da151b2bafea816d2f0..397c28fa2f40334d33cfcaf7dafc6b59e7f692e8 100644 --- a/src/videoag/course/Player.tsx +++ b/src/videoag/course/Player.tsx @@ -1,37 +1,22 @@ import type React from "react"; -import { MouseEvent, useEffect, useRef, useState } from "react"; +import { MouseEvent, useEffect, useRef } from "react"; import Link from "next/link"; import Head from "next/head"; import OverlayTrigger from "react-bootstrap/OverlayTrigger"; import Popover from "react-bootstrap/Popover"; -import Dropdown from "react-bootstrap/Dropdown"; import "video.js/dist/video-js.min.css"; import VideoJsPlayerType from "video.js/dist/types/player"; import "@silvermine/videojs-quality-selector/dist/css/quality-selector.css"; import VideoJsMarkers from "@/videoag/miscellaneous/videojs-markers"; -import type { - course, - GetCourseResponse, - authentication_method, - authentication_methods, - chapter, - publish_medium, - lecture, - int, -} from "@/videoag/api/types"; -import { ApiError } from "@/videoag/api/ApiError"; -import { useApi } from "@/videoag/api/ApiProvider"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; -import { showError, showErrorToast } from "@/videoag/error/ErrorDisplay"; +import type { course, chapter, publish_medium, lecture } from "@/videoag/api/types"; +import { ViewPermissionAuthorization } from "@/videoag/authentication/ViewPermissions"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { filesizeToHuman, timestampToString } from "@/videoag/miscellaneous/Formatting"; -import { useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import Title from "@/videoag/miscellaneous/TitleComponent"; import { ResourceType } from "@/videoag/miscellaneous/PromiseHelpers"; import { UpdateOverlay } from "@/videoag/miscellaneous/UpdateOverlay"; -import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; +import { showInfoToast } from "@/videoag/miscellaneous/Util"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { OMDelete, @@ -41,7 +26,13 @@ import { } from "@/videoag/object_management/OMConfigComponent"; import { basePath } from "@/../basepath"; -import { LectureCard, urlForLecture, getLectureThumbnailUrl } from "./Lecture"; +import { LectureCard, urlForLecture, getLectureThumbnailUrlNoPlaceholder } from "./Lecture"; +import { + PublishMediumDownloadButton, + getSortedPlayerPublishMedia, + getNamedPublishMedia, +} from "./Medium"; +import { Chapters, AddChapterButton } from "./Chapter"; import { PlayerData } from "@/pages/dynamic_player"; @@ -77,20 +68,16 @@ function initPlayer( .getChild("PlaybackRateMenuButton")! .on("contextmenu", changeRate(-1)); } - const player_media = [...publish_media]; - player_media.filter((medium) => - ["plain_video", "plain_audio"].includes(medium.medium_metadata.type), + const sources = getNamedPublishMedia(getSortedPlayerPublishMedia(publish_media)).map( + ({ name, medium }) => { + return { + src: medium.url, + type: medium.medium_metadata.type === "plain_video" ? "video/mp4" : "audio/mpeg", + label: name, + selected: false, + }; + }, ); - sortPublishMediaByQuality(player_media); - - const sources = getNamedPublishMedia(player_media).map(({ name, medium }) => { - return { - src: medium.url, - type: medium.medium_metadata.type === "plain_video" ? "video/mp4" : "audio/mpeg", - label: name, - selected: false, - }; - }); if (sources.length > 0) { sources[0].selected = true; @@ -203,425 +190,17 @@ function VideoPlayer({ lecture, className }: { lecture: lecture; className?: str ); } -function KapitelPopover({ - timeStr, - closePop, - courseIdOrHandle, - lectureId, -}: { - timeStr: string; - closePop: () => void; - courseIdOrHandle: string | int; - lectureId: number; -}) { - const userContext = useUserContext(); - const api = useApi(); - const reloadFunc = useReloadBoundary(); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - if (isSubmitting) return; - let form = e.currentTarget; - let timeHuman = form.time.value; // Format HH:MM:SS - let time = 0; - let timeSplit = timeHuman.split(":"); - for (let i = 0; i < timeSplit.length; i++) { - time += - Math.max(0, Math.min(60, parseInt(timeSplit[i]))) * - Math.pow(60, timeSplit.length - i - 1); - } - - let text = form.text.value; - setIsSubmitting(true); - if (userContext.canEditStuff()) { - api.createOMObject("chapter", { - parent_type: "lecture", - parent_id: lectureId, - values: { - start_time: time, - name: text, - visible: true, - }, - }) - .then(() => { - closePop(); - reloadFunc(); - }) - .catch((e) => { - showError(e, "Unable to create chapter"); - }) - .finally(() => setIsSubmitting(false)); - } else { - api.suggestChapter(courseIdOrHandle, lectureId, { - start_time: time, - name: text, - }) - .then(() => { - closePop(); - reloadFunc(); - }) - .catch((e) => { - showError(e, "Unable to suggest chapter"); - }) - .finally(() => setIsSubmitting(false)); - } - - return false; - }; - - return ( - <form className="needs-validation" onSubmit={handleSubmit}> - <input - className="form-control" - style={{ marginTop: "6px", marginBottom: "6px" }} - placeholder="00:00" - name="time" - type="text" - defaultValue={timeStr} - pattern="(|[0-9]{1,2}:(|[0-9]{1,2}:))[0-9]{1,2}" - /> - <input - className="form-control" - placeholder="Titel" - name="text" - type="text" - style={{ marginBottom: "15px" }} - required - /> - <input - type="submit" - className="btn btn-primary" - style={{ marginBottom: "6px" }} - value={userContext.canEditStuff() ? "Hinzufügen" : "Vorschlagen"} - disabled={isSubmitting} - /> - {isSubmitting && <div className="spinner-border ms-2" role="status" />} - </form> - ); -} - -function AuthorizeHelper({ - course, - lecture, - authed_methods, -}: { - course: GetCourseResponse; - lecture: lecture; - authed_methods: authentication_methods; -}) { - const api = useApi(); - const reloadFunc = useReloadBoundary(); - const [userPwErrorState, setUserPwErrorState] = useState<any>(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const { language } = useLanguage(); - - useEffect(() => { - if (hasUserInfo === true) { - // reload page when the user logs in - reloadFunc(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasUserInfo]); - - useEffect(() => { - return () => { - setIsLoggingIn(false); // this will prevent the interval from running after the component is unmounted - }; - }, []); - - const startOauth = (method: authentication_method) => { - if (method !== "moodle" && method !== "rwth") return; - if (isLoggingIn) return; - setIsLoggingIn(true); - const pop = window.open(undefined, "_blank"); // Open popup here already, so the browser doesn't block it (safari gets confused by the async code below) - if (!pop) { - showErrorToast( - "Cannot open popup window, cannot start OAuth. Please send a bug report to video@fsmpi.rwth-aachen.de", - ); - return; - } - api.startAuthentication({ type: method }) - .then((res) => { - pop.location = res.verification_url; - pop.focus(); - let triggerReload = false; - let tries = 0; - const testAuth = () => { - tries++; - if (pop.closed || triggerReload || tries > 120 || isLoggingIn === false) { - return; // stop trying - } - api.getAuthenticationStatus({ lecture_id: lecture.id }) - .then((res) => { - if (res.is_lecture_authenticated) { - triggerReload = true; - } else if ((res.in_progress_authentication ?? "").length === 0) { - console.log( - "Authentication appears to have been cancelled by the user", - ); - triggerReload = true; - } else { - setTimeout(testAuth, 1000); - } - }) - .catch((e) => { - console.error(e); - }); - }; - setTimeout(testAuth, 5000); - let popInterval = setInterval(() => { - if (pop.closed || triggerReload) { - triggerReload = true; - clearInterval(popInterval); - setIsLoggingIn(false); - reloadFunc(); - } else if (isLoggingIn === false) { - clearInterval(popInterval); - } - }, 100); - }) - .catch((e) => { - showError(e, "Cannot start OAuth"); - setIsLoggingIn(false); - }); - }; - - const handleUserPass = (e: React.FormEvent<HTMLFormElement>) => { - e.preventDefault(); - let form = e.currentTarget; - let user = form.username.value; - let pass = form.password.value; - setIsLoggingIn(true); - api.authenticatePassword({ username: user, password: pass, lecture_id: lecture.id }) - .then(reloadFunc) - .catch(setUserPwErrorState) - .finally(() => setIsLoggingIn(false)); - return false; - }; - - let errorElement = <></>; - if (userPwErrorState) { - if ( - userPwErrorState instanceof ApiError && - userPwErrorState.error_code === "authentication_failed" - ) { - errorElement = ( - <p className="alert alert-warning"> - {language.get("ui.video_player.login_user_password.user_or_pass_incorrect")} - </p> - ); - } else errorElement = <p className="alert alert-warning">{userPwErrorState + ""}</p>; - } else if (authed_methods.includes("password")) { - errorElement = ( - <p className="alert alert-warning"> - {language.get("ui.video_player.login_user_password.current_password_incorrect")} - </p> - ); - } - - return ( - <div - className="col-12 rounded-2 h-100" - style={{ padding: "10px", backgroundColor: "rgba(0, 0, 0, 0.3)" }} - > - <h3 className="text-center my-2">{language.get("ui.video_player.login_required")}</h3> - {course.authentication_information !== undefined && ( - <p className="text-center"> - <StylizedText markdown>{course.authentication_information}</StylizedText> - </p> - )} - <div style={{ paddingBottom: "20px" }} className="container-fluid"> - <div className="row"> - {lecture.authentication_methods.includes("password") && ( - <div className="col-sm-4"> - <h4 className="text-center"> - <span className="bi bi-lock" aria-hidden="true" />{" "} - {language.get("ui.video_player.login_user_password.description")} - </h4> - {errorElement} - <form onSubmit={handleUserPass}> - <div className="mb-3"> - <label htmlFor="exampleInputEmail1" className="form-label"> - {language.get("object.permission.username")} - </label> - <input - type="text" - className="form-control" - id="username" - name="username" - placeholder="" - /> - </div> - <div className="mb-3"> - <label htmlFor="exampleInputPassword1" className="form-label"> - {language.get("object.permission.password")} - </label> - <input - type="password" - className="form-control" - id="password" - name="password" - placeholder="" - /> - </div> - <button - type="submit" - className="btn btn-secondary" - disabled={isLoggingIn} - > - {language.get("ui.video_player.login")} - </button> - {isLoggingIn && ( - <div - className="ms-2 spinner-border text-primary" - role="status" - /> - )} - </form> - </div> - )} - {lecture.authentication_methods.includes("rwth") && ( - <div className="col-sm-4"> - <h4 className="text-center"> - <span className="bi bi-person-fill" aria-hidden="true" /> RWTH - </h4> - <p>{language.get("ui.video_player.login_rwth.description")}</p> - <button - type="button" - onClick={() => startOauth("rwth")} - className="btn btn-secondary" - disabled={isLoggingIn} - > - {language.get("ui.video_player.login")} - </button> - {isLoggingIn && ( - <div className="ms-2 spinner-border text-primary" role="status" /> - )} - </div> - )} - {lecture.authentication_methods.includes("moodle") && ( - <div className="col-sm-4"> - <h4 className="text-center"> - <span className="bi bi-person-fill" aria-hidden="true" /> Moodle - </h4> - <p>{language.get("ui.video_player.login_moodle.description")}</p> - - {authed_methods.includes("moodle") ? ( - <> - <p className="alert alert-info"> - {language.get("ui.video_player.login_moodle.not_in_course")} - </p> - <button - type="button" - onClick={() => startOauth("moodle")} - className="btn btn-secondary" - disabled={isLoggingIn} - > - {language.get( - "ui.video_player.login_moodle.refresh_course", - )} - </button> - </> - ) : ( - <button - type="button" - onClick={() => startOauth("moodle")} - className="btn btn-secondary" - disabled={isLoggingIn} - > - {language.get("ui.video_player.login")} - </button> - )} - {isLoggingIn && ( - <div className="ms-2 spinner-border text-primary" role="status" /> - )} - </div> - )} - </div> - {!lecture.authentication_methods.includes("rwth") && - !lecture.authentication_methods.includes("moodle") && - !lecture.authentication_methods.includes("password") && ( - <p className="alert alert-info" style={{ marginTop: "2em" }}> - {language.get("ui.video_player.student_council_only")} - </p> - )} - </div> - </div> - ); -} - -function Chapters({ chapters, seekTo }: { chapters?: chapter[]; seekTo: (time: number) => void }) { - const { editMode } = useEditMode(); - - if (!chapters || chapters.length == 0) return <></>; - - return ( - <div className="col-12 table-responsive" style={{ paddingTop: "10px" }}> - <h4> - <strong>Kapitel:</strong> - </h4> - <table className="table"> - <tbody> - {chapters - .sort((a, b) => a.start_time - b.start_time) - .map((c) => { - const bgColor = c.visible === false ? "bg-danger-subtle" : "bg-body"; - return ( - <tr - key={`${c.start_time}+${c.name}`} - className={"w-100 " + bgColor} - > - <td style={{ width: "130px" }} className={bgColor}> - <a - className="chapterlink" - onClick={() => { - seekTo(c.start_time); - }} - href="#" - > - {timestampToString(c.start_time)} - </a> - </td> - <td className={bgColor}>{c.name}</td> - {editMode && ( - <td className={"d-flex " + bgColor}> - <div className="flex-fill" /> - <EmbeddedOMFieldComponent - object_type="chapter" - object_id={c.id!} - field_id="visible" - field_type="boolean" - initialValue={c.visible} - className="me-2 align-self-center" - changeIndicator="background" - /> - <OMDelete object_type="chapter" object_id={c.id!} /> - </td> - )} - </tr> - ); - })} - </tbody> - </table> - </div> - ); -} - -function VideoSuggestions({ course, lecture }: { course: course; lecture: lecture }) { +function LectureSuggestions({ course, lecture }: { course: course; lecture: lecture }) { const { language } = useLanguage(); - // find previous and next lecture - const sortedLectures = course.lectures?.sort((a, b) => { - // time looks like this: 2015-10-20T12:15:00 - return new Date(a.time).valueOf() - new Date(b.time).valueOf(); - }); - const prevLecture = sortedLectures?.findLast( - (l) => new Date(l.time) < new Date(lecture.time) && (l.publish_media ?? []).length > 0, + const prevLecture = course.lectures?.findLast( + (l) => + new Date(l.time) < new Date(lecture.time) && + (l.publish_media ?? []).find((m) => m.include_in_player), ); - const nextLecture = sortedLectures?.find( - (l) => new Date(l.time) > new Date(lecture.time) && (l.publish_media ?? []).length > 0, + const nextLecture = course.lectures?.find( + (l) => + new Date(l.time) > new Date(lecture.time) && + (l.publish_media ?? []).find((m) => m.include_in_player), ); return ( @@ -670,8 +249,67 @@ function VideoSuggestions({ course, lecture }: { course: course; lecture: lectur ); } -// TODO move and rework +function EmbedButton({ + course, + lecture, + className, +}: { + course: course; + lecture: lecture; + className?: string | undefined; +}) { + const { language } = useLanguage(); + + if (!course.allow_embed) { + return <></>; + } + const embedCode = `<div style="position:relative;padding-top:56.25%;"><iframe style="position:absolute;top:0;left:0;width:100%;height:100%;overflow: hidden;border: none;" src="${window.location.origin}${basePath}/${course.handle}/${lecture.id}/embed" allowfullscreen="true" scrolling="no"></iframe></div>`; + const copyEmbedCode = () => { + var embedCodeInput = document.getElementById("embedInput") as HTMLInputElement; + + embedCodeInput.select(); + embedCodeInput.setSelectionRange(0, embedCodeInput.value.length); // For mobile devices + navigator.clipboard.writeText(embedCodeInput.value); + showInfoToast("Copied embed code!"); + }; + return ( + <span className={className}> + <OverlayTrigger + trigger="click" + placement="top" + overlay={ + <Popover> + <Popover.Header as="h3"> + {language.get("ui.video_player.embed")} + </Popover.Header> + <Popover.Body> + <input + id="embedInput" + type="text" + className="w-100" + onClick={(e: MouseEvent<HTMLInputElement>) => + (e.target as HTMLInputElement).select() + } + value={embedCode} + readOnly + /> + <button + type="button" + className="btn btn-primary mt-2" + onClick={copyEmbedCode} + > + Kopieren + </button> + </Popover.Body> + </Popover> + } + > + <button className="btn btn-primary">{language.get("ui.video_player.embed")}</button> + </OverlayTrigger> + </span> + ); +} export function EmbeddedPlayer({ playerData, @@ -680,9 +318,7 @@ export function EmbeddedPlayer({ playerData: ResourceType<PlayerData>; disabled: boolean; }) { - const { course, perms, lectureId } = playerData.read()!; - - const lecture = course.lectures!.find((l) => l.id === lectureId)!; + const { course, lecture } = playerData.read()!; useEffect(() => { import("video.js"); @@ -696,7 +332,7 @@ export function EmbeddedPlayer({ ); } - if (!perms.is_lecture_authenticated) { + if (!lecture.is_authenticated) { // Show link to page return ( <div className="h-100 w-100 d-flex flex-column justify-content-center"> @@ -729,176 +365,53 @@ export default function Player({ playerData: ResourceType<PlayerData>; disabled: boolean; }) { - const { course, lectureId, perms, loaderHadUserInfo } = playerData.read()!; - const api = useApi(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); const { language } = useLanguage(); - const lecture = course.lectures!.find((l) => l.id === lectureId)!; + const { course, lecture } = playerData.read()!; useEffect(() => { import("video.js"); }, []); - const [popContent, setPopContent] = useState<React.ReactNode | null>(null); - const [showPop, setShowPop] = useState(false); const { editMode } = useEditMode(); - const reloadFunc = useReloadBoundary(); - - useEffect(() => { - if (hasUserInfo === loaderHadUserInfo) return; - reloadFunc(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loaderHadUserInfo, hasUserInfo]); - - const vorschlagenClick = () => { - if (showPop) { - setShowPop(false); - return; - } - import("video.js").then((videojs) => { - let timestamp = videojs.default("videoplayer").currentTime()!; - let tstr = timestampToString(timestamp); - - setPopContent( - <KapitelPopover - timeStr={tstr} - key={tstr} - closePop={() => setShowPop(false)} - courseIdOrHandle={course.handle} - lectureId={lectureId} - />, - ); - setShowPop(true); - }); - }; - let seekTo = (time: number) => { - import("video.js").then((videojs) => { - let player = videojs.default("videoplayer"); - player.currentTime(time); - }); - }; - - const kapitelPopover = ( - <Popover> - <Popover.Header as="h3"> - {userContext.canEditStuff() ? "Neues Kapitel" : "Kapitelmarker vorschlagen"} - </Popover.Header> - <Popover.Body>{popContent}</Popover.Body> - </Popover> - ); - - let einbettenText = `<div style="position:relative;padding-top:56.25%;"><iframe style="position:absolute;top:0;left:0;width:100%;height:100%;overflow: hidden;border: none;" src="${window.location.origin}${basePath}/${course.handle}/${lecture.id}/embed" allowfullscreen="true" scrolling="no"></iframe></div>`; - const einbettenCopy = () => { - var copyText = document.getElementById("einbettenInput") as HTMLInputElement; - - copyText.select(); - copyText.setSelectionRange(0, copyText.value.length); // For mobile devices - - navigator.clipboard.writeText(copyText.value); - }; - const einbettenPopover = ( - <Popover> - <Popover.Header as="h3">Einbetten</Popover.Header> - <Popover.Body> - <input - id="einbettenInput" - type="text" - className="w-100" - onClick={(e: MouseEvent<HTMLInputElement>) => - (e.target as HTMLInputElement).select() - } - value={einbettenText} - readOnly - /> - <button type="button" className="btn btn-primary mt-2" onClick={einbettenCopy}> - Kopieren - </button> - </Popover.Body> - </Popover> - ); - - let pageContent = ( - <AuthorizeHelper - lecture={lecture} - course={course} - authed_methods={perms.authenticated_methods} - /> - ); - if (perms.is_lecture_authenticated) { + let seekTo = undefined; + let pageContent; + if (lecture.is_authenticated) { + seekTo = (time: number) => { + import("video.js").then((videojs) => { + let player = videojs.default("videoplayer"); + player.currentTime(time); + }); + }; pageContent = ( <> <div className="col-12 p-0"> <VideoPlayer lecture={lecture} /> </div> - <div className="col-12 pt-4"> - <OverlayTrigger - trigger="click" - placement="top" - overlay={kapitelPopover} - show={showPop} - > - <button - className="btn btn-primary" - id="hintnewchapter" - onClick={vorschlagenClick} - > - {userContext.canEditStuff() - ? language.get("ui.video_player.add_chapter") - : language.get("ui.video_player.suggest_chapter")} - </button> - </OverlayTrigger> + <div className="col-12 pt-3"> + <AddChapterButton courseHandle={course.handle} lectureId={lecture.id} /> <ul className="list-inline float-none float-sm-end mb-0 mt-2 mt-sm-0"> - {course.allow_embed && ( - <li className="list-inline-item"> - <OverlayTrigger - trigger="click" - placement="top" - overlay={einbettenPopover} - > - <a className="btn btn-primary"> - <span>{language.get("ui.video_player.embed")}</span> - </a> - </OverlayTrigger> - </li> - )} - {lecture.publish_media && - lecture.publish_media.some((ele) => ele.url !== undefined) && ( - <li className="list-inline-item"> - <DownloadMedia - className="list-inline-item" - direction="up" - publish_media={lecture.publish_media ?? []} - /> - </li> - )} + <EmbedButton + course={course} + lecture={lecture} + className="list-inline-item" + /> + <PublishMediumDownloadButton + publish_media={lecture.publish_media ?? []} + direction="up" + className="list-inline-item" + /> </ul> </div> - {lecture.description.length > 0 && ( - <div className="mt-2"> - <h5>Beschreibung</h5> - - { - <EmbeddedOMFieldComponent - object_type="lecture" - object_id={lecture.id} - field_id="description" - field_type="string" - initialValue={lecture.description} - allowMarkdown={true} - /> - } - </div> - )} - <Chapters chapters={lecture.chapters} seekTo={seekTo} /> </> ); + } else { + pageContent = <ViewPermissionAuthorization lecture={lecture} course={course} />; } - const thumbnailUrl = getLectureThumbnailUrl(lecture); + const thumbnailUrlNoPlaceholder = getLectureThumbnailUrlNoPlaceholder(lecture); return ( <> @@ -906,7 +419,9 @@ export default function Player({ <meta property="og:title" content={`${course.short_name} - ${lecture.title}`} /> {/* TODO Why not lecture description? */} <meta property="og:description" content={course.description} /> - {thumbnailUrl && <meta property="og:image" content={thumbnailUrl} />} + {thumbnailUrlNoPlaceholder && ( + <meta property="og:image" content={thumbnailUrlNoPlaceholder} /> + )} </Head> <Title title={`${course.short_name} - ${lecture.title}`} /> <UpdateOverlay show={disabled} /> @@ -969,7 +484,25 @@ export default function Player({ </div> <div className="row mb-2">{pageContent}</div> - <VideoSuggestions course={course} lecture={lecture} /> + {lecture.description.length > 0 && ( + <div className="mt-2"> + <h5>Beschreibung:</h5> + + { + <EmbeddedOMFieldComponent + object_type="lecture" + object_id={lecture.id} + field_id="description" + field_type="string" + initialValue={lecture.description} + allowMarkdown={true} + /> + } + </div> + )} + <Chapters chapters={lecture.chapters} seekTo={seekTo} /> + + <LectureSuggestions course={course} lecture={lecture} /> {lecture.visible === false && ( <> diff --git a/src/videoag/miscellaneous/PromiseHelpers.tsx b/src/videoag/miscellaneous/PromiseHelpers.tsx index 76f38d029237093090215ee680b7c9bf6faba994..60508a2327db123a84f990013430ae4eb3928c5d 100644 --- a/src/videoag/miscellaneous/PromiseHelpers.tsx +++ b/src/videoag/miscellaneous/PromiseHelpers.tsx @@ -124,7 +124,9 @@ export function useDebounceUpdateData<D, R>( if (unprocessedDataRef.current !== undefined) { if (unprocessedDataRef.current === data) { unprocessedDataRef.current = undefined; - } else { + } else if (timeoutIdRef.current === undefined) { + // Only run immediately when no timeout is set. A call to deferred should still be deferred even if + // an update was running during the call. updateData(); } } @@ -139,10 +141,17 @@ export function useDebounceUpdateData<D, R>( (newData: D) => { unprocessedDataRef.current = newData; clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = setTimeout(updateData, delay); + timeoutIdRef.current = undefined; + + const timeoutRef = setTimeout(() => { + if (timeoutIdRef.current === timeoutRef) timeoutIdRef.current = undefined; + updateData(); + }, delay); + timeoutIdRef.current = timeoutRef; }, (newData: D) => { clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = undefined; unprocessedDataRef.current = newData; return updateData()!; }, diff --git a/src/videoag/site/DefaultLayout.tsx b/src/videoag/site/DefaultLayout.tsx index d0fe15bd578c7b1fe2a098a8d827650995619fa8..6546a935becab144ff2091a61a4743d0b1b0a85e 100644 --- a/src/videoag/site/DefaultLayout.tsx +++ b/src/videoag/site/DefaultLayout.tsx @@ -1,22 +1,19 @@ import Link from "next/link"; import type React from "react"; -import { MouseEvent, startTransition, useEffect, useState } from "react"; +import { MouseEvent, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { DropdownButton } from "react-bootstrap"; import Collapse from "react-bootstrap/Collapse"; import Dropdown from "react-bootstrap/Dropdown"; -import OverlayTrigger from "react-bootstrap/OverlayTrigger"; -import Popover from "react-bootstrap/Popover"; import type { GetStatusResponse } from "@/videoag/api/types"; import { useApi } from "@/videoag/api/ApiProvider"; -import { ApiError } from "@/videoag/api/ApiError"; -import { useUserContext } from "@/videoag/authentication/UserDataProvider"; -import { showError, showErrorToast, ErrorComponent } from "@/videoag/error/ErrorDisplay"; +import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; +import UserField from "@/videoag/authentication/UserField"; +import { showErrorToast, ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { storageGetOrDefault, storageSet } from "@/videoag/miscellaneous/Storage"; -import { TooltipButton } from "@/videoag/miscellaneous/Util"; import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import { StylizedText } from "@/videoag/miscellaneous/StylizedText"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; @@ -67,200 +64,6 @@ function NavBarIcon({ ); } -function UserField({ isUnavailable }: { isUnavailable: boolean }) { - const api = useApi(); - 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) { - userContext.replace(resp.user); - 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()!.display_name} <span className="caret" /> - </Dropdown.Toggle> - <Dropdown.Menu className="me-2"> - <li> - <Dropdown.Item as={Link} href="/internal/changelog"> - Changelog - </Dropdown.Item> - </li> - <li> - <Dropdown.Item as={Link} href="/internal/timetable"> - Drehplan - </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> - </> - ); -} - export function Search({ className, queryCallback, @@ -526,8 +329,8 @@ function NavBar({ status }: { status?: GetStatusResponse }) { function Footer({ status }: { status?: GetStatusResponse }) { const { language } = useLanguage(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); return ( <footer className={"footer bg-body-tertiary"}> @@ -646,8 +449,8 @@ function Footer({ status }: { status?: GetStatusResponse }) { export default function DefaultLayout({ children }: { children: React.ReactNode }) { const route = useRouter().pathname; const api = useApi(); - const userContext = useUserContext(); - const hasUserInfo = userContext.hasUserInfo(); + const authStatus = useAuthStatus(); + const hasUserInfo = authStatus.hasUserInfo(); const { setEditMode } = useEditMode(); const { language } = useLanguage(); const [status, setStatus] = useState<GetStatusResponse>(); @@ -660,7 +463,7 @@ export default function DefaultLayout({ children }: { children: React.ReactNode const onKeydown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.key === "e" && userContext.canEditStuff()) { + if (e.key === "e" && authStatus.canEditStuff()) { setEditMode((e) => !e); } }; @@ -669,7 +472,7 @@ export default function DefaultLayout({ children }: { children: React.ReactNode return () => { document.removeEventListener("keydown", onKeydown); }; - }, [isEmbed, setEditMode, userContext]); + }, [isEmbed, setEditMode, authStatus]); useEffect(() => { api.getStatus()