Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
380 lines (344 loc) · 12.9 KB
/
Copy pathapp.js
File metadata and controls
380 lines (344 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
import {
SCHEDULE_URL,
buildFeedbackUrl,
fetchSchedule,
fetchTrackName,
findNextSession,
findPreviousSession,
findSession,
formatSessionTime,
sessionIdFromLocation,
} from "./schedule.js?v=20260814-button-picker-1";
const LANGS = ["zh-Hant", "en", "ja", "ko"];
const copy = {
selected: ["選擇的議程", "Selected session", "選択したセッション", "선택한 세션"],
ended: ["已過議程", "Ended session", "終了したセッション", "종료된 세션"],
openForm: [
"填寫這場議程的回饋",
"Give feedback for this session",
"このセッションにフィードバック",
"이 세션에 피드백 남기기",
],
trackUnavailable: [
"議程軌暫時無法載入(表單不會誤填語言)",
"Track unavailable (language will not be used by mistake)",
"トラックを取得できません(言語を誤入力しません)",
"트랙을 불러올 수 없음 (언어를 잘못 입력하지 않음)",
],
};
const sessionStatuses = {
selected: { key: "selected", tag: "Selected", labels: copy.selected },
ended: { key: "ended", tag: "Ended", labels: copy.ended },
};
const statusPanel = document.querySelector(".status-panel");
const statusMessage = document.querySelector("#status-message");
const sessionResults = document.querySelector("#session-results");
const previousButton = document.querySelector("#show-previous-session");
const currentButton = document.querySelector("#show-current-session");
const nextButton = document.querySelector("#show-next-session");
const selectedSlot = document.querySelector("#selected-session");
const lookup = document.querySelector("#session-lookup");
const lookupForm = document.querySelector("#lookup-form");
const sessionInput = document.querySelector("#session-id");
const lookupButton = lookupForm.querySelector('button[type="submit"]');
const trackPickerStep = document.querySelector("#track-picker-step");
const sessionPickerStep = document.querySelector("#session-picker-step");
const trackList = document.querySelector("#track-list");
const trackSessionList = document.querySelector("#track-session-list");
const selectedTrackName = document.querySelector("#selected-track-name");
const changeTrackButton = document.querySelector("#change-track");
const cardTemplate = document.querySelector("#session-card-template");
let sessions = [];
let renderVersion = 0;
let anchorSession = null;
function addLanguageLines(container, lines, className = "language-line") {
container.replaceChildren();
lines.forEach((line, index) => {
const span = document.createElement("span");
span.className = className;
span.lang = LANGS[index];
span.textContent = line;
container.append(span);
});
}
function setStatus(lines, state = "loading") {
statusPanel.hidden = false;
addLanguageLines(statusMessage, lines, "status-line");
statusPanel.dataset.state = state;
}
function addSessionStatus(container, status) {
container.replaceChildren();
const tag = document.createElement("span");
tag.className = "session-card__status-tag";
tag.lang = "en";
tag.textContent = status.tag;
container.append(tag);
status.labels.forEach((line, index) => {
const span = document.createElement("span");
span.className = "language-line";
span.lang = LANGS[index];
span.textContent = line;
container.append(span);
});
}
function renderSessionCard(slot, session, status) {
slot.replaceChildren();
const card = cardTemplate.content.firstElementChild.cloneNode(true);
card.classList.toggle("is-selected", status.key === "selected");
card.dataset.status = status.key;
card.href = buildFeedbackUrl(session);
card.target = "_blank";
card.rel = "noreferrer";
card.setAttribute(
"aria-label",
`${status.labels.join(" / ")}:${session.title}。${copy.openForm.join(" / ")}`,
);
addSessionStatus(card.querySelector(".session-card__label"), status);
card.querySelector(".session-card__title").textContent = session.title;
card.querySelector('[data-field="time"]').textContent = formatSessionTime(session);
card.querySelector('[data-field="room"]').textContent = session.roomName;
card.querySelector('[data-field="speaker"]').textContent =
session.speakerNames.length ? session.speakerNames.join("、") : "—";
const trackField = card.querySelector('[data-field="track"]');
if (session.trackName) {
trackField.textContent = session.trackName;
} else {
addLanguageLines(trackField, copy.trackUnavailable, "meta-language-line");
trackField.classList.add("is-missing");
}
slot.append(card);
}
function sessionStatus(session) {
if (!anchorSession || session.id === anchorSession.id) return sessionStatuses.selected;
return sessionStatuses.ended;
}
function updateNavigation(session) {
const previous = findPreviousSession(sessions, session);
const next = findNextSession(sessions, session);
previousButton.disabled = !previous;
currentButton.disabled = session.id === anchorSession?.id;
nextButton.disabled = !next;
previousButton.dataset.sessionId = previous?.id ?? "";
nextButton.dataset.sessionId = next?.id ?? "";
}
async function hydrateTrack(session) {
if (!session) return null;
try {
const trackName = await fetchTrackName(session);
return { ...session, trackName };
} catch (error) {
console.warn(`Unable to load the track for ${session.id}`, error);
return { ...session, trackName: "" };
}
}
async function displaySession(source) {
const version = ++renderVersion;
previousButton.disabled = true;
currentButton.disabled = true;
nextButton.disabled = true;
setStatus([
"正在載入選擇的議程…",
"Loading the selected session…",
"選択したセッションを読み込んでいます…",
"선택한 세션을 불러오고 있습니다…",
]);
const session = await hydrateTrack(source);
if (version !== renderVersion) return;
renderSessionCard(selectedSlot, session, sessionStatus(session));
updateNavigation(source);
sessionResults.hidden = false;
if (!session.trackName) {
setStatus(
[
"議程已載入;議程軌無法取得,表單將保留空白供確認。",
"Session loaded; the unavailable track is left blank for confirmation.",
"セッションを読み込みました。取得できないトラックは確認用に空欄にします。",
"세션을 불러왔습니다. 확인할 수 없는 트랙은 빈칸으로 둡니다.",
],
"error",
);
} else {
statusPanel.hidden = true;
}
}
async function showSession(sessionId) {
const currentSource = findSession(sessions, sessionId);
document.body.dataset.mode = currentSource ? "results" : "lookup";
sessionInput.value = sessionId;
lookup.hidden = Boolean(currentSource);
if (!currentSource) {
++renderVersion;
anchorSession = null;
sessionResults.hidden = true;
setStatus(
[
`找不到 session ID「${sessionId}」,請確認後再試一次。`,
`Session ID “${sessionId}” was not found. Please check it and try again.`,
`セッション ID「${sessionId}」が見つかりません。確認してもう一度お試しください。`,
`세션 ID “${sessionId}”을(를) 찾을 수 없습니다. 확인 후 다시 시도해 주세요.`,
],
"error",
);
return;
}
anchorSession = currentSource;
await displaySession(currentSource);
}
function navigateFrom(button) {
const target = findSession(sessions, button.dataset.sessionId);
if (target) void displaySession(target);
}
previousButton.addEventListener("click", () => navigateFrom(previousButton));
currentButton.addEventListener("click", () => {
if (anchorSession) void displaySession(anchorSession);
});
nextButton.addEventListener("click", () => navigateFrom(nextButton));
function updateSessionInUrl(sessionId) {
const url = new URL(window.location.href);
url.searchParams.set("session", sessionId);
url.searchParams.delete("id");
url.searchParams.delete("session_id");
url.searchParams.delete("sessionId");
url.hash = "";
window.history.replaceState({}, "", url);
}
function pickerSessionMeta(session) {
const date = new Intl.DateTimeFormat("zh-TW", {
timeZone: "Asia/Taipei",
month: "numeric",
day: "numeric",
}).format(session.startAt);
const time = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Taipei",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(session.startAt);
return { date, time };
}
function createChoiceButton(className, label) {
const button = document.createElement("button");
button.type = "button";
button.className = className;
button.textContent = label;
return button;
}
function populateTrackPicker() {
const tracks = new Map();
sessions.forEach((session) => {
if (!session.trackName) return;
const key = session.trackId || session.trackName;
if (!tracks.has(key)) tracks.set(key, session.trackName);
});
const sortedTracks = [...tracks.entries()].sort((a, b) =>
a[1].localeCompare(b[1], "zh-Hant"),
);
trackList.replaceChildren();
sortedTracks.forEach(([value, label]) => {
const button = createChoiceButton("choice-button choice-button--track", label);
button.addEventListener("click", () => populateSessionPicker(value, label));
trackList.append(button);
});
if (!sortedTracks.length) {
const message = document.createElement("p");
message.className = "choice-list__message";
message.textContent = "目前沒有可選的議程軌 / No tracks available";
trackList.append(message);
}
}
function populateSessionPicker(trackKey, trackName) {
const matchingSessions = sessions
.filter((session) => (session.trackId || session.trackName) === trackKey)
.sort((a, b) => a.startAt - b.startAt || a.title.localeCompare(b.title, "zh-Hant"));
selectedTrackName.textContent = trackName;
trackSessionList.replaceChildren();
matchingSessions.forEach((session) => {
const { date, time } = pickerSessionMeta(session);
const button = createChoiceButton("choice-button choice-button--session", "");
const meta = document.createElement("span");
meta.className = "choice-session__meta";
meta.textContent = `${date} ${time} · ${session.roomName}`;
const title = document.createElement("b");
title.textContent = session.title;
button.append(meta, title);
button.setAttribute(
"aria-label",
`${session.title},${date} ${time},${session.roomName}`,
);
button.addEventListener("click", () => {
updateSessionInUrl(session.id);
void showSession(session.id);
});
trackSessionList.append(button);
});
if (!matchingSessions.length) {
const message = document.createElement("p");
message.className = "choice-list__message";
message.textContent = "此議程軌沒有可選議程 / No sessions available";
trackSessionList.append(message);
}
trackPickerStep.hidden = true;
sessionPickerStep.hidden = false;
trackSessionList.querySelector("button")?.focus();
}
changeTrackButton.addEventListener("click", () => {
sessionPickerStep.hidden = true;
trackPickerStep.hidden = false;
trackList.querySelector("button")?.focus();
});
lookupForm.addEventListener("submit", (event) => {
event.preventDefault();
const sessionId = sessionInput.value.trim();
if (!sessionId) {
setStatus(
[
"請先輸入 session ID。",
"Enter a session ID first.",
"先にセッション ID を入力してください。",
"먼저 세션 ID를 입력해 주세요.",
],
"error",
);
sessionInput.focus();
return;
}
updateSessionInUrl(sessionId);
void showSession(sessionId);
});
async function init() {
const sessionId = sessionIdFromLocation(window.location);
document.body.dataset.mode = sessionId ? "results" : "lookup";
lookup.hidden = Boolean(sessionId);
lookupButton.disabled = true;
try {
sessions = await fetchSchedule(SCHEDULE_URL);
if (!sessions.length) throw new Error("議程資料是空的");
populateTrackPicker();
lookupButton.disabled = false;
if (sessionId) {
await showSession(sessionId);
} else {
statusPanel.hidden = true;
trackList.querySelector("button")?.focus();
}
} catch (error) {
const timeout = error?.name === "AbortError";
setStatus(
timeout
? [
"讀取議程資料逾時,請稍後重新整理頁面。",
"Loading the program timed out. Please refresh later.",
"プログラムの読み込みがタイムアウトしました。後でもう一度お試しください。",
"프로그램 로딩 시간이 초과되었습니다. 잠시 후 새로고침해 주세요.",
]
: [
`暫時無法讀取議程資料:${error.message}`,
"The program is temporarily unavailable.",
"現在プログラムを読み込めません。",
"현재 프로그램을 불러올 수 없습니다.",
],
"error",
);
}
}
void init();