diff --git a/api/courses.go b/api/courses.go
index 2ae7e1ef7..7502d87f5 100644
--- a/api/courses.go
+++ b/api/courses.go
@@ -6,12 +6,12 @@ import (
"errors"
"fmt"
"github.com/RBG-TUM/commons"
- "github.com/getsentry/sentry-go"
- "github.com/gin-gonic/gin"
"github.com/TUM-Dev/gocast/dao"
"github.com/TUM-Dev/gocast/model"
"github.com/TUM-Dev/gocast/tools"
"github.com/TUM-Dev/gocast/tools/tum"
+ "github.com/getsentry/sentry-go"
+ "github.com/gin-gonic/gin"
"github.com/meilisearch/meilisearch-go"
uuid "github.com/satori/go.uuid"
log "github.com/sirupsen/logrus"
@@ -61,6 +61,7 @@ func configGinCourseRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {
courses.Use(tools.InitCourse(daoWrapper))
courses.Use(tools.AdminOfCourse)
courses.DELETE("/", routes.deleteCourse)
+ courses.GET("/lectures", routes.fetchLectures)
courses.POST("/createVOD", routes.createVOD)
courses.POST("/uploadVODMedia", routes.uploadVODMedia)
courses.POST("/copy", routes.copyCourse)
@@ -1038,6 +1039,17 @@ func (r coursesRoutes) renameLecture(c *gin.Context) {
}
}
+func (r coursesRoutes) fetchLectures(c *gin.Context) {
+ tlctx := c.MustGet("TUMLiveContext").(tools.TUMLiveContext)
+
+ lectureHalls := r.LectureHallsDao.GetAllLectureHalls()
+ streams := tlctx.Course.AdminJson(lectureHalls)
+
+ c.JSON(http.StatusOK, gin.H{
+ "streams": streams,
+ })
+}
+
func (r coursesRoutes) updateLectureSeries(c *gin.Context) {
stream, err := r.StreamsDao.GetStreamByID(context.Background(), c.Param("streamID"))
if err != nil {
diff --git a/web/assets/init-admin.js b/web/assets/init-admin.js
new file mode 100644
index 000000000..172d2513d
--- /dev/null
+++ b/web/assets/init-admin.js
@@ -0,0 +1,157 @@
+document.addEventListener("alpine:init", () => {
+ const textInputTypes = [
+ 'text',
+ 'password',
+ 'email',
+ 'search',
+ 'url',
+ 'tel',
+ 'number',
+ 'datetime-local',
+ 'date',
+ 'month',
+ 'week',
+ 'time',
+ 'color'
+ ];
+
+ const convert = (modifiers, value) => {
+ if (modifiers.includes("int")) {
+ return parseInt(value);
+ } else if (modifiers.includes("float")) {
+ return parseFloat(value);
+ }
+ return value;
+ }
+
+ /**
+ * Alpine.js Directive: `x-bind-change-set`
+ *
+ * This directive allows you to synchronize form elements with a given changeSet object.
+ * It is designed to work with different form input types including text inputs,
+ * textareas, checkboxes, and file inputs.
+ *
+ * ## Parameters
+ *
+ * - `el`: The DOM element this directive is attached to.
+ * - `expression`: The JavaScript expression passed to the directive, evaluated to get the changeSet object.
+ * - `value`: Optional parameter to specify the field name. Defaults to the `name` attribute of the element.
+ * - `modifiers`: Array of additional modifiers to customize the behavior. For instance, use 'single' for single-file uploads.
+ * - `evaluate`: Function to evaluate Alpine.js expressions.
+ * - `cleanup`: Function to remove event listeners when element is destroyed or directive is unbound.
+ *
+ * ## Events
+ *
+ * This directive emits a custom event named "csupdate" whenever the changeSet object or the form element is updated.
+ *
+ * ## Usage
+ *
+ * ### Example in HTML
+ *
+ * ```html
+ *
+ * Self streaming
+ *
+ *
+ * ```
+ *
+ * - `changeSet`: The changeSet object you want to bind with the form element.
+ *
+ * ## Modifiers
+ *
+ * - `single`: Use this modifier for file inputs when you want to work with a single file instead of a FileList.
+ * - `int`: Use this modifier to convert the inserted value to integer.
+ * - `float`: Use this modifier to convert the inserted value to float.
+ *
+ * ```html
+ *
+ * ```
+ *
+ * ## Notes
+ *
+ * This directive is intended to be used with the existing `ChangeSet` class.
+ * Make sure to import and initialize a `ChangeSet` object in your Alpine.js component
+ * to utilize this directive effectively. The `ChangeSet` class should have implemented
+ * methods such as `patch`, `listen`, `removeListener`, and `get`,
+ * and manage a `DirtyState` object for tracking changes.
+ */
+
+ Alpine.directive("bind-change-set", (el, { expression, value, modifiers }, { evaluate, cleanup }) => {
+ const changeSet = evaluate(expression);
+ const fieldName = value || el.name;
+ const nativeEventName = "csupdate";
+
+ if (el.type === "file") {
+ const isSingle = modifiers.includes("single")
+
+ const changeHandler = (e) => {
+ changeSet.patch(fieldName, isSingle ? e.target.files[0] : e.target.files);
+ };
+
+ const onChangeSetUpdateHandler = (data) => {
+ if (!data[fieldName]) {
+ el.value = "";
+ }
+ el.dispatchEvent(new CustomEvent(nativeEventName, { detail: data[fieldName] }));
+ };
+
+ changeSet.listen(onChangeSetUpdateHandler);
+ el.addEventListener('change', changeHandler);
+
+ cleanup(() => {
+ changeSet.removeListener(onChangeSetUpdateHandler);
+ el.removeEventListener('change', changeHandler)
+ })
+ } else if (el.type === "checkbox") {
+ const changeHandler = (e) => {
+ changeSet.patch(fieldName, e.target.checked);
+ };
+
+ const onChangeSetUpdateHandler = (data) => {
+ el.checked = !!data[fieldName];
+ el.dispatchEvent(new CustomEvent(nativeEventName, { detail: !!data[fieldName] }));
+ };
+
+ changeSet.listen(onChangeSetUpdateHandler);
+ el.addEventListener('change', changeHandler)
+ el.checked = changeSet.get()[fieldName];
+
+ cleanup(() => {
+ changeSet.removeListener(onChangeSetUpdateHandler);
+ el.removeEventListener('change', changeHandler)
+ })
+ } else if (el.tagName === "textarea" || textInputTypes.includes(el.type)) {
+ const keyupHandler = (e) => changeSet.patch(fieldName, convert(modifiers, e.target.value));
+
+ const onChangeSetUpdateHandler = (data) => {
+ el.value = `${data[fieldName]}`;
+ el.dispatchEvent(new CustomEvent(nativeEventName, { detail: data[fieldName] }));
+ };
+
+ changeSet.listen(onChangeSetUpdateHandler);
+ el.addEventListener('keyup', keyupHandler)
+ el.value = `${changeSet.get()[fieldName]}`;
+
+ cleanup(() => {
+ changeSet.removeListener(onChangeSetUpdateHandler);
+ el.removeEventListener('keyup', keyupHandler)
+ })
+ } else {
+ const changeHandler = (e) => changeSet.patch(fieldName, convert(modifiers, e.target.value));
+
+ const onChangeSetUpdateHandler = (data) => {
+ el.value = `${data[fieldName]}`;
+ el.dispatchEvent(new CustomEvent(nativeEventName, { detail: data[fieldName] }));
+ };
+
+ changeSet.listen(onChangeSetUpdateHandler);
+ el.addEventListener('change', changeHandler)
+ el.value = `${changeSet.get()[fieldName]}`;
+
+ cleanup(() => {
+ changeSet.removeListener(onChangeSetUpdateHandler);
+ el.removeEventListener('change', changeHandler)
+ })
+ }
+ });
+});
\ No newline at end of file
diff --git a/web/template/admin/admin.gohtml b/web/template/admin/admin.gohtml
index 8e7549b56..72a4ff16c 100755
--- a/web/template/admin/admin.gohtml
+++ b/web/template/admin/admin.gohtml
@@ -4,6 +4,7 @@
{{.IndexData.Branding.Title}} | Administration
{{template "headImports" .IndexData.VersionTag}}
+
diff --git a/web/template/partial/course/manage/course-lecture-management.gohtml b/web/template/partial/course/manage/course-lecture-management.gohtml
index 268dc1e33..1c0c38e1c 100644
--- a/web/template/partial/course/manage/course-lecture-management.gohtml
+++ b/web/template/partial/course/manage/course-lecture-management.gohtml
@@ -59,9 +59,8 @@
{{- /* streams: */ -}}
-
+
{{template "lecture-management-card" .}}
diff --git a/web/template/partial/course/manage/lecture-management-card.gohtml b/web/template/partial/course/manage/lecture-management-card.gohtml
index c0b8a831a..6046fc044 100644
--- a/web/template/partial/course/manage/lecture-management-card.gohtml
+++ b/web/template/partial/course/manage/lecture-management-card.gohtml
@@ -6,49 +6,50 @@
{{$ingestBase := .IngestBase}}
{{$lectureHalls := .LectureHalls}}
-
-
+
-
id != lecture.lectureId))"
+
id != lectureData.lectureId))"
class="w-auto mr-2" type="checkbox"/>
-
-
+
-
-
+
-
-
+
Stream Server:
-
+
global.copyToClipboard('{{$ingestBase}}{{$course.Slug}}-'+ lecture.lectureId +'?secret=' + lecture.streamKey)">
+ @click="() => global.copyToClipboard('{{$ingestBase}}{{$course.Slug}}-'+ lectureData.lectureId +'?secret=' + lectureData.streamKey)">
Stream Key:
-
+
global.copyToClipboard(lecture.courseSlug + '-' + lecture.lectureId)">
+ @click="() => global.copyToClipboard(lectureData.courseSlug + '-' + lectureData.lectureId)">
{{if ne $user.Role 1}}
Want to stream from a lecture hall instead? Please reach out to the RBG.
{{end}}
-
+
Converting:
-
+
-
- Unknown Name
-
+ Unknown Name
+
-
+
Generate subtitles
-
🇩🇪
German
-
🇬🇧
@@ -98,7 +99,7 @@
-
+
-
+
-
- VoD
+ :class="lectureData.private?'bg-gray-500':'bg-success'">
+
+ VoD
-
Live
-
Past
-
Scheduled
@@ -184,29 +185,29 @@
class="absolute left-0 mt-2 w-36 bg-white dark:bg-gray-800 border border-black rounded shadow-md overflow-hidden"
>
-
Edit Lecture
-
Delete Lecture
-
+ @click="toggleVisibility(); closeMoreDropdown()"
+ x-show="lectureData.isRecording"
+ :class="lectureData.private?'text-gray-400 dark:hover:text-gray-500 hover:text-gray-300':'text-red-400 dark:hover:text-red-500 hover:text-red-300'">
Make private
-
+
-
Edit Series
-
Delete Series
@@ -217,87 +218,42 @@
-
Edit Lecture
-
+
- { if (lectureId === lecture.lectureId) { progress[type] = progress; }}"
+ { if (lectureId === lectureData.lectureId) { progress[type] = progress; }}"
class="w-full flex justify-center flex-wrap">
-
-
-
- Combined Video
-
-
- Click to upload new Video
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Presentation Video
-
-
- Click to upload new Video
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Camera Video
-
-
- Click to upload new Video
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Click to upload new Video
+
+
+
+
+
+
+
+
{ file = e.detail; }" />
+
+
+
-
+
Title and Description
@@ -307,8 +263,7 @@
Lecture Title
@@ -317,13 +272,12 @@
class="w-full">
Lecture description
+ x-bind-change-set:description="changeSet">
Attach files or URL to files by Drag & Drop in the description field.
@@ -350,7 +304,7 @@
-
{{if eq $user.Role 1}}
@@ -358,15 +312,13 @@
Select Lecture Hall
+ name="lectureHallId"
+ x-bind-change-set.int="changeSet">
Self streaming
{{range $lectureHall := $lectureHalls}}
-
+
{{$lectureHall.Name}}
{{end}}
@@ -375,10 +327,10 @@
{{end}}
{{if eq $user.Role 2}}
-
+
This lecture will be streamed from {{range $lectureHall := $lectureHalls}}{{$lectureHall.Name}} {{end}} .
+ x-show="lectureData.lectureHallId === '{{$lectureHall.Model.ID}}'">{{$lectureHall.Name}}{{end}}.
If you need this changed or want to stream from home (with obs, zoom etc.), please reach
out to the RBG.
@@ -386,18 +338,18 @@
-
+
Attachments
-
+
+ @click="deleteAttachment(file.id)">
@@ -408,17 +360,17 @@
-
+
{{template "editvideosections"}}
-
+
-
+
@@ -427,8 +379,7 @@
diff --git a/web/ts/api/admin-lecture-list.ts b/web/ts/api/admin-lecture-list.ts
new file mode 100644
index 000000000..c7f2d6ac9
--- /dev/null
+++ b/web/ts/api/admin-lecture-list.ts
@@ -0,0 +1,307 @@
+import {
+ del,
+ get,
+ patch,
+ post,
+ postFormData,
+ PostFormDataListener,
+ put,
+ uploadFile,
+} from "../utilities/fetch-wrappers";
+import { StatusCodes } from "http-status-codes";
+
+export interface UpdateLectureMetaRequest {
+ name?: string;
+ description?: string;
+ lectureHallId?: number;
+ isChatEnabled?: boolean;
+}
+
+export class LectureFile {
+ readonly id: number;
+ readonly fileType: number;
+ readonly friendlyName: string;
+
+ constructor({ id, fileType, friendlyName }) {
+ this.id = id;
+ this.fileType = fileType;
+ this.friendlyName = friendlyName;
+ }
+}
+
+export interface CreateNewLectureRequest {
+ title: "";
+ lectureHallId: 0;
+ start: "";
+ end: "";
+ isChatEnabled: false;
+ duration: 0; // Duration in Minutes
+ formatedDuration: ""; // Duration in Minutes
+ premiere: false;
+ vodup: false;
+ adHoc: false;
+ recurring: false;
+ recurringInterval: "weekly";
+ eventsCount: 10;
+ recurringDates: [];
+ combFile: [];
+ presFile: [];
+ camFile: [];
+}
+
+export interface TranscodingProgress {
+ version: string;
+ progress: number;
+}
+
+export interface LectureVideoType {
+ key: string;
+ type: string;
+}
+
+export const LectureVideoTypeComb = {
+ key: "newCombinedVideo",
+ type: "COMB",
+} as LectureVideoType;
+
+export const LectureVideoTypePres = {
+ key: "newPresentationVideo",
+ type: "PRES",
+} as LectureVideoType;
+
+export const LectureVideoTypeCam = {
+ key: "newCameraVideo",
+ type: "CAM",
+} as LectureVideoType;
+
+export const LectureVideoTypes = [
+ LectureVideoTypeComb,
+ LectureVideoTypePres,
+ LectureVideoTypeCam,
+] as LectureVideoType[];
+
+export interface Lecture {
+ color: string;
+ courseId: number;
+ courseSlug: string;
+ description: string;
+ downloadableVods: DownloadableVOD[];
+ end: string;
+ files: LectureFile[];
+ hasStats: boolean;
+ isChatEnabled: boolean;
+ isConverting: boolean;
+ isLiveNow: boolean;
+ isPast: boolean;
+ isRecording: boolean;
+ lectureHallId: number;
+ lectureHallName: string;
+ lectureId: number;
+ name: string;
+ private: boolean;
+ seriesIdentifier: string;
+ start: string;
+ streamKey: string;
+ transcodingProgresses: TranscodingProgress[];
+
+ // Clientside computed fields
+ hasAttachments: boolean;
+ startDate: Date;
+ startDateFormatted: string;
+ startTimeFormatted: string;
+ endDate: Date;
+ endDateFormatted: string;
+ endTimeFormatted: string;
+
+ // Clientside pseudo fields
+ newCombinedVideo: File | null;
+ newPresentationVideo: File | null;
+ newCameraVideo: File | null;
+}
+
+export interface DownloadableVOD {
+ FriendlyName: string;
+ DownloadURL: string;
+}
+
+/**
+ * REST API Wrapper for /api/stream/:id/sections
+ */
+export const AdminLectureList = {
+ /**
+ * Fetches all lectures for a course
+ * @param courseId
+ */
+ get: async function (courseId: number): Promise
{
+ const result = await get(`/api/course/${courseId}/lectures`);
+ return result["streams"];
+ },
+
+ /**
+ * Adds a new lecture to a course.
+ * @param courseId
+ * @param request
+ */
+ add: async function (courseId: number, request: object) {
+ return post(`/api/stream/${courseId}/sections`, request);
+ },
+
+ /**
+ * Updates metadata of a lecture.
+ * @param courseId
+ * @param lectureId
+ * @param request
+ */
+ updateMetadata: async function (courseId: number, lectureId: number, request: UpdateLectureMetaRequest) {
+ const promises = [];
+ if (request.name !== undefined) {
+ promises.push(
+ post(`/api/course/${courseId}/renameLecture/${lectureId}`, {
+ name: request.name,
+ }),
+ );
+ }
+
+ if (request.description !== undefined) {
+ promises.push(
+ put(`/api/course/${courseId}/updateDescription/${lectureId}`, {
+ name: request.description,
+ }),
+ );
+ }
+
+ if (request.lectureHallId !== undefined) {
+ promises.push(
+ post("/api/setLectureHall", {
+ streamIds: [lectureId],
+ lectureHall: request.lectureHallId,
+ }),
+ );
+ }
+
+ if (request.isChatEnabled !== undefined) {
+ promises.push(
+ patch(`/api/stream/${lectureId}/chat/enabled`, {
+ lectureId,
+ isChatEnabled: request.isChatEnabled,
+ }),
+ );
+ }
+
+ const errors = (await Promise.all(promises)).filter((res) => res.status !== StatusCodes.OK);
+ if (errors.length > 0) {
+ console.error(errors);
+ throw Error("Failed to update all data.");
+ }
+ },
+
+ /**
+ * Distributes the lecture metadata of given lecture to all lectures in its series.
+ * @param courseId
+ * @param lectureId
+ */
+ saveSeriesMetadata: async (courseId: number, lectureId: number): Promise => {
+ await post(`/api/course/${courseId}/updateLectureSeries/${lectureId}`);
+ },
+
+ /**
+ * Updates the private state of a lecture.
+ * @param lectureId
+ * @param isPrivate
+ */
+ setPrivate: async (lectureId: number, isPrivate: boolean): Promise => {
+ await patch(`/api/stream/${lectureId}/visibility`, {
+ private: isPrivate,
+ });
+ },
+
+ /**
+ * Uploads a video to a lecture.
+ * @param courseId
+ * @param lectureId
+ * @param videoType
+ * @param file
+ * @param listener
+ */
+ uploadVideo: async (
+ courseId: number,
+ lectureId: number,
+ videoType: string,
+ file: File,
+ listener: PostFormDataListener = {},
+ ) => {
+ await uploadFile(
+ `/api/course/${courseId}/uploadVODMedia?streamID=${lectureId}&videoType=${videoType}`,
+ file,
+ listener,
+ );
+ },
+
+ /**
+ * Upload a file as attachment for a lecture
+ * @param courseId
+ * @param lectureId
+ * @param file
+ * @param listener
+ */
+ uploadAttachmentFile: async (
+ courseId: number,
+ lectureId: number,
+ file: File,
+ listener: PostFormDataListener = {},
+ ) => {
+ return await uploadFile(`/api/stream/${lectureId}/files?type=file`, file, listener);
+ },
+
+ /**
+ * Upload a url as attachment for a lecture
+ * @param courseId
+ * @param lectureId
+ * @param url
+ * @param listener
+ */
+ uploadAttachmentUrl: async (
+ courseId: number,
+ lectureId: number,
+ url: string,
+ listener: PostFormDataListener = {},
+ ) => {
+ const vodUploadFormData = new FormData();
+ vodUploadFormData.append("file_url", url);
+ return postFormData(`/api/stream/${lectureId}/files?type=url`, vodUploadFormData, listener);
+ },
+
+ deleteAttachment: async (courseId: number, lectureId: number, attachmentId: number) => {
+ return del(`/api/stream/${lectureId}/files/${attachmentId}`);
+ },
+
+ /**
+ * Get transcoding progress
+ * @param courseId
+ * @param lectureId
+ * @param version
+ */
+ getTranscodingProgress: async (courseId: number, lectureId: number, version: number): Promise => {
+ return (await fetch(`/api/course/${courseId}/stream/${lectureId}/transcodingProgress?v=${version}`)).json();
+ },
+
+ /**
+ * Deletes a lecture
+ * @param courseId
+ * @param lectureIds
+ */
+ delete: async function (courseId: number, lectureIds: number[]): Promise {
+ return await post(`/api/course/${courseId}/deleteLectures`, {
+ streamIDs: lectureIds.map((id) => `${id}`),
+ });
+ },
+
+ /**
+ * Delete lecture series of a lecture
+ * @param courseId
+ * @param lectureId
+ */
+ deleteSeries: async function (courseId: number, lectureId: number): Promise {
+ return await del(`/api/course/${courseId}/deleteLectureSeries/${lectureId}`);
+ },
+};
diff --git a/web/ts/change-set.ts b/web/ts/change-set.ts
new file mode 100644
index 000000000..ff590d7fe
--- /dev/null
+++ b/web/ts/change-set.ts
@@ -0,0 +1,223 @@
+export interface DirtyState {
+ isDirty: boolean;
+ dirtyKeys: string[];
+}
+
+/**
+ * ## ChangeSet Class
+ *
+ * The `ChangeSet` class is designed to manage and track changes to a state object.
+ * It provides an encapsulated way of observing state changes, committing them,
+ * or rolling them back. Essentially, it helps in maintaining two versions of a state:
+ * one that represents the current, committed state and another that captures all the changes (dirty state).
+ *
+ * ### Features
+ * - **DirtyState**: Utilizes a `DirtyState` object to indicate if the state is dirty (modified but not committed)
+ * and which keys in the state object are dirty.
+ * - **Custom Comparators**: Optionally, you can pass in a custom comparator function to determine how to compare state objects.
+ * - **Event Subscriptions**: Offers an API for listening to changes in the state.
+ *
+ * ### Example Usage
+ * ```typescript
+ * const myState = { key1: 'value1', key2: 'value2' };
+ * const changeSet = new ChangeSet(myState);
+ *
+ * changeSet.listen((changeState, dirtyState) => {
+ * console.log("Changed State:", changeState);
+ * console.log("Is Dirty:", dirtyState.isDirty);
+ * });
+ *
+ * changeSet.patch('key1', 'new_value1');
+ * ```
+ *
+ * ### Methods
+ * - `listen(onUpdate)`: Subscribe to state changes.
+ * - `removeListener(onUpdate)`: Unsubscribe from state changes.
+ * - `get()`: Get the current uncommitted state.
+ * - `set(val)`: Set the change state.
+ * - `patch(key, val, options)`: Patch a specific key in the state object.
+ * - `updateState(state)`: Update the state object, and reconcile it with the change state.
+ * - `commit(options)`: Commit the change state, making it the new committed state.
+ * - `reset()`: Reset the change state back to the last committed state.
+ * - `isDirty()`: Check if the state has changes that are not yet committed.
+ * - `changedKeys()`: Get the keys that have uncommitted changes.
+ *
+ * ### Alpine.js `bind-change-set` Directive Example
+ * The `ChangeSet` class seamlessly integrates with Alpine.js through the `bind-change-set` directive.
+ * For example, to bind a text input element to a `ChangeSet` instance, you can use the following HTML snippet:
+ *
+ * ```html
+ *
+ * ```
+ *
+ * This makes it a useful utility for handling state changes in a predictable way.
+ */
+export class ChangeSet {
+ private state: T;
+ private changeState: T;
+ private readonly comparator?: LectureComparator;
+ private onUpdate: ((changeState: T, dirtyState: DirtyState) => void)[];
+
+ constructor(
+ state: T,
+ comparator?: (key: string, a: T, b: T) => boolean,
+ onUpdate?: (changeState: T, dirtyState: DirtyState) => void,
+ ) {
+ this.state = state;
+ this.onUpdate = onUpdate ? [onUpdate] : [];
+ this.comparator = comparator;
+ this.reset();
+ }
+
+ /**
+ * Add listener to receive change set updates
+ * @param onUpdate
+ */
+ listen(onUpdate: (changeState: T, dirtyState: DirtyState) => void) {
+ this.onUpdate.push(onUpdate);
+ }
+
+ /**
+ * Remove listener from change set.
+ * @param onUpdate
+ */
+ removeListener(onUpdate: (changeState: T, dirtyState: DirtyState) => void) {
+ this.onUpdate = this.onUpdate.filter((o) => o !== onUpdate);
+ }
+
+ /**
+ * Returns the current uncommitted change state.
+ */
+ get(): T {
+ return this.changeState;
+ }
+
+ /**
+ * Sets the change state.
+ * @param val
+ */
+ set(val: T) {
+ this.changeState = { ...val };
+ this.dispatchUpdate();
+ }
+
+ /**
+ * Patches a key with a new value. This makes the state dirty.
+ * @param key
+ * @param val
+ * @param isCommitted if true, the data will be passed also to the state, and won't make the model dirty.
+ */
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ patch(key: string, val: any, { isCommitted = false }: { isCommitted?: boolean } = {}) {
+ this.changeState = { ...this.changeState, [key]: val };
+ if (isCommitted) {
+ this.state = { ...this.state, [key]: val };
+ }
+ this.dispatchUpdate();
+ }
+
+ /**
+ * Updates the state. Also updates all keys that are not dirty on the change-state, so they remain "undirty".
+ * @param state
+ */
+ updateState(state: T) {
+ const changedKeys = this.changedKeys();
+ this.state = { ...state };
+
+ for (const key of Object.keys(this.state)) {
+ if (!changedKeys.includes(key)) {
+ this.changeState[key] = this.state[key];
+ }
+ }
+
+ this.dispatchUpdate();
+ }
+
+ /**
+ * Commits the change state to the state. State is updated to the latest change state afterwards.
+ * @param discardKeys List of keys that should be discarded and not committed.
+ */
+ commit({ discardKeys = [] }: { discardKeys?: string[] } = {}): void {
+ for (const key in discardKeys) {
+ this.changeState[key] = this.state[key];
+ }
+ this.state = { ...this.changeState };
+ this.dispatchUpdate();
+ }
+
+ /**
+ * Resets the change state to the state. Change state is the most current state afterwards.
+ */
+ reset(): void {
+ this.changeState = { ...this.state };
+ this.dispatchUpdate();
+ }
+
+ /**
+ * A flag that indicated whether the change state is the same then the state or not.
+ */
+ isDirty(): boolean {
+ for (const key of Object.keys(this.state)) {
+ if (this.keyChanged(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the keys that are not the same between the state and the change state.
+ */
+ changedKeys(): string[] {
+ const res = [];
+ for (const key of Object.keys(this.state)) {
+ if (this.keyChanged(key)) {
+ res.push(key);
+ }
+ }
+ return res;
+ }
+
+ /**
+ * Checks if a specific key's value is different on the state and the change state.
+ * @param key Key that should be checked
+ */
+ keyChanged(key: string): boolean {
+ // Check with custom comparator if set
+ if (this.comparator !== undefined) {
+ const result = this.comparator(key, this.state, this.changeState);
+ if (result !== null) {
+ return result;
+ }
+ }
+
+ // else just check if equiv
+ return this.state[key] !== this.changeState[key];
+ }
+
+ /**
+ * Executes all onUpdate listeners
+ */
+ dispatchUpdate() {
+ if (this.onUpdate.length > 0) {
+ const dirtyKeys = this.changedKeys();
+ for (const onUpdate of this.onUpdate) {
+ onUpdate(this.changeState, {
+ dirtyKeys,
+ isDirty: dirtyKeys.length > 0,
+ });
+ }
+ }
+ }
+}
+
+export type LectureComparator = (key: string, a: T, b: T) => boolean | null;
+
+export function ignoreKeys(list: string[]): LectureComparator {
+ return (key: string, a, b) => {
+ if (list.includes(key)) {
+ return false;
+ }
+ return null;
+ };
+}
diff --git a/web/ts/data-store/admin-lecture-list.ts b/web/ts/data-store/admin-lecture-list.ts
new file mode 100644
index 000000000..077a71984
--- /dev/null
+++ b/web/ts/data-store/admin-lecture-list.ts
@@ -0,0 +1,179 @@
+import { StreamableMapProvider } from "./provider";
+import { AdminLectureList, Lecture, LectureFile, UpdateLectureMetaRequest } from "../api/admin-lecture-list";
+import { FileType } from "../edit-course";
+import { UploadFileListener } from "../global";
+
+const dateFormatOptions: Intl.DateTimeFormatOptions = {
+ weekday: "long",
+ year: "numeric",
+ month: "short",
+ day: "2-digit",
+};
+const timeFormatOptions: Intl.DateTimeFormatOptions = {
+ hour: "2-digit",
+ minute: "2-digit",
+};
+
+export interface UpdateMetaProps {
+ payload: UpdateLectureMetaRequest;
+ options?: {
+ saveSeries?: boolean;
+ };
+}
+
+export class AdminLectureListProvider extends StreamableMapProvider {
+ protected async fetcher(courseId: number): Promise {
+ const result = await AdminLectureList.get(courseId);
+ return result.map((s) => {
+ s.hasAttachments = (s.files || []).some((f) => f.fileType === FileType.attachment);
+
+ s.startDate = new Date(s.start);
+ s.startDateFormatted = s.startDate.toLocaleDateString("en-US", dateFormatOptions);
+ s.startTimeFormatted = s.startDate.toLocaleTimeString("en-US", timeFormatOptions);
+
+ s.endDate = new Date(s.end);
+ s.endDateFormatted = s.endDate.toLocaleDateString("en-US", dateFormatOptions);
+ s.endTimeFormatted = s.endDate.toLocaleTimeString("en-US", timeFormatOptions);
+
+ s.newCombinedVideo = null;
+ s.newPresentationVideo = null;
+ s.newCameraVideo = null;
+
+ return s;
+ });
+ }
+
+ async add(courseId: number, lecture: Lecture): Promise {
+ await AdminLectureList.add(courseId, lecture);
+ await this.fetch(courseId, true);
+ await this.triggerUpdate(courseId);
+ }
+
+ async setPrivate(courseId: number, lectureId: number, isPrivate: boolean) {
+ await AdminLectureList.setPrivate(lectureId, isPrivate);
+ this.data[courseId] = (await this.getData(courseId)).map((s) => {
+ if (s.lectureId !== lectureId) {
+ return s;
+ }
+ return {
+ ...s,
+ private: !s.private,
+ };
+ });
+ await this.triggerUpdate(courseId);
+ }
+
+ async delete(courseId: number, lectureIds: number[]) {
+ await AdminLectureList.delete(courseId, lectureIds);
+ this.data[courseId] = (await this.getData(courseId)).filter((s) => !lectureIds.includes(s.lectureId));
+ await this.triggerUpdate(courseId);
+ }
+
+ async deleteSeries(courseId: number, lectureId: number) {
+ await AdminLectureList.deleteSeries(courseId, lectureId);
+
+ const lectures = await this.getData(courseId);
+ const seriesIdentifier = lectures.find((l) => l.lectureId === lectureId)?.seriesIdentifier ?? null;
+ const lectureIds = lectures.filter((l) => l.seriesIdentifier === seriesIdentifier).map((l) => l.lectureId);
+
+ this.data[courseId] = lectures.filter((s) => !lectureIds.includes(s.lectureId));
+ await this.triggerUpdate(courseId);
+ }
+
+ async updateMeta(courseId: number, lectureId: number, props: UpdateMetaProps) {
+ const updateSeries = props?.options?.saveSeries === true;
+ const seriesIdentifier =
+ (await this.getData(courseId)).find((l) => l.lectureId === lectureId)?.seriesIdentifier ?? null;
+
+ await AdminLectureList.updateMetadata(courseId, lectureId, props.payload);
+ if (updateSeries) {
+ await AdminLectureList.saveSeriesMetadata(courseId, lectureId);
+ }
+
+ this.data[courseId] = (await this.getData(courseId)).map((s) => {
+ const isLecture = s.lectureId === lectureId;
+ const isInLectureSeries = s.seriesIdentifier === seriesIdentifier;
+
+ if (isLecture || (updateSeries && isInLectureSeries)) {
+ s = {
+ ...s,
+ };
+
+ // Patch updated keys in local data
+ for (const requestKey in props.payload) {
+ const val = props.payload[requestKey];
+ if (val !== undefined) {
+ s[requestKey] = val;
+ }
+ }
+ }
+ return s;
+ });
+ await this.triggerUpdate(courseId);
+ }
+
+ async uploadAttachmentFile(courseId: number, lectureId: number, file: File) {
+ const res = await AdminLectureList.uploadAttachmentFile(courseId, lectureId, file);
+ const newFile = new LectureFile({
+ id: JSON.parse(res.responseText),
+ fileType: 2,
+ friendlyName: file.name,
+ });
+
+ this.data[courseId] = (await this.getData(courseId)).map((s) => {
+ if (s.lectureId === lectureId) {
+ return {
+ ...s,
+ files: [...s.files, newFile],
+ };
+ }
+ return s;
+ });
+ await this.triggerUpdate(courseId);
+ }
+
+ async uploadAttachmentUrl(courseId: number, lectureId: number, url: string) {
+ const res = await AdminLectureList.uploadAttachmentUrl(courseId, lectureId, url);
+ const newFile = new LectureFile({
+ id: JSON.parse(res.responseText),
+ fileType: 2,
+ friendlyName: url.substring(url.lastIndexOf("/") + 1),
+ });
+
+ this.data[courseId] = (await this.getData(courseId)).map((s) => {
+ if (s.lectureId === lectureId) {
+ return {
+ ...s,
+ files: [...s.files, newFile],
+ };
+ }
+ return s;
+ });
+ await this.triggerUpdate(courseId);
+ }
+
+ async deleteAttachment(courseId: number, lectureId: number, attachmentId: number) {
+ await AdminLectureList.deleteAttachment(courseId, lectureId, attachmentId);
+
+ this.data[courseId] = (await this.getData(courseId)).map((s) => {
+ if (s.lectureId === lectureId) {
+ return {
+ ...s,
+ files: [...s.files.filter((a) => a.id !== attachmentId)],
+ };
+ }
+ return s;
+ });
+ await this.triggerUpdate(courseId);
+ }
+
+ async uploadVideo(
+ courseId: number,
+ lectureId: number,
+ videoType: string,
+ file: File,
+ listener: UploadFileListener = {},
+ ) {
+ await AdminLectureList.uploadVideo(courseId, lectureId, videoType, file, listener);
+ }
+}
diff --git a/web/ts/data-store/data-store.ts b/web/ts/data-store/data-store.ts
index 381f2efc4..39890a666 100644
--- a/web/ts/data-store/data-store.ts
+++ b/web/ts/data-store/data-store.ts
@@ -1,9 +1,13 @@
import { VideoSectionProvider } from "./video-sections";
import { BookmarksProvider } from "./bookmarks";
import { StreamPlaylistProvider } from "./stream-playlist";
+import { AdminLectureListProvider } from "./admin-lecture-list";
export abstract class DataStore {
static bookmarks: BookmarksProvider = new BookmarksProvider();
static videoSections: VideoSectionProvider = new VideoSectionProvider();
static streamPlaylist: StreamPlaylistProvider = new StreamPlaylistProvider();
+
+ // Admin Data-Stores
+ static adminLectureList: AdminLectureListProvider = new AdminLectureListProvider();
}
diff --git a/web/ts/edit-course.ts b/web/ts/edit-course.ts
index 0b1e65ab7..039559e60 100644
--- a/web/ts/edit-course.ts
+++ b/web/ts/edit-course.ts
@@ -1,5 +1,17 @@
-import { Delete, patchData, postData, putData, sendFormData, showMessage } from "./global";
+import { Delete, patchData, postData, putData, sendFormData, showMessage, uploadFile } from "./global";
import { StatusCodes } from "http-status-codes";
+import { DataStore } from "./data-store/data-store";
+import {
+ AdminLectureList,
+ Lecture,
+ LectureVideoType,
+ LectureVideoTypeCam,
+ LectureVideoTypeComb,
+ LectureVideoTypePres,
+ LectureVideoTypes,
+} from "./api/admin-lecture-list";
+import { ChangeSet, ignoreKeys } from "./change-set";
+import { AlpineComponent } from "./components/alpine-component";
export enum UIEditMode {
none,
@@ -18,36 +30,30 @@ export enum FileType {
}
export class LectureList {
- static lectures: Lecture[] = [];
- static markedIds: number[] = [];
-
- static init(initialState: Lecture[]) {
- if (initialState === null) {
- initialState = [];
- }
+ courseId: number;
+ lectures: Lecture[] = [];
+ markedIds: number[] = [];
+ constructor(courseId: number) {
+ this.courseId = courseId;
this.markedIds = this.parseUrlHash();
+ this.setup();
+ }
- // load initial state into lecture objects
- for (const lecture of initialState) {
- let l = new Lecture();
- l = Object.assign(l, lecture);
- l.start = new Date(lecture.start);
- l.end = new Date(lecture.end);
- LectureList.lectures.push(l);
- }
-
- LectureList.triggerUpdate();
- setTimeout(() => this.scrollSelectedLecturesIntoView(), 500);
+ async setup() {
+ await DataStore.adminLectureList.subscribe(this.courseId, (lectures) => {
+ this.lectures = lectures;
+ this.triggerUpdateEvent();
+ });
}
- static scrollSelectedLecturesIntoView() {
+ scrollSelectedLecturesIntoView() {
if (this.markedIds.length > 0) {
document.querySelector(`#lecture-${this.markedIds[0]}`).scrollIntoView({ behavior: "smooth" });
}
}
- static parseUrlHash(): number[] {
+ parseUrlHash(): number[] {
if (!window.location.hash.startsWith("#lectures:")) {
return [];
}
@@ -57,17 +63,17 @@ export class LectureList {
.map((s) => parseInt(s));
}
- static hasIndividualChatEnabledSettings(): boolean {
+ hasIndividualChatEnabledSettings(): boolean {
const lectures = this.lectures;
if (lectures.length < 2) return false;
const first = lectures[0];
- return lectures.slice(1).some((lecture) => lecture.isChatEnabled !== first.isChatEnabled);
+ return lectures.slice(1).some((l) => l.isChatEnabled !== first.isChatEnabled);
}
- static triggerUpdate() {
+ triggerUpdateEvent() {
const event = new CustomEvent("newlectures", {
detail: {
- lectures: LectureList.lectures,
+ lectures: this.lectures,
markedIds: this.markedIds,
},
});
@@ -75,426 +81,198 @@ export class LectureList {
}
}
-class LectureFile {
- readonly id: number;
- readonly fileType: number;
- readonly friendlyName: string;
-
- constructor({ id, fileType, friendlyName }) {
- this.id = id;
- this.fileType = fileType;
- this.friendlyName = friendlyName;
- }
-}
-
class DownloadableVod {
downloadURL: string;
friendlyName: string;
}
-class TranscodingProgress {
- version: string;
- progress: number;
+interface VideoFileUI {
+ info: LectureVideoType;
+ title: string;
+ inputId: string;
}
-export class Lecture {
- static dateFormatOptions: Intl.DateTimeFormatOptions = {
- weekday: "long",
- year: "numeric",
- month: "short",
- day: "2-digit",
- };
- static timeFormatOptions: Intl.DateTimeFormatOptions = {
- hour: "2-digit",
- minute: "2-digit",
- };
- readonly courseId: number;
- readonly courseSlug: string;
- readonly lectureId: number;
- readonly streamKey: string;
- readonly seriesIdentifier: string;
- color: string;
- readonly vodViews: number;
- start: Date;
- end: Date;
- readonly isLiveNow: boolean;
- isConverting: boolean;
- readonly isRecording: boolean;
- readonly isPast: boolean;
- readonly hasStats: boolean;
-
- name: string;
- description: string;
- lectureHallId: string;
- lectureHallName: string;
- isChatEnabled = false;
- uiEditMode: UIEditMode = UIEditMode.none;
- newName: string;
- newDescription: string;
- newLectureHallId: string;
- newIsChatEnabled = false;
- newCombinedVideo = null;
- newPresentationVideo = null;
- newCameraVideo = null;
- isDirty = false;
- isSaving = false;
- isDeleted = false;
- lastErrors: string[] = [];
- transcodingProgresses: TranscodingProgress[];
- files: LectureFile[];
- private: boolean;
- downloadableVods: DownloadableVod[];
-
- clone() {
- return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
- }
-
- startDateFormatted() {
- return this.start.toLocaleDateString("en-US", Lecture.dateFormatOptions);
- }
-
- startTimeFormatted() {
- return this.start.toLocaleTimeString("en-US", Lecture.timeFormatOptions);
- }
-
- endFormatted() {
- return this.end.toLocaleDateString("en-US", Lecture.dateFormatOptions);
- }
-
- endTimeFormatted() {
- return this.end.toLocaleTimeString("en-US", Lecture.timeFormatOptions);
- }
-
- updateIsDirty() {
- this.isDirty =
- this.newName !== this.name ||
- this.newDescription !== this.description ||
- this.newLectureHallId !== this.lectureHallId ||
- this.newIsChatEnabled !== this.isChatEnabled ||
- this.newCombinedVideo !== null ||
- this.newPresentationVideo !== null ||
- this.newCameraVideo !== null;
- }
-
- resetNewFields() {
- this.newName = this.name;
- this.newDescription = this.description;
- this.newLectureHallId = this.lectureHallId;
- this.newIsChatEnabled = this.isChatEnabled;
- this.isDirty = false;
- this.lastErrors = [];
- this.newCombinedVideo = null;
- this.newPresentationVideo = null;
- this.newCameraVideo = null;
- }
-
- startSeriesEdit() {
- if (this.uiEditMode !== UIEditMode.none) return;
- this.resetNewFields();
- this.uiEditMode = UIEditMode.series;
- }
-
- startSingleEdit() {
- if (this.uiEditMode !== UIEditMode.none) return;
- this.resetNewFields();
- this.uiEditMode = UIEditMode.single;
- }
+export function lectureEditor(lecture: Lecture): AlpineComponent {
+ return {
+ videoFiles: [
+ { info: LectureVideoTypeComb, title: "Combined Video", inputId: `input_${lecture.lectureId}_comb` },
+ { info: LectureVideoTypePres, title: "Presentation Video", inputId: `input_${lecture.lectureId}_pres` },
+ { info: LectureVideoTypeCam, title: "Camera Video", inputId: `input_${lecture.lectureId}_cam` },
+ ] as VideoFileUI[],
+
+ // UI Data
+ lastErrors: [] as string[],
+ uiEditMode: UIEditMode.none,
+ isDirty: false,
+ isSaving: false,
+
+ // Lecture Data
+ changeSet: null as ChangeSet | null,
+ lectureData: null as Lecture | null,
+
+ /**
+ * AlpineJS init function which is called automatically in addition to 'x-init'
+ */
+ init() {
+ // This tracks changes that are not saved yet
+ this.changeSet = new ChangeSet(lecture, ignoreKeys(["files"]), (data, dirtyState) => {
+ this.lectureData = data;
+ this.isDirty = dirtyState.isDirty;
+ });
- async toggleVisibility() {
- fetch(`/api/stream/${this.lectureId}/visibility`, {
- method: "PATCH",
- body: JSON.stringify({ private: !this.private }),
- headers: { "Content-Type": "application/json" },
- }).then((r) => {
- if (r.status == StatusCodes.OK) {
- this.private = !this.private;
- if (this.private) {
- this.color = "gray-500";
- } else {
- this.color = "success";
+ // This updates the state live in background
+ DataStore.adminLectureList.subscribe(lecture.courseId, (lectureList) => {
+ const update = lectureList.find((l) => l.lectureId === lecture.lectureId);
+ if (update) {
+ this.changeSet.updateState(update);
}
- }
- });
- }
-
- async keepProgressesUpdated() {
- if (!this.isConverting) {
- return;
- }
- setTimeout(() => {
- for (let i = 0; i < this.transcodingProgresses.length; i++) {
- fetch(
- `/api/course/${this.courseId}/stream/${this.lectureId}/transcodingProgress?v=${this.transcodingProgresses[i].version}`,
- )
- .then((r) => {
- return r.json() as Promise;
- })
- .then((r) => {
- if (r === 100) {
- this.transcodingProgresses.splice(i, 1);
- } else {
- this.transcodingProgresses[i].progress = r;
- }
- });
- }
- this.isConverting = this.transcodingProgresses.length > 0;
- this.keepProgressesUpdated();
- }, 5000);
- }
-
- async saveEdit() {
- this.lastErrors = [];
- if (this.uiEditMode === UIEditMode.none) return;
-
- this.isSaving = true;
-
- // Save Settings
- const promises = [];
- if (this.newName !== this.name) promises.push(this.saveNewLectureName());
- if (this.newDescription !== this.description) promises.push(this.saveNewLectureDescription());
- if (this.newLectureHallId !== this.lectureHallId) promises.push(this.saveNewLectureHall());
- if (this.newIsChatEnabled !== this.isChatEnabled) promises.push(this.saveNewIsChatEnabled());
- const errors = (await Promise.all(promises)).filter((res) => res.status !== StatusCodes.OK);
-
- if (this.uiEditMode === UIEditMode.series && errors.length === 0) {
- const seriesUpdateResult = await this.saveSeries();
- if (seriesUpdateResult.status !== StatusCodes.OK) {
- errors.push(seriesUpdateResult);
- }
- }
+ });
+ },
- if (errors.length > 0) {
- this.lastErrors = await Promise.all(
- errors.map((e) => {
- const text = e.text();
- try {
- const msg = JSON.parse(text).msg;
- if (msg != null && msg.length > 0) {
- return msg;
- }
- // eslint-disable-next-line no-empty
- } catch (_) {}
- return text;
- }),
+ toggleVisibility() {
+ DataStore.adminLectureList.setPrivate(
+ this.lectureData.courseId,
+ this.lectureData.lectureId,
+ !this.lectureData.private,
);
- this.isSaving = false;
- return false;
- }
-
- // Upload new media files
- const uploadErrors = await this.saveNewVODMedia();
-
- if (uploadErrors.length > 0) {
- this.lastErrors = uploadErrors;
- this.isSaving = false;
- return false;
- }
-
- this.uiEditMode = UIEditMode.none;
- this.isSaving = false;
- return true;
- }
-
- discardEdit() {
- this.uiEditMode = UIEditMode.none;
- }
-
- async saveNewLectureName() {
- const res = await postData("/api/course/" + this.courseId + "/renameLecture/" + this.lectureId, {
- name: this.newName,
- });
-
- if (res.status == StatusCodes.OK) {
- this.name = this.newName;
- }
-
- return res;
- }
-
- async saveNewLectureDescription() {
- const res = await putData("/api/course/" + this.courseId + "/updateDescription/" + this.lectureId, {
- name: this.newDescription,
- });
-
- if (res.status == StatusCodes.OK) {
- this.description = this.newDescription;
- }
-
- return res;
- }
-
- async saveNewLectureHall() {
- const res = await saveLectureHall([this.lectureId], this.newLectureHallId);
-
- if (res.status == StatusCodes.OK) {
- this.lectureHallId = this.newLectureHallId;
- this.lectureHallName = "";
- }
-
- return res;
- }
-
- async saveNewIsChatEnabled() {
- const res = await saveIsChatEnabled(this.lectureId, this.newIsChatEnabled);
-
- if (res.status == StatusCodes.OK) {
- this.isChatEnabled = this.newIsChatEnabled;
- } else {
- res.text().then((t) => showMessage(t));
- }
- return res;
- }
+ },
- async saveNewVODMedia(): Promise {
- const errors = [];
- const mediaInfo = [
- { key: "newCombinedVideo", type: "COMB" },
- { key: "newPresentationVideo", type: "PRES" },
- { key: "newCameraVideo", type: "CAM" },
- ];
+ async keepProgressesUpdated() {
+ if (!this.isConverting) {
+ return;
+ }
+ setTimeout(async () => {
+ for (let i = 0; i < this.transcodingProgresses.length; i++) {
+ const res = await AdminLectureList.getTranscodingProgress(
+ this.lectureData.courseId,
+ this.lectureData.lectureId,
+ this.transcodingProgresses[i].version,
+ );
+ if (res === 100) {
+ this.transcodingProgresses.splice(i, 1);
+ } else {
+ this.transcodingProgresses[i].progress = res;
+ }
+ }
+ this.isConverting = this.transcodingProgresses.length > 0;
+ this.keepProgressesUpdated();
+ }, 5000);
+ },
- for (const info of mediaInfo) {
- const mediaFile = this[info.key];
- if (mediaFile === null) continue;
+ getVideoFile(key: string): File {
+ return this.lectureData[key];
+ },
- try {
- await uploadFilePost(
- `/api/course/${this.courseId}/uploadVODMedia?streamID=${this.lectureId}&videoType=${info.type}`,
- mediaFile,
- (progress) => {
- window.dispatchEvent(
- new CustomEvent(`voduploadprogressedit`, {
- detail: { type: info.type, progress, lectureId: this.lectureId },
- }),
+ onAttachmentFileDrop(e) {
+ if (e.dataTransfer.items) {
+ const item = e.dataTransfer.items[0];
+ const { kind } = item;
+ switch (kind) {
+ case "file": {
+ DataStore.adminLectureList.uploadAttachmentFile(
+ this.lectureData.courseId,
+ this.lectureData.lectureId,
+ item.getAsFile(),
);
- },
- );
- this[info.key] = null;
- } catch (e) {
- const error = `Failed to upload "${mediaFile.name}".`;
- errors.push(error);
- }
- }
- return errors;
- }
-
- async saveSeries() {
- const res = await postData("/api/course/" + this.courseId + "/updateLectureSeries/" + this.lectureId);
-
- if (res.status == StatusCodes.OK) {
- LectureList.lectures = LectureList.lectures.map((lecture) => {
- if (this.lectureId !== lecture.lectureId && lecture.seriesIdentifier === this.seriesIdentifier) {
- /* cloning, as otherwise alpine doesn't detect the changed object in the array ... */
- lecture = lecture.clone();
- lecture.name = this.name;
- lecture.description = this.description;
- lecture.lectureHallId = this.lectureHallId;
- lecture.uiEditMode = UIEditMode.none;
+ break;
+ }
+ case "string": {
+ DataStore.adminLectureList.uploadAttachmentUrl(
+ this.lectureData.courseId,
+ this.lectureData.lectureId,
+ e.dataTransfer.getData("URL"),
+ );
+ }
}
- return lecture;
- });
- LectureList.triggerUpdate();
- }
-
- return res;
- }
+ }
+ },
- async deleteLecture() {
- if (confirm("Confirm deleting video?")) {
- const res = await postData("/api/course/" + this.courseId + "/deleteLectures", {
- streamIDs: [this.lectureId.toString()],
- });
+ deleteAttachment(id: number) {
+ DataStore.adminLectureList.deleteAttachment(this.lectureData.courseId, this.lectureData.lectureId, id);
+ },
- if (res.status !== StatusCodes.OK) {
- alert("An unknown error occurred during the deletion process!");
- return;
- }
+ deleteLecture() {
+ DataStore.adminLectureList.delete(this.lectureData.courseId, [this.lectureData.lectureId]);
+ },
- LectureList.lectures = LectureList.lectures.filter((l) => l.lectureId !== this.lectureId);
- LectureList.triggerUpdate();
- }
- }
+ deleteLectureSeries() {
+ DataStore.adminLectureList.deleteSeries(this.lectureData.courseId, this.lectureData.lectureId);
+ },
- async deleteLectureSeries() {
- const lectureCount = LectureList.lectures.filter((l) => l.seriesIdentifier === this.seriesIdentifier).length;
- if (confirm("Confirm deleting " + lectureCount + " videos in the lecture series?")) {
- const res = await Delete("/api/course/" + this.courseId + "/deleteLectureSeries/" + this.lectureId);
+ /**
+ * Opens the series lecture editor UI
+ */
+ startSeriesEdit() {
+ if (this.uiEditMode !== UIEditMode.none) return;
+ this.changeSet.reset();
+ this.uiEditMode = UIEditMode.series;
+ },
- if (res.status === StatusCodes.OK) {
- LectureList.lectures = LectureList.lectures.filter((l) => l.seriesIdentifier !== this.seriesIdentifier);
- LectureList.triggerUpdate();
- }
+ /**
+ * Opens the single lecture editor UI
+ */
+ startSingleEdit() {
+ if (this.uiEditMode !== UIEditMode.none) return;
+ this.changeSet.reset();
+ this.uiEditMode = UIEditMode.single;
+ },
- return res;
- }
- }
+ /**
+ * Discards current changes
+ */
+ discardEdit() {
+ this.changeSet.reset();
+ this.uiEditMode = UIEditMode.none;
+ },
- getDownloads() {
- return this.downloadableVods;
- }
+ /**
+ * Save changes send them to backend and commit change set.
+ */
+ async saveEdit() {
+ const { courseId, lectureId, name, description, lectureHallId, isChatEnabled } = this.lectureData;
+ const changedKeys = this.changeSet.changedKeys();
- async deleteFile(fileId: number) {
- await fetch(`/api/stream/${this.lectureId}/files/${fileId}`, {
- method: "DELETE",
- })
- .catch((err) => console.log(err))
- .then(() => {
- this.files = this.files.filter((f) => f.id !== fileId);
- });
- }
+ try {
+ // Saving new meta data
+ await DataStore.adminLectureList.updateMeta(courseId, lectureId, {
+ payload: {
+ name: changedKeys.includes("name") ? name : undefined,
+ description: changedKeys.includes("description") ? description : undefined,
+ lectureHallId: changedKeys.includes("lectureHallId") ? lectureHallId : undefined,
+ isChatEnabled: changedKeys.includes("isChatEnabled") ? isChatEnabled : undefined,
+ },
+ options: {
+ saveSeries: this.uiEditMode === UIEditMode.series,
+ },
+ });
- hasAttachments(): boolean {
- if (this.files !== undefined && this.files !== null) {
- const filtered = this.files.filter((f) => f.fileType === FileType.attachment);
- return filtered.length > 0;
- }
- return false;
- }
+ // Uploading new videos
+ for (const videoFile of this.videoFiles) {
+ if (!changedKeys.includes(videoFile.info.key)) {
+ continue;
+ }
- onFileDrop(e) {
- if (e.dataTransfer.items) {
- const kind = e.dataTransfer.items[0].kind;
- switch (kind) {
- case "file": {
- this.postFile(e.dataTransfer.items[0].getAsFile());
- break;
- }
- case "string": {
- this.postFileAsURL(e.dataTransfer.getData("URL"));
+ const file = this.lectureData[videoFile.info.key];
+ await DataStore.adminLectureList.uploadVideo(courseId, lectureId, videoFile.info.type, file, {
+ onProgress: (progress) => {
+ window.dispatchEvent(
+ new CustomEvent(`voduploadprogressedit`, {
+ detail: { type: videoFile.info.type, progress, lectureId: this.lectureId },
+ }),
+ );
+ },
+ });
}
+ } catch (e) {
+ console.error(e);
+ this.lastErrors = [e.message];
+ return;
}
- }
- }
-
- private async postFile(file) {
- const formData = new FormData();
- formData.append("file", file);
- await fetch(`/api/stream/${this.lectureId}/files?type=file`, {
- method: "POST",
- body: formData,
- }).then((res) =>
- res.json().then((id) => {
- const friendlyName = file.name;
- const fileType = 2;
- this.files.push(new LectureFile({ id, fileType, friendlyName }));
- }),
- );
- }
- private async postFileAsURL(fileURL) {
- const formData = new FormData();
- formData.append("file_url", fileURL);
- await fetch(`/api/stream/${this.lectureId}/files?type=url`, {
- method: "POST",
- body: formData,
- }).then((res) =>
- res.json().then((id) => {
- const friendlyName = fileURL.substring(fileURL.lastIndexOf("/") + 1);
- const fileType = 2;
- this.files.push(new LectureFile({ id, fileType, friendlyName }));
- }),
- );
- }
+ this.changeSet.commit({ discardKeys: this.videoFiles.map((v) => v.info.key) });
+ this.uiEditMode = UIEditMode.none;
+ },
+ } as AlpineComponent;
}
export function decodeHtml(html) {
@@ -514,8 +292,8 @@ export async function deleteLectures(cid: number, lids: number[]) {
return;
}
- LectureList.lectures = LectureList.lectures.filter((l) => !lids.includes(l.lectureId));
- LectureList.triggerUpdate();
+ //LectureList.lectures = LectureList.lectures.filter((l) => !lids.includes(l.lectureId));
+ //LectureList.triggerUpdate();
}
}
@@ -525,9 +303,9 @@ export function saveIsChatEnabled(streamId: number, isChatEnabled: boolean) {
export async function saveIsChatEnabledForAllLectures(isChatEnabled: boolean) {
const promises = [];
- for (const lecture of LectureList.lectures) {
+ /*for (const lecture of LectureList.lectures) {
promises.push(saveIsChatEnabled(lecture.lectureId, isChatEnabled));
- }
+ }*/
const errors = (await Promise.all(promises)).filter((res) => res.status !== StatusCodes.OK);
return errors.length <= 0;
}
@@ -1126,12 +904,14 @@ export function createLectureForm(args: { s: [] }) {
// Upload media
try {
for (const mediaUpload of mediaFiles) {
- await uploadFilePost(
+ await uploadFile(
`/api/course/${this.courseID}/uploadVODMedia?streamID=${streamID}&videoType=${mediaUpload.type}`,
mediaUpload.file,
- (progress) => {
- mediaUpload.progress = progress;
- this.dispatchMediaProgress(mediaFiles);
+ {
+ onProgress: (progress) => {
+ mediaUpload.progress = progress;
+ this.dispatchMediaProgress(mediaFiles);
+ },
},
);
}
@@ -1147,29 +927,6 @@ export function createLectureForm(args: { s: [] }) {
};
}
-function uploadFilePost(url: string, file: File, onProgress: (progress: number) => void): Promise {
- const xhr = new XMLHttpRequest();
- const vodUploadFormData = new FormData();
- vodUploadFormData.append("file", file);
- return new Promise((resolve, reject) => {
- xhr.onloadend = () => {
- if (xhr.status === 200) {
- resolve(xhr.responseText);
- } else {
- reject();
- }
- };
- xhr.upload.onprogress = (e: ProgressEvent) => {
- if (!e.lengthComputable) {
- return;
- }
- onProgress(Math.floor(100 * (e.loaded / e.total)));
- };
- xhr.open("POST", url);
- xhr.send(vodUploadFormData);
- });
-}
-
export function sendCourseSettingsForm(courseId: number) {
const form = document.getElementById("course-settings-form") as HTMLFormElement;
const formData = new FormData(form);
diff --git a/web/ts/global.ts b/web/ts/global.ts
index 5fe96316d..a0069080a 100644
--- a/web/ts/global.ts
+++ b/web/ts/global.ts
@@ -45,6 +45,39 @@ export async function Delete(url = "") {
});
}
+export interface UploadFileListener {
+ onProgress?: (progress: number) => void;
+}
+
+export function uploadFile(url: string, file: File, listener: UploadFileListener = {}): Promise {
+ const vodUploadFormData = new FormData();
+ vodUploadFormData.append("file", file);
+ return postFormData(url, vodUploadFormData, listener);
+}
+
+export function postFormData(url: string, data: FormData, listener: UploadFileListener = {}): Promise {
+ const xhr = new XMLHttpRequest();
+ return new Promise((resolve, reject) => {
+ xhr.onloadend = () => {
+ if (xhr.status === 200) {
+ resolve(xhr);
+ } else {
+ reject(xhr);
+ }
+ };
+ xhr.upload.onprogress = (e: ProgressEvent) => {
+ if (!e.lengthComputable) {
+ return;
+ }
+ if (listener.onProgress) {
+ listener.onProgress(Math.floor(100 * (e.loaded / e.total)));
+ }
+ };
+ xhr.open("POST", url);
+ xhr.send(data);
+ });
+}
+
export function sendFormData(url, formData: FormData) {
const HttpReq = new XMLHttpRequest();
HttpReq.open("POST", url, false);
diff --git a/web/ts/utilities/fetch-wrappers.ts b/web/ts/utilities/fetch-wrappers.ts
index 797912d56..ed4bf4f0c 100644
--- a/web/ts/utilities/fetch-wrappers.ts
+++ b/web/ts/utilities/fetch-wrappers.ts
@@ -42,6 +42,44 @@ export async function post(url: string, body: object = {}) {
});
}
+export interface PostFormDataListener {
+ onProgress?: (progress: number) => void;
+}
+
+/**
+ * Wrapper for XMLHttpRequest to send form data via POST
+ * @param {string} url URL to send to
+ * @param {FormData} body form-data
+ * @param {PostFormDataListener} listener attached progress listeners
+ * @return {Promise}
+ */
+export function postFormData(
+ url: string,
+ body: FormData,
+ listener: PostFormDataListener = {},
+): Promise {
+ const xhr = new XMLHttpRequest();
+ return new Promise((resolve, reject) => {
+ xhr.onloadend = () => {
+ if (xhr.status === 200) {
+ resolve(xhr);
+ } else {
+ reject(xhr);
+ }
+ };
+ xhr.upload.onprogress = (e: ProgressEvent) => {
+ if (!e.lengthComputable) {
+ return;
+ }
+ if (listener.onProgress) {
+ listener.onProgress(Math.floor(100 * (e.loaded / e.total)));
+ }
+ };
+ xhr.open("POST", url);
+ xhr.send(body);
+ });
+}
+
/**
* Wrapper for Javascript's fetch function for PUT
* @param {string} url URL to fetch
@@ -63,6 +101,27 @@ export async function put(url = "", body: object = {}) {
});
}
+/**
+ * Wrapper for Javascript's fetch function for PATCH
+ * @param {string} url URL to fetch
+ * @param {object} body Data object to put
+ * @return {Promise}
+ */
+export async function patch(url = "", body = {}) {
+ return await fetch(url, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ }).then((res) => {
+ if (!res.ok) {
+ throw Error(res.statusText);
+ }
+ return res;
+ });
+}
+
/**
* Wrapper for Javascript's fetch function for DELETE
* @param {string} url URL to fetch
@@ -71,3 +130,15 @@ export async function put(url = "", body: object = {}) {
export async function del(url: string) {
return await fetch(url, { method: "DELETE" });
}
+
+/**
+ * Wrapper for XMLHttpRequest to upload a file
+ * @param url URL to upload to
+ * @param file File to be uploaded
+ * @param listener Upload progress listeners
+ */
+export function uploadFile(url: string, file: File, listener: PostFormDataListener = {}): Promise {
+ const vodUploadFormData = new FormData();
+ vodUploadFormData.append("file", file);
+ return postFormData(url, vodUploadFormData, listener);
+}