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 @@
+
+
+
+
+
+
+
+
+
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);
},