diff --git a/src/pages/courses.tsx b/src/pages/courses.tsx index aa83c34142ab16507f264b516c19a940a5b8cbb8..ffa6b8d1ff71f89b62b57dd9eb65f13c7a68a9da 100644 --- a/src/pages/courses.tsx +++ b/src/pages/courses.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import Link from "next/link"; import DropdownButton from "react-bootstrap/DropdownButton"; import DropdownItem from "react-bootstrap/DropdownItem"; @@ -12,7 +12,6 @@ import { useLanguage } from "@/videoag/localization/LanguageProvider"; import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; import { semesterToHuman } from "@/videoag/miscellaneous/Formatting"; import { UpdateOverlay } from "@/videoag/miscellaneous/UpdateOverlay"; -import { ResourceType, fetchDataWrapper } from "@/videoag/miscellaneous/PromiseHelpers"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { OMCreate, @@ -135,11 +134,11 @@ function CourseList({ groupBy, onlyShowPublicCourses, }: { - courseData: ResourceType<GetCoursesResponse>; + courseData: GetCoursesResponse; groupBy: GroupByTypes; onlyShowPublicCourses: boolean; }) { - const data = courseData.read()!; + const data = courseData; const groups = Object.values(data.courses).reduce( (acc, course) => { @@ -149,17 +148,12 @@ function CourseList({ if (groupBy === "semester") { groupTitle = semesterToHuman(course.semester, true); } - acc[groupKey] = { - groupTitle, - list: [], - }; + acc[groupKey] = { groupTitle, list: [] }; } acc[groupKey].list.push(course); return acc; }, - {} as { - [key: string]: { groupTitle: string; list: Array<course> }; - }, + {} as { [key: string]: { groupTitle: string; list: Array<course> } }, ); return Object.entries(groups) @@ -185,7 +179,8 @@ export default function Courses() { const authStatus = useAuthStatus(); const hasUserInfo = authStatus.hasUserInfo(); const { editMode } = useEditMode(); - const [courseData, setCourseData] = useState<ResourceType<GetCoursesResponse>>(); + const [courseData, setCourseData] = useState<GetCoursesResponse>(); + const [isPending, startTransition] = useTransition(); const nextCoursesDataPromise = useRef<Promise<GetCoursesResponse> | null>(null); const [isReloading, setIsReloading] = useState(false); const { language } = useLanguage(); @@ -198,16 +193,24 @@ export default function Courses() { const prom = api.getCourses(); nextCoursesDataPromise.current = prom; if (!courseData) { - setCourseData(fetchDataWrapper(prom)); + startTransition(async () => { + const data = await prom; + startTransition(() => { + setCourseData(data); + }); + }); } else { setIsReloading(true); - prom.finally(() => { + startTransition(async () => { + const data = await prom; if (prom !== nextCoursesDataPromise.current) { console.log("Ignoring stale promise"); return; } - setCourseData(fetchDataWrapper(prom)); - setIsReloading(false); + startTransition(() => { + setCourseData(data); + setIsReloading(false); + }); }); } }; @@ -217,8 +220,6 @@ export default function Courses() { const [groupedBy, setGroupedBy] = useState<GroupByTypes>("semester"); - if (courseData === undefined) return <></>; - const groupNames: { name: string; gtype: GroupByTypes }[] = [ { name: language.get("object.course.semester"), gtype: "semester" }, { name: language.get("object.course.full_name"), gtype: "full_name" }, @@ -285,14 +286,15 @@ export default function Courses() { </ul> </div> - <Suspense fallback={<div className="spinner-border" />}> + {isPending && !isReloading && <div className="spinner-border" />} + {courseData && ( <CourseList courseData={courseData} groupBy={groupedBy} onlyShowPublicCourses={onlyShowPublic} /> - <UpdateOverlay show={isReloading} /> - </Suspense> + )} + <UpdateOverlay show={isReloading} /> </FallbackErrorBoundary> </ReloadBoundary> ); diff --git a/src/pages/dynamic_course_listing.tsx b/src/pages/dynamic_course_listing.tsx index e1955070fc84fbd0d894b742c6aba127cfe33ded..d95a3ed47082714a9a5f82d8b08820524c7d9dda 100644 --- a/src/pages/dynamic_course_listing.tsx +++ b/src/pages/dynamic_course_listing.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { usePathname } from "next/navigation"; import { GetCourseResponse } from "@/videoag/api/types"; @@ -8,7 +8,6 @@ import CourseListing from "@/videoag/course/CourseListing"; import { Four04, ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; -import { ResourceType, fetchDataWrapper } from "@/videoag/miscellaneous/PromiseHelpers"; function ActualRedirector() { const api = useApi(); @@ -16,7 +15,8 @@ function ActualRedirector() { const hasUserInfo = authStatus.hasUserInfo(); const pathname = usePathname(); const matchedParams = pathname.match(/^\/([a-z0-9_-]+)\/?$/); - const [courseData, setCourseData] = useState<ResourceType<GetCourseResponse>>(); + const [courseData, setCourseData] = useState<GetCourseResponse>(); + const [isPending, startTransition] = useTransition(); const nextCourseDataPromise = useRef<Promise<GetCourseResponse> | null>(null); const [isReloading, setIsReloading] = useState(false); @@ -29,17 +29,25 @@ function ActualRedirector() { const prom = api.getCourse(matchedParams[1], true); nextCourseDataPromise.current = prom; if (!courseData) { - setCourseData(fetchDataWrapper(prom)); + startTransition(async () => { + const data = await prom; + startTransition(() => { + setCourseData(data); + }); + }); } else { setIsReloading(true); - // already showing course data, disable old one and load new one in the background - prom.finally(() => { + + startTransition(async () => { + const data = await prom; if (prom !== nextCourseDataPromise.current) { console.log("Ignoring stale promise"); return; } - setCourseData(fetchDataWrapper(prom)); - setIsReloading(false); + startTransition(() => { + setCourseData(data); + setIsReloading(false); + }); }); } }; @@ -66,9 +74,8 @@ function ActualRedirector() { /> )} > - <Suspense fallback={<div className="spinner-border" />}> - <CourseListing courseData={courseData} disabled={isReloading} /> - </Suspense> + {isPending && <div className="spinner-border" />} + {courseData && <CourseListing courseData={courseData} disabled={isReloading} />} </FallbackErrorBoundary> </ReloadBoundary> ); diff --git a/src/pages/dynamic_player.tsx b/src/pages/dynamic_player.tsx index 9d8a347abd862509ec5c67d7d8da85079f5ce886..89946e4bba36022d33e320228b76ca46477759f1 100644 --- a/src/pages/dynamic_player.tsx +++ b/src/pages/dynamic_player.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useTransition } from "react"; import { usePathname } from "next/navigation"; import { course, lecture } from "@/videoag/api/types"; @@ -9,7 +9,6 @@ import Player, { EmbeddedPlayer } from "@/videoag/course/Player"; import { Four04, ErrorComponent } from "@/videoag/error/ErrorDisplay"; import { FallbackErrorBoundary } from "@/videoag/error/FallbackErrorBoundary"; import { ReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; -import { ResourceType, fetchDataWrapper } from "@/videoag/miscellaneous/PromiseHelpers"; export interface PlayerData { course: course; @@ -37,7 +36,8 @@ export function ActualRedirector() { const hasUserInfo = authStatus.hasUserInfo(); const currentlyAuthenticatedLectureId = authStatus.getCurrentlyAuthenticatedLectureId(); const pathname = usePathname(); - const [playerData, setPlayerData] = useState<ResourceType<PlayerData>>(); + const [playerData, setPlayerData] = useState<PlayerData>(); + const [isPending, startTransition] = useTransition(); const nextPlayerDataPromise = useRef<Promise<PlayerData> | null>(null); const [isReloading, setIsReloading] = useState(false); @@ -55,16 +55,24 @@ export function ActualRedirector() { const prom = dataLoader(api, courseId, lectureId); nextPlayerDataPromise.current = prom; if (!playerData) { - setPlayerData(fetchDataWrapper(prom)); + startTransition(async () => { + const data = await prom; + startTransition(() => { + setPlayerData(data); + }); + }); } else { setIsReloading(true); - prom.finally(() => { + startTransition(async () => { + const data = await prom; if (prom !== nextPlayerDataPromise.current) { console.log("Ignoring stale promise"); return; } - setPlayerData(fetchDataWrapper(prom)); - setIsReloading(false); + startTransition(() => { + setPlayerData(data); + setIsReloading(false); + }); }); } }; @@ -94,14 +102,13 @@ export function ActualRedirector() { /> )} > - <Suspense fallback={<div className="spinner-border" />}> - {matchedParamsEmbed && ( - <EmbeddedPlayer playerData={playerData} disabled={isReloading} /> - )} - {matchedParamsPlayer && ( - <Player playerData={playerData} disabled={isReloading} /> - )} - </Suspense> + {isPending && <div className="spinner-border" />} + {matchedParamsEmbed && playerData && ( + <EmbeddedPlayer playerData={playerData} disabled={isReloading} /> + )} + {matchedParamsPlayer && playerData && ( + <Player playerData={playerData} disabled={isReloading} /> + )} </FallbackErrorBoundary> </ReloadBoundary> ); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f7599cfc089a16ff8055e8a82a349a804e832bcc..f1451cdf132c74921ee2ca2e763ef7d22044caa2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Suspense, useEffect, useState } from "react"; +import React, { useTransition } from "react"; +import { useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useRouter } from "next/router"; @@ -16,11 +16,7 @@ import { datetimeToStringOnlyTime, } from "@/videoag/miscellaneous/Formatting"; import { ReloadBoundary, useReloadBoundary } from "@/videoag/miscellaneous/ReloadBoundary"; -import { - ResourceType, - fetchDataWrapper, - useDebounceWithArgument, -} from "@/videoag/miscellaneous/PromiseHelpers"; +import { useDebounceWithArgument } from "@/videoag/miscellaneous/PromiseHelpers"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; import { OMCreate, @@ -46,12 +42,8 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea const movePanel = (dir: int) => { const myIndex = all_featured.findIndex((f) => f.id === data.id); api.updateOMObject("featured", data.id!, { - updates: { - display_priority: dir == -1 ? myIndex - 2 : myIndex + 1, - }, - expected_current_values: { - display_priority: myIndex, - }, + updates: { display_priority: dir == -1 ? myIndex - 2 : myIndex + 1 }, + expected_current_values: { display_priority: myIndex }, }) .then(reloadFunc) .catch((error) => { @@ -152,19 +144,16 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea ); } -function FeaturedContent({ homepageData }: { homepageData: ResourceType<GetHomepageResponse> }) { - const data = homepageData.read()!; - - return data.featured.map((f) => ( - <FeatureCard key={f.title} data={f} all_featured={data.featured} /> +function FeaturedContent({ homepageData }: { homepageData: GetHomepageResponse }) { + return homepageData.featured.map((f) => ( + <FeatureCard key={f.title} data={f} all_featured={homepageData.featured} /> )); } -function UpcomingUploads({ homepageData }: { homepageData: ResourceType<GetHomepageResponse> }) { - const data = homepageData.read()!; +function UpcomingUploads({ homepageData }: { homepageData: GetHomepageResponse }) { const { language } = useLanguage(); - if (data.upcoming_lectures.length === 0) { + if (homepageData.upcoming_lectures.length === 0) { return ( <span className="disable-last-paragraph-spacing"> {language.getStyled("ui.index.no_scheduled_recordings", true)} @@ -173,7 +162,7 @@ function UpcomingUploads({ homepageData }: { homepageData: ResourceType<GetHomep } let dates = []; // group by day/date - for (let lecture of data.upcoming_lectures) { + for (let lecture of homepageData.upcoming_lectures) { let date_string = datetimeToStringOnlyDate(lecture.time); let date_index = dates.findIndex((date) => date.date === date_string); @@ -191,7 +180,7 @@ function UpcomingUploads({ homepageData }: { homepageData: ResourceType<GetHomep <strong>{date.date}</strong> <ul className="list-group"> {date.lectures.map((lecture, index) => { - let course_data = data.course_context[lecture.course_id]; + let course_data = homepageData.course_context[lecture.course_id]; return ( <li className="list-group-item list-group-item-condensed" @@ -216,13 +205,11 @@ function UpcomingUploads({ homepageData }: { homepageData: ResourceType<GetHomep ); } -function RecentUploads({ homepageData }: { homepageData: ResourceType<GetHomepageResponse> }) { - const data = homepageData.read()!; - +function RecentUploads({ homepageData }: { homepageData: GetHomepageResponse }) { return ( <ul className="list-group videopreview"> - {data.latest_lectures.map((lecture, index) => { - let course_data = data.course_context[lecture.course_id]; + {homepageData.latest_lectures.map((lecture, index) => { + let course_data = homepageData.course_context[lecture.course_id]; return ( <li className="list-group-item" key={index}> <LectureCard lecture={lecture} course={course_data} />{" "} @@ -253,7 +240,8 @@ export default function Home() { const api = useApi(); const authStatus = useAuthStatus(); - const [homepageData, setHomepageData] = useState<ResourceType<GetHomepageResponse>>(); + const [homepageData, setHomepageData] = useState<GetHomepageResponse>(); + const [isPending, startTransition] = useTransition(); const [query, setQuery] = useState(""); const [updateQueryDeferred, _] = useDebounceWithArgument(setQuery, 500); @@ -284,7 +272,12 @@ export default function Home() { useEffect(handleLegacyRedirects, [searchParams, router]); const reloadData = () => { - setHomepageData(fetchDataWrapper(api.getHomepage())); + startTransition(async () => { + const data = await api.getHomepage(); + startTransition(() => { + setHomepageData(data); + }); + }); }; useEffect(reloadData, [api, hasUserInfo]); @@ -316,34 +309,33 @@ export default function Home() { <hr /> </> )} + {isPending && <div className="spinner-border" />} {homepageData && ( <div className="row"> {editMode && <ModButtons />} - <Suspense fallback={<div className="spinner-border" />}> - <div className="col-md-6"> - <FeaturedContent homepageData={homepageData} /> - </div> - <div className="col-md-6"> - <div className="card mb-3"> - <div className="card-body"> - <h5 className="card-title"> - {language.get("ui.index.next_recordings")} - </h5> - - <UpcomingUploads homepageData={homepageData} /> - </div> + <div className="col-md-6"> + <FeaturedContent homepageData={homepageData} /> + </div> + <div className="col-md-6"> + <div className="card mb-3"> + <div className="card-body"> + <h5 className="card-title"> + {language.get("ui.index.next_recordings")} + </h5> + + <UpcomingUploads homepageData={homepageData} /> </div> - <div className="card mb-3"> - <div className="card-body"> - <h5 className="card-title"> - {language.get("ui.index.recent_videos")} - </h5> - - <RecentUploads homepageData={homepageData} /> - </div> + </div> + <div className="card mb-3"> + <div className="card-body"> + <h5 className="card-title"> + {language.get("ui.index.recent_videos")} + </h5> + + <RecentUploads homepageData={homepageData} /> </div> </div> - </Suspense> + </div> </div> )} </FallbackErrorBoundary> diff --git a/src/videoag/course/CourseListing.tsx b/src/videoag/course/CourseListing.tsx index 79f6be781b40ea739f1154ea4e6c16ab53553fcb..3488d348964e9f68477c35e33e0446c209ca52a3 100644 --- a/src/videoag/course/CourseListing.tsx +++ b/src/videoag/course/CourseListing.tsx @@ -4,7 +4,6 @@ import type { GetCourseResponse } from "@/videoag/api/types"; import { useAuthStatus } from "@/videoag/authentication/AuthStatus"; import { AuthenticationMethodIcons } from "@/videoag/authentication/ViewPermissions"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; -import { ResourceType } from "@/videoag/miscellaneous/PromiseHelpers"; import Title from "@/videoag/miscellaneous/TitleComponent"; import { UpdateOverlay } from "@/videoag/miscellaneous/UpdateOverlay"; import { @@ -200,19 +199,15 @@ export default function CourseListing({ courseData, disabled, }: { - courseData: ResourceType<GetCourseResponse>; + courseData: GetCourseResponse; disabled: boolean; }) { - const course = courseData.read()!; - - if (!course) return <>invalid course listing</>; - return ( <> - <Title title={course.full_name} /> + <Title title={courseData.full_name} /> - <ListingHeader course={course} /> - <ListingBody course={course} /> + <ListingHeader course={courseData} /> + <ListingBody course={courseData} /> <UpdateOverlay show={disabled} /> </> ); diff --git a/src/videoag/course/Player.tsx b/src/videoag/course/Player.tsx index 9151c182fdea979ea5ee25751f523292f1123da1..55d52149559eeed5f8f88fa4cdc2bcdf54ed22f5 100644 --- a/src/videoag/course/Player.tsx +++ b/src/videoag/course/Player.tsx @@ -15,7 +15,6 @@ import { useApi } from "@/videoag/api/ApiProvider"; import { ViewPermissionAuthorization } from "@/videoag/authentication/ViewPermissions"; import { useLanguage } from "@/videoag/localization/LanguageProvider"; import Title from "@/videoag/miscellaneous/TitleComponent"; -import { ResourceType } from "@/videoag/miscellaneous/PromiseHelpers"; import { UpdateOverlay } from "@/videoag/miscellaneous/UpdateOverlay"; import { showInfoToast, parseApiDatetime } from "@/videoag/miscellaneous/Util"; import { useEditMode } from "@/videoag/object_management/EditModeProvider"; @@ -169,9 +168,7 @@ function VideoPlayer({ language: lang, userActions: { hotkeys: false }, playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 3, 3.5, 4], - controlBar: { - pictureInPictureToggle: false, - }, + controlBar: { pictureInPictureToggle: false }, plugins: { hotkeys: { seekStep: 15, @@ -367,10 +364,10 @@ export function EmbeddedPlayer({ playerData, disabled, }: { - playerData: ResourceType<PlayerData>; + playerData: PlayerData; disabled: boolean; }) { - const { course, lecture } = playerData.read()!; + const { course, lecture } = playerData; useEffect(() => { import("video.js"); @@ -414,12 +411,12 @@ export default function Player({ playerData, disabled, }: { - playerData: ResourceType<PlayerData>; + playerData: PlayerData; disabled: boolean; }) { const { language } = useLanguage(); - const { course, lecture } = playerData.read()!; + const { course, lecture } = playerData; useEffect(() => { import("video.js"); diff --git a/src/videoag/miscellaneous/PromiseHelpers.tsx b/src/videoag/miscellaneous/PromiseHelpers.tsx index 8d62cddcf7858febd2ae6e2f50d23174a90cc621..62ef7628189610f2f486dae8cb4426fbd9689344 100644 --- a/src/videoag/miscellaneous/PromiseHelpers.tsx +++ b/src/videoag/miscellaneous/PromiseHelpers.tsx @@ -1,64 +1,6 @@ import { useRef } from "react"; /* global NodeJS */ -export function fetchDataWrapper<T>(prom: Promise<T>) { - let status = "pending"; - let result: T; - let suspender = new Promise<void>((resolve, reject) => { - prom.then((r) => { - status = "success"; - result = r; - resolve(); - }).catch((e) => { - status = "error"; - result = e; - reject(); - }); - }); - return { - read() { - if (status === "pending") { - throw suspender; - } else if (status === "error") { - throw result; - } else if (status === "success") { - return result; - } - }, - }; -} - -export function fetchDataDelayWrapper<T>(prom: Promise<T>, artificialDelay: number) { - let status = "pending"; - let result: T; - let suspender = new Promise<void>((resolve, reject) => { - setTimeout(() => { - prom.then((r) => { - status = "success"; - result = r; - resolve(); - }).catch((e) => { - status = "error"; - result = e; - reject(); - }); - }, artificialDelay); - }); - return { - read() { - if (status === "pending") { - throw suspender; - } else if (status === "error") { - throw result; - } else if (status === "success") { - return result; - } - }, - }; -} - -export type ResourceType<T> = { read(): T | undefined }; - // Returns [deferred, now] export function useDebounce<T extends () => any>(func: T, delay: number): [() => void, T] { let timeoutId = useRef<NodeJS.Timeout>(undefined);