feat: file manager folders

parent 0cbeec37
......@@ -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: {
......@@ -182,6 +183,18 @@ module.exports = {
}
},
/**
* 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
*/
async flushTempUploads(obj, args, context) {
......
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
}
......
# ===============================================
# 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",
......
<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>
\ No newline at end of file
<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>
\ No newline at end of file
<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>
\ No newline at end of file
<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>
......
<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 {
background-color: rgba(0,0,0,.05);
@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
......
<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,12 +29,16 @@ 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')
q-item-section Rename...
q-item(clickable)
......@@ -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>
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
......
This diff was suppressed by a .gitattributes entry.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment