rei-kit 0.1.0
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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/app-error-DF9cijE0.js +63 -0
- package/dist/app-error-DF9cijE0.js.map +1 -0
- package/dist/components/BaseButton.vue.d.ts +20 -0
- package/dist/components/BaseInput.vue.d.ts +23 -0
- package/dist/components/BaseSheet.vue.d.ts +32 -0
- package/dist/components/EmptyState.vue.d.ts +19 -0
- package/dist/components/LocaleLinks.vue.d.ts +33 -0
- package/dist/components/PageHeader.vue.d.ts +20 -0
- package/dist/components/SectionHeading.vue.d.ts +26 -0
- package/dist/components/SegmentedControl.vue.d.ts +27 -0
- package/dist/components/SettingsGroup.vue.d.ts +16 -0
- package/dist/components/SettingsRow.vue.d.ts +35 -0
- package/dist/components/SkeletonList.vue.d.ts +8 -0
- package/dist/components/StatCard.vue.d.ts +8 -0
- package/dist/components/ToneDot.vue.d.ts +16 -0
- package/dist/composables/use-debounced-callback.d.ts +23 -0
- package/dist/composables/use-drag-scroll.d.ts +26 -0
- package/dist/composables/use-online.d.ts +18 -0
- package/dist/composables/use-theme.d.ts +23 -0
- package/dist/composables/use-today.d.ts +10 -0
- package/dist/composables/use-visual-viewport.d.ts +29 -0
- package/dist/i18n/runtime.d.ts +53 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +1349 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +27 -0
- package/dist/supabase/index.d.ts +28 -0
- package/dist/supabase.js +100 -0
- package/dist/supabase.js.map +1 -0
- package/dist/tokens.css +139 -0
- package/dist/utils/app-error.d.ts +58 -0
- package/dist/utils/date.d.ts +122 -0
- package/dist/utils/day-label.d.ts +27 -0
- package/dist/utils/download.d.ts +7 -0
- package/dist/utils/format.d.ts +24 -0
- package/dist/utils/haptics.d.ts +9 -0
- package/dist/utils/platform.d.ts +23 -0
- package/dist/utils/redirect.d.ts +41 -0
- package/package.json +98 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
import { n as registerErrorMapper, r as toAppError, t as AppError } from "./app-error-DF9cijE0.js";
|
|
2
|
+
import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, mergeModels, mergeProps, nextTick, normalizeClass, normalizeStyle, onMounted, onScopeDispose, onUnmounted, openBlock, readonly, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, unref, useId, useModel, vModelDynamic, vModelRadio, watch, watchEffect, withCtx, withDirectives } from "vue";
|
|
3
|
+
import { ArrowDown, ArrowRight, ArrowUp, ChevronRight, X } from "lucide-vue-next";
|
|
4
|
+
import { createI18n } from "vue-i18n";
|
|
5
|
+
//#region src/utils/date.ts
|
|
6
|
+
/**
|
|
7
|
+
* Local calendar-day helpers.
|
|
8
|
+
*
|
|
9
|
+
* Every function is pure and works on `YYYY-MM-DD` keys, the same shape as the
|
|
10
|
+
* `date` columns in Postgres. Nothing here calls `toISOString`: that converts to
|
|
11
|
+
* UTC, so in a UTC+9 timezone every entry made between midnight and 09:00 would
|
|
12
|
+
* be written to the previous day.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Formats a `Date` as a local `YYYY-MM-DD` key.
|
|
16
|
+
*
|
|
17
|
+
* @param date - Any `Date`; only its local year, month and day are read.
|
|
18
|
+
* @returns The calendar day in the runtime's own timezone.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // 2026-08-23 01:30 in Tokyo
|
|
23
|
+
* toDateKey(new Date()) // '2026-08-23'
|
|
24
|
+
* new Date().toISOString() // '2026-08-22T16:30…' ← the bug
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
function toDateKey(date) {
|
|
28
|
+
return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
29
|
+
}
|
|
30
|
+
/** Today's key in the user's own timezone. */
|
|
31
|
+
function todayKey() {
|
|
32
|
+
return toDateKey(/* @__PURE__ */ new Date());
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parses a `YYYY-MM-DD` key into a `Date` at local midnight.
|
|
36
|
+
*
|
|
37
|
+
* @param key - A key produced by {@link toDateKey}.
|
|
38
|
+
* @returns Local midnight of that calendar day.
|
|
39
|
+
* @throws If the key is not three numeric parts.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* fromDateKey('2026-08-23') // local midnight, correct
|
|
44
|
+
* new Date('2026-08-23') // UTC midnight — shifts a day in some zones
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
function fromDateKey(key) {
|
|
48
|
+
const [year, month, day] = key.split("-").map(Number);
|
|
49
|
+
if (year === void 0 || month === void 0 || day === void 0) throw new Error(`Invalid date key: ${key}`);
|
|
50
|
+
return new Date(year, month - 1, day);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Shifts a date key by whole calendar days.
|
|
54
|
+
*
|
|
55
|
+
* Uses `setDate`, which is calendar-aware: it rolls over month and year ends,
|
|
56
|
+
* and stays correct across daylight-saving transitions. Adding
|
|
57
|
+
* `days * 86_400_000` milliseconds would not — a DST day is 23 or 25 hours long.
|
|
58
|
+
*
|
|
59
|
+
* @param key - Starting `YYYY-MM-DD` key.
|
|
60
|
+
* @param days - Days to add; negative goes back.
|
|
61
|
+
* @returns The resulting key.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* addDays('2026-01-31', 1) // '2026-02-01'
|
|
66
|
+
* addDays('2026-01-01', -1) // '2025-12-31'
|
|
67
|
+
* addDays('2028-02-28', 1) // '2028-02-29' — leap year
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
function addDays(key, days) {
|
|
71
|
+
const date = fromDateKey(key);
|
|
72
|
+
date.setDate(date.getDate() + days);
|
|
73
|
+
return toDateKey(date);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The last `count` days ending today, oldest first.
|
|
77
|
+
*
|
|
78
|
+
* `today` is a parameter so the function stays pure and testable; call sites
|
|
79
|
+
* normally omit it.
|
|
80
|
+
*
|
|
81
|
+
* @param count - How many days to return, including `today`.
|
|
82
|
+
* @param today - End of the range. Defaults to the real today.
|
|
83
|
+
* @returns Keys in ascending order.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* lastNDays(3, '2026-08-23') // ['2026-08-21', '2026-08-22', '2026-08-23']
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
function lastNDays(count, today = todayKey()) {
|
|
91
|
+
const keys = [];
|
|
92
|
+
for (let offset = count - 1; offset >= 0; offset -= 1) keys.push(addDays(today, -offset));
|
|
93
|
+
return keys;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The first day of the week containing `key`.
|
|
97
|
+
*
|
|
98
|
+
* The user's preference is a parameter, not a module-level setting: changing it
|
|
99
|
+
* in Profile has to re-render the week grid and the year heatmap immediately,
|
|
100
|
+
* and a global would make that a hidden dependency.
|
|
101
|
+
*
|
|
102
|
+
* @param key - Any day in the week.
|
|
103
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
104
|
+
* @returns Key of that week's first day.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* // 2026-08-23 is a Sunday
|
|
109
|
+
* startOfWeek('2026-08-23', 1) // '2026-08-17' — previous Monday
|
|
110
|
+
* startOfWeek('2026-08-23', 0) // '2026-08-23' — already Sunday
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
function startOfWeek(key, weekStartsOn) {
|
|
114
|
+
return addDays(key, -((fromDateKey(key).getDay() - weekStartsOn + 7) % 7));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Every day of a calendar year, in order.
|
|
118
|
+
*
|
|
119
|
+
* Leap years fall out of the loop for free: it walks day by day until the year
|
|
120
|
+
* rolls over, so February 29 is included when it exists.
|
|
121
|
+
*
|
|
122
|
+
* @param year - Four-digit year.
|
|
123
|
+
* @returns 365 or 366 keys, oldest first.
|
|
124
|
+
*/
|
|
125
|
+
function eachDayOfYear(year) {
|
|
126
|
+
const keys = [];
|
|
127
|
+
const date = new Date(year, 0, 1);
|
|
128
|
+
while (date.getFullYear() === year) {
|
|
129
|
+
keys.push(toDateKey(date));
|
|
130
|
+
date.setDate(date.getDate() + 1);
|
|
131
|
+
}
|
|
132
|
+
return keys;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Empty cells before a block's first day in a seven-row column grid.
|
|
136
|
+
*
|
|
137
|
+
* The grid fills column by column, so the first column is only partly used
|
|
138
|
+
* unless the block starts exactly on the week's first day. An off-by-one here
|
|
139
|
+
* shifts the whole block by a row, so this is unit tested.
|
|
140
|
+
*
|
|
141
|
+
* @param firstDayKey - First day of the block, e.g. `'2026-02-01'`.
|
|
142
|
+
* @param weekStartsOn - 0 for Sunday, 1 for Monday.
|
|
143
|
+
* @returns 0-6 blank cells.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* leadingBlanks('2026-01-01', 1) // 3 — a Thursday, Mon-Wed are blank
|
|
148
|
+
* leadingBlanks('2024-01-01', 1) // 0 — a Monday
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function leadingBlanks(firstDayKey, weekStartsOn) {
|
|
152
|
+
return (fromDateKey(firstDayKey).getDay() - weekStartsOn + 7) % 7;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/utils/format.ts
|
|
156
|
+
/**
|
|
157
|
+
* The locale `Intl` formatting uses.
|
|
158
|
+
*
|
|
159
|
+
* Held here rather than imported from an i18n runtime so the utilities have no
|
|
160
|
+
* i18n dependency at all: an app that never installs vue-i18n still gets dates
|
|
161
|
+
* in the right language. `createI18nRuntime` sets this when it is used.
|
|
162
|
+
*/
|
|
163
|
+
var locale = ref(typeof navigator === "undefined" ? "en" : navigator.language ?? "en");
|
|
164
|
+
/**
|
|
165
|
+
* Points every formatter at a new locale.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```ts
|
|
169
|
+
* setFormatLocale('tr-TR')
|
|
170
|
+
* ```
|
|
171
|
+
*/
|
|
172
|
+
function setFormatLocale(next) {
|
|
173
|
+
locale.value = next;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* `Intl.DateTimeFormat` is expensive to construct, so instances are cached per
|
|
177
|
+
* locale and option set. The key includes the locale, which is what lets the
|
|
178
|
+
* cache survive a language change instead of returning stale formatters.
|
|
179
|
+
*/
|
|
180
|
+
var cache = /* @__PURE__ */ new Map();
|
|
181
|
+
/**
|
|
182
|
+
* Formats a date in the active locale.
|
|
183
|
+
*
|
|
184
|
+
* Reading the locale ref here is deliberate: called from a `computed`, the
|
|
185
|
+
* result re-evaluates when the language changes.
|
|
186
|
+
*
|
|
187
|
+
* @param date - Date to format.
|
|
188
|
+
* @param options - Passed straight to `Intl.DateTimeFormat`.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* formatDate(new Date(), { weekday: 'narrow' }) // 'T'
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
function formatDate(date, options) {
|
|
196
|
+
const tag = locale.value;
|
|
197
|
+
const key = `${tag}:${JSON.stringify(options)}`;
|
|
198
|
+
let formatter = cache.get(key);
|
|
199
|
+
if (!formatter) {
|
|
200
|
+
formatter = new Intl.DateTimeFormat(tag, options);
|
|
201
|
+
cache.set(key, formatter);
|
|
202
|
+
}
|
|
203
|
+
return formatter.format(date);
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/utils/day-label.ts
|
|
207
|
+
/**
|
|
208
|
+
* A short name for a day, relative to today.
|
|
209
|
+
*
|
|
210
|
+
* "Today" and "Yesterday" are worth spelling out — they are the two a user
|
|
211
|
+
* actually reaches for. Anything older gets its weekday, which inside a
|
|
212
|
+
* five-day window is unambiguous and stays two or three characters in every
|
|
213
|
+
* language.
|
|
214
|
+
*
|
|
215
|
+
* The two words are arguments rather than translated here: a library that calls
|
|
216
|
+
* `t()` forces every consumer onto one i18n setup.
|
|
217
|
+
*
|
|
218
|
+
* @param dateKey - The day to label (`YYYY-MM-DD`).
|
|
219
|
+
* @param today - Today's key, passed in so the caller controls the clock.
|
|
220
|
+
* @param labels - What to call today and yesterday.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```ts
|
|
224
|
+
* relativeDayLabel('2026-08-28', '2026-08-31', { today: 'Today', yesterday: 'Yesterday' })
|
|
225
|
+
* // 'Fri'
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
function relativeDayLabel(dateKey, today, labels) {
|
|
229
|
+
if (dateKey === today) return labels.today;
|
|
230
|
+
if (dateKey === addDays(today, -1)) return labels.yesterday;
|
|
231
|
+
return formatDate(fromDateKey(dateKey), { weekday: "short" });
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/utils/download.ts
|
|
235
|
+
/**
|
|
236
|
+
* Hands the user a file without a server round trip.
|
|
237
|
+
*
|
|
238
|
+
* @param data - Anything `JSON.stringify` can serialise.
|
|
239
|
+
* @param filename - Suggested name, e.g. `hibi-export-2026-08-24.json`.
|
|
240
|
+
*/
|
|
241
|
+
function downloadJson(data, filename) {
|
|
242
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
243
|
+
const url = URL.createObjectURL(blob);
|
|
244
|
+
const link = document.createElement("a");
|
|
245
|
+
link.href = url;
|
|
246
|
+
link.download = filename;
|
|
247
|
+
link.click();
|
|
248
|
+
URL.revokeObjectURL(url);
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/utils/redirect.ts
|
|
252
|
+
/**
|
|
253
|
+
* Resolves a `?redirect=` query value into a safe in-app path.
|
|
254
|
+
*
|
|
255
|
+
* Only same-origin paths are accepted. Anything else falls back to `/`,
|
|
256
|
+
* so a crafted link cannot bounce a user from the real login page to a
|
|
257
|
+
* phishing clone.
|
|
258
|
+
*
|
|
259
|
+
* Pure: takes the query value instead of reading the router, so it also
|
|
260
|
+
* works inside navigation guards and can be unit tested.
|
|
261
|
+
*
|
|
262
|
+
* @param target - Raw `route.query.redirect` value. May be a string, an
|
|
263
|
+
* array (repeated query key), `null`, or `undefined`.
|
|
264
|
+
* @returns A path starting with a single `/`. Defaults to `/`.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* // in a view
|
|
269
|
+
* await router.push(safeRedirect(route.query.redirect))
|
|
270
|
+
*
|
|
271
|
+
* // in a guard
|
|
272
|
+
* return safeRedirect(to.query.redirect)
|
|
273
|
+
* ```
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* ```ts
|
|
277
|
+
* safeRedirect('/week') // '/week'
|
|
278
|
+
* safeRedirect('https://evil.com') // '/'
|
|
279
|
+
* safeRedirect('//evil.com') // '/' (protocol-relative URL)
|
|
280
|
+
* safeRedirect(['/a', '/b']) // '/'
|
|
281
|
+
* safeRedirect(undefined) // '/'
|
|
282
|
+
* ```
|
|
283
|
+
*/
|
|
284
|
+
function safeRedirect(target) {
|
|
285
|
+
if (typeof target === "string" && target.startsWith("/") && !target.startsWith("//")) return target;
|
|
286
|
+
return "/";
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
//#region src/utils/haptics.ts
|
|
290
|
+
/**
|
|
291
|
+
* A short vibration for a confirmed tap.
|
|
292
|
+
*
|
|
293
|
+
* Optional chaining is not decoration: iOS Safari has no `vibrate` at all, and
|
|
294
|
+
* calling it unguarded would throw on every marked day.
|
|
295
|
+
*
|
|
296
|
+
* @param duration - Milliseconds. Keep it under ~15ms; longer reads as an alert.
|
|
297
|
+
*/
|
|
298
|
+
function tapFeedback(duration = 10) {
|
|
299
|
+
navigator.vibrate?.(duration);
|
|
300
|
+
}
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/utils/platform.ts
|
|
303
|
+
/**
|
|
304
|
+
* Whether the app is running from the Home Screen rather than a browser tab.
|
|
305
|
+
*
|
|
306
|
+
* Two checks because iOS predates the standard one: `display-mode: standalone`
|
|
307
|
+
* is the modern signal, `navigator.standalone` is Safari's own.
|
|
308
|
+
*/
|
|
309
|
+
function isInstalled() {
|
|
310
|
+
if (typeof window === "undefined") return false;
|
|
311
|
+
return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
|
312
|
+
}
|
|
313
|
+
/** iPhone and iPad, including iPadOS reporting itself as a Mac. */
|
|
314
|
+
function isApplePortable() {
|
|
315
|
+
if (typeof window === "undefined") return false;
|
|
316
|
+
return /iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Whether this device can only receive notifications once the app is installed.
|
|
320
|
+
*
|
|
321
|
+
* Safari on iOS grants notification permission to an installed web app and to
|
|
322
|
+
* nothing else — in a normal tab the request does not even prompt. Telling the
|
|
323
|
+
* user to allow notifications there is asking for something the browser will
|
|
324
|
+
* not offer, so the UI has to say "add to Home Screen" instead.
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```ts
|
|
328
|
+
* if (needsIosInstall()) // show the Home Screen instruction, not the button
|
|
329
|
+
* ```
|
|
330
|
+
*/
|
|
331
|
+
function needsIosInstall() {
|
|
332
|
+
return isApplePortable() && !isInstalled();
|
|
333
|
+
}
|
|
334
|
+
//#endregion
|
|
335
|
+
//#region src/composables/use-theme.ts
|
|
336
|
+
/**
|
|
337
|
+
* Namespaced by the app, not by this package.
|
|
338
|
+
*
|
|
339
|
+
* Two rei-kit apps served from the same origin would otherwise share one theme
|
|
340
|
+
* setting — and during development on localhost, they will be.
|
|
341
|
+
*/
|
|
342
|
+
var storageKey = "rei-theme";
|
|
343
|
+
function isThemePreference(value) {
|
|
344
|
+
return value === "system" || value === "light" || value === "dark";
|
|
345
|
+
}
|
|
346
|
+
/** Reads the stored preference, falling back to `system`. */
|
|
347
|
+
function readStoredTheme() {
|
|
348
|
+
try {
|
|
349
|
+
const stored = localStorage.getItem(storageKey);
|
|
350
|
+
return isThemePreference(stored) ? stored : "system";
|
|
351
|
+
} catch {
|
|
352
|
+
return "system";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function storeTheme(preference) {
|
|
356
|
+
try {
|
|
357
|
+
localStorage.setItem(storageKey, preference);
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
/** Adds or removes `.dark` on `<html>`, resolving `system` against the OS. */
|
|
361
|
+
function applyTheme(preference) {
|
|
362
|
+
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
363
|
+
const isDark = preference === "dark" || preference === "system" && prefersDark;
|
|
364
|
+
document.documentElement.classList.toggle("dark", isDark);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The shared preference, created on first use rather than at import.
|
|
368
|
+
*
|
|
369
|
+
* Lazy on purpose: reading storage at import time would lock in the default key
|
|
370
|
+
* before an app had a chance to set its own, leaving the controller reading one
|
|
371
|
+
* key and writing another.
|
|
372
|
+
*/
|
|
373
|
+
var preference = null;
|
|
374
|
+
function controller() {
|
|
375
|
+
if (preference) return preference;
|
|
376
|
+
preference = ref(readStoredTheme());
|
|
377
|
+
watch(preference, (next) => {
|
|
378
|
+
storeTheme(next);
|
|
379
|
+
applyTheme(next);
|
|
380
|
+
}, { immediate: true });
|
|
381
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
382
|
+
if (preference?.value === "system") applyTheme("system");
|
|
383
|
+
});
|
|
384
|
+
return preference;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Sets where the preference is stored.
|
|
388
|
+
*
|
|
389
|
+
* Safe in either order: called before the first `useTheme()` it simply changes
|
|
390
|
+
* the key, and called after it re-reads under the new one, so the controller
|
|
391
|
+
* never reads from one key while writing to another.
|
|
392
|
+
*
|
|
393
|
+
* @example
|
|
394
|
+
* ```ts
|
|
395
|
+
* setThemeStorageKey('hibi-theme') // once, at startup
|
|
396
|
+
* ```
|
|
397
|
+
*/
|
|
398
|
+
function setThemeStorageKey(key) {
|
|
399
|
+
storageKey = key;
|
|
400
|
+
if (preference) preference.value = readStoredTheme();
|
|
401
|
+
}
|
|
402
|
+
/** @returns The shared preference ref; assigning to it stores and applies it. */
|
|
403
|
+
function useTheme() {
|
|
404
|
+
return controller();
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
//#region src/composables/use-today.ts
|
|
408
|
+
/**
|
|
409
|
+
* Today's date key, kept current while the app stays open.
|
|
410
|
+
*
|
|
411
|
+
* `todayKey()` called once in `setup` freezes the date for the lifetime of the
|
|
412
|
+
* component. Nobody notices in a session that lasts minutes, but a phone left
|
|
413
|
+
* on the Today screen overnight would keep marking yesterday, and the Week grid
|
|
414
|
+
* would disable the column that just became today.
|
|
415
|
+
*/
|
|
416
|
+
var current = ref(todayKey());
|
|
417
|
+
var timer;
|
|
418
|
+
/** A second past midnight, so a fast timer cannot fire on the old date. */
|
|
419
|
+
function msUntilMidnight() {
|
|
420
|
+
const now = /* @__PURE__ */ new Date();
|
|
421
|
+
return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 1).getTime() - now.getTime();
|
|
422
|
+
}
|
|
423
|
+
function refresh() {
|
|
424
|
+
current.value = todayKey();
|
|
425
|
+
}
|
|
426
|
+
function schedule() {
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
timer = setTimeout(() => {
|
|
429
|
+
refresh();
|
|
430
|
+
schedule();
|
|
431
|
+
}, msUntilMidnight());
|
|
432
|
+
}
|
|
433
|
+
schedule();
|
|
434
|
+
document.addEventListener("visibilitychange", () => {
|
|
435
|
+
if (document.visibilityState !== "visible") return;
|
|
436
|
+
refresh();
|
|
437
|
+
schedule();
|
|
438
|
+
});
|
|
439
|
+
/**
|
|
440
|
+
* @returns Read-only ref holding today's `YYYY-MM-DD` key.
|
|
441
|
+
*
|
|
442
|
+
* @example
|
|
443
|
+
* ```ts
|
|
444
|
+
* const today = useToday()
|
|
445
|
+
* const isFuture = computed(() => day > today.value)
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
448
|
+
function useToday() {
|
|
449
|
+
return readonly(current);
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/composables/use-online.ts
|
|
453
|
+
/**
|
|
454
|
+
* Tracks whether the browser thinks it has a network connection.
|
|
455
|
+
*
|
|
456
|
+
* Note the limit: `navigator.onLine` only reports whether a network interface
|
|
457
|
+
* is up, not whether requests actually succeed. Treat it as a hint for the UI,
|
|
458
|
+
* never as a reason to skip error handling.
|
|
459
|
+
*
|
|
460
|
+
* Listeners are removed on unmount, so the composable is safe to call per view.
|
|
461
|
+
*
|
|
462
|
+
* @returns A readonly ref that flips with the browser's online/offline events.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
* ```ts
|
|
466
|
+
* const isOnline = useOnline()
|
|
467
|
+
* // <p v-if="!isOnline">You're offline.</p>
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
function useOnline() {
|
|
471
|
+
const isOnline = ref(true);
|
|
472
|
+
function update() {
|
|
473
|
+
isOnline.value = navigator.onLine;
|
|
474
|
+
}
|
|
475
|
+
onMounted(() => {
|
|
476
|
+
update();
|
|
477
|
+
window.addEventListener("online", update);
|
|
478
|
+
window.addEventListener("offline", update);
|
|
479
|
+
});
|
|
480
|
+
onUnmounted(() => {
|
|
481
|
+
window.removeEventListener("online", update);
|
|
482
|
+
window.removeEventListener("offline", update);
|
|
483
|
+
});
|
|
484
|
+
return readonly(isOnline);
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/composables/use-debounced-callback.ts
|
|
488
|
+
/**
|
|
489
|
+
* Delays a callback until the caller stops calling it.
|
|
490
|
+
*
|
|
491
|
+
* Used for note autosave: a request per keystroke would be wasteful, but losing
|
|
492
|
+
* the last keystrokes when the user navigates away would be worse — so the
|
|
493
|
+
* pending call is flushed on dispose, and `flush` is exposed for route guards.
|
|
494
|
+
*
|
|
495
|
+
* @param callback - Runs with the arguments of the most recent call.
|
|
496
|
+
* @param delay - Quiet period in milliseconds.
|
|
497
|
+
* @returns `run` to schedule, `flush` to run now, `cancel` to drop.
|
|
498
|
+
*
|
|
499
|
+
* @example
|
|
500
|
+
* ```ts
|
|
501
|
+
* const save = useDebouncedCallback((body: string) => mutate(body), 800)
|
|
502
|
+
* watch(text, (value) => save.run(value))
|
|
503
|
+
* onBeforeRouteLeave(() => save.flush())
|
|
504
|
+
* ```
|
|
505
|
+
*/
|
|
506
|
+
function useDebouncedCallback(callback, delay = 800) {
|
|
507
|
+
let timer = null;
|
|
508
|
+
let pending = null;
|
|
509
|
+
/** Runs the pending call right now, if there is one. */
|
|
510
|
+
function flush() {
|
|
511
|
+
if (timer !== null) clearTimeout(timer);
|
|
512
|
+
timer = null;
|
|
513
|
+
if (pending !== null) {
|
|
514
|
+
const args = pending;
|
|
515
|
+
pending = null;
|
|
516
|
+
callback(...args);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Drops the pending call without running it. */
|
|
520
|
+
function cancel() {
|
|
521
|
+
if (timer !== null) clearTimeout(timer);
|
|
522
|
+
timer = null;
|
|
523
|
+
pending = null;
|
|
524
|
+
}
|
|
525
|
+
function run(...args) {
|
|
526
|
+
pending = args;
|
|
527
|
+
if (timer !== null) clearTimeout(timer);
|
|
528
|
+
timer = setTimeout(flush, delay);
|
|
529
|
+
}
|
|
530
|
+
onScopeDispose(flush);
|
|
531
|
+
return {
|
|
532
|
+
run,
|
|
533
|
+
flush,
|
|
534
|
+
cancel
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/composables/use-drag-scroll.ts
|
|
539
|
+
/** Movement before a press counts as a drag rather than a tap. */
|
|
540
|
+
var DRAG_THRESHOLD_PX = 6;
|
|
541
|
+
/**
|
|
542
|
+
* Drag-to-scroll for a horizontally scrolling element.
|
|
543
|
+
*
|
|
544
|
+
* The app puts `touch-action: pan-y` on the page content so the tab-swipe
|
|
545
|
+
* gesture keeps its pointer events — the browser never claims a horizontal
|
|
546
|
+
* drag, which also means it never pans this element natively. Rather than give
|
|
547
|
+
* that up, horizontal scrolling is driven here.
|
|
548
|
+
*
|
|
549
|
+
* @param target - The scroll container.
|
|
550
|
+
* @returns `didDrag`, so a click handler can ignore the press that ended a drag.
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* const scroller = ref<HTMLElement | null>(null)
|
|
555
|
+
* const { didDrag } = useDragScroll(scroller)
|
|
556
|
+
*
|
|
557
|
+
* function onClick() {
|
|
558
|
+
* if (didDrag()) return
|
|
559
|
+
* // …treat as a tap
|
|
560
|
+
* }
|
|
561
|
+
* ```
|
|
562
|
+
*/
|
|
563
|
+
function useDragScroll(target) {
|
|
564
|
+
let pointerId = null;
|
|
565
|
+
let startX = 0;
|
|
566
|
+
let startScroll = 0;
|
|
567
|
+
let dragged = false;
|
|
568
|
+
function onPointerDown(event) {
|
|
569
|
+
const element = target.value;
|
|
570
|
+
if (!element || event.pointerType === "mouse") return;
|
|
571
|
+
pointerId = event.pointerId;
|
|
572
|
+
startX = event.clientX;
|
|
573
|
+
startScroll = element.scrollLeft;
|
|
574
|
+
dragged = false;
|
|
575
|
+
}
|
|
576
|
+
function onPointerMove(event) {
|
|
577
|
+
const element = target.value;
|
|
578
|
+
if (!element || event.pointerId !== pointerId) return;
|
|
579
|
+
const dx = event.clientX - startX;
|
|
580
|
+
if (!dragged && Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
|
581
|
+
if (!dragged) {
|
|
582
|
+
dragged = true;
|
|
583
|
+
element.setPointerCapture(event.pointerId);
|
|
584
|
+
}
|
|
585
|
+
element.scrollLeft = startScroll - dx;
|
|
586
|
+
}
|
|
587
|
+
function onPointerUp(event) {
|
|
588
|
+
const element = target.value;
|
|
589
|
+
if (element?.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
|
|
590
|
+
pointerId = null;
|
|
591
|
+
}
|
|
592
|
+
function bind(element) {
|
|
593
|
+
element.addEventListener("pointerdown", onPointerDown);
|
|
594
|
+
element.addEventListener("pointermove", onPointerMove);
|
|
595
|
+
element.addEventListener("pointerup", onPointerUp);
|
|
596
|
+
element.addEventListener("pointercancel", onPointerUp);
|
|
597
|
+
}
|
|
598
|
+
function unbind(element) {
|
|
599
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
600
|
+
element.removeEventListener("pointermove", onPointerMove);
|
|
601
|
+
element.removeEventListener("pointerup", onPointerUp);
|
|
602
|
+
element.removeEventListener("pointercancel", onPointerUp);
|
|
603
|
+
}
|
|
604
|
+
watch(target, (element, previous) => {
|
|
605
|
+
if (previous) unbind(previous);
|
|
606
|
+
if (element) bind(element);
|
|
607
|
+
}, { immediate: true });
|
|
608
|
+
onScopeDispose(() => {
|
|
609
|
+
if (target.value) unbind(target.value);
|
|
610
|
+
});
|
|
611
|
+
return { didDrag: () => dragged };
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/composables/use-visual-viewport.ts
|
|
615
|
+
/**
|
|
616
|
+
* Tracks the visual viewport.
|
|
617
|
+
*
|
|
618
|
+
* Chrome and Android browsers honour `interactive-widget=resizes-content`, so
|
|
619
|
+
* the layout viewport already shrinks for the keyboard there. Safari on iOS
|
|
620
|
+
* does not implement it: it shrinks only the *visual* viewport, leaving a sheet
|
|
621
|
+
* sized in `dvh` sitting partly underneath the keyboard.
|
|
622
|
+
*
|
|
623
|
+
* `null` means the API is unavailable, which callers should read as "trust the
|
|
624
|
+
* layout viewport" rather than as zero.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* ```ts
|
|
628
|
+
* const viewport = useVisualViewport()
|
|
629
|
+
* // :style="viewport ? { height: `${viewport.height}px` } : undefined"
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
function useVisualViewport() {
|
|
633
|
+
const rect = ref(null);
|
|
634
|
+
const viewport = window.visualViewport;
|
|
635
|
+
if (!viewport) return readonly(rect);
|
|
636
|
+
function read() {
|
|
637
|
+
if (!viewport) return;
|
|
638
|
+
rect.value = {
|
|
639
|
+
height: viewport.height,
|
|
640
|
+
offsetTop: viewport.offsetTop
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
read();
|
|
644
|
+
viewport.addEventListener("resize", read);
|
|
645
|
+
viewport.addEventListener("scroll", read);
|
|
646
|
+
onScopeDispose(() => {
|
|
647
|
+
viewport.removeEventListener("resize", read);
|
|
648
|
+
viewport.removeEventListener("scroll", read);
|
|
649
|
+
});
|
|
650
|
+
return readonly(rect);
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
653
|
+
//#region src/components/BaseButton.vue?vue&type=script&setup=true&lang.ts
|
|
654
|
+
var _hoisted_1$12 = [
|
|
655
|
+
"type",
|
|
656
|
+
"disabled",
|
|
657
|
+
"aria-busy"
|
|
658
|
+
];
|
|
659
|
+
var _hoisted_2$11 = {
|
|
660
|
+
key: 0,
|
|
661
|
+
class: "size-4 animate-spin rounded-full border-2 border-current border-t-transparent",
|
|
662
|
+
"aria-hidden": "true"
|
|
663
|
+
};
|
|
664
|
+
//#endregion
|
|
665
|
+
//#region src/components/BaseButton.vue
|
|
666
|
+
var BaseButton_default = /* @__PURE__ */ defineComponent({
|
|
667
|
+
__name: "BaseButton",
|
|
668
|
+
props: {
|
|
669
|
+
variant: { default: "primary" },
|
|
670
|
+
size: { default: "md" },
|
|
671
|
+
loading: {
|
|
672
|
+
type: Boolean,
|
|
673
|
+
default: false
|
|
674
|
+
},
|
|
675
|
+
disabled: {
|
|
676
|
+
type: Boolean,
|
|
677
|
+
default: false
|
|
678
|
+
},
|
|
679
|
+
type: { default: "button" }
|
|
680
|
+
},
|
|
681
|
+
setup(__props) {
|
|
682
|
+
const VARIANT_CLASS = {
|
|
683
|
+
primary: "bg-primary text-white hover:bg-primary/90",
|
|
684
|
+
ghost: "bg-transparent text-ink hover:bg-muted",
|
|
685
|
+
danger: "bg-negative text-white hover:bg-negative/90"
|
|
686
|
+
};
|
|
687
|
+
const SIZE_CLASS = {
|
|
688
|
+
sm: "h-9 px-3 text-sm",
|
|
689
|
+
md: "h-11 px-4 text-base"
|
|
690
|
+
};
|
|
691
|
+
return (_ctx, _cache) => {
|
|
692
|
+
return openBlock(), createElementBlock("button", {
|
|
693
|
+
type: __props.type,
|
|
694
|
+
disabled: __props.disabled || __props.loading,
|
|
695
|
+
"aria-busy": __props.loading,
|
|
696
|
+
class: normalizeClass(["rounded-card focus-visible:outline-primary inline-flex items-center justify-center gap-2 font-medium transition-transform duration-100 select-none focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-95 disabled:pointer-events-none disabled:opacity-50", [VARIANT_CLASS[__props.variant], SIZE_CLASS[__props.size]]])
|
|
697
|
+
}, [__props.loading ? (openBlock(), createElementBlock("span", _hoisted_2$11)) : createCommentVNode("", true), renderSlot(_ctx.$slots, "default")], 10, _hoisted_1$12);
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
//#endregion
|
|
702
|
+
//#region src/components/BaseInput.vue?vue&type=script&setup=true&lang.ts
|
|
703
|
+
var _hoisted_1$11 = { class: "flex flex-col gap-1.5" };
|
|
704
|
+
var _hoisted_2$10 = ["for"];
|
|
705
|
+
var _hoisted_3$6 = [
|
|
706
|
+
"id",
|
|
707
|
+
"type",
|
|
708
|
+
"aria-invalid",
|
|
709
|
+
"aria-describedby"
|
|
710
|
+
];
|
|
711
|
+
//#endregion
|
|
712
|
+
//#region src/components/BaseInput.vue
|
|
713
|
+
var BaseInput_default = /* @__PURE__ */ defineComponent({
|
|
714
|
+
inheritAttrs: false,
|
|
715
|
+
__name: "BaseInput",
|
|
716
|
+
props: /*@__PURE__*/ mergeModels({
|
|
717
|
+
label: {},
|
|
718
|
+
error: { default: "" },
|
|
719
|
+
hint: { default: "" },
|
|
720
|
+
labelHidden: {
|
|
721
|
+
type: Boolean,
|
|
722
|
+
default: false
|
|
723
|
+
},
|
|
724
|
+
type: { default: "text" }
|
|
725
|
+
}, {
|
|
726
|
+
"modelValue": {},
|
|
727
|
+
"modelModifiers": {}
|
|
728
|
+
}),
|
|
729
|
+
emits: ["update:modelValue"],
|
|
730
|
+
setup(__props) {
|
|
731
|
+
const model = useModel(__props, "modelValue");
|
|
732
|
+
const id = useId();
|
|
733
|
+
const errorId = `${id}-error`;
|
|
734
|
+
const hintId = `${id}-hint`;
|
|
735
|
+
const describedBy = computed(() => {
|
|
736
|
+
if (__props.error) return errorId;
|
|
737
|
+
if (__props.hint) return hintId;
|
|
738
|
+
});
|
|
739
|
+
return (_ctx, _cache) => {
|
|
740
|
+
return openBlock(), createElementBlock("div", _hoisted_1$11, [
|
|
741
|
+
createElementVNode("label", {
|
|
742
|
+
for: unref(id),
|
|
743
|
+
class: normalizeClass(["text-ink text-sm font-medium", __props.labelHidden ? "sr-only" : ""])
|
|
744
|
+
}, toDisplayString(__props.label), 11, _hoisted_2$10),
|
|
745
|
+
withDirectives(createElementVNode("input", mergeProps({
|
|
746
|
+
id: unref(id),
|
|
747
|
+
"onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
|
|
748
|
+
type: __props.type,
|
|
749
|
+
"aria-invalid": Boolean(__props.error),
|
|
750
|
+
"aria-describedby": describedBy.value
|
|
751
|
+
}, _ctx.$attrs, { class: ["border-hair bg-surface text-ink rounded-card focus-visible:outline-primary h-11 border px-3 focus-visible:outline-2 focus-visible:outline-offset-1", __props.error ? "border-negative" : ""] }), null, 16, _hoisted_3$6), [[vModelDynamic, model.value]]),
|
|
752
|
+
__props.error ? (openBlock(), createElementBlock("p", {
|
|
753
|
+
key: 0,
|
|
754
|
+
id: errorId,
|
|
755
|
+
class: "text-negative text-xs"
|
|
756
|
+
}, toDisplayString(__props.error), 1)) : __props.hint ? (openBlock(), createElementBlock("p", {
|
|
757
|
+
key: 1,
|
|
758
|
+
id: hintId,
|
|
759
|
+
class: "text-ink-soft text-xs"
|
|
760
|
+
}, toDisplayString(__props.hint), 1)) : createCommentVNode("", true)
|
|
761
|
+
]);
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/components/BaseSheet.vue?vue&type=script&setup=true&lang.ts
|
|
767
|
+
var _hoisted_1$10 = { class: "shell-frame md:rounded-shell relative flex max-h-full flex-col justify-end overflow-hidden" };
|
|
768
|
+
var _hoisted_2$9 = ["aria-label"];
|
|
769
|
+
var _hoisted_3$5 = { class: "flex shrink-0 items-start gap-3 px-6 pt-4 pb-5" };
|
|
770
|
+
var _hoisted_4$4 = { class: "min-w-0 flex-1" };
|
|
771
|
+
var _hoisted_5$3 = { class: "text-ink text-xl leading-tight font-semibold" };
|
|
772
|
+
var _hoisted_6$1 = {
|
|
773
|
+
key: 0,
|
|
774
|
+
class: "text-ink-soft mt-1 text-sm leading-snug"
|
|
775
|
+
};
|
|
776
|
+
var _hoisted_7$1 = ["aria-label"];
|
|
777
|
+
var _hoisted_8 = { class: "min-h-0 flex-1 overflow-y-auto px-6 pb-[calc(2rem+env(safe-area-inset-bottom,0px))]" };
|
|
778
|
+
var BaseSheet_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineComponent({
|
|
779
|
+
__name: "BaseSheet",
|
|
780
|
+
props: /*@__PURE__*/ mergeModels({
|
|
781
|
+
title: {},
|
|
782
|
+
subtitle: { default: "" },
|
|
783
|
+
closeLabel: { default: "Close" }
|
|
784
|
+
}, {
|
|
785
|
+
"modelValue": {
|
|
786
|
+
type: Boolean,
|
|
787
|
+
required: true
|
|
788
|
+
},
|
|
789
|
+
"modelModifiers": {}
|
|
790
|
+
}),
|
|
791
|
+
emits: ["update:modelValue"],
|
|
792
|
+
setup(__props) {
|
|
793
|
+
const open = useModel(__props, "modelValue");
|
|
794
|
+
const viewport = useVisualViewport();
|
|
795
|
+
/**
|
|
796
|
+
* Pins the sheet to the area the keyboard has left visible.
|
|
797
|
+
*
|
|
798
|
+
* Only needed where the layout viewport does not shrink on its own — iOS. On
|
|
799
|
+
* Android the numbers already agree, so this is a no-op there rather than a
|
|
800
|
+
* second, competing adjustment.
|
|
801
|
+
*/
|
|
802
|
+
const viewportStyle = computed(() => viewport.value ? {
|
|
803
|
+
height: `${viewport.value.height}px`,
|
|
804
|
+
top: `${viewport.value.offsetTop}px`
|
|
805
|
+
} : void 0);
|
|
806
|
+
const panel = ref(null);
|
|
807
|
+
let lastFocused = null;
|
|
808
|
+
function close() {
|
|
809
|
+
open.value = false;
|
|
810
|
+
}
|
|
811
|
+
function onKeydown(event) {
|
|
812
|
+
if (event.key === "Escape") close();
|
|
813
|
+
}
|
|
814
|
+
watch(open, async (isOpen) => {
|
|
815
|
+
if (isOpen) {
|
|
816
|
+
setBackgroundInert(true);
|
|
817
|
+
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
818
|
+
window.addEventListener("keydown", onKeydown);
|
|
819
|
+
await nextTick();
|
|
820
|
+
panel.value?.focus();
|
|
821
|
+
} else {
|
|
822
|
+
window.removeEventListener("keydown", onKeydown);
|
|
823
|
+
lastFocused?.focus();
|
|
824
|
+
lastFocused = null;
|
|
825
|
+
setBackgroundInert(false);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
/**
|
|
829
|
+
* `inert` takes the whole app out of tab order and pointer events while the
|
|
830
|
+
* sheet is open — a real focus trap without keydown bookkeeping.
|
|
831
|
+
*
|
|
832
|
+
* The sheet itself is teleported to `#sheet-root`, a sibling of `#app`, so it
|
|
833
|
+
* stays interactive.
|
|
834
|
+
*/
|
|
835
|
+
function setBackgroundInert(isInert) {
|
|
836
|
+
document.getElementById("app")?.toggleAttribute("inert", isInert);
|
|
837
|
+
}
|
|
838
|
+
onUnmounted(() => {
|
|
839
|
+
window.removeEventListener("keydown", onKeydown);
|
|
840
|
+
setBackgroundInert(false);
|
|
841
|
+
});
|
|
842
|
+
return (_ctx, _cache) => {
|
|
843
|
+
return openBlock(), createBlock(Teleport, { to: "#sheet-root" }, [createVNode(Transition, { name: "sheet" }, {
|
|
844
|
+
default: withCtx(() => [open.value ? (openBlock(), createElementBlock("div", {
|
|
845
|
+
key: 0,
|
|
846
|
+
class: "fixed inset-x-0 top-0 bottom-0 z-50 flex items-center justify-center",
|
|
847
|
+
style: normalizeStyle(viewportStyle.value)
|
|
848
|
+
}, [createElementVNode("div", _hoisted_1$10, [createElementVNode("div", {
|
|
849
|
+
class: "bg-ink/45 absolute inset-0 backdrop-blur-[2px]",
|
|
850
|
+
onClick: close
|
|
851
|
+
}), createElementVNode("section", {
|
|
852
|
+
ref_key: "panel",
|
|
853
|
+
ref: panel,
|
|
854
|
+
role: "dialog",
|
|
855
|
+
"aria-modal": "true",
|
|
856
|
+
"aria-label": __props.title,
|
|
857
|
+
tabindex: "-1",
|
|
858
|
+
class: "sheet-panel bg-surface relative flex max-h-[94%] min-h-[56dvh] flex-col rounded-t-[28px] shadow-2xl outline-none"
|
|
859
|
+
}, [
|
|
860
|
+
_cache[0] || (_cache[0] = createElementVNode("div", {
|
|
861
|
+
class: "flex shrink-0 justify-center pt-3",
|
|
862
|
+
"aria-hidden": "true"
|
|
863
|
+
}, [createElementVNode("span", { class: "bg-hair h-1.5 w-10 rounded-full" })], -1)),
|
|
864
|
+
createElementVNode("header", _hoisted_3$5, [createElementVNode("div", _hoisted_4$4, [createElementVNode("h2", _hoisted_5$3, toDisplayString(__props.title), 1), __props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_6$1, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)]), createElementVNode("button", {
|
|
865
|
+
type: "button",
|
|
866
|
+
class: "text-ink-soft hover:bg-muted hover:text-ink -mt-1 flex size-10 shrink-0 items-center justify-center rounded-full transition-colors active:scale-90",
|
|
867
|
+
"aria-label": __props.closeLabel,
|
|
868
|
+
onClick: close
|
|
869
|
+
}, [createVNode(unref(X), { class: "size-5" })], 8, _hoisted_7$1)]),
|
|
870
|
+
createElementVNode("div", _hoisted_8, [renderSlot(_ctx.$slots, "default", {}, void 0, true)])
|
|
871
|
+
], 8, _hoisted_2$9)])], 4)) : createCommentVNode("", true)]),
|
|
872
|
+
_: 3
|
|
873
|
+
})]);
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region \0plugin-vue:export-helper
|
|
879
|
+
var _plugin_vue_export_helper_default = (sfc, props) => {
|
|
880
|
+
const target = sfc.__vccOpts || sfc;
|
|
881
|
+
for (const [key, val] of props) target[key] = val;
|
|
882
|
+
return target;
|
|
883
|
+
};
|
|
884
|
+
//#endregion
|
|
885
|
+
//#region src/components/BaseSheet.vue
|
|
886
|
+
var BaseSheet_default = /*#__PURE__*/ _plugin_vue_export_helper_default(BaseSheet_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-3f2e9ce4"]]);
|
|
887
|
+
//#endregion
|
|
888
|
+
//#region src/components/EmptyState.vue?vue&type=script&setup=true&lang.ts
|
|
889
|
+
var _hoisted_1$9 = { class: "flex flex-col items-center gap-3 px-6 py-10 text-center" };
|
|
890
|
+
var _hoisted_2$8 = {
|
|
891
|
+
key: 0,
|
|
892
|
+
class: "bg-muted text-primary rounded-card flex size-12 items-center"
|
|
893
|
+
};
|
|
894
|
+
var _hoisted_3$4 = { class: "text-ink text-base font-semibold" };
|
|
895
|
+
var _hoisted_4$3 = {
|
|
896
|
+
key: 1,
|
|
897
|
+
class: "text-ink-soft max-w-[36ch] text-sm"
|
|
898
|
+
};
|
|
899
|
+
var _hoisted_5$2 = {
|
|
900
|
+
key: 2,
|
|
901
|
+
class: "mt-2 flex w-full flex-col gap-2"
|
|
902
|
+
};
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/components/EmptyState.vue
|
|
905
|
+
var EmptyState_default = /* @__PURE__ */ defineComponent({
|
|
906
|
+
__name: "EmptyState",
|
|
907
|
+
props: {
|
|
908
|
+
title: {},
|
|
909
|
+
description: { default: "" }
|
|
910
|
+
},
|
|
911
|
+
setup(__props) {
|
|
912
|
+
return (_ctx, _cache) => {
|
|
913
|
+
return openBlock(), createElementBlock("div", _hoisted_1$9, [
|
|
914
|
+
_ctx.$slots.icon ? (openBlock(), createElementBlock("div", _hoisted_2$8, [renderSlot(_ctx.$slots, "icon")])) : createCommentVNode("", true),
|
|
915
|
+
createElementVNode("h3", _hoisted_3$4, toDisplayString(__props.title), 1),
|
|
916
|
+
__props.description ? (openBlock(), createElementBlock("p", _hoisted_4$3, toDisplayString(__props.description), 1)) : createCommentVNode("", true),
|
|
917
|
+
_ctx.$slots.action ? (openBlock(), createElementBlock("div", _hoisted_5$2, [renderSlot(_ctx.$slots, "action")])) : createCommentVNode("", true)
|
|
918
|
+
]);
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
//#endregion
|
|
923
|
+
//#region src/components/PageHeader.vue?vue&type=script&setup=true&lang.ts
|
|
924
|
+
var _hoisted_1$8 = { class: "grid h-12 shrink-0 grid-cols-[2.5rem_1fr_2.5rem] items-center" };
|
|
925
|
+
var _hoisted_2$7 = { class: "justify-self-start" };
|
|
926
|
+
var _hoisted_3$3 = { class: "text-ink flex min-w-0 justify-center text-base font-semibold tabular-nums" };
|
|
927
|
+
var _hoisted_4$2 = { class: "truncate" };
|
|
928
|
+
var _hoisted_5$1 = { class: "justify-self-end" };
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/components/PageHeader.vue
|
|
931
|
+
var PageHeader_default = /* @__PURE__ */ defineComponent({
|
|
932
|
+
__name: "PageHeader",
|
|
933
|
+
props: { title: {} },
|
|
934
|
+
setup(__props) {
|
|
935
|
+
return (_ctx, _cache) => {
|
|
936
|
+
return openBlock(), createElementBlock("header", _hoisted_1$8, [
|
|
937
|
+
createElementVNode("div", _hoisted_2$7, [renderSlot(_ctx.$slots, "left")]),
|
|
938
|
+
createElementVNode("h1", _hoisted_3$3, [renderSlot(_ctx.$slots, "title", {}, () => [createElementVNode("span", _hoisted_4$2, toDisplayString(__props.title), 1)])]),
|
|
939
|
+
createElementVNode("div", _hoisted_5$1, [renderSlot(_ctx.$slots, "right")])
|
|
940
|
+
]);
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/components/ToneDot.vue?vue&type=script&setup=true&lang.ts
|
|
946
|
+
var _hoisted_1$7 = { class: "inline-flex items-center gap-1.5" };
|
|
947
|
+
var _hoisted_2$6 = {
|
|
948
|
+
key: 0,
|
|
949
|
+
class: "text-ink-soft text-xs font-medium"
|
|
950
|
+
};
|
|
951
|
+
//#endregion
|
|
952
|
+
//#region src/components/ToneDot.vue
|
|
953
|
+
var ToneDot_default = /* @__PURE__ */ defineComponent({
|
|
954
|
+
__name: "ToneDot",
|
|
955
|
+
props: {
|
|
956
|
+
fill: {},
|
|
957
|
+
label: { default: "" }
|
|
958
|
+
},
|
|
959
|
+
setup(__props) {
|
|
960
|
+
/**
|
|
961
|
+
* A small coloured dot, optionally labelled.
|
|
962
|
+
*
|
|
963
|
+
* Takes the colour as a class rather than a category, so an app can key it off
|
|
964
|
+
* whatever its own domain calls a category — habit kinds, expense types,
|
|
965
|
+
* priorities — without this component knowing about any of them.
|
|
966
|
+
*/
|
|
967
|
+
return (_ctx, _cache) => {
|
|
968
|
+
return openBlock(), createElementBlock("span", _hoisted_1$7, [createElementVNode("span", { class: normalizeClass(["size-2 rounded-full", __props.fill]) }, null, 2), __props.label ? (openBlock(), createElementBlock("span", _hoisted_2$6, toDisplayString(__props.label), 1)) : createCommentVNode("", true)]);
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
//#endregion
|
|
973
|
+
//#region src/components/SectionHeading.vue?vue&type=script&setup=true&lang.ts
|
|
974
|
+
var _hoisted_1$6 = {
|
|
975
|
+
key: 0,
|
|
976
|
+
class: "text-ink-soft text-xs tabular-nums"
|
|
977
|
+
};
|
|
978
|
+
//#endregion
|
|
979
|
+
//#region src/components/SectionHeading.vue
|
|
980
|
+
var SectionHeading_default = /* @__PURE__ */ defineComponent({
|
|
981
|
+
__name: "SectionHeading",
|
|
982
|
+
props: {
|
|
983
|
+
tone: {},
|
|
984
|
+
label: {},
|
|
985
|
+
count: { default: 0 }
|
|
986
|
+
},
|
|
987
|
+
setup(__props) {
|
|
988
|
+
return (_ctx, _cache) => {
|
|
989
|
+
return openBlock(), createElementBlock("h2", { class: normalizeClass(["flex items-center gap-2 self-start rounded-full border px-3 py-1", __props.tone.card]) }, [
|
|
990
|
+
createVNode(ToneDot_default, { fill: __props.tone.fill }, null, 8, ["fill"]),
|
|
991
|
+
createElementVNode("span", { class: normalizeClass(["text-xs font-semibold tracking-wide uppercase", __props.tone.text]) }, toDisplayString(__props.label), 3),
|
|
992
|
+
__props.count > 0 ? (openBlock(), createElementBlock("span", _hoisted_1$6, toDisplayString(__props.count), 1)) : createCommentVNode("", true)
|
|
993
|
+
], 2);
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region src/components/SegmentedControl.vue?vue&type=script&setup=true&lang.ts
|
|
999
|
+
var _hoisted_1$5 = { class: "bg-muted rounded-card flex w-full gap-1 p-1" };
|
|
1000
|
+
var _hoisted_2$5 = ["value", "name"];
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/components/SegmentedControl.vue
|
|
1003
|
+
var SegmentedControl_default = /* @__PURE__ */ defineComponent({
|
|
1004
|
+
__name: "SegmentedControl",
|
|
1005
|
+
props: /*@__PURE__*/ mergeModels({ options: {} }, {
|
|
1006
|
+
"modelValue": { required: true },
|
|
1007
|
+
"modelModifiers": {}
|
|
1008
|
+
}),
|
|
1009
|
+
emits: ["update:modelValue"],
|
|
1010
|
+
setup(__props) {
|
|
1011
|
+
const model = useModel(__props, "modelValue");
|
|
1012
|
+
const name = useId();
|
|
1013
|
+
return (_ctx, _cache) => {
|
|
1014
|
+
return openBlock(), createElementBlock("div", _hoisted_1$5, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.options, (option) => {
|
|
1015
|
+
return openBlock(), createElementBlock("label", {
|
|
1016
|
+
key: String(option.value),
|
|
1017
|
+
class: "flex-1 cursor-pointer"
|
|
1018
|
+
}, [withDirectives(createElementVNode("input", {
|
|
1019
|
+
"onUpdate:modelValue": _cache[0] || (_cache[0] = ($event) => model.value = $event),
|
|
1020
|
+
type: "radio",
|
|
1021
|
+
value: option.value,
|
|
1022
|
+
name: unref(name),
|
|
1023
|
+
class: "sr-only"
|
|
1024
|
+
}, null, 8, _hoisted_2$5), [[vModelRadio, model.value]]), createElementVNode("span", { class: normalizeClass(["flex h-10 items-center justify-center rounded-xl px-2 text-sm font-medium transition-colors select-none", model.value === option.value ? "bg-surface text-ink shadow-sm" : "text-ink-soft"]) }, toDisplayString(option.label), 3)]);
|
|
1025
|
+
}), 128))]);
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/components/SettingsGroup.vue?vue&type=script&setup=true&lang.ts
|
|
1031
|
+
var _hoisted_1$4 = { class: "flex flex-col gap-2" };
|
|
1032
|
+
var _hoisted_2$4 = { class: "text-ink-soft px-1 text-xs font-semibold tracking-wide uppercase" };
|
|
1033
|
+
var _hoisted_3$2 = { class: "border-hair bg-surface rounded-card divide-hair divide-y overflow-hidden border" };
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/components/SettingsGroup.vue
|
|
1036
|
+
var SettingsGroup_default = /* @__PURE__ */ defineComponent({
|
|
1037
|
+
__name: "SettingsGroup",
|
|
1038
|
+
props: { title: {} },
|
|
1039
|
+
setup(__props) {
|
|
1040
|
+
return (_ctx, _cache) => {
|
|
1041
|
+
return openBlock(), createElementBlock("section", _hoisted_1$4, [createElementVNode("h2", _hoisted_2$4, toDisplayString(__props.title), 1), createElementVNode("div", _hoisted_3$2, [renderSlot(_ctx.$slots, "default")])]);
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
//#endregion
|
|
1046
|
+
//#region src/components/SettingsRow.vue?vue&type=script&setup=true&lang.ts
|
|
1047
|
+
var _hoisted_1$3 = { class: "flex items-center gap-3" };
|
|
1048
|
+
var _hoisted_2$3 = {
|
|
1049
|
+
key: 0,
|
|
1050
|
+
class: "bg-muted text-ink-soft flex size-9 shrink-0 items-center justify-center rounded-xl",
|
|
1051
|
+
"aria-hidden": "true"
|
|
1052
|
+
};
|
|
1053
|
+
var _hoisted_3$1 = { class: "min-w-0 flex-1" };
|
|
1054
|
+
var _hoisted_4$1 = { class: "text-ink text-sm font-medium" };
|
|
1055
|
+
var _hoisted_5 = {
|
|
1056
|
+
key: 0,
|
|
1057
|
+
class: "text-ink-soft mt-0.5 text-xs leading-snug"
|
|
1058
|
+
};
|
|
1059
|
+
var _hoisted_6 = {
|
|
1060
|
+
key: 1,
|
|
1061
|
+
class: "shrink-0"
|
|
1062
|
+
};
|
|
1063
|
+
var _hoisted_7 = { key: 0 };
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/components/SettingsRow.vue
|
|
1066
|
+
var SettingsRow_default = /* @__PURE__ */ defineComponent({
|
|
1067
|
+
__name: "SettingsRow",
|
|
1068
|
+
props: {
|
|
1069
|
+
label: {},
|
|
1070
|
+
description: { default: "" },
|
|
1071
|
+
icon: { default: () => void 0 },
|
|
1072
|
+
interactive: {
|
|
1073
|
+
type: Boolean,
|
|
1074
|
+
default: false
|
|
1075
|
+
},
|
|
1076
|
+
stacked: {
|
|
1077
|
+
type: Boolean,
|
|
1078
|
+
default: false
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
emits: ["click"],
|
|
1082
|
+
setup(__props, { emit: __emit }) {
|
|
1083
|
+
const emit = __emit;
|
|
1084
|
+
return (_ctx, _cache) => {
|
|
1085
|
+
return openBlock(), createBlock(resolveDynamicComponent(__props.interactive ? "button" : "div"), {
|
|
1086
|
+
type: __props.interactive ? "button" : void 0,
|
|
1087
|
+
class: normalizeClass(["flex w-full items-center gap-3 px-4 py-3 text-left", [__props.interactive ? "hover:bg-muted/60 transition-colors active:scale-[0.99]" : "", __props.stacked ? "flex-col items-stretch gap-3" : ""]]),
|
|
1088
|
+
onClick: _cache[0] || (_cache[0] = ($event) => __props.interactive && emit("click"))
|
|
1089
|
+
}, {
|
|
1090
|
+
default: withCtx(() => [createElementVNode("div", _hoisted_1$3, [
|
|
1091
|
+
__props.icon ? (openBlock(), createElementBlock("span", _hoisted_2$3, [(openBlock(), createBlock(resolveDynamicComponent(__props.icon), { class: "size-[18px]" }))])) : createCommentVNode("", true),
|
|
1092
|
+
createElementVNode("div", _hoisted_3$1, [createElementVNode("p", _hoisted_4$1, toDisplayString(__props.label), 1), __props.description ? (openBlock(), createElementBlock("p", _hoisted_5, toDisplayString(__props.description), 1)) : createCommentVNode("", true)]),
|
|
1093
|
+
!__props.stacked ? (openBlock(), createElementBlock("div", _hoisted_6, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true),
|
|
1094
|
+
__props.interactive ? (openBlock(), createBlock(unref(ChevronRight), {
|
|
1095
|
+
key: 2,
|
|
1096
|
+
class: "text-ink-soft size-4 shrink-0",
|
|
1097
|
+
"aria-hidden": "true"
|
|
1098
|
+
})) : createCommentVNode("", true)
|
|
1099
|
+
]), __props.stacked ? (openBlock(), createElementBlock("div", _hoisted_7, [renderSlot(_ctx.$slots, "default")])) : createCommentVNode("", true)]),
|
|
1100
|
+
_: 3
|
|
1101
|
+
}, 8, ["type", "class"]);
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/components/SkeletonList.vue?vue&type=script&setup=true&lang.ts
|
|
1107
|
+
var _hoisted_1$2 = {
|
|
1108
|
+
role: "status",
|
|
1109
|
+
class: "flex flex-col gap-1"
|
|
1110
|
+
};
|
|
1111
|
+
var _hoisted_2$2 = { class: "sr-only" };
|
|
1112
|
+
//#endregion
|
|
1113
|
+
//#region src/components/SkeletonList.vue
|
|
1114
|
+
var SkeletonList_default = /* @__PURE__ */ defineComponent({
|
|
1115
|
+
__name: "SkeletonList",
|
|
1116
|
+
props: {
|
|
1117
|
+
rows: { default: 3 },
|
|
1118
|
+
rowHeight: { default: "h-14" },
|
|
1119
|
+
label: { default: "Loading…" }
|
|
1120
|
+
},
|
|
1121
|
+
setup(__props) {
|
|
1122
|
+
return (_ctx, _cache) => {
|
|
1123
|
+
return openBlock(), createElementBlock("div", _hoisted_1$2, [createElementVNode("span", _hoisted_2$2, toDisplayString(__props.label), 1), (openBlock(true), createElementBlock(Fragment, null, renderList(__props.rows, (row) => {
|
|
1124
|
+
return openBlock(), createElementBlock("div", {
|
|
1125
|
+
key: row,
|
|
1126
|
+
class: normalizeClass(["bg-muted rounded-card animate-pulse", __props.rowHeight]),
|
|
1127
|
+
"aria-hidden": "true"
|
|
1128
|
+
}, null, 2);
|
|
1129
|
+
}), 128))]);
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
//#endregion
|
|
1134
|
+
//#region src/components/StatCard.vue?vue&type=script&setup=true&lang.ts
|
|
1135
|
+
var _hoisted_1$1 = { class: "border-hair rounded-card flex flex-1 flex-col gap-0.5 border p-3" };
|
|
1136
|
+
var _hoisted_2$1 = { class: "flex items-baseline gap-1" };
|
|
1137
|
+
var _hoisted_3 = { class: "text-ink text-xl font-semibold tabular-nums" };
|
|
1138
|
+
var _hoisted_4 = { class: "text-ink-soft text-xs" };
|
|
1139
|
+
//#endregion
|
|
1140
|
+
//#region src/components/StatCard.vue
|
|
1141
|
+
var StatCard_default = /* @__PURE__ */ defineComponent({
|
|
1142
|
+
__name: "StatCard",
|
|
1143
|
+
props: {
|
|
1144
|
+
value: {},
|
|
1145
|
+
label: {},
|
|
1146
|
+
trend: { default: null }
|
|
1147
|
+
},
|
|
1148
|
+
setup(__props) {
|
|
1149
|
+
const TREND_ICON = {
|
|
1150
|
+
up: ArrowUp,
|
|
1151
|
+
down: ArrowDown,
|
|
1152
|
+
flat: ArrowRight
|
|
1153
|
+
};
|
|
1154
|
+
return (_ctx, _cache) => {
|
|
1155
|
+
return openBlock(), createElementBlock("div", _hoisted_1$1, [createElementVNode("div", _hoisted_2$1, [createElementVNode("span", _hoisted_3, toDisplayString(__props.value), 1), __props.trend ? (openBlock(), createBlock(resolveDynamicComponent(TREND_ICON[__props.trend]), {
|
|
1156
|
+
key: 0,
|
|
1157
|
+
class: "text-ink-soft size-3"
|
|
1158
|
+
})) : createCommentVNode("", true)]), createElementVNode("span", _hoisted_4, toDisplayString(__props.label), 1)]);
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
//#endregion
|
|
1163
|
+
//#region src/components/LocaleLinks.vue?vue&type=script&setup=true&lang.ts
|
|
1164
|
+
var _hoisted_1 = ["aria-label"];
|
|
1165
|
+
var _hoisted_2 = [
|
|
1166
|
+
"lang",
|
|
1167
|
+
"aria-pressed",
|
|
1168
|
+
"onClick"
|
|
1169
|
+
];
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/components/LocaleLinks.vue
|
|
1172
|
+
var LocaleLinks_default = /* @__PURE__ */ defineComponent({
|
|
1173
|
+
__name: "LocaleLinks",
|
|
1174
|
+
props: /*@__PURE__*/ mergeModels({
|
|
1175
|
+
locales: {},
|
|
1176
|
+
labels: {},
|
|
1177
|
+
label: { default: "" }
|
|
1178
|
+
}, {
|
|
1179
|
+
"modelValue": { required: true },
|
|
1180
|
+
"modelModifiers": {}
|
|
1181
|
+
}),
|
|
1182
|
+
emits: ["update:modelValue"],
|
|
1183
|
+
setup(__props) {
|
|
1184
|
+
/**
|
|
1185
|
+
* A flat language switcher for screens with no Settings behind them.
|
|
1186
|
+
*
|
|
1187
|
+
* The list and the labels are props: only the app knows which languages it
|
|
1188
|
+
* ships, and endonyms — each language written in itself — are what make the
|
|
1189
|
+
* right option legible to someone who cannot read the current interface.
|
|
1190
|
+
*/
|
|
1191
|
+
/**
|
|
1192
|
+
* Two-way bound rather than taking the runtime's ref as a prop: props are not
|
|
1193
|
+
* unwrapped in a template and cannot be assigned to, so the ref would compare
|
|
1194
|
+
* against itself and the click handler would not compile.
|
|
1195
|
+
*/
|
|
1196
|
+
const preference = useModel(__props, "modelValue");
|
|
1197
|
+
return (_ctx, _cache) => {
|
|
1198
|
+
return openBlock(), createElementBlock("nav", {
|
|
1199
|
+
class: "flex flex-wrap items-center justify-center gap-1",
|
|
1200
|
+
"aria-label": __props.label || void 0
|
|
1201
|
+
}, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.locales, (locale) => {
|
|
1202
|
+
return openBlock(), createElementBlock("button", {
|
|
1203
|
+
key: locale,
|
|
1204
|
+
type: "button",
|
|
1205
|
+
lang: locale,
|
|
1206
|
+
class: normalizeClass(["rounded-full px-2.5 py-1.5 text-xs transition-colors", preference.value === locale ? "bg-muted text-ink font-semibold" : "text-ink-soft hover:text-ink"]),
|
|
1207
|
+
"aria-pressed": preference.value === locale,
|
|
1208
|
+
onClick: ($event) => preference.value = locale
|
|
1209
|
+
}, toDisplayString(__props.labels[locale]), 11, _hoisted_2);
|
|
1210
|
+
}), 128))], 8, _hoisted_1);
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
//#endregion
|
|
1215
|
+
//#region src/i18n/runtime.ts
|
|
1216
|
+
/**
|
|
1217
|
+
* Builds an i18n runtime around an app's own catalogue.
|
|
1218
|
+
*
|
|
1219
|
+
* A factory rather than a module singleton because the schema is the app's:
|
|
1220
|
+
* typing every locale as `typeof en` is what makes a missing key a build error,
|
|
1221
|
+
* and this package has no `en` of its own to type against.
|
|
1222
|
+
*
|
|
1223
|
+
* @example
|
|
1224
|
+
* ```ts
|
|
1225
|
+
* export const { i18n, t, useLocalePreference, loadActiveLocale } =
|
|
1226
|
+
* createI18nRuntime({
|
|
1227
|
+
* locales: ['en', 'tr'] as const,
|
|
1228
|
+
* fallback: 'en',
|
|
1229
|
+
* intlTags: { en: 'en-GB', tr: 'tr-TR' },
|
|
1230
|
+
* messages: en,
|
|
1231
|
+
* loaders: { tr: () => import('./locales/tr') },
|
|
1232
|
+
* storageKey: 'myapp-locale',
|
|
1233
|
+
* })
|
|
1234
|
+
* ```
|
|
1235
|
+
*/
|
|
1236
|
+
function createI18nRuntime(options) {
|
|
1237
|
+
const { locales, fallback, intlTags, messages, storageKey = "rei-locale" } = options;
|
|
1238
|
+
const loaders = options.loaders ?? {};
|
|
1239
|
+
function isSupported(value) {
|
|
1240
|
+
return locales.includes(value);
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* First browser language the app can actually speak.
|
|
1244
|
+
*
|
|
1245
|
+
* `navigator.languages` is ordered by the user's own preference, so the first
|
|
1246
|
+
* match is the best one — not simply the first entry.
|
|
1247
|
+
*/
|
|
1248
|
+
function detectSystemLocale() {
|
|
1249
|
+
for (const tag of navigator.languages ?? [navigator.language]) {
|
|
1250
|
+
const base = tag.split("-")[0]?.toLowerCase();
|
|
1251
|
+
if (base && isSupported(base)) return base;
|
|
1252
|
+
}
|
|
1253
|
+
return fallback;
|
|
1254
|
+
}
|
|
1255
|
+
function readStored() {
|
|
1256
|
+
try {
|
|
1257
|
+
const stored = localStorage.getItem(storageKey);
|
|
1258
|
+
if (stored === "system" || stored && isSupported(stored)) return stored;
|
|
1259
|
+
} catch {}
|
|
1260
|
+
return "system";
|
|
1261
|
+
}
|
|
1262
|
+
const preference = ref(readStored());
|
|
1263
|
+
const activeLocale = computed(() => preference.value === "system" ? detectSystemLocale() : preference.value);
|
|
1264
|
+
const intlLocale = computed(() => intlTags[activeLocale.value]);
|
|
1265
|
+
const initial = { [fallback]: messages };
|
|
1266
|
+
const i18n = createI18n({
|
|
1267
|
+
legacy: false,
|
|
1268
|
+
locale: activeLocale.value,
|
|
1269
|
+
fallbackLocale: fallback,
|
|
1270
|
+
messages: initial
|
|
1271
|
+
});
|
|
1272
|
+
/**
|
|
1273
|
+
* A narrow view of the instance.
|
|
1274
|
+
*
|
|
1275
|
+
* vue-i18n infers its own generics from the messages it is handed, which
|
|
1276
|
+
* fights a runtime that is generic over the app's schema. Casting once, here,
|
|
1277
|
+
* keeps that fight out of every call site — and the surface below is the
|
|
1278
|
+
* whole of what this runtime uses.
|
|
1279
|
+
*/
|
|
1280
|
+
const core = i18n.global;
|
|
1281
|
+
const loaded = /* @__PURE__ */ new Set([fallback]);
|
|
1282
|
+
/**
|
|
1283
|
+
* Makes sure a locale's messages are in place before it becomes active.
|
|
1284
|
+
*
|
|
1285
|
+
* Awaited rather than fired and forgotten: setting the locale first paints one
|
|
1286
|
+
* frame of the fallback at every other user, which is the flash a fallback
|
|
1287
|
+
* exists to prevent, not cause.
|
|
1288
|
+
*/
|
|
1289
|
+
async function ensureMessages(locale) {
|
|
1290
|
+
if (loaded.has(locale)) return;
|
|
1291
|
+
const load = loaders[locale];
|
|
1292
|
+
if (!load) return;
|
|
1293
|
+
try {
|
|
1294
|
+
const module = await load();
|
|
1295
|
+
core.setLocaleMessage(locale, module.default);
|
|
1296
|
+
loaded.add(locale);
|
|
1297
|
+
} catch {}
|
|
1298
|
+
}
|
|
1299
|
+
/** Loads whatever the stored preference resolves to. Call before mounting. */
|
|
1300
|
+
function loadActiveLocale() {
|
|
1301
|
+
return ensureMessages(activeLocale.value);
|
|
1302
|
+
}
|
|
1303
|
+
watchEffect(() => {
|
|
1304
|
+
core.locale.value = activeLocale.value;
|
|
1305
|
+
setFormatLocale(intlLocale.value);
|
|
1306
|
+
document.documentElement.lang = activeLocale.value;
|
|
1307
|
+
});
|
|
1308
|
+
/** Read and write the language preference. */
|
|
1309
|
+
function useLocalePreference() {
|
|
1310
|
+
return computed({
|
|
1311
|
+
get: () => preference.value,
|
|
1312
|
+
set: (next) => {
|
|
1313
|
+
ensureMessages(next === "system" ? detectSystemLocale() : next).then(() => {
|
|
1314
|
+
preference.value = next;
|
|
1315
|
+
});
|
|
1316
|
+
try {
|
|
1317
|
+
localStorage.setItem(storageKey, next);
|
|
1318
|
+
} catch {}
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
i18n,
|
|
1324
|
+
/** `t` for code outside a component. Tracks the locale inside a computed. */
|
|
1325
|
+
t: core.t,
|
|
1326
|
+
activeLocale,
|
|
1327
|
+
intlLocale,
|
|
1328
|
+
ensureMessages,
|
|
1329
|
+
loadActiveLocale,
|
|
1330
|
+
useLocalePreference
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
//#endregion
|
|
1334
|
+
//#region src/index.ts
|
|
1335
|
+
/**
|
|
1336
|
+
* rei-kit — the layer every app starts from.
|
|
1337
|
+
*
|
|
1338
|
+
* Everything here is free of any backend, router or i18n choice. Components
|
|
1339
|
+
* take strings rather than calling a translator, and utilities take the clock
|
|
1340
|
+
* rather than reading it, so nothing in this package can force a decision on
|
|
1341
|
+
* the app that installs it.
|
|
1342
|
+
*
|
|
1343
|
+
* @see https://github.com/ramazandogna/rei-kit
|
|
1344
|
+
*/
|
|
1345
|
+
var VERSION = "0.0.0";
|
|
1346
|
+
//#endregion
|
|
1347
|
+
export { AppError, BaseButton_default as BaseButton, BaseInput_default as BaseInput, BaseSheet_default as BaseSheet, EmptyState_default as EmptyState, LocaleLinks_default as LocaleLinks, PageHeader_default as PageHeader, SectionHeading_default as SectionHeading, SegmentedControl_default as SegmentedControl, SettingsGroup_default as SettingsGroup, SettingsRow_default as SettingsRow, SkeletonList_default as SkeletonList, StatCard_default as StatCard, ToneDot_default as ToneDot, VERSION, addDays, applyTheme, createI18nRuntime, downloadJson, eachDayOfYear, formatDate, fromDateKey, isApplePortable, isInstalled, isThemePreference, lastNDays, leadingBlanks, needsIosInstall, readStoredTheme, registerErrorMapper, relativeDayLabel, safeRedirect, setFormatLocale, setThemeStorageKey, startOfWeek, tapFeedback, toAppError, toDateKey, todayKey, useDebouncedCallback, useDragScroll, useOnline, useTheme, useToday, useVisualViewport };
|
|
1348
|
+
|
|
1349
|
+
//# sourceMappingURL=index.js.map
|