From 0c6510b9583cc9ef070e1a4eae62fea51a4e2fb3 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 1 Aug 2026 22:49:44 -0400 Subject: [PATCH] refactor: cleanup old dependencies + upgrade frontend to latest --- frontend/.oxlintrc.json | 5 - frontend/index.html | 9 + frontend/jsconfig.json | 5 +- frontend/package-lock.json | 5323 ++++------------- frontend/package.json | 72 +- frontend/postcss.config.js | 29 - frontend/scripts/generate-icons.mjs | 10 +- frontend/src/components/BlockVideoPlayer.vue | 25 - frontend/src/components/BlueprintIcon.vue | 9 +- frontend/src/components/CheckUpdateDialog.vue | 3 +- frontend/src/components/LoadingGeneric.vue | 57 +- frontend/src/components/StatusLight.vue | 4 +- frontend/src/components/TreeNav.vue | 12 +- frontend/src/components/shared/WFooter.vue | 23 +- frontend/src/components/shared/WLayout.vue | 4 + frontend/src/components/shared/WPage.vue | 7 +- .../src/components/shared/WPageContainer.vue | 29 +- frontend/src/helpers/datetime.js | 78 + frontend/src/layouts/AdminLayout.vue | 25 +- frontend/src/layouts/InboxLayout.vue | 4 +- frontend/src/layouts/MainLayout.vue | 10 +- frontend/src/layouts/ProfileLayout.vue | 21 +- frontend/src/main.js | 4 - frontend/src/pages/AdminInstances.vue | 37 +- frontend/src/pages/AdminNavigation.vue | 16 +- frontend/src/pages/AdminRendering.vue | 3 - frontend/src/pages/AdminScheduler.vue | 50 +- frontend/src/pages/AdminSystem.vue | 1 - frontend/src/pages/AdminUsers.vue | 25 +- frontend/src/pages/Index.vue | 29 +- frontend/src/pages/Login.vue | 9 +- frontend/src/pages/ProfileAuth.vue | 7 +- frontend/src/pages/Search.vue | 27 +- .../renderers/modules/markdown-it-token.js | 17 + frontend/src/stores/admin.js | 23 +- frontend/src/stores/common.js | 2 +- frontend/src/stores/editor.js | 1 - frontend/src/stores/user.js | 63 +- frontend/vite.config.js | 23 +- 39 files changed, 1586 insertions(+), 4515 deletions(-) delete mode 100644 frontend/postcss.config.js delete mode 100644 frontend/src/components/BlockVideoPlayer.vue create mode 100644 frontend/src/helpers/datetime.js create mode 100644 frontend/src/renderers/modules/markdown-it-token.js diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json index 553eca131..2fffaa5cf 100644 --- a/frontend/.oxlintrc.json +++ b/frontend/.oxlintrc.json @@ -6,10 +6,6 @@ }, "globals": { "__statics": "readonly", - "__QUASAR_SSR__": "readonly", - "__QUASAR_SSR_SERVER__": "readonly", - "__QUASAR_SSR_CLIENT__": "readonly", - "__QUASAR_SSR_PWA__": "readonly", "process": "readonly", "chrome": "readonly", "API_CLIENT": "readonly", @@ -18,7 +14,6 @@ }, "ignorePatterns": [ "dist/**", - ".quasar/**", "node_modules/**", ".harness/**" ], diff --git a/frontend/index.html b/frontend/index.html index 45537b326..dbd6a3b48 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,15 @@ Wiki.js + diff --git a/frontend/src/components/StatusLight.vue b/frontend/src/components/StatusLight.vue index 42d35b6a7..a06740ed7 100644 --- a/frontend/src/components/StatusLight.vue +++ b/frontend/src/components/StatusLight.vue @@ -1,5 +1,5 @@ - @@ -440,13 +440,24 @@ import { useUserStore } from '@/stores/user' import AccountMenu from '../components/AccountMenu.vue' import FooterNav from '@/components/FooterNav.vue' +import LoadingGeneric from '@/components/LoadingGeneric.vue' +// -> Each with a loading placeholder, as the overlays opened from the page view have: the dialog +// around them is already on screen while the chunk is fetched, so without one the panel is empty +// until it arrives and then fills in all at once const overlays = { - EditorMarkdownConfig: defineAsyncComponent( - () => import('../components/EditorMarkdownConfigOverlay.vue') - ), - GroupEditOverlay: defineAsyncComponent(() => import('../components/GroupEditOverlay.vue')), - // MailTemplateEditorOverlay: defineAsyncComponent(() => import('../components/MailTemplateEditorOverlay.vue')), - UserEditOverlay: defineAsyncComponent(() => import('../components/UserEditOverlay.vue')) + EditorMarkdownConfig: defineAsyncComponent({ + loader: () => import('../components/EditorMarkdownConfigOverlay.vue'), + loadingComponent: LoadingGeneric + }), + GroupEditOverlay: defineAsyncComponent({ + loader: () => import('../components/GroupEditOverlay.vue'), + loadingComponent: LoadingGeneric + }), + // MailTemplateEditorOverlay: defineAsyncComponent({ loader: () => import('../components/MailTemplateEditorOverlay.vue'), loadingComponent: LoadingGeneric }), + UserEditOverlay: defineAsyncComponent({ + loader: () => import('../components/UserEditOverlay.vue'), + loadingComponent: LoadingGeneric + }) } // STORES diff --git a/frontend/src/layouts/InboxLayout.vue b/frontend/src/layouts/InboxLayout.vue index 2446111a9..4e217f365 100644 --- a/frontend/src/layouts/InboxLayout.vue +++ b/frontend/src/layouts/InboxLayout.vue @@ -148,8 +148,8 @@ watch( border-radius: 7px; display: flex; align-items: stretch; - // -> The margin above, top and bottom, is what this subtracts: the card fills what is left - min-height: calc(100% - 32px); + // -> No height of its own: the scrolling page container grows this into what is left beside the + // 16px margins above, and lets its content take it past that. See `.layout-profile-card`. @at-root .body--light & { background-color: #fff; diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 29071e8e0..81753c074 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -105,6 +105,12 @@ + @@ -117,9 +123,6 @@ - - - @@ -141,7 +144,6 @@ import { useUserStore } from '@/stores/user' // COMPONENTS -import FooterNav from '@/components/FooterNav.vue' import HeaderNav from '@/components/HeaderNav.vue' import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import NavBrowseMenu from '@/components/NavBrowseMenu.vue' diff --git a/frontend/src/layouts/ProfileLayout.vue b/frontend/src/layouts/ProfileLayout.vue index 6da62448f..e9aefeb6b 100644 --- a/frontend/src/layouts/ProfileLayout.vue +++ b/frontend/src/layouts/ProfileLayout.vue @@ -44,11 +44,11 @@ + + + - - - @@ -193,10 +193,17 @@ watch( border-radius: 7px; display: flex; align-items: stretch; - // -> Replaces the per-page `style-fn` that computed `height - 100 - offset` in JS: the 100px is - // this element's own 50px top and bottom margins, and the offsets are now handled by the - // layout grid rather than measured at runtime. - min-height: calc(100% - 100px); + /* + No height of its own. The card is a flex item of the scrolling page container, which grows it + into the height left over beside its 50px margins and lets it grow past that with its content -- + so both the "short page, card fills the window" and "long page, card extends and scrolls" cases + fall out of the parent. + + It used to say `min-height: calc(100% - 100px)`, subtracting those margins from the box by hand + (itself the successor to a per-page `style-fn` that computed `height - 100 - offset` in JS). That + is what a percentage cannot do once a footer shares the box: 100% is the WHOLE of it, footer + included, so the card claimed the footer's height too and its own content spilled out the bottom. + */ /* A foreground to go with the background. diff --git a/frontend/src/main.js b/frontend/src/main.js index 7174a7f43..034c9c711 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -10,10 +10,6 @@ import { initializeIconify } from './boot/iconify' import { initializeTemporal } from './boot/temporal' import { initializeHairlines } from './helpers/hairline' -// Roboto only: icon data is inlined at build time by scripts/generate-icons.mjs, -// so no icon webfont is loaded. -import '@quasar/extras/roboto-font/roboto-font.css' - import './css/tailwind.css' import './css/app.scss' diff --git a/frontend/src/pages/AdminInstances.vue b/frontend/src/pages/AdminInstances.vue index 4fe7a4c5a..d65662446 100644 --- a/frontend/src/pages/AdminInstances.vue +++ b/frontend/src/pages/AdminInstances.vue @@ -110,7 +110,7 @@ import { notify } from '@/composables/notify' import { useSiteStore } from '@/stores/site' -import { DateTime, Duration, Interval } from 'luxon' +import { humanizeDuration, relativeDate } from '@/helpers/datetime' // STORES @@ -168,7 +168,7 @@ const instancesHeaders = [ field: 'dbFirstSeen', name: 'firstseen', sortable: true, - format: (v) => DateTime.fromISO(v).toRelative() + format: relativeDate }, { label: t('admin.instances.lastSeen'), @@ -176,29 +176,28 @@ const instancesHeaders = [ field: 'dbLastSeen', name: 'lastseen', sortable: true, - format: (v) => DateTime.fromISO(v).toRelative() + format: relativeDate } ] // METHODS +/* + The fields luxon's `fff` expanded to, so the cell reads exactly as before -- long month, no seconds. + The scheduler spells out seconds in its own copy of this, because there a job's timing is the point. +*/ function humanizeDate(val) { - return DateTime.fromISO(val).toFormat('fff') -} - -function humanizeDuration(start, end) { - const dur = Interval.fromDateTimes(DateTime.fromISO(start), DateTime.fromISO(end)).toDuration([ - 'hours', - 'minutes', - 'seconds', - 'milliseconds' - ]) - return Duration.fromObject({ - ...(dur.hours > 0 && { hours: dur.hours }), - ...(dur.minutes > 0 && { minutes: dur.minutes }), - ...(dur.seconds > 0 && { seconds: dur.seconds }), - ...(dur.milliseconds > 0 && { milliseconds: dur.milliseconds }) - }).toHuman({ unitDisplay: 'narrow', listStyle: 'short' }) + if (!val) { + return '---' + } + return Temporal.Instant.from(val).toLocaleString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }) } async function load() { diff --git a/frontend/src/pages/AdminNavigation.vue b/frontend/src/pages/AdminNavigation.vue index 048928fa1..d68af4bf1 100644 --- a/frontend/src/pages/AdminNavigation.vue +++ b/frontend/src/pages/AdminNavigation.vue @@ -331,7 +331,7 @@ import { useMeta } from '@/composables/meta' import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' -import { find, intersectionBy, pull, unionBy } from 'lodash-es' +import { intersectionBy, pull, unionBy } from 'es-toolkit/array' import { v4 as uuid } from 'uuid' import draggable from 'vuedraggable' @@ -381,19 +381,21 @@ const navTypes = computed(() => [ ]) const locales = computed(() => { + // -> `(l) => l.code` rather than the `'code'` shorthand lodash took: es-toolkit's `*By` helpers + // want a mapper function, and a string reaches `uniqBy` as one and throws return intersectionBy( state.allLocales, - unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], 'code'), - 'code' + unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], (l) => l.code), + (l) => l.code ) }) const currentTree = computed({ get() { - return find(state.trees, ['locale', state.currentLang])?.items || [] + return state.trees.find((tree) => tree.locale === state.currentLang)?.items || [] }, set(val) { - const tree = find(state.trees, ['locale', state.currentLang]) + const tree = state.trees.find((t) => t.locale === state.currentLang) if (tree) { tree.items = val } else { @@ -453,7 +455,7 @@ function addItem(kind) { } function deleteItem(item) { - state.currentTree = pull(state.currentTree, item) + state.currentTree = pull(state.currentTree, [item]) state.current = {} } @@ -473,7 +475,7 @@ function copyFromLocale() { state.copyFromLocaleDialogIsShown = false state.currentTree = [ ...state.currentTree, - ...(find(state.trees, ['locale', state.copyFromLocaleCode])?.items || []) + ...(state.trees.find((tree) => tree.locale === state.copyFromLocaleCode)?.items || []) ] } diff --git a/frontend/src/pages/AdminRendering.vue b/frontend/src/pages/AdminRendering.vue index fca42d847..915e8a0c5 100644 --- a/frontend/src/pages/AdminRendering.vue +++ b/frontend/src/pages/AdminRendering.vue @@ -82,9 +82,6 @@ import { useMeta } from '@/composables/meta' import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' -import { cloneDeep, concat, filter, find, findIndex, reduce, reverse, sortBy } from 'lodash-es' -import { DepGraph } from 'dependency-graph' - // STORES const adminStore = useAdminStore() diff --git a/frontend/src/pages/AdminScheduler.vue b/frontend/src/pages/AdminScheduler.vue index 106c92a49..d40980261 100644 --- a/frontend/src/pages/AdminScheduler.vue +++ b/frontend/src/pages/AdminScheduler.vue @@ -403,6 +403,8 @@ import { useDark } from '@/composables/dark' import { useMeta } from '@/composables/meta' import { notify } from '@/composables/notify' +import { humanizeDuration, relativeDate } from '@/helpers/datetime' + import { useSiteStore } from '@/stores/site' // COMPOSABLES @@ -617,30 +619,7 @@ watch( // METHODS -/** Largest-first. `week` is deliberately absent, so output reads e.g. "21 days ago". */ -const RELATIVE_UNITS = [ - ['year', 31536000], - ['month', 2592000], - ['day', 86400], - ['hour', 3600], - ['minute', 60], - ['second', 1] -] -const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - -/** Reads both ways: past for history, future for a job still waiting its turn. */ -function relativeDate(val) { - if (!val) { - return '---' - } - const seconds = Temporal.Instant.from(val).until(Temporal.Now.instant()).total('seconds') - for (const [unit, secondsPerUnit] of RELATIVE_UNITS) { - if (Math.abs(seconds) >= secondsPerUnit || unit === 'second') { - return relativeTimeFormat.format(-Math.round(seconds / secondsPerUnit), unit) - } - } -} - +/** Absolute, and with seconds: for a job, the timing IS the thing being read. */ function humanizeDate(val) { if (!val) { return '---' @@ -656,29 +635,6 @@ function humanizeDate(val) { }) } -/** Narrow, largest-first and skipping empty units — "1h 4m 32s", or "820ms" for a quick job. */ -const DURATION_UNITS = ['hour', 'minute', 'second', 'millisecond'] -const durationListFormat = new Intl.ListFormat(undefined, { style: 'narrow', type: 'unit' }) - -function humanizeDuration(start, end) { - if (!start || !end) { - return '---' - } - const dur = Temporal.Instant.from(start).until(Temporal.Instant.from(end)).round({ - largestUnit: 'hour', - smallestUnit: 'millisecond' - }) - const parts = DURATION_UNITS.filter((unit) => dur[`${unit}s`] > 0).map((unit) => - new Intl.NumberFormat(undefined, { - style: 'unit', - unit, - unitDisplay: 'narrow' - }).format(dur[`${unit}s`]) - ) - // -> A job that took under a millisecond still has to render as something - return parts.length > 0 ? durationListFormat.format(parts) : '0ms' -} - async function load() { state.loading++ try { diff --git a/frontend/src/pages/AdminSystem.vue b/frontend/src/pages/AdminSystem.vue index f49c248d8..6dd999f75 100644 --- a/frontend/src/pages/AdminSystem.vue +++ b/frontend/src/pages/AdminSystem.vue @@ -279,7 +279,6 @@ import { dialog } from '@/composables/dialog' import { useSiteStore } from '@/stores/site' -import { cloneDeep } from 'lodash-es' import ClipboardJS from 'clipboard' import CheckUpdateDialog from '../components/CheckUpdateDialog.vue' diff --git a/frontend/src/pages/AdminUsers.vue b/frontend/src/pages/AdminUsers.vue index 4b01faba6..f6ab3df06 100644 --- a/frontend/src/pages/AdminUsers.vue +++ b/frontend/src/pages/AdminUsers.vue @@ -98,7 +98,7 @@ keypath="admin.users.lastLoginAt" tag="div"> @@ -153,6 +153,8 @@ import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' +import { relativeDate } from '@/helpers/datetime' + import { debounce } from 'es-toolkit/function' import UserCreateDialog from '../components/UserCreateDialog.vue' import UserDefaultsMenu from '@/components/UserDefaultsMenu.vue' @@ -285,27 +287,6 @@ async function load({ page } = {}) { } /** Largest-first. `week` is deliberately absent, so output reads e.g. "21 days ago". */ -const RELATIVE_UNITS = [ - ['year', 31536000], - ['month', 2592000], - ['day', 86400], - ['hour', 3600], - ['minute', 60], - ['second', 1] -] -const relativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - -function humanizeDate(val) { - if (!val) { - return '---' - } - const seconds = Temporal.Instant.from(val).until(Temporal.Now.instant()).total('seconds') - for (const [unit, secondsPerUnit] of RELATIVE_UNITS) { - if (Math.abs(seconds) >= secondsPerUnit || unit === 'second') { - return relativeTimeFormat.format(-Math.round(seconds / secondsPerUnit), unit) - } - } -} function formattedDate(val) { return userStore.formatDateTime(t, val) } diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index a425e4b00..f785f566b 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -49,7 +49,7 @@ @click="promptUnlock" /> -
+
+ + +
{{ t('profile.authTfaBadge') }} + diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue index 899c7849c..f6d32f685 100644 --- a/frontend/src/pages/Search.vue +++ b/frontend/src/pages/Search.vue @@ -201,9 +201,9 @@
+ - @@ -499,7 +499,15 @@ onUnmounted(() => { border-radius: 7px; display: flex; align-items: stretch; - height: 100%; + /* + No height of its own, as `.layout-profile-card` explains at length: the scrolling page container + grows this into the height left over beside its margins, and lets its content take it past that. + + It used to say `height: 100%`, which overflowed the box by exactly its own margins on every + search however few results came back -- so the footer under it started 100px below the fold -- + and, since a height is not a minimum, spilled a long result list out past the bottom edge of the + white card the results are supposed to sit on. + */ /* A foreground to go with the background, as `.layout-profile-card` needs for the same reason: @@ -595,17 +603,6 @@ body.body--dark { background-color: $dark-6; } -.w-footer { - // FooterNav still renders a q-bar; this goes with it in a later phase. - .q-bar { - @at-root .body--light & { - background-color: $grey-3; - color: $grey-7; - } - @at-root .body--dark & { - background-color: $dark-4; - color: rgba(255, 255, 255, 0.3); - } - } -} +// -> The `.w-footer .q-bar` rule that used to sit here never matched: FooterNav renders +// `.site-footer`, never a q-bar. Its colours live in FooterNav's own scoped style. diff --git a/frontend/src/renderers/modules/markdown-it-token.js b/frontend/src/renderers/modules/markdown-it-token.js new file mode 100644 index 000000000..e67698149 --- /dev/null +++ b/frontend/src/renderers/modules/markdown-it-token.js @@ -0,0 +1,17 @@ +/** + * `markdown-it`'s `Token` class, at the specifier a plugin still asks for. + * + * markdown-it 15 removed its package-internal subpath exports (`markdown-it/lib/*`) and moved the + * parser internals onto the main export as static classes. `markdown-it-mdc` has not caught up — it + * still does `import TokenClass from 'markdown-it/lib/token.mjs'`, which now resolves to nothing and + * fails the build outright rather than at runtime. + * + * `vite.config.js` aliases that dead specifier here, and `package.json` overrides the plugin's own + * `markdown-it: ^14.0.0` peer range to the version the app actually installs -- without that second + * half, npm refuses to resolve the tree at all and EVERY subsequent `npm install` fails on ERESOLVE. + * + * Nothing in this repo imports this file directly. Both halves come out when the plugin catches up. + */ +import MarkdownIt from 'markdown-it' + +export default MarkdownIt.Token diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index 471f9ac80..d494c5b3c 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' -import { clone, cloneDeep, sortBy } from 'lodash-es' +import { sortBy } from 'es-toolkit/array' +import { cloneDeep } from 'es-toolkit/object' import semverGte from 'semver/functions/gte' export const useAdminStore = defineStore('admin', { @@ -44,16 +45,16 @@ export const useAdminStore = defineStore('admin', { }, async fetchInfo() { const resp = await API_CLIENT.get('system/info').json() - this.info.groupsTotal = clone(resp?.groupsTotal ?? 0) - this.info.tagsTotal = clone(resp?.tagsTotal ?? 0) - this.info.usersTotal = clone(resp?.usersTotal ?? 0) - this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0) - this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a') - this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a') - this.info.isApiEnabled = clone(resp?.isApiEnabled ?? false) - this.info.isMetricsEnabled = clone(resp?.isMetricsEnabled ?? false) - this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false) - this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false) + this.info.groupsTotal = resp?.groupsTotal ?? 0 + this.info.tagsTotal = resp?.tagsTotal ?? 0 + this.info.usersTotal = resp?.usersTotal ?? 0 + this.info.loginsPastDay = resp?.loginsPastDay ?? 0 + this.info.currentVersion = resp?.currentVersion ?? 'n/a' + this.info.latestVersion = resp?.latestVersion ?? 'n/a' + this.info.isApiEnabled = resp?.isApiEnabled ?? false + this.info.isMetricsEnabled = resp?.isMetricsEnabled ?? false + this.info.isMailConfigured = resp?.isMailConfigured ?? false + this.info.isSchedulerHealthy = resp?.isSchedulerHealthy ?? false }, async fetchSites() { this.sites = (await API_CLIENT.get('sites').json()) ?? [] diff --git a/frontend/src/stores/common.js b/frontend/src/stores/common.js index 97ebd1c68..91d880249 100644 --- a/frontend/src/stores/common.js +++ b/frontend/src/stores/common.js @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' -import { difference } from 'lodash-es' +import { difference } from 'es-toolkit/array' export const useCommonStore = defineStore('common', { state: () => ({ diff --git a/frontend/src/stores/editor.js b/frontend/src/stores/editor.js index b39eec74d..a42a3266f 100644 --- a/frontend/src/stores/editor.js +++ b/frontend/src/stores/editor.js @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' -import { clone } from 'lodash-es' import { v4 as uuid } from 'uuid' import { useSiteStore } from './site' diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js index 22aa96f24..2cb563c0a 100644 --- a/frontend/src/stores/user.js +++ b/frontend/src/stores/user.js @@ -30,6 +30,28 @@ function formatDatePart (zoned, dateFormat) { } } +/** + * The moment as this user's clock shows it, whatever form the API sent it in. + * + * @param date A `Temporal.Instant`, a `Date`, or a string one can be parsed from. + * @param timezone This user's stored zone, which may be empty or no longer exist. + */ +function toUserZone(date, timezone) { + let instant = date + if (typeof date === 'string') { + instant = Temporal.Instant.from(date) + } else if (date instanceof Date) { + instant = date.toTemporalInstant() + } + // -> A preference set before the zone list changed, or none at all, falls back to this browser's + // zone rather than throwing in the middle of a table + try { + return instant.toZonedDateTimeISO(timezone || Temporal.Now.timeZoneId()) + } catch { + return instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()) + } +} + /** * Render the time part. `hourCycle` rather than `hour12: false`, which some locales render as 24:00 * where they mean 00:00. @@ -60,20 +82,6 @@ export const useUserStore = defineStore('user', { authenticated: false, profileLoaded: false }), - getters: { - // -> Luxon format tokens, for the call sites that still format dates with luxon themselves. They - // retire with the last of those; `formatDateTime()` no longer goes through them. - preferredDateFormat: (state) => { - if (!state.dateFormat) { - return 'D' - } else { - return state.dateFormat.replaceAll('Y', 'y').replaceAll('D', 'd') - } - }, - preferredTimeFormat: (state) => { - return state.timeFormat === '24h' ? 'T' : 't' - } - }, actions: { async refreshProfile() { try { @@ -181,24 +189,23 @@ export const useUserStore = defineStore('user', { if (!date) { return '' } - let instant = date - if (typeof date === 'string') { - instant = Temporal.Instant.from(date) - } else if (date instanceof Date) { - instant = date.toTemporalInstant() - } - // -> A preference set before the zone list changed, or none at all, falls back to this browser's - // zone rather than throwing in the middle of a table - let zoned - try { - zoned = instant.toZonedDateTimeISO(this.timezone || Temporal.Now.timeZoneId()) - } catch { - zoned = instant.toZonedDateTimeISO(Temporal.Now.timeZoneId()) - } + const zoned = toUserZone(date, this.timezone) return t('common.datetime', { date: formatDatePart(zoned, this.dateFormat), time: formatTimePart(zoned, this.timeFormat) }) + }, + /** + * Format the DATE alone, in this user's pattern and zone. For a line with no room for a time, or + * where the time says nothing worth reading -- the day an update was released, say. + * + * No `t`: with only one part there is no word order for a locale to have an opinion about. + */ + formatDate(date) { + if (!date) { + return '' + } + return formatDatePart(toUserZone(date, this.timezone), this.dateFormat) } } }) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e54d1f36c..577ac0c1b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -import yaml from 'js-yaml' +// -> A named import: js-yaml 5 ships ESM with no default export, so `import yaml from` throws +import { load as loadYaml } from 'js-yaml' import fs from 'node:fs' import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' @@ -10,7 +11,7 @@ import vueDevTools from 'vite-plugin-vue-devtools' export default defineConfig(({ mode }) => { const userConfig = mode === 'development' ? { dev: { port: 3001, hmrClientPort: 3001 }, - ...yaml.load(fs.readFileSync(fileURLToPath(new URL('../config.yml', import.meta.url)), 'utf8')) + ...loadYaml(fs.readFileSync(fileURLToPath(new URL('../config.yml', import.meta.url)), 'utf8')) } : {} return { @@ -36,14 +37,6 @@ export default defineConfig(({ mode }) => { }, target: 'es2022' }, - optimizeDeps: { - include: [ - 'prosemirror-state', - 'prosemirror-transform', - 'prosemirror-model', - 'prosemirror-view' - ] - }, plugins: [ vue({ template: { @@ -76,7 +69,15 @@ export default defineConfig(({ mode }) => { }, resolve: { alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)) + '@': fileURLToPath(new URL('./src', import.meta.url)), + /* + markdown-it 15 dropped its `markdown-it/lib/*` subpath exports and put the parser internals + on the main export as static classes. `markdown-it-mdc` still imports the old path, so + without this the build fails to resolve it -- see the shim for the rest. + */ + 'markdown-it/lib/token.mjs': fileURLToPath( + new URL('./src/renderers/modules/markdown-it-token.js', import.meta.url) + ) } }, server: {