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 @@
-
-.status-light(:class='cssClasses')
+
+
diff --git a/frontend/src/helpers/datetime.js b/frontend/src/helpers/datetime.js
new file mode 100644
index 000000000..f788fd39f
--- /dev/null
+++ b/frontend/src/helpers/datetime.js
@@ -0,0 +1,78 @@
+/**
+ * Date and duration rendering for the admin tables, in the reader's own locale.
+ *
+ * Shared because three screens had grown their own copy of the same walk down a units table — one of
+ * them under a different name — and the copies had already started to drift. What stays local to a
+ * screen is ABSOLUTE formatting: the scheduler spells out seconds because a job's timing is the point,
+ * where the instances table does not, and anything a user chose a pattern for goes through
+ * `userStore.formatDate()` instead.
+ *
+ * `Intl` rather than a formatting library: the browser already knows how the reader's locale words
+ * "3 minutes ago" and "1h 4m 32s", which is what luxon's `toRelative()` and `Duration.toHuman()` were
+ * here for.
+ */
+
+/*
+ Largest first, so the first unit the difference clears is the one it reads best in. `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' })
+
+/**
+ * How long ago a moment was, or how far off it still is.
+ *
+ * Reads both ways on purpose: past for history, future for a job still waiting its turn.
+ *
+ * @param {string|null} value An ISO instant, as the API returns.
+ * @returns {string} e.g. `3 minutes ago`, `in 2 days`, or `---` for nothing at all.
+ */
+export function relativeDate(value) {
+ if (!value) {
+ return '---'
+ }
+ const seconds = Temporal.Instant.from(value).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)
+ }
+ }
+}
+
+/** 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' })
+
+/**
+ * How long something took.
+ *
+ * @param {string|null} start An ISO instant.
+ * @param {string|null} end An ISO instant.
+ * @returns {string} e.g. `1h 4m 32s`, or `---` when either end is missing.
+ */
+export 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`])
+ )
+ // -> Something that took under a millisecond still has to render as something
+ return parts.length > 0 ? durationListFormat.format(parts) : '0ms'
+}
diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue
index a6e03a97b..a820bfdf8 100644
--- a/frontend/src/layouts/AdminLayout.vue
+++ b/frontend/src/layouts/AdminLayout.vue
@@ -417,11 +417,11 @@
+
-
@@ -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">
- {{ humanizeDate(props.row.lastLoginAt) }}
+ {{ relativeDate(props.row.lastLoginAt) }}
@@ -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" />
-
+
-
@@ -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: {