mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
fix: meta title missing translation + missing props on duplicate + tags ordering
This commit is contained in:
48 files changed
+309
-121
No files matched your search
@@ -264,7 +264,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import * as monaco from 'monaco-editor'
|
||||
import { renderVersionSource, saveVersionSource, versionContentType } from '@/helpers/pageVersions'
|
||||
import {
|
||||
renderVersionSource,
|
||||
saveVersionSource,
|
||||
versionContentType,
|
||||
versionPageProps
|
||||
} from '@/helpers/pageVersions'
|
||||
|
||||
import { confirm, dialog } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
@@ -627,18 +632,14 @@ function branchFrom(version) {
|
||||
const content = full.content ?? ''
|
||||
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
|
||||
json: {
|
||||
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
|
||||
...versionPageProps(full),
|
||||
path: target.path,
|
||||
title: target.title,
|
||||
locale: pageStore.locale,
|
||||
editor: full.meta?.editor || pageStore.editor,
|
||||
content,
|
||||
render: await renderOf(full),
|
||||
description: full.meta?.description ?? '',
|
||||
icon: full.meta?.icon ?? '',
|
||||
tags: full.meta?.tags ?? [],
|
||||
// -> A version that was scheduled carries dates this new page has not got, and the API
|
||||
// rightly refuses that combination
|
||||
publishState: full.meta?.publishState === 'published' ? 'published' : 'draft',
|
||||
reasonForChange: t('history.branchReason', { date: humanizeDate(full.versionDate) })
|
||||
}
|
||||
}).json()
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
v-if="props.edit"
|
||||
outlined
|
||||
v-model="pageStore.tags"
|
||||
:options="state.tags"
|
||||
:options="sortedTags"
|
||||
dense
|
||||
options-dense
|
||||
use-input
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -87,6 +87,22 @@ const state = reactive({
|
||||
loading: false
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* The suggestions in the order they are offered: alphabetical.
|
||||
*
|
||||
* `GET /sites/:siteId/tags` answers most-used first, and deliberately -- that ordering is what makes
|
||||
* a `limit` mean the tags a wiki is actually about. It is the wrong order to pick from, though: with
|
||||
* a few hundred tags in the list, WSelect narrows it as you type but never reorders it, so scanning
|
||||
* the remaining suggestions meant reading them in an order nothing on screen explains. Sorted here
|
||||
* rather than in the store, because usage order is the answer another caller may want.
|
||||
*
|
||||
* `localeCompare`, not a plain `sort()`, which would order by code unit and scatter every
|
||||
* non-ASCII tag -- tags are page-authored words, in whatever language the wiki is written in.
|
||||
*/
|
||||
const sortedTags = computed(() => [...state.tags].sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
// WATCHERS
|
||||
|
||||
pageStore.$subscribe(() => {
|
||||
|
||||
@@ -84,9 +84,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('welcome.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// METHODS
|
||||
|
||||
|
||||
@@ -36,23 +36,24 @@ function apply() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object|(() => object)} source Either a plain `{ title }` / `{ titleTemplate }` object, or
|
||||
* a getter returning one -- pass a getter when the title depends on reactive state, since a plain
|
||||
* object is read once at call time.
|
||||
* @param {() => object} source A getter returning `{ title }` / `{ titleTemplate }`, re-read
|
||||
* whenever anything it touched changes.
|
||||
*
|
||||
* A getter and not a plain object, because every caller reads reactive state that is not
|
||||
* necessarily there yet: the locale strings are fetched after the app mounts (`App.vue` ->
|
||||
* `applyLocale`) and the site config with them, so a `t()` or a `siteStore.title` evaluated once
|
||||
* during `setup()` resolves to a raw translation key on a page loaded directly, and nothing would
|
||||
* ever come back to correct it. Read inside the effect, the tab title fixes itself the moment
|
||||
* `setLocaleMessage` fills the strings in.
|
||||
*/
|
||||
export function useMeta(source) {
|
||||
const entry = {}
|
||||
stack.push(entry)
|
||||
|
||||
if (typeof source === 'function') {
|
||||
watchEffect(() => {
|
||||
Object.assign(entry, source())
|
||||
apply()
|
||||
})
|
||||
} else {
|
||||
Object.assign(entry, source)
|
||||
watchEffect(() => {
|
||||
Object.assign(entry, source())
|
||||
apply()
|
||||
}
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
const idx = stack.indexOf(entry)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileSave } from 'browser-fs-access'
|
||||
import { omitBy, pick } from 'es-toolkit/object'
|
||||
|
||||
import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
|
||||
@@ -6,8 +7,8 @@ import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
* What a recorded page version is, to the two screens that show one.
|
||||
*
|
||||
* The history overlay and the version view both have to answer the same questions about a version --
|
||||
* what format was it written in, what does its source save as, and what HTML does it become -- and
|
||||
* they used to answer them separately. The format question alone is asked six times between them
|
||||
* what format was it written in, what does its source save as, what HTML does it become, and what
|
||||
* does a new page branched off it start with -- and they used to answer them separately. The format question alone is asked six times between them
|
||||
* (colouring a diff, two renders, two downloads, a restore), so it lives here once.
|
||||
*
|
||||
* Nothing here touches a store or the i18n catalogue, which is what keeps it a helper: a caller
|
||||
@@ -101,3 +102,72 @@ export function renderVersionSource(version, { markdownConfig, pagePath }) {
|
||||
}
|
||||
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
|
||||
}
|
||||
|
||||
/**
|
||||
* A version's page properties, in the shape `POST /pages` takes them.
|
||||
*
|
||||
* What branching from a version needs, and the reason it needs a translation: a version's `meta` is
|
||||
* the stored ROW minus the columns it has of its own (`pageHistory.record`), so the display options
|
||||
* are inside a `config` blob and the per-page scripts inside a `scripts` one, under the names the
|
||||
* columns use. `PageInput` is flat and spells the scripts differently. Both screens that branch were
|
||||
* carrying four fields by hand and dropping the rest.
|
||||
*
|
||||
* Two page properties are deliberately not among them. `alias` is unique across the site, so a
|
||||
* branch carrying the source's would be refused with a 409 nobody asked for; and a version records
|
||||
* `localeGroupId` -- the translation set the page was in -- which is not something a new page joins
|
||||
* by stating it, and whose set already holds a page for this locale anyway. Both are left for the
|
||||
* author to fill in on the new page.
|
||||
*
|
||||
* A key the version has no answer for is left out rather than defaulted here, so that `createPage`
|
||||
* applies its own default -- which is what a version recorded before a field existed should get.
|
||||
*
|
||||
* @param {object} version A version as the API returns one; `content` is not needed.
|
||||
* @returns {object} Page properties, ready to spread into a create payload.
|
||||
*/
|
||||
export function versionPageProps(version) {
|
||||
const meta = version?.meta ?? {}
|
||||
const config = meta.config ?? {}
|
||||
const scripts = meta.scripts ?? {}
|
||||
return omitBy(
|
||||
{
|
||||
description: meta.description,
|
||||
icon: meta.icon,
|
||||
tags: meta.tags,
|
||||
/*
|
||||
Narrowed to a relation's own fields, as `pageLoad` does with the live page's: `relations` is
|
||||
stored as JSON, so anything an older shape left in one would otherwise be written back out.
|
||||
*/
|
||||
relations: (meta.relations ?? []).map((r) =>
|
||||
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
|
||||
),
|
||||
/*
|
||||
The state and its dates travel together or not at all -- a `scheduled` page with neither date
|
||||
is refused, which is why this used to be downgraded to a draft rather than carried.
|
||||
*/
|
||||
publishState: meta.publishState,
|
||||
publishStartDate: meta.publishStartDate,
|
||||
publishEndDate: meta.publishEndDate,
|
||||
isBrowsable: meta.isBrowsable,
|
||||
isSearchable: meta.isSearchable,
|
||||
// -> A branch off a protected page is protected too, rather than being an unlocked copy of a
|
||||
// body somebody chose to put a password on
|
||||
password: meta.password,
|
||||
allowComments: config.allowComments,
|
||||
allowContributions: config.allowContributions,
|
||||
allowRatings: config.allowRatings,
|
||||
showSidebar: config.showSidebar,
|
||||
showTags: config.showTags,
|
||||
showToc: config.showToc,
|
||||
tocDepth: config.tocDepth,
|
||||
/*
|
||||
Writing either one needs a permission (`write:scripts`, `write:styles`), and `buildScripts`
|
||||
drops what the author may not write rather than refusing the page -- so a branch made by
|
||||
somebody without them simply arrives without them.
|
||||
*/
|
||||
scriptJsLoad: scripts.jsLoad,
|
||||
scriptJsUnload: scripts.jsUnload,
|
||||
scriptCss: scripts.css
|
||||
},
|
||||
(v) => v === undefined || v === null
|
||||
)
|
||||
}
|
||||
@@ -186,9 +186,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.api.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -143,9 +143,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.approval.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -290,9 +290,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.audit.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -427,9 +427,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.auth.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// CONSTANTS
|
||||
|
||||
|
||||
@@ -135,9 +135,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.editors.title')
|
||||
})
|
||||
}))
|
||||
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
|
||||
@@ -461,9 +461,9 @@ const versionCard = computed(() => {
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.dashboard.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// METHODS
|
||||
|
||||
|
||||
@@ -116,9 +116,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.editors.title')
|
||||
})
|
||||
}))
|
||||
|
||||
const state = reactive({
|
||||
loading: 0,
|
||||
|
||||
@@ -132,9 +132,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.extensions.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -163,9 +163,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.flags.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -632,9 +632,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.dashboard.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -149,9 +149,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.groups.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// COMPUTED
|
||||
|
||||
|
||||
@@ -266,9 +266,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.icons.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -122,9 +122,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.instances.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -199,9 +199,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.locale.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -263,9 +263,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.login.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -392,9 +392,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.mail.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -113,9 +113,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.mcp.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -268,9 +268,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.metrics.title')
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* The loopback addresses the `local` class is named by, shown beside its label.
|
||||
|
||||
@@ -346,9 +346,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.navigation.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.rendering.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -429,9 +429,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.scheduler.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -126,9 +126,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.search.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -442,9 +442,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.security.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -141,9 +141,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.sites.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// METHODS
|
||||
|
||||
|
||||
@@ -664,9 +664,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.storage.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -296,9 +296,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.system.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -88,9 +88,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.terminal.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -352,9 +352,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.theme.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -198,9 +198,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.users.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// COMPUTED
|
||||
|
||||
|
||||
@@ -257,9 +257,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.utilities.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -148,9 +148,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('admin.webhooks.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('common.error.title')
|
||||
})
|
||||
}))
|
||||
|
||||
// MOUNTED
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('inbox.inbox')
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
@@ -178,9 +178,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('inbox.pendingReview')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -93,9 +93,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('inbox.watching')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('auth.login.title')
|
||||
})
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -157,7 +157,7 @@ import { loading } from '@/composables/loading'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { scrollToAnchor } from '@/helpers/anchors'
|
||||
import { enhanceRenderedContent, resolveContentClick } from '@/helpers/renderedContent'
|
||||
import { renderVersionSource, saveVersionSource } from '@/helpers/pageVersions'
|
||||
import { renderVersionSource, saveVersionSource, versionPageProps } from '@/helpers/pageVersions'
|
||||
import { flattenToc } from '@/helpers/toc'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
@@ -467,6 +467,8 @@ function branchFrom() {
|
||||
try {
|
||||
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
|
||||
json: {
|
||||
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
|
||||
...versionPageProps(version),
|
||||
path: target.path,
|
||||
title: target.title,
|
||||
locale: version.pageLocale,
|
||||
@@ -474,12 +476,6 @@ function branchFrom() {
|
||||
content: version.content ?? '',
|
||||
// -> Rendered for where it is going, not for where the version came from
|
||||
render: await renderFor(version, target.path),
|
||||
description: version.meta?.description ?? '',
|
||||
icon: version.meta?.icon ?? '',
|
||||
tags: version.meta?.tags ?? [],
|
||||
// -> A version that was scheduled carries dates this new page has not got, and the API
|
||||
// rightly refuses that combination
|
||||
publishState: version.meta?.publishState === 'published' ? 'published' : 'draft',
|
||||
reasonForChange: t('history.branchReason', { date: snapshotFrom.value })
|
||||
}
|
||||
}).json()
|
||||
|
||||
@@ -179,9 +179,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('profile.auth')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('profile.avatar')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -37,9 +37,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('profile.groups')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -234,9 +234,9 @@ const { t } = useI18n()
|
||||
|
||||
// META
|
||||
|
||||
useMeta({
|
||||
useMeta(() => ({
|
||||
title: t('profile.myInfo')
|
||||
})
|
||||
}))
|
||||
|
||||
// DATA
|
||||
|
||||
|
||||
@@ -348,7 +348,16 @@ const publishStates = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const tags = computed(() => siteStore.tags.map((t) => t.tag))
|
||||
/**
|
||||
* The tags offered by the filter, alphabetically.
|
||||
*
|
||||
* `GET /sites/:siteId/tags` answers most-used first, which is deliberate on its side but the wrong
|
||||
* order to pick from: WSelect narrows the list as you type and never reorders it, so the remaining
|
||||
* suggestions read in an order nothing on screen explains. `localeCompare` rather than a plain
|
||||
* `sort()`, since a tag is a page-authored word and code-unit order scatters every non-ASCII one.
|
||||
* `PageTags` sorts the same list the same way for the editor's field.
|
||||
*/
|
||||
const tags = computed(() => siteStore.tags.map((t) => t.tag).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
const defaultPageIcon = DEFAULT_PAGE_ICON
|
||||
|
||||
|
||||
+104
-9
@@ -15,6 +15,37 @@ import { useUserStore } from './user'
|
||||
*/
|
||||
export const DEFAULT_PAGE_ICON = 'mdi:file-document-outline'
|
||||
|
||||
/**
|
||||
* The page properties a copy of a page starts with, as `pageDuplicate` reads them off the source.
|
||||
*
|
||||
* Everything the properties panel edits, less the four a copy cannot be given. `title`, `path` and
|
||||
* `locale` are what the author just picked in the dialog, and `description` comes across as an
|
||||
* argument of its own. `alias` is unique across the site, so a copy carrying the source's would be
|
||||
* refused with a 409 nobody asked for. `localeRelations` is a translation set that already holds a
|
||||
* page for this locale -- refused the same way. Both are left for the author to fill in on the copy.
|
||||
*/
|
||||
const DUPLICATED_PAGE_PROPS = [
|
||||
'allowComments',
|
||||
'allowContributions',
|
||||
'allowRatings',
|
||||
'icon',
|
||||
'isBrowsable',
|
||||
'isSearchable',
|
||||
'password',
|
||||
'publishEndDate',
|
||||
'publishStartDate',
|
||||
'publishState',
|
||||
'relations',
|
||||
'scriptCss',
|
||||
'scriptJsLoad',
|
||||
'scriptJsUnload',
|
||||
'showSidebar',
|
||||
'showTags',
|
||||
'showToc',
|
||||
'tags',
|
||||
'tocDepth'
|
||||
]
|
||||
|
||||
export const usePageStore = defineStore('page', {
|
||||
state: () => ({
|
||||
alias: '',
|
||||
@@ -369,6 +400,11 @@ export const usePageStore = defineStore('page', {
|
||||
/**
|
||||
* PAGE - CREATE
|
||||
*/
|
||||
/**
|
||||
* @param props Page properties the new page starts with, over the defaults below -- which is
|
||||
* what duplicating a page carries across from its source. Every key is optional and an absent
|
||||
* one means the default, so creating a blank page passes none of them.
|
||||
*/
|
||||
async pageCreate({
|
||||
editor,
|
||||
locale,
|
||||
@@ -377,6 +413,7 @@ export const usePageStore = defineStore('page', {
|
||||
title = '',
|
||||
description = '',
|
||||
content = '',
|
||||
props = {},
|
||||
fromNavigate = false
|
||||
} = {}) {
|
||||
const editorStore = useEditorStore()
|
||||
@@ -432,14 +469,43 @@ export const usePageStore = defineStore('page', {
|
||||
editor,
|
||||
title: title ?? '',
|
||||
description: description ?? '',
|
||||
icon: DEFAULT_PAGE_ICON,
|
||||
icon: props.icon ?? DEFAULT_PAGE_ICON,
|
||||
// -> Never carried over by a copy: see `DUPLICATED_PAGE_PROPS`
|
||||
alias: '',
|
||||
publishState: 'published',
|
||||
relations: [],
|
||||
publishState: props.publishState ?? 'published',
|
||||
/*
|
||||
Set here alongside the state they belong to rather than left at whatever page the store
|
||||
last held: a `scheduled` page with no dates is refused, and so is a date on a page that is
|
||||
not scheduled, so the three only ever make sense together.
|
||||
*/
|
||||
publishStartDate: props.publishStartDate ?? '',
|
||||
publishEndDate: props.publishEndDate ?? '',
|
||||
relations: props.relations ?? [],
|
||||
// -> A page being created is in no translation set yet, whatever the page it was started from
|
||||
// belonged to
|
||||
// belonged to -- a copy included, whose set already holds a page for this locale
|
||||
localeRelations: [],
|
||||
tags: [],
|
||||
tags: props.tags ?? [],
|
||||
allowComments: props.allowComments ?? false,
|
||||
allowContributions: props.allowContributions ?? true,
|
||||
allowRatings: props.allowRatings ?? true,
|
||||
showSidebar: props.showSidebar ?? true,
|
||||
showTags: props.showTags ?? true,
|
||||
showToc: props.showToc ?? true,
|
||||
tocDepth: props.tocDepth ?? { min: 1, max: 2 },
|
||||
/*
|
||||
Writing either one needs a permission (`write:scripts`, `write:styles`), and the server
|
||||
drops what an author may not write rather than refusing the page -- so a copy made by
|
||||
somebody without them arrives without them, which is the right answer either way.
|
||||
*/
|
||||
scriptJsLoad: props.scriptJsLoad ?? '',
|
||||
scriptJsUnload: props.scriptJsUnload ?? '',
|
||||
scriptCss: props.scriptCss ?? '',
|
||||
/*
|
||||
A copy of a protected page is protected too. The source's password is only in the answer
|
||||
for a requester who may edit it -- and that is the same requester the source's CONTENT is
|
||||
in the answer for, so a copy can never end up holding the body without the lock.
|
||||
*/
|
||||
password: props.password ?? '',
|
||||
content: content ?? '',
|
||||
// -> A page being created has no stored source to lose: whatever it starts with IS the source
|
||||
contentLoaded: true,
|
||||
@@ -451,9 +517,13 @@ export const usePageStore = defineStore('page', {
|
||||
`createPage` in `models/pages.ts` -- so this is the store agreeing with it rather than
|
||||
deciding it. The difference is that browsing is a choice the author can turn back on, in the
|
||||
redirect editor or the properties panel, and searching is not offered at all.
|
||||
|
||||
A copy states the source's answer instead, since both are properties panel fields. That
|
||||
cannot smuggle a searchable redirection in: `createPage` forces `isSearchable` false for
|
||||
one whatever it was sent, so the source's own answer was already false.
|
||||
*/
|
||||
isBrowsable: editor !== 'redirect',
|
||||
isSearchable: editor !== 'redirect',
|
||||
isBrowsable: props.isBrowsable ?? editor !== 'redirect',
|
||||
isSearchable: props.isSearchable ?? editor !== 'redirect',
|
||||
// -> The page being created is very often the one that was missing, and it is not missing now
|
||||
notFound: false,
|
||||
mode: 'edit'
|
||||
@@ -461,6 +531,16 @@ export const usePageStore = defineStore('page', {
|
||||
},
|
||||
/**
|
||||
* PAGE - DUPLICATE
|
||||
*
|
||||
* A copy is the whole page and not just its text: the properties panel is where most of what
|
||||
* makes a page what it is lives -- its tags, its relations, its per-page CSS and scripts, what
|
||||
* its sidebar shows, whether it is browsable, its password -- and a copy that dropped all of it
|
||||
* left the author reproducing the original by hand beside it. Carried across through
|
||||
* `pageCreate`, which is what makes them survive as far as the save: `pageSave` sends every one
|
||||
* of these fields on a create, so seeding the store is all that was ever missing.
|
||||
*
|
||||
* Nothing is written here. What comes back from the dialog is where the copy should go, and the
|
||||
* editor opens on an unsaved page -- so a duplicate nobody saves never existed.
|
||||
*/
|
||||
async pageDuplicate({ sourcePageId, title, path, locale }) {
|
||||
const siteStore = useSiteStore()
|
||||
@@ -472,7 +552,7 @@ export const usePageStore = defineStore('page', {
|
||||
if (!pageData?.id) {
|
||||
throw new Error('ERR_PAGE_NOT_FOUND')
|
||||
}
|
||||
this.pageCreate({
|
||||
await this.pageCreate({
|
||||
editor: pageData.editor,
|
||||
title,
|
||||
path,
|
||||
@@ -480,7 +560,22 @@ export const usePageStore = defineStore('page', {
|
||||
// at the same path, in a locale that does not have it yet
|
||||
locale,
|
||||
content: pageData.content,
|
||||
description: pageData.description
|
||||
description: pageData.description,
|
||||
/*
|
||||
Picked rather than spread: the answer also carries what identifies the SOURCE -- its id,
|
||||
its hash, its author, its dates, the reader's own standing on it -- and none of that
|
||||
describes the page being written.
|
||||
|
||||
`relations` is narrowed to its own fields on the way in, as `pageLoad` does with it: it
|
||||
is the one field of these whose schema is `additionalProperties: true`, so it is the one
|
||||
whose extra keys survive being serialized and would be written back out on the copy.
|
||||
*/
|
||||
props: {
|
||||
...pick(pageData, DUPLICATED_PAGE_PROPS),
|
||||
relations: (pageData.relations ?? []).map((r) =>
|
||||
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
|
||||
Reference in new issue
Block a user