feat: file manager folders

pull/6078/head
Nicolas Giard 2 years ago
parent 0cbeec37d6
commit 2a051637a6
No known key found for this signature in database
GPG Key ID: 85061B8F9D55B7C8

@ -220,7 +220,6 @@ exports.up = async knex => {
.createTable('pages', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('path').notNullable()
table.specificType('dotPath', 'ltree').notNullable().index()
table.string('hash').notNullable()
table.string('alias')
table.string('title').notNullable()
@ -285,6 +284,18 @@ exports.up = async knex => {
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
})
// TREE --------------------------------
.createTable('tree', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.specificType('folderPath', 'ltree').index().index('tree_folderpath_gist_index', { indexType: 'GIST' })
table.string('fileName').notNullable().index()
table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
table.uuid('targetId').index()
table.string('title').notNullable()
table.jsonb('meta').notNullable().defaultTo('{}')
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
})
// USER AVATARS ------------------------
.createTable('userAvatars', table => {
table.uuid('id').notNullable().primary()
@ -379,6 +390,9 @@ exports.up = async knex => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
table.unique(['siteId', 'tag'])
})
.table('tree', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
.table('userKeys', table => {
table.uuid('userId').notNullable().references('id').inTable('users')
})

@ -2,6 +2,7 @@ const _ = require('lodash')
const sanitize = require('sanitize-filename')
const graphHelper = require('../../helpers/graph')
const assetHelper = require('../../helpers/asset')
const { setTimeout } = require('node:timers/promises')
module.exports = {
Query: {
@ -181,6 +182,18 @@ module.exports = {
return graphHelper.generateError(err)
}
},
/**
* Upload Assets
*/
async uploadAssets(obj, args, context) {
try {
return {
operation: graphHelper.generateSuccess('Asset(s) uploaded successfully.')
}
} catch (err) {
return graphHelper.generateError(err)
}
},
/**
* Flush Temporary Uploads
*/

@ -0,0 +1,148 @@
const _ = require('lodash')
const graphHelper = require('../../helpers/graph')
const typeResolvers = {
folder: 'TreeItemFolder',
page: 'TreeItemPage',
asset: 'TreeItemAsset'
}
const rePathName = /^[a-z0-9_]+$/
const reTitle = /^[^<>"]+$/
module.exports = {
Query: {
async tree (obj, args, context, info) {
// Offset
const offset = args.offset || 0
if (offset < 0) {
throw new Error('Invalid Offset')
}
// Limit
const limit = args.limit || 100
if (limit < 1 || limit > 100) {
throw new Error('Invalid Limit')
}
// Order By
const orderByDirection = args.orderByDirection || 'asc'
const orderBy = args.orderBy || 'title'
// Parse depth
const depth = args.depth || 0
if (depth < 0 || depth > 10) {
throw new Error('Invalid Depth')
}
const depthCondition = depth > 0 ? `*{,${depth}}` : '*{0}'
// Get parent path
let parentPath = ''
if (args.parentId) {
const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
if (parent) {
parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
}
} else if (args.parentPath) {
parentPath = args.parentPath.replaceAll('/', '.').replaceAll('-', '_').toLowerCase()
}
const folderPathCondition = parentPath ? `${parentPath}.${depthCondition}` : depthCondition
// Fetch Items
const items = await WIKI.db.knex('tree')
.select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
.where(builder => {
builder.where('folderPath', '~', folderPathCondition)
if (args.includeAncestors) {
const parentPathParts = parentPath.split('.')
for (let i = 1; i <= parentPathParts.length; i++) {
builder.orWhere({
folderPath: _.dropRight(parentPathParts, i).join('.'),
fileName: _.nth(parentPathParts, i * -1)
})
}
}
})
.andWhere(builder => {
if (args.types && args.types.length > 0) {
builder.whereIn('type', args.types)
}
})
.limit(limit)
.offset(offset)
.orderBy([
{ column: 'depth' },
{ column: orderBy, order: orderByDirection }
])
return items.map(item => ({
id: item.id,
depth: item.depth,
type: item.type,
folderPath: item.folderPath.replaceAll('.', '/').replaceAll('_', '-'),
fileName: item.fileName,
title: item.title,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
...(item.type === 'folder') && {
childrenCount: 0
}
}))
}
},
Mutation: {
async createFolder (obj, args, context) {
try {
// Get parent path
let parentPath = ''
if (args.parentId) {
const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
parentPath = parent ? `${parent.folderPath}.${parent.fileName}` : ''
if (parent) {
parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
}
}
// Validate path name
const pathName = args.pathName.replaceAll('-', '_')
if (!rePathName.test(pathName)) {
throw new Error('ERR_INVALID_PATH_NAME')
}
// Validate title
if (!reTitle.test(args.title)) {
throw new Error('ERR_INVALID_TITLE')
}
// Check for collision
const existingFolder = await WIKI.db.knex('tree').where({
siteId: args.siteId,
folderPath: parentPath,
fileName: pathName
}).first()
if (existingFolder) {
throw new Error('ERR_FOLDER_ALREADY_EXISTS')
}
// Create folder
await WIKI.db.knex('tree').insert({
folderPath: parentPath,
fileName: pathName,
type: 'folder',
title: args.title,
siteId: args.siteId
})
return {
operation: graphHelper.generateSuccess('Folder created successfully')
}
} catch (err) {
return graphHelper.generateError(err)
}
}
},
TreeItem: {
__resolveType (obj, context, info) {
return typeResolvers[obj.type] ?? null
}
}
}

@ -29,6 +29,11 @@ extend type Mutation {
id: Int!
): DefaultResponse
uploadAssets(
siteId: UUID!
files: [Upload!]!
): DefaultResponse
flushTempUploads: DefaultResponse
}

@ -0,0 +1,98 @@
# ===============================================
# TREE
# ===============================================
extend type Query {
tree(
siteId: UUID!
parentId: UUID
parentPath: String
types: [TreeItemType]
limit: Int
offset: Int
orderBy: TreeOrderBy
orderByDirection: OrderByDirection
depth: Int
includeAncestors: Boolean
): [TreeItem]
}
extend type Mutation {
createFolder(
siteId: UUID!
parentId: UUID
pathName: String!
title: String!
): DefaultResponse
deleteFolder(
folderId: UUID!
): DefaultResponse
duplicateFolder(
folderId: UUID!
targetParentId: UUID
targetPathName: String!
targetTitle: String!
): DefaultResponse
moveFolder(
folderId: UUID!
targetParentId: UUID
): DefaultResponse
renameFolder(
folderId: UUID!
pathName: String
title: String
): DefaultResponse
}
# -----------------------------------------------
# TYPES
# -----------------------------------------------
enum TreeItemType {
asset
folder
page
}
enum TreeOrderBy {
createdAt
fileName
title
updatedAt
}
type TreeItemFolder {
id: UUID
childrenCount: Int
depth: Int
fileName: String
folderPath: String
title: String
}
type TreeItemPage {
id: UUID
createdAt: Date
depth: Int
fileName: String
folderPath: String
pageEditor: String
pageType: String
title: String
updatedAt: Date
}
type TreeItemAsset {
id: UUID
createdAt: Date
depth: Int
fileName: String
# In Bytes
fileSize: Int
fileType: String
folderPath: String
title: String
updatedAt: Date
}
union TreeItem = TreeItemFolder | TreeItemPage | TreeItemAsset

@ -249,7 +249,7 @@ module.exports = class Page extends Model {
}
opts.path = opts.path.toLowerCase()
const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
// const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
// -> Check for page access
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
@ -310,7 +310,7 @@ module.exports = class Page extends Model {
},
contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
description: opts.description,
dotPath: dotPath,
// dotPath: dotPath,
editor: opts.editor,
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
icon: opts.icon,

@ -72,6 +72,7 @@
"pinia": "2.0.23",
"pug": "3.0.2",
"quasar": "2.10.1",
"slugify": "1.6.5",
"socket.io-client": "4.5.3",
"tippy.js": "6.3.7",
"uuid": "9.0.0",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5,35.5v-31h10.293l3,3H38.5V34c0,0.827-0.673,1.5-1.5,1.5H1.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v26c0,0.551-0.449,1-1,1H2V5H11.586 M12,4H1v32h36 c1.105,0,2-0.895,2-2V7H15L12,4L12,4z"/><path fill="#dff0fe" d="M1.5,35.5v-26h10.651l3-2H38.5V34c0,0.827-0.673,1.5-1.5,1.5H1.5z"/><path fill="#4788c7" d="M38,8v26c0,0.551-0.449,1-1,1H2V10h10h0.303l0.252-0.168L15.303,8H38 M39,7H15l-3,2H1v27h36 c1.105,0,2-0.895,2-2V7L39,7z"/><path fill="#98ccfd" d="M31 22.5A8.5 8.5 0 1 0 31 39.5A8.5 8.5 0 1 0 31 22.5Z"/><path fill="#4788c7" d="M31,23c4.411,0,8,3.589,8,8s-3.589,8-8,8s-8-3.589-8-8S26.589,23,31,23 M31,22 c-4.971,0-9,4.029-9,9s4.029,9,9,9s9-4.029,9-9S35.971,22,31,22L31,22z"/><path fill="none" stroke="#fff" stroke-miterlimit="10" stroke-width="2" d="M31 36L31 26M26 31L36 31"/></svg>

After

Width:  |  Height:  |  Size: 919 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v27H2V5H11.586 M12,4H1v32h38V7H15L12,4L12,4z"/><g><path fill="#dff0fe" d="M12.5 35.5L12.5 15.5 20.793 15.5 23.793 18.5 38.5 18.5 38.5 35.5z"/><path fill="#4788c7" d="M20.586,16l2.707,2.707L23.586,19H24h14v16H13V16H20.586 M21,15h-9v21h27V18H24L21,15L21,15z"/></g></svg>

After

Width:  |  Height:  |  Size: 502 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M1.5 35.5L1.5 4.5 11.793 4.5 14.793 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M11.586,5l2.707,2.707L14.586,8H15h23v27H2V5H11.586 M12,4H1v32h38V7H15L12,4L12,4z"/><g><path fill="#dff0fe" d="M1.5 35.5L1.5 9.5 12.151 9.5 15.151 7.5 38.5 7.5 38.5 35.5z"/><path fill="#4788c7" d="M38,8v27H2V10h10h0.303l0.252-0.168L15.303,8H38 M39,7H15l-3,2H1v27h38V7L39,7z"/></g></svg>

After

Width:  |  Height:  |  Size: 483 B

@ -1,5 +1,5 @@
<template lang="pug">
q-layout(view='hHh lpR lFr', container)
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')
@ -28,32 +28,87 @@ q-layout(view='hHh lpR lFr', container)
q-btn(
flat
dense
color='white'
:label='t(`common.actions.close`)'
no-caps
color='red-3'
:aria-label='t(`common.actions.close`)'
icon='las la-times'
@click='close'
)
q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
q-drawer.bg-blue-grey-1(:model-value='true', :width='350')
q-drawer.fileman-left(:model-value='true', :width='350')
.q-px-md.q-pb-sm
tree(
:nodes='state.treeNodes'
:roots='state.treeRoots'
v-model:selected='state.currentFolderId'
@lazy-load='treeLazyLoad'
:use-lazy-load='true'
@context-action='treeContextAction'
)
q-drawer.bg-grey-1(:model-value='true', :width='350', side='right')
q-drawer.fileman-right(:model-value='true', :width='350', side='right')
.q-pa-md
q-img.rounded-borders(
template(v-if='currentFileDetails')
q-img.rounded-borders.q-mb-md(
src='https://picsum.photos/id/134/340/340'
width='100%'
fit='cover'
:ratio='16/10'
no-spinner
)
.fileman-details-row(
v-for='item of currentFileDetails.items'
)
label {{item.label}}
span {{item.value}}
q-page-container
q-page.bg-white
q-toolbar.bg-grey-1
q-page.fileman-center
//- TOOLBAR -----------------------------------------------------
q-toolbar.fileman-toolbar
template(v-if='state.isUploading')
.fileman-progressbar
div(:style='`width: ` + state.uploadPercentage + `%`') {{state.uploadPercentage}}%
q-btn.acrylic-btn.q-ml-sm(
flat
dense
no-caps
color='negative'
:aria-label='t(`common.actions.cancel`)'
icon='las la-square'
@click='uploadCancel'
v-if='state.uploadPercentage < 100'
)
template(v-else)
q-space
q-btn.q-mr-sm(
flat
dense
no-caps
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-menu(
auto-close
transition-show='jump-down'
transition-hide='jump-up'
anchor='bottom right'
self='top right'
)
q-card.q-pa-sm
.text-center
small.text-grey {{t(`fileman.viewOptions`)}}
q-list(dense)
q-separator.q-my-sm
q-item(clickable)
q-item-section(side)
q-icon(name='las la-circle', color='grey', size='xs')
q-item-section.q-pr-sm Compact List
q-item(clickable)
q-item-section(side)
q-icon(name='las la-check-circle', color='positive', size='xs')
q-item-section.q-pr-sm Show Folders
q-btn.q-mr-sm(
flat
dense
@ -75,7 +130,11 @@ q-layout(view='hHh lpR lFr', container)
icon='las la-plus-circle'
@click=''
)
new-menu(hide-asset-btn)
new-menu(
:hide-asset-btn='true'
:show-new-folder='true'
@new-folder='() => newFolder(state.currentFolderId)'
)
q-btn(
flat
dense
@ -84,57 +143,99 @@ q-layout(view='hHh lpR lFr', container)
:label='t(`common.actions.upload`)'
:aria-label='t(`common.actions.upload`)'
icon='las la-cloud-upload-alt'
@click=''
@click='uploadFile'
)
q-list.fileman-filelist
q-item(
v-for='item of files'
:key='item.id'
clickable
active-class='active'
:active='item.id === state.currentFileId'
@click.native='state.currentFileId = item.id'
@dblclick.native='openItem(item)'
)
q-item-section.fileman-filelist-icon(avatar)
q-icon(:name='item.icon', size='xl')
q-item-section.fileman-filelist-label
q-item-label {{item.title}}
q-item-label(caption) {{item.caption}}
q-item-section.fileman-filelist-side(side, v-if='item.side')
.text-caption {{item.side}}
//- RIGHT-CLICK MENU
q-menu(
touch-position
context-menu
auto-close
transition-show='jump-down'
transition-hide='jump-up'
)
q-card.q-pa-sm
q-list(dense, style='min-width: 150px;')
q-item(clickable, v-if='item.type === `page`')
q-item-section(side)
q-icon(name='las la-edit', color='orange')
q-item-section Edit
q-item(clickable, v-if='item.type !== `folder`')
q-item-section(side)
q-icon(name='las la-eye', color='primary')
q-item-section View
q-item(clickable, v-if='item.type !== `folder`')
q-item-section(side)
q-icon(name='las la-clipboard', color='primary')
q-item-section Copy URL
q-item(clickable)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-folder.svg', size='xl')
q-item-section
q-item-label Beep Boop
q-item-label(caption) 19 Items
q-item-section(side)
.text-caption 1
q-icon(name='las la-copy', color='teal')
q-item-section Duplicate...
q-item(clickable)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-folder.svg', size='xl')
q-item-section
q-item-label Beep Boop
q-item-label(caption) 19 Items
q-item-section(side)
.text-caption 1
q-icon(name='las la-redo', color='teal')
q-item-section Rename...
q-item(clickable)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/color-document.svg', size='xl')
q-item-section
q-item-label Beep Boop
q-item-label(caption) Markdown
q-item-section(side)
.text-caption 1
q-icon(name='las la-arrow-right', color='teal')
q-item-section Move to...
q-item(clickable)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/color-pdf.svg', size='xl')
q-item-section
q-item-label Beep Boop
q-item-label(caption) 4 pages
q-item-section(side)
.text-caption 2022/01/01
q-icon(name='las la-trash-alt', color='negative')
q-item-section.text-negative Delete
q-footer
q-bar.bg-blue-grey-1
q-bar.fileman-path
small.text-caption.text-grey-7 / foo / bar
input(
type='file'
ref='fileIpt'
multiple
@change='uploadNewFiles'
style='display: none'
)
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { reactive } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { filesize } from 'filesize'
import { useQuasar } from 'quasar'
import { DateTime } from 'luxon'
import { cloneDeep, find } from 'lodash-es'
import gql from 'graphql-tag'
import NewMenu from './PageNewMenu.vue'
import Tree from './TreeNav.vue'
import fileTypes from '../helpers/fileTypes'
import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
// QUASAR
const $q = useQuasar()
// STORES
const pageStore = usePageStore()
@ -149,31 +250,135 @@ const { t } = useI18n()
const state = reactive({
loading: 0,
search: '',
currentFolderId: 'boop',
treeNodes: {
beep: {
text: 'Beep',
children: ['foo', 'bar']
},
foo: {
text: 'Foo'
currentFolderId: '',
currentFileId: '',
treeNodes: {},
treeRoots: [],
isUploading: false,
shouldCancelUpload: false,
uploadPercentage: 0,
fileList: [
{
id: '1',
type: 'folder',
title: 'Beep Boop',
children: 19
},
bar: {
text: 'Bar',
children: ['boop']
{
id: '2',
type: 'folder',
title: 'Second Folder',
children: 0
},
boop: {
text: 'Boop'
{
id: '3',
type: 'page',
title: 'Some Page',
pageType: 'markdown',
updatedAt: '2022-11-24T18:27:00Z'
},
bop: {
text: 'Bop',
children: ['bap']
},
bap: {
text: 'Bap'
{
id: '4',
type: 'file',
title: 'Important Document',
fileType: 'pdf',
fileSize: 19000
}
},
treeRoots: ['beep', 'bop']
]
})
// REFS
const fileIpt = ref(null)
// COMPUTED
const files = computed(() => {
return state.fileList.map(f => {
switch (f.type) {
case 'folder': {
f.icon = fileTypes.folder.icon
f.caption = t('fileman.folderChildrenCount', f.children, { count: f.children })
break
}
case 'page': {
f.icon = fileTypes.page.icon
f.caption = t(`fileman.${f.pageType}PageType`)
break
}
case 'file': {
f.icon = fileTypes[f.fileType]?.icon ?? ''
f.side = filesize(f.fileSize)
if (fileTypes[f.fileType]) {
f.caption = t(`fileman.${f.fileType}FileType`)
} else {
f.caption = t('fileman.unknownFileType', { type: f.fileType.toUpperCase() })
}
break
}
}
return f
})
})
const currentFileDetails = computed(() => {
if (state.currentFileId) {
const item = find(state.fileList, ['id', state.currentFileId])
if (item.type === 'folder') {
return null
}
const items = [
{
label: t('fileman.detailsTitle'),
value: item.title
}
]
switch (item.type) {
case 'page': {
items.push({
label: t('fileman.detailsPageType'),
value: t(`fileman.${item.pageType}PageType`)
})
items.push({
label: t('fileman.detailsPageEditor'),
value: item.pageType
})
items.push({
label: t('fileman.detailsPageUpdated'),
value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
})
items.push({
label: t('fileman.detailsPageCreated'),
value: DateTime.fromISO(item.updatedAt).toFormat('yyyy-MM-dd \'at\' h:mm ZZZZ')
})
break
}
case 'file': {
items.push({
label: t('fileman.detailsAssetType'),
value: fileTypes[item.fileType] ? t(`fileman.${item.fileType}FileType`) : t('fileman.unknownFileType', { type: item.fileType.toUpperCase() })
})
items.push({
label: t('fileman.detailsAssetSize'),
value: filesize(item.fileSize)
})
break
}
}
return {
thumbnail: '',
items
}
} else {
return null
}
})
// WATCHERS
watch(() => state.currentFolderId, (newValue) => {
state.currentFileId = null
})
// METHODS
@ -182,21 +387,355 @@ function close () {
siteStore.overlay = null
}
function openFolder (node, noder) {
console.info(node, noder)
async function treeLazyLoad (nodeId, { done, fail }) {
await loadTree(nodeId, ['folder'])
done()
}
async function loadTree (parentId, types) {
try {
const resp = await APOLLO_CLIENT.query({
query: gql`
query loadTree (
$siteId: UUID!
$parentId: UUID
$types: [TreeItemType]
) {
tree (
siteId: $siteId
parentId: $parentId
types: $types
) {
__typename
... on TreeItemFolder {
id
folderPath
fileName
title
childrenCount
}
... on TreeItemPage {
id
folderPath
fileName
title
createdAt
updatedAt
pageEditor
}
... on TreeItemAsset {
id
folderPath
fileName
title
createdAt
updatedAt
fileSize
}
}
}
`,
variables: {
siteId: siteStore.id,
parentId,
types
},
fetchPolicy: 'network-only'
})
const items = cloneDeep(resp?.data?.tree)
if (items?.length > 0) {
const newTreeRoots = []
for (const item of items) {
switch (item.__typename) {
case 'TreeItemFolder': {
state.treeNodes[item.id] = {
text: item.title,
children: []
}
if (!item.folderPath) {
newTreeRoots.push(item.id)
} else {
state.treeNodes[parentId].children.push(item.id)
}
break
}
}
}
if (newTreeRoots.length > 0) {
state.treeRoots = newTreeRoots
}
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to load folder tree.',
caption: err.message
})
}
}
function treeContextAction (nodeId, action) {
console.info(nodeId, action)
switch (action) {
case 'newFolder': {
newFolder(nodeId)
break
}
}
}
function newFolder (parentId) {
$q.dialog({
component: FolderCreateDialog,
componentProps: {
parentId
}
}).onOk(() => {
loadTree(parentId)
})
}
// -> Upload Methods
function uploadFile () {
fileIpt.value.click()
}
async function uploadNewFiles () {
if (!fileIpt.value.files?.length) {
return
}
console.info(fileIpt.value.files)
state.isUploading = true
state.uploadPercentage = 0
state.loading++
nextTick(() => {
setTimeout(async () => {
try {
const totalFiles = fileIpt.value.files.length
let idx = 0
for (const fileToUpload of fileIpt.value.files) {
idx++
state.uploadPercentage = totalFiles > 1 ? Math.round(idx / totalFiles * 100) : 90
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation uploadAssets (
$siteId: UUID!
$files: [Upload!]!
) {
uploadAssets (
siteId: $siteId
files: $files
) {
operation {
succeeded
message
}
}
}
`,
variables: {
siteId: siteStore.id,
files: [fileToUpload]
}
})
if (!resp?.data?.uploadAssets?.operation?.succeeded) {
throw new Error(resp?.data?.uploadAssets?.operation?.message || 'An unexpected error occured.')
}
}
state.uploadPercentage = 100
$q.notify({
type: 'positive',
message: t('fileman.uploadSuccess')
})
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to upload file.',
caption: err.message
})
}
state.loading--
fileIpt.value.value = null
setTimeout(() => {
state.isUploading = false
state.uploadPercentage = 0
}, 1500)
}, 400)
})
}
function uploadCancel () {
state.isUploading = false
state.uploadPercentage = 0
}
function openItem (item) {
console.info(item.id)
}
// MOUNTED
onMounted(() => {
loadTree()
})
</script>
<style lang="scss">
.fileman {
&-left {
@at-root .body--light & {
background-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
}
}
&-center {
@at-root .body--light & {
background-color: #FFF;
}
@at-root .body--dark & {
background-color: $dark-6;
}
}
&-right {
@at-root .body--light & {
background-color: $grey-1;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
&-toolbar {
@at-root .body--light & {
background-color: $grey-1;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
&-path {
@at-root .body--light & {
background-color: $blue-grey-1 !important;
}
@at-root .body--dark & {
background-color: $dark-4 !important;
}
}
&-filelist {
padding: 8px 12px;
> .q-item {
padding: 8px 6px;
border-radius: 8px;
&.active {
background-color: var(--q-primary);
color: #FFF;
.fileman-filelist-label .q-item__label--caption {
color: rgba(255,255,255,.7);
}
.fileman-filelist-side .text-caption {
color: rgba(255,255,255,.7);
}
}
}
}
&-details-row {
display: flex;
flex-direction: column;
padding: 5px 0;
label {
font-size: .7rem;
font-weight: 500;
@at-root .body--light & {
color: $grey-6;
}
@at-root .body--dark & {
color: $blue-grey-4;
}
}
span {
font-size: .85rem;
@at-root .body--light & {
color: $grey-8;
}
@at-root .body--dark & {
color: $blue-grey-2;
}
}
& + .fileman-details-row {
margin-top: 5px;
}
}
&-progressbar {
width: 100%;
flex: 1;
height: 12px;
border-radius: 3px;
@at-root .body--light & {
background-color: $blue-grey-2;
}
@at-root .body--dark & {
background-color: $dark-4 !important;
}
> div {
height: 12px;
background-color: $positive;
border-radius: 3px 0 0 3px;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.3) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0.3) 75%,
transparent 75%,
transparent
);
background-size: 50px 50px;
background-position: 0 0;
animation: fileman-progress 2s linear infinite;
box-shadow: 0 0 5px 0 $positive;
font-size: 9px;
letter-spacing: 2px;
font-weight: 700;
color: #FFF;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
transition: all 1s ease;
}
}
}
@keyframes fileman-progress {
0% {
background-position: 0 0;
}
100% {
background-position: -50px -50px;
}
}
</style>

@ -0,0 +1,185 @@
<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-plus-plus.svg', left, size='sm')
span {{t(`fileman.folderCreate`)}}
q-form.q-py-sm(ref='newFolderForm', @submit='create')
q-item
blueprint-icon(icon='folder')
q-item-section
q-input(
outlined
v-model='state.title'
dense
:rules='titleValidation'
hide-bottom-space
:label='t(`fileman.folderTitle`)'
:aria-label='t(`fileman.folderTitle`)'
lazy-rules='ondemand'
autofocus
ref='iptTitle'
)
q-item
blueprint-icon.self-start(icon='file-submodule')
q-item-section
q-input(
outlined
v-model='state.path'
dense
:rules='pathValidation'
hide-bottom-space
:label='t(`fileman.folderFileName`)'
:aria-label='t(`fileman.folderFileName`)'
:hint='t(`fileman.folderFileNameHint`)'
lazy-rules='ondemand'
@focus='state.pathDirty = true'
)
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.create`)'
color='primary'
padding='xs md'
@click='create'
:loading='state.loading > 0'
)
</template>
<script setup>
import gql from 'graphql-tag'
import { useI18n } from 'vue-i18n'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { reactive, ref, watch } from 'vue'
import slugify from 'slugify'
import { useSiteStore } from 'src/stores/site'
// PROPS
const props = defineProps({
parentId: {
type: String,
default: null
}
})
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
path: '',
title: '',
pathDirty: false,
loading: false
})
// REFS
const newFolderForm = ref(null)
const iptTitle = ref(null)
// VALIDATION RULES
const titleValidation = [
val => val.length > 0 || t('fileman.folderTitleMissing'),
val => /^[^<>"]+$/.test(val) || t('fileman.folderTitleInvalidChars')
]
const pathValidation = [
val => val.length > 0 || t('fileman.folderFileNameMissing'),
val => /^[a-z0-9-]+$/.test(val) || t('fileman.folderFileNameInvalid')
]
// WATCHERS
watch(() => state.title, (newValue) => {
if (state.pathDirty && !state.path) {
state.pathDirty = false
}
if (!state.pathDirty) {
state.path = slugify(newValue, { lower: true, strict: true })
}
})
// METHODS
async function create () {
state.loading++
try {
const isFormValid = await newFolderForm.value.validate(true)
if (!isFormValid) {
throw new Error(t('fileman.createFolderInvalidData'))
}
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation createFolder (
$siteId: UUID!
$parentId: UUID
$pathName: String!
$title: String!
) {
createFolder (
siteId: $siteId
parentId: $parentId
pathName: $pathName
title: $title
) {
operation {
succeeded
message
}
}
}
`,
variables: {
siteId: siteStore.id,
parentId: props.parentId,
pathName: state.path,
title: state.title
}
})
if (resp?.data?.createFolder?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('fileman.createFolderSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.createFolder?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.loading--
}
</script>

@ -83,7 +83,7 @@ q-header.bg-header.text-white.site-header(
icon='las la-folder-open'
color='positive'
aria-label='File Manager'
@click='toggleFileManager'
@click='openFileManager'
)
q-tooltip File Manager
q-btn.q-ml-md(
@ -129,7 +129,7 @@ const state = reactive({
// METHODS
function toggleFileManager () {
function openFileManager () {
siteStore.overlay = 'FileManager'
}
</script>

@ -0,0 +1,31 @@
<template lang="pug">
q-dialog.main-overlay(
v-model='siteStore.overlayIsShown'
persistent
full-width
full-height
no-shake
transition-show='jump-up'
transition-hide='jump-down'
)
component(:is='overlays[siteStore.overlay]')
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
import { useSiteStore } from '../stores/site'
import LoadingGeneric from './LoadingGeneric.vue'
const overlays = {
FileManager: defineAsyncComponent({
loader: () => import('./FileManager.vue'),
loadingComponent: LoadingGeneric
})
}
// STORES
const siteStore = useSiteStore()
</script>

@ -28,6 +28,11 @@ q-menu.translucent-menu(
q-item(clickable, @click='openFileManager')
blueprint-icon(icon='add-image')
q-item-section.q-pr-sm Upload Media Asset
template(v-if='props.showNewFolder')
q-separator.q-my-sm(inset)
q-item(clickable, @click='newFolder')
blueprint-icon(icon='add-folder')
q-item-section.q-pr-sm New Folder
</template>
<script setup>
@ -43,9 +48,17 @@ const props = defineProps({
hideAssetBtn: {
type: Boolean,
default: false
},
showNewFolder: {
type: Boolean,
default: false
}
})
// EMITS
const emit = defineEmits(['newFolder'])
// QUASAR
const $q = useQuasar()
@ -69,4 +82,8 @@ function create (editor) {
function openFileManager () {
siteStore.overlay = 'FileManager'
}
function newFolder () {
emit('newFolder')
}
</script>

@ -4,7 +4,7 @@ ul.treeview-level
li.treeview-node(v-if='!props.parentId')
.treeview-label(@click='setRoot', :class='{ "active": !selection }')
q-icon(name='img:/_assets/icons/fluent-ftp.svg', size='sm')
em.text-purple root
.treeview-label-text(:class='$q.dark.isActive ? `text-purple-4` : `text-purple`') root
q-menu(
touch-position
context-menu
@ -14,10 +14,15 @@ ul.treeview-level
)
q-card.q-pa-sm
q-list(dense, style='min-width: 150px;')
q-item(clickable)
q-item(clickable, @click='createRootFolder')
q-item-section(side)
q-icon(name='las la-plus-circle', color='primary')
q-item-section New Folder
q-icon(
v-if='!selection'
name='las la-angle-right'
:color='$q.dark.isActive ? `purple-4` : `purple`'
)
//- NORMAL NODES
tree-node(
v-for='node of level'
@ -30,6 +35,7 @@ ul.treeview-level
<script setup>
import { computed, inject } from 'vue'
import { useQuasar } from 'quasar'
import TreeNode from './TreeNode.vue'
@ -46,18 +52,23 @@ const props = defineProps({
}
})
// QUASAR
const $q = useQuasar()
// INJECT
const roots = inject('roots', [])
const roots = inject('roots')
const nodes = inject('nodes')
const selection = inject('selection')
const emitContextAction = inject('emitContextAction')
// COMPUTED
const level = computed(() => {
const items = []
if (!props.parentId) {
for (const root of roots) {
for (const root of roots.value) {
items.push({
id: root,
...nodes[root]
@ -80,4 +91,8 @@ function setRoot () {
selection.value = null
}
function createRootFolder () {
emitContextAction(null, 'newFolder')
}
</script>

@ -7,7 +7,7 @@
</template>
<script setup>
import { computed, onMounted, provide, reactive } from 'vue'
import { computed, onMounted, provide, reactive, toRef } from 'vue'
import { findKey } from 'lodash-es'
import TreeLevel from './TreeLevel.vue'
@ -26,16 +26,21 @@ const props = defineProps({
selected: {
type: String,
default: null
},
useLazyLoad: {
type: Boolean,
default: false
}
})
// EMITS
const emit = defineEmits(['update:selected'])
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
// DATA
const state = reactive({
loaded: {},
opened: {}
})
@ -52,12 +57,27 @@ const selection = computed({
// METHODS
function emitLazyLoad (nodeId, clb) {
if (props.useLazyLoad) {
emit('lazyLoad', nodeId, clb)
} else {
clb.done()
}
}
function emitContextAction (nodeId, action) {
emit('contextAction', nodeId, action)
}
// PROVIDE
provide('roots', props.roots)
provide('roots', toRef(props, 'roots'))
provide('nodes', props.nodes)
provide('loaded', state.loaded)
provide('opened', state.opened)
provide('selection', selection)
provide('emitLazyLoad', emitLazyLoad)
provide('emitContextAction', emitContextAction)
// MOUNTED
@ -102,6 +122,13 @@ onMounted(() => {
&-node {
display: block;
border-left: 2px solid rgba(0,0,0,.05);
@at-root .body--light & {
border-left: 2px solid rgba(0,0,0,.05);
}
@at-root .body--dark & {
border-left: 2px solid rgba(255,255,255,.1);
}
}
&-label {
@ -113,12 +140,21 @@ onMounted(() => {
transition: background-color .4s ease;
&:hover, &:focus, &.active {
@at-root .body--light & {
background-color: rgba(0,0,0,.05);
}
@at-root .body--dark & {
background-color: rgba(255,255,255,.1);
}
}
> .q-icon {
margin-right: 5px;
}
&-text {
flex: 1 0;
}
}
// Animations

@ -1,9 +1,22 @@
<template lang="pug">
li.treeview-node
//- NODE
.treeview-label(@click='toggleNode', :class='{ "active": isActive }')
q-icon(:name='icon', size='sm')
span {{node.text}}
.treeview-label(@click='openNode', :class='{ "active": isActive }')
q-icon(
:name='icon'
size='sm'
@click.stop='hasChildren ? toggleNode() : openNode()'
)
.treeview-label-text {{node.text}}
q-spinner.q-mr-xs(
color='primary'
v-if='state.isLoading'
)
q-icon(
v-if='isActive'
name='las la-angle-right'
:color='$q.dark.isActive ? `yellow-9` : `brown-4`'
)
//- RIGHT-CLICK MENU
q-menu(
touch-position
@ -16,10 +29,14 @@ li.treeview-node
)
q-card.q-pa-sm
q-list(dense, style='min-width: 150px;')
q-item(clickable)
q-item(clickable, @click='contextAction(`newFolder`)')
q-item-section(side)
q-icon(name='las la-plus-circle', color='primary')
q-item-section New Folder
q-item(clickable)
q-item-section(side)
q-icon(name='las la-copy', color='teal')
q-item-section Duplicate...
q-item(clickable)
q-item-section(side)
q-icon(name='las la-redo', color='teal')
@ -43,6 +60,7 @@ li.treeview-node
<script setup>
import { computed, inject, reactive } from 'vue'
import { useQuasar } from 'quasar'
import TreeLevel from './TreeLevel.vue'
@ -63,15 +81,23 @@ const props = defineProps({
}
})
// QUASAR
const $q = useQuasar()
// INJECT
const loaded = inject('loaded')
const opened = inject('opened')
const selection = inject('selection')
const emitLazyLoad = inject('emitLazyLoad')
const emitContextAction = inject('emitContextAction')
// DATA
const state = reactive({
isContextMenuShown: false
isContextMenuShown: false,
isLoading: false
})
// COMPUTED
@ -80,7 +106,7 @@ const icon = computed(() => {
if (props.node.icon) {
return props.node.icon
}
return hasChildren.value && isOpened.value ? 'img:/_assets/icons/fluent-opened-folder.svg' : 'img:/_assets/icons/fluent-folder.svg'
return isOpened.value ? 'img:/_assets/icons/fluent-opened-folder.svg' : 'img:/_assets/icons/fluent-folder.svg'
})
const hasChildren = computed(() => {
@ -95,13 +121,33 @@ const isActive = computed(() => {
// METHODS
function toggleNode () {
selection.value = props.node.id
async function toggleNode () {
opened[props.node.id] = !(opened[props.node.id] === true)
if (opened[props.node.id] && !loaded[props.node.id]) {
state.isLoading = true
await Promise.race([
new Promise((resolve, reject) => {
emitLazyLoad(props.node.id, { done: resolve, fail: reject })
}),
new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Async tree loading timeout')), 30000)
})
])
loaded[props.node.id] = true
state.isLoading = false
}
}
function openNode () {
selection.value = props.node.id
if (selection.value !== props.node.id && opened[props.node.id]) {
return
}
opened[props.node.id] = !(opened[props.node.id] === true)
toggleNode()
}
function contextAction (action) {
emitContextAction(props.node.id, action)
}
</script>

@ -0,0 +1,11 @@
export default {
folder: {
icon: 'img:/_assets/icons/fluent-folder.svg'
},
page: {
icon: 'img:/_assets/icons/color-document.svg'
},
pdf: {
icon: 'img:/_assets/icons/color-pdf.svg'
}
}

@ -291,8 +291,12 @@
"admin.groups.users": "Users",
"admin.groups.usersCount": "0 user | 1 user | {count} users",
"admin.groups.usersNone": "This group doesn't have any user yet.",
"admin.icons.mandatory": "Used by the system and cannot be disabled.",
"admin.icons.reference": "Reference",
"admin.icons.subtitle": "Configure the icon packs available for use",
"admin.icons.title": "Icons",
"admin.icons.warnHint": "Only activate the icon packs you actually use.",
"admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!",
"admin.instances.activeConnections": "Active Connections",
"admin.instances.activeListeners": "Active Listeners",
"admin.instances.firstSeen": "First Seen",
@ -1189,6 +1193,7 @@
"common.field.lastUpdated": "Last Updated",
"common.field.name": "Name",
"common.field.task": "Task",
"common.field.title": "Title",
"common.footerCopyright": "© {year} {company}. All rights reserved.",
"common.footerGeneric": "Powered by {link}, an open source project.",
"common.footerLicense": "Content is available under the {license}, by {company}.",
@ -1470,7 +1475,30 @@
"editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
"editor.unsaved.title": "Discard Unsaved Changes?",
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
"fileman.createFolderInvalidData": "One or more fields are invalid.",
"fileman.createFolderSuccess": "Folder created successfully.",
"fileman.detailsAssetSize": "File Size",
"fileman.detailsAssetType": "Type",
"fileman.detailsPageCreated": "Created",
"fileman.detailsPageEditor": "Editor",
"fileman.detailsPageType": "Type",
"fileman.detailsPageUpdated": "Last Updated",
"fileman.detailsTitle": "Title",
"fileman.folderChildrenCount": "Empty folder | 1 child | {count} children",
"fileman.folderCreate": "New Folder",
"fileman.folderFileName": "Path Name",
"fileman.folderFileNameHint": "URL friendly version of the folder name. Must consist of lowercase alphanumerical or hypen characters only.",
"fileman.folderFileNameInvalid": "Invalid Characters in Folder Path Name. Lowercase alphanumerical and hyphen characters only.",
"fileman.folderFileNameMissing": "Missing Folder Path Name",
"fileman.folderTitle": "Title",
"fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name",
"fileman.folderTitleMissing": "Missing Folder Title",
"fileman.markdownPageType": "Markdown Page",
"fileman.pdfFileType": "PDF Document",
"fileman.title": "File Manager",
"fileman.unknownFileType": "{type} file",
"fileman.uploadSuccess": "File(s) uploaded successfully.",
"fileman.viewOptions": "View Options",
"history.restore.confirmButton": "Restore",
"history.restore.confirmText": "Are you sure you want to restore this page content as it was on {date}? This version will be copied on top of the current history. As such, newer versions will still be preserved.",
"history.restore.confirmTitle": "Restore page version?",
@ -1558,9 +1586,5 @@
"welcome.admin": "Administration Area",
"welcome.createHome": "Create the homepage",
"welcome.subtitle": "Let's get started...",
"welcome.title": "Welcome to Wiki.js!",
"admin.icons.warnLabel": "Enabling additional icon packs can significantly increase page load times!",
"admin.icons.warnHint": "Only activate the icon packs you actually use.",
"admin.icons.reference": "Reference",
"admin.icons.mandatory": "Used by the system and cannot be disabled."
"welcome.title": "Welcome to Wiki.js!"
}

@ -27,6 +27,7 @@ q-layout(view='hHh Lpr lff')
label='Browse'
aria-label='Browse'
size='sm'
@click='openFileManager'
)
q-scroll-area.sidebar-nav(
:thumb-style='thumbStyle'
@ -76,22 +77,13 @@ q-layout(view='hHh Lpr lff')
round
size='md'
)
q-dialog.main-overlay(
v-model='siteStore.overlayIsShown'
persistent
full-width
full-height
no-shake
transition-show='jump-up'
transition-hide='jump-down'
)
component(:is='overlays[siteStore.overlay]')
main-overlay-dialog
footer-nav
</template>
<script setup>
import { useMeta, useQuasar, setCssVar } from 'quasar'
import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -99,16 +91,9 @@ import { useSiteStore } from '../stores/site'
// COMPONENTS
import HeaderNav from '../components/HeaderNav.vue'
import FooterNav from 'src/components/FooterNav.vue'
import LoadingGeneric from 'src/components/LoadingGeneric.vue'
const overlays = {
FileManager: defineAsyncComponent({
loader: () => import('../components/FileManager.vue'),
loadingComponent: LoadingGeneric
})
}
import HeaderNav from 'src/components/HeaderNav.vue'
import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
// QUASAR
@ -151,6 +136,12 @@ const barStyle = {
opacity: 0.1
}
// METHODS
function openFileManager () {
siteStore.overlay = 'FileManager'
}
</script>
<style lang="scss">

@ -39,6 +39,7 @@ q-layout(view='hHh Lpr lff')
q-item-section
q-item-label.text-negative {{ t('common.header.logout') }}
router-view
main-overlay-dialog
footer-nav
</template>
@ -52,6 +53,7 @@ import { useUserStore } from 'src/stores/user'
import HeaderNav from 'src/components/HeaderNav.vue'
import FooterNav from 'src/components/FooterNav.vue'
import MainOverlayDialog from 'src/components/MainOverlayDialog.vue'
// QUASAR

@ -6624,6 +6624,13 @@ __metadata:
languageName: node
linkType: hard
"slugify@npm:1.6.5":
version: 1.6.5
resolution: "slugify@npm:1.6.5"
checksum: a955a1b600201030f4c1daa9bb74a17d4402a0693fc40978bbd17e44e64fd72dad3bac4037422aa8aed55b5170edd57f3f4cd8f59ba331f5cf0f10f1a7795609
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
@ -7181,6 +7188,7 @@ __metadata:
pinia: 2.0.23
pug: 3.0.2
quasar: 2.10.1
slugify: 1.6.5
socket.io-client: 4.5.3
tippy.js: 6.3.7
uuid: 9.0.0

Loading…
Cancel
Save