diff --git a/web/assets/init-admin.js b/web/assets/init-admin.js index f4e8be806..f8802cd96 100644 --- a/web/assets/init-admin.js +++ b/web/assets/init-admin.js @@ -172,6 +172,7 @@ 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. @@ -187,6 +188,9 @@ document.addEventListener("alpine:init", () => { if (modifiers.includes("text")) { el.innerText = `${value}`; } + if (modifiers.includes("value")) { + el.value = value; + } el.dispatchEvent(new CustomEvent(nativeEventName, { detail: { changeSet, value } })); }; diff --git a/web/template/partial/course/manage/lecture-management-card.gohtml b/web/template/partial/course/manage/lecture-management-card.gohtml index 6046fc044..c7148987a 100644 --- a/web/template/partial/course/manage/lecture-management-card.gohtml +++ b/web/template/partial/course/manage/lecture-management-card.gohtml @@ -255,6 +255,41 @@ + +
+
Date and Time
+
+
+ + +
+
Title and Description
diff --git a/web/ts/api/admin-lecture-list.ts b/web/ts/api/admin-lecture-list.ts index fc65bc786..94d8e853b 100644 --- a/web/ts/api/admin-lecture-list.ts +++ b/web/ts/api/admin-lecture-list.ts @@ -187,8 +187,9 @@ export interface Lecture { startDateFormatted: string; startTimeFormatted: string; endDate: Date; - endDateFormatted: string; endTimeFormatted: string; + duration: number; + durationFormatted: string; // Clientside pseudo fields newCombinedVideo: File | null; diff --git a/web/ts/change-set.ts b/web/ts/change-set.ts index 72ed57ad4..a323552b7 100644 --- a/web/ts/change-set.ts +++ b/web/ts/change-set.ts @@ -3,6 +3,12 @@ export interface DirtyState { dirtyKeys: string[]; } +export interface ChangeSetOptions { + comparator?: (key: string, a: T, b: T) => boolean, + updateTransformer?: ComputedProperties, + onUpdate?: (changeState: T, dirtyState: DirtyState) => void, +}; + /** * ## ChangeSet Class * @@ -57,16 +63,19 @@ 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); constructor( state: T, - comparator?: (key: string, a: T, b: T) => boolean, - onUpdate?: (changeState: T, dirtyState: DirtyState) => void, + { comparator, updateTransformer, onUpdate }: ChangeSetOptions = {} ) { 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.init(); } /** @@ -90,7 +99,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]; } @@ -110,7 +119,7 @@ export class ChangeSet { */ set(val: T) { this.changeState = { ...val }; - this.dispatchUpdate(); + this.dispatchUpdate(false); } /** @@ -125,7 +134,7 @@ export class ChangeSet { if (isCommitted) { this.state = { ...this.state, [key]: val }; } - this.dispatchUpdate(); + this.dispatchUpdate(isCommitted); } /** @@ -141,8 +150,7 @@ export class ChangeSet { this.changeState[key] = this.state[key]; } } - - this.dispatchUpdate(); + this.dispatchUpdate(true); } /** @@ -154,7 +162,15 @@ export class ChangeSet { this.changeState[key] = this.state[key]; } this.state = { ...this.changeState }; - this.dispatchUpdate(); + this.dispatchUpdate(true); + } + + /** + * Init new state + */ + init(): void { + this.changeState = { ...this.state }; + this.dispatchUpdate(true); } /** @@ -162,7 +178,7 @@ export class ChangeSet { */ reset(): void { this.changeState = { ...this.state }; - this.dispatchUpdate(); + this.dispatchUpdate(false); } /** @@ -209,8 +225,17 @@ export class ChangeSet { /** * Executes all onUpdate listeners + * @param stateChanged if state changed, state computed values are recalculated */ - dispatchUpdate() { + dispatchUpdate(stateChanged: boolean) { + if (stateChanged && this.stateTransformer) { + this.state = this.stateTransformer(this.state); + } + + if (this.changeStateTransformer) { + this.changeState = this.changeStateTransformer(this.changeState); + } + if (this.onUpdate.length > 0) { const dirtyKeys = this.changedKeys(); for (const onUpdate of this.onUpdate) { @@ -257,3 +282,34 @@ export function comparatorPipeline(list: PropertyComparator[]): PropertyCo return null; }; } + +export type ComputedPropertyTransformer = ((state: T) => T); +export type ComputedPropertySubTransformer = ((state: T, oldState: T) => T); + +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(state, oldState); + } + oldState = {...state}; + return state; + }; + } +} + +export function computedProperty(key: string, updater: (changeState: T, old: T|null) => R, deps: string[] = []): ComputedPropertySubTransformer { + return (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; + }; +} \ No newline at end of file diff --git a/web/ts/data-store/admin-lecture-list.ts b/web/ts/data-store/admin-lecture-list.ts index 6633bf78e..c460a0cb5 100644 --- a/web/ts/data-store/admin-lecture-list.ts +++ b/web/ts/data-store/admin-lecture-list.ts @@ -10,13 +10,13 @@ import { 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 +36,6 @@ export class AdminLectureListProvider extends StreamableMapProvider("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"]), + ]); + // 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, { + comparator: customComparator, + updateTransformer: computedFields, + onUpdate: (data, dirtyState) => { + this.lectureData = data; + this.isDirty = dirtyState.isDirty; + }, }); // This updates the state live in background @@ -218,6 +258,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); },