@noe-teritorio/opening_hours 3.14.1
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/CHANGELOG.md +816 -0
- package/LICENSES/AGPL-3.0-only.txt +235 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC0-1.0.txt +121 -0
- package/LICENSES/LGPL-3.0-only.txt +304 -0
- package/LICENSES/ODbL-1.0.txt +540 -0
- package/Makefile +526 -0
- package/README.md +926 -0
- package/REUSE.toml +40 -0
- package/build/opening_hours.esm.mjs +45870 -0
- package/build/opening_hours.esm.mjs.map +1 -0
- package/build/opening_hours.js +45875 -0
- package/package.json +133 -0
- package/site/js/countryToLanguageMapping.js +247 -0
- package/site/js/helpers.js +735 -0
- package/site/js/i18n-resources.js +2284 -0
- package/site/js/main.js +401 -0
- package/site/js/opening_hours_table.js +414 -0
- package/site/js/theme.js +73 -0
- package/site/js/yohours_model.js +2791 -0
- package/src/locales/i18n.js +97 -0
- package/types/index.d.ts +159 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: © 2013 Robin Schneider <ypid@riseup.net>
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: LGPL-3.0-only
|
|
5
|
+
*/
|
|
6
|
+
// Import dependencies
|
|
7
|
+
import i18next from '../../node_modules/i18next/dist/esm/i18next.js';
|
|
8
|
+
|
|
9
|
+
export const OpeningHoursTable = {
|
|
10
|
+
|
|
11
|
+
// JS functions for generating the table {{{
|
|
12
|
+
// In English. Localization is done somewhere else (above).
|
|
13
|
+
months: ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'],
|
|
14
|
+
weekdays: ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'],
|
|
15
|
+
|
|
16
|
+
getLocalizedWeekday(date) {
|
|
17
|
+
return date.toLocaleString(i18next.language, { weekday: 'short' });
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
// Returns a date's local time as a percentage of the day (0–100).
|
|
21
|
+
dayPercent(date) {
|
|
22
|
+
return (date.getHours() * 60 + date.getMinutes()) / 1440 * 100;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Formats a date's local time as "HH:MM".
|
|
26
|
+
formatHM(date) {
|
|
27
|
+
return date.toLocaleString('en', { hourCycle: 'h23', hour: '2-digit', minute: '2-digit' });
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
formatdate (now, nextchange, from) {
|
|
31
|
+
const now_daystart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
32
|
+
const nextdays = (nextchange.getTime() - now_daystart.getTime()) / 1000 / 60 / 60 / 24;
|
|
33
|
+
|
|
34
|
+
let timediff = '';
|
|
35
|
+
|
|
36
|
+
let delta = Math.floor((nextchange.getTime() - now.getTime()) / 1000 / 60); // delta is minutes
|
|
37
|
+
if (delta < 60) {
|
|
38
|
+
timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.minute')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const deltaminutes = delta % 60;
|
|
42
|
+
delta = Math.floor(delta / 60); // delta is now hours
|
|
43
|
+
|
|
44
|
+
if (delta < 48 && timediff === '') {
|
|
45
|
+
timediff =
|
|
46
|
+
`${i18next.t('words.in duration')} `
|
|
47
|
+
+ `${delta} `
|
|
48
|
+
+ `${this.plural(delta, 'words.time.hour')} `
|
|
49
|
+
+ `${i18next.t('words.time.hours minutes sep')}`
|
|
50
|
+
+ `${this.pad(deltaminutes)} `
|
|
51
|
+
+ `${this.plural(deltaminutes, 'words.time.minute')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const deltahours = delta % 24;
|
|
55
|
+
delta = Math.floor(delta / 24); // delta is now days
|
|
56
|
+
|
|
57
|
+
if (delta < 14 && timediff === '') {
|
|
58
|
+
timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day')
|
|
59
|
+
} ${deltahours} ${this.plural(deltahours, 'words.time.hour')}`;
|
|
60
|
+
} else if (timediff === '') {
|
|
61
|
+
timediff = `${i18next.t('words.in duration')} ${delta} ${this.plural(delta, 'words.time.day')}`;
|
|
62
|
+
}
|
|
63
|
+
let atday = '';
|
|
64
|
+
if (from ? (nextdays < 1) : (nextdays <= 1)) {
|
|
65
|
+
atday = i18next.t('words.today');
|
|
66
|
+
} else if (from ? (nextdays < 2) : (nextdays <= 2)) {
|
|
67
|
+
atday = i18next.t('words.tomorrow');
|
|
68
|
+
} else if (from ? (nextdays < 7) : (nextdays <= 7)) {
|
|
69
|
+
if (i18next.exists(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`)) {
|
|
70
|
+
atday = i18next.t(`weekdays.days next week.${this.weekdays[nextchange.getDay()]}`, {
|
|
71
|
+
day: nextchange.toLocaleString(i18next.language, {weekday: 'long'})
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
atday = i18next.t('weekdays.day next week', {
|
|
75
|
+
day: nextchange.toLocaleString(i18next.language, {weekday: 'long'})
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let month_name = nextchange.toLocaleString(i18next.language, {month: 'long'});
|
|
81
|
+
const month_name_match = month_name.match(/\(([^|]+?)\|.*\)/);
|
|
82
|
+
if (month_name_match && typeof month_name_match[1] === 'string') {
|
|
83
|
+
/* The language has multiple words for the month (nominative, subjective).
|
|
84
|
+
* Use the first one.
|
|
85
|
+
* https://github.com/opening-hours/opening_hours_map/issues/41
|
|
86
|
+
*/
|
|
87
|
+
month_name = month_name_match[1];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const atdate = `${nextchange.getDate()} ${month_name}`;
|
|
91
|
+
const res = [];
|
|
92
|
+
|
|
93
|
+
if (atday !== '') res.push(atday);
|
|
94
|
+
if (atdate !== '') res.push(atdate);
|
|
95
|
+
if (timediff !== '') res.push(timediff);
|
|
96
|
+
|
|
97
|
+
return res.join(', ');
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
pad (n) { return n < 10 ? `0${n}` : n; },
|
|
101
|
+
|
|
102
|
+
plural (n, trans_base) {
|
|
103
|
+
// i18next plural function call
|
|
104
|
+
return i18next.t(trans_base, {count: n});
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
toISODateString (date) {
|
|
108
|
+
// ISO 8601: https://xkcd.com/1179/
|
|
109
|
+
return `${date.getFullYear()}-${
|
|
110
|
+
this.pad(date.getMonth() + 1)}-${
|
|
111
|
+
this.pad(date.getDate())}`;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
printTime (date) {
|
|
115
|
+
// return date.toLocaleTimeString('de');
|
|
116
|
+
return `${this.pad(date.getHours())}:${
|
|
117
|
+
this.pad(date.getMinutes())}:${
|
|
118
|
+
this.pad(date.getSeconds())}`;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
drawTable (it, date_today, has_next_change, evalDate) {
|
|
122
|
+
date_today = new Date(date_today);
|
|
123
|
+
date_today.setHours(0, 0, 0, 0);
|
|
124
|
+
|
|
125
|
+
const date = new Date(date_today);
|
|
126
|
+
date.setDate(date.getDate() - date.getDay() - 1); // start at begin of the week
|
|
127
|
+
|
|
128
|
+
// Calculate current time position for "now" marker (percentage of day)
|
|
129
|
+
// Use evalDate instead of new Date() to show the evaluation time, not browser time
|
|
130
|
+
const now = evalDate || new Date();
|
|
131
|
+
const nowPercent = this.dayPercent(now);
|
|
132
|
+
|
|
133
|
+
const tableData = [];
|
|
134
|
+
|
|
135
|
+
for (let row = 0; row < 7; row++) {
|
|
136
|
+
date.setDate(date.getDate() + 1);
|
|
137
|
+
|
|
138
|
+
it.setDate(date);
|
|
139
|
+
let is_open = it.getState();
|
|
140
|
+
let unknown = it.getUnknown();
|
|
141
|
+
let state_string = it.getStateString(true);
|
|
142
|
+
let prevdate = date;
|
|
143
|
+
let curdate = date;
|
|
144
|
+
|
|
145
|
+
const rowData = {
|
|
146
|
+
date: new Date(date),
|
|
147
|
+
times: [],
|
|
148
|
+
text: [],
|
|
149
|
+
isToday: date.getDay() === date_today.getDay()
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
while (has_next_change && it.advance() && curdate.getTime() - date.getTime() < 24 * 60 * 60 * 1000) {
|
|
153
|
+
curdate = it.getDate();
|
|
154
|
+
|
|
155
|
+
// Use local clock time (not elapsed ms) so bars stay aligned
|
|
156
|
+
// with labels on DST transition days.
|
|
157
|
+
const crossesMidnight = prevdate.getDay() !== curdate.getDay();
|
|
158
|
+
|
|
159
|
+
const from = this.dayPercent(prevdate);
|
|
160
|
+
const to = crossesMidnight ? 100 : this.dayPercent(curdate);
|
|
161
|
+
|
|
162
|
+
const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed');
|
|
163
|
+
const timeFrom = this.formatHM(prevdate);
|
|
164
|
+
const timeTo = crossesMidnight ? '24:00' : this.formatHM(curdate);
|
|
165
|
+
|
|
166
|
+
// Use current state_string for this period (before advancing)
|
|
167
|
+
const currentStateString = state_string;
|
|
168
|
+
const tooltip = `${i18next.t(`words.${currentStateString}`)}: ${timeFrom} - ${timeTo}`;
|
|
169
|
+
|
|
170
|
+
rowData.times.push(
|
|
171
|
+
`<div class="timebar ${stateClass}" style="width:${to - from}%" title="${tooltip}"></div>`
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (is_open || unknown) {
|
|
175
|
+
const text = `${i18next.t(`words.${currentStateString}`)} ${i18next.t('words.from')} ${timeFrom} ${i18next.t('words.to')} ${timeTo}`;
|
|
176
|
+
rowData.text.push(text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
prevdate = curdate;
|
|
180
|
+
is_open = it.getState();
|
|
181
|
+
unknown = it.getUnknown();
|
|
182
|
+
state_string = it.getStateString(true);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!has_next_change && rowData.text.length === 0) { // 24/7
|
|
186
|
+
const stateClass = is_open ? 'open' : (unknown ? 'unknown' : 'closed');
|
|
187
|
+
const tooltip = is_open ? `${i18next.t('words.open')}: 00:00 - 24:00` : '';
|
|
188
|
+
rowData.times.push(
|
|
189
|
+
`<div class="timebar ${stateClass}" style="width:100%" title="${tooltip}"></div>`
|
|
190
|
+
);
|
|
191
|
+
if (is_open) {
|
|
192
|
+
rowData.text.push(`${i18next.t('words.open')} 00:00 ${i18next.t('words.to')} 24:00`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
tableData.push(rowData);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build table HTML
|
|
200
|
+
const headerRow = `
|
|
201
|
+
<tr class="time-scale">
|
|
202
|
+
<td></td>
|
|
203
|
+
<td>
|
|
204
|
+
<div class="scale-labels">
|
|
205
|
+
<span>0h</span>
|
|
206
|
+
<span>6h</span>
|
|
207
|
+
<span>12h</span>
|
|
208
|
+
<span>18h</span>
|
|
209
|
+
<span>24h</span>
|
|
210
|
+
</div>
|
|
211
|
+
</td>
|
|
212
|
+
<td></td>
|
|
213
|
+
</tr>`;
|
|
214
|
+
|
|
215
|
+
const rows = tableData.map(row => {
|
|
216
|
+
const isToday = row.date.getDay() === date_today.getDay();
|
|
217
|
+
const isEndWeek = (row.date.getDay() + 1) % 7 === date_today.getDay();
|
|
218
|
+
const rowClass = isToday ? ' class="today"' : (isEndWeek ? ' class="endweek"' : '');
|
|
219
|
+
const dayClass = row.date.getDay() % 6 === 0 ? 'weekend' : 'workday';
|
|
220
|
+
const weekdayName = this.getLocalizedWeekday(row.date);
|
|
221
|
+
|
|
222
|
+
// Add "now" marker for today
|
|
223
|
+
const nowMarker = isToday
|
|
224
|
+
? `<div class="now-marker" style="left:${nowPercent}%" title="${i18next.t('words.time.now')}"></div>`
|
|
225
|
+
: '';
|
|
226
|
+
|
|
227
|
+
return `<tr${rowClass}>
|
|
228
|
+
<td class="day ${dayClass}">
|
|
229
|
+
<span class="weekday">${weekdayName}</span>
|
|
230
|
+
<span class="date">${this.toISODateString(row.date)}</span>
|
|
231
|
+
</td>
|
|
232
|
+
<td class="times">
|
|
233
|
+
${row.times.join('')}
|
|
234
|
+
${nowMarker}
|
|
235
|
+
</td>
|
|
236
|
+
<td class="description">${row.text.join(', ') || ' '}</td>
|
|
237
|
+
</tr>`;
|
|
238
|
+
}).join('');
|
|
239
|
+
|
|
240
|
+
return `<table class="opening-hours-table">${headerRow}${rows}</table>`;
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
drawTableAndComments (oh, it, evalDate, warnings = [], publicHolidayContext = {}) {
|
|
244
|
+
const prevdate = it.getDate();
|
|
245
|
+
const unknown = it.getUnknown();
|
|
246
|
+
const currentState = it.getState();
|
|
247
|
+
const state_string_past = it.getStateString(true);
|
|
248
|
+
const comment = it.getComment();
|
|
249
|
+
const has_next_change = it.advance();
|
|
250
|
+
const hasPublicHolidayWarning = warnings.some(
|
|
251
|
+
warning => warning.type === 'public_holiday');
|
|
252
|
+
const hasPublicHolidayWarningForDate = publicHolidayContext.isHoliday
|
|
253
|
+
&& hasPublicHolidayWarning;
|
|
254
|
+
|
|
255
|
+
let output = '';
|
|
256
|
+
|
|
257
|
+
// 1. Current status
|
|
258
|
+
output += `<p class="${state_string_past} status-info">${
|
|
259
|
+
i18next.t(`texts.${state_string_past} ${has_next_change ? 'now' : 'always'}`)}`;
|
|
260
|
+
if (unknown) {
|
|
261
|
+
output += i18next.t('texts.depends on', {comment: `"${comment}"`});
|
|
262
|
+
}
|
|
263
|
+
if (hasPublicHolidayWarningForDate) {
|
|
264
|
+
output += `<span class="public-holiday-status">${i18next.t('texts.public holiday status context')}</span>`;
|
|
265
|
+
}
|
|
266
|
+
output += '</p>';
|
|
267
|
+
|
|
268
|
+
// 2. Show reason (comment) if present and not unknown
|
|
269
|
+
if (typeof comment !== 'undefined' && !unknown) {
|
|
270
|
+
output += `<p class="status-reason">↳ ${i18next.t('texts.reason')}: ${comment}</p>`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 3. Find next REAL state change (not just interval boundary)
|
|
274
|
+
if (has_next_change) {
|
|
275
|
+
let nextRealChangeDate = null;
|
|
276
|
+
let nextRealStateString = null;
|
|
277
|
+
let time_diff = 0;
|
|
278
|
+
|
|
279
|
+
// Check if immediate next change is a real state change
|
|
280
|
+
if (it.getState() !== currentState) {
|
|
281
|
+
nextRealChangeDate = it.getDate();
|
|
282
|
+
nextRealStateString = it.getStateString(false);
|
|
283
|
+
time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60;
|
|
284
|
+
} else {
|
|
285
|
+
// Keep advancing until we find a real state change
|
|
286
|
+
// Limit iterations to prevent infinite loops with complex values
|
|
287
|
+
const maxIterations = 1000;
|
|
288
|
+
let iterations = 0;
|
|
289
|
+
while (it.advance() && iterations < maxIterations) {
|
|
290
|
+
iterations++;
|
|
291
|
+
if (it.getState() !== currentState) {
|
|
292
|
+
nextRealChangeDate = it.getDate();
|
|
293
|
+
nextRealStateString = it.getStateString(false);
|
|
294
|
+
time_diff = (nextRealChangeDate.getTime() - prevdate.getTime()) / 1000 + 60;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (nextRealChangeDate) {
|
|
301
|
+
const timeString = this.formatdate(prevdate, nextRealChangeDate, true);
|
|
302
|
+
// Use "opens again" or "closes again" based on what will happen
|
|
303
|
+
const translationKey = nextRealStateString === 'open' ? 'texts.opens again' : 'texts.closes again';
|
|
304
|
+
const statusText = i18next.t(translationKey);
|
|
305
|
+
const buttonText = i18next.t('texts.jump to time');
|
|
306
|
+
|
|
307
|
+
const nextStateClass = nextRealStateString === 'open' ? 'opened' : 'closed';
|
|
308
|
+
output += `<p class="${nextStateClass} status-info next-change">
|
|
309
|
+
${statusText}: ${timeString}
|
|
310
|
+
<a href="#" class="time-jump-btn" data-offset="${time_diff}" title="${buttonText}">
|
|
311
|
+
${buttonText}
|
|
312
|
+
</a>
|
|
313
|
+
</p>`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Add upcoming changes timeline
|
|
318
|
+
const upcomingChanges = this.generateUpcomingChanges(oh, evalDate, 5);
|
|
319
|
+
output += this.generateUpcomingChangesHTML(upcomingChanges, evalDate);
|
|
320
|
+
|
|
321
|
+
output += this.drawTable(it, prevdate, has_next_change, evalDate);
|
|
322
|
+
|
|
323
|
+
if (oh.isWeekStable()) {
|
|
324
|
+
output += `<p><b>${i18next.t('texts.week stable')}</b></p>`;
|
|
325
|
+
} else {
|
|
326
|
+
output += `<p><b>${i18next.t('texts.not week stable')}</b></p>`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return output;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// Generate upcoming changes timeline {{{
|
|
333
|
+
generateUpcomingChanges(oh, currentDate, maxChanges = 5) {
|
|
334
|
+
const changes = [];
|
|
335
|
+
const it = oh.getIterator(currentDate);
|
|
336
|
+
const currentState = it.getState();
|
|
337
|
+
let previousState = currentState;
|
|
338
|
+
|
|
339
|
+
// Collect next changes (all interval boundaries, not just state changes)
|
|
340
|
+
let count = 0;
|
|
341
|
+
while (count < maxChanges && it.advance()) {
|
|
342
|
+
const changeDate = it.getDate();
|
|
343
|
+
const newState = it.getState();
|
|
344
|
+
const comment = it.getComment();
|
|
345
|
+
const stateString = it.getStateString(true); // Use past form for consistency
|
|
346
|
+
|
|
347
|
+
changes.push({
|
|
348
|
+
date: changeDate,
|
|
349
|
+
state: newState,
|
|
350
|
+
stateString: stateString,
|
|
351
|
+
comment: comment,
|
|
352
|
+
isActualStateChange: previousState !== newState
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
previousState = newState;
|
|
356
|
+
count++;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return changes;
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
formatUpcomingChangeTime(currentDate, changeDate) {
|
|
363
|
+
const now_daystart = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate());
|
|
364
|
+
const change_daystart = new Date(changeDate.getFullYear(), changeDate.getMonth(), changeDate.getDate());
|
|
365
|
+
const daysDiff = Math.round((change_daystart.getTime() - now_daystart.getTime()) / (1000 * 60 * 60 * 24));
|
|
366
|
+
|
|
367
|
+
const timeStr = this.formatHM(changeDate);
|
|
368
|
+
|
|
369
|
+
if (daysDiff === 0) {
|
|
370
|
+
return `${i18next.t('words.today')} ${timeStr}`;
|
|
371
|
+
} else if (daysDiff === 1) {
|
|
372
|
+
return `${i18next.t('words.tomorrow')} ${timeStr}`;
|
|
373
|
+
} else if (daysDiff === -1) {
|
|
374
|
+
return `${i18next.t('words.yesterday')} ${timeStr}`;
|
|
375
|
+
} else {
|
|
376
|
+
// For dates further away, show date + time
|
|
377
|
+
const dateStr = this.toISODateString(changeDate);
|
|
378
|
+
return `${dateStr} ${timeStr}`;
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
generateUpcomingChangesHTML(changes, currentDate) {
|
|
383
|
+
if (changes.length === 0) return '';
|
|
384
|
+
|
|
385
|
+
let html = `<details class="upcoming-changes">
|
|
386
|
+
<summary>${i18next.t('texts.interval boundaries')}</summary>
|
|
387
|
+
<p class="timeline-hint">${i18next.t('texts.interval boundaries hint')}</p>
|
|
388
|
+
<ul class="timeline">`;
|
|
389
|
+
|
|
390
|
+
for (const change of changes) {
|
|
391
|
+
const timeStr = this.formatUpcomingChangeTime(currentDate, change.date);
|
|
392
|
+
const stateClass = change.state ? 'opened' : 'closed';
|
|
393
|
+
// Visual distinction: filled circle for real changes, empty for boundaries
|
|
394
|
+
const changeIcon = change.isActualStateChange ? '●' : '○';
|
|
395
|
+
const changeType = change.isActualStateChange ? 'state-change' : 'boundary-only';
|
|
396
|
+
const stateText = i18next.t(`words.${change.stateString}`);
|
|
397
|
+
const commentText = typeof change.comment === 'string'
|
|
398
|
+
? ` <span class="timeline-comment">(${change.comment})</span>`
|
|
399
|
+
: '';
|
|
400
|
+
|
|
401
|
+
html += `<li class="timeline-item ${stateClass} ${changeType}">
|
|
402
|
+
<span class="timeline-icon">${changeIcon}</span>
|
|
403
|
+
<span class="timeline-time">${timeStr}</span>
|
|
404
|
+
<span class="timeline-arrow">→</span>
|
|
405
|
+
<span class="timeline-state">${stateText}</span>${commentText}
|
|
406
|
+
</li>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
html += '</ul></details>';
|
|
410
|
+
return html;
|
|
411
|
+
},
|
|
412
|
+
// }}}
|
|
413
|
+
// }}}
|
|
414
|
+
};
|
package/site/js/theme.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: © 2025 Kristjan ESPERANTO <https://github.com/KristjanESPERANTO>
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: LGPL-3.0-only
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Theme management: explicit preference or browser preference in auto mode
|
|
8
|
+
(function() {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'theme-preference';
|
|
12
|
+
const THEMES = ['auto', 'light', 'dark'];
|
|
13
|
+
const THEME_ICONS = { auto: '🌗', light: '☀️', dark: '🌙' };
|
|
14
|
+
|
|
15
|
+
function getThemePreference() {
|
|
16
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
17
|
+
if (stored === 'light' || stored === 'dark') {
|
|
18
|
+
return stored;
|
|
19
|
+
}
|
|
20
|
+
if (stored !== null) {
|
|
21
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
22
|
+
}
|
|
23
|
+
return 'auto';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const themeToggle = document.getElementById('theme-toggle');
|
|
27
|
+
|
|
28
|
+
function applyTheme(theme) {
|
|
29
|
+
if (theme === 'auto') {
|
|
30
|
+
document.body.removeAttribute('data-theme');
|
|
31
|
+
} else {
|
|
32
|
+
document.body.setAttribute('data-theme', theme);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const currentIcon = document.getElementById('theme-current-icon');
|
|
36
|
+
if (currentIcon) {
|
|
37
|
+
currentIcon.textContent = THEME_ICONS[theme];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (themeToggle) {
|
|
41
|
+
themeToggle.dataset.theme = theme;
|
|
42
|
+
themeToggle.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setTheme(theme) {
|
|
47
|
+
const selectedTheme = THEMES.includes(theme) ? theme : 'auto';
|
|
48
|
+
applyTheme(selectedTheme);
|
|
49
|
+
|
|
50
|
+
if (selectedTheme === 'auto') {
|
|
51
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
52
|
+
} else {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, selectedTheme);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
applyTheme(getThemePreference());
|
|
58
|
+
|
|
59
|
+
if (themeToggle) {
|
|
60
|
+
themeToggle.addEventListener('click', () => {
|
|
61
|
+
const currentTheme = getThemePreference();
|
|
62
|
+
const nextTheme = THEMES[(THEMES.indexOf(currentTheme) + 1) % THEMES.length];
|
|
63
|
+
setTheme(nextTheme);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
68
|
+
darkModeQuery.addEventListener('change', () => {
|
|
69
|
+
if (getThemePreference() === 'auto') {
|
|
70
|
+
applyTheme('auto');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
})();
|