mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
refactor: cleanup old dependencies + upgrade frontend to latest
This commit is contained in:
@@ -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/**"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Wiki.js</title>
|
||||
<!--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" />
|
||||
<style type="text/css">
|
||||
@keyframes initspinner {
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
"node_modules"
|
||||
],
|
||||
"vueCompilerOptions": {
|
||||
"target": 3,
|
||||
"plugins": [
|
||||
"@vue/language-plugin-pug"
|
||||
]
|
||||
"target": 3
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+955
-3988
File diff suppressed because it is too large
Load Diff
+27
-45
@@ -17,34 +17,26 @@
|
||||
"emoji:check": "node scripts/generate-emoji.mjs --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "2.0.1",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tanstack/vue-query": "5.101.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"browser-fs-access": "0.38.0",
|
||||
"clipboard": "2.0.11",
|
||||
"dependency-graph": "1.0.0",
|
||||
"es-toolkit": "1.50.0",
|
||||
"filesize": "11.0.17",
|
||||
"filesize-parser": "1.5.1",
|
||||
"fuse.js": "7.4.2",
|
||||
"filesize": "11.0.22",
|
||||
"filesize-parser": "1.5.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"highlight.js": "11.11.1",
|
||||
"iconify-icon": "3.0.2",
|
||||
"js-cookie": "3.0.8",
|
||||
"jwt-decode": "4.0.0",
|
||||
"ky": "2.0.2",
|
||||
"lodash-es": "4.18.1",
|
||||
"lowlight": "3.3.0",
|
||||
"luxon": "3.7.2",
|
||||
"markdown-it": "14.2.0",
|
||||
"markdown-it": "15.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-emoji": "3.0.0",
|
||||
"markdown-it-emoji": "3.1.0",
|
||||
"markdown-it-expand-tabs": "1.0.13",
|
||||
"markdown-it-footnote": "4.0.0",
|
||||
"markdown-it-imsize": "2.0.1",
|
||||
"markdown-it-mark": "4.0.0",
|
||||
"markdown-it-mdc": "0.2.12",
|
||||
"markdown-it-multimd-table": "4.2.3",
|
||||
@@ -52,31 +44,21 @@
|
||||
"markdown-it-sup": "2.0.0",
|
||||
"markdown-it-task-lists": "2.1.1",
|
||||
"mitt": "3.0.1",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pinia": "3.0.4",
|
||||
"prosemirror-commands": "1.7.1",
|
||||
"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",
|
||||
"monaco-editor": "0.56.0",
|
||||
"pinia": "4.0.2",
|
||||
"semver": "7.8.5",
|
||||
"slugify": "1.6.9",
|
||||
"socket.io-client": "4.8.3",
|
||||
"sortablejs": "1.15.7",
|
||||
"sortablejs-vue3": "1.3.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"temporal-polyfill": "1.0.1",
|
||||
"tippy.js": "6.3.7",
|
||||
"temporal-polyfill": "1.0.2",
|
||||
"twemoji": "14.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"uuid": "14.0.0",
|
||||
"v-network-graph": "0.9.22",
|
||||
"vue": "3.5.38",
|
||||
"vue-i18n": "11.4.5",
|
||||
"vue-router": "5.1.0",
|
||||
"uuid": "14.0.1",
|
||||
"v-network-graph": "0.9.23",
|
||||
"vue": "3.5.40",
|
||||
"vue-i18n": "11.4.8",
|
||||
"vue-router": "5.2.0",
|
||||
"vue3-otp-input": "0.5.40",
|
||||
"vuedraggable": "4.1.0",
|
||||
"zxcvbn": "4.4.2"
|
||||
@@ -84,20 +66,20 @@
|
||||
"devDependencies": {
|
||||
"@iconify-json/la": "1.2.1",
|
||||
"@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",
|
||||
"@vue/language-plugin-pug": "3.3.5",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserlist": "latest",
|
||||
"npm-check-updates": "22.2.3",
|
||||
"oxfmt": "0.54.0",
|
||||
"oxlint": "1.69.0",
|
||||
"sass": "1.101.0",
|
||||
"js-yaml": "5.2.3",
|
||||
"npm-check-updates": "23.0.0",
|
||||
"oxfmt": "0.61.0",
|
||||
"oxlint": "1.76.0",
|
||||
"sass": "1.102.0",
|
||||
"unicode-emoji-json": "0.9.0",
|
||||
"vite": "8.1.5",
|
||||
"vite-plugin-vue-devtools": "8.1.2"
|
||||
"vite": "8.2.0",
|
||||
"vite-plugin-vue-devtools": "8.2.1"
|
||||
},
|
||||
"overrides": {
|
||||
"markdown-it-mdc": {
|
||||
"markdown-it": "$markdown-it"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"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
|
||||
* (`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
|
||||
@@ -103,7 +103,9 @@ export function build() {
|
||||
const sets = Object.fromEntries(
|
||||
SETS.map((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.writeFileSync(OUT, 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,
|
||||
default: ''
|
||||
},
|
||||
dark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
indicator: {
|
||||
type: String,
|
||||
default: null
|
||||
@@ -51,7 +47,6 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
const dark = useDark()
|
||||
@@ -62,10 +57,10 @@ const textMode = computed(() => {
|
||||
return props.text !== null
|
||||
})
|
||||
const avatarBgColor = computed(() => {
|
||||
return dark.isActive || props.dark ? 'dark-4' : 'blue-1'
|
||||
return dark.isActive ? 'dark-4' : 'blue-1'
|
||||
})
|
||||
const avatarTextColor = computed(() => {
|
||||
return dark.isActive || props.dark ? 'white' : 'blue-7'
|
||||
return dark.isActive ? 'white' : 'blue-7'
|
||||
})
|
||||
const indicatorDot = computed(() => {
|
||||
if (props.indicator === null) {
|
||||
|
||||
@@ -64,7 +64,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { computed, onMounted, reactive } from 'vue'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@@ -107,7 +106,7 @@ async function check() {
|
||||
if (resp?.current) {
|
||||
state.current = resp.current
|
||||
state.latest = resp.latest
|
||||
state.latestDate = DateTime.fromISO(resp.latestDate).toFormat(userStore.preferredDateFormat)
|
||||
state.latestDate = userStore.formatDate(resp.latestDate)
|
||||
} else {
|
||||
throw new Error(resp?.message || 'An unexpected error occured.')
|
||||
}
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
<template lang="pug">
|
||||
.loader-generic
|
||||
div
|
||||
<template>
|
||||
<div class="loader-generic">
|
||||
<div />
|
||||
</div>
|
||||
</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">
|
||||
/*
|
||||
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 {
|
||||
box-shadow: none !important;
|
||||
padding-top: 64px;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> div {
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 5px !important;
|
||||
border-radius: 5px;
|
||||
position: relative;
|
||||
|
||||
&: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 {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loader-generic > div:before {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template lang="pug">
|
||||
.status-light(:class='cssClasses')
|
||||
<template>
|
||||
<div class="status-light" :class="cssClasses" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template lang="pug">
|
||||
.treeview
|
||||
tree-level(
|
||||
:depth='0'
|
||||
:parent-id='null'
|
||||
)
|
||||
<template>
|
||||
<div class="treeview">
|
||||
<tree-level :depth="0" :parent-id="null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, provide, reactive, toRef } from 'vue'
|
||||
import { findKey } from 'lodash-es'
|
||||
import { findKey } from 'es-toolkit/object'
|
||||
|
||||
import TreeLevel from './TreeLevel.vue'
|
||||
|
||||
|
||||
@@ -6,8 +6,24 @@
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Bottom bar of a `WLayout`. Sits beside the drawers rather than under them, matching the `lff`
|
||||
* footer row every layout in this app declared.
|
||||
* Bottom bar of a `WLayout`.
|
||||
*
|
||||
* 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({
|
||||
elevated: {
|
||||
@@ -20,5 +36,8 @@ defineProps({
|
||||
<style scoped>
|
||||
.w-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>
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
* `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.
|
||||
*
|
||||
* 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
|
||||
* what the overlay and dialog layouts need.
|
||||
*/
|
||||
|
||||
@@ -24,9 +24,14 @@ defineProps({
|
||||
|
||||
<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
|
||||
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 {
|
||||
min-height: 100%;
|
||||
|
||||
@@ -6,15 +6,42 @@
|
||||
|
||||
<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
|
||||
* 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>
|
||||
|
||||
<style scoped>
|
||||
.w-page-container {
|
||||
grid-area: main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.w-page-container :deep(> :not(.w-footer)) {
|
||||
flex: 1 0 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</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-page-container class="admin-container">
|
||||
<router-view v-slot="{ Component }"><component :is="Component" /></router-view>
|
||||
<w-footer><footer-nav generic /></w-footer>
|
||||
</w-page-container>
|
||||
<w-dialog class="admin-overlay" v-model="overlayIsShown" persistent full-width full-height>
|
||||
<component :is="overlays[adminStore.overlay]" />
|
||||
</w-dialog>
|
||||
<w-footer><footer-nav generic /></w-footer>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -105,6 +105,12 @@
|
||||
</w-bar>
|
||||
</template>
|
||||
</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>
|
||||
<router-view />
|
||||
<!-- -> `.page-container-scrl` is the page view's article column, which is what scrolls -->
|
||||
@@ -117,9 +123,6 @@
|
||||
</w-page-scroller>
|
||||
</w-page-container>
|
||||
<main-overlay-dialog />
|
||||
<w-footer v-if="!editorStore.isActive">
|
||||
<footer-nav />
|
||||
</w-footer>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
<w-footer>
|
||||
<footer-nav />
|
||||
</w-footer>
|
||||
</w-page-container>
|
||||
<main-overlay-dialog />
|
||||
<w-footer>
|
||||
<footer-nav />
|
||||
</w-footer>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 || [])
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
keypath="admin.users.lastLoginAt"
|
||||
tag="div">
|
||||
<template #date>
|
||||
<strong>{{ humanizeDate(props.row.lastLoginAt) }}</strong>
|
||||
<strong>{{ relativeDate(props.row.lastLoginAt) }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</w-td>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
@click="promptUnlock" />
|
||||
</div>
|
||||
<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
|
||||
nothing here to put a handler on, and they are replaced wholesale on every render.
|
||||
@@ -114,6 +114,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
<div
|
||||
@@ -233,6 +243,7 @@ import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import FooterNav from '@/components/FooterNav.vue'
|
||||
import LoadingGeneric from '@/components/LoadingGeneric.vue'
|
||||
import PageActionsCol from '@/components/PageActionsCol.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 {
|
||||
@at-root .body--light & {
|
||||
border-top: 1px solid #fff;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useMeta } from '@/composables/meta'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import { find, has, head, reject, sortBy } from 'lodash-es'
|
||||
import Cookies from 'js-cookie'
|
||||
import AuthLoginPanel from '@/components/AuthLoginPanel.vue'
|
||||
|
||||
@@ -134,7 +133,7 @@ async function login() {
|
||||
strategy: this.selectedStrategy.key
|
||||
}
|
||||
})
|
||||
if (has(resp, 'data.authentication.login')) {
|
||||
if (resp?.data?.authentication?.login) {
|
||||
const respObj = resp?.data?.authentication?.login ?? {}
|
||||
if (respObj.responseResult.succeeded === true) {
|
||||
this.handleLoginResponse(respObj)
|
||||
@@ -208,7 +207,7 @@ async function verifySecurityCode(setup = false) {
|
||||
setup
|
||||
}
|
||||
})
|
||||
if (has(resp, 'data.authentication.loginTFA')) {
|
||||
if (resp?.data?.authentication?.loginTFA) {
|
||||
const respObj = resp?.data?.authentication?.loginTFA ?? {}
|
||||
if (respObj.responseResult.succeeded === true) {
|
||||
this.handleLoginResponse(respObj)
|
||||
@@ -269,7 +268,7 @@ async function changePassword() {
|
||||
newPassword: this.newPassword
|
||||
}
|
||||
})
|
||||
if (has(resp, 'data.authentication.loginChangePassword')) {
|
||||
if (resp?.data?.authentication?.loginChangePassword) {
|
||||
const respObj = resp?.data?.authentication?.loginChangePassword ?? {}
|
||||
if (respObj.responseResult.succeeded === true) {
|
||||
this.handleLoginResponse(respObj)
|
||||
@@ -331,7 +330,7 @@ async function forgotPasswordSubmit() {
|
||||
email: this.username
|
||||
}
|
||||
})
|
||||
if (has(resp, 'data.authentication.forgotPassword.responseResult')) {
|
||||
if (resp?.data?.authentication?.forgotPassword?.responseResult) {
|
||||
const respObj = resp?.data?.authentication?.forgotPassword?.responseResult ?? {}
|
||||
if (respObj.succeeded === true) {
|
||||
this.$store.commit('showNotification', {
|
||||
|
||||
@@ -45,11 +45,14 @@
|
||||
<w-icon name="la:check" />
|
||||
<span>{{ t('profile.authTfaBadge') }}</span>
|
||||
</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
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="la:cog"
|
||||
color="primary"
|
||||
:aria-label="t(`profile.authActions`)">
|
||||
|
||||
@@ -201,9 +201,9 @@
|
||||
</w-page>
|
||||
<w-inner-loading :showing="state.loading > 0" />
|
||||
</div>
|
||||
<w-footer><footer-nav /></w-footer>
|
||||
</w-page-container>
|
||||
<main-overlay-dialog />
|
||||
<w-footer><footer-nav /></w-footer>
|
||||
</w-layout>
|
||||
</template>
|
||||
|
||||
@@ -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.
|
||||
</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 { 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()) ?? []
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { clone } from 'lodash-es'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import { useSiteStore } from './site'
|
||||
|
||||
+35
-28
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+12
-11
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user