diff --git a/src/pages/courses.tsx b/src/pages/courses.tsx index cfc33fef79e9a1ec016081b91de05d7026f58c06..da60016cb83d8432fe5fee3e9976368c6021cbc1 100644 --- a/src/pages/courses.tsx +++ b/src/pages/courses.tsx @@ -8,7 +8,7 @@ import { useApi } from "@/api"; import { useAuthStatus } from "@/authentication"; import { ErrorComponent, FallbackErrorBoundary } from "@/error"; import { useLanguage } from "@/localization"; -import { ReloadBoundary, semesterToHuman, UpdateOverlay } from "@/miscellaneous"; +import { CollapsableCard, ReloadBoundary, semesterToHuman, UpdateOverlay } from "@/miscellaneous"; import { useEditMode, OMCreate, OMEdit, EmbeddedOMFieldComponent } from "@/object_management"; type GroupByTypes = "semester" | "full_name" | "organizer" | "topic"; @@ -85,39 +85,36 @@ function CourseGroupCard({ g, groupedby, onlyShowPublicCourses, + defaultExpanded, }: { g: { groupTitle: string; list: Array<course> }; groupedby: string; onlyShowPublicCourses: boolean; + defaultExpanded: boolean; }) { return ( - <div className="card mb-3"> - <div className="card-body"> - <h4 className="card-title">{g.groupTitle}</h4> - <div className="card-text"> - <ul className="courses-list m-0 p-0"> - {g.list.flatMap((c) => { - if ( - onlyShowPublicCourses && - (!c.default_authentication_methods?.includes("public") || - c.visible === false) - ) { - // I chose not to check c.listed here because - // it may be useful for videoag admins to check which courses are falsely set to visible and public - return []; - } - return [ - <CourseItem - key={c.id} - course={c} - showSemester={groupedby !== "semester"} - />, - ]; - })} - </ul> - </div> - </div> - </div> + <CollapsableCard header={g.groupTitle} defaultVisible={defaultExpanded}> + <ul className="courses-list m-0 p-0"> + {g.list.flatMap((c) => { + if ( + onlyShowPublicCourses && + (!c.default_authentication_methods?.includes("public") || + c.visible === false) + ) { + // I chose not to check c.listed here because + // it may be useful for videoag admins to check which courses are falsely set to visible and public + return []; + } + return [ + <CourseItem + key={c.id} + course={c} + showSemester={groupedby !== "semester"} + />, + ]; + })} + </ul> + </CollapsableCard> ); } @@ -154,13 +151,17 @@ function CourseList({ // descending order for semesters return groupBy === "semester" ? -cmp : cmp; }) - .map(([key, groupObject]) => { + .map(([key, groupObject], index) => { + console.log(index); return ( <CourseGroupCard key={key} g={groupObject} groupedby={groupBy} onlyShowPublicCourses={onlyShowPublicCourses} + defaultExpanded={ + groupBy !== "semester" || key !== "none" /* Collapse 'Zeitlos' by default */ + } /> ); }); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f95c6e9fbd8e9e84ae36423dcb17a011405ad60d..488e59b5c34bf88d9848e5a3b50f798e36f80418 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -10,6 +10,7 @@ import { LectureCard, LectureLiveLabel } from "@/course"; import { ErrorComponent, showError, showWarningToast, FallbackErrorBoundary } from "@/error"; import { useLanguage } from "@/localization"; import { + Card, datetimeToStringOnlyDate, datetimeToStringOnlyTime, ReloadBoundary, @@ -56,10 +57,11 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea } return ( - <div className={"card mb-3 " + bgColor}> - <div className="card-body"> - <h5 className="card-title d-flex align-items-center"> - <div className="overflow-hidden pb-1"> + <Card + objectVisible={data.visible} + header={ + <> + <span className="overflow-hidden"> <EmbeddedOMFieldComponent object_type="featured" object_id={data.id!} @@ -68,7 +70,7 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea initialValue={data.title} allowMarkdown={true} /> - </div> + </span> {editMode && ( <EmbeddedOMFieldComponent object_type="featured" @@ -78,13 +80,13 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea initialValue={data.visible} /> )} - <div className="flex-fill" /> + <span className="flex-fill" /> {editMode && ( - <div> + <span> <OMHistory object_type="featured" object_id={data.id!} /> - <div className="btn-group m-1" role="group"> + <span className="btn-group m-1" role="group"> <button type="button" className="btn btn-outline-info" @@ -101,45 +103,36 @@ function FeatureCard({ data, all_featured }: { data: featured; all_featured: fea > <i className="bi bi-arrow-down" /> </button> - </div> + </span> <OMEdit object_type="featured" object_id={data.id!} /> <OMDelete object_type="featured" object_id={data.id!} /> - </div> + </span> )} - </h5> - - {data.type === "image" && data.image_url && ( - <img - src={data.image_url} - alt={data.title} - className="img-fluid rounded" - style={{ maxHeight: "500px" }} - /> - )} - - {/* Add support for type lecture and course */} - - <EmbeddedOMFieldComponent - object_type="featured" - object_id={data.id!} - field_id="text" - field_type="string" - initialValue={data.text} - className="flex-fill" - allowMarkdown={true} + </> + } + > + {data.type === "image" && data.image_url && ( + <img + src={data.image_url} + alt={data.title} + className="img-fluid rounded" + style={{ maxHeight: "500px" }} /> - - {data.visible === false && ( - <> - <hr /> - <small className="text-muted"> - {language.get("ui.generic.object_invisible")} - </small> - </> - )} - </div> - </div> + )} + + {/* Add support for type lecture and course */} + + <EmbeddedOMFieldComponent + object_type="featured" + object_id={data.id!} + field_id="text" + field_type="string" + initialValue={data.text} + className="flex-fill" + allowMarkdown={true} + /> + </Card> ); } @@ -301,13 +294,13 @@ export default function Home() { <Search className="mt-3" queryCallback={updateQueryDeferred} /> </div> </div> - <hr /> {query.length > 0 && ( <> - <SearchResults query={query} /> <hr /> + <SearchResults query={query} /> </> )} + <hr className="mb-0" /> {isPending && <div className="spinner-border" />} {homepageData && ( <div className="row"> @@ -316,24 +309,12 @@ export default function Home() { <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> - <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> + <Card header={language.get("ui.index.next_recordings")}> + <UpcomingUploads homepageData={homepageData} /> + </Card> + <Card header={language.get("ui.index.recent_videos")}> + <RecentUploads homepageData={homepageData} /> + </Card> </div> </div> )} diff --git a/src/pages/internal/changelog.tsx b/src/pages/internal/changelog.tsx index a22dd121958eb8224e5302771c3cbe2e3b7d7120..e3aef207569f2b355d1c8e376d5c170919d790b0 100644 --- a/src/pages/internal/changelog.tsx +++ b/src/pages/internal/changelog.tsx @@ -6,7 +6,7 @@ import { useApi, OBJECT_TYPES } from "@/api"; import { ModeratorBarrier } from "@/authentication"; import { showError, ErrorComponent } from "@/error"; import { ChooserEditor, StringEditor, IntEditor } from "@/form"; -import { ReloadBoundary, useReloadBoundary } from "@/miscellaneous"; +import { Card, ReloadBoundary, useReloadBoundary } from "@/miscellaneous"; import { useFilteredDataView, PagingNavigation, FilterInput } from "@/object_management"; function ValToStr({ val }: { val: any }) { @@ -455,63 +455,60 @@ function ChangelogImpl() { }), ); return ( - <div className="card"> - <div className="card-header d-flex">Changelog</div> - <div className="card-body"> - <p> - Hier werden alle Änderungen an Kursen/Veranstaltungen/Videos etc. geloggt und - können Rückgängig gemacht werden. - </p> - <ReloadBoundary reloadFunc={reloadData}> - <div className="d-flex"> - <FilterInput - component={(props) => ( - <ChooserEditor - langKeyPrefix="object" - unchosenLangKey="ui.generic.filter.any" - possibleValues={OBJECT_TYPES} - {...props} - /> - )} - label="Object Type" - filterId="object_type" - setFilter={setFilter} - filters={filters} - /> - <FilterInput - component={(props) => <IntEditor allowUndefined={true} {...props} />} - label="Object Id" - filterId="object_id" - setFilter={setFilter} - filters={filters} - /> - {/*TODO suggestions? handle errors? handle no object type*/} - <FilterInput - component={(props) => ( - <StringEditor maxLength={100} allowUndefined={true} {...props} /> - )} - label="Field" - filterId="field_id" - setFilter={setFilter} - filters={filters} - /> - </div> - <div className="d-flex"> - <PagingNavigation - setFilter={setFilter} - filters={filters} - pageCount={data?.page_count} - /> - {isLoading && <div className="spinner-border ms-3 mt-1" />} - </div> - {error ? ( - <ErrorComponent error={error} objectName="Changelog" showButtons={false} /> - ) : ( - data && <ChangelogList changelog={data} setFilter={setFilter} /> - )} - </ReloadBoundary> - </div> - </div> + <Card header="Changelog"> + <p> + Hier werden alle Änderungen an Kursen/Veranstaltungen/Videos etc. geloggt und können + Rückgängig gemacht werden. + </p> + <ReloadBoundary reloadFunc={reloadData}> + <div className="d-flex"> + <FilterInput + component={(props) => ( + <ChooserEditor + langKeyPrefix="object" + unchosenLangKey="ui.generic.filter.any" + possibleValues={OBJECT_TYPES} + {...props} + /> + )} + label="Object Type" + filterId="object_type" + setFilter={setFilter} + filters={filters} + /> + <FilterInput + component={(props) => <IntEditor allowUndefined={true} {...props} />} + label="Object Id" + filterId="object_id" + setFilter={setFilter} + filters={filters} + /> + {/*TODO suggestions? handle errors? handle no object type*/} + <FilterInput + component={(props) => ( + <StringEditor maxLength={100} allowUndefined={true} {...props} /> + )} + label="Field" + filterId="field_id" + setFilter={setFilter} + filters={filters} + /> + </div> + <div className="d-flex"> + <PagingNavigation + setFilter={setFilter} + filters={filters} + pageCount={data?.page_count} + /> + {isLoading && <div className="spinner-border ms-3 mt-1" />} + </div> + {error ? ( + <ErrorComponent error={error} objectName="Changelog" showButtons={false} /> + ) : ( + data && <ChangelogList changelog={data} setFilter={setFilter} /> + )} + </ReloadBoundary> + </Card> ); } diff --git a/src/pages/internal/import.tsx b/src/pages/internal/import.tsx index 15f0f836c8db7db143123dbbb9149d4d5efb0c05..96daebb07c8cfa622d1a2b52f7506774efc8a8f2 100644 --- a/src/pages/internal/import.tsx +++ b/src/pages/internal/import.tsx @@ -7,7 +7,7 @@ import type { GetNewConfigurationResponse, course } from "@/api/types"; import { useApi } from "@/api"; import { ModeratorBarrier } from "@/authentication"; import { showError, showErrorToast } from "@/error"; -import { ICalEvent, parseICal, ReloadBoundary, useReloadBoundary } from "@/miscellaneous"; +import { Card, ICalEvent, parseICal, ReloadBoundary, useReloadBoundary } from "@/miscellaneous"; function TerminBody({ course, @@ -260,106 +260,107 @@ function TerminBody({ <div className="spinner-border mb-2" role="status" /> ) : null} - <div className="card mb-2"> - <div className="card-header d-flex align-items-center"> - <span className="flex-grow-1">Im Import, nicht bei uns</span> - - <button - className="btn btn-primary" - onClick={importAllEvents} - disabled={lockedIds === undefined || lockedIds.length > 0} - > - Alle anlegen - </button> - </div> - <div className="card-body"> - <table className="table"> - <thead> - <tr> - <th>Zeit</th> - <th>Ort</th> - <th>Dauer</th> - <th></th> + <Card + header={ + <> + <span className="flex-grow-1">Im Import, nicht bei uns</span> + <button + className="btn btn-primary" + onClick={importAllEvents} + disabled={lockedIds === undefined || lockedIds.length > 0} + > + Alle anlegen + </button> + </> + } + > + <table className="table"> + <thead> + <tr> + <th>Zeit</th> + <th>Ort</th> + <th>Dauer</th> + <th></th> + </tr> + </thead> + <tbody> + {new_events.map((event, index) => ( + <tr key={index}> + <td> + {event.startDate + ? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss") + : "kein datum"} + </td> + <td>{event.location}</td> + <td>{event.duration}</td> + <td> + <button + className="btn btn-primary" + onClick={() => importEvent(event)} + disabled={ + lockedIds === undefined || + lockedIds.includes(event.locally_unique_id) + } + > + Anlegen + </button> + </td> </tr> - </thead> - <tbody> - {new_events.map((event, index) => ( - <tr key={index}> - <td> - {event.startDate - ? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss") - : "kein datum"} - </td> - <td>{event.location}</td> - <td>{event.duration}</td> - <td> - <button - className="btn btn-primary" - onClick={() => importEvent(event)} - disabled={ - lockedIds === undefined || - lockedIds.includes(event.locally_unique_id) - } - > - Anlegen - </button> - </td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - <div className="card"> - <div className="card-header d-flex align-items-center"> - <span className="flex-grow-1">Bei uns, nicht im Import</span> - - <button - className="btn btn-primary" - onClick={removeAllEvents} - disabled={lockedIds === undefined || lockedIds.length > 0} - > - Alle entfernen - </button> - </div> - <div className="card-body"> - <table className="table"> - <thead> - <tr> - <th>Zeit</th> - <th>Ort</th> - <th>Dauer</th> - <th></th> + ))} + </tbody> + </table> + </Card> + + <Card + header={ + <> + <span className="flex-grow-1">Bei uns, nicht im Import</span> + <button + className="btn btn-primary" + onClick={removeAllEvents} + disabled={lockedIds === undefined || lockedIds.length > 0} + > + Alle entfernen + </button> + </> + } + > + <table className="table"> + <thead> + <tr> + <th>Zeit</th> + <th>Ort</th> + <th>Dauer</th> + <th></th> + </tr> + </thead> + <tbody> + {not_imported_events.map((event, index) => ( + <tr key={index}> + <td> + {event.startDate + ? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss") + : "kein datum"} + </td> + <td>{event.location}</td> + <td>{event.duration}</td> + <td> + <button + className="btn btn-primary" + onClick={() => removeEvent(event)} + disabled={ + lockedIds === undefined || + lockedIds.includes(event.locally_unique_id) + } + > + Entfernen + </button> + </td> </tr> - </thead> - <tbody> - {not_imported_events.map((event, index) => ( - <tr key={index}> - <td> - {event.startDate - ? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss") - : "kein datum"} - </td> - <td>{event.location}</td> - <td>{event.duration}</td> - <td> - <button - className="btn btn-primary" - onClick={() => removeEvent(event)} - disabled={ - lockedIds === undefined || - lockedIds.includes(event.locally_unique_id) - } - > - Entfernen - </button> - </td> - </tr> - ))} - </tbody> - </table> - </div> - </div> + ))} + </tbody> + </table> + </Card> </div> ); } @@ -469,59 +470,52 @@ function RWTHOnlineImportImpl() { <Link href={`/${course.handle}`} className="btn btn-primary mb-2"> <span className="bi bi-chevron-left" /> Zum Kurs </Link> - <div className="card mb-2"> - <div className="card-header d-flex">Import von RWTHOnline</div> - <div className="card-body"> - <a - href="https://online.rwth-aachen.de/RWTHonline/pl/ui/%24ctx/wbSuche.LVSuche" - target="_blank" - > - Suche den Kurs auf RWTHOnline - </a> - , und kopiere die URL hier rein: - <div> - <input - type="text" - className="w-100" - placeholder={exampleImportUrl} - ref={inputRef} - /> - {isImporting ? ( - <div className="spinner-border mt-2" role="status" /> - ) : ( - <button - className="btn btn-primary mt-2" - type="button" - onClick={onImport} - > - Zum Import hinzufügen - </button> - )} - </div> - <div> - Bereits importierte Termine: {imported_events.current.length} - <table className="table table-bordered"> - <tbody> - {imported_courses.current.map((courseId, index) => ( - <tr key={index}> - <td> - {courseId} - - <button - type="button" - className="ms-2 btn btn-danger" - onClick={() => removeImportedCourse(courseId)} - > - <i className="bi bi-trash-fill" /> - </button> - </td> - </tr> - ))} - </tbody> - </table> - </div> + <Card header="Import von RWTHOnline"> + <a + href="https://online.rwth-aachen.de/RWTHonline/pl/ui/%24ctx/wbSuche.LVSuche" + target="_blank" + > + Suche den Kurs auf RWTHOnline + </a> + , und kopiere die URL hier rein: + <div> + <input + type="text" + className="w-100" + placeholder={exampleImportUrl} + ref={inputRef} + /> + {isImporting ? ( + <div className="spinner-border mt-2" role="status" /> + ) : ( + <button className="btn btn-primary mt-2" type="button" onClick={onImport}> + Zum Import hinzufügen + </button> + )} </div> - </div> + <div> + Bereits importierte Termine: {imported_events.current.length} + <table className="table table-bordered"> + <tbody> + {imported_courses.current.map((courseId, index) => ( + <tr key={index}> + <td> + {courseId} + + <button + type="button" + className="ms-2 btn btn-danger" + onClick={() => removeImportedCourse(courseId)} + > + <i className="bi bi-trash-fill" /> + </button> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </Card> <TerminBody course={course} imported_events={imported_events} /> </ReloadBoundary> ); diff --git a/src/pages/internal/jobs.tsx b/src/pages/internal/jobs.tsx index 6b55ec14c8a5fb68ca62573ef705dac848c7d0b8..e4fae426d9d30fa83abd7935b6b2921fd2bfb848 100644 --- a/src/pages/internal/jobs.tsx +++ b/src/pages/internal/jobs.tsx @@ -3,7 +3,7 @@ import { useApi, JOB_STATES } from "@/api"; import { ModeratorBarrier } from "@/authentication"; import { ErrorComponent } from "@/error"; import { StringEditor, ChooserEditor } from "@/form"; -import { ReloadBoundary, ExpandableString, datetimeToString, Spinner } from "@/miscellaneous"; +import { Card, ReloadBoundary, ExpandableString, datetimeToString, Spinner } from "@/miscellaneous"; import { useFilteredDataView, PagingNavigation, FilterInput } from "@/object_management"; function JobList({ jobsResp }: { jobsResp: GetJobsResponse }) { @@ -113,52 +113,49 @@ function JobsImpl() { }), ); return ( - <div className="card"> - <div className="card-header d-flex">Jobs</div> - <div className="card-body"> - <p>Hier werden alle Jobs angezeigt</p> - <ReloadBoundary reloadFunc={reloadData}> - <div className="d-flex align-items-center"> - <FilterInput - component={(props) => ( - <ChooserEditor - langKeyPrefix="job_state" - unchosenLangKey="ui.generic.filter.any" - possibleValues={JOB_STATES} - {...props} - /> - )} - label="State" - filterId="state" - setFilter={setFilter} - filters={filters} - /> - <FilterInput - component={(props) => ( - <StringEditor maxLength={100} allowUndefined={true} {...props} /> - )} - label="Type" - filterId="type" - setFilter={setFilter} - filters={filters} - /> - </div> - <div className="d-flex"> - <PagingNavigation - setFilter={setFilter} - filters={filters} - pageCount={data?.page_count} - /> - <Spinner visible={isLoading} /> - </div> - {error ? ( - <ErrorComponent error={error} objectName="Jobs" showButtons={false} /> - ) : ( - data && <JobList jobsResp={data} /> - )} - </ReloadBoundary> - </div> - </div> + <Card header="Jobs"> + <p>Hier werden alle Jobs angezeigt</p> + <ReloadBoundary reloadFunc={reloadData}> + <div className="d-flex align-items-center"> + <FilterInput + component={(props) => ( + <ChooserEditor + langKeyPrefix="job_state" + unchosenLangKey="ui.generic.filter.any" + possibleValues={JOB_STATES} + {...props} + /> + )} + label="State" + filterId="state" + setFilter={setFilter} + filters={filters} + /> + <FilterInput + component={(props) => ( + <StringEditor maxLength={100} allowUndefined={true} {...props} /> + )} + label="Type" + filterId="type" + setFilter={setFilter} + filters={filters} + /> + </div> + <div className="d-flex"> + <PagingNavigation + setFilter={setFilter} + filters={filters} + pageCount={data?.page_count} + /> + <Spinner visible={isLoading} /> + </div> + {error ? ( + <ErrorComponent error={error} objectName="Jobs" showButtons={false} /> + ) : ( + data && <JobList jobsResp={data} /> + )} + </ReloadBoundary> + </Card> ); } diff --git a/src/pages/internal/sorter_files.tsx b/src/pages/internal/sorter_files.tsx index 4628b4655431b36b3f19e4a2deaf611013e97d95..657a32f880c96c3827a91350bfad94b25a84b350 100644 --- a/src/pages/internal/sorter_files.tsx +++ b/src/pages/internal/sorter_files.tsx @@ -7,7 +7,13 @@ import { ModeratorBarrier } from "@/authentication"; import { showError, ErrorComponent } from "@/error"; import { BooleanEditor } from "@/form"; import { JobStatusCard } from "@/job"; -import { ReloadBoundary, ExpandableString, datetimeToString, showInfoToast } from "@/miscellaneous"; +import { + Card, + ReloadBoundary, + ExpandableString, + datetimeToString, + showInfoToast, +} from "@/miscellaneous"; import { useFilteredDataView, PagingNavigation, @@ -141,59 +147,52 @@ function SorterFilesImpl() { }; return ( - <div className="card"> - <div className="card-header d-flex">Sorter Files</div> - <div className="card-body"> - <div className="d-flex align-items-center mb-4"> - <button - onClick={runSourceFileSorter} - disabled={doingSorterRequest} - className="btn btn-primary" - > - Run Sorter - </button> - {doingSorterRequest && <div className="spinner-border ms-3 mt-1" />} - {sorterJobId && ( - <span className="m-2"> - <JobStatusCard jobId={sorterJobId} /> - </span> - )} - </div> - - <p> - Hier werden alle Dateien angezeigt welche einsortiert wurden, bzw. wo es einen - Fehler beim Sortieren gab. - </p> - <ReloadBoundary reloadFunc={reloadData}> - <div className="d-flex align-items-center"> - <FilterInput - component={(props) => <BooleanEditor {...props} />} - label="Include Sorted" - filterId="include_sorted" - setFilter={setFilter} - filters={filters} - /> - </div> - <div className="d-flex"> - <PagingNavigation - setFilter={setFilter} - filters={filters} - pageCount={data?.page_count} - /> - {isLoading && <div className="spinner-border ms-3 mt-1" />} - </div> - {error ? ( - <ErrorComponent - error={error} - objectName="Sorter Files" - showButtons={false} - /> - ) : ( - data && <SorterFileList sorterFilesResp={data} /> - )} - </ReloadBoundary> + <Card header="Sorter Files"> + <div className="d-flex align-items-center mb-4"> + <button + onClick={runSourceFileSorter} + disabled={doingSorterRequest} + className="btn btn-primary" + > + Run Sorter + </button> + {doingSorterRequest && <div className="spinner-border ms-3 mt-1" />} + {sorterJobId && ( + <span className="m-2"> + <JobStatusCard jobId={sorterJobId} /> + </span> + )} </div> - </div> + + <p> + Hier werden alle Dateien angezeigt welche einsortiert wurden, bzw. wo es einen + Fehler beim Sortieren gab. + </p> + <ReloadBoundary reloadFunc={reloadData}> + <div className="d-flex align-items-center"> + <FilterInput + component={(props) => <BooleanEditor {...props} />} + label="Include Sorted" + filterId="include_sorted" + setFilter={setFilter} + filters={filters} + /> + </div> + <div className="d-flex"> + <PagingNavigation + setFilter={setFilter} + filters={filters} + pageCount={data?.page_count} + /> + {isLoading && <div className="spinner-border ms-3 mt-1" />} + </div> + {error ? ( + <ErrorComponent error={error} objectName="Sorter Files" showButtons={false} /> + ) : ( + data && <SorterFileList sorterFilesResp={data} /> + )} + </ReloadBoundary> + </Card> ); } diff --git a/src/pages/internal/timetable.tsx b/src/pages/internal/timetable.tsx index 8d0611eb40c74e114112d893ea3fb32ef3ee46ff..f1b96ed769b12643470068a12775a91b8231e121 100644 --- a/src/pages/internal/timetable.tsx +++ b/src/pages/internal/timetable.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { DateTime } from "luxon"; import { ModeratorBarrier } from "@/authentication"; +import { Card } from "@/miscellaneous"; interface Days { index: number; @@ -168,14 +169,11 @@ function TimetableTable() { function TimetableImpl() { return ( - <div className="card"> - <div className="card-header d-flex">Timetable</div> - <div className="card-body"> - This page is a work in progress, the data here is not real. - <hr /> - <TimetableTable /> - </div> - </div> + <Card header="Timetable"> + This page is a work in progress, the data here is not real. + <hr /> + <TimetableTable /> + </Card> /* TODO: pagination <div className="hidden-print"> <div style="margin-top: 10px; padding: 15px;" className="col-xs-12"> diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 0eb68c04206aabba1cd4fb652f659037bfd8cf6a..547d93c584a6a0fd6bfae50268f27e5fe07827b8 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -99,6 +99,14 @@ html:has(> body.modal-open) { background-color: inherit; // this fixes tables messing up background colors of parent elements } +.card-header:not(:has(+ .card-body)) { + border-bottom: 0; +} + +.card-header { + --bs-card-cap-padding-y: 0.6em; +} + .icon-rwth { display: inline-block; vertical-align: middle; diff --git a/src/videoag/course/CourseListing.tsx b/src/videoag/course/CourseListing.tsx index aa3adbe529d493a9883f19a41447f507ced160d5..bfffb0b4479c50ef2603382c91ca7ec896c060bc 100644 --- a/src/videoag/course/CourseListing.tsx +++ b/src/videoag/course/CourseListing.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { GetCourseResponse } from "@/api/types"; import { useAuthStatus, AuthenticationMethodIcons } from "@/authentication"; import { useLanguage } from "@/localization"; -import { Title, UpdateOverlay } from "@/miscellaneous"; +import { Card, Title, UpdateOverlay } from "@/miscellaneous"; import { useEditMode, OMCreate, @@ -23,9 +23,10 @@ function ListingHeader({ course }: { course: GetCourseResponse }) { const { language } = useLanguage(); return ( - <div className={`card mb-3 ${course.visible === false ? "bg-danger-subtle" : ""}`}> - <div className="card-body"> - <h5 className="card-title d-flex"> + <Card + objectVisible={course.visible} + header={ + <> <span className="panel-title flex-fill"> <EmbeddedOMFieldComponent object_type="course" @@ -58,92 +59,82 @@ function ListingHeader({ course }: { course: GetCourseResponse }) { authentication_methods={course.default_authentication_methods} /> </div> - </h5> - <div className="row"> - <div className="col-12"> - <Link - href={`/courses#course-${course.id}`} - className="btn btn-primary mb-1" - > - <span className="bi bi-chevron-left" />{" "} - {language.get("ui.course.back_to_list")} - </Link> - <table className="table table-sm"> - <tbody> - <tr> - <td>{language.get("object.course.semester")}:</td> - <td> - <EmbeddedOMFieldComponent - object_type="course" - object_id={course.id!} - field_id="semester" - field_type="semester_string" - initialValue={course.semester} - /> - </td> - </tr> - <tr> - <td>{language.get("object.course.organizer")}:</td> - <td> - <EmbeddedOMFieldComponent - object_type="course" - object_id={course.id!} - field_id="organizer" - field_type="string" - initialValue={course.organizer} - allowMarkdown={true} - /> - </td> - </tr> - <tr> - <td className="w-25"> - {language.get("object.course.description")}: - </td> - <td> - <EmbeddedOMFieldComponent - object_type="course" - object_id={course.id!} - field_id="description" - field_type="string" - initialValue={course.description} - allowMarkdown={true} - /> - </td> - </tr> - {hasUserInfo && ( - <> - <tr> - <td className="w-25"> - {language.get("object.course.internal_comment")}: - </td> - <td> - <EmbeddedOMFieldComponent - object_type="course" - object_id={course.id!} - field_id="internal_comment" - field_type="string" - initialValue={course.internal_comment} - allowMarkdown={true} - /> - </td> - </tr> - </> - )} - </tbody> - </table> - <DownloadAllModal course={course} /> - </div> + </> + } + > + <div className="row"> + <div className="col-12"> + <Link href={`/courses#course-${course.id}`} className="btn btn-primary mb-1"> + <span className="bi bi-chevron-left" />{" "} + {language.get("ui.course.back_to_list")} + </Link> + <table className="table table-sm"> + <tbody> + <tr> + <td>{language.get("object.course.semester")}:</td> + <td> + <EmbeddedOMFieldComponent + object_type="course" + object_id={course.id!} + field_id="semester" + field_type="semester_string" + initialValue={course.semester} + /> + </td> + </tr> + <tr> + <td>{language.get("object.course.organizer")}:</td> + <td> + <EmbeddedOMFieldComponent + object_type="course" + object_id={course.id!} + field_id="organizer" + field_type="string" + initialValue={course.organizer} + allowMarkdown={true} + /> + </td> + </tr> + <tr> + <td className="w-25"> + {language.get("object.course.description")}: + </td> + <td> + <EmbeddedOMFieldComponent + object_type="course" + object_id={course.id!} + field_id="description" + field_type="string" + initialValue={course.description} + allowMarkdown={true} + /> + </td> + </tr> + {hasUserInfo && ( + <> + <tr> + <td className="w-25"> + {language.get("object.course.internal_comment")}: + </td> + <td> + <EmbeddedOMFieldComponent + object_type="course" + object_id={course.id!} + field_id="internal_comment" + field_type="string" + initialValue={course.internal_comment} + allowMarkdown={true} + /> + </td> + </tr> + </> + )} + </tbody> + </table> + <DownloadAllModal course={course} /> </div> - {course.visible === false && ( - <> - <hr /> - <small className="text-muted"> - {language.get("ui.generic.object_invisible")} - </small> - </> - )} </div> - </div> + </Card> ); } @@ -151,9 +142,9 @@ function ListingBody({ course }: { course: GetCourseResponse }) { const { editMode } = useEditMode(); return ( - <div className="card mb-3"> - <div className="card-body"> - <h5 className="card-title d-flex align-items-center"> + <Card + header={ + <> Videos <div className="flex-fill" /> {editMode && ( @@ -177,19 +168,20 @@ function ListingBody({ course }: { course: GetCourseResponse }) { </Link> </> )} - </h5> - <ul className="list-group lectureslist"> - {course.lectures!.map((lecture) => ( - <LectureListItem - key={lecture.id} - lecture={lecture} - courseHandle={course.handle} - show_chapters={course.show_chapters_on_course} - /> - ))} - </ul> - </div> - </div> + </> + } + > + <ul className="list-group lectureslist"> + {course.lectures!.map((lecture) => ( + <LectureListItem + key={lecture.id} + lecture={lecture} + courseHandle={course.handle} + show_chapters={course.show_chapters_on_course} + /> + ))} + </ul> + </Card> ); } diff --git a/src/videoag/course/Medium.tsx b/src/videoag/course/Medium.tsx index 896e13a656beed97b68f9f488878b0ed1fcda1b8..83022c7384c002b2cdb038d2cfa4eb0c738b9119 100644 --- a/src/videoag/course/Medium.tsx +++ b/src/videoag/course/Medium.tsx @@ -7,8 +7,9 @@ import { showError, ErrorComponent } from "@/error"; import { JobStatusCard } from "@/job"; import { useLanguage } from "@/localization"; import { + Card, Spinner, - ReloadBoundary, + NestedReloadBoundary, useMutexCall, showInfoToast, TooltipButton, @@ -390,69 +391,69 @@ function MediumFileListItem({ } return ( - <div className={"card mb-3 " + (file.to_be_replaced ? "bg-warning-subtle" : "")}> - <div id={`medium_file_${file.id}`} className="card-body"> - <h5>Medium File {file.id}</h5> - <table className="table"> - <thead> - <tr> - <th scope="col" style={{ width: "30%" }} /> - <th scope="col" style={{ width: "70%" }} /> - </tr> - </thead> - <tbody> - <tr> - <td>File Path</td> - <td>{file.file_path}</td> - </tr> - <tr> - <td>Input Data SHA256</td> - <td>{file.input_data_sha256}</td> - </tr> - <tr> - <td>Process SHA256</td> - <td>{file.process_sha256}</td> - </tr> - <tr> - <td>Process Target ID</td> - <td>{file.process_target_id}</td> - </tr> - <tr> - <td>Producer Job</td> - <td> - {file.producer_job_id && - (showJobStatus ? ( - <JobStatusCard jobId={file.producer_job_id} /> - ) : ( - <> - <span>{file.producer_job_id}</span> - <button - className="btn btn-secondary ms-2" - onClick={(e) => setShowJobStatus(true)} - > - Show Status - </button> - </> - ))} - </td> - </tr> - <tr> - <td> - To Be Replaced? - <TooltipButton> - Indicates whether this medium file is outdated due to various - reasons. Usually because the process changed or the input - dependencies changed. - </TooltipButton> - </td> - <td>{file.to_be_replaced ? "Yes" : "No"}</td> - </tr> - </tbody> - </table> - {metadataComp} - {publishMediumComp} - </div> - </div> + <Card + header={`Medium File ${file.id}`} + className={file.to_be_replaced ? "bg-warning-subtle" : ""} + > + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>File Path</td> + <td>{file.file_path}</td> + </tr> + <tr> + <td>Input Data SHA256</td> + <td>{file.input_data_sha256}</td> + </tr> + <tr> + <td>Process SHA256</td> + <td>{file.process_sha256}</td> + </tr> + <tr> + <td>Process Target ID</td> + <td>{file.process_target_id}</td> + </tr> + <tr> + <td>Producer Job</td> + <td> + {file.producer_job_id && + (showJobStatus ? ( + <JobStatusCard jobId={file.producer_job_id} /> + ) : ( + <> + <span>{file.producer_job_id}</span> + <button + className="btn btn-secondary ms-2" + onClick={(e) => setShowJobStatus(true)} + > + Show Status + </button> + </> + ))} + </td> + </tr> + <tr> + <td> + To Be Replaced? + <TooltipButton> + Indicates whether this medium file is outdated due to various + reasons. Usually because the process changed or the input + dependencies changed. + </TooltipButton> + </td> + <td>{file.to_be_replaced ? "Yes" : "No"}</td> + </tr> + </tbody> + </table> + {metadataComp} + {publishMediumComp} + </Card> ); } @@ -466,57 +467,53 @@ function SorterFileListItem({ const file = context.sorter_files[sorterFileId.toString()]!; return ( - <div className="card mb-3"> - <div className="card-body"> - <h5>Sorter File {file.id}</h5> - <table className="table"> - <thead> - <tr> - <th scope="col" style={{ width: "30%" }} /> - <th scope="col" style={{ width: "70%" }} /> - </tr> - </thead> - <tbody> - <tr> - <td>File Path</td> - <td>{file.file_path}</td> - </tr> - <tr> - <td>File Modification Time</td> - <td>{datetimeToString(file.file_modification_time)}</td> - </tr> - <tr> - <td>SHA256</td> - <td>{file.sha256}</td> - </tr> - <tr> - <td>Tag</td> - <td>{file.tag}</td> - </tr> - <tr> - <td> - Designated Medium File ID - <TooltipButton> - Shows which medium file was created for this source file. That - medium file and this source file reference the same file (have - the same file path). - <br /> - Note that this medium file can change if the process changes, - etc. - </TooltipButton> - </td> - <td> - {file.designated_medium_file_id && ( - <a href={`#medium_file_${file.designated_medium_file_id}`}> - {file.designated_medium_file_id} - </a> - )} - </td> - </tr> - </tbody> - </table> - </div> - </div> + <Card header={`Sorter File ${file.id}`}> + <table className="table"> + <thead> + <tr> + <th scope="col" style={{ width: "30%" }} /> + <th scope="col" style={{ width: "70%" }} /> + </tr> + </thead> + <tbody> + <tr> + <td>File Path</td> + <td>{file.file_path}</td> + </tr> + <tr> + <td>File Modification Time</td> + <td>{datetimeToString(file.file_modification_time)}</td> + </tr> + <tr> + <td>SHA256</td> + <td>{file.sha256}</td> + </tr> + <tr> + <td>Tag</td> + <td>{file.tag}</td> + </tr> + <tr> + <td> + Designated Medium File ID + <TooltipButton> + Shows which medium file was created for this source file. That + medium file and this source file reference the same file (have the + same file path). + <br /> + Note that this medium file can change if the process changes, etc. + </TooltipButton> + </td> + <td> + {file.designated_medium_file_id && ( + <a href={`#medium_file_${file.designated_medium_file_id}`}> + {file.designated_medium_file_id} + </a> + )} + </td> + </tr> + </tbody> + </table> + </Card> ); } @@ -580,7 +577,7 @@ export function MediaProcessOverview({ lectureId }: { lectureId: int }) { } return ( - <NestedReloadBoundary reloadFunc={reloadData} > + <NestedReloadBoundary reloadFunc={reloadData}> <ul className="list-unstyled"> <table className="table"> <thead> @@ -612,10 +609,11 @@ export function MediaProcessOverview({ lectureId }: { lectureId: int }) { <td> Is automatic Media Process Scheduler enabled?{" "} <TooltipButton> - This shows whether the media process scheduler will automatically - run when any changes occur that might require processing media - files. This can be changed in the lecture config or, if the lecture - has specified 'Inherit', in the course config. + This shows whether the media process scheduler will + automatically run when any changes occur that might require + processing media files. This can be changed in the lecture + config or, if the lecture has specified 'Inherit', in + the course config. </TooltipButton> </td> <td> @@ -635,15 +633,15 @@ export function MediaProcessOverview({ lectureId }: { lectureId: int }) { Run Media Process Scheduler Manually </button> <TooltipButton> - If the automatic media process scheduler is disabled, you can run it manually - here (You will need to run it multiple times until the process is finished; - after every finished job). + If the automatic media process scheduler is disabled, you can run it + manually here (You will need to run it multiple times until the process is + finished; after every finished job). <br /> <br /> If a job failed the automatic process scheduler will also not run again - automatically (to prevent infinite loops) and you can run it manually. It will - automatically detect the failed job and schedule a new one which might or might - not fail again. + automatically (to prevent infinite loops) and you can run it manually. It + will automatically detect the failed job and schedule a new one which might + or might not fail again. <br /> <br /> In all other cases you do not need to run it manually. diff --git a/src/videoag/course/Player.tsx b/src/videoag/course/Player.tsx index c2306106e85560dc3516cdebc035ac7acd0473fa..0fb1c9279c3f441d50fd60799c4ac3e92f62056c 100644 --- a/src/videoag/course/Player.tsx +++ b/src/videoag/course/Player.tsx @@ -14,7 +14,7 @@ import type { course, chapter, publish_medium, lecture } from "@/api/types"; import { useApi } from "@/api"; import { ViewPermissionAuthorization } from "@/authentication"; import { useLanguage } from "@/localization"; -import { Title, UpdateOverlay, showInfoToast, parseApiDatetime } from "@/miscellaneous"; +import { Card, Title, UpdateOverlay, showInfoToast, parseApiDatetime } from "@/miscellaneous"; import { useEditMode, OMDelete, @@ -24,7 +24,7 @@ import { } from "@/object_management"; import { basePath } from "#basepath"; -import { LectureCard, urlForLecture, getLectureThumbnailUrlNoPlaceholder } from "./Lecture"; +import { LectureCard, getLectureThumbnailUrlNoPlaceholder } from "./Lecture"; import { PublishMediumDownloadButton, getSortedPlayerPublishMedia, @@ -237,8 +237,34 @@ function VideoPlayer({ ); } -function LectureSuggestions({ course, lecture }: { course: course; lecture: lecture }) { +function LectureSuggestionCard({ + course, + lecture, + isPrevious, +}: { + course: course; + lecture: lecture; + isPrevious: boolean; +}) { const { language } = useLanguage(); + return ( + <Card + objectVisible={lecture.visible} + headerNoStyling={true} + header={ + <span className="d-flex justify-content-center"> + {isPrevious && <i className="bi bi-arrow-left-circle me-2" />} + {language.get(`ui.video_player.${isPrevious ? "previous" : "next"}_lecture`)} + {!isPrevious && <i className="bi bi-arrow-right-circle ms-2" />} + </span> + } + > + <LectureCard course={course} lecture={lecture} size="small" /> + </Card> + ); +} + +function LectureSuggestions({ course, lecture }: { course: course; lecture: lecture }) { const prevLecture = course.lectures?.findLast( (l) => parseApiDatetime(l.time) < parseApiDatetime(lecture.time) && @@ -253,44 +279,11 @@ function LectureSuggestions({ course, lecture }: { course: course; lecture: lect return ( <div className="d-flex w-100 flex-column flex-sm-row"> {prevLecture && ( - <div className={`card ${prevLecture.visible === false ? "bg-danger-subtle" : ""}`}> - <Link - className="card-header text-center bg-light-subtle" - href={urlForLecture(course.handle, prevLecture.id)} - > - <i className="bi bi-arrow-left-circle me-2" /> - {language.get("ui.video_player.previous_lecture")} - </Link> - <div className="card-body"> - <LectureCard course={course} lecture={prevLecture} size="small" /> - </div> - {prevLecture.visible === false && ( - <div className="card-footer text-center text-muted"> - {language.get("ui.generic.object_invisible")} - </div> - )} - </div> + <LectureSuggestionCard course={course} lecture={prevLecture} isPrevious={true} /> )} - <div className="flex-fill p-2" /> {nextLecture && ( - <div className={`card ${nextLecture.visible === false ? "bg-danger-subtle" : ""}`}> - <Link - className="card-header text-center bg-light-subtle" - href={urlForLecture(course.handle, nextLecture.id)} - > - {language.get("ui.video_player.next_lecture")} - <i className="bi bi-arrow-right-circle ms-2" /> - </Link> - <div className="card-body"> - <LectureCard course={course} lecture={nextLecture} size="small" /> - </div> - {nextLecture.visible === false && ( - <div className="card-footer text-center text-muted"> - {language.get("ui.generic.object_invisible")} - </div> - )} - </div> + <LectureSuggestionCard course={course} lecture={nextLecture} isPrevious={false} /> )} </div> ); @@ -473,34 +466,36 @@ export default function Player({ </Head> <Title title={`${course.short_name} - ${lecture.title}`} /> <UpdateOverlay show={disabled} /> - <div className={`card ${lecture.visible === false ? "bg-danger-subtle" : ""}`}> - <div className="card-header d-flex"> - <div className="flex-fill align-self-center"> - <strong> - <Link href={`/${course.handle}#lecture-${lecture.id}`}> - {course.full_name} - </Link> - </strong> - :{" "} - <EmbeddedOMFieldComponent - object_type="lecture" - object_id={lecture.id!} - field_id="title" - field_type="string" - initialValue={lecture.title} - />{" "} - ( - <EmbeddedOMFieldComponent - object_type="lecture" - object_id={lecture.id!} - field_id="time" - field_type="datetime" - initialValue={lecture.time} - /> - ) - </div> + <Card + objectVisible={lecture.visible} + header={ + <> + <span> + <strong> + <Link href={`/${course.handle}#lecture-${lecture.id}`}> + {course.full_name} + </Link> + </strong> + :{" "} + <EmbeddedOMFieldComponent + object_type="lecture" + object_id={lecture.id!} + field_id="title" + field_type="string" + initialValue={lecture.title} + />{" "} + ( + <EmbeddedOMFieldComponent + object_type="lecture" + object_id={lecture.id!} + field_id="time" + field_type="datetime" + initialValue={lecture.time} + /> + ) + </span> + <span className="flex-fill" /> - <div> {editMode && ( <> <EmbeddedOMFieldComponent @@ -516,52 +511,42 @@ export default function Player({ <OMDelete object_type="lecture" object_id={lecture.id} /> </> )} + </> + } + > + <div className="row p-0 mt-1"> + <div className="col-12 pb-4"> + <Link + href={`/${course.handle}#lecture-${lecture.id}`} + className="btn btn-primary" + > + <span className="bi bi-chevron-left" />{" "} + {language.get("ui.video_player.back_to_course")} + </Link> </div> </div> - <div className="card-body"> - <div className="row p-0 mt-1"> - <div className="col-12 pb-4"> - <Link - href={`/${course.handle}#lecture-${lecture.id}`} - className="btn btn-primary" - > - <span className="bi bi-chevron-left" />{" "} - {language.get("ui.video_player.back_to_course")} - </Link> - </div> + <div className="row mb-2">{pageContent}</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> - <div className="row mb-2">{pageContent}</div> - - {lecture.description.length > 0 && ( - <div className="mt-2"> - <h5>Beschreibung:</h5> + )} + <Chapters chapters={lecture.chapters} seekTo={seekTo} /> - { - <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 && ( - <> - <hr /> - <small className="text-muted"> - {language.get("ui.generic.object_invisible")} - </small> - </> - )} - </div> - </div> + <LectureSuggestions course={course} lecture={lecture} /> + </Card> </> ); } diff --git a/src/videoag/job/Job.tsx b/src/videoag/job/Job.tsx index 20de7a6711ca218ada56d62982230f14ba0c424c..7a98ca9ae0a2ff54224dc02a8df84f743e0a4ba6 100644 --- a/src/videoag/job/Job.tsx +++ b/src/videoag/job/Job.tsx @@ -2,7 +2,13 @@ import { useState, useEffect, useRef } from "react"; import type { int, job, job_state } from "@/api/types"; import { useApi } from "@/api"; -import { datetimeToString, Spinner, useDebouncedCall, useReloadBoundary } from "@/miscellaneous"; +import { + Card, + datetimeToString, + Spinner, + useDebouncedCall, + useReloadBoundary, +} from "@/miscellaneous"; export function JobStatusCard({ jobId }: { jobId: int }) { const api = useApi(); @@ -73,42 +79,41 @@ export function JobStatusCard({ jobId }: { jobId: int }) { }, []); return ( - <div className="card"> - <span className="card-header d-flex align-items-center"> - Job {jobId} - <Spinner visible={isLoading} /> - </span> - <div className="card-body"> - {job !== undefined && ( - <table className="table"> - <tbody> - <tr> - <td>Type</td> - <td>{job.type}</td> - </tr> - <tr> - <td>State</td> - <td>{job.state}</td> - </tr> - <tr> - <td>Creation Time</td> - <td>{datetimeToString(job.creation_time)}</td> - </tr> - <tr> - <td>Run Start Time</td> - <td> - {job.run_start_time && datetimeToString(job.run_start_time)} - </td> - </tr> - <tr> - <td>Run End Time</td> - <td>{job.run_end_time && datetimeToString(job.run_end_time)}</td> - </tr> - </tbody> - </table> - )} - {errorMsg && <div className="text-warning">{errorMsg}</div>} - </div> - </div> + <Card + header={ + <> + Job {jobId} + <Spinner visible={isLoading} /> + </> + } + > + {job !== undefined && ( + <table className="table"> + <tbody> + <tr> + <td>Type</td> + <td>{job.type}</td> + </tr> + <tr> + <td>State</td> + <td>{job.state}</td> + </tr> + <tr> + <td>Creation Time</td> + <td>{datetimeToString(job.creation_time)}</td> + </tr> + <tr> + <td>Run Start Time</td> + <td>{job.run_start_time && datetimeToString(job.run_start_time)}</td> + </tr> + <tr> + <td>Run End Time</td> + <td>{job.run_end_time && datetimeToString(job.run_end_time)}</td> + </tr> + </tbody> + </table> + )} + {errorMsg && <div className="text-warning">{errorMsg}</div>} + </Card> ); } diff --git a/src/videoag/miscellaneous/Card.tsx b/src/videoag/miscellaneous/Card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..966ed375c03a2a3e568e693bc4a5345b8f37a7a5 --- /dev/null +++ b/src/videoag/miscellaneous/Card.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { useState } from "react"; + +import { useLanguage } from "@/localization"; + +export function Card({ + header, + headerNoStyling, + className, + bodyVisible, + objectVisible, + children, +}: { + header: any; + headerNoStyling?: boolean; + className?: string; + bodyVisible?: boolean; + objectVisible?: boolean; + children: React.ReactNode; +}) { + const { language } = useLanguage(); + return ( + <div + className={ + "card mt-3 mb-3 " + + ((objectVisible ?? true) ? "" : "bg-danger-subtle ") + + (className ?? "") + } + > + {(headerNoStyling ?? false) ? ( + <span className="card-header">{header}</span> + ) : ( + <h5 className="card-header d-flex align-items-center">{header}</h5> + )} + {(bodyVisible ?? true) && <div className="card-body">{children}</div>} + {!(objectVisible ?? true) && ( + <div className="card-footer text-center text-muted"> + {language.get("ui.generic.object_invisible")} + </div> + )} + </div> + ); +} + +export function CollapsableCard({ + header, + headerNoStyling, + defaultVisible, + className, + objectVisible, + children, +}: { + header: any; + headerNoStyling?: boolean; + defaultVisible: boolean; + className?: string; + objectVisible?: boolean; + children: React.ReactNode; +}) { + const [visible, setVisible] = useState<boolean>(defaultVisible); + + return ( + <Card + className={className} + headerNoStyling={headerNoStyling} + bodyVisible={visible} + objectVisible={objectVisible} + header={ + <> + {header} + <button + className="btn btn-outline-secondary border-0 ms-1" + onClick={() => setVisible((old) => !old)} + > + <i className={"bi bi-chevron-" + (visible ? "down" : "up")} /> + </button> + </> + } + > + {children} + </Card> + ); +} diff --git a/src/videoag/miscellaneous/index.ts b/src/videoag/miscellaneous/index.ts index d903cd63f3f839abbf0b56af378ae458121301a7..e6bf4ceaa92690c6c2ffc3387a74d217ac44314f 100644 --- a/src/videoag/miscellaneous/index.ts +++ b/src/videoag/miscellaneous/index.ts @@ -8,6 +8,7 @@ export { datetimeToString, filesizeToHuman, } from "./Formatting"; +export { Card, CollapsableCard } from "./Card"; export { useDebounce, useDebounceWithArgument, diff --git a/src/videoag/object_management/OMConfigComponent.tsx b/src/videoag/object_management/OMConfigComponent.tsx index 930a0119c92667107a39ab0816c30c4f9d7f8072..d8de3fffaa8e4a1c677685bb6d6a06d126db2811 100644 --- a/src/videoag/object_management/OMConfigComponent.tsx +++ b/src/videoag/object_management/OMConfigComponent.tsx @@ -308,7 +308,7 @@ export function EmbeddedOMFieldComponent({ className={ "h-auto d-inline-flex align-middle" + className + - ` ${isEditing ? "w-100" : ""} ${changeIndicator === "background" ? "p-2 rounded" : ""}` + ` ${isEditing && field_type !== "datetime" ? "w-100" : ""} ${changeIndicator === "background" ? "p-2 rounded" : ""}` } style={{ backgroundColor: