@capytale/meta-player 0.3.7 → 0.3.8

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.8",
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,276 @@
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
+ console.log("showOneMinuteWarning");
31
+ oneMinuteWarningShown.current = true;
32
+ toast.current?.show({
33
+ severity: "warn",
34
+ summary: "Activité non sauvegardée",
35
+ detail: (
36
+ <>
37
+ <div>
38
+ Il vous reste moins d'une minute pour rendre votre activité. Elle
39
+ sera sauvegardée automatiquement 30 secondes avant la fin.
40
+ </div>
41
+ <div
42
+ style={{
43
+ display: "flex",
44
+ flexDirection: "column",
45
+ gap: "8px",
46
+ marginTop: "8px",
47
+ alignItems: "stretch",
48
+ }}
49
+ >
50
+ <Button
51
+ severity="secondary"
52
+ outlined
53
+ onClick={() => {
54
+ shouldSaveAt30s.current = false;
55
+ toast.current?.clear();
56
+ }}
57
+ >
58
+ Ne pas sauvegarder (danger)
59
+ </Button>
60
+ <Button severity="warning" onClick={save}>
61
+ Sauvegarder maintenant
62
+ </Button>
63
+ </div>
64
+ </>
65
+ ),
66
+ sticky: true,
67
+ closable: false,
68
+ });
69
+ };
70
+
71
+ const showFifteenSecondsWarning = () => {
72
+ fifteenSecondsWarningShown.current = true;
73
+ toast.current?.show({
74
+ severity: "error",
75
+ summary: "Activité non sauvegardée",
76
+ detail: (
77
+ <>
78
+ <div>
79
+ Il vous reste moins de 15 secondes pour rendre votre activité.
80
+ </div>
81
+ <div
82
+ style={{
83
+ display: "flex",
84
+ flexDirection: "column",
85
+ gap: "8px",
86
+ marginTop: "8px",
87
+ alignItems: "stretch",
88
+ }}
89
+ >
90
+ <Button
91
+ severity="danger"
92
+ onClick={() => {
93
+ save();
94
+ toast.current?.clear();
95
+ }}
96
+ >
97
+ Sauvegarder maintenant
98
+ </Button>
99
+ </div>
100
+ </>
101
+ ),
102
+ sticky: true,
103
+ });
104
+ };
105
+
106
+ const activityJS = useActivityJS();
107
+ const deadline =
108
+ activityJS.activitySession?.activityBunch.access_timerange.value?.end;
109
+
110
+ const [diffMs, setDiffMs] = useState<number | null>(null);
111
+
112
+ const updateDiffMs = useCallback(() => {
113
+ if (!deadline) {
114
+ return;
115
+ }
116
+ const deadlineUTC = Date.UTC(
117
+ deadline.getFullYear(),
118
+ deadline.getMonth(),
119
+ deadline.getDate(),
120
+ deadline.getHours(),
121
+ deadline.getMinutes(),
122
+ deadline.getSeconds(),
123
+ deadline.getMilliseconds(),
124
+ );
125
+ const now = new Date();
126
+ const nowUTC = Date.UTC(
127
+ now.getFullYear(),
128
+ now.getMonth(),
129
+ now.getDate(),
130
+ now.getHours(),
131
+ now.getMinutes(),
132
+ now.getSeconds(),
133
+ now.getMilliseconds(),
134
+ );
135
+ const newDiffMs = deadlineUTC - nowUTC;
136
+ setDiffMs(newDiffMs);
137
+ if (mode !== "assignment") {
138
+ return;
139
+ }
140
+ console.log("check", newDiffMs, isDirty, oneMinuteWarningShown.current);
141
+ if (
142
+ newDiffMs != null &&
143
+ newDiffMs < MS_PER_MINUTE &&
144
+ newDiffMs > 35000 &&
145
+ isDirty &&
146
+ !oneMinuteWarningShown.current
147
+ ) {
148
+ showOneMinuteWarning();
149
+ }
150
+ if (
151
+ newDiffMs != null &&
152
+ newDiffMs < 30000 &&
153
+ newDiffMs > 0 &&
154
+ shouldSaveAt30s.current &&
155
+ !save30sAttempted.current
156
+ ) {
157
+ if (isDirty) {
158
+ save();
159
+ }
160
+ toast.current?.clear();
161
+ save30sAttempted.current = true;
162
+ }
163
+ if (
164
+ isDirty &&
165
+ newDiffMs != null &&
166
+ newDiffMs < 15000 &&
167
+ newDiffMs > 0 &&
168
+ !fifteenSecondsWarningShown.current
169
+ ) {
170
+ showFifteenSecondsWarning();
171
+ }
172
+ if (newDiffMs <= 0 && !zeroSecondsWarningShown.current) {
173
+ zeroSecondsWarningShown.current = true;
174
+ toast.current?.clear();
175
+ toast.current?.show({
176
+ severity: "info",
177
+ summary: "Temps écoulé",
178
+ detail: "Le temps est écoulé, il n'est plus possible de sauvegarder.",
179
+ sticky: true,
180
+ });
181
+ }
182
+ }, [deadline, mode, isDirty]);
183
+
184
+ useInterval(updateDiffMs, 1000, !!deadline);
185
+
186
+ const displayed = useMemo<ReactNode>(() => {
187
+ if (!deadline || diffMs == null) {
188
+ return null;
189
+ }
190
+ if (diffMs >= MS_PER_DAY) {
191
+ return (
192
+ <div
193
+ style={{
194
+ display: "flex",
195
+ alignItems: "center",
196
+ gap: "8px",
197
+ marginRight: "8px",
198
+ }}
199
+ >
200
+ <i className="pi pi-calendar-clock" />
201
+ <div
202
+ style={{
203
+ display: "flex",
204
+ flexDirection: "column",
205
+ alignItems: "center",
206
+ }}
207
+ >
208
+ {deadline
209
+ .toLocaleString("fr-FR", {
210
+ year: diffMs >= MS_PER_YEAR ? "numeric" : undefined,
211
+ month: "short",
212
+ day: "numeric",
213
+ hour: "numeric",
214
+ minute: "numeric",
215
+ })
216
+ .split(", ")
217
+ .map((s, i) => (
218
+ <div key={i}>{s}</div>
219
+ ))}
220
+ </div>
221
+ </div>
222
+ );
223
+ }
224
+ if (diffMs <= 0) {
225
+ return (
226
+ <div
227
+ style={{
228
+ display: "flex",
229
+ alignItems: "center",
230
+ gap: "8px",
231
+ marginRight: "8px",
232
+ }}
233
+ >
234
+ <i className="pi pi-calendar-times" />
235
+ <span>Temps écoulé</span>
236
+ </div>
237
+ );
238
+ }
239
+ const diffSeconds = Math.floor(diffMs / 1000);
240
+ const seconds = diffSeconds % 60;
241
+ const minutes = Math.floor(diffSeconds / 60) % 60;
242
+ const hours = Math.floor(diffSeconds / 3600) % 24;
243
+ return (
244
+ <div
245
+ style={{
246
+ display: "flex",
247
+ alignItems: "center",
248
+ gap: "8px",
249
+ marginRight: "8px",
250
+ }}
251
+ >
252
+ <i className="pi pi-hourglass" />
253
+ {`${hours.toString().padStart(2, "0")}:${minutes
254
+ .toString()
255
+ .padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`}
256
+ </div>
257
+ );
258
+ }, [deadline, diffMs]);
259
+
260
+ if (!deadline) {
261
+ return null;
262
+ }
263
+
264
+ return (
265
+ <div
266
+ style={{
267
+ userSelect: "none",
268
+ }}
269
+ >
270
+ {displayed}
271
+ <Toast position="bottom-right" ref={toast} />
272
+ </div>
273
+ );
274
+ };
275
+
276
+ 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
+ }