feat: handle pasted uploads + option to specify destination + various fixes

This commit is contained in:
NGPixel
2026-08-26 05:08:18 -04:00
parent f537f04ecc
commit 1513e88019
14 changed files with 356 additions and 45 deletions
+20 -5
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { decodeTreePath } from '../helpers/common.ts'
import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts'
import { INLINE_EXTS } from '../models/assets.ts'
const assetIdParam = {
@@ -60,7 +60,7 @@ async function routes(app: FastifyInstance) {
*/
app.post<{
Params: { siteId: string }
Querystring: { fileName: string; folderId?: string; locale?: string }
Querystring: { fileName: string; folderId?: string; folderPath?: string; locale?: string }
}>(
'/sites/:siteId/assets',
{
@@ -94,7 +94,13 @@ async function routes(app: FastifyInstance) {
folderId: {
type: 'string',
format: 'uuid',
description: 'The folder to upload into. The site root when absent.'
description: 'The folder to upload into. Wins over `folderPath`.'
},
folderPath: {
type: 'string',
maxLength: 2048,
description:
'Slash-separated path of the folder to upload into, created if it does not exist. The site root when both this and `folderId` are absent.'
},
locale: {
type: 'string',
@@ -132,11 +138,19 @@ async function routes(app: FastifyInstance) {
return reply.badRequest('No file was sent.')
}
/*
Where this is going, as a path, which is what a rule addresses. An ID has to be looked up to
get one; a path is already one, and is normalized here rather than trusted -- the model would
happily create a folder called `..`.
*/
const folder = req.query.folderId
? await WIKI.models.tree.getFolderById(req.query.folderId)
: null
const folderPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : ''
const destination = folder ? [folderPath, folder.fileName].filter(Boolean).join('/') : ''
const folderPath = req.query.folderId ? null : normalizeFolderPath(req.query.folderPath)
const parentPath = folder ? (decodeTreePath(folder.folderPath ?? '') ?? '') : ''
const destination = folder
? [parentPath, folder.fileName].filter(Boolean).join('/')
: (folderPath ?? '')
if (
!mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName })
) {
@@ -146,6 +160,7 @@ async function routes(app: FastifyInstance) {
siteId: req.params.siteId,
locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en',
folderId: req.query.folderId,
folderPath,
fileName: req.query.fileName,
mimeType: req.headers['content-type'],
data,
+6
View File
@@ -116,6 +116,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
description:
'What an upload does about a file already at the name it wants: replace it in place, refuse the upload, or store the arrival as the next free `name-1.ext`.',
enum: ['overwrite', 'reject', 'new']
},
pastedDestination: {
type: 'string',
maxLength: 2048,
description:
"Where a file pasted or dropped into the editor is filed when the page is saved. Empty is the page's own folder. A relative path is a folder under it — `assets` files them in `<page folder>/assets`. A path starting with `/` is from the site root, so every page's pasted files land in the one place. Missing folders are created on the first upload. Normalized on save: doubled slashes and `.`/`..` segments go, and a leading slash is kept because it is what tells the two apart."
}
}
},
+12 -1
View File
@@ -1,5 +1,5 @@
import { validate as uuidValidate } from 'uuid'
import { CustomError } from '../helpers/common.ts'
import { CustomError, normalizePastedDestination } from '../helpers/common.ts'
import { detectImageMime, detectSvg, imageMimeTypes, svgMimeType } from '../helpers/images.ts'
import { siteAssetKinds } from '../models/sites.ts'
import type { SiteAssetKind } from '../models/sites.ts'
@@ -476,6 +476,17 @@ async function routes(app: FastifyInstance) {
config.features.ratings = config.features.ratingsMode !== 'off'
}
/*
The pasted-uploads destination is stored in one form, so that what the admin area reads back is
what an upload will do with it -- `assets/`, `./assets` and `assets` are the same folder, and
the editor should not have to know that.
*/
if (config.uploads?.pastedDestination !== undefined) {
config.uploads.pastedDestination = normalizePastedDestination(
config.uploads.pastedDestination
)
}
// -> Update site
try {
await WIKI.models.sites.updateSite(req.params.siteId, {
+39
View File
@@ -102,6 +102,45 @@ export function normalizePagePath(input?: string | null): string {
.toLowerCase()
}
/**
* Reduce a folder path to the segments it actually names.
*
* Wrapping and doubled slashes go, and so do `.` and `..` segments — in a wiki tree those name a
* literal folder rather than a relative path, so a caller asking for `../etc` is asking for a folder
* called `etc` and never for somewhere outside the site. Which is what makes this safe to hand a path
* that came from a request.
*
* Case is left alone: `encodeTreePath` lowercases on the way into the database, so the lookup does not
* care, and the tree is what decides what a new folder ends up called.
*/
export function normalizeFolderPath(input?: string | null): string {
return (input ?? '')
.trim()
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..')
.join('/')
}
/**
* Reduce the site's pasted-uploads destination to its stored form.
*
* `normalizeFolderPath` plus the one thing that setting carries which a plain folder path does not: a
* LEADING SLASH, which is what distinguishes a path from the site root from one relative to the page
* being edited. So it survives normalization, and `/` on its own stays `/` — the site root, which is a
* real answer and a different one from empty (the page's own folder).
*/
export function normalizePastedDestination(input?: string | null): string {
const raw = (input ?? '').trim()
if (!raw) {
return ''
}
const normalized = normalizeFolderPath(raw)
if (!raw.startsWith('/')) {
return normalized
}
return `/${normalized}`
}
/**
* Drop a site's page extension from the end of a URL path.
*
+4
View File
@@ -346,6 +346,9 @@
"admin.general.logoUploadSuccess": "Site logo uploaded successfully.",
"admin.general.pageExtensions": "Page Extensions",
"admin.general.pageExtensionsHint": "A comma-separated list of URL extensions that address a page. For example, adding md redirects /foobar.md to /foobar. These extensions are reserved for pages: a file using one cannot be uploaded as an asset.",
"admin.general.pastedDestination": "Pasted Uploads Destination",
"admin.general.pastedDestinationHint": "Where files pasted or dropped into the editor are filed when the page is saved, creating folders as needed. Leave empty for the same folder as the page; a relative path (e.g. assets) is a folder under it, and a path starting with / is from the site root.",
"admin.general.pastedDestinationPlaceholder": "Same folder as the page",
"admin.general.ratingsOff": "Off",
"admin.general.ratingsStars": "Stars",
"admin.general.ratingsThumbs": "Thumbs",
@@ -1825,6 +1828,7 @@
"editor.pageRel.title": "Add Page Relation",
"editor.pageRel.titleEdit": "Edit Page Relation",
"editor.pageScripts.title": "Page Scripts",
"editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.",
"editor.pendingAssetsUploading": "Uploading assets...",
"editor.props.alias": "Alias",
"editor.props.allowComments": "Allow Comments",
+23 -6
View File
@@ -286,7 +286,11 @@ class Assets {
* `UploadConflictBehavior`. An overwrite returns the existing asset's ID, so a caller that means to
* link to what it just uploaded must read the returned name and ID rather than assume its own.
*
* @param folderId UUID of the folder to upload into. The site root when absent.
* @param folderId UUID of the folder to upload into. Takes precedence over `folderPath`.
* @param folderPath Slash-separated path of the folder to upload into, created if it does not exist.
* The site root when both are absent. A caller that knows a path and not an ID --
* the editor uploading what was pasted into a page, which knows the page it is in
* -- addresses the folder this way rather than looking it up first.
* @param fileName What to call it. Sanitized, so what comes back may differ from what went in.
* @param data The file itself.
*/
@@ -294,6 +298,7 @@ class Assets {
siteId,
locale,
folderId,
folderPath,
fileName,
mimeType,
data,
@@ -302,6 +307,7 @@ class Assets {
siteId: string
locale: string
folderId?: string | null
folderPath?: string | null
fileName: string
mimeType?: string | null
data: Buffer
@@ -312,7 +318,14 @@ class Assets {
throw new CustomError('assetInvalidFileName', 'This file name cannot be used.')
}
const fileExt = extensionOf(safeName)
await this.guardAgainstPageCollision({ siteId, locale, folderId, fileName: safeName, fileExt })
await this.guardAgainstPageCollision({
siteId,
locale,
folderId,
folderPath,
fileName: safeName,
fileExt
})
// -> The extension decides the type, not the request: the declared one is whatever the client felt
// like sending, and this value is what gets served back to a browser later
const resolvedMime = mime.getType(safeName) ?? mimeType ?? 'application/octet-stream'
@@ -333,6 +346,7 @@ class Assets {
siteId,
locale,
parentId: folderId,
parentPath: folderPath,
fileName: safeName
})
if (occupant) {
@@ -373,6 +387,7 @@ class Assets {
// that was actually free, which is not always the one asked for.
const entry = await WIKI.models.tree.addAsset({
parentId: folderId,
parentPath: folderPath,
fileName: safeName,
title: safeName,
locale,
@@ -384,7 +399,9 @@ class Assets {
}
})
const storedName = entry.fileName
const folderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
// -> Read off the row rather than from the request: the folder may have just been created, and a
// name that was taken took the next free one
const storedFolderPath = decodeTreePath(entry.folderPath ?? '') ?? ''
try {
// -> The metadata row goes in before the bytes, since the database target writes them into it
@@ -405,7 +422,7 @@ class Assets {
siteId,
actorId: authorId,
locale,
folderPath,
folderPath: storedFolderPath,
fileName: storedName,
kind,
fileSize: data.length
@@ -422,7 +439,7 @@ class Assets {
WIKI.models.hooks.emit('asset:upload', {
id: entry.id,
fileName: storedName,
folderPath,
folderPath: storedFolderPath,
siteId,
authorId,
metadata: { fileSize: data.length, mimeType: resolvedMime, kind }
@@ -435,7 +452,7 @@ class Assets {
kind,
mimeType: resolvedMime,
fileSize: data.length,
folderPath,
folderPath: storedFolderPath,
locale,
title: entry.title,
hasPreview: Boolean(preview),
+4 -2
View File
@@ -192,7 +192,8 @@ class Sites {
}
},
uploads: {
conflictBehavior: 'overwrite'
conflictBehavior: 'overwrite',
pastedDestination: ''
},
storage: {
largeThreshold: '25MB',
@@ -455,7 +456,8 @@ class Sites {
contentFont: 'roboto'
},
uploads: {
conflictBehavior: 'overwrite'
conflictBehavior: 'overwrite',
pastedDestination: ''
},
storage: {
largeThreshold: '25MB',
+67 -15
View File
@@ -1294,8 +1294,23 @@ function processContent(newContent) {
* An image goes in as one, anything else as a link with its file name for text — a dropped PDF is a
* link to a PDF, not a broken picture. The name is the image's alt text as well, which is both what the
* handler this replaces did and better than nothing for a reader who cannot see it.
*
* Except while suggesting an edit, where files are refused outright. A pending asset is uploaded when
* the page is SAVED, and submitting a suggestion is not a save — nothing would ever send these, so the
* markdown would keep a `blob:` URL that dies with the tab. Nor is that only plumbing: somebody
* suggesting an edit is by definition somebody without write access to this page, and filing their
* files into the wiki beside it is not a decision this flow gets to make. Carrying an attachment on a
* suggestion is a feature, and until there is one, the refusal is said out loud — the paste has already
* been taken off the browser by the time this runs, so a silent return is a paste that vanished.
*/
function insertFilesAsAssets(files) {
if (editorStore.mode === 'suggest') {
notify({
type: 'warning',
message: t('editor.pendingAssetsNotInSuggestions')
})
return
}
const markup = files.map((file) => {
const blobUrl = editorStore.addPendingAsset(file)
return `${file.type.startsWith('image/') ? '!' : ''}[${file.name}](${blobUrl})`
@@ -1359,6 +1374,26 @@ function onEditorDrop(event) {
insertFilesAsAssets([...event.dataTransfer.files])
}
/**
* The editor's model onto the page store: the source a save sends, and the render made from it.
*
* Debounced because it renders the whole document on every keystroke, and NAMED so that it can also be
* flushed — see `reloadEditorContent`, which needs it to have happened before it returns rather than
* half a second later.
*/
const syncContentToStore = debounce(() => {
editorStore.$patch({
lastChangeTimestamp: Temporal.Now.instant()
})
pageStore.$patch({
content: editor.getValue(),
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
// the guard in `pageSave`
contentLoaded: true
})
processContent(pageStore.content)
}, 500)
/**
* Rewrite text that was already in the editor — the blob URLs of pending assets, once the upload has
* given them real paths.
@@ -1380,6 +1415,15 @@ function reloadEditorContent({ replacements = [] } = {}) {
}
if (edits.length > 0) {
editor.executeEdits('assets', edits)
/*
And the store follows the model NOW, rather than when the debounce would have got to it.
This runs from `UploadPendingAssetsDialog`, immediately before the page is saved. Left to the
timer, the sync would land after that save -- so the page would go up with a render still full of
`blob:` URLs, and then be marked dirty half a second later by the very edit that fixed it,
needing a second save to publish. Flushing here is what makes one save enough.
*/
syncContentToStore.flush()
}
}
@@ -1546,29 +1590,29 @@ onMounted(async () => {
}
})
/*
Ctrl/Cmd+S, asking for the header's Save button rather than saving anything itself. What that
button does is the header's to know -- which of the three it currently is, whether the
reason-for-change dialog has to be answered first, and that it is disabled with nothing pending --
so the shortcut goes through the event bus instead of reaching for `pageSave` and getting a
different save from the one on screen.
A Monaco action rather than a listener because that is what stops the browser offering to save the
page as a file: Monaco takes the keystroke off the event once a keybinding resolves. It has been
registered here, doing nothing, for exactly that reason.
*/
editor.addAction({
id: 'save',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
label: 'Save',
precondition: '',
run(ed) {}
run(ed) {
EVENT_BUS.emit('savePage')
}
})
// -> Handle content change
editor.onDidChangeModelContent(
debounce((ev) => {
editorStore.$patch({
lastChangeTimestamp: Temporal.Now.instant()
})
pageStore.$patch({
content: editor.getValue(),
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
// the guard in `pageSave`
contentLoaded: true
})
processContent(pageStore.content)
}, 500)
)
editor.onDidChangeModelContent(syncContentToStore)
// -> Handle cursor movement
editor.onDidChangeCursorPosition(
@@ -1728,6 +1772,14 @@ onBeforeUnmount(() => {
// -> Before the editor goes: the binding is holding the model, and leaving the room is what takes
// this author's avatar out of everyone else's header
stopCollabSession()
/*
Anything pasted but never uploaded goes with the session that held it. This hook is where an
editing session ends, whichever way it ended -- discarded, closed, submitted as a suggestion, or
walked away from by following a link -- because the editor is mounted exactly while
`editorStore.isActive` holds (see `pages/Index.vue`). A save has already emptied this by the time
it gets here: the upload runs before the page goes up, not after.
*/
editorStore.clearPendingAssets()
if (editor) {
editor.dispose()
}
+76 -9
View File
@@ -328,7 +328,16 @@
</template>
<script setup>
import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
import {
computed,
defineAsyncComponent,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch
} from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -429,7 +438,12 @@ const state = reactive({
* that watching a page again a minute later rings it again — a class left on plays once and never
* plays a second time.
*/
bellRinging: false
bellRinging: false,
/**
* Whether a save is on the wire. Read only by `savePageFromShortcut`, where the keystroke can repeat
* faster than the request comes back; see the note there.
*/
isSaving: false
})
// REFS
@@ -464,8 +478,53 @@ watch(
(description) => syncEditable(descriptionEl.value, description)
)
// LIFECYCLE
/*
Ctrl+S in an editor, emitted by whichever editor is open (`EditorMarkdown.vue`). The listener lives
here because the buttons do.
*/
onMounted(() => {
EVENT_BUS.on('savePage', savePageFromShortcut)
})
onBeforeUnmount(() => {
EVENT_BUS.off('savePage', savePageFromShortcut)
})
// METHODS
/**
* The keyboard asking for the Save button.
*
* Which button that is depends on the mode -- a suggestion is submitted, a page that has no row yet is
* created through the save-as dialog, everything else is saved in place -- so this dispatches the same
* three ways the template does, and stays disabled where they are. A shortcut that saved a page the
* button in front of the author could not would be a different action wearing its name.
*/
function savePageFromShortcut() {
if (isSuggesting.value) {
if (editorStore.hasPendingChanges) {
submitSuggestion()
}
return
}
if (editorStore.mode === 'create') {
// -> No pending-changes guard, matching its button: a page being created has nothing to compare to
createPage()
return
}
/*
`state.isSaving` is only ever consulted here. A button cannot be pressed thirty times a second, but
Ctrl+S held down auto-repeats, and every repeat that landed before the answer came back would write
another version into the page's history. The two branches above need no such guard: both open a
dialog, which takes the focus off the editor and ends the repeat.
*/
if (editorStore.hasPendingChanges && !state.isSaving) {
saveChanges(false)
}
}
/**
* Put a value into a contenteditable without disturbing a caret that is already in it.
*
@@ -612,6 +671,7 @@ async function saveChanges(closeAfter = false) {
async function saveChangesCommit(closeAfter = false) {
await processPendingAssets()
loading.show()
state.isSaving = true
try {
await pageStore.pageSave()
notify({
@@ -642,6 +702,8 @@ async function saveChangesCommit(closeAfter = false) {
message: 'Failed to save page changes.',
caption: err.message
})
} finally {
state.isSaving = false
}
loading.hide()
}
@@ -685,17 +747,22 @@ async function createPage() {
locale: pageStore.locale
}
}).onOk(async ({ path, title, locale }) => {
pageStore.$patch({
title,
path,
// -> The dialog is where the locale is settled for a page that has none yet, so what it
// hands back is what the page is written in
locale
})
/*
After the patch above, and before the save: pending assets are uploaded into the folder the page
is in, and until this dialog answered, the page was not anywhere yet. Uploading first would have
filed everything pasted into a new page against wherever the editor was opened from.
*/
await processPendingAssets()
loading.show()
try {
pageStore.$patch({
title,
path,
// -> The dialog is where the locale is settled for a page that has none yet, so what it
// hands back is what the page is written in
locale
})
await pageStore.pageSave()
notify({
type: 'positive',
@@ -27,7 +27,7 @@ import { useEditorStore } from '@/stores/editor'
import { useSiteStore } from '@/stores/site'
import { usePageStore } from '@/stores/page'
import { apiErrorMessage } from '@/helpers/apiError'
import { assetPath } from '@/helpers/assets'
import { assetPath, pastedAssetFolder } from '@/helpers/assets'
// EMITS
@@ -69,15 +69,30 @@ onMounted(async () => {
*/
const replacements = []
// -> Read once, not per file: every file in one save goes to the same place, and the page cannot
// move underneath this
const folderPath = pastedAssetFolder(siteStore.uploads.pastedDestination, pageStore.folderPath)
try {
for (const item of editorStore.pendingAssets) {
state.current++
// -> The body is the file itself rather than a multipart form, and the locale is left to the
// server, which uses the site's primary one
/*
The body is the file itself rather than a multipart form.
Addressed by path because a path is what the editor knows, and any folder in it that does not
exist yet is created by the upload. Where that path leads is the site's to say -- see
`pastedAssetFolder` -- and it defaults to the page's own folder. The site root is where these
used to land regardless, which put every screenshot anybody ever pasted in one flat list beside
the site's top-level pages.
The locale is the page's, not the site's default: a folder belongs to one locale, so a file
for the French page has to be filed in the French tree or it is not in the same folder at all.
*/
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, {
searchParams: {
fileName: item.fileName
// TODO: Upload to page specific folder
fileName: item.fileName,
locale: pageStore.locale,
...(folderPath ? { folderPath } : {})
},
headers: {
'content-type': item.file.type || 'application/octet-stream'
+31
View File
@@ -21,6 +21,37 @@ export function assetPath(folderPath, fileName) {
return folderPath ? `/${folderPath}/${fileName}` : `/${fileName}`
}
/**
* Which folder a file pasted or dropped into the editor is filed in.
*
* The site's `uploads.pastedDestination` decides, and it has three forms — see the setting in the admin
* area's General section:
*
* - **empty**: the page's own folder, so a screenshot sits beside the page that shows it.
* - **relative** (`assets`): a folder under the page's own, per page.
* - **absolute** (`/media/uploads`): from the site root, so every page's pasted files land together.
*
* The leading slash is the whole of what separates the last two, which is why the setting keeps one.
* `/` alone is therefore the site root, and is a different answer from empty.
*
* Nothing here checks that the folder exists: the upload creates what it needs. `.` and `..` segments
* are dropped rather than followed — in a wiki tree they name a literal folder, never a parent — which
* matches what the upload route does with whatever it is sent.
*
* @param {string} destination The site's `uploads.pastedDestination`.
* @param {string} pageFolderPath The folder the page being edited is in, empty at the site root.
* @returns {string} A folder path from the site root, empty for the root itself.
*/
export function pastedAssetFolder(destination, pageFolderPath) {
const configured = (destination ?? '').trim()
const base = configured.startsWith('/') ? '' : (pageFolderPath ?? '')
return [base, configured]
.join('/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..')
.join('/')
}
/** Where uploaded files are served from — `backend/controllers/files.ts`. */
export const FILES_PREFIX = '/_files/'
+20 -2
View File
@@ -5,7 +5,9 @@
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-web.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 admin-page-title animated fadeInLeft">{{ t('admin.general.title') }}</div>
<div class="text-h5 admin-page-title animated fadeInLeft">
{{ t('admin.general.title') }}
</div>
<div class="text-subtitle1 text-grey animated fadeInLeft wait-p2s">
{{ t('admin.general.subtitle') }}
</div>
@@ -512,6 +514,21 @@
:aria-label="t(`admin.general.uploadConflictBehavior`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="opened-folder" />
<w-item-section>
<w-item-label>{{ t(`admin.general.pastedDestination`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.general.pastedDestinationHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
outlined
v-model="state.config.uploads.pastedDestination"
dense
:placeholder="t(`admin.general.pastedDestinationPlaceholder`)"
:aria-label="t(`admin.general.pastedDestination`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- URL Handling -->
@@ -766,7 +783,8 @@ async function save() {
logoText: state.config.logoText ?? false,
sitemap: state.config.sitemap ?? false,
uploads: {
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite'
conflictBehavior: state.config.uploads?.conflictBehavior ?? 'overwrite',
pastedDestination: state.config.uploads?.pastedDestination ?? ''
},
robots: {
index: state.config.robots?.index ?? false,
+22
View File
@@ -65,6 +65,28 @@ export const useEditorStore = defineStore('editor', {
}
return blobUrl
},
/**
* Drop every pending asset without uploading any of them.
*
* What the end of an editing session that did not save means for these. A pending asset becomes a
* file only when the page is SAVED, so once the editor is gone nothing is ever going to send them,
* and nothing points at them either -- the markdown that did went with the draft that was
* discarded.
*
* Left in place they outlive the editor that made them and are uploaded by the next save instead,
* which is very often another page: a file filed into that page's folder that nothing references,
* and one nobody chose to upload. `pendingAssets` is not per page, and the rail that lists them is
* only drawn while an editor is open, so a leftover is also invisible until it lands.
*
* Revokes the URLs on the way out, since the browser holds the bytes behind each one until it is
* told it can let go.
*/
clearPendingAssets () {
for (const item of this.pendingAssets) {
URL.revokeObjectURL(item.blobUrl)
}
this.pendingAssets = []
},
async fetchConfigs () {
const siteStore = useSiteStore()
try {
+12
View File
@@ -95,6 +95,14 @@ export const useSiteStore = defineStore('site', {
reasonForChange: 'required',
search: false
},
/**
* What this site does with uploads. Set in the admin area's General section; only the parts the
* app itself acts on are carried here, which is where a pasted file goes -- the conflict behavior
* is the server's business alone.
*/
uploads: {
pastedDestination: ''
},
/** How this site handles signing in. Set in the admin area's Login section. */
auth: {
/**
@@ -285,6 +293,10 @@ export const useSiteStore = defineStore('site', {
...this.auth,
...siteInfo.auth
},
uploads: {
...this.uploads,
...siteInfo.uploads
},
editors: {
asciidoc: siteInfo.editors.asciidoc.isActive,
markdown: siteInfo.editors.markdown.isActive,