mirror of
https://github.com/requarks/wiki.git
synced 2026-10-26 06:00:04 -05:00
feat: drag and drop move in File Manager
This commit is contained in:
10 files changed
+583
-86
No files matched your search
@@ -3261,6 +3261,12 @@
|
||||
"fileman.detailsTitle": "Title",
|
||||
"fileman.dmgFileType": "Apple Disk Image File",
|
||||
"fileman.docxFileType": "Microsoft Word Document",
|
||||
"fileman.dropMove.confirm": "Move {name} into {destination}?",
|
||||
"fileman.dropMove.failed": "Failed to move the item.",
|
||||
"fileman.dropMove.folderHint": "Every page and file under it moves with it.",
|
||||
"fileman.dropMove.pageSuccess": "Page moved successfully.",
|
||||
"fileman.dropMove.root": "the root folder",
|
||||
"fileman.dropMove.title": "Move Item",
|
||||
"fileman.epsFileType": "EPS Image",
|
||||
"fileman.excalidrawPageType": "Drawing",
|
||||
"fileman.exeFileType": "Windows Executable",
|
||||
|
||||
Generated
+22
-22
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "wiki-backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.0-dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiki-backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.0-dev",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1155.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1155.0",
|
||||
"@aws-sdk/client-s3": "3.1156.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1156.0",
|
||||
"@azure/identity": "4.14.0",
|
||||
"@azure/storage-blob": "12.34.0",
|
||||
"@fastify/compress": "9.2.2",
|
||||
@@ -40,10 +40,10 @@
|
||||
"diff": "9.0.0",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"emittery": "2.2.0",
|
||||
"es-toolkit": "1.52.0",
|
||||
"es-toolkit": "1.53.0",
|
||||
"fastify": "5.12.7",
|
||||
"fastify-favicon": "5.0.0",
|
||||
"filesize": "11.0.28",
|
||||
"filesize": "11.0.29",
|
||||
"fs-extra": "11.4.1",
|
||||
"js-yaml": "5.5.0",
|
||||
"ldapts": "9.2.0",
|
||||
@@ -51,7 +51,7 @@
|
||||
"mime": "4.1.1",
|
||||
"nanoid": "6.0.2",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "10.2.0",
|
||||
"nodemailer": "10.2.1",
|
||||
"openid-client": "6.8.8",
|
||||
"pg": "8.23.1",
|
||||
"poolifier": "6.0.2",
|
||||
@@ -120,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1155.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1155.0.tgz",
|
||||
"integrity": "sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==",
|
||||
"version": "3.1156.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1156.0.tgz",
|
||||
"integrity": "sha512-Z2PqQqKMDVX56tNZ6ggmjoI5o/8+4+8ArmjLwE5ktYsEzs1BrvklBT1ZzI+s0Vcf3wfPguoEMD7EkMswmEta/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1001.5",
|
||||
@@ -345,9 +345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||
"version": "3.1155.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1155.0.tgz",
|
||||
"integrity": "sha512-t0AlTvpL6bqCGlN2avesGtNNZ/qssSJgIZgIMEoQan0dH8k64k8iK5jDWOGFAjTs4G+ALvXjjkNl5ITxhYGpxw==",
|
||||
"version": "3.1156.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1156.0.tgz",
|
||||
"integrity": "sha512-M4yj4HmAPydhIMxKK90eTcnHr5FHBl000d2oFMAWpt+XPX86c2kmNJpiYbKrPLIe8jyK9sweRt5CDdWrhq/QTg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.5",
|
||||
@@ -5346,9 +5346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz",
|
||||
"integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
@@ -5675,9 +5675,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filesize": {
|
||||
"version": "11.0.28",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.28.tgz",
|
||||
"integrity": "sha512-tsZcwUmhL0WQpsxe9Oua6FBjb0aBTRkXobZBhQdkoII0pYgy9h+cXm5tp13Fsr8dSwp1+UVDxNwAFULDpxkXaw==",
|
||||
"version": "11.0.29",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.29.tgz",
|
||||
"integrity": "sha512-+Mkmp8keoklLzAnvP/RQGDUEWEpjGmhvm4d4U739C1ZnkqxqoSqdLHnjv9/4QX6lltKSRk6qzQI7+94JVDtuCA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 10.8.0"
|
||||
@@ -6651,9 +6651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
|
||||
"integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
|
||||
"integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"db-up": "drizzle-kit up --dialect=postgresql --out=./db/migrations"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1155.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1155.0",
|
||||
"@aws-sdk/client-s3": "3.1156.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.1156.0",
|
||||
"@azure/identity": "4.14.0",
|
||||
"@azure/storage-blob": "12.34.0",
|
||||
"@fastify/compress": "9.2.2",
|
||||
@@ -66,10 +66,10 @@
|
||||
"diff": "9.0.0",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"emittery": "2.2.0",
|
||||
"es-toolkit": "1.52.0",
|
||||
"es-toolkit": "1.53.0",
|
||||
"fastify": "5.12.7",
|
||||
"fastify-favicon": "5.0.0",
|
||||
"filesize": "11.0.28",
|
||||
"filesize": "11.0.29",
|
||||
"fs-extra": "11.4.1",
|
||||
"js-yaml": "5.5.0",
|
||||
"ldapts": "9.2.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"mime": "4.1.1",
|
||||
"nanoid": "6.0.2",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "10.2.0",
|
||||
"nodemailer": "10.2.1",
|
||||
"openid-client": "6.8.8",
|
||||
"pg": "8.23.1",
|
||||
"poolifier": "6.0.2",
|
||||
|
||||
Generated
+20
-20
@@ -24,8 +24,8 @@
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-transition": "3.0.1",
|
||||
"d3-zoom": "3.0.0",
|
||||
"es-toolkit": "1.52.0",
|
||||
"filesize": "11.0.28",
|
||||
"es-toolkit": "1.53.0",
|
||||
"filesize": "11.0.29",
|
||||
"filesize-parser": "1.5.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"highlight.js": "11.12.0",
|
||||
@@ -53,13 +53,13 @@
|
||||
"prosemirror-history": "1.5.1",
|
||||
"prosemirror-inputrules": "1.5.1",
|
||||
"prosemirror-keymap": "1.2.3",
|
||||
"prosemirror-markdown": "1.13.8",
|
||||
"prosemirror-markdown": "1.13.9",
|
||||
"prosemirror-model": "1.25.12",
|
||||
"prosemirror-schema-list": "1.5.1",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-tables": "1.8.5",
|
||||
"prosemirror-transform": "1.12.2",
|
||||
"prosemirror-view": "1.42.6",
|
||||
"prosemirror-transform": "1.12.4",
|
||||
"prosemirror-view": "1.42.8",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"semver": "7.8.5",
|
||||
@@ -5099,9 +5099,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
|
||||
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
|
||||
"version": "1.53.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz",
|
||||
"integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
@@ -5160,9 +5160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/filesize": {
|
||||
"version": "11.0.28",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.28.tgz",
|
||||
"integrity": "sha512-tsZcwUmhL0WQpsxe9Oua6FBjb0aBTRkXobZBhQdkoII0pYgy9h+cXm5tp13Fsr8dSwp1+UVDxNwAFULDpxkXaw==",
|
||||
"version": "11.0.29",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.29.tgz",
|
||||
"integrity": "sha512-+Mkmp8keoklLzAnvP/RQGDUEWEpjGmhvm4d4U739C1ZnkqxqoSqdLHnjv9/4QX6lltKSRk6qzQI7+94JVDtuCA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 10.8.0"
|
||||
@@ -6743,9 +6743,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz",
|
||||
"integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==",
|
||||
"version": "1.13.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.9.tgz",
|
||||
"integrity": "sha512-qAI3I9YdtjWHjTCBErc08ivr24KSrUjcTh5dYncCxFM+JS02kT0iHeWYjO35xeTPwt5t8rhFrelewXhXz16WZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
@@ -6792,18 +6792,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||
"version": "1.12.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.4.tgz",
|
||||
"integrity": "sha512-2/WuPum3pn+O/Vi2vzpi8210ICUe2kIPXdLYjQ09Pv5DtT5bcKWMJhAKPBwvpPjpp5iOYUVwVEr0gOM1BY1Mgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.6",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||
"version": "1.42.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.8.tgz",
|
||||
"integrity": "sha512-ftDZjCs9AjAekXmCfJRn2U3IdlRCl0Yc4tZT1/jJWCsz4DiSifEBXtWmpJpw/4f1Rg328uAUQ+u2lCJ0+AVZkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.12",
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"d3-shape": "3.2.0",
|
||||
"d3-transition": "3.0.1",
|
||||
"d3-zoom": "3.0.0",
|
||||
"es-toolkit": "1.52.0",
|
||||
"filesize": "11.0.28",
|
||||
"es-toolkit": "1.53.0",
|
||||
"filesize": "11.0.29",
|
||||
"filesize-parser": "1.5.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"highlight.js": "11.12.0",
|
||||
@@ -62,13 +62,13 @@
|
||||
"prosemirror-history": "1.5.1",
|
||||
"prosemirror-inputrules": "1.5.1",
|
||||
"prosemirror-keymap": "1.2.3",
|
||||
"prosemirror-markdown": "1.13.8",
|
||||
"prosemirror-markdown": "1.13.9",
|
||||
"prosemirror-model": "1.25.12",
|
||||
"prosemirror-schema-list": "1.5.1",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-tables": "1.8.5",
|
||||
"prosemirror-transform": "1.12.2",
|
||||
"prosemirror-view": "1.42.6",
|
||||
"prosemirror-transform": "1.12.4",
|
||||
"prosemirror-view": "1.42.8",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1",
|
||||
"semver": "7.8.5",
|
||||
|
||||
@@ -130,7 +130,10 @@
|
||||
@lazy-load="treeLazyLoad"
|
||||
:use-lazy-load="true"
|
||||
@context-action="treeContextAction"
|
||||
:display-mode="state.displayMode" />
|
||||
:display-mode="state.displayMode"
|
||||
:drag-active="state.dragItem !== null"
|
||||
:can-drop="canDropOn"
|
||||
@drop-item="dropOnFolder" />
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
<!--
|
||||
@@ -550,6 +553,10 @@
|
||||
<img src="/_assets/icons/carbon-copy-empty-box.svg" />
|
||||
<span>This folder is empty.</span>
|
||||
</div>
|
||||
<!--
|
||||
Every row can be dragged onto a folder -- a folder row here, or any folder in the tree,
|
||||
root included -- to move it there. Only folder rows take a drop.
|
||||
-->
|
||||
<w-list class="fileman-filelist" v-else :class="state.isCompact && `is-compact`">
|
||||
<w-item
|
||||
v-for="item of files"
|
||||
@@ -557,8 +564,16 @@
|
||||
clickable
|
||||
active-class="active"
|
||||
:active="item.id === state.currentFileId"
|
||||
:class="{ 'is-drop-target': item.id === state.dropFolderId }"
|
||||
draggable="true"
|
||||
@click="selectItem(item)"
|
||||
@dblclick="doubleClickItem(item)">
|
||||
@dblclick="doubleClickItem(item)"
|
||||
@dragstart="onItemDragStart($event, item)"
|
||||
@dragend="onItemDragEnd"
|
||||
@dragenter="onListDragOver($event, item)"
|
||||
@dragover="onListDragOver($event, item)"
|
||||
@dragleave="onListDragLeave($event, item)"
|
||||
@drop="onListDrop($event, item)">
|
||||
<w-item-section class="fileman-filelist-icon" avatar>
|
||||
<w-icon
|
||||
:name="item.icon"
|
||||
@@ -945,7 +960,11 @@ const state = reactive({
|
||||
* Whether the graph view covers the manager. Independent of `isRecycleBin`: it goes over whichever
|
||||
* of the two was showing, and closing it comes back to that.
|
||||
*/
|
||||
isGraph: false
|
||||
isGraph: false,
|
||||
/** The listing entry being dragged, from its `dragstart` to its `dragend`. */
|
||||
dragItem: null,
|
||||
/** The folder row in the listing a drag would drop into, which is what lights it up. */
|
||||
dropFolderId: null
|
||||
})
|
||||
|
||||
// -> Over the defaults just above, which is what the view falls back to on a first visit
|
||||
@@ -1638,18 +1657,10 @@ function moveFolder(folderId) {
|
||||
caption: t('fileman.folderMovingHint')
|
||||
})
|
||||
try {
|
||||
const resp = await API_CLIENT.put(`sites/${siteStore.id}/tree/folders/${folderId}/path`, {
|
||||
json: {
|
||||
folderPath: opts.folderPath,
|
||||
locale: opts.locale
|
||||
},
|
||||
// -> Nothing on this side gives up on it, since the server does not either
|
||||
timeout: false
|
||||
}).json()
|
||||
// -> The API client does not throw on 400, so a refused destination comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
const resp = await requestFolderMove(folderId, {
|
||||
folderPath: opts.folderPath,
|
||||
locale: opts.locale
|
||||
})
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: resp.message
|
||||
@@ -1669,6 +1680,25 @@ function moveFolder(folderId) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The request behind every folder move, answering with the folder where it now is.
|
||||
*
|
||||
* @param folderPath The new parent's path, empty for the root.
|
||||
* @param locale The locale to move it to; it stays in its own when absent.
|
||||
*/
|
||||
async function requestFolderMove(folderId, { folderPath, locale }) {
|
||||
const resp = await API_CLIENT.put(`sites/${siteStore.id}/tree/folders/${folderId}/path`, {
|
||||
json: { folderPath, locale },
|
||||
// -> Nothing on this side gives up on it, since the server does not either
|
||||
timeout: false
|
||||
}).json()
|
||||
// -> The API client does not throw on 400, so a refused destination comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour a folder's icon.
|
||||
*
|
||||
@@ -1986,19 +2016,13 @@ function renameMoveAsset(item) {
|
||||
}
|
||||
}).onOk(async (opts) => {
|
||||
try {
|
||||
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${item.id}`, {
|
||||
json: {
|
||||
fileName: opts.fileName,
|
||||
folderPath: opts.folderPath,
|
||||
// -> The destination is a locale as well as a folder, as it is for a page: the same folder
|
||||
// in another locale is somewhere else
|
||||
locale: opts.locale
|
||||
}
|
||||
}).json()
|
||||
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
const resp = await requestAssetMove(item.id, {
|
||||
fileName: opts.fileName,
|
||||
folderPath: opts.folderPath,
|
||||
// -> The destination is a locale as well as a folder, as it is for a page: the same folder
|
||||
// in another locale is somewhere else
|
||||
locale: opts.locale
|
||||
})
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: resp.message
|
||||
@@ -2015,6 +2039,21 @@ function renameMoveAsset(item) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The request behind every file rename and move. Each of the three is optional, and one left out
|
||||
* stays as it is.
|
||||
*/
|
||||
async function requestAssetMove(assetId, { fileName, folderPath, locale }) {
|
||||
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${assetId}`, {
|
||||
json: { fileName, folderPath, locale }
|
||||
}).json()
|
||||
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an image, over itself or into a new file beside it. Either way the folder is listed again:
|
||||
* a new file has to appear in it, and a replaced one has a new size and thumbnail to show.
|
||||
@@ -2363,6 +2402,211 @@ function delItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------
|
||||
// DRAG AND DROP
|
||||
// --------------------------------------
|
||||
|
||||
/**
|
||||
* The type a listing row is dragged as. Its own rather than `text/plain`, so that nothing outside the
|
||||
* manager -- an editor's text area, another tab -- takes a dragged row for something to paste.
|
||||
*/
|
||||
const DRAG_TYPE = 'application/x-wiki-fileman-item'
|
||||
|
||||
/** A loaded folder's own path, as the move endpoints address a destination. */
|
||||
function treeNodePath(node) {
|
||||
return node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the row being dragged may be dropped into a folder (`null` for the root). Asked by the
|
||||
* listing's folder rows and by every row of the tree.
|
||||
*
|
||||
* Every row in the listing sits in the folder being listed, so that one is never a destination. Nor,
|
||||
* for a folder, is the folder itself or anything under it.
|
||||
*/
|
||||
function canDropOn(folderId) {
|
||||
const item = state.dragItem
|
||||
if (!item || folderId === state.currentFolderId) {
|
||||
return false
|
||||
}
|
||||
const target = folderId ? state.treeNodes[folderId] : null
|
||||
if (folderId && !target) {
|
||||
return false
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
if (folderId === item.id) {
|
||||
return false
|
||||
}
|
||||
const source = state.treeNodes[item.id]
|
||||
if (!source) {
|
||||
return false
|
||||
}
|
||||
if (target && treeNodePath(target).startsWith(`${treeNodePath(source)}/`)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onItemDragStart(ev, item) {
|
||||
ev.dataTransfer.effectAllowed = 'move'
|
||||
// -> Firefox starts no drag without some data, though nothing here reads it back
|
||||
ev.dataTransfer.setData(DRAG_TYPE, item.id)
|
||||
state.dragItem = item
|
||||
}
|
||||
|
||||
/** The end of every drag, whether it was dropped, dropped somewhere that refused it, or cancelled. */
|
||||
function onItemDragEnd() {
|
||||
state.dragItem = null
|
||||
state.dropFolderId = null
|
||||
}
|
||||
|
||||
/*
|
||||
The listing's half of the drop target, which the tree does for itself (see `TreeNav`). Driven from
|
||||
`dragover` for the same reason: it keeps firing while the pointer is over the row, so the highlight
|
||||
cannot be left behind by a `dragleave` fired on one of the row's children.
|
||||
*/
|
||||
function onListDragOver(ev, item) {
|
||||
if (item.type !== 'folder' || !canDropOn(item.id)) {
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
state.dropFolderId = item.id
|
||||
}
|
||||
|
||||
function onListDragLeave(ev, item) {
|
||||
if (ev.currentTarget.contains(ev.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
if (state.dropFolderId === item.id) {
|
||||
state.dropFolderId = null
|
||||
}
|
||||
}
|
||||
|
||||
function onListDrop(ev, item) {
|
||||
state.dropFolderId = null
|
||||
if (item.type !== 'folder' || !canDropOn(item.id)) {
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
dropOnFolder(item.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row dropped onto a folder: asked about first, then moved.
|
||||
*
|
||||
* The item is captured here, because `dragend` clears it as soon as this returns -- long before the
|
||||
* dialog is answered.
|
||||
*/
|
||||
function dropOnFolder(folderId) {
|
||||
const item = state.dragItem
|
||||
if (!item || !canDropOn(folderId)) {
|
||||
return
|
||||
}
|
||||
const target = folderId ? state.treeNodes[folderId] : null
|
||||
const folderPath = target ? treeNodePath(target) : ''
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('@/components/FileManagerMoveDialog.vue')),
|
||||
componentProps: {
|
||||
itemType: item.type,
|
||||
itemTitle: item.title,
|
||||
folderTitle: target?.title ?? null,
|
||||
folderPath
|
||||
}
|
||||
}).onOk((opts) => {
|
||||
moveItemInto(item, folderId, folderPath, opts)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a listing entry into another folder of the same locale, keeping its name.
|
||||
*
|
||||
* The view stays where it is, unlike the Move To actions: a drop is a quick gesture, often one of
|
||||
* several in a row, and following each item away would mean finding the way back every time.
|
||||
*/
|
||||
async function moveItemInto(item, folderId, folderPath, { updateLinks = false } = {}) {
|
||||
try {
|
||||
switch (item.type) {
|
||||
case 'page': {
|
||||
const relinked = await pageStore.pageMove({
|
||||
id: item.id,
|
||||
path: folderPath ? `${folderPath}/${item.fileName}` : item.fileName,
|
||||
locale: state.locale,
|
||||
updateLinks
|
||||
})
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('fileman.dropMove.pageSuccess')
|
||||
})
|
||||
notifyRelinked(relinked, t)
|
||||
break
|
||||
}
|
||||
case 'asset': {
|
||||
const resp = await requestAssetMove(item.id, { folderPath })
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: resp.message
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'folder': {
|
||||
// -> Blocking, as Move To is, and for its reason: see `moveFolder`
|
||||
loading.show({
|
||||
message: t('fileman.folderMoving'),
|
||||
caption: t('fileman.folderMovingHint')
|
||||
})
|
||||
try {
|
||||
const resp = await requestFolderMove(item.id, { folderPath })
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: resp.message
|
||||
})
|
||||
await relocateTreeBranch(item.id, folderId)
|
||||
} finally {
|
||||
loading.hide()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('fileman.dropMove.failed'),
|
||||
caption: apiErrorMessage(err, 'An unexpected error occured.')
|
||||
})
|
||||
}
|
||||
// -> Whatever happened, the listing is fetched again: the row has gone, or a folder beside it now
|
||||
// holds one more thing -- and a failure may have been a conflict the listing is out of date about
|
||||
await loadTree({ parentId: state.currentFolderId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a moved folder where it now is in the tree, without starting the tree over.
|
||||
*
|
||||
* The folder and everything cached under it is forgotten -- all of it has a new path -- and the
|
||||
* destination's folders are fetched again, which brings the folder back under its new parent. What
|
||||
* was under it is fetched again when it is next opened.
|
||||
*/
|
||||
async function relocateTreeBranch(folderId, destinationId) {
|
||||
const node = state.treeNodes[folderId]
|
||||
if (node) {
|
||||
const branchPath = treeNodePath(node)
|
||||
for (const [id, other] of Object.entries(state.treeNodes)) {
|
||||
if (id === folderId || treeNodePath(other).startsWith(`${branchPath}/`)) {
|
||||
delete state.treeNodes[id]
|
||||
treeComp.value?.setLoaded(id, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const other of Object.values(state.treeNodes)) {
|
||||
other.children = other.children.filter((c) => c !== folderId)
|
||||
}
|
||||
state.treeRoots = state.treeRoots.filter((c) => c !== folderId)
|
||||
// -> Never the folder being listed, which cannot be a destination: this fetch touches the tree alone
|
||||
await loadTree({ parentId: destinationId, types: ['folder'] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl+K reaches THIS search field while the overlay is up.
|
||||
*
|
||||
@@ -2771,6 +3015,11 @@ $fileman-bottom-row-height: 45px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// -> The folder a dragged item would land in, outlined the way the tree outlines one
|
||||
&.is-drop-target {
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-compact {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" max-width="850px" @hide="onDialogHide">
|
||||
<w-card style="min-width: 550px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-document-in-folder.svg" size="sm" class="mr-2" />
|
||||
<span>{{ t(`fileman.dropMove.title`) }}</span>
|
||||
</w-card-section>
|
||||
<w-card-section>
|
||||
<div class="text-body2">
|
||||
<i18n-t keypath="fileman.dropMove.confirm">
|
||||
<template #name>
|
||||
<strong>{{ itemTitle }}</strong>
|
||||
</template>
|
||||
<template #destination>
|
||||
<strong>{{ folderTitle ?? t(`fileman.dropMove.root`) }}</strong>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-2">/{{ folderPath ? `${folderPath}/` : `` }}</div>
|
||||
<div v-if="itemType === `folder`" class="text-caption text-grey mt-2">
|
||||
{{ t(`fileman.dropMove.folderHint`) }}
|
||||
</div>
|
||||
</w-card-section>
|
||||
<!-- -> The same choice, and the same default, the Rename / Move Page dialog offers -->
|
||||
<w-list v-if="itemType === `page`">
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="link" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`pageRenameDialog.updateLinks`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`pageRenameDialog.updateLinksHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle v-model="state.updateLinks" :aria-label="t(`pageRenameDialog.updateLinks`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="cancel"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.move`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
@click="confirm" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/*
|
||||
Asks before a drop in the File Manager moves anything. It only asks: the move itself is the File
|
||||
Manager's to make, since what it refreshes afterwards depends on what moved.
|
||||
*/
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** `page`, `asset` or `folder` -- which decides what else there is to say or ask. */
|
||||
itemType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
itemTitle: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
/** The destination's title, or null for the root. */
|
||||
folderTitle: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/** The destination's path, empty for the root. */
|
||||
folderPath: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
updateLinks: true
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
function confirm() {
|
||||
onDialogOK(props.itemType === 'page' ? { updateLinks: state.updateLinks } : {})
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,14 @@
|
||||
<ul class="treeview-level">
|
||||
<!-- ROOT NODE -->
|
||||
<li class="treeview-node" v-if="!props.parentId">
|
||||
<div class="treeview-label" @click="setRoot" :class="{ active: !selection }">
|
||||
<div
|
||||
class="treeview-label"
|
||||
@click="setRoot"
|
||||
:class="{ active: !selection, 'is-drop-target': isDropTarget }"
|
||||
@dragenter="dropTarget.over($event, null)"
|
||||
@dragover="dropTarget.over($event, null)"
|
||||
@dragleave="dropTarget.leave($event, null)"
|
||||
@drop="dropTarget.drop($event, null)">
|
||||
<w-icon name="img:/_assets/icons/fluent-ftp.svg" size="sm" />
|
||||
<div class="treeview-label-text" :class="dark.isActive ? `text-purple-4` : `text-purple`">
|
||||
root
|
||||
@@ -74,6 +81,7 @@ const nodes = inject('nodes')
|
||||
const selection = inject('selection')
|
||||
// -> A computed, not a plain array: see the note where `TreeNav` provides it
|
||||
const contextActionList = inject('contextActionList')
|
||||
const dropTarget = inject('dropTarget')
|
||||
|
||||
// COMPOSABLES
|
||||
|
||||
@@ -88,6 +96,8 @@ const rootContextActionList = computed(() => {
|
||||
return contextActionList.value.filter((c) => c.key === 'newFolder')
|
||||
})
|
||||
|
||||
const isDropTarget = computed(() => dropTarget.isOver(null) && dropTarget.accepts(null))
|
||||
|
||||
const level = computed(() => {
|
||||
const items = []
|
||||
if (!props.parentId) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, provide, reactive, toRef } from 'vue'
|
||||
import { computed, onMounted, provide, reactive, toRef, watch } from 'vue'
|
||||
import { findKey } from 'es-toolkit/object'
|
||||
|
||||
import TreeLevel from './TreeLevel.vue'
|
||||
@@ -37,12 +37,30 @@ const props = defineProps({
|
||||
displayMode: {
|
||||
type: String,
|
||||
default: 'title'
|
||||
},
|
||||
/**
|
||||
* Whether something the parent cares about is being dragged right now. The tree cannot tell on its
|
||||
* own: a drag is started elsewhere (the File Manager's list), and one from outside the page -- a
|
||||
* file off the desktop -- must not open folders or light rows up.
|
||||
*/
|
||||
dragActive: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Whether the item being dragged may be dropped on a node (`null` for the root). Leaving it unset
|
||||
* is what keeps the tree a plain picker: nothing is ever a drop target, which is how every dialog
|
||||
* that browses folders uses it.
|
||||
*/
|
||||
canDrop: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
|
||||
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction', 'dropItem'])
|
||||
|
||||
// I18N
|
||||
|
||||
@@ -107,7 +125,12 @@ provide(
|
||||
|
||||
const state = reactive({
|
||||
loaded: {},
|
||||
opened: {}
|
||||
opened: {},
|
||||
/**
|
||||
* The node a drag is over, droppable or not -- the folder the item already sits in still opens on a
|
||||
* hover, so that the folders under it can be reached. `null` is the root, so nothing is `undefined`.
|
||||
*/
|
||||
dragOverId: undefined
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
@@ -131,6 +154,59 @@ function emitLazyLoad(nodeId, isCurrent, clb) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
One set of handlers for every row, the root included, keyed by node. The drag is tracked from
|
||||
`dragover` rather than from `dragenter` / `dragleave` pairs: those fire for every child element a
|
||||
pointer crosses, and a row that missed one would stay lit. `dragover` keeps firing while the pointer
|
||||
is anywhere over the row, so a wrong guess is corrected a moment later.
|
||||
*/
|
||||
function acceptsDrop(nodeId) {
|
||||
return props.dragActive && Boolean(props.canDrop?.(nodeId))
|
||||
}
|
||||
|
||||
const dropTarget = {
|
||||
isOver: (nodeId) => props.dragActive && state.dragOverId === nodeId,
|
||||
accepts: acceptsDrop,
|
||||
over(ev, nodeId) {
|
||||
if (!props.dragActive || !props.canDrop) {
|
||||
return
|
||||
}
|
||||
state.dragOverId = nodeId
|
||||
// -> Cancelling the event is what makes the row a drop target at all
|
||||
if (acceptsDrop(nodeId)) {
|
||||
ev.preventDefault()
|
||||
ev.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
},
|
||||
leave(ev, nodeId) {
|
||||
// -> Moving onto the row's own icon or label is not leaving it
|
||||
if (ev.currentTarget.contains(ev.relatedTarget)) {
|
||||
return
|
||||
}
|
||||
if (state.dragOverId === nodeId) {
|
||||
state.dragOverId = undefined
|
||||
}
|
||||
},
|
||||
drop(ev, nodeId) {
|
||||
state.dragOverId = undefined
|
||||
if (!acceptsDrop(nodeId)) {
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
emit('dropItem', nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
// -> A drag cancelled with Escape, or dropped anywhere else, ends here too
|
||||
watch(
|
||||
() => props.dragActive,
|
||||
(active) => {
|
||||
if (!active) {
|
||||
state.dragOverId = undefined
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function setOpened(nodeId) {
|
||||
state.opened[nodeId] = true
|
||||
}
|
||||
@@ -159,6 +235,7 @@ provide('opened', state.opened)
|
||||
provide('displayMode', toRef(props, 'displayMode'))
|
||||
provide('selection', selection)
|
||||
provide('emitLazyLoad', emitLazyLoad)
|
||||
provide('dropTarget', dropTarget)
|
||||
|
||||
// EXPOSE
|
||||
|
||||
@@ -239,6 +316,12 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// -> Where a dragged item would land. Outlined rather than filled, so it reads over the selected
|
||||
// row's fill as well as over a plain one
|
||||
&.is-drop-target {
|
||||
box-shadow: inset 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
> .w-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<li class="treeview-node">
|
||||
<!-- NODE -->
|
||||
<div class="treeview-label" @click="openNode" :class="{ active: isActive }">
|
||||
<div
|
||||
class="treeview-label"
|
||||
@click="openNode"
|
||||
:class="{ active: isActive, 'is-drop-target': isDropTarget }"
|
||||
@dragenter="dropTarget.over($event, node.id)"
|
||||
@dragover="dropTarget.over($event, node.id)"
|
||||
@dragleave="dropTarget.leave($event, node.id)"
|
||||
@drop="dropTarget.drop($event, node.id)">
|
||||
<w-icon :name="icon" size="sm" :style="hueFilter" @click.stop="toggleNode()" />
|
||||
<div class="treeview-label-text">
|
||||
{{ displayMode === 'path' ? node.fileName : node.title }}
|
||||
@@ -50,7 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, reactive } from 'vue'
|
||||
import { computed, inject, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { folderIconStyle } from '@/helpers/folderColors'
|
||||
@@ -84,6 +91,10 @@ const emitLazyLoad = inject('emitLazyLoad')
|
||||
// -> A computed, not a plain array: see the note where `TreeNav` provides it. Unwrapped by the
|
||||
// template, which is the only place it is read here.
|
||||
const contextActionList = inject('contextActionList')
|
||||
const dropTarget = inject('dropTarget')
|
||||
|
||||
/** How long a drag has to rest on a closed folder before it opens, to reach the folders inside. */
|
||||
const DRAG_EXPAND_DELAY_MS = 700
|
||||
|
||||
// DATA
|
||||
|
||||
@@ -122,6 +133,30 @@ const isOpened = computed(() => {
|
||||
const isActive = computed(() => {
|
||||
return state.isContextMenuShown || selection.value === props.node.id
|
||||
})
|
||||
const isDragOver = computed(() => dropTarget.isOver(props.node.id))
|
||||
const isDropTarget = computed(() => isDragOver.value && dropTarget.accepts(props.node.id))
|
||||
|
||||
// WATCHERS
|
||||
|
||||
/*
|
||||
Opens a closed folder a drag is resting on, droppable or not: the folder an item already sits in
|
||||
cannot take it, but a folder inside it can. Never closes one -- a drag passing over an open folder
|
||||
is on its way somewhere in it.
|
||||
*/
|
||||
let dragExpandTimer = null
|
||||
watch(isDragOver, (over) => {
|
||||
clearTimeout(dragExpandTimer)
|
||||
if (over && !isOpened.value) {
|
||||
dragExpandTimer = setTimeout(() => {
|
||||
if (isDragOver.value && !isOpened.value) {
|
||||
toggleNode()
|
||||
}
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(dragExpandTimer)
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
|
||||
Reference in new issue
Block a user