feat: File Manager improvements + system flags

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

@ -71,8 +71,9 @@ defaults:
authJwtRenewablePeriod: '14d'
enforceSameOriginReferrerPolicy: true
flags:
ldapdebug: false
sqllog: false
experimental: false
authDebug: false
sqlLog: false
# System defaults
channel: NEXT
cors:

@ -133,7 +133,7 @@ module.exports = {
* Apply Dev Flags
*/
async applyFlags() {
WIKI.db.knex.client.config.debug = WIKI.config.flags.sqllog
WIKI.db.knex.client.config.debug = WIKI.config.flags.sqlLog
},
/**

@ -285,7 +285,7 @@ exports.up = async knex => {
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('localeCode', 5).notNullable().defaultTo('en').index()
table.string('title').notNullable()
table.jsonb('meta').notNullable().defaultTo('{}')
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
@ -372,6 +372,7 @@ exports.up = async knex => {
table.string('localeCode', 5).references('code').inTable('locales').index()
table.uuid('authorId').notNullable().references('id').inTable('users').index()
table.uuid('creatorId').notNullable().references('id').inTable('users').index()
table.uuid('ownerId').notNullable().references('id').inTable('users').index()
table.uuid('siteId').notNullable().references('id').inTable('sites').index()
})
.table('storage', table => {
@ -439,6 +440,14 @@ exports.up = async knex => {
guestUserId: userGuestId
}
},
{
key: 'flags',
value: {
experimental: false,
authDebug: false,
sqlLog: false
}
},
{
key: 'icons',
value: {

@ -11,9 +11,7 @@ const graphHelper = require('../../helpers/graph')
module.exports = {
Query: {
systemFlags () {
return _.transform(WIKI.config.flags, (result, value, key) => {
result.push({ key, value })
}, [])
return WIKI.config.flags
},
async systemInfo () { return {} },
async systemExtensions () {
@ -150,9 +148,10 @@ module.exports = {
}
},
async updateSystemFlags (obj, args, context) {
WIKI.config.flags = _.transform(args.flags, (result, row) => {
_.set(result, row.key, row.value)
}, {})
WIKI.config.flags = {
...WIKI.config.flags,
...args.flags
}
await WIKI.configSvc.applyFlags()
await WIKI.configSvc.saveToDb(['flags'])
return {
@ -164,7 +163,7 @@ module.exports = {
// TODO: broadcast config update
await WIKI.configSvc.saveToDb(['security'])
return {
status: graphHelper.generateSuccess('System Security configuration applied successfully')
operation: graphHelper.generateSuccess('System Security configuration applied successfully')
}
}
},

@ -7,7 +7,7 @@ const typeResolvers = {
asset: 'TreeItemAsset'
}
const rePathName = /^[a-z0-9_]+$/
const rePathName = /^[a-z0-9-]+$/
const reTitle = /^[^<>"]+$/
module.exports = {
@ -41,7 +41,7 @@ module.exports = {
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
parentPath = (parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName).replaceAll('-', '_')
}
} else if (args.parentPath) {
parentPath = args.parentPath.replaceAll('/', '.').replaceAll('-', '_').toLowerCase()
@ -101,11 +101,11 @@ module.exports = {
if (parent) {
parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
}
parentPath = parentPath.replaceAll('-', '_')
}
// Validate path name
const pathName = args.pathName.replaceAll('-', '_')
if (!rePathName.test(pathName)) {
if (!rePathName.test(args.pathName)) {
throw new Error('ERR_INVALID_PATH_NAME')
}
@ -118,7 +118,7 @@ module.exports = {
const existingFolder = await WIKI.db.knex('tree').where({
siteId: args.siteId,
folderPath: parentPath,
fileName: pathName
fileName: args.pathName
}).first()
if (existingFolder) {
throw new Error('ERR_FOLDER_ALREADY_EXISTS')
@ -127,7 +127,7 @@ module.exports = {
// Create folder
await WIKI.db.knex('tree').insert({
folderPath: parentPath,
fileName: pathName,
fileName: args.pathName,
type: 'folder',
title: args.title,
siteId: args.siteId

@ -4,7 +4,7 @@
extend type Query {
systemExtensions: [SystemExtension]
systemFlags: [SystemFlag]
systemFlags: JSON
systemInfo: SystemInfo
systemInstances: [SystemInstance]
systemSecurity: SystemSecurity
@ -31,7 +31,7 @@ extend type Mutation {
): DefaultResponse
updateSystemFlags(
flags: [SystemFlagInput]!
flags: JSON!
): DefaultResponse
updateSystemSecurity(
@ -60,16 +60,6 @@ extend type Mutation {
# TYPES
# -----------------------------------------------
type SystemFlag {
key: String
value: Boolean
}
input SystemFlagInput {
key: String!
value: Boolean!
}
type SystemInfo {
configFile: String
cpuCores: Int

@ -310,12 +310,12 @@ module.exports = class Page extends Model {
},
contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
description: opts.description,
// dotPath: dotPath,
editor: opts.editor,
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
icon: opts.icon,
isBrowsable: opts.isBrowsable ?? true,
localeCode: opts.locale,
ownerId: opts.user.id,
path: opts.path,
publishState: opts.publishState,
publishEndDate: opts.publishEndDate?.toISO(),
@ -339,6 +339,29 @@ module.exports = class Page extends Model {
// -> Render page to HTML
await WIKI.db.pages.renderPage(page)
// -> Add to tree
const pathParts = page.path.split('/')
await WIKI.db.knex('tree').insert({
id: page.id,
folderPath: _.initial(pathParts).join('/'),
fileName: _.last(pathParts),
type: 'page',
localeCode: page.localeCode,
title: page.title,
meta: {
authorId: page.authorId,
contentType: page.contentType,
creatorId: page.creatorId,
description: page.description,
isBrowsable: page.isBrowsable,
ownerId: page.ownerId,
publishState: page.publishState,
publishEndDate: page.publishEndDate,
publishStartDate: page.publishStartDate
},
siteId: page.siteId
})
return page
// TODO: Handle remaining flow
@ -590,6 +613,23 @@ module.exports = class Page extends Model {
}
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
// -> Update tree
await WIKI.db.knex('tree').where('id', page.id).update({
title: page.title,
meta: {
authorId: page.authorId,
contentType: page.contentType,
creatorId: page.creatorId,
description: page.description,
isBrowsable: page.isBrowsable,
ownerId: page.ownerId,
publishState: page.publishState,
publishEndDate: page.publishEndDate,
publishStartDate: page.publishStartDate
},
updatedAt: page.updatedAt
})
// // -> Update Search Index
// const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
// page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
@ -948,12 +988,10 @@ module.exports = class Page extends Model {
// -> Delete page
await WIKI.db.pages.query().delete().where('id', page.id)
await WIKI.db.knex('tree').where('id', page.id).del()
await WIKI.db.pages.deletePageFromCache(page.hash)
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
// -> Rebuild page tree
await WIKI.db.pages.rebuildTree()
// -> Delete from Search Index
await WIKI.data.searchEngine.deleted(page)

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M17.676,38.5l-1.125-4.501l-0.284-0.076c-2.458-0.658-4.696-1.946-6.473-3.724l-0.208-0.208 l-4.445,1.271l-2.324-4.025l3.335-3.225l-0.076-0.284C5.741,22.475,5.571,21.22,5.571,20s0.17-2.475,0.504-3.729l0.076-0.284 l-3.335-3.225l2.324-4.025l4.445,1.271l0.208-0.208c1.778-1.779,4.016-3.066,6.473-3.724l0.284-0.076L17.676,1.5h4.647 l1.125,4.501l0.284,0.076c2.457,0.657,4.695,1.945,6.473,3.724l0.208,0.208l4.445-1.271l2.324,4.025l-3.335,3.225l0.076,0.284 c0.335,1.253,0.505,2.507,0.505,3.728c0,1.22-0.17,2.475-0.504,3.729l-0.076,0.284l3.335,3.224l-2.324,4.026l-4.445-1.272 l-0.208,0.208c-1.777,1.779-4.016,3.066-6.473,3.724l-0.284,0.076L22.324,38.5H17.676z M20,12.036 c-4.392,0-7.964,3.573-7.964,7.964s3.573,7.964,7.964,7.964s7.964-3.573,7.964-7.964S24.392,12.036,20,12.036z"/><path fill="#4788c7" d="M21.933,2l0.959,3.837l0.143,0.571l0.569,0.152c2.372,0.635,4.532,1.878,6.248,3.594l0.416,0.417 l0.566-0.162l3.787-1.083l1.934,3.349l-2.843,2.749l-0.423,0.409l0.152,0.568c0.324,1.211,0.488,2.422,0.488,3.599 c0,1.177-0.164,2.388-0.488,3.599l-0.152,0.568l0.423,0.409l2.843,2.749l-1.934,3.349l-3.787-1.083l-0.566-0.162l-0.416,0.417 c-1.715,1.716-3.876,2.959-6.248,3.594l-0.569,0.152l-0.143,0.571L21.934,38h-3.867l-0.959-3.837l-0.143-0.571l-0.569-0.152 c-2.372-0.635-4.533-1.878-6.248-3.594l-0.416-0.417l-0.566,0.162l-3.787,1.083l-1.934-3.349l2.843-2.749l0.423-0.409 l-0.152-0.568C6.235,22.388,6.071,21.177,6.071,20s0.164-2.388,0.488-3.599l0.152-0.568l-0.423-0.409l-2.843-2.749l1.934-3.349 l3.787,1.083l0.566,0.162l0.416-0.417c1.715-1.716,3.876-2.959,6.248-3.594l0.569-0.152l0.143-0.571L18.066,2H21.933 M20,28.464 c4.667,0,8.464-3.797,8.464-8.464c0-4.667-3.797-8.464-8.464-8.464c-4.667,0-8.464,3.797-8.464,8.464 C11.536,24.667,15.333,28.464,20,28.464 M22.714,1h-5.429l-1.149,4.594c-2.569,0.688-4.871,2.027-6.696,3.853L4.903,8.149 l-2.714,4.701l3.405,3.292C5.264,17.375,5.071,18.664,5.071,20s0.192,2.625,0.522,3.857l-3.405,3.292l2.714,4.701l4.538-1.298 c1.825,1.826,4.128,3.165,6.697,3.853L17.286,39h5.429l1.148-4.594c2.569-0.688,4.872-2.027,6.697-3.853l4.538,1.298l2.714-4.701 l-3.405-3.292c0.329-1.232,0.522-2.521,0.522-3.857s-0.192-2.625-0.522-3.857l3.405-3.292l-2.714-4.701l-4.538,1.298 c-1.825-1.826-4.127-3.165-6.696-3.853L22.714,1L22.714,1z M20,27.464c-4.122,0-7.464-3.342-7.464-7.464 c0-4.122,3.342-7.464,7.464-7.464c4.122,0,7.464,3.342,7.464,7.464C27.464,24.122,24.122,27.464,20,27.464L20,27.464z"/><path fill="#dff0fe" d="M20,9C13.925,9,9,13.925,9,20c0,6.075,4.925,11,11,11s11-4.925,11-11C31,13.925,26.075,9,20,9z M20,24c-2.209,0-4-1.791-4-4c0-2.209,1.791-4,4-4s4,1.791,4,4C24,22.209,22.209,24,20,24z"/><path fill="#4788c7" d="M20,16c2.209,0,4,1.791,4,4c0,2.209-1.791,4-4,4s-4-1.791-4-4C16,17.791,17.791,16,20,16 M20,15 c-2.757,0-5,2.243-5,5s2.243,5,5,5s5-2.243,5-5S22.757,15,20,15L20,15z"/><path fill="#fff" d="M21.5 23.5H39.5V39.5H21.5z"/><path fill="#4788c7" d="M39,24v15H22V24H39 M40,23H21v17h19V23L40,23z"/><g><path fill="#4788c7" d="M21 23H40V27H21z"/></g><path fill="none" stroke="#4788c7" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M27 32L29.5 34.375 34 30"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M13.687,7.865L34.903,44.827L71.741,44.827C71.99,43.253 72.114,41.595 72.114,39.938C72.114,22.244 57.735,7.865 40,7.865L13.687,7.865Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
<path d="M7.886,19.716L22.306,44.869L7.886,44.869L7.886,19.716Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
<path d="M7.886,55.808L7.886,65.878C7.886,69.317 10.662,72.135 14.143,72.135L37.97,72.135L28.605,55.808C28.605,55.85 7.886,55.85 7.886,55.808Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
<path d="M49.696,70.602C57.487,68.157 63.992,62.77 67.97,55.767L41.16,55.767L49.696,70.602Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -5,6 +5,7 @@ router-view
<script setup>
import { nextTick, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useFlagsStore } from 'src/stores/flags'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
import { setCssVar, useQuasar } from 'quasar'
@ -17,6 +18,7 @@ const $q = useQuasar()
// STORES
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()
@ -67,6 +69,10 @@ if (typeof siteConfig !== 'undefined') {
router.beforeEach(async (to, from) => {
siteStore.routerLoading = true
// System Flags
if (!flagsStore.loaded) {
flagsStore.load()
}
// Site Info
if (!siteStore.id) {
console.info('No pre-cached site config. Loading site info...')

@ -38,6 +38,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
q-drawer.fileman-left(:model-value='true', :width='350')
.q-px-md.q-pb-sm
tree(
ref='treeComp'
:nodes='state.treeNodes'
:roots='state.treeRoots'
v-model:selected='state.currentFolderId'
@ -46,7 +47,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
@context-action='treeContextAction'
:display-mode='state.displayMode'
)
q-drawer.fileman-right(:model-value='true', :width='350', side='right')
q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right')
.q-pa-md
template(v-if='currentFileDetails')
q-img.rounded-borders.q-mb-md(
@ -143,7 +144,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
color='grey'
:aria-label='t(`common.actions.refresh`)'
icon='las la-redo-alt'
@click=''
@click='reloadFolder(state.currentFolderId)'
)
q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
q-separator.q-mr-sm(inset, vertical)
@ -172,14 +173,21 @@ q-layout.fileman(view='hHh lpR lFr', container)
icon='las la-cloud-upload-alt'
@click='uploadFile'
)
q-list.fileman-filelist
.fileman-emptylist(v-if='files.length < 1')
template(v-if='state.fileListLoading')
q-spinner.q-mr-sm(color='primary', size='xs', :thickness='3')
span.text-primary Loading...
template(v-else)
q-icon.q-mr-sm(name='las la-exclamation-triangle', size='sm')
span This folder is empty.
q-list.fileman-filelist(v-else)
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'
@click.native='selectItem(item)'
@dblclick.native='openItem(item)'
)
q-item-section.fileman-filelist-icon(avatar)
@ -229,7 +237,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
q-item-section.text-negative Delete
q-footer
q-bar.fileman-path
small.text-caption.text-grey-7 / foo / bar
small.text-caption.text-grey-7 {{folderPath}}
input(
type='file'
@ -258,6 +266,7 @@ import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
// QUASAR
@ -277,50 +286,34 @@ const { t } = useI18n()
const state = reactive({
loading: 0,
search: '',
currentFolderId: '',
currentFileId: '',
currentFolderId: null,
currentFileId: null,
treeNodes: {},
treeRoots: [],
displayMode: 'title',
isUploading: false,
shouldCancelUpload: false,
uploadPercentage: 0,
fileList: [
{
id: '1',
type: 'folder',
title: 'Beep Boop',
children: 19
},
{
id: '2',
type: 'folder',
title: 'Second Folder',
children: 0
},
{
id: '3',
type: 'page',
title: 'Some Page',
pageType: 'markdown',
updatedAt: '2022-11-24T18:27:00Z'
},
{
id: '4',
type: 'file',
title: 'Important Document',
fileType: 'pdf',
fileSize: 19000
}
]
fileList: [],
fileListLoading: false
})
// REFS
const fileIpt = ref(null)
const treeComp = ref(null)
// COMPUTED
const folderPath = computed(() => {
if (!state.currentFolderId) {
return '/'
} else {
const folderNode = state.treeNodes[state.currentFolderId] ?? {}
return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
}
})
const files = computed(() => {
return state.fileList.map(f => {
switch (f.type) {
@ -334,7 +327,7 @@ const files = computed(() => {
f.caption = t(`fileman.${f.pageType}PageType`)
break
}
case 'file': {
case 'asset': {
f.icon = fileTypes[f.fileType]?.icon ?? ''
f.side = filesize(f.fileSize)
if (fileTypes[f.fileType]) {
@ -382,7 +375,7 @@ const currentFileDetails = computed(() => {
})
break
}
case 'file': {
case 'asset': {
items.push({
label: t('fileman.detailsAssetType'),
value: fileTypes[item.fileType] ? t(`fileman.${item.fileType}FileType`) : t('fileman.unknownFileType', { type: item.fileType.toUpperCase() })
@ -405,8 +398,8 @@ const currentFileDetails = computed(() => {
// WATCHERS
watch(() => state.currentFolderId, (newValue) => {
state.currentFileId = null
watch(() => state.currentFolderId, async (newValue) => {
await loadTree(newValue)
})
// METHODS
@ -420,7 +413,16 @@ async function treeLazyLoad (nodeId, { done, fail }) {
done()
}
async function loadTree (parentId, types) {
async function loadTree (parentId, types, noCache = false) {
if (!parentId) {
parentId = null
state.treeRoots = []
}
if (parentId === state.currentFolderId) {
state.fileListLoading = true
state.currentFileId = null
state.fileList = []
}
try {
const resp = await APOLLO_CLIENT.query({
query: gql`
@ -476,15 +478,58 @@ async function loadTree (parentId, types) {
for (const item of items) {
switch (item.__typename) {
case 'TreeItemFolder': {
state.treeNodes[item.id] = {
text: item.title,
fileName: item.fileName,
children: []
// -> Tree Nodes
if (!state.treeNodes[item.id] || (parentId && !treeComp.value.isLoaded(item.id))) {
state.treeNodes[item.id] = {
folderPath: item.folderPath,
fileName: item.fileName,
title: item.title,
children: []
}
if (item.folderPath) {
if (!state.treeNodes[parentId].children.includes(item.id)) {
state.treeNodes[parentId].children.push(item.id)
}
}
}
// -> Set Tree Roots
if (!item.folderPath) {
newTreeRoots.push(item.id)
} else {
state.treeNodes[parentId].children.push(item.id)
}
// -> File List
if (parentId === state.currentFolderId) {
state.fileList.push({
id: item.id,
type: 'folder',
title: item.title,
children: 0
})
}
break
}
case 'TreeItemAsset': {
if (parentId === state.currentFolderId) {
state.fileList.push({
id: item.id,
type: 'asset',
title: item.title,
fileType: 'pdf',
fileSize: 19000
})
}
break
}
case 'TreeItemPage': {
if (parentId === state.currentFolderId) {
state.fileList.push({
id: item.id,
type: 'page',
title: item.title,
pageType: 'markdown',
updatedAt: '2022-11-24T18:27:00Z'
})
}
break
}
@ -501,15 +546,23 @@ async function loadTree (parentId, types) {
caption: err.message
})
}
if (parentId === state.currentFolderId) {
nextTick(() => {
state.fileListLoading = false
})
}
}
function treeContextAction (nodeId, action) {
console.info(nodeId, action)
switch (action) {
case 'newFolder': {
newFolder(nodeId)
break
}
case 'del': {
delFolder(nodeId)
break
}
}
}
@ -524,6 +577,28 @@ function newFolder (parentId) {
})
}
function delFolder (folderId) {
$q.dialog({
component: FolderDeleteDialog,
componentProps: {
folderId,
folderName: state.treeNodes[folderId].title
}
}).onOk(() => {
for (const nodeId in state.treeNodes) {
if (state.treeNodes[nodeId].children.includes(folderId)) {
state.treeNodes[nodeId].children = state.treeNodes[nodeId].children.filter(c => c !== folderId)
}
}
delete state.treeNodes[folderId]
})
}
function reloadFolder (folderId) {
loadTree(folderId, null, true)
treeComp.value.resetLoaded()
}
// -> Upload Methods
function uploadFile () {
@ -603,6 +678,15 @@ function uploadCancel () {
state.uploadPercentage = 0
}
function selectItem (item) {
if (item.type === 'folder') {
state.currentFolderId = item.id
treeComp.value.setOpened(item.id)
} else {
state.currentFileId = item.id
}
}
function openItem (item) {
console.info(item.id)
}
@ -662,6 +746,20 @@ onMounted(() => {
}
}
&-emptylist {
padding: 16px;
font-style: italic;
display: flex;
align-items: center;
@at-root .body--light & {
color: $grey-6;
}
@at-root .body--dark & {
color: $dark-4;
}
}
&-filelist {
padding: 8px 12px;

@ -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(`folderDeleteDialog.title`)}}
q-card-section
.text-body2
i18n-t(keypath='folderDeleteDialog.confirm')
template(v-slot:name)
strong {{folderName}}
.text-caption.text-grey.q-mt-sm {{t('folderDeleteDialog.folderId', { id: folderId })}}
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({
folderId: {
type: String,
required: true
},
folderName: {
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 deleteFolder ($id: UUID!) {
deleteFolder(folderId: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: props.folderId
}
})
if (resp?.data?.deleteFolder?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('folderDeleteDialog.deleteSuccess')
})
onDialogOK()
} else {
throw new Error(resp?.data?.deleteFolder?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.isLoading = false
}
</script>

@ -11,15 +11,19 @@ q-menu.translucent-menu(
q-item(clickable, @click='create(`markdown`)')
blueprint-icon(icon='markdown')
q-item-section.q-pr-sm New Markdown Page
q-item(clickable, @click='create(`channel`)')
blueprint-icon(icon='chat')
q-item-section.q-pr-sm New Discussion Space
q-item(clickable, @click='create(`blog`)')
blueprint-icon(icon='typewriter-with-paper')
q-item-section.q-pr-sm New Blog Page
q-item(clickable, @click='create(`api`)')
blueprint-icon(icon='api')
q-item-section.q-pr-sm New API Documentation
q-item(clickable, @click='create(`asciidoc`)')
blueprint-icon(icon='asciidoc')
q-item-section.q-pr-sm New AsciiDoc Page
template(v-if='flagsStore.experimental')
q-item(clickable, @click='create(`channel`)')
blueprint-icon(icon='chat')
q-item-section.q-pr-sm New Discussion Space
q-item(clickable, @click='create(`blog`)')
blueprint-icon(icon='typewriter-with-paper')
q-item-section.q-pr-sm New Blog Page
q-item(clickable, @click='create(`api`)')
blueprint-icon(icon='api')
q-item-section.q-pr-sm New API Documentation
q-item(clickable, @click='create(`redirect`)')
blueprint-icon(icon='advance')
q-item-section.q-pr-sm New Redirection
@ -41,6 +45,7 @@ import { useQuasar } from 'quasar'
import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
import { useFlagsStore } from 'src/stores/flags'
// PROPS
@ -65,6 +70,7 @@ const $q = useQuasar()
// STORES
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()

@ -5,17 +5,23 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
span {{t('pageSaveDialog.title')}}
.row.page-save-dialog-browser
.col-4.q-px-sm
tree(
:nodes='state.treeNodes'
:roots='state.treeRoots'
v-model:selected='state.currentFolderId'
@lazy-load='treeLazyLoad'
:use-lazy-load='true'
@context-action='treeContextAction'
:context-action-list='[`newFolder`]'
:display-mode='state.displayMode'
)
.col-4
q-scroll-area(
:thumb-style='thumbStyle'
:bar-style='barStyle'
style='height: 300px'
)
.q-px-sm
tree(
:nodes='state.treeNodes'
:roots='state.treeRoots'
v-model:selected='state.currentFolderId'
@lazy-load='treeLazyLoad'
:use-lazy-load='true'
@context-action='treeContextAction'
:context-action-list='[`newFolder`]'
:display-mode='state.displayMode'
)
.col-8
q-list.page-save-dialog-filelist(dense)
q-item(
@ -31,6 +37,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
q-icon(:name='item.icon', size='sm')
q-item-section
q-item-label {{item.title}}
.page-save-dialog-path.font-robotomono {{folderPath}}
q-list.q-py-sm
q-item
blueprint-icon(icon='new-document')
@ -197,8 +204,28 @@ const displayModes = [
{ value: 'path', label: t('pageSaveDialog.displayModePath') }
]
const thumbStyle = {
right: '1px',
borderRadius: '5px',
backgroundColor: '#666',
width: '5px',
opacity: 0.5
}
const barStyle = {
width: '7px'
}
// COMPUTED
const folderPath = computed(() => {
if (!state.currentFolderId) {
return '/'
} else {
const folderNode = state.treeNodes[state.currentFolderId] ?? {}
return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
}
})
const files = computed(() => {
return state.fileList.map(f => {
switch (f.type) {
@ -274,8 +301,9 @@ async function loadTree (parentId, types) {
switch (item.__typename) {
case 'TreeItemFolder': {
state.treeNodes[item.id] = {
text: item.title,
folderPath: item.folderPath,
fileName: item.fileName,
title: item.title,
children: []
}
if (!item.folderPath) {
@ -336,16 +364,23 @@ onMounted(() => {
&-browser {
height: 300px;
max-height: 90vh;
border-bottom: 1px solid $blue-grey-1;
border-bottom: 1px solid #FFF;
@at-root .body--light & {
border-bottom-color: $blue-grey-1;
}
@at-root .body--dark & {
border-bottom-color: $dark-3;
}
> .col-4 {
height: 300px;
@at-root .body--light & {
background-color: $blue-grey-1;
border-bottom-color: $blue-grey-1;
}
@at-root .body--dark & {
background-color: $dark-4;
border-bottom-color: $dark-4;
}
}
}
@ -372,5 +407,22 @@ onMounted(() => {
}
}
&-path {
padding: 5px 16px;
font-size: 12px;
border-bottom: 1px solid #FFF;
@at-root .body--light & {
background-color: lighten($blue-grey-1, 4%);
border-bottom-color: $blue-grey-1;
color: $blue-grey-9;
}
@at-root .body--dark & {
background-color: darken($dark-4, 1%);
border-bottom-color: $dark-1;
color: $blue-grey-3;
}
}
}
</style>

@ -95,7 +95,7 @@ const state = reactive({
opened: {}
})
// COMPOUTED
// COMPUTED
const selection = computed({
get () {
@ -120,6 +120,16 @@ function emitContextAction (nodeId, action) {
emit('contextAction', nodeId, action)
}
function setOpened (nodeId) {
state.opened[nodeId] = true
}
function isLoaded (nodeId) {
return state.loaded[nodeId]
}
function resetLoaded (nodeId) {
state.loaded[nodeId] = false
}
// PROVIDE
provide('roots', toRef(props, 'roots'))
@ -131,6 +141,14 @@ provide('selection', selection)
provide('emitLazyLoad', emitLazyLoad)
provide('emitContextAction', emitContextAction)
// EXPOSE
defineExpose({
setOpened,
isLoaded,
resetLoaded
})
// MOUNTED
onMounted(() => {

@ -7,7 +7,7 @@ li.treeview-node
size='sm'
@click.stop='hasChildren ? toggleNode() : openNode()'
)
.treeview-label-text {{displayMode === 'path' ? node.fileName : node.text}}
.treeview-label-text {{displayMode === 'path' ? node.fileName : node.title}}
q-spinner.q-mr-xs(
color='primary'
v-if='state.isLoading'

@ -157,15 +157,13 @@
"admin.extensions.requiresSharp": "Requires Sharp extension",
"admin.extensions.subtitle": "Install extensions for extra functionality",
"admin.extensions.title": "Extensions",
"admin.flags.hidedonatebtn.hint": "You have already donated to this project (thank you!) and want to hide the button from the administration area.",
"admin.flags.hidedonatebtn.label": "Hide Donate Button",
"admin.flags.ldapdebug.hint": "Log detailed debug info on LDAP/AD login attempts.",
"admin.flags.ldapdebug.label": "LDAP Debug",
"admin.flags.sqllog.hint": "Log all queries made to the database to console.",
"admin.flags.sqllog.label": "SQL Query Logging",
"admin.flags.authDebug.hint": "Log detailed debug info of all login / registration attempts.",
"admin.flags.authDebug.label": "Auth Debug",
"admin.flags.sqlLog.hint": "Log all queries made to the database to console.",
"admin.flags.sqlLog.label": "SQL Query Logging",
"admin.flags.subtitle": "Low-level system flags for debugging or experimental purposes",
"admin.flags.title": "Flags",
"admin.flags.warn.hint": "Doing so may result in data loss or broken installation!",
"admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
"admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
"admin.general.allowComments": "Allow Comments",
"admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
@ -1603,5 +1601,14 @@
"common.actions.duplicate": "Duplicate",
"common.actions.moveTo": "Move To",
"pageSaveDialog.displayModeTitle": "Title",
"pageSaveDialog.displayModePath": "Path"
"pageSaveDialog.displayModePath": "Path",
"folderDeleteDialog.title": "Confirm Delete Folder",
"folderDeleteDialog.confirm": "Are you sure you want to delete folder {name} and all its content?",
"folderDeleteDialog.folderId": "Folder ID {id}",
"folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.",
"admin.flags.experimental.label": "Experimental Features",
"admin.flags.experimental.hint": "Enable unstable / unfinished features. DO NOT enable in a production environment!",
"admin.flags.advanced.label": "Custom Configuration",
"admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.",
"admin.flags.saveSuccess": "Flags have been updated successfully."
}

@ -29,7 +29,7 @@ q-layout.admin(view='hHh Lpr lff')
:thumb-style='thumbStyle'
:bar-style='barStyle'
)
q-list.text-white(padding, dense)
q-list.text-white.q-pb-lg(padding, dense)
q-item.q-mb-sm
q-item-section
q-btn.acrylic-btn(

@ -5,19 +5,19 @@ q-layout(view='hHh Lpr lff')
.layout-profile-card
.layout-profile-sd
q-list
q-item(
v-for='navItem of sidenav'
:key='navItem.key'
clickable
:to='`/_profile/` + navItem.key'
active-class='is-active'
:disabled='navItem.disabled'
v-ripple
)
q-item-section(side)
q-icon(:name='navItem.icon')
q-item-section
q-item-label {{navItem.label}}
template(v-for='navItem of sidenav' :key='navItem.key')
q-item(
v-if='!navItem.disabled || flagsStore.experimental'
clickable
:to='`/_profile/` + navItem.key'
active-class='is-active'
:disabled='navItem.disabled'
v-ripple
)
q-item-section(side)
q-icon(:name='navItem.icon')
q-item-section
q-item-label {{navItem.label}}
q-separator.q-my-sm(inset)
q-item(
clickable
@ -48,6 +48,7 @@ import { useI18n } from 'vue-i18n'
import { useMeta, useQuasar } from 'quasar'
import { onMounted, reactive, watch } from 'vue'
import { useFlagsStore } from 'src/stores/flags'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
@ -61,6 +62,7 @@ const $q = useQuasar()
// STORES
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
const userStore = useUserStore()

@ -15,13 +15,20 @@ q-page.admin-flags
target='_blank'
type='a'
)
q-btn.q-mr-sm.acrylic-btn(
icon='las la-redo-alt'
flat
color='secondary'
:loading='state.loading > 0'
@click='load'
)
q-btn(
unelevated
icon='fa-solid fa-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
:loading='loading'
:loading='state.loading > 0'
)
q-separator(inset)
.row.q-pa-md.q-col-gutter-md
@ -39,44 +46,60 @@ q-page.admin-flags
q-item(tag='label')
blueprint-icon(icon='flag-filled')
q-item-section
q-item-label {{t(`admin.flags.ldapdebug.label`)}}
q-item-label(caption) {{t(`admin.flags.ldapdebug.hint`)}}
q-item-label {{t(`admin.flags.experimental.label`)}}
q-item-label(caption) {{t(`admin.flags.experimental.hint`)}}
q-item-section(avatar)
q-toggle(
v-model='flags.ldapdebug'
color='primary'
v-model='state.flags.experimental'
color='negative'
checked-icon='las la-check'
unchecked-icon='las la-times'
:aria-label='t(`admin.flags.ldapdebug.label`)'
:aria-label='t(`admin.flags.experimental.label`)'
)
q-separator.q-my-sm(inset)
q-item(tag='label')
blueprint-icon(icon='flag-filled')
q-item-section
q-item-label {{t(`admin.flags.sqllog.label`)}}
q-item-label(caption) {{t(`admin.flags.sqllog.hint`)}}
q-item-label {{t(`admin.flags.authDebug.label`)}}
q-item-label(caption) {{t(`admin.flags.authDebug.hint`)}}
q-item-section(avatar)
q-toggle(
v-model='flags.sqllog'
color='primary'
v-model='state.flags.authDebug'
color='negative'
checked-icon='las la-check'
unchecked-icon='las la-times'
:aria-label='t(`admin.flags.sqllog.label`)'
:aria-label='t(`admin.flags.authDebug.label`)'
)
q-card.shadow-1.q-py-sm.q-mt-md
q-separator.q-my-sm(inset)
q-item(tag='label')
blueprint-icon(icon='heart-outline')
blueprint-icon(icon='flag-filled')
q-item-section
q-item-label {{t(`admin.flags.hidedonatebtn.label`)}}
q-item-label(caption) {{t(`admin.flags.hidedonatebtn.hint`)}}
q-item-label {{t(`admin.flags.sqlLog.label`)}}
q-item-label(caption) {{t(`admin.flags.sqlLog.hint`)}}
q-item-section(avatar)
q-toggle(
v-model='flags.hidedonatebtn'
color='primary'
v-model='state.flags.sqlLog'
color='negative'
checked-icon='las la-check'
unchecked-icon='las la-times'
:aria-label='t(`admin.flags.hidedonatebtn.label`)'
:aria-label='t(`admin.flags.sqlLog.label`)'
)
q-card.shadow-1.q-py-sm.q-mt-md
q-item
blueprint-icon(icon='administrative-tools')
q-item-section
q-item-label {{t(`admin.flags.advanced.label`)}}
q-item-label(caption) {{t(`admin.flags.advanced.hint`)}}
q-item-section(avatar)
q-btn(
:label='t(`common.actions.edit`)'
unelevated
icon='las la-code'
color='primary'
text-color='white'
@click=''
disabled
)
.col-12.col-lg-5.gt-md
.q-pa-md.text-center
@ -85,12 +108,13 @@ q-page.admin-flags
<script setup>
import gql from 'graphql-tag'
import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { transform } from 'lodash-es'
import { defineAsyncComponent, onMounted, reactive, ref } from 'vue'
import { cloneDeep, omit } from 'lodash-es'
import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { useSiteStore } from 'src/stores/site'
import { useFlagsStore } from 'src/stores/flags'
// QUASAR
@ -98,6 +122,7 @@ const $q = useQuasar()
// STORES
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
// I18N
@ -110,67 +135,76 @@ useMeta({
title: t('admin.flags.title')
})
const loading = ref(false)
const flags = reactive({
ldapdebug: false,
sqllog: false,
hidedonatebtn: false
// DATA
const state = reactive({
loading: 0,
flags: {
experimental: false,
authDebug: false,
sqlLog: false
}
})
const save = async () => {
// METHODS
async function load () {
state.loading++
$q.loading.show()
await flagsStore.load()
state.flags = omit(cloneDeep(flagsStore.$state), ['loaded'])
$q.loading.hide()
state.loading--
}
// methods: {
// async save () {
// try {
// await this.$apollo.mutate({
// mutation: gql`
// mutation updateFlags (
// $flags: [SystemFlagInput]!
// ) {
// updateSystemFlags(
// flags: $flags
// ) {
// status {
// succeeded
// slug
// message
// }
// }
// }
// `,
// variables: {
// flags: _transform(this.flags, (result, value, key) => {
// result.push({ key, value })
// }, [])
// },
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-flags-update')
// }
// })
// this.$store.commit('showNotification', {
// style: 'success',
// message: 'Flags applied successfully.',
// icon: 'check'
// })
// } catch (err) {
// this.$store.commit('pushGraphError', err)
// }
// }
// }
// apollo: {
// flags: {
// query: gql``,
// fetchPolicy: 'network-only',
// update: (data) => _transform(data.system.flags, (result, row) => {
// _set(result, row.key, row.value)
// }, {}),
// watchLoading (isLoading) {
// this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-flags-refresh')
// }
// }
// }
async function save () {
if (state.loading > 0) { return }
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation updateFlags (
$flags: JSON!
) {
updateSystemFlags(
flags: $flags
) {
operation {
succeeded
message
}
}
}
`,
variables: {
flags: state.flags
}
})
if (resp?.data?.updateSystemFlags?.operation?.succeeded) {
load()
$q.notify({
type: 'positive',
message: t('admin.flags.saveSuccess')
})
} else {
throw new Error(resp?.data?.updateSystemFlags?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: err.message
})
}
state.loading--
}
// MOUNTED
onMounted(async () => {
load()
})
</script>
<style lang='scss'>

@ -577,7 +577,7 @@ async function cancelJob (jobId) {
}
})
if (resp?.data?.cancelJob?.operation?.succeeded) {
this.load()
load()
$q.notify({
type: 'positive',
message: t('admin.scheduler.cancelJobSuccess')

@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import gql from 'graphql-tag'
export const useFlagsStore = defineStore('flags', {
state: () => ({
loaded: false,
experimental: false
}),
getters: {},
actions: {
async load () {
try {
const resp = await APOLLO_CLIENT.query({
query: gql`
query getFlag {
systemFlags
}
`,
fetchPolicy: 'network-only'
})
const systemFlags = resp.data.systemFlags
if (systemFlags) {
this.$patch({
...systemFlags,
loaded: true
})
} else {
throw new Error('Could not fetch system flags.')
}
} catch (err) {
console.warn(err.networkError?.result ?? err.message)
throw err
}
}
}
})
Loading…
Cancel
Save