diff --git a/api/courses.go b/api/courses.go
index 7502d87f5..c092cb4d8 100644
--- a/api/courses.go
+++ b/api/courses.go
@@ -70,6 +70,7 @@ func configGinCourseRouter(router *gin.Engine, daoWrapper dao.DaoWrapper) {
courses.POST("/deleteLectures", routes.deleteLectures)
courses.POST("/renameLecture/:streamID", routes.renameLecture)
courses.POST("/updateLectureSeries/:streamID", routes.updateLectureSeries)
+ courses.POST("/updateStartEnd/:streamID", routes.updateStartEnd)
courses.PUT("/updateDescription/:streamID", routes.updateDescription)
courses.DELETE("/deleteLectureSeries/:streamID", routes.deleteLectureSeries)
courses.POST("/submitCut", routes.submitCut)
@@ -991,6 +992,43 @@ func (r coursesRoutes) updateDescription(c *gin.Context) {
}
}
+type changeDateTime struct {
+ Start time.Time `json:"start" binding:"required"`
+ End time.Time `json:"end" binding:"required"`
+}
+
+func (r coursesRoutes) updateStartEnd(c *gin.Context) {
+ var req changeDateTime
+ if err := c.Bind(&req); err != nil {
+ _ = c.Error(tools.RequestError{
+ Status: http.StatusBadRequest,
+ CustomMessage: "invalid body",
+ Err: err,
+ })
+ return
+ }
+
+ stream, err := r.StreamsDao.GetStreamByID(context.Background(), c.Param("streamID"))
+ if err != nil {
+ _ = c.Error(tools.RequestError{
+ Status: http.StatusNotFound,
+ CustomMessage: "can not find stream",
+ Err: err,
+ })
+ return
+ }
+ stream.Start = req.Start
+ stream.End = req.End
+ if err = r.StreamsDao.UpdateStream(stream); err != nil {
+ _ = c.Error(tools.RequestError{
+ Status: http.StatusInternalServerError,
+ CustomMessage: "couldn't update lecture Description",
+ Err: err,
+ })
+ return
+ }
+}
+
func (r coursesRoutes) renameLecture(c *gin.Context) {
sIDInt, err := strconv.Atoi(c.Param("streamID"))
if err != nil {
diff --git a/web/assets/init-admin.js b/web/assets/init-admin.js
index f4e8be806..0b5fb6c6c 100644
--- a/web/assets/init-admin.js
+++ b/web/assets/init-admin.js
@@ -82,7 +82,7 @@ document.addEventListener("alpine:init", () => {
const changeSet = evaluate(expression);
const fieldName = value || el.name;
- if (el.type === "file") {
+ if (el.type.toLowerCase() === "file") {
const isSingle = modifiers.includes("single")
const changeHandler = (e) => {
@@ -103,7 +103,7 @@ document.addEventListener("alpine:init", () => {
changeSet.removeListener(onChangeSetUpdateHandler);
el.removeEventListener('change', changeHandler)
})
- } else if (el.type === "checkbox") {
+ } else if (el.type.toLowerCase() === "checkbox") {
const changeHandler = (e) => {
changeSet.patch(fieldName, e.target.checked);
};
@@ -121,7 +121,7 @@ document.addEventListener("alpine:init", () => {
changeSet.removeListener(onChangeSetUpdateHandler);
el.removeEventListener('change', changeHandler)
})
- } else if (el.tagName === "textarea" || textInputTypes.includes(el.type)) {
+ } else if (el.tagName.toLowerCase() === "textarea" || textInputTypes.includes(el.type.toLowerCase())) {
const keyupHandler = (e) => changeSet.patch(fieldName, convert(modifiers, e.target.value));
const changeHandler = (e) => changeSet.patch(fieldName, convert(modifiers, e.target.value));
@@ -172,23 +172,29 @@ document.addEventListener("alpine:init", () => {
*
* Modifiers:
* - "text": When provided, the directive will also update the element's innerText.
+ * - "value": When provided, the directive will also update the element's value.
*
* Custom Events:
* - "csupdate": Custom event triggered when the change set is updated.
* The detail property of the event object contains the new value of the specified field.
*/
Alpine.directive("change-set-listen", (el, { expression, modifiers }, { effect, evaluate, cleanup }) => {
- effect(() => {
- const [changeSetExpression, fieldName = null] = expression.split(".");
- const changeSet = evaluate(changeSetExpression);
+ const [changeSetExpression, fieldName = null] = expression.split(".");
+ let changeSet = evaluate(changeSetExpression);
- const onChangeSetUpdateHandler = (data) => {
- const value = fieldName != null ? data[fieldName] : data;
- if (modifiers.includes("text")) {
- el.innerText = `${value}`;
- }
- el.dispatchEvent(new CustomEvent(nativeEventName, { detail: { changeSet, value } }));
- };
+ const onChangeSetUpdateHandler = (data) => {
+ const value = fieldName != null ? data[fieldName] : data;
+ if (modifiers.includes("text")) {
+ el.innerText = `${value}`;
+ }
+ if (modifiers.includes("value")) {
+ el.value = value;
+ }
+ el.dispatchEvent(new CustomEvent(nativeEventName, { detail: { changeSet, value } }));
+ };
+
+ effect(() => {
+ changeSet = evaluate(changeSetExpression);
if (!changeSet) {
return;
@@ -197,11 +203,11 @@ document.addEventListener("alpine:init", () => {
changeSet.removeListener(onChangeSetUpdateHandler);
onChangeSetUpdateHandler(changeSet.get());
changeSet.listen(onChangeSetUpdateHandler);
-
- cleanup(() => {
- changeSet.removeListener(onChangeSetUpdateHandler);
- })
});
+
+ cleanup(() => {
+ changeSet.removeListener(onChangeSetUpdateHandler);
+ })
});
/**
diff --git a/web/template/partial/course/manage/lecture-management-card.gohtml b/web/template/partial/course/manage/lecture-management-card.gohtml
index 62ff54803..b84260384 100644
--- a/web/template/partial/course/manage/lecture-management-card.gohtml
+++ b/web/template/partial/course/manage/lecture-management-card.gohtml
@@ -255,6 +255,48 @@
+
+
+
+
+
+
+
+
+
@@ -395,7 +437,7 @@
diff --git a/web/ts/api/admin-lecture-list.ts b/web/ts/api/admin-lecture-list.ts
index fc65bc786..af8d51ce9 100644
--- a/web/ts/api/admin-lecture-list.ts
+++ b/web/ts/api/admin-lecture-list.ts
@@ -17,6 +17,11 @@ export interface UpdateLectureMetaRequest {
isChatEnabled?: boolean;
}
+export interface UpdateLectureStartEndRequest {
+ start: Date;
+ end: Date;
+}
+
export class LectureFile {
readonly id: number;
readonly fileType: number;
@@ -187,8 +192,9 @@ export interface Lecture {
startDateFormatted: string;
startTimeFormatted: string;
endDate: Date;
- endDateFormatted: string;
endTimeFormatted: string;
+ duration: number;
+ durationFormatted: string;
// Clientside pseudo fields
newCombinedVideo: File | null;
@@ -230,9 +236,9 @@ export const AdminLectureList = {
* @param request
*/
updateMetadata: async function (courseId: number, lectureId: number, request: UpdateLectureMetaRequest) {
- const promises = [];
+ const promises: (() => Promise)[] = [];
if (request.name !== undefined) {
- promises.push(
+ promises.push(() =>
post(`/api/course/${courseId}/renameLecture/${lectureId}`, {
name: request.name,
}),
@@ -240,7 +246,7 @@ export const AdminLectureList = {
}
if (request.description !== undefined) {
- promises.push(
+ promises.push(() =>
put(`/api/course/${courseId}/updateDescription/${lectureId}`, {
name: request.description,
}),
@@ -248,7 +254,7 @@ export const AdminLectureList = {
}
if (request.lectureHallId !== undefined) {
- promises.push(
+ promises.push(() =>
post("/api/setLectureHall", {
streamIds: [lectureId],
lectureHall: request.lectureHallId,
@@ -257,7 +263,7 @@ export const AdminLectureList = {
}
if (request.isChatEnabled !== undefined) {
- promises.push(
+ promises.push(() =>
patch(`/api/stream/${lectureId}/chat/enabled`, {
lectureId,
isChatEnabled: request.isChatEnabled,
@@ -265,8 +271,15 @@ export const AdminLectureList = {
);
}
- const errors = (await Promise.all(promises)).filter((res) => res.status !== StatusCodes.OK);
- if (errors.length > 0) {
+ let errors = 0;
+ for (const promise of promises) {
+ const res = await promise();
+ if (res.status !== StatusCodes.OK) {
+ errors++;
+ }
+ }
+
+ if (errors > 0) {
console.error(errors);
throw Error("Failed to update all data.");
}
@@ -281,6 +294,16 @@ export const AdminLectureList = {
await post(`/api/course/${courseId}/updateLectureSeries/${lectureId}`);
},
+ /**
+ * Updates date time of a lecture.
+ * @param courseId
+ * @param lectureId
+ * @param request
+ */
+ updateStartEnd: async function (courseId: number, lectureId: number, { start, end }: UpdateLectureStartEndRequest) {
+ await post(`/api/course/${courseId}/updateStartEnd/${lectureId}`, { start, end });
+ },
+
/**
* Add sections to a lecture
* @param lectureId
diff --git a/web/ts/change-set.ts b/web/ts/change-set.ts
index 72ed57ad4..7b5bf71c5 100644
--- a/web/ts/change-set.ts
+++ b/web/ts/change-set.ts
@@ -1,8 +1,27 @@
+import { throttle, ThrottleFunc } from "./throttle";
+
+export enum LogLevel {
+ none,
+ warn,
+ info,
+ debug,
+}
+
+const logLevelNames: string[] = ["None", "Warn", "Info", "Debug"];
+
export interface DirtyState {
isDirty: boolean;
dirtyKeys: string[];
}
+export interface ChangeSetOptions {
+ comparator?: (key: string, a: T, b: T) => boolean;
+ updateTransformer?: ComputedProperties;
+ onUpdate?: (changeState: T, dirtyState: DirtyState) => void;
+ updateThrottle?: number;
+ logLevel?: LogLevel;
+}
+
/**
* ## ChangeSet Class
*
@@ -57,16 +76,38 @@ export class ChangeSet {
private changeState: T;
private readonly comparator?: PropertyComparator;
private onUpdate: ((changeState: T, dirtyState: DirtyState) => void)[];
+ private readonly changeStateTransformer?: (changeState: T) => T;
+ private readonly stateTransformer?: (changeState: T) => T;
+
+ private readonly throttledDispatchUpdateNoStateChanged?: ThrottleFunc;
+ private readonly throttledDispatchUpdateStateChanged?: ThrottleFunc;
+
+ private readonly logLevel: LogLevel;
+ private lastLogTimestamp: number;
+ private static logIdCounter: number = 0;
+ private readonly logId: number;
constructor(
state: T,
- comparator?: (key: string, a: T, b: T) => boolean,
- onUpdate?: (changeState: T, dirtyState: DirtyState) => void,
+ {
+ comparator,
+ updateTransformer,
+ onUpdate,
+ updateThrottle = 0,
+ logLevel = LogLevel.none,
+ }: ChangeSetOptions = {},
) {
+ this.lastLogTimestamp = Date.now();
+ this.logLevel = logLevel;
+ this.logId = ChangeSet.logIdCounter++;
this.state = state;
this.onUpdate = onUpdate ? [onUpdate] : [];
+ this.changeStateTransformer = updateTransformer !== undefined ? updateTransformer.create() : undefined;
+ this.stateTransformer = updateTransformer !== undefined ? updateTransformer.create() : undefined;
this.comparator = comparator;
- this.reset();
+ this.throttledDispatchUpdateNoStateChanged = throttle(() => this._dispatchUpdate(false), updateThrottle, true);
+ this.throttledDispatchUpdateStateChanged = throttle(() => this._dispatchUpdate(true), updateThrottle, true);
+ this.init();
}
/**
@@ -74,6 +115,7 @@ export class ChangeSet {
* @param onUpdate
*/
listen(onUpdate: (changeState: T, dirtyState: DirtyState) => void) {
+ this._log(LogLevel.debug, "Add Update Listener", { onUpdate });
this.onUpdate.push(onUpdate);
}
@@ -82,6 +124,7 @@ export class ChangeSet {
* @param onUpdate
*/
removeListener(onUpdate: (changeState: T, dirtyState: DirtyState) => void) {
+ this._log(LogLevel.debug, "Remove Update Listener", { onUpdate });
this.onUpdate = this.onUpdate.filter((o) => o !== onUpdate);
}
@@ -90,7 +133,7 @@ export class ChangeSet {
* @param key key to return
* @param lastCommittedState if set to true, value of the last committed state is returned
*/
- getValue(key: string, { lastCommittedState = false }): T {
+ getValue(key: string, { lastCommittedState = false } = {}): T {
if (lastCommittedState) {
return this.state[key];
}
@@ -109,8 +152,9 @@ export class ChangeSet {
* @param val
*/
set(val: T) {
+ this._log(LogLevel.info, "Set ChangeState", { val });
this.changeState = { ...val };
- this.dispatchUpdate();
+ this.dispatchUpdateThrottled(false);
}
/**
@@ -121,11 +165,12 @@ export class ChangeSet {
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
patch(key: string, val: any, { isCommitted = false }: { isCommitted?: boolean } = {}) {
+ this._log(LogLevel.info, "Patch State", { key, val, isCommitted });
this.changeState = { ...this.changeState, [key]: val };
if (isCommitted) {
this.state = { ...this.state, [key]: val };
}
- this.dispatchUpdate();
+ this.dispatchUpdateThrottled(isCommitted);
}
/**
@@ -133,6 +178,7 @@ export class ChangeSet {
* @param state
*/
updateState(state: T) {
+ this._log(LogLevel.info, "Update State", { state });
const changedKeys = this.changedKeys();
this.state = { ...state };
@@ -141,8 +187,7 @@ export class ChangeSet {
this.changeState[key] = this.state[key];
}
}
-
- this.dispatchUpdate();
+ this.dispatchUpdateThrottled(true);
}
/**
@@ -150,19 +195,30 @@ export class ChangeSet {
* @param discardKeys List of keys that should be discarded and not committed.
*/
commit({ discardKeys = [] }: { discardKeys?: string[] } = {}): void {
+ this._log(LogLevel.info, "Perform Commit", { discardKeys });
for (const key in discardKeys) {
this.changeState[key] = this.state[key];
}
this.state = { ...this.changeState };
- this.dispatchUpdate();
+ this.dispatchUpdateThrottled(true);
+ }
+
+ /**
+ * Init new state
+ */
+ init(): void {
+ this._log(LogLevel.info, "Perform Init");
+ this.changeState = { ...this.state };
+ this.dispatchUpdateThrottled(true);
}
/**
* Resets the change state to the state. Change state is the most current state afterwards.
*/
reset(): void {
+ this._log(LogLevel.info, "Perform Reset");
this.changeState = { ...this.state };
- this.dispatchUpdate();
+ this.dispatchUpdateThrottled(false);
}
/**
@@ -209,9 +265,23 @@ export class ChangeSet {
/**
* Executes all onUpdate listeners
+ * @param stateChanged if state changed, state computed values are recalculated
*/
- dispatchUpdate() {
+ _dispatchUpdate(stateChanged: boolean) {
+ this._log(LogLevel.info, "Dispatch Update", { stateChanged });
+
+ if (stateChanged && this.stateTransformer) {
+ this._log(LogLevel.info, "Reevaluate Computed on State");
+ this.state = this.stateTransformer(this.state);
+ }
+
+ if (this.changeStateTransformer) {
+ this._log(LogLevel.info, "Reevaluate Computed on ChangeState");
+ this.changeState = this.changeStateTransformer(this.changeState);
+ }
+
if (this.onUpdate.length > 0) {
+ this._log(LogLevel.info, `Trigger ${this.onUpdate.length} onUpdate handler`);
const dirtyKeys = this.changedKeys();
for (const onUpdate of this.onUpdate) {
onUpdate(this.changeState, {
@@ -221,6 +291,47 @@ export class ChangeSet {
}
}
}
+
+ /**
+ * Log Function that prints messages to console or discards them if the loglevel of changeSet is lower than the
+ * loglevel of the message
+ * @param level LogLevel of the message, if lower than changeSet's loglevel it will be discarded
+ * @param message The message to print to console
+ * @param payload Any payload object, "instance" is automatically attached if the changeSet loglevel is set to "debug"
+ */
+ _log(level: LogLevel, message: string, payload?: object) {
+ if (level <= this.logLevel) {
+ const ts = Date.now();
+ const tsDelta = ts - this.lastLogTimestamp;
+ this.lastLogTimestamp = ts;
+
+ if (this.logLevel === LogLevel.debug) {
+ payload = { ...(payload ?? {}), instance: this };
+ }
+
+ console.log(
+ `[CHANGESET | ${this.logId.toString().padStart(4, "0")} | ${logLevelNames[level].padEnd(
+ 5,
+ " ",
+ )}] ${message}`,
+ payload ?? "",
+ `[${tsDelta}ms]`,
+ );
+ }
+ }
+
+ /**
+ * Executes all onUpdate listeners
+ * @param stateChanged if state changed, state computed values are recalculated
+ */
+ dispatchUpdateThrottled(stateChanged: boolean) {
+ this._log(LogLevel.debug, "Throttled: Dispatch Update", { stateChanged });
+ if (stateChanged && this.stateTransformer) {
+ return this.throttledDispatchUpdateStateChanged();
+ } else {
+ return this.throttledDispatchUpdateNoStateChanged();
+ }
+ }
}
export type PropertyComparator = (key: string, a: T, b: T) => boolean | null;
@@ -244,6 +355,15 @@ export function singleProperty(key: string, comparator: SinglePropertyCompara
};
}
+export function multiProperty(keys: string[], comparator: PropertyComparator): PropertyComparator {
+ return (_key: string, a, b) => {
+ if (!keys.includes(_key)) {
+ return null;
+ }
+ return comparator(_key, a, b);
+ };
+}
+
export function comparatorPipeline(list: PropertyComparator[]): PropertyComparator {
return (key: string, a, b) => {
for (const comparator of list) {
@@ -257,3 +377,51 @@ export function comparatorPipeline(list: PropertyComparator[]): PropertyCo
return null;
};
}
+
+export type ComputedPropertyTransformer = (state: T) => T;
+export type ComputedPropertySubTransformer = {
+ transform: (state: T, oldState: T) => T;
+ key: string;
+};
+
+export class ComputedProperties {
+ private readonly computed: ComputedPropertySubTransformer[];
+
+ constructor(computed: ComputedPropertySubTransformer[]) {
+ this.computed = computed;
+ }
+
+ create(): ComputedPropertyTransformer {
+ let oldState: T | null = null;
+ return (state: T) => {
+ for (const transformer of this.computed) {
+ state = transformer.transform(state, oldState);
+ }
+ oldState = { ...state };
+ return state;
+ };
+ }
+
+ /**
+ * This is syntactic sugar to quickly ignore the computed keys in a changeset
+ */
+ ignore(): PropertyComparator {
+ return ignoreKeys(this.computed.map((transformer) => transformer.key));
+ }
+}
+
+export function computedProperty(
+ key: string,
+ updater: (changeState: T, old: T | null) => R,
+ deps: string[] = [],
+): ComputedPropertySubTransformer {
+ return {
+ transform: (state: T, oldState: T | null) => {
+ if (oldState == null || deps.length == 0 || deps.some((k) => oldState[k] !== state[k])) {
+ state[key] = updater(state, oldState);
+ }
+ return state;
+ },
+ key,
+ };
+}
diff --git a/web/ts/data-store/admin-lecture-list.ts b/web/ts/data-store/admin-lecture-list.ts
index 6633bf78e..c5295e95d 100644
--- a/web/ts/data-store/admin-lecture-list.ts
+++ b/web/ts/data-store/admin-lecture-list.ts
@@ -4,19 +4,20 @@ import {
Lecture,
LectureFile,
UpdateLectureMetaRequest,
+ UpdateLectureStartEndRequest,
VideoSection,
videoSectionSort,
} from "../api/admin-lecture-list";
import { FileType } from "../edit-course";
import { PostFormDataListener } from "../utilities/fetch-wrappers";
-const dateFormatOptions: Intl.DateTimeFormatOptions = {
+export const dateFormatOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "short",
day: "2-digit",
};
-const timeFormatOptions: Intl.DateTimeFormatOptions = {
+export const timeFormatOptions: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
};
@@ -36,14 +37,6 @@ export class AdminLectureListProvider extends StreamableMapProvider {
+ if (s.lectureId === lectureId) {
+ return {
+ ...s,
+ start: payload.start,
+ end: payload.end,
+ };
+ }
+ 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({
diff --git a/web/ts/edit-course.ts b/web/ts/edit-course.ts
index c517000c6..333b302c1 100644
--- a/web/ts/edit-course.ts
+++ b/web/ts/edit-course.ts
@@ -15,9 +15,18 @@ import {
videoSectionSort,
videoSectionTimestamp,
} from "./api/admin-lecture-list";
-import { ChangeSet, comparatorPipeline, ignoreKeys, singleProperty } from "./change-set";
+import {
+ ChangeSet,
+ comparatorPipeline,
+ ComputedProperties,
+ computedProperty,
+ ignoreKeys,
+ multiProperty,
+ singleProperty,
+} from "./change-set";
import { AlpineComponent } from "./components/alpine-component";
import { uploadFile } from "./utilities/fetch-wrappers";
+import { dateFormatOptions, timeFormatOptions } from "./data-store/admin-lecture-list";
export enum UIEditMode {
none,
@@ -111,6 +120,7 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
uiEditMode: UIEditMode.none,
isDirty: false,
isSaving: false,
+ isInvalid: false,
// Lecture Data
changeSet: null as ChangeSet | null,
@@ -120,8 +130,65 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
* AlpineJS init function which is called automatically in addition to 'x-init'
*/
init() {
+ const computedFields = new ComputedProperties([
+ computedProperty(
+ "startDate",
+ (changeSet) => {
+ return new Date(changeSet.start);
+ },
+ ["start"],
+ ),
+ computedProperty(
+ "startDateFormatted",
+ (changeSet) => {
+ return changeSet.startDate.toLocaleDateString("en-US", dateFormatOptions);
+ },
+ ["start"],
+ ),
+ computedProperty(
+ "startTimeFormatted",
+ (changeSet) => {
+ return changeSet.startDate.toLocaleDateString("en-US", timeFormatOptions);
+ },
+ ["start"],
+ ),
+ computedProperty(
+ "endDate",
+ (changeSet) => {
+ return new Date(changeSet.end);
+ },
+ ["end"],
+ ),
+ computedProperty(
+ "endTimeFormatted",
+ (changeSet) => {
+ return changeSet.endDate.toLocaleTimeString("en-US", timeFormatOptions);
+ },
+ ["end"],
+ ),
+ computedProperty(
+ "duration",
+ (changeSet) => {
+ // To ignore day differences
+ const normalizedEndDate = new Date(changeSet.startDate.getTime());
+ normalizedEndDate.setHours(changeSet.endDate.getHours());
+ normalizedEndDate.setMinutes(changeSet.endDate.getMinutes());
+ return normalizedEndDate.getTime() - changeSet.startDate.getTime();
+ },
+ ["start", "end"],
+ ),
+ computedProperty(
+ "durationFormatted",
+ (changeSet) => {
+ return this.generateFormattedDuration(changeSet);
+ },
+ ["start", "end"],
+ ),
+ ]);
+
const customComparator = comparatorPipeline([
- ignoreKeys(["files"]),
+ computedFields.ignore(),
+ ignoreKeys(["files", "downloadableVods", "transcodingProgresses"]),
singleProperty("videoSections", (a: Lecture, b: Lecture): boolean | null => {
// List length differs, something was removed or added
if (a.videoSections.length !== b.videoSections.length) {
@@ -139,12 +206,23 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
// A section has edited and different information now
return a.videoSections.some((sA) => b.videoSections.some((sB) => videoSectionHasChanged(sA, sB)));
}),
+ multiProperty(["start", "end"], (key: string, a: Lecture, b: Lecture): boolean | null => {
+ const dateA = new Date(a[key]);
+ const dateB = new Date(b[key]);
+ return dateA.getTime() !== dateB.getTime();
+ }),
]);
// This tracks changes that are not saved yet
- this.changeSet = new ChangeSet(lecture, customComparator, (data, dirtyState) => {
- this.lectureData = data;
- this.isDirty = dirtyState.isDirty;
+ this.changeSet = new ChangeSet(lecture, {
+ //logLevel: LogLevel.debug,
+ comparator: customComparator,
+ updateTransformer: computedFields,
+ onUpdate: (data, dirtyState) => {
+ this.lectureData = data;
+ this.isDirty = dirtyState.isDirty;
+ this.isInvalid = data.duration <= 0;
+ },
});
// This updates the state live in background
@@ -218,6 +296,23 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
DataStore.adminLectureList.deleteAttachment(this.lectureData.courseId, this.lectureData.lectureId, id);
},
+ generateFormattedDuration(lecture: Lecture): string {
+ if (lecture.duration <= 0) {
+ return "invalid";
+ }
+ const duration = lecture.duration / 1000 / 60;
+ const hours = Math.floor(duration / 60);
+ const minutes = duration - hours * 60;
+ let res = "";
+ if (hours > 0) {
+ res += `${hours}h `;
+ }
+ if (minutes > 0) {
+ res += `${minutes}min`;
+ }
+ return res;
+ },
+
friendlySectionTimestamp(section: VideoSection): string {
return videoSectionFriendlyTimestamp(section);
},
@@ -317,7 +412,7 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
* Save changes send them to backend and commit change set.
*/
async saveEdit() {
- const { courseId, lectureId, name, description, lectureHallId, isChatEnabled, videoSections } =
+ const { courseId, lectureId, name, description, lectureHallId, isChatEnabled, videoSections, start, end } =
this.lectureData;
const changedKeys = this.changeSet.changedKeys();
@@ -335,6 +430,19 @@ export function lectureEditor(lecture: Lecture): AlpineComponent {
},
});
+ // Save new date and time
+ if (changedKeys.includes("start") || changedKeys.includes("end")) {
+ const startDate = new Date(start);
+ const endDate = new Date(end);
+ endDate.setFullYear(startDate.getFullYear());
+ endDate.setMonth(startDate.getMonth());
+ endDate.setDate(startDate.getDate());
+ await DataStore.adminLectureList.updateStartEnd(courseId, lectureId, {
+ start: startDate,
+ end: endDate,
+ });
+ }
+
// Saving VideoSections
if (changedKeys.includes("videoSections")) {
const oldVideoSections = this.changeSet.getValue("videoSections", { lastCommittedState: true });
diff --git a/web/ts/throttle.ts b/web/ts/throttle.ts
new file mode 100644
index 000000000..d3ebac9f6
--- /dev/null
+++ b/web/ts/throttle.ts
@@ -0,0 +1,22 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type ThrottleFunc = (...args: any[]) => void;
+
+export function throttle(fun: ThrottleFunc, delay = 100, skipFirst: boolean = false): ThrottleFunc {
+ let lastInstance: NodeJS.Timeout | null = null;
+ let first: boolean = true;
+
+ return (...args): void => {
+ if (skipFirst && first) {
+ first = false;
+ fun(...args);
+ return;
+ }
+
+ if (lastInstance !== null) {
+ clearTimeout(lastInstance);
+ }
+ lastInstance = setTimeout(() => {
+ fun(...args);
+ }, delay);
+ };
+}