@capytale/meta-player 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capytale/meta-player",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -29,6 +29,7 @@ import {
29
29
  selectShowSaveForStudents,
30
30
  selectShowWorkflow,
31
31
  } from "../layout/layoutSlice";
32
+ import Countdown from "./Countdown";
32
33
 
33
34
  const CapytaleMenu: React.FC = () => {
34
35
  const dispatch = useAppDispatch();
@@ -91,17 +92,20 @@ const CapytaleMenu: React.FC = () => {
91
92
  )}
92
93
  {
93
94
  hasSaveButton && (
94
- <Button
95
- label={isQuiteSmall ? undefined : "Enregistrer"}
96
- disabled={!isDirty}
97
- severity={"warning"}
98
- size="small"
99
- icon="pi pi-save"
100
- loading={saveState !== "idle"}
101
- onClick={() => {
102
- dispatch(setSaveState("should-save"));
103
- }}
104
- />
95
+ <>
96
+ <Countdown />
97
+ <Button
98
+ label={isQuiteSmall ? undefined : "Enregistrer"}
99
+ disabled={!isDirty}
100
+ severity={"warning"}
101
+ size="small"
102
+ icon="pi pi-save"
103
+ loading={saveState !== "idle"}
104
+ onClick={() => {
105
+ dispatch(setSaveState("should-save"));
106
+ }}
107
+ />
108
+ </>
105
109
  ) /**
106
110
  tooltip={isDirty ? "Enregistrer l'activité" : "Rien à enregistrer"}
107
111
  tooltipOptions={{
@@ -0,0 +1,278 @@
1
+ import { FC, ReactNode, useCallback, useMemo, useRef, useState } from "react";
2
+ import { useInterval } from "primereact/hooks";
3
+ import { Toast } from "primereact/toast";
4
+ import { useActivityJS } from "../activityJS/ActivityJSProvider";
5
+ import { useAppSelector } from "../../app/hooks";
6
+ import { selectIsDirty, selectMode } from "../activityData/activityDataSlice";
7
+ import { Button } from "primereact/button";
8
+ import { useSave } from "../activityData/hooks";
9
+
10
+ // TODO use https://capytale2.ac-paris.fr/vanilla/time-s.php
11
+ // https://forge.apps.education.fr/capytale/activity-js/-/blob/main/src/backend/capytale/clock.ts?ref_type=heads
12
+ // https://forge.apps.education.fr/capytale/activity-js/-/blob/main/src/api/time/clock.ts?ref_type=heads
13
+
14
+ const MS_PER_MINUTE = 1000 * 60;
15
+ const MS_PER_DAY = MS_PER_MINUTE * 60 * 24;
16
+ const MS_PER_YEAR = MS_PER_DAY * 365;
17
+
18
+ const Countdown: FC = () => {
19
+ const toast = useRef<Toast>(null);
20
+ const mode = useAppSelector(selectMode);
21
+ const isDirty = useAppSelector(selectIsDirty);
22
+ const oneMinuteWarningShown = useRef(false);
23
+ const fifteenSecondsWarningShown = useRef(false);
24
+ const shouldSaveAt30s = useRef(true);
25
+ const save30sAttempted = useRef(false);
26
+ const zeroSecondsWarningShown = useRef(false);
27
+ const save = useSave();
28
+
29
+ const showOneMinuteWarning = () => {
30
+ oneMinuteWarningShown.current = true;
31
+ toast.current?.show({
32
+ severity: "warn",
33
+ summary: "Activité non sauvegardée",
34
+ detail: (
35
+ <>
36
+ <div>
37
+ Il vous reste moins d'une minute pour rendre votre activité. Elle
38
+ sera sauvegardée automatiquement 30 secondes avant la fin.
39
+ </div>
40
+ <div
41
+ style={{
42
+ display: "flex",
43
+ flexDirection: "column",
44
+ gap: "8px",
45
+ marginTop: "8px",
46
+ alignItems: "stretch",
47
+ }}
48
+ >
49
+ <Button
50
+ severity="secondary"
51
+ outlined
52
+ onClick={() => {
53
+ shouldSaveAt30s.current = false;
54
+ toast.current?.clear();
55
+ }}
56
+ >
57
+ Ne pas sauvegarder (danger)
58
+ </Button>
59
+ <Button severity="warning" onClick={save}>
60
+ Sauvegarder maintenant
61
+ </Button>
62
+ </div>
63
+ </>
64
+ ),
65
+ sticky: true,
66
+ closable: false,
67
+ });
68
+ };
69
+
70
+ const showFifteenSecondsWarning = () => {
71
+ fifteenSecondsWarningShown.current = true;
72
+ toast.current?.show({
73
+ severity: "error",
74
+ summary: "Activité non sauvegardée",
75
+ detail: (
76
+ <>
77
+ <div>
78
+ Il vous reste moins de 15 secondes pour rendre votre activité.
79
+ </div>
80
+ <div
81
+ style={{
82
+ display: "flex",
83
+ flexDirection: "column",
84
+ gap: "8px",
85
+ marginTop: "8px",
86
+ alignItems: "stretch",
87
+ }}
88
+ >
89
+ <Button
90
+ severity="danger"
91
+ onClick={() => {
92
+ save();
93
+ toast.current?.clear();
94
+ }}
95
+ >
96
+ Sauvegarder maintenant
97
+ </Button>
98
+ </div>
99
+ </>
100
+ ),
101
+ sticky: true,
102
+ });
103
+ };
104
+
105
+ const activityJS = useActivityJS();
106
+ const deadlineMode =
107
+ activityJS.activitySession?.activityBunch.access_tr_mode.value;
108
+ const deadline =
109
+ !deadlineMode || deadlineMode === "none"
110
+ ? null
111
+ : activityJS.activitySession?.activityBunch.access_timerange.value?.end;
112
+
113
+ const [diffMs, setDiffMs] = useState<number | null>(null);
114
+
115
+ const updateDiffMs = useCallback(() => {
116
+ if (!deadline) {
117
+ return;
118
+ }
119
+ const deadlineUTC = Date.UTC(
120
+ deadline.getFullYear(),
121
+ deadline.getMonth(),
122
+ deadline.getDate(),
123
+ deadline.getHours(),
124
+ deadline.getMinutes(),
125
+ deadline.getSeconds(),
126
+ deadline.getMilliseconds(),
127
+ );
128
+ const now = new Date();
129
+ const nowUTC = Date.UTC(
130
+ now.getFullYear(),
131
+ now.getMonth(),
132
+ now.getDate(),
133
+ now.getHours(),
134
+ now.getMinutes(),
135
+ now.getSeconds(),
136
+ now.getMilliseconds(),
137
+ );
138
+ const newDiffMs = deadlineUTC - nowUTC;
139
+ setDiffMs(newDiffMs);
140
+ if (mode !== "assignment") {
141
+ return;
142
+ }
143
+ if (
144
+ newDiffMs != null &&
145
+ newDiffMs < MS_PER_MINUTE &&
146
+ newDiffMs > 35000 &&
147
+ isDirty &&
148
+ !oneMinuteWarningShown.current
149
+ ) {
150
+ showOneMinuteWarning();
151
+ }
152
+ if (
153
+ newDiffMs != null &&
154
+ newDiffMs < 30000 &&
155
+ newDiffMs > 0 &&
156
+ shouldSaveAt30s.current &&
157
+ !save30sAttempted.current
158
+ ) {
159
+ if (isDirty) {
160
+ save();
161
+ }
162
+ toast.current?.clear();
163
+ save30sAttempted.current = true;
164
+ }
165
+ if (
166
+ isDirty &&
167
+ newDiffMs != null &&
168
+ newDiffMs < 15000 &&
169
+ newDiffMs > 0 &&
170
+ !fifteenSecondsWarningShown.current
171
+ ) {
172
+ showFifteenSecondsWarning();
173
+ }
174
+ if (newDiffMs <= 0 && !zeroSecondsWarningShown.current) {
175
+ zeroSecondsWarningShown.current = true;
176
+ toast.current?.clear();
177
+ toast.current?.show({
178
+ severity: "info",
179
+ summary: "Temps écoulé",
180
+ detail: "Le temps est écoulé, il n'est plus possible de sauvegarder.",
181
+ sticky: true,
182
+ });
183
+ }
184
+ }, [deadline, mode, isDirty]);
185
+
186
+ useInterval(updateDiffMs, 1000, !!deadline);
187
+
188
+ const displayed = useMemo<ReactNode>(() => {
189
+ if (!deadline || diffMs == null) {
190
+ return null;
191
+ }
192
+ if (diffMs >= MS_PER_DAY) {
193
+ return (
194
+ <div
195
+ style={{
196
+ display: "flex",
197
+ alignItems: "center",
198
+ gap: "8px",
199
+ marginRight: "8px",
200
+ }}
201
+ >
202
+ <i className="pi pi-calendar-clock" />
203
+ <div
204
+ style={{
205
+ display: "flex",
206
+ flexDirection: "column",
207
+ alignItems: "center",
208
+ }}
209
+ >
210
+ {deadline
211
+ .toLocaleString("fr-FR", {
212
+ year: diffMs >= MS_PER_YEAR ? "numeric" : undefined,
213
+ month: "short",
214
+ day: "numeric",
215
+ hour: "numeric",
216
+ minute: "numeric",
217
+ })
218
+ .split(", ")
219
+ .map((s, i) => (
220
+ <div key={i}>{s}</div>
221
+ ))}
222
+ </div>
223
+ </div>
224
+ );
225
+ }
226
+ if (diffMs <= 0) {
227
+ return (
228
+ <div
229
+ style={{
230
+ display: "flex",
231
+ alignItems: "center",
232
+ gap: "8px",
233
+ marginRight: "8px",
234
+ }}
235
+ >
236
+ <i className="pi pi-calendar-times" />
237
+ <span>Temps écoulé</span>
238
+ </div>
239
+ );
240
+ }
241
+ const diffSeconds = Math.floor(diffMs / 1000);
242
+ const seconds = diffSeconds % 60;
243
+ const minutes = Math.floor(diffSeconds / 60) % 60;
244
+ const hours = Math.floor(diffSeconds / 3600) % 24;
245
+ return (
246
+ <div
247
+ style={{
248
+ display: "flex",
249
+ alignItems: "center",
250
+ gap: "8px",
251
+ marginRight: "8px",
252
+ }}
253
+ >
254
+ <i className="pi pi-hourglass" />
255
+ {`${hours.toString().padStart(2, "0")}:${minutes
256
+ .toString()
257
+ .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`}
258
+ </div>
259
+ );
260
+ }, [deadline, diffMs]);
261
+
262
+ if (!deadline) {
263
+ return null;
264
+ }
265
+
266
+ return (
267
+ <div
268
+ style={{
269
+ userSelect: "none",
270
+ }}
271
+ >
272
+ {displayed}
273
+ <Toast position="bottom-right" ref={toast} />
274
+ </div>
275
+ );
276
+ };
277
+
278
+ export default Countdown;
@@ -88,6 +88,7 @@
88
88
  }
89
89
 
90
90
  .capytaleMenu {
91
+ align-items: center;
91
92
  display: flex;
92
93
  gap: 8px;
93
94
  }
@@ -157,4 +158,4 @@
157
158
 
158
159
  .overlayPanelWorkflowTitle {
159
160
  margin-top: 0;
160
- }
161
+ }