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 @@ + +
+
Date and Time
+
+
+ + +
+
Title and Description
@@ -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); + }; +}