feat: asset rename + asset delete dialogs + linting fixes

pull/7004/head
NGPixel 5 months ago
parent f8bc9e8c24
commit 291fe26272
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

@ -8,7 +8,7 @@
"vue"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"i18n-ally.localesPaths": [
"server/locales",

@ -1,6 +1,7 @@
import _ from 'lodash-es'
import sanitize from 'sanitize-filename'
import { generateError, generateSuccess } from '../../helpers/graph.mjs'
import { decodeFolderPath, decodeTreePath, generateHash } from '../../helpers/common.mjs'
import path from 'node:path'
import fs from 'fs-extra'
import { v4 as uuid } from 'uuid'
@ -9,7 +10,12 @@ import { pipeline } from 'node:stream/promises'
export default {
Query: {
async assetById(obj, args, context) {
return null
const asset = await WIKI.db.assets.query().findById(args.id)
if (asset) {
return asset
} else {
throw new Error('ERR_ASSET_NOT_FOUND')
}
}
},
Mutation: {
@ -18,75 +24,75 @@ export default {
*/
async renameAsset(obj, args, context) {
try {
const filename = sanitize(args.filename).toLowerCase()
const filename = sanitize(args.fileName).toLowerCase()
const asset = await WIKI.db.assets.query().findById(args.id)
if (asset) {
const treeItem = await WIKI.db.tree.query().findById(args.id)
if (asset && treeItem) {
// Check for extension mismatch
if (!_.endsWith(filename, asset.ext)) {
throw new WIKI.Error.AssetRenameInvalidExt()
if (!_.endsWith(filename, asset.fileExt)) {
throw new Error('ERR_ASSET_EXT_MISMATCH')
}
// Check for non-dot files changing to dotfile
if (asset.ext.length > 0 && filename.length - asset.ext.length < 1) {
throw new WIKI.Error.AssetRenameInvalid()
if (asset.fileExt.length > 0 && filename.length - asset.fileExt.length < 1) {
throw new Error('ERR_ASSET_INVALID_DOTFILE')
}
// Check for collision
const assetCollision = await WIKI.db.assets.query().where({
filename,
folderId: asset.folderId
const assetCollision = await WIKI.db.tree.query().where({
folderPath: treeItem.folderPath,
fileName: filename
}).first()
if (assetCollision) {
throw new WIKI.Error.AssetRenameCollision()
}
// Get asset folder path
let hierarchy = []
if (asset.folderId) {
hierarchy = await WIKI.db.assetFolders.getHierarchy(asset.folderId)
throw new Error('ERR_ASSET_ALREADY_EXISTS')
}
// Check source asset permissions
const assetSourcePath = (asset.folderId) ? hierarchy.map(h => h.slug).join('/') + `/${asset.filename}` : asset.filename
const assetSourcePath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${treeItem.fileName}` : treeItem.fileName
if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetSourcePath })) {
throw new WIKI.Error.AssetRenameForbidden()
throw new Error('ERR_FORBIDDEN')
}
// Check target asset permissions
const assetTargetPath = (asset.folderId) ? hierarchy.map(h => h.slug).join('/') + `/${filename}` : filename
const assetTargetPath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${filename}` : filename
if (!WIKI.auth.checkAccess(context.req.user, ['write:assets'], { path: assetTargetPath })) {
throw new WIKI.Error.AssetRenameTargetForbidden()
throw new Error('ERR_TARGET_FORBIDDEN')
}
// Update filename + hash
const fileHash = '' // assetHelper.generateHash(assetTargetPath)
const itemHash = generateHash(assetTargetPath)
await WIKI.db.assets.query().patch({
filename: filename,
hash: fileHash
}).findById(args.id)
// Delete old asset cache
await asset.deleteAssetCache()
// Rename in Storage
await WIKI.db.storage.assetEvent({
event: 'renamed',
asset: {
...asset,
path: assetSourcePath,
destinationPath: assetTargetPath,
moveAuthorId: context.req.user.id,
moveAuthorName: context.req.user.name,
moveAuthorEmail: context.req.user.email
}
})
fileName: filename
}).findById(asset.id)
await WIKI.db.tree.query().patch({
fileName: filename,
title: filename,
hash: itemHash
}).findById(treeItem.id)
// TODO: Delete old asset cache
WIKI.events.outbound.emit('purgeItemCache', itemHash)
// TODO: Rename in Storage
// await WIKI.db.storage.assetEvent({
// event: 'renamed',
// asset: {
// ...asset,
// path: assetSourcePath,
// destinationPath: assetTargetPath,
// moveAuthorId: context.req.user.id,
// moveAuthorName: context.req.user.name,
// moveAuthorEmail: context.req.user.email
// }
// })
return {
responseResult: generateSuccess('Asset has been renamed successfully.')
operation: generateSuccess('Asset has been renamed successfully.')
}
} else {
throw new WIKI.Error.AssetInvalid()
throw new Error('ERR_INVALID_ASSET')
}
} catch (err) {
return generateError(err)
@ -97,35 +103,38 @@ export default {
*/
async deleteAsset(obj, args, context) {
try {
const asset = await WIKI.db.assets.query().findById(args.id)
if (asset) {
const treeItem = await WIKI.db.tree.query().findById(args.id)
if (treeItem) {
// Check permissions
const assetPath = await asset.getAssetPath()
const assetPath = (treeItem.folderPath) ? decodeTreePath(decodeFolderPath(treeItem.folderPath)) + `/${treeItem.fileName}` : treeItem.fileName
if (!WIKI.auth.checkAccess(context.req.user, ['manage:assets'], { path: assetPath })) {
throw new WIKI.Error.AssetDeleteForbidden()
throw new Error('ERR_FORBIDDEN')
}
await WIKI.db.knex('assetData').where('id', args.id).del()
await WIKI.db.assets.query().deleteById(args.id)
await asset.deleteAssetCache()
// Delete from Storage
await WIKI.db.storage.assetEvent({
event: 'deleted',
asset: {
...asset,
path: assetPath,
authorId: context.req.user.id,
authorName: context.req.user.name,
authorEmail: context.req.user.email
}
})
// Delete from DB
await WIKI.db.assets.query().deleteById(treeItem.id)
await WIKI.db.tree.query().deleteById(treeItem.id)
// TODO: Delete asset cache
WIKI.events.outbound.emit('purgeItemCache', treeItem.hash)
// TODO: Delete from Storage
// await WIKI.db.storage.assetEvent({
// event: 'deleted',
// asset: {
// ...asset,
// path: assetPath,
// authorId: context.req.user.id,
// authorName: context.req.user.name,
// authorEmail: context.req.user.email
// }
// })
return {
responseResult: generateSuccess('Asset has been deleted successfully.')
operation: generateSuccess('Asset has been deleted successfully.')
}
} else {
throw new WIKI.Error.AssetInvalid()
throw new Error('ERR_INVALID_ASSET')
}
} catch (err) {
return generateError(err)
@ -373,7 +382,7 @@ export default {
try {
await WIKI.db.assets.flushTempUploads()
return {
responseResult: generateSuccess('Temporary Uploads have been flushed successfully.')
operation: generateSuccess('Temporary Uploads have been flushed successfully.')
}
} catch (err) {
return generateError(err)

@ -5,13 +5,13 @@
extend type Query {
assetById(
id: UUID!
): [AssetItem]
): AssetItem
}
extend type Mutation {
renameAsset(
id: UUID!
filename: String!
fileName: String!
): DefaultResponse
deleteAsset(
@ -39,7 +39,7 @@ extend type Mutation {
type AssetItem {
id: UUID
filename: String
fileName: String
ext: String
kind: AssetKind
mime: String

@ -1643,6 +1643,13 @@
"fileman.aiFileType": "Adobe Illustrator Document",
"fileman.aifFileType": "AIF Audio File",
"fileman.apkFileType": "Android Package",
"fileman.assetDelete": "Delete Asset",
"fileman.assetDeleteConfirm": "Are you sure you want to delete {name}?",
"fileman.assetDeleteId": "Asset ID {id}",
"fileman.assetDeleteSuccess": "Asset deleted successfully.",
"fileman.assetFileName": "Asset Name",
"fileman.assetFileNameHint": "Filename of the asset, including the file extension.",
"fileman.assetRename": "Rename Asset",
"fileman.aviFileType": "AVI Video File",
"fileman.binFileType": "Binary File",
"fileman.bz2FileType": "BZIP2 Archive",
@ -1698,6 +1705,8 @@
"fileman.pptxFileType": "Microsoft Powerpoint Presentation",
"fileman.psdFileType": "Adobe Photoshop Document",
"fileman.rarFileType": "RAR Archive",
"fileman.renameAssetInvalid": "Asset name is invalid.",
"fileman.renameAssetSuccess": "Asset renamed successfully",
"fileman.renameFolderInvalidData": "One or more fields are invalid.",
"fileman.renameFolderSuccess": "Folder renamed successfully.",
"fileman.searchFolder": "Search folder...",

@ -47,13 +47,13 @@ export class Asset extends Model {
async $beforeUpdate(opt, context) {
await super.$beforeUpdate(opt, context)
this.updatedAt = moment.utc().toISOString()
this.updatedAt = new Date().toISOString()
}
async $beforeInsert(context) {
await super.$beforeInsert(context)
this.createdAt = moment.utc().toISOString()
this.updatedAt = moment.utc().toISOString()
this.createdAt = new Date().toISOString()
this.updatedAt = new Date().toISOString()
}
async getAssetPath() {

@ -13,19 +13,16 @@ import CleanCSS from 'clean-css'
import TurndownService from 'turndown'
import { gfm as turndownPluginGfm } from '@joplin/turndown-plugin-gfm'
import cheerio from 'cheerio'
import matter from 'gray-matter'
import { Locale } from './locales.mjs'
import { PageLink } from './pageLinks.mjs'
import { Tag } from './tags.mjs'
import { User } from './users.mjs'
const pageRegex = /^[a-zA-Z0-9-_/]*$/
const aliasRegex = /^[a-zA-Z0-9-_]*$/
const frontmatterRegex = {
html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/
}
/**
@ -178,30 +175,20 @@ export class Page extends Model {
* @returns {Object} Parsed Page Metadata with Raw Content
*/
static parseMetadata (raw, contentType) {
let result
try {
switch (contentType) {
case 'markdown':
result = frontmatterRegex.markdown.exec(raw)
if (result[2]) {
case 'markdown': {
const result = matter(raw)
if (!result?.isEmpty) {
return {
...yaml.safeLoad(result[2]),
content: result[3]
}
} else {
// Attempt legacy v1 format
result = frontmatterRegex.legacy.exec(raw)
if (result[2]) {
return {
title: result[2],
description: result[4],
content: result[5]
}
content: result.content,
...result.data
}
}
break
case 'html':
result = frontmatterRegex.html.exec(raw)
}
case 'html': {
const result = frontmatterRegex.html.exec(raw)
if (result[2]) {
return {
...yaml.safeLoad(result[2]),
@ -209,6 +196,7 @@ export class Page extends Model {
}
}
break
}
}
} catch (err) {
WIKI.logger.warn('Failed to parse page metadata. Invalid syntax.')

@ -85,6 +85,7 @@
"graphql-rate-limit-directive": "2.0.4",
"graphql-tools": "9.0.0",
"graphql-upload": "16.0.2",
"gray-matter": "4.0.3",
"he": "1.2.0",
"highlight.js": "11.9.0",
"image-size": "1.0.2",

@ -152,6 +152,9 @@ dependencies:
graphql-upload:
specifier: 16.0.2
version: 16.0.2(graphql@16.8.1)
gray-matter:
specifier: 4.0.3
version: 4.0.3
he:
specifier: 1.2.0
version: 1.2.0
@ -2114,6 +2117,12 @@ packages:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
dev: false
/argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
dependencies:
sprintf-js: 1.0.3
dev: false
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@ -3852,6 +3861,13 @@ packages:
- supports-color
dev: false
/extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'}
dependencies:
is-extendable: 0.1.1
dev: false
/extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
dev: false
@ -4290,6 +4306,16 @@ packages:
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
dev: false
/gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
dependencies:
js-yaml: 3.14.1
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
dev: false
/has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
dev: true
@ -4625,6 +4651,11 @@ packages:
has-tostringtag: 1.0.0
dev: true
/is-extendable@0.1.1:
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
engines: {node: '>=0.10.0'}
dev: false
/is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@ -4762,6 +4793,14 @@ packages:
dev: false
optional: true
/js-yaml@3.14.1:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
dependencies:
argparse: 1.0.10
esprima: 4.0.1
dev: false
/js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
@ -4895,6 +4934,11 @@ packages:
json-buffer: 3.0.1
dev: true
/kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
dev: false
/klaw@4.1.0:
resolution: {integrity: sha512-1zGZ9MF9H22UnkpVeuaGKOjfA2t6WrfdrJmGjy16ykcjnKQDmHVX+KI477rpbGevz/5FD4MC3xf1oxylBgcaQw==}
engines: {node: '>=14.14.0'}
@ -6591,6 +6635,14 @@ packages:
apg-lib: 3.2.0
dev: false
/section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
dependencies:
extend-shallow: 2.0.1
kind-of: 6.0.3
dev: false
/semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -6821,6 +6873,10 @@ packages:
engines: {node: '>= 10.x'}
dev: false
/sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
dev: false
/statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@ -6890,6 +6946,11 @@ packages:
dependencies:
ansi-regex: 5.0.1
/strip-bom-string@1.0.0:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
dev: false
/strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}

@ -0,0 +1,31 @@
---
title: Home
description: Welcome to your wiki!
published: true
---
Feel free to modify this page (or delete it!).
## Next Steps
- Configure your wiki in the [Administration Area](/_admin).
- [Modify your profile](/_profile) to set preferences or change your password.
- Create new pages by clicking the <kbd>+</kbd> button in the upper right corner.
- Edit the navigation by clicking the <kbd>Edit Nav</kbd> button in the lower left corner.
## Read the documentation
How do permissions work? How can I make my wiki publicly accessible?
It's all [in the docs](https://beta.js.wiki/docs/admin/groups)!
## Example Blocks
Did you know that you can insert dynamic [blocks](https://beta.js.wiki/docs/editor/blocks)?
For example, here are the 5 most recently updated pages on your wiki:
::block-index{orderBy="updatedAt" orderByDirection="desc" limit="5"}
::
This list will automatically update as you create / edit pages.

@ -26,6 +26,8 @@ module.exports = {
'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
'plugin:vue-pug/vue3-strongly-recommended',
'standard'
],
@ -72,6 +74,14 @@ module.exports = {
'vue/multi-word-component-names': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// disable bogus rules
'vue/valid-template-root': 'off',
'vue/no-parsing-error': 'off',
'vue-pug/no-parsing-error': 'off',
'vue/valid-v-for': 'off',
'vue/html-quotes': ['warn', 'single'],
'vue/max-attributes-per-line': 'off'
}
}

@ -40,7 +40,7 @@
"vueCompilerOptions": {
"target": 3,
"plugins": [
"@volar/vue-language-plugin-pug"
"@vue/language-plugin-pug"
]
}
}

@ -117,7 +117,8 @@
"eslint-plugin-import": "2.29.0",
"eslint-plugin-n": "16.3.1",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-vue": "9.18.1"
"eslint-plugin-vue": "9.18.1",
"eslint-plugin-vue-pug": "0.6.1"
},
"engines": {
"node": ">= 18.0",

@ -325,6 +325,9 @@ devDependencies:
eslint-plugin-vue:
specifier: 9.18.1
version: 9.18.1(eslint@8.54.0)
eslint-plugin-vue-pug:
specifier: 0.6.1
version: 0.6.1(eslint-plugin-vue@9.18.1)(vue-eslint-parser@9.3.2)
packages:
@ -2736,6 +2739,17 @@ packages:
eslint: 8.54.0
dev: true
/eslint-plugin-vue-pug@0.6.1(eslint-plugin-vue@9.18.1)(vue-eslint-parser@9.3.2):
resolution: {integrity: sha512-wOId81xH42+X9M0qVRU5o39KJ3Phd+fdgemPNAy1cD9hUROp/aSHHapOP7muDV/sHmu9zP/mU34yDfCfQWUWEQ==}
peerDependencies:
eslint-plugin-vue: ^9.8.0
dependencies:
eslint-plugin-vue: 9.18.1(eslint@8.54.0)
vue-eslint-parser-template-tokenizer-pug: 0.4.10(vue-eslint-parser@9.3.2)
transitivePeerDependencies:
- vue-eslint-parser
dev: true
/eslint-plugin-vue@9.18.1(eslint@8.54.0):
resolution: {integrity: sha512-7hZFlrEgg9NIzuVik2I9xSnJA5RsmOfueYgsUGUokEDLJ1LHtxO0Pl4duje1BriZ/jDWb+44tcIlC3yi0tdlZg==}
engines: {node: ^14.17.0 || >=16.0.0}
@ -5478,6 +5492,15 @@ packages:
dependencies:
vue: 3.3.8(typescript@5.3.2)
/vue-eslint-parser-template-tokenizer-pug@0.4.10(vue-eslint-parser@9.3.2):
resolution: {integrity: sha512-Npzjna9PUJzIal/o7hOo4D7dF4hqjHwTafBtLgdtja2LZuCc4UT5BU7dyYJeKb9s1SnCFBflHMg3eFA3odq6bg==}
peerDependencies:
vue-eslint-parser: ^9.0.0
dependencies:
pug-lexer: 5.0.1
vue-eslint-parser: 9.3.2(eslint@8.54.0)
dev: true
/vue-eslint-parser@9.3.2(eslint@8.54.0):
resolution: {integrity: sha512-q7tWyCVaV9f8iQyIA5Mkj/S6AoJ9KBN8IeUSf3XEmBrOtxOZnfTg5s4KClbZBCK3GtnT/+RyCLZyDHuZwTuBjg==}
engines: {node: ^14.17.0 || >=16.0.0}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M1.5 4.5H38.5V35.5H1.5z"/><path fill="#4788c7" d="M38,5v30H2V5H38 M39,4H1v32h38V4L39,4z"/><path fill="#98ccfd" d="M27.45 19.612L20 25.437 30.247 35 38 35 38 29.112zM30 10A3 3 0 1 0 30 16 3 3 0 1 0 30 10z"/><path fill="#b6dcfe" d="M32.468 35L2 35 2 27.421 14 17.316z"/><g><path fill="#fff" d="M36,7v26H4V7H36 M38,5H2v30h36V5L38,5z"/></g></svg>

After

Width:  |  Height:  |  Size: 454 B

@ -5,7 +5,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
q-icon(name='img:/_assets/icons/fluent-key-2.svg', left, size='sm')
span {{t(`admin.api.copyKeyTitle`)}}
q-card-section.card-negative
i18n-t(tag='span', keypath='admin.api.newKeyCopyWarn')
i18n-t(tag='span', keypath='admin.api.newKeyCopyWarn', scope='global')
template(#bold)
strong {{t('admin.api.newKeyCopyWarnBold')}}
q-form.q-py-sm
@ -15,7 +15,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
q-input(
type='textarea'
outlined
v-model='props.keyValue'
:model-value='props.keyValue'
dense
hide-bottom-space
:label='t(`admin.api.key`)'

@ -64,11 +64,11 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
)
template(v-slot:selected)
.text-caption(v-if='state.keyGroups.length > 1')
i18n-t(keypath='admin.api.groupsSelected')
i18n-t(keypath='admin.api.groupsSelected', scope='global')
template(#count)
strong {{ state.keyGroups.length }}
.text-caption(v-else-if='state.keyGroups.length === 1')
i18n-t(keypath='admin.api.groupSelected')
i18n-t(keypath='admin.api.groupSelected', scope='global')
template(#group)
strong {{ selectedGroupName }}
span(v-else)

@ -0,0 +1,109 @@
<template lang='pug'>
q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card(style='min-width: 550px; max-width: 850px;')
q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-delete-bin.svg', left, size='sm')
span {{ t(`fileman.assetDelete`) }}
q-card-section
.text-body2
i18n-t(keypath='fileman.assetDeleteConfirm')
template(#name)
strong {{assetName}}
.text-caption.text-grey.q-mt-sm {{ t('fileman.assetDeleteId', { id: assetId }) }}
q-card-actions.card-actions
q-space
q-btn.acrylic-btn(
flat
:label='t(`common.actions.cancel`)'
color='grey'
padding='xs md'
@click='onDialogCancel'
)
q-btn(
unelevated
:label='t(`common.actions.delete`)'
color='negative'
padding='xs md'
@click='confirm'
:loading='state.isLoading'
)
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive } from 'vue'
// PROPS
const props = defineProps({
assetId: {
type: String,
required: true
},
assetName: {
type: String,
required: true
}
})
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
isLoading: false
})
// METHODS
async function confirm () {
state.isLoading = true
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation deleteAsset ($id: UUID!) {
deleteAsset(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.assetId
}
})
if (resp?.data?.deleteAsset?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.assetDeleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deleteAsset?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.isLoading = false
}
</script>

@ -0,0 +1,165 @@
<template lang='pug'>
q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card(style='min-width: 650px;')
q-card-section.card-header
q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
span {{ t(`fileman.assetRename`) }}
q-form.q-py-sm(@submit='rename')
q-item
blueprint-icon.self-start(icon='image')
q-item-section
q-input(
autofocus
outlined
v-model='state.path'
dense
hide-bottom-space
:label='t(`fileman.assetFileName`)'
:aria-label='t(`fileman.assetFileName`)'
:hint='t(`fileman.assetFileNameHint`)'
lazy-rules='ondemand'
@keyup.enter='rename'
)
q-card-actions.card-actions
q-space
q-btn.acrylic-btn(
flat
:label='t(`common.actions.cancel`)'
color='grey'
padding='xs md'
@click='onDialogCancel'
)
q-btn(
unelevated
:label='t(`common.actions.rename`)'
color='primary'
padding='xs md'
@click='rename'
:loading='state.loading > 0'
)
q-inner-loading(:showing='state.loading > 0')
q-spinner(color='accent', size='lg')
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { onMounted, reactive, ref } from 'vue'
// PROPS
const props = defineProps({
assetId: {
type: String,
required: true
}
})
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
path: '',
loading: false
})
// METHODS
async function rename () {
state.loading++
try {
if (state.path?.length < 2 || !state.path?.includes('.')) {
throw new Error(t('fileman.renameAssetInvalid'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation renameAsset (
$id: UUID!
$fileName: String!
) {
renameAsset (
id: $id
fileName: $fileName
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.assetId,
fileName: state.path
}
})
if (resp?.data?.renameAsset?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.renameAssetSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.renameAsset?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.loading--
}
// MOUNTED
onMounted(async () => {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: gql`
query fetchAssetForRename (
$id: UUID!
) {
assetById (
id: $id
) {
id
fileName
}
}
`,
fetchPolicy: 'network-only',
variables: {
id: props.assetId
}
})
if (resp?.data?.assetById?.id !== props.assetId) {
throw new Error('Failed to fetch asset data.')
}
state.path = resp.data.assetById.fileName
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
onDialogCancel()
}
state.loading--
})
</script>

@ -1,9 +1,9 @@
<template lang="pug">
<template lang='pug'>
q-layout.fileman(view='hHh lpR lFr', container)
q-header.card-header
q-toolbar(dark)
q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
span {{t(`fileman.title`)}}
span {{ t(`fileman.title`) }}
q-toolbar(dark)
q-btn.q-mr-sm.acrylic-btn(
flat
@ -23,9 +23,9 @@ q-layout.fileman(view='hHh lpR lFr', container)
:label='t(`fileman.searchFolder`)'
:debounce='500'
)
template(v-slot:prepend)
template(#prepend)
q-icon(name='las la-search')
template(v-slot:append)
template(#append)
q-icon.cursor-pointer(
name='las la-times'
@click='state.search=``'
@ -78,9 +78,10 @@ q-layout.fileman(view='hHh lpR lFr', container)
)
.fileman-details-row(
v-for='item of currentFileDetails.items'
:key='item.id'
)
label {{item.label}}
span {{item.value}}
label {{ item.label }}
span {{ item.value }}
template(v-if='insertMode')
q-separator.q-my-md
q-btn.full-width(
@ -97,7 +98,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
q-toolbar.fileman-toolbar
template(v-if='state.isUploading')
.fileman-progressbar
div(:style='`width: ` + state.uploadPercentage + `%`') {{state.uploadPercentage}}%
div(:style='`width: ` + state.uploadPercentage + `%`') {{ state.uploadPercentage }}%
q-btn.acrylic-btn.q-ml-sm(
flat
dense
@ -117,9 +118,8 @@ q-layout.fileman(view='hHh lpR lFr', container)
color='grey'
:aria-label='t(`fileman.viewOptions`)'
icon='las la-th-list'
@click=''
)
q-tooltip(anchor='bottom middle', self='top middle') {{t(`fileman.viewOptions`)}}
q-tooltip(anchor='bottom middle', self='top middle') {{ t(`fileman.viewOptions`) }}
q-menu(
transition-show='jump-down'
transition-hide='jump-up'
@ -128,7 +128,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
)
q-card.q-pa-sm
.text-center
small.text-grey {{t(`fileman.viewOptions`)}}
small.text-grey {{ t(`fileman.viewOptions`) }}
q-list(dense)
q-separator.q-my-sm
q-item(clickable)
@ -183,7 +183,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
icon='las la-redo-alt'
@click='reloadFolder(state.currentFolderId)'
)
q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
q-tooltip(anchor='bottom middle', self='top middle') {{ t(`common.actions.refresh`) }}
q-separator.q-mr-sm(inset, vertical)
q-btn.q-mr-sm(
flat
@ -193,7 +193,6 @@ q-layout.fileman(view='hHh lpR lFr', container)
:label='t(`common.actions.new`)'
:aria-label='t(`common.actions.new`)'
icon='las la-plus-circle'
@click=''
)
new-menu(
:hide-asset-btn='true'
@ -236,16 +235,16 @@ q-layout.fileman(view='hHh lpR lFr', container)
clickable
active-class='active'
:active='item.id === state.currentFileId'
@click.native='selectItem(item)'
@dblclick.native='doubleClickItem(item)'
@click='selectItem(item)'
@dblclick='doubleClickItem(item)'
)
q-item-section.fileman-filelist-icon(avatar)
q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
q-item-section.fileman-filelist-label
q-item-label {{usePathTitle ? item.fileName : item.title}}
q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
q-item-label {{ usePathTitle ? item.fileName : item.title }}
q-item-label(caption, v-if='!state.isCompact') {{ item.caption }}
q-item-section.fileman-filelist-side(side, v-if='item.side')
.text-caption {{item.side}}
.text-caption {{ item.side }}
//- RIGHT-CLICK MENU
q-menu.translucent-menu(
touch-position
@ -341,6 +340,7 @@ import { useSiteStore } from 'src/stores/site'
import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
import FolderRenameDialog from 'src/components/FolderRenameDialog.vue'
import AssetRenameDialog from 'src/components/AssetRenameDialog.vue'
import LocaleSelectorMenu from 'src/components/LocaleSelectorMenu.vue'
// QUASAR
@ -787,6 +787,7 @@ function reloadFolder (folderId) {
treeComp.value.resetLoaded()
}
// --------------------------------------
// PAGE METHODS
// --------------------------------------
@ -811,6 +812,34 @@ function delPage (pageId, pageName) {
})
}
// --------------------------------------
// ASSET METHODS
// --------------------------------------
function renameAsset (assetId) {
$q.dialog({
component: AssetRenameDialog,
componentProps: {
assetId
}
}).onOk(async () => {
// -> Reload current view
await loadTree({ parentId: state.currentFolderId })
})
}
function delAsset (assetId, assetName) {
$q.dialog({
component: defineAsyncComponent(() => import('src/components/AssetDeleteDialog.vue')),
componentProps: {
assetId,
assetName
}
}).onOk(() => {
loadTree(state.currentFolderId, null)
})
}
// --------------------------------------
// UPLOAD METHODS
// --------------------------------------
@ -991,7 +1020,7 @@ function renameItem (item) {
break
}
case 'asset': {
// TODO: Rename asset
renameAsset(item.id)
break
}
}
@ -999,6 +1028,10 @@ function renameItem (item) {
function delItem (item) {
switch (item.type) {
case 'asset': {
delAsset(item.id, item.title)
break
}
case 'folder': {
delFolder(item.id, true)
break

@ -5,6 +5,7 @@ q-footer.site-footer
v-if='hasSiteFooter'
:keypath='isCopyright ? `common.footerCopyright` : `common.footerLicense`'
tag='span'
scope='global'
)
template(#company)
strong {{siteStore.company}}
@ -15,6 +16,7 @@ q-footer.site-footer
i18n-t(
:keypath='props.generic ? `common.footerGeneric` : `common.footerPoweredBy`'
tag='span'
scope='global'
)
template(#link)
a(href='https://js.wiki', target='_blank', ref='noopener noreferrer'): strong Wiki.js

@ -66,6 +66,7 @@ q-toolbar(
.flex.q-mb-md
q-chip(
v-for='tag of popularTags'
:key='tag'
square
color='grey-8'
text-color='white'

@ -1,5 +1,6 @@
<!-- eslint-disable -->
<template lang="pug">
q-card.icon-picker(flat, style='width: 400px;')
q-card.icon-picker(flat, style='width: 400px')
q-tabs.text-primary(
v-model='state.currentTab'
no-caps
@ -43,7 +44,8 @@ q-card.icon-picker(flat, style='width: 400px;')
)
q-item-section
q-item-label {{scope.opt.name}}
q-item-label(caption): strong(:class='scope.selected ? `text-white` : `text-primary`') {{scope.opt.subset}}
q-item-label(caption)
strong(:class='scope.selected ? `text-white` : `text-primary`') {{scope.opt.subset}}
q-item-section(side, v-if='scope.opt.subset')
q-chip(
color='primary'

@ -8,6 +8,7 @@ q-menu.translucent-menu(
q-list(padding, style='min-width: 200px;')
q-item(
v-for='lang of siteStore.locales.active'
:key='lang.code'
clickable
@click='commonStore.setLocale(lang.code)'
)

@ -27,6 +27,7 @@ q-scroll-area.sidebar-nav(
q-item(
v-for='itemChild of item.children'
:to='itemChild.target'
:key='itemChild.id'
)
q-item-section(side)
q-icon(:name='itemChild.icon', color='white')

@ -48,7 +48,10 @@
span Pending Asset Uploads
q-card-section(v-if='!hasPendingAssets') There are no assets pending uploads.
q-list(v-else, separator)
q-item(v-for='item of editorStore.pendingAssets')
q-item(
v-for='item of editorStore.pendingAssets'
:key='item.id'
)
q-item-section(side)
q-icon(name='las la-file-image')
q-item-section {{ item.fileName }}

@ -38,7 +38,8 @@
q-item-section(side)
q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
q-item-section
q-item-label(v-html='scope.opt')
q-item-label
span(v-html='scope.opt')
</template>
<script setup>

@ -7,7 +7,7 @@ q-menu(
@before-hide='menuHidden'
)
q-list(dense, padding)
q-item(clickable, @click='', ref='copyUrlButton')
q-item(clickable, ref='copyUrlButton')
q-item-section.items-center(avatar)
q-icon(color='grey', name='las la-clipboard', size='sm')
q-item-section.q-pr-md Copy URL

@ -1,15 +1,15 @@
<template lang="pug">
<template lang='pug'>
q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
q-card-section.card-header(v-if='props.mode === `savePage`')
q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
span {{t('pageSaveDialog.title')}}
span {{ t('pageSaveDialog.title') }}
q-card-section.card-header(v-else-if='props.mode === `duplicatePage`')
q-icon(name='img:/_assets/icons/color-documents.svg', left, size='sm')
span {{t('pageDuplicateDialog.title')}}
span {{ t('pageDuplicateDialog.title') }}
q-card-section.card-header(v-else-if='props.mode === `renamePage`')
q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
span {{t('pageRenameDialog.title')}}
span {{ t('pageRenameDialog.title') }}
.row.page-save-dialog-browser
.col-4
q-scroll-area(
@ -36,13 +36,13 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
clickable
active-class='active'
:active='item.id === state.currentFileId'
@click.native='selectItem(item)'
@click='selectItem(item)'
)
q-item-section(side)
q-icon(:name='item.icon', size='sm')
q-item-section
q-item-label {{item.title}}
.page-save-dialog-path.font-robotomono {{currentFolderPath}}
.page-save-dialog-path.font-robotomono {{ currentFolderPath }}
q-list.q-py-sm
q-item
blueprint-icon(icon='new-document')

@ -28,6 +28,7 @@ q-layout.admin(view='hHh Lpr lff')
q-list(separator, padding)
q-item(
v-for='lang of adminStore.locales'
:key='lang.code'
clickable
@click='commonStore.setLocale(lang.code)'
)

@ -13,7 +13,6 @@ q-layout(view='hHh Lpr lff')
icon='las la-globe'
color='white'
aria-label='Switch Locale'
@click=''
)
locale-selector-menu(anchor='top right' self='top left')
q-tooltip(anchor='center right' self='center left') Switch Locale

@ -32,7 +32,7 @@ q-page.admin-extensions
q-card
q-list(separator)
q-item(
v-for='(ext, idx) of state.extensions'
v-for='ext of state.extensions'
:key='`ext-` + ext.key'
)
blueprint-icon(icon='module')

@ -101,7 +101,6 @@ q-page.admin-flags
icon='las la-code'
color='primary'
text-color='white'
@click=''
disabled
)

@ -84,7 +84,7 @@ q-page.admin-locale
.text-caption(:class='$q.dark.isActive ? `text-grey-4` : `text-grey-7`') Select the locales that can be used on this site.
q-item(
v-for='(lc, idx) of state.locales'
v-for='lc of state.locales'
:key='lc.code'
:tag='lc.code !== state.selectedLocale ? `label` : null'
)

@ -177,7 +177,6 @@ q-page.admin-login
q-card-section.col-auto.q-pr-none
q-icon(name='las la-info-circle', size='sm')
q-card-section.text-caption {{ t('admin.login.providersVisbleWarning') }}
</template>
<script setup>

@ -54,7 +54,7 @@ q-page.admin-api
q-card-section.col-auto.q-pr-none
q-icon(name='las la-info-circle', size='sm')
q-card-section
i18n-t(tag='span', keypath='admin.metrics.endpoint')
i18n-t(tag='span', keypath='admin.metrics.endpoint', scope='global')
template(#endpoint)
strong.font-robotomono /metrics
.text-caption {{ t('admin.metrics.endpointWarning') }}
@ -66,7 +66,7 @@ q-page.admin-api
q-card-section.col-auto.q-pr-none
q-icon(name='las la-key', size='sm')
q-card-section
i18n-t(tag='span', keypath='admin.metrics.auth')
i18n-t(tag='span', keypath='admin.metrics.auth', scope='global')
template(#headerName)
strong.font-robotomono Authorization
template(#tokenType)

@ -32,7 +32,6 @@ q-page.admin-system
icon='mdi-clipboard-text-outline'
label='Copy System Info'
color='primary'
@click=''
:disabled='state.loading > 0'
)
q-separator(inset)

@ -65,7 +65,7 @@ q-page.admin-theme
unchecked-icon='las la-times'
:aria-label='t(`admin.theme.darkMode`)'
)
template(v-for='(cl, idx) of colorKeys', :key='cl')
template(v-for='cl of colorKeys', :key='cl')
q-separator.q-my-sm(inset)
q-item
blueprint-icon(icon='fill-color')

@ -39,7 +39,6 @@ q-page.admin-groups
unelevated
color='secondary'
:aria-label='t(`admin.users.defaults`)'
@click=''
)
q-tooltip {{ t(`admin.users.defaults`) }}
user-defaults-menu

@ -44,7 +44,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
@ -57,7 +56,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
@ -70,7 +68,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
@ -83,7 +80,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
@ -108,7 +104,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
@ -121,7 +116,6 @@ q-page.admin-utilities
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
</template>

@ -390,7 +390,7 @@ function refreshTocExpanded (baseToc, lvl) {
}
}
.page-header {
min-height: 95px;
height: 95px;
@at-root .body--light & {
background: linear-gradient(to bottom, $grey-2 0%, $grey-1 100%);

@ -67,7 +67,8 @@ q-layout(view='hHh Lpr lff')
q-item-section(side)
q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)', size='sm')
q-item-section
q-item-label(v-html='scope.opt')
q-item-label
span(v-html='scope.opt')
//- q-input.q-mt-sm(
//- outlined
//- dense
@ -103,7 +104,8 @@ q-layout(view='hHh Lpr lff')
q-item-section(side)
q-checkbox(:model-value='scope.selected', @update:model-value='scope.toggleOption(scope.opt)')
q-item-section
q-item-label(v-html='scope.opt.name')
q-item-label
span(v-html='scope.opt.name')
q-select.q-mt-sm(
outlined
v-model='state.params.filterEditor'
@ -154,7 +156,8 @@ q-layout(view='hHh Lpr lff')
q-item-section
q-item-label {{ item.title }}
q-item-label(v-if='item.description', caption) {{ item.description }}
q-item-label.text-highlight(v-if='item.highlight', caption, v-html='item.highlight')
q-item-label.text-highlight(v-if='item.highlight', caption)
span(v-html='item.highlight')
q-item-section(side)
.flex.layout-search-itemtags
q-chip(

@ -64,7 +64,7 @@ export const useSiteStore = defineStore('site', {
},
sideDialogShown: false,
sideDialogComponent: '',
docsBase: 'https://next.js.wiki/docs',
docsBase: 'https://beta.js.wiki/docs',
nav: {
currentId: null,
items: []

Loading…
Cancel
Save