refactor: cleanup old dependencies + upgrade frontend to latest

scarlett
NGPixel 1 month ago
parent db99e3d6ef
commit 0c6510b958
No known key found for this signature in database

@ -6,10 +6,6 @@
}, },
"globals": { "globals": {
"__statics": "readonly", "__statics": "readonly",
"__QUASAR_SSR__": "readonly",
"__QUASAR_SSR_SERVER__": "readonly",
"__QUASAR_SSR_CLIENT__": "readonly",
"__QUASAR_SSR_PWA__": "readonly",
"process": "readonly", "process": "readonly",
"chrome": "readonly", "chrome": "readonly",
"API_CLIENT": "readonly", "API_CLIENT": "readonly",
@ -18,7 +14,6 @@
}, },
"ignorePatterns": [ "ignorePatterns": [
"dist/**", "dist/**",
".quasar/**",
"node_modules/**", "node_modules/**",
".harness/**" ".harness/**"
], ],

@ -8,6 +8,15 @@
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width"> <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
<title>Wiki.js</title> <title>Wiki.js</title>
<!--preload-links--> <!--preload-links-->
<!--
The app's only webfont, and self-hosted: Roboto 300-900 as woff2, covering latin, latin-ext,
greek, cyrillic and vietnamese. Icon data is inlined at build time by scripts/generate-icons.mjs,
so there is no icon webfont to load.
Declared here rather than imported from a package, which is what `@quasar/extras/roboto-font`
used to do from main.js. Bundled CSS is linked after this, so those rules won -- leaving the app
on a latin-only woff copy of the same family while these files went unfetched.
-->
<link href="/_assets/fonts/roboto/roboto.css" rel="stylesheet" /> <link href="/_assets/fonts/roboto/roboto.css" rel="stylesheet" />
<style type="text/css"> <style type="text/css">
@keyframes initspinner { @keyframes initspinner {

@ -38,9 +38,6 @@
"node_modules" "node_modules"
], ],
"vueCompilerOptions": { "vueCompilerOptions": {
"target": 3, "target": 3
"plugins": [
"@vue/language-plugin-pug"
]
} }
} }

File diff suppressed because it is too large Load Diff

@ -17,34 +17,26 @@
"emoji:check": "node scripts/generate-emoji.mjs --check" "emoji:check": "node scripts/generate-emoji.mjs --check"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "2.0.1",
"@simplewebauthn/browser": "13.3.0", "@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3", "@tailwindcss/vite": "4.3.3",
"@tanstack/vue-query": "5.101.0",
"@xterm/xterm": "6.0.0", "@xterm/xterm": "6.0.0",
"browser-fs-access": "0.38.0", "browser-fs-access": "0.38.0",
"clipboard": "2.0.11", "clipboard": "2.0.11",
"dependency-graph": "1.0.0",
"es-toolkit": "1.50.0", "es-toolkit": "1.50.0",
"filesize": "11.0.17", "filesize": "11.0.22",
"filesize-parser": "1.5.1", "filesize-parser": "1.5.2",
"fuse.js": "7.4.2", "fuse.js": "7.5.0",
"highlight.js": "11.11.1", "highlight.js": "11.11.1",
"iconify-icon": "3.0.2", "iconify-icon": "3.0.2",
"js-cookie": "3.0.8", "js-cookie": "3.0.8",
"jwt-decode": "4.0.0",
"ky": "2.0.2", "ky": "2.0.2",
"lodash-es": "4.18.1", "markdown-it": "15.0.0",
"lowlight": "3.3.0",
"luxon": "3.7.2",
"markdown-it": "14.2.0",
"markdown-it-abbr": "2.0.0", "markdown-it-abbr": "2.0.0",
"markdown-it-attrs": "5.0.0", "markdown-it-attrs": "5.0.1",
"markdown-it-decorate": "1.2.2", "markdown-it-decorate": "1.2.2",
"markdown-it-emoji": "3.0.0", "markdown-it-emoji": "3.1.0",
"markdown-it-expand-tabs": "1.0.13", "markdown-it-expand-tabs": "1.0.13",
"markdown-it-footnote": "4.0.0", "markdown-it-footnote": "4.0.0",
"markdown-it-imsize": "2.0.1",
"markdown-it-mark": "4.0.0", "markdown-it-mark": "4.0.0",
"markdown-it-mdc": "0.2.12", "markdown-it-mdc": "0.2.12",
"markdown-it-multimd-table": "4.2.3", "markdown-it-multimd-table": "4.2.3",
@ -52,31 +44,21 @@
"markdown-it-sup": "2.0.0", "markdown-it-sup": "2.0.0",
"markdown-it-task-lists": "2.1.1", "markdown-it-task-lists": "2.1.1",
"mitt": "3.0.1", "mitt": "3.0.1",
"monaco-editor": "0.55.1", "monaco-editor": "0.56.0",
"pinia": "3.0.4", "pinia": "4.0.2",
"prosemirror-commands": "1.7.1", "semver": "7.8.5",
"prosemirror-history": "1.5.0",
"prosemirror-keymap": "1.2.3",
"prosemirror-model": "1.25.8",
"prosemirror-schema-list": "1.5.1",
"prosemirror-state": "1.4.4",
"prosemirror-transform": "1.12.0",
"prosemirror-view": "1.41.9",
"pug": "3.0.4",
"slugify": "1.6.9", "slugify": "1.6.9",
"socket.io-client": "4.8.3", "socket.io-client": "4.8.3",
"sortablejs": "1.15.7", "sortablejs": "1.15.7",
"sortablejs-vue3": "1.3.0", "sortablejs-vue3": "1.3.0",
"tailwindcss": "4.3.3", "tailwindcss": "4.3.3",
"temporal-polyfill": "1.0.1", "temporal-polyfill": "1.0.2",
"tippy.js": "6.3.7",
"twemoji": "14.0.2", "twemoji": "14.0.2",
"typescript": "6.0.3", "uuid": "14.0.1",
"uuid": "14.0.0", "v-network-graph": "0.9.23",
"v-network-graph": "0.9.22", "vue": "3.5.40",
"vue": "3.5.38", "vue-i18n": "11.4.8",
"vue-i18n": "11.4.5", "vue-router": "5.2.0",
"vue-router": "5.1.0",
"vue3-otp-input": "0.5.40", "vue3-otp-input": "0.5.40",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"zxcvbn": "4.4.2" "zxcvbn": "4.4.2"
@ -84,20 +66,20 @@
"devDependencies": { "devDependencies": {
"@iconify-json/la": "1.2.1", "@iconify-json/la": "1.2.1",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/tabler": "1.2.38",
"@intlify/unplugin-vue-i18n": "11.2.3",
"@types/lodash": "4.17.24",
"@vitejs/plugin-vue": "6.0.8", "@vitejs/plugin-vue": "6.0.8",
"@vue/language-plugin-pug": "3.3.5", "js-yaml": "5.2.3",
"autoprefixer": "10.5.0", "npm-check-updates": "23.0.0",
"browserlist": "latest", "oxfmt": "0.61.0",
"npm-check-updates": "22.2.3", "oxlint": "1.76.0",
"oxfmt": "0.54.0", "sass": "1.102.0",
"oxlint": "1.69.0",
"sass": "1.101.0",
"unicode-emoji-json": "0.9.0", "unicode-emoji-json": "0.9.0",
"vite": "8.1.5", "vite": "8.2.0",
"vite-plugin-vue-devtools": "8.1.2" "vite-plugin-vue-devtools": "8.2.1"
},
"overrides": {
"markdown-it-mdc": {
"markdown-it": "$markdown-it"
}
}, },
"engines": { "engines": {
"node": ">= 26.0" "node": ">= 26.0"

@ -1,29 +0,0 @@
import autoprefixer from 'autoprefixer'
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
export default {
plugins: [
// https://github.com/postcss/autoprefixer
autoprefixer({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}

@ -34,7 +34,7 @@ const OUT = path.join(SRC, 'assets/icons.generated.js')
* The skip is silent on purpose: `prefix:name` also describes every permission string in the frontend * The skip is silent on purpose: `prefix:name` also describes every permission string in the frontend
* (`write:pages`, `manage:system`), and those must not be mistaken for icons. * (`write:pages`, `manage:system`), and those must not be mistaken for icons.
*/ */
const SETS = ['mdi', 'la', 'tabler'] const SETS = ['mdi', 'la']
/** /**
* A quoted string that is EXACTLY an Iconify reference. Requiring the whole literal to match is what * A quoted string that is EXACTLY an Iconify reference. Requiring the whole literal to match is what
@ -103,7 +103,9 @@ export function build() {
const sets = Object.fromEntries( const sets = Object.fromEntries(
SETS.map((p) => [ SETS.map((p) => [
p, p,
JSON.parse(fs.readFileSync(path.join(ROOT, `node_modules/@iconify-json/${p}/icons.json`), 'utf8')) JSON.parse(
fs.readFileSync(path.join(ROOT, `node_modules/@iconify-json/${p}/icons.json`), 'utf8')
)
]) ])
) )
@ -172,5 +174,7 @@ if (process.argv.includes('--check')) {
fs.mkdirSync(path.dirname(OUT), { recursive: true }) fs.mkdirSync(path.dirname(OUT), { recursive: true })
fs.writeFileSync(OUT, output) fs.writeFileSync(OUT, output)
const bytes = Buffer.byteLength(output) const bytes = Buffer.byteLength(output)
console.log(`wrote ${Object.keys(icons).length} icons to src/assets/icons.generated.js (${bytes.toLocaleString()} B)`) console.log(
`wrote ${Object.keys(icons).length} icons to src/assets/icons.generated.js (${bytes.toLocaleString()} B)`
)
} }

@ -1,25 +0,0 @@
<template lang="pug">
div(style='max-width: 760px;')
Player(controls)
Youtube(video-id='DyTCOwB0DVw', :autoplay='0')
//- DefaultUi(noControls)
//- DefaultControls(
//- hideOnMouseLeave
//- :activeDuration='2000'
//- )
</template>
<script>
import '@vime/core/themes/default.css'
import { Player, Video, DefaultUi, DefaultControls, Youtube } from '@vime/vue-next'
export default {
components: {
Player,
Video,
DefaultUi,
DefaultControls,
Youtube
}
}
</script>

@ -29,10 +29,6 @@ const props = defineProps({
type: String, type: String,
default: '' default: ''
}, },
dark: {
type: Boolean,
default: false
},
indicator: { indicator: {
type: String, type: String,
default: null default: null
@ -51,7 +47,6 @@ const props = defineProps({
} }
}) })
// COMPOSABLES // COMPOSABLES
const dark = useDark() const dark = useDark()
@ -62,10 +57,10 @@ const textMode = computed(() => {
return props.text !== null return props.text !== null
}) })
const avatarBgColor = computed(() => { const avatarBgColor = computed(() => {
return dark.isActive || props.dark ? 'dark-4' : 'blue-1' return dark.isActive ? 'dark-4' : 'blue-1'
}) })
const avatarTextColor = computed(() => { const avatarTextColor = computed(() => {
return dark.isActive || props.dark ? 'white' : 'blue-7' return dark.isActive ? 'white' : 'blue-7'
}) })
const indicatorDot = computed(() => { const indicatorDot = computed(() => {
if (props.indicator === null) { if (props.indicator === null) {

@ -64,7 +64,6 @@ import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog' import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { computed, onMounted, reactive } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { DateTime } from 'luxon'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
@ -107,7 +106,7 @@ async function check() {
if (resp?.current) { if (resp?.current) {
state.current = resp.current state.current = resp.current
state.latest = resp.latest state.latest = resp.latest
state.latestDate = DateTime.fromISO(resp.latestDate).toFormat(userStore.preferredDateFormat) state.latestDate = userStore.formatDate(resp.latestDate)
} else { } else {
throw new Error(resp?.message || 'An unexpected error occured.') throw new Error(resp?.message || 'An unexpected error occured.')
} }

@ -1,18 +1,42 @@
<template lang="pug"> <template>
.loader-generic <div class="loader-generic">
div <div />
</div>
</template> </template>
<script setup>
/**
* Placeholder for an async component that has not arrived yet.
*
* Passed as `loadingComponent` wherever a shell is on screen before its content is: the overlays and
* side panels, whose backdrop, panel and open transition are already running by the time the chunk is
* fetched, and the markdown editor, which takes over the page view's article column. Without it those
* shells stand there empty and then snap to a full screen of content.
*/
</script>
<style lang="scss"> <style lang="scss">
/*
Fills whatever holds it and centres the box. It did neither: sized and centred by the dialog inner
of the library this replaces, it was a plain block whose box sat at the top left of the panel, 64px
of padding under the panel's own top edge.
Both hosts are covered at once. A dialog panel is a flex column, so `flex: 1` claims the height;
the editor slot is a plain block of definite height, so `height: 100%` claims it there. Whichever
applies, the other is inert.
*/
.loader-generic { .loader-generic {
box-shadow: none !important; flex: 1;
padding-top: 64px; height: 100%;
display: flex;
align-items: center;
justify-content: center;
> div { > div {
background-color: rgba(0, 0, 0, 0.75); background-color: rgba(0, 0, 0, 0.75);
width: 64px; width: 64px;
height: 64px; height: 64px;
border-radius: 5px !important; border-radius: 5px;
position: relative; position: relative;
&:before { &:before {
@ -33,9 +57,30 @@
} }
} }
/*
A panel waiting on its content shows nothing of itself, so what is on screen is the dimmed backdrop
and this spinner -- not an overlay that has apparently opened empty.
The surfaces being suppressed are declared per host (a gradient and a hairline for the admin and
main overlays, nothing at all for a side panel, whose content brings its own card), so there is no
one rule to sit in front of and `!important` is what makes this independent of them. The
`box-shadow: none !important` this file used to carry was reaching for the same thing, back when the
shadow to cancel was on this element rather than on the panel around it.
*/
.w-dialog-panel:has(> .loader-generic) {
background: none !important;
box-shadow: none !important;
}
@keyframes loadergenericspinner { @keyframes loadergenericspinner {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@media (prefers-reduced-motion: reduce) {
.loader-generic > div:before {
animation-duration: 2s;
}
}
</style> </style>

@ -1,5 +1,5 @@
<template lang="pug"> <template>
.status-light(:class='cssClasses') <div class="status-light" :class="cssClasses" />
</template> </template>
<script setup> <script setup>

@ -1,15 +1,13 @@
<template lang="pug"> <template>
.treeview <div class="treeview">
tree-level( <tree-level :depth="0" :parent-id="null" />
:depth='0' </div>
:parent-id='null'
)
</template> </template>
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed, onMounted, provide, reactive, toRef } from 'vue' import { computed, onMounted, provide, reactive, toRef } from 'vue'
import { findKey } from 'lodash-es' import { findKey } from 'es-toolkit/object'
import TreeLevel from './TreeLevel.vue' import TreeLevel from './TreeLevel.vue'

@ -6,8 +6,24 @@
<script setup> <script setup>
/** /**
* Bottom bar of a `WLayout`. Sits beside the drawers rather than under them, matching the `lff` * Bottom bar of a `WLayout`.
* footer row every layout in this app declared. *
* Where it goes decides what it means, and both placements are used:
*
* - As a child of the `WLayout` itself it takes the shell's footer row, beside the drawers rather
* than under them, and is PINNED -- the shell is the viewport, so the bar stays put while the page
* scrolls behind it. That is what a status bar wants (`FileManager`'s current folder path).
* - As the last child of a `WPageContainer` it is part of the content that scrolls, so it comes to
* rest at the bottom of the page rather than the bottom of the window. That is what the site footer
* wants, and it is where every layout puts it.
*
* Nothing here has to switch between the two: `grid-area` places it in the shell's grid, and the
* page container is a flex column -- as is the page view's article scroll area, which holds its
* footer directly -- where `grid-area` does not apply at all and the bar simply stacks last.
*
* `grid-area` is only inert in a parent that is not a grid, though. Dropped into one that has no area
* by that name it is worse than ignored: the spec has every implicit line answer to an unmatched
* name, so the bar lands in an implicit COLUMN beside the page rather than under it.
*/ */
defineProps({ defineProps({
elevated: { elevated: {
@ -20,5 +36,8 @@ defineProps({
<style scoped> <style scoped>
.w-footer { .w-footer {
grid-area: footer; grid-area: footer;
/* -> In a flex column -- the page view puts its footer at the end of the article's scroll area --
the bar is a fixed-size item, not something to squeeze to make the content fit */
flex-shrink: 0;
} }
</style> </style>

@ -18,6 +18,10 @@
* `WPageContainer` and `WFooter` are siblings, exactly as before. Absent drawers collapse their * `WPageContainer` and `WFooter` are siblings, exactly as before. Absent drawers collapse their
* column to zero width, so nothing has to be conditional in the template. * column to zero width, so nothing has to be conditional in the template.
* *
* The footer row is for a bar that has to stay put while the page scrolls behind it -- a status bar.
* The site footer belongs at the bottom of the CONTENT, so it goes inside the `WPageContainer`
* instead, which stacks it under the page and scrolls the two together; see `WFooter`.
*
* `container` scopes the layout to its parent box (height 100%) instead of the viewport, which is * `container` scopes the layout to its parent box (height 100%) instead of the viewport, which is
* what the overlay and dialog layouts need. * what the overlay and dialog layouts need.
*/ */

@ -24,9 +24,14 @@ defineProps({
<style scoped> <style scoped>
/* /*
Fill the content cell. `min-height` rather than `height` so a page taller than the viewport still Fill whatever holds this. `min-height` rather than `height` so a page taller than the viewport still
grows, and on the page cell rather than the container so that a container holding something other grows, and on the page cell rather than the container so that a container holding something other
than a page (the search screen's floating card) is left alone. than a page (the search screen's floating card) is left alone.
For a page sitting DIRECTLY in a `WPageContainer` the container overrides this and grows the page
with flex instead, because a percentage would also claim the height of a footer sharing that box.
This still does the work wherever a page is nested inside something else -- the profile and search
cards, say.
*/ */
.w-page { .w-page {
min-height: 100%; min-height: 100%;

@ -6,15 +6,42 @@
<script setup> <script setup>
/** /**
* The main content cell of a `WLayout`. * The main content cell of a `WLayout`, and the box that scrolls.
* *
* `min-width: 0` is load-bearing: without it a wide child (a table, a code block) would blow the * `min-width: 0` is load-bearing: without it a wide child (a table, a code block) would blow the
* grid column out rather than scrolling inside it. * grid column out rather than scrolling inside it.
*
* A column, so that a `WFooter` written as the last child comes to rest at the bottom of the CONTENT
* rather than the bottom of the window: the page above it takes the leftover height, the footer sits
* under it, and both scroll together inside this box.
*
* The classic sticky-footer arrangement, and the parts of it matter:
*
* - `flex-basis: auto` starts from the page's OWN height, so a page taller than this box grows past
* it and scrolls, taking the footer with it. This is what a grid with a `1fr` content row could not
* do: a grid item is stretched to its track, which fixes its height at the leftover space, and
* anything longer then spilled out of the bottom of the page instead of extending it.
* - `flex-grow: 1` fills the leftover height when the page is shorter, so the footer still lands at
* the bottom of the window rather than hanging under two lines of content.
* - `flex-shrink: 0` keeps a long page from being squeezed to make room for the footer.
* - `min-height: 0` overrides `WPage`'s own `min-height: 100%`, which measures the WHOLE box: the
* page would claim the footer's height as well and every short page would scroll a little. Nested
* pages -- the one inside the profile card, say -- still use it, so it stays there.
*
* Specificity: `:not()` counts its argument, so `> :not(.w-footer)` outweighs `WPage`'s own rule
* rather than depending on which stylesheet Vite happens to inject first.
*/ */
</script> </script>
<style scoped> <style scoped>
.w-page-container { .w-page-container {
grid-area: main; grid-area: main;
display: flex;
flex-direction: column;
}
.w-page-container :deep(> :not(.w-footer)) {
flex: 1 0 auto;
min-height: 0;
} }
</style> </style>

@ -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'
}

@ -417,11 +417,11 @@
</w-drawer> </w-drawer>
<w-page-container class="admin-container"> <w-page-container class="admin-container">
<router-view v-slot="{ Component }"><component :is="Component" /></router-view> <router-view v-slot="{ Component }"><component :is="Component" /></router-view>
<w-footer><footer-nav generic /></w-footer>
</w-page-container> </w-page-container>
<w-dialog class="admin-overlay" v-model="overlayIsShown" persistent full-width full-height> <w-dialog class="admin-overlay" v-model="overlayIsShown" persistent full-width full-height>
<component :is="overlays[adminStore.overlay]" /> <component :is="overlays[adminStore.overlay]" />
</w-dialog> </w-dialog>
<w-footer><footer-nav generic /></w-footer>
</w-layout> </w-layout>
</template> </template>
@ -440,13 +440,24 @@ import { useUserStore } from '@/stores/user'
import AccountMenu from '../components/AccountMenu.vue' import AccountMenu from '../components/AccountMenu.vue'
import FooterNav from '@/components/FooterNav.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 = { const overlays = {
EditorMarkdownConfig: defineAsyncComponent( EditorMarkdownConfig: defineAsyncComponent({
() => import('../components/EditorMarkdownConfigOverlay.vue') loader: () => import('../components/EditorMarkdownConfigOverlay.vue'),
), loadingComponent: LoadingGeneric
GroupEditOverlay: defineAsyncComponent(() => import('../components/GroupEditOverlay.vue')), }),
// MailTemplateEditorOverlay: defineAsyncComponent(() => import('../components/MailTemplateEditorOverlay.vue')), GroupEditOverlay: defineAsyncComponent({
UserEditOverlay: defineAsyncComponent(() => import('../components/UserEditOverlay.vue')) 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 // STORES

@ -148,8 +148,8 @@ watch(
border-radius: 7px; border-radius: 7px;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
// -> The margin above, top and bottom, is what this subtracts: the card fills what is left // -> No height of its own: the scrolling page container grows this into what is left beside the
min-height: calc(100% - 32px); // 16px margins above, and lets its content take it past that. See `.layout-profile-card`.
@at-root .body--light & { @at-root .body--light & {
background-color: #fff; background-color: #fff;

@ -105,6 +105,12 @@
</w-bar> </w-bar>
</template> </template>
</w-drawer> </w-drawer>
<!--
No `<w-footer>` here, unlike every other layout: this one only ever holds the page view, and
there the article column scrolls inside a shell that holds still, so a footer at this level
would be pinned to the window no matter which row it took. The page view puts it at the end of
that scrolling column instead -- see `pages/Index.vue`.
-->
<w-page-container> <w-page-container>
<router-view /> <router-view />
<!-- -> `.page-container-scrl` is the page view's article column, which is what scrolls --> <!-- -> `.page-container-scrl` is the page view's article column, which is what scrolls -->
@ -117,9 +123,6 @@
</w-page-scroller> </w-page-scroller>
</w-page-container> </w-page-container>
<main-overlay-dialog /> <main-overlay-dialog />
<w-footer v-if="!editorStore.isActive">
<footer-nav />
</w-footer>
</w-layout> </w-layout>
</template> </template>
@ -141,7 +144,6 @@ import { useUserStore } from '@/stores/user'
// COMPONENTS // COMPONENTS
import FooterNav from '@/components/FooterNav.vue'
import HeaderNav from '@/components/HeaderNav.vue' import HeaderNav from '@/components/HeaderNav.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
import NavBrowseMenu from '@/components/NavBrowseMenu.vue' import NavBrowseMenu from '@/components/NavBrowseMenu.vue'

@ -44,11 +44,11 @@
</div> </div>
<router-view /> <router-view />
</div> </div>
</w-page-container>
<main-overlay-dialog />
<w-footer> <w-footer>
<footer-nav /> <footer-nav />
</w-footer> </w-footer>
</w-page-container>
<main-overlay-dialog />
</w-layout> </w-layout>
</template> </template>
@ -193,10 +193,17 @@ watch(
border-radius: 7px; border-radius: 7px;
display: flex; display: flex;
align-items: stretch; 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 No height of its own. The card is a flex item of the scrolling page container, which grows it
// layout grid rather than measured at runtime. into the height left over beside its 50px margins and lets it grow past that with its content --
min-height: calc(100% - 100px); 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. A foreground to go with the background.

@ -10,10 +10,6 @@ import { initializeIconify } from './boot/iconify'
import { initializeTemporal } from './boot/temporal' import { initializeTemporal } from './boot/temporal'
import { initializeHairlines } from './helpers/hairline' 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/tailwind.css'
import './css/app.scss' import './css/app.scss'

@ -110,7 +110,7 @@ import { notify } from '@/composables/notify'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { DateTime, Duration, Interval } from 'luxon' import { humanizeDuration, relativeDate } from '@/helpers/datetime'
// STORES // STORES
@ -168,7 +168,7 @@ const instancesHeaders = [
field: 'dbFirstSeen', field: 'dbFirstSeen',
name: 'firstseen', name: 'firstseen',
sortable: true, sortable: true,
format: (v) => DateTime.fromISO(v).toRelative() format: relativeDate
}, },
{ {
label: t('admin.instances.lastSeen'), label: t('admin.instances.lastSeen'),
@ -176,29 +176,28 @@ const instancesHeaders = [
field: 'dbLastSeen', field: 'dbLastSeen',
name: 'lastseen', name: 'lastseen',
sortable: true, sortable: true,
format: (v) => DateTime.fromISO(v).toRelative() format: relativeDate
} }
] ]
// METHODS // 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) { function humanizeDate(val) {
return DateTime.fromISO(val).toFormat('fff') if (!val) {
return '---'
} }
return Temporal.Instant.from(val).toLocaleString(undefined, {
function humanizeDuration(start, end) { year: 'numeric',
const dur = Interval.fromDateTimes(DateTime.fromISO(start), DateTime.fromISO(end)).toDuration([ month: 'long',
'hours', day: 'numeric',
'minutes', hour: 'numeric',
'seconds', minute: '2-digit',
'milliseconds' timeZoneName: 'short'
]) })
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' })
} }
async function load() { async function load() {

@ -331,7 +331,7 @@ import { useMeta } from '@/composables/meta'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' 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 { v4 as uuid } from 'uuid'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
@ -381,19 +381,21 @@ const navTypes = computed(() => [
]) ])
const locales = 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( return intersectionBy(
state.allLocales, state.allLocales,
unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], 'code'), unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], (l) => l.code),
'code' (l) => l.code
) )
}) })
const currentTree = computed({ const currentTree = computed({
get() { get() {
return find(state.trees, ['locale', state.currentLang])?.items || [] return state.trees.find((tree) => tree.locale === state.currentLang)?.items || []
}, },
set(val) { set(val) {
const tree = find(state.trees, ['locale', state.currentLang]) const tree = state.trees.find((t) => t.locale === state.currentLang)
if (tree) { if (tree) {
tree.items = val tree.items = val
} else { } else {
@ -453,7 +455,7 @@ function addItem(kind) {
} }
function deleteItem(item) { function deleteItem(item) {
state.currentTree = pull(state.currentTree, item) state.currentTree = pull(state.currentTree, [item])
state.current = {} state.current = {}
} }
@ -473,7 +475,7 @@ function copyFromLocale() {
state.copyFromLocaleDialogIsShown = false state.copyFromLocaleDialogIsShown = false
state.currentTree = [ state.currentTree = [
...state.currentTree, ...state.currentTree,
...(find(state.trees, ['locale', state.copyFromLocaleCode])?.items || []) ...(state.trees.find((tree) => tree.locale === state.copyFromLocaleCode)?.items || [])
] ]
} }

@ -82,9 +82,6 @@ import { useMeta } from '@/composables/meta'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { cloneDeep, concat, filter, find, findIndex, reduce, reverse, sortBy } from 'lodash-es'
import { DepGraph } from 'dependency-graph'
// STORES // STORES
const adminStore = useAdminStore() const adminStore = useAdminStore()

@ -403,6 +403,8 @@ import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { humanizeDuration, relativeDate } from '@/helpers/datetime'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// COMPOSABLES // COMPOSABLES
@ -617,30 +619,7 @@ watch(
// METHODS // METHODS
/** Largest-first. `week` is deliberately absent, so output reads e.g. "21 days ago". */ /** Absolute, and with seconds: for a job, the timing IS the thing being read. */
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)
}
}
}
function humanizeDate(val) { function humanizeDate(val) {
if (!val) { if (!val) {
return '---' 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() { async function load() {
state.loading++ state.loading++
try { try {

@ -279,7 +279,6 @@ import { dialog } from '@/composables/dialog'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { cloneDeep } from 'lodash-es'
import ClipboardJS from 'clipboard' import ClipboardJS from 'clipboard'
import CheckUpdateDialog from '../components/CheckUpdateDialog.vue' import CheckUpdateDialog from '../components/CheckUpdateDialog.vue'

@ -98,7 +98,7 @@
keypath="admin.users.lastLoginAt" keypath="admin.users.lastLoginAt"
tag="div"> tag="div">
<template #date> <template #date>
<strong>{{ humanizeDate(props.row.lastLoginAt) }}</strong> <strong>{{ relativeDate(props.row.lastLoginAt) }}</strong>
</template> </template>
</i18n-t> </i18n-t>
</w-td> </w-td>
@ -153,6 +153,8 @@ import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { relativeDate } from '@/helpers/datetime'
import { debounce } from 'es-toolkit/function' import { debounce } from 'es-toolkit/function'
import UserCreateDialog from '../components/UserCreateDialog.vue' import UserCreateDialog from '../components/UserCreateDialog.vue'
import UserDefaultsMenu from '@/components/UserDefaultsMenu.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". */ /** 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) { function formattedDate(val) {
return userStore.formatDateTime(t, val) return userStore.formatDateTime(t, val)
} }

@ -49,7 +49,7 @@
@click="promptUnlock" /> @click="promptUnlock" />
</div> </div>
<w-scroll-area class="page-container-scrl" v-else style="height: 100%"> <w-scroll-area class="page-container-scrl" v-else style="height: 100%">
<div class="p-4"> <div class="page-container-body p-4">
<!-- <!--
Delegated rather than bound per link: the anchors are written by `v-html`, so there is Delegated rather than bound per link: the anchors are written by `v-html`, so there is
nothing here to put a handler on, and they are replaced wholesale on every render. nothing here to put a handler on, and they are replaced wholesale on every render.
@ -114,6 +114,16 @@
</div> </div>
</template> </template>
</div> </div>
<!--
Inside the scrolling column, and last: this is the bottom of the PAGE, so it is reached by
reading to the end of it rather than sitting over the article the whole way down.
The editor replaces this column wholesale, which is how it goes without a footer, and the
lock screen likewise -- a page that sent no body has no end to arrive at.
-->
<w-footer>
<footer-nav />
</w-footer>
</w-scroll-area> </w-scroll-area>
</div> </div>
<div <div
@ -233,6 +243,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import FooterNav from '@/components/FooterNav.vue'
import LoadingGeneric from '@/components/LoadingGeneric.vue' import LoadingGeneric from '@/components/LoadingGeneric.vue'
import PageActionsCol from '@/components/PageActionsCol.vue' import PageActionsCol from '@/components/PageActionsCol.vue'
import PageHeader from '@/components/PageHeader.vue' import PageHeader from '@/components/PageHeader.vue'
@ -656,6 +667,22 @@ function promptUnlock() {
} }
} }
} }
/*
The article and the footer under it, stacked inside the one box that scrolls.
`flex: 1 0 auto` on the article is what keeps the footer at the BOTTOM of a short page instead of
leaving it hanging under two lines of content: the article takes the leftover height, and past that
grows with its own content and pushes the footer out of view until the reader gets there. It must
not shrink either, or a long article would be squeezed to make room rather than scrolling.
*/
.page-container-scrl {
display: flex;
flex-direction: column;
}
.page-container-body {
flex: 1 0 auto;
}
.page-container { .page-container {
@at-root .body--light & { @at-root .body--light & {
border-top: 1px solid #fff; border-top: 1px solid #fff;

@ -18,7 +18,6 @@ import { useMeta } from '@/composables/meta'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { find, has, head, reject, sortBy } from 'lodash-es'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import AuthLoginPanel from '@/components/AuthLoginPanel.vue' import AuthLoginPanel from '@/components/AuthLoginPanel.vue'
@ -134,7 +133,7 @@ async function login() {
strategy: this.selectedStrategy.key strategy: this.selectedStrategy.key
} }
}) })
if (has(resp, 'data.authentication.login')) { if (resp?.data?.authentication?.login) {
const respObj = resp?.data?.authentication?.login ?? {} const respObj = resp?.data?.authentication?.login ?? {}
if (respObj.responseResult.succeeded === true) { if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj) this.handleLoginResponse(respObj)
@ -208,7 +207,7 @@ async function verifySecurityCode(setup = false) {
setup setup
} }
}) })
if (has(resp, 'data.authentication.loginTFA')) { if (resp?.data?.authentication?.loginTFA) {
const respObj = resp?.data?.authentication?.loginTFA ?? {} const respObj = resp?.data?.authentication?.loginTFA ?? {}
if (respObj.responseResult.succeeded === true) { if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj) this.handleLoginResponse(respObj)
@ -269,7 +268,7 @@ async function changePassword() {
newPassword: this.newPassword newPassword: this.newPassword
} }
}) })
if (has(resp, 'data.authentication.loginChangePassword')) { if (resp?.data?.authentication?.loginChangePassword) {
const respObj = resp?.data?.authentication?.loginChangePassword ?? {} const respObj = resp?.data?.authentication?.loginChangePassword ?? {}
if (respObj.responseResult.succeeded === true) { if (respObj.responseResult.succeeded === true) {
this.handleLoginResponse(respObj) this.handleLoginResponse(respObj)
@ -331,7 +330,7 @@ async function forgotPasswordSubmit() {
email: this.username email: this.username
} }
}) })
if (has(resp, 'data.authentication.forgotPassword.responseResult')) { if (resp?.data?.authentication?.forgotPassword?.responseResult) {
const respObj = resp?.data?.authentication?.forgotPassword?.responseResult ?? {} const respObj = resp?.data?.authentication?.forgotPassword?.responseResult ?? {}
if (respObj.succeeded === true) { if (respObj.succeeded === true) {
this.$store.commit('showNotification', { this.$store.commit('showNotification', {

@ -45,11 +45,14 @@
<w-icon name="la:check" /> <w-icon name="la:check" />
<span>{{ t('profile.authTfaBadge') }}</span> <span>{{ t('profile.authTfaBadge') }}</span>
</w-badge> </w-badge>
<!--
Shaped like the Delete button on a passkey row -- same acrylic tint, drawn in the
brand blue instead of the negative red, which `acrylic-btn` picks up on its own since
it mixes its background out of `currentcolor`.
-->
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
flat flat
dense
round
icon="la:cog" icon="la:cog"
color="primary" color="primary"
:aria-label="t(`profile.authActions`)"> :aria-label="t(`profile.authActions`)">

@ -201,9 +201,9 @@
</w-page> </w-page>
<w-inner-loading :showing="state.loading > 0" /> <w-inner-loading :showing="state.loading > 0" />
</div> </div>
<w-footer><footer-nav /></w-footer>
</w-page-container> </w-page-container>
<main-overlay-dialog /> <main-overlay-dialog />
<w-footer><footer-nav /></w-footer>
</w-layout> </w-layout>
</template> </template>
@ -499,7 +499,15 @@ onUnmounted(() => {
border-radius: 7px; border-radius: 7px;
display: flex; display: flex;
align-items: stretch; 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: 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; background-color: $dark-6;
} }
.w-footer { // -> The `.w-footer .q-bar` rule that used to sit here never matched: FooterNav renders
// FooterNav still renders a q-bar; this goes with it in a later phase. // `.site-footer`, never a q-bar. Its colours live in FooterNav's own scoped style.
.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);
}
}
}
</style> </style>

@ -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

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' 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' import semverGte from 'semver/functions/gte'
export const useAdminStore = defineStore('admin', { export const useAdminStore = defineStore('admin', {
@ -44,16 +45,16 @@ export const useAdminStore = defineStore('admin', {
}, },
async fetchInfo() { async fetchInfo() {
const resp = await API_CLIENT.get('system/info').json() const resp = await API_CLIENT.get('system/info').json()
this.info.groupsTotal = clone(resp?.groupsTotal ?? 0) this.info.groupsTotal = resp?.groupsTotal ?? 0
this.info.tagsTotal = clone(resp?.tagsTotal ?? 0) this.info.tagsTotal = resp?.tagsTotal ?? 0
this.info.usersTotal = clone(resp?.usersTotal ?? 0) this.info.usersTotal = resp?.usersTotal ?? 0
this.info.loginsPastDay = clone(resp?.loginsPastDay ?? 0) this.info.loginsPastDay = resp?.loginsPastDay ?? 0
this.info.currentVersion = clone(resp?.currentVersion ?? 'n/a') this.info.currentVersion = resp?.currentVersion ?? 'n/a'
this.info.latestVersion = clone(resp?.latestVersion ?? 'n/a') this.info.latestVersion = resp?.latestVersion ?? 'n/a'
this.info.isApiEnabled = clone(resp?.isApiEnabled ?? false) this.info.isApiEnabled = resp?.isApiEnabled ?? false
this.info.isMetricsEnabled = clone(resp?.isMetricsEnabled ?? false) this.info.isMetricsEnabled = resp?.isMetricsEnabled ?? false
this.info.isMailConfigured = clone(resp?.isMailConfigured ?? false) this.info.isMailConfigured = resp?.isMailConfigured ?? false
this.info.isSchedulerHealthy = clone(resp?.isSchedulerHealthy ?? false) this.info.isSchedulerHealthy = resp?.isSchedulerHealthy ?? false
}, },
async fetchSites() { async fetchSites() {
this.sites = (await API_CLIENT.get('sites').json()) ?? [] this.sites = (await API_CLIENT.get('sites').json()) ?? []

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { difference } from 'lodash-es' import { difference } from 'es-toolkit/array'
export const useCommonStore = defineStore('common', { export const useCommonStore = defineStore('common', {
state: () => ({ state: () => ({

@ -1,6 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { clone } from 'lodash-es'
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { useSiteStore } from './site' import { useSiteStore } from './site'

@ -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 * Render the time part. `hourCycle` rather than `hour12: false`, which some locales render as 24:00
* where they mean 00:00. * where they mean 00:00.
@ -60,20 +82,6 @@ export const useUserStore = defineStore('user', {
authenticated: false, authenticated: false,
profileLoaded: 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: { actions: {
async refreshProfile() { async refreshProfile() {
try { try {
@ -181,24 +189,23 @@ export const useUserStore = defineStore('user', {
if (!date) { if (!date) {
return '' return ''
} }
let instant = date const zoned = toUserZone(date, this.timezone)
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())
}
return t('common.datetime', { return t('common.datetime', {
date: formatDatePart(zoned, this.dateFormat), date: formatDatePart(zoned, this.dateFormat),
time: formatTimePart(zoned, this.timeFormat) 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)
} }
} }
}) })

@ -1,6 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' 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 fs from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@ -10,7 +11,7 @@ import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const userConfig = mode === 'development' ? { const userConfig = mode === 'development' ? {
dev: { port: 3001, hmrClientPort: 3001 }, 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 { return {
@ -36,14 +37,6 @@ export default defineConfig(({ mode }) => {
}, },
target: 'es2022' target: 'es2022'
}, },
optimizeDeps: {
include: [
'prosemirror-state',
'prosemirror-transform',
'prosemirror-model',
'prosemirror-view'
]
},
plugins: [ plugins: [
vue({ vue({
template: { template: {
@ -76,7 +69,15 @@ export default defineConfig(({ mode }) => {
}, },
resolve: { resolve: {
alias: { 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: { server: {

Loading…
Cancel
Save