feat: page browser dialog + various improvements

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

@ -241,12 +241,6 @@
page-selector(mode='create', v-model='duplicateOpts.modal', :open-handler='pageDuplicateHandle', :path='duplicateOpts.path', :locale='duplicateOpts.locale')
page-delete(v-model='deletePageModal', v-if='path && path.length')
page-convert(v-model='convertPageModal', v-if='path && path.length')
.nav-header-dev(v-if='isDevMode')
v-icon mdi-alert
div
.overline DEVELOPMENT VERSION
.overline This code base is NOT for production use!
</template>
<script>

@ -36,26 +36,6 @@
img(src='/_assets-legacy/svg/editor-icon-ckeditor.svg', alt='Visual Editor', style='width: 36px;')
.body-2.mt-2.primary--text Visual Editor
.caption.grey--text Rich-text WYSIWYG
v-card.radius-7.mt-2(color='teal darken-3', dark)
v-card-text.text-center.py-4
.subtitle-1.white--text {{$t('editor:select.customView')}}
v-container(grid-list-lg, fluid)
v-layout(row, wrap, justify-center)
v-flex(xs4)
v-hover
template(v-slot:default='{ hover }')
v-card.radius-7.animated.fadeInUp(
hover
light
ripple
)
v-card-text.text-center(@click='fromTemplate')
img(src='/_assets-legacy/svg/icon-cube.svg', alt='From Template', style='width: 42px; opacity: .5;')
.body-2.mt-1.teal--text From Template
.caption.grey--text Use an existing page...
page-selector(mode='select', v-model='templateDialogIsShown', :open-handler='fromTemplateHandle', :path='path', :locale='locale', must-exist)
</template>
<script>

@ -58,12 +58,7 @@ exports.up = async knex => {
.createTable('assetData', table => {
table.uuid('id').notNullable().primary()
table.binary('data').notNullable()
})
// ASSET FOLDERS -----------------------
.createTable('assetFolders', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('name').notNullable()
table.string('slug').notNullable()
table.binary('preview')
})
// AUTHENTICATION ----------------------
.createTable('authentication', table => {
@ -351,13 +346,9 @@ exports.up = async knex => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
.table('assets', table => {
table.uuid('folderId').notNullable().references('id').inTable('assetFolders').index()
table.uuid('authorId').notNullable().references('id').inTable('users')
table.uuid('siteId').notNullable().references('id').inTable('sites').index()
})
.table('assetFolders', table => {
table.uuid('parentId').references('id').inTable('assetFolders').index()
})
.table('commentProviders', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
@ -551,6 +542,7 @@ exports.up = async knex => {
comments: false,
contributions: false,
profile: true,
reasonForChange: 'required',
search: true
},
logoText: true,

@ -2,7 +2,9 @@ const _ = require('lodash')
const sanitize = require('sanitize-filename')
const graphHelper = require('../../helpers/graph')
const assetHelper = require('../../helpers/asset')
const { setTimeout } = require('node:timers/promises')
const path = require('node:path')
const fs = require('fs-extra')
const { v4: uuid } = require('uuid')
module.exports = {
Query: {
@ -187,10 +189,54 @@ module.exports = {
*/
async uploadAssets(obj, args, context) {
try {
const results = await Promise.allSettled(args.files.map(async fl => {
const { filename, mimetype, createReadStream } = await fl
WIKI.logger.debug(`Processing asset upload ${filename} of type ${mimetype}...`)
if (!WIKI.extensions.ext.sharp.isInstalled) {
throw new Error('This feature requires the Sharp extension but it is not installed.')
}
if (!['.png', '.jpg', 'webp', '.gif'].some(s => filename.endsWith(s))) {
throw new Error('Invalid File Extension. Must be svg, png, jpg, webp or gif.')
}
const destFormat = mimetype.startsWith('image/svg') ? 'svg' : 'png'
const destFolder = path.resolve(
process.cwd(),
WIKI.config.dataPath,
`assets`
)
const destPath = path.join(destFolder, `logo-${args.id}.${destFormat}`)
await fs.ensureDir(destFolder)
// -> Resize
await WIKI.extensions.ext.sharp.resize({
format: destFormat,
inputStream: createReadStream(),
outputPath: destPath,
height: 72
})
// -> Save logo meta to DB
const site = await WIKI.db.sites.query().findById(args.id)
if (!site.config.assets.logo) {
site.config.assets.logo = uuid()
}
site.config.assets.logoExt = destFormat
await WIKI.db.sites.query().findById(args.id).patch({ config: site.config })
await WIKI.db.sites.reloadCache()
// -> Save image data to DB
const imgBuffer = await fs.readFile(destPath)
await WIKI.db.knex('assetData').insert({
id: site.config.assets.logo,
data: imgBuffer
}).onConflict('id').merge()
}))
WIKI.logger.debug('Asset(s) uploaded successfully.')
return {
operation: graphHelper.generateSuccess('Asset(s) uploaded successfully.')
operation: graphHelper.generateSuccess('Asset(s) uploaded successfully')
}
} catch (err) {
WIKI.logger.warn(err)
return graphHelper.generateError(err)
}
},

@ -103,7 +103,7 @@ extend type Mutation {
): PageResponse
convertPage(
id: Int!
id: UUID!
editor: String!
): DefaultResponse
@ -114,7 +114,7 @@ extend type Mutation {
): DefaultResponse
deletePage(
id: Int!
id: UUID!
): DefaultResponse
deleteTag(

@ -78,10 +78,11 @@ type SiteRobots {
type SiteFeatures {
ratings: Boolean
ratingsMode: SitePageRatingModes
ratingsMode: SitePageRatingMode
comments: Boolean
contributions: Boolean
profile: Boolean
reasonForChange: SiteReasonForChangeMode
search: Boolean
}
@ -129,12 +130,18 @@ enum SiteThemePosition {
right
}
enum SitePageRatingModes {
enum SitePageRatingMode {
off
thumbs
stars
}
enum SiteReasonForChangeMode {
off
optional
required
}
type SiteCreateResponse {
operation: Operation
site: Site
@ -164,10 +171,11 @@ input SiteRobotsInput {
input SiteFeaturesInput {
ratings: Boolean
ratingsMode: SitePageRatingModes
ratingsMode: SitePageRatingMode
comments: Boolean
contributions: Boolean
profile: Boolean
reasonForChange: SiteReasonForChangeMode
search: Boolean
}

@ -40,20 +40,28 @@ html(lang=siteConfig.lang)
//- CSS
link(
type='text/css'
rel='stylesheet'
href='/_assets-legacy/css/app.36b4c9522aa279325701.css'
)
//- JS
script(
type='text/javascript'
src='/_assets-legacy/js/runtime.js'
src='/_assets-legacy/js/runtime.js?1671237890'
)
script(
type='text/javascript'
src='/_assets-legacy/js/app.js'
src='/_assets-legacy/js/app.js?1671237890'
)

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="ejEliz0oBcrSlcUFYPkYAa" x1="5.715" x2="40.857" y1="40.37" y2="5.229" gradientTransform="matrix(1 0 0 -1 0 48)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#32bdef"/><stop offset="1" stop-color="#1ea2e4"/></linearGradient><path fill="url(#ejEliz0oBcrSlcUFYPkYAa)" d="M36.2,6H8C6.9,6,6,6.9,6,8v32c0,1.1,0.9,2,2,2h32c1.1,0,2-0.9,2-2V11.8c0-0.5-0.2-1-0.6-1.4l-3.8-3.8 C37.2,6.2,36.7,6,36.2,6z"/><linearGradient id="ejEliz0oBcrSlcUFYPkYAb" x1="38.003" x2="40.027" y1="9.997" y2="7.973" gradientTransform="matrix(1 0 0 -1 0 48)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0d61a9"/><stop offset="1" stop-color="#16528c"/></linearGradient><rect width="2" height="2" x="38" y="38" fill="url(#ejEliz0oBcrSlcUFYPkYAb)"/><linearGradient id="ejEliz0oBcrSlcUFYPkYAc" x1="8.003" x2="10.027" y1="9.997" y2="7.973" gradientTransform="matrix(1 0 0 -1 0 48)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0d61a9"/><stop offset="1" stop-color="#16528c"/></linearGradient><rect width="2" height="2" x="8" y="38" fill="url(#ejEliz0oBcrSlcUFYPkYAc)"/><linearGradient id="ejEliz0oBcrSlcUFYPkYAd" x1="13.83" x2="34.965" y1="23.83" y2="44.965" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset=".242" stop-color="#f2f2f2"/><stop offset="1" stop-color="#ccc"/></linearGradient><path fill="url(#ejEliz0oBcrSlcUFYPkYAd)" d="M34,42H14c-1.1,0-2-0.9-2-2V28c0-1.1,0.9-2,2-2h20c1.1,0,2,0.9,2,2v12C36,41.1,35.1,42,34,42z"/><path d="M42,15.6L26.1,31.5l-1.2,4.9c-0.1,0.4,0.3,0.8,0.7,0.7l4.9-1.2L42,24.4V15.6z" opacity=".05"/><path d="M42,16.3L26.9,31.4l-0.5,1l0,0h0l-0.9,3.4c-0.1,0.4,0.3,0.7,0.6,0.6l3.4-0.9l0,0l0,0l1-0.5L42,23.7V16.3z" opacity=".07"/><path fill="#c94f60" d="M45.8,18.1l-1.9-1.9c-0.3-0.3-0.8-0.3-1.1,0l-0.9,0.9l3,3l0.9-0.9C46.1,18.9,46.1,18.4,45.8,18.1"/><path fill="#f0f0f0" d="M27,32l-1,4l4-1l0.4-3.5L27,32z"/><path fill="#edbe00" d="M42.3,22.6L30,35l-3-3l12.3-12.3L42.3,22.6z"/><linearGradient id="ejEliz0oBcrSlcUFYPkYAe" x1="42.112" x2="42.112" y1="30.688" y2="25.199" gradientTransform="matrix(1 0 0 -1 0 48)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#dedede"/><stop offset="1" stop-color="#d6d6d6"/></linearGradient><path fill="url(#ejEliz0oBcrSlcUFYPkYAe)" d="M39.3,19.7l2.5-2.5l3,3l-2.5,2.5L39.3,19.7z"/><path fill="#787878" d="M26.5,34L26,36l2-0.5L26.5,34z"/><path fill="#1fa4e6" d="M33,19H13c-1.1,0-2-0.9-2-2V6h24v11C35,18.1,34.1,19,33,19z"/><path d="M33,18.5H17c-1.4,0-2.5-1.1-2.5-2.5V6h21v10C35.5,17.4,34.4,18.5,33,18.5z" opacity=".07"/><radialGradient id="ejEliz0oBcrSlcUFYPkYAf" cx="17.573" cy="45.392" r="23.87" gradientTransform="matrix(1 0 0 -1 0 48)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fafafb"/><stop offset=".523" stop-color="#e2e4e7"/><stop offset="1" stop-color="#c8cdd1"/></radialGradient><path fill="url(#ejEliz0oBcrSlcUFYPkYAf)" d="M15,6v10c0,1.1,0.9,2,2,2h16c1.1,0,2-0.9,2-2V6H15z"/><rect width="4" height="8" x="27" y="8" fill="#177cad"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M13.5,38.5v-5H12c-2.481,0-4.5-2.019-4.5-4.5v-5.141l-2.512-0.932 c-0.197-0.073-0.349-0.22-0.428-0.413c-0.08-0.194-0.074-0.406,0.015-0.596L7.5,15.705v-0.111c0-3.502,0.904-6.11,2.687-7.751 l0.376-0.346l-0.354-0.369C9.469,6.357,8.94,5.583,8.663,5.14C9.93,4.276,14.53,1.5,21.268,1.5C28.982,1.5,35.5,7.867,35.5,15.403 c0,5.646-2.612,8.415-4.917,10.856c-1.352,1.433-2.628,2.785-3.067,4.505L27.5,30.826V38.5H13.5z"/><path fill="#4788c7" d="M21.268,2C28.711,2,35,8.138,35,15.403c0,5.448-2.54,8.139-4.781,10.514 c-1.397,1.481-2.717,2.879-3.188,4.724L27,30.763v0.126V38H14v-4v-1h-1h-1c-2.206,0-4-1.794-4-4v-4.793v-0.696l-0.652-0.242 l-2.185-0.81c-0.082-0.03-0.121-0.09-0.139-0.134s-0.032-0.114,0.005-0.193l2.877-6.112L8,15.817v-0.224 c0-3.356,0.85-5.84,2.526-7.383l0.752-0.692L10.57,6.783c-0.518-0.54-0.928-1.082-1.215-1.499C10.99,4.255,15.259,2,21.268,2 M21.268,1C13.043,1,8,5,8,5s0.657,1.234,1.849,2.475C8.132,9.055,7,11.635,7,15.594l-2.877,6.112 c-0.309,0.657,0.01,1.439,0.691,1.691L7,24.207V29c0,2.761,2.239,5,5,5h1v5h15v-8.111c1.105-4.326,8-6.228,8-15.486 C36,7.449,29.223,1,21.268,1L21.268,1z"/><g><path fill="#98ccfd" d="M18.548,23.841c-0.073-0.203-0.28-0.922-0.28-1.756c0-2.916,4.013-4.213,4.013-6.565 c0-2.044-2.383-2.154-2.757-2.154c-1.212,0-2.366,0.541-3.463,1.622v-2.817C17.393,11.39,18.775,11,20.206,11 c3.955,0,4.817,2.557,4.817,3.988c0,3.792-4.305,4.749-4.305,7.183c0,0.762,0.313,1.476,0.402,1.671H18.548z M19.938,29 c-0.79,0-1.744-0.566-1.744-1.622c0-1.163,1.125-1.634,1.744-1.634c1.168,0,1.732,0.953,1.732,1.634 C21.67,28.424,20.654,29,19.938,29z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M6,36.5c-1.379,0-2.5-1.121-2.5-2.5V6c0-1.379,1.121-2.5,2.5-2.5h28c1.379,0,2.5,1.121,2.5,2.5v28 c0,1.379-1.121,2.5-2.5,2.5H6z"/><path fill="#4788c7" d="M34,4c1.103,0,2,0.897,2,2v28c0,1.103-0.897,2-2,2H6c-1.103,0-2-0.897-2-2V6c0-1.103,0.897-2,2-2 H34 M34,3H6C4.343,3,3,4.343,3,6v28c0,1.657,1.343,3,3,3h28c1.657,0,3-1.343,3-3V6C37,4.343,35.657,3,34,3L34,3z"/><path fill="#4788c7" d="M28.5 13h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 12.776 28.776 13 28.5 13zM23.5 17h-12c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5v0C24 16.776 23.776 17 23.5 17zM28.5 21h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 20.776 28.776 21 28.5 21zM28.5 29h-17c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h17c.276 0 .5.224.5.5v0C29 28.776 28.776 29 28.5 29zM23.385 25h-12c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h12c.276 0 .5.224.5.5v0C23.885 24.776 23.661 25 23.385 25z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@ -44,6 +44,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
@lazy-load='treeLazyLoad'
:use-lazy-load='true'
@context-action='treeContextAction'
:display-mode='state.displayMode'
)
q-drawer.fileman-right(:model-value='true', :width='350', side='right')
.q-pa-md
@ -90,7 +91,6 @@ q-layout.fileman(view='hHh lpR lFr', container)
)
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'
@ -103,11 +103,38 @@ q-layout.fileman(view='hHh lpR lFr', container)
q-separator.q-my-sm
q-item(clickable)
q-item-section(side)
q-icon(name='las la-circle', color='grey', size='xs')
q-icon(name='las la-list', color='grey', size='xs')
q-item-section.q-pr-sm Browse using...
q-item-section(side)
q-icon(name='las la-angle-right', color='grey', size='xs')
q-menu(
anchor='top end'
self='top start'
)
q-list.q-pa-sm(dense)
q-item(clickable, @click='state.displayMode = `path`')
q-item-section(side)
q-icon(
:name='state.displayMode === `path` ? `las la-check-circle` : `las la-circle`'
:color='state.displayMode === `path` ? `positive` : `grey`'
size='xs'
)
q-item-section.q-pr-sm Browse Using Paths
q-item(clickable, @click='state.displayMode = `title`')
q-item-section(side)
q-icon(
:name='state.displayMode === `title` ? `las la-check-circle` : `las la-circle`'
:color='state.displayMode === `title` ? `positive` : `grey`'
size='xs'
)
q-item-section.q-pr-sm Browse Using Titles
q-item(clickable)
q-item-section(side)
q-icon(name='las la-stop', 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-icon(name='las la-check-square', color='positive', size='xs')
q-item-section.q-pr-sm Show Folders
q-btn.q-mr-sm(
flat
@ -254,6 +281,7 @@ const state = reactive({
currentFileId: '',
treeNodes: {},
treeRoots: [],
displayMode: 'title',
isUploading: false,
shouldCancelUpload: false,
uploadPercentage: 0,
@ -450,6 +478,7 @@ async function loadTree (parentId, types) {
case 'TreeItemFolder': {
state.treeNodes[item.id] = {
text: item.title,
fileName: item.fileName,
children: []
}
if (!item.folderPath) {

@ -1,42 +0,0 @@
<template lang="pug">
.row
.col-auto.bg-grey-1(style='width: 250px;')
q-tree(
:nodes='tree'
default-expand-all
node-key='label'
@lazy-load='onLazyLoad'
)
.col Doude
</template>
<script>
export default {
data () {
return {
tree: [
{
label: 'Item 1',
icon: 'las la-folder',
children: [
{ label: 'Item 1.1' },
{
label: 'Item 1.2',
icon: 'las la-folder',
children: [
{ label: 'Item 1.2.1' },
{ label: 'Item 1.2.2' }
]
}
]
}
]
}
},
methods: {
async onLazyLoad ({ node, key, done, fail }) {
done([])
}
}
}
</script>

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

@ -1,57 +1,376 @@
<template lang="pug">
q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
q-toolbar.bg-primary.text-white
.text-subtitle2 {{$t('editor.pageSave.title')}}
page-browser
q-card-section
q-input(
v-model='reason'
label='Reason for change'
dense
outlined
)
q-card-actions.card-actions
q-space
q-btn.acrylic-btn(
icon='las la-times'
:label='$t(`common.actions.cancel`)'
color='grey-7'
padding='xs md'
v-close-popup
flat
)
q-btn(
icon='las la-check'
:label='$t(`common.actions.save`)'
unelevated
color='primary'
padding='xs md'
@click=''
v-close-popup
)
q-dialog(ref='dialogRef', @hide='onDialogHide')
q-card.page-save-dialog(style='width: 860px; max-width: 90vw;')
q-card-section.card-header
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-8
q-list.page-save-dialog-filelist(dense)
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(side)
q-icon(:name='item.icon', size='sm')
q-item-section
q-item-label {{item.title}}
q-list.q-py-sm
q-item
blueprint-icon(icon='new-document')
q-item-section
q-input(
v-model='state.title'
label='Page Title'
dense
outlined
)
q-item
blueprint-icon(icon='file-submodule')
q-item-section
q-input(
v-model='state.path'
label='Path Name'
dense
outlined
)
q-card-actions.card-actions.q-px-md
q-btn.acrylic-btn(
icon='las la-ellipsis-h'
color='blue-grey'
padding='xs sm'
flat
)
q-tooltip(anchor='center right' self='center left') Display Options
q-menu(
auto-close
transition-show='jump-down'
transition-hide='jump-up'
anchor='top left'
self='bottom left'
)
q-card.q-pa-sm
q-list(dense)
q-item(clickable, @click='state.displayMode = `path`')
q-item-section(side)
q-icon(
:name='state.displayMode === `path` ? `las la-check-circle` : `las la-circle`'
:color='state.displayMode === `path` ? `positive` : `grey`'
size='xs'
)
q-item-section.q-pr-sm Browse Using Paths
q-item(clickable, @click='state.displayMode = `title`')
q-item-section(side)
q-icon(
:name='state.displayMode === `title` ? `las la-check-circle` : `las la-circle`'
:color='state.displayMode === `title` ? `positive` : `grey`'
size='xs'
)
q-item-section.q-pr-sm Browse Using Titles
q-space
q-btn.acrylic-btn(
icon='las la-times'
:label='t(`common.actions.cancel`)'
color='grey-7'
padding='xs md'
@click='onDialogCancel'
flat
)
q-btn(
icon='las la-check'
:label='t(`common.actions.save`)'
unelevated
color='primary'
padding='xs md'
@click='save'
v-close-popup
)
</template>
<script>
import PageBrowser from './PageBrowser.vue'
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive } from 'vue'
import { useDialogPluginComponent, useQuasar } from 'quasar'
import { cloneDeep, find } from 'lodash-es'
import gql from 'graphql-tag'
export default {
components: {
PageBrowser
},
data () {
return {
reason: ''
}
import fileTypes from '../helpers/fileTypes'
import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
import Tree from 'src/components/TreeNav.vue'
import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
// PROPS
const props = defineProps({
mode: {
type: String,
required: false,
default: 'save'
},
computed: {
pageId: {
type: String,
required: true
},
mounted () {
pageName: {
type: String,
required: false,
default: ''
},
methods: {
pagePath: {
type: String,
required: false,
default: ''
}
})
// EMITS
defineEmits([
...useDialogPluginComponent.emits
])
// QUASAR
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
const $q = useQuasar()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
displayMode: 'title',
currentFolderId: '',
currentFileId: '',
treeNodes: {},
treeRoots: [],
fileList: [
{
id: '1',
type: 'folder',
title: 'Beep Boop'
},
{
id: '2',
type: 'folder',
title: 'Second Folder'
},
{
id: '3',
type: 'page',
title: 'Some Page',
pageType: 'markdown'
}
],
title: '',
path: ''
})
const displayModes = [
{ value: 'title', label: t('pageSaveDialog.displayModeTitle') },
{ value: 'path', label: t('pageSaveDialog.displayModePath') }
]
// COMPUTED
const files = computed(() => {
return state.fileList.map(f => {
switch (f.type) {
case 'folder': {
f.icon = fileTypes.folder.icon
break
}
case 'page': {
f.icon = fileTypes.page.icon
break
}
}
return f
})
})
// METHODS
async function save () {
onDialogOK()
}
async function treeLazyLoad (nodeId, { done, fail }) {
await loadTree(nodeId, ['folder', 'page'])
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
}
}
}
`,
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,
fileName: item.fileName,
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) {
switch (action) {
case 'newFolder': {
newFolder(nodeId)
break
}
}
}
function newFolder (parentId) {
$q.dialog({
component: FolderCreateDialog,
componentProps: {
parentId
}
}).onOk(() => {
loadTree(parentId)
})
}
// MOUNTED
onMounted(() => {
loadTree()
state.title = props.pageName || ''
state.path = props.pagePath || ''
})
</script>
<style lang="scss">
.page-save-dialog {
&-browser {
height: 300px;
max-height: 90vh;
border-bottom: 1px solid $blue-grey-1;
> .col-4 {
@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;
}
}
}
&-filelist {
padding: 8px 12px;
> .q-item {
padding: 4px 6px;
border-radius: 4px;
&.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);
}
}
}
}
}
</style>

@ -6,6 +6,7 @@ ul.treeview-level
q-icon(name='img:/_assets/icons/fluent-ftp.svg', size='sm')
.treeview-label-text(:class='$q.dark.isActive ? `text-purple-4` : `text-purple`') root
q-menu(
v-if='rootContextActionList.length > 0'
touch-position
context-menu
auto-close
@ -14,10 +15,15 @@ ul.treeview-level
)
q-card.q-pa-sm
q-list(dense, style='min-width: 150px;')
q-item(clickable, @click='createRootFolder')
q-item(
v-for='action of rootContextActionList'
:key='action.key'
clickable
@click='action.handler(null)'
)
q-item-section(side)
q-icon(name='las la-plus-circle', color='primary')
q-item-section New Folder
q-icon(:name='action.icon', :color='action.iconColor')
q-item-section(:class='action.labelColor && (`text-` + action.labelColor)') {{action.label}}
q-icon(
v-if='!selection'
name='las la-angle-right'
@ -62,9 +68,15 @@ const roots = inject('roots')
const nodes = inject('nodes')
const selection = inject('selection')
const emitContextAction = inject('emitContextAction')
const contextActionList = inject('contextActionList')
// COMPUTED
const rootContextActionList = computed(() => {
if (props.parentId) { return [] }
return contextActionList.filter(c => c.key === 'newFolder')
})
const level = computed(() => {
const items = []
if (!props.parentId) {

@ -7,6 +7,7 @@
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, provide, reactive, toRef } from 'vue'
import { findKey } from 'lodash-es'
@ -30,6 +31,14 @@ const props = defineProps({
useLazyLoad: {
type: Boolean,
default: false
},
contextActionList: {
type: Array,
default: () => ['newFolder', 'duplicate', 'rename', 'move', 'del']
},
displayMode: {
type: String,
default: 'title'
}
})
@ -37,6 +46,48 @@ const props = defineProps({
const emit = defineEmits(['update:selected', 'lazyLoad', 'contextAction'])
// I18N
const { t } = useI18n()
// Context Actions
const contextActions = {
newFolder: {
icon: 'las la-plus-circle',
iconColor: 'blue',
label: t('common.actions.newFolder')
},
duplicate: {
icon: 'las la-copy',
iconColor: 'teal',
label: t('common.actions.duplicate') + '...'
},
rename: {
icon: 'las la-redo',
iconColor: 'teal',
label: t('common.actions.rename') + '...'
},
move: {
icon: 'las la-arrow-right',
iconColor: 'teal',
label: t('common.actions.moveTo') + '...'
},
del: {
icon: 'las la-trash-alt',
iconColor: 'negative',
label: t('common.actions.delete'),
labelColor: 'negative'
}
}
provide('contextActionList', props.contextActionList.map(key => ({
key,
...contextActions[key],
handler: (nodeId) => {
emit('contextAction', nodeId, key)
}
})))
// DATA
const state = reactive({
@ -75,6 +126,7 @@ provide('roots', toRef(props, 'roots'))
provide('nodes', props.nodes)
provide('loaded', state.loaded)
provide('opened', state.opened)
provide('displayMode', toRef(props, 'displayMode'))
provide('selection', selection)
provide('emitLazyLoad', emitLazyLoad)
provide('emitContextAction', emitContextAction)

@ -7,7 +7,7 @@ li.treeview-node
size='sm'
@click.stop='hasChildren ? toggleNode() : openNode()'
)
.treeview-label-text {{node.text}}
.treeview-label-text {{displayMode === 'path' ? node.fileName : node.text}}
q-spinner.q-mr-xs(
color='primary'
v-if='state.isLoading'
@ -19,6 +19,7 @@ li.treeview-node
)
//- RIGHT-CLICK MENU
q-menu(
v-if='contextActionList.length > 0'
touch-position
context-menu
auto-close
@ -29,26 +30,15 @@ li.treeview-node
)
q-card.q-pa-sm
q-list(dense, style='min-width: 150px;')
q-item(clickable, @click='contextAction(`newFolder`)')
q-item(
v-for='action of contextActionList'
:key='action.key'
clickable
@click='action.handler(node.id)'
)
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)
q-item-section(side)
q-icon(name='las la-arrow-right', color='teal')
q-item-section Move to...
q-item(clickable)
q-item-section(side)
q-icon(name='las la-trash-alt', color='negative')
q-item-section.text-negative Delete
q-icon(:name='action.icon', :color='action.iconColor')
q-item-section(:class='action.labelColor && (`text-` + action.labelColor)') {{action.label}}
//- SUB-LEVEL
transition(name='treeview')
tree-level(
@ -89,9 +79,11 @@ const $q = useQuasar()
const loaded = inject('loaded')
const opened = inject('opened')
const displayMode = inject('displayMode')
const selection = inject('selection')
const emitLazyLoad = inject('emitLazyLoad')
const emitContextAction = inject('emitContextAction')
const contextActionList = inject('contextActionList')
// DATA

@ -1586,5 +1586,22 @@
"welcome.admin": "Administration Area",
"welcome.createHome": "Create the homepage",
"welcome.subtitle": "Let's get started...",
"welcome.title": "Welcome to Wiki.js!"
"welcome.title": "Welcome to Wiki.js!",
"admin.utilities.scanPageProblems": "Scan for Page Problems",
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
"pageSaveDialog.title": "Save As...",
"admin.general.reasonForChange": "Reason for Change",
"admin.general.reasonForChangeHint": "Should users be prompted the reason for changes made to a page?",
"admin.general.reasonForChangeRequired": "Required",
"admin.general.reasonForChangeOptional": "Optional",
"admin.general.reasonForChangeOff": "Off",
"pageDeleteDialog.title": "Confirm Page Deletion",
"pageDeleteDialog.confirm": "Are you sure you want to delete the page {name}?",
"pageDeleteDialog.pageId": "Page ID {id}",
"pageDeleteDialog.deleteSuccess": "Page deleted successfully.",
"common.actions.newFolder": "New Folder",
"common.actions.duplicate": "Duplicate",
"common.actions.moveTo": "Move To",
"pageSaveDialog.displayModeTitle": "Title",
"pageSaveDialog.displayModePath": "Path"
}

@ -11,7 +11,7 @@ q-page.admin-general
icon='las la-question-circle'
flat
color='grey'
:href='siteStore.docsBase + `/admin/sites`'
:href='siteStore.docsBase + `/admin/sites#general`'
target='_blank'
type='a'
)
@ -208,6 +208,21 @@ q-page.admin-general
unchecked-icon='las la-times'
:aria-label='t(`admin.general.allowSearch`)'
)
q-separator.q-my-sm(inset)
q-item(tag='label')
blueprint-icon(icon='confusion')
q-item-section
q-item-label {{t(`admin.general.reasonForChange`)}}
q-item-label(caption) {{t(`admin.general.reasonForChangeHint`)}}
q-item-section(avatar)
q-btn-toggle(
v-model='state.config.features.reasonForChange'
push
glossy
no-caps
toggle-color='primary'
:options='reasonForChangeModes'
)
//- -----------------------
//- URL Handling
@ -493,6 +508,7 @@ const state = reactive({
ratingsMode: 'off',
comments: false,
contributions: false,
reasonForChange: 'off',
profile: false
},
defaults: {
@ -528,6 +544,11 @@ const ratingsModes = [
{ value: 'thumbs', label: t('admin.general.ratingsThumbs') },
{ value: 'stars', label: t('admin.general.ratingsStars') }
]
const reasonForChangeModes = [
{ value: 'off', label: t('admin.general.reasonForChangeOff') },
{ value: 'optional', label: t('admin.general.reasonForChangeOptional') },
{ value: 'required', label: t('admin.general.reasonForChangeRequired') }
]
const dateFormats = [
{ value: '', label: t('profile.localeDefault') },
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
@ -586,10 +607,11 @@ async function load () {
}
features {
comments
ratings
ratingsMode
contributions
profile
ratings
ratingsMode
reasonForChange
search
}
defaults {

@ -109,7 +109,19 @@ q-page.admin-utilities
@click=''
:label='t(`common.actions.proceed`)'
)
q-item
blueprint-icon(icon='rescan-document', :hue-rotate='45')
q-item-section
q-item-label {{t(`admin.utilities.scanPageProblems`)}}
q-item-label(caption) {{t(`admin.utilities.scanPageProblemsHint`)}}
q-item-section(side)
q-btn.acrylic-btn(
flat
icon='las la-arrow-circle-right'
color='primary'
@click=''
:label='t(`common.actions.proceed`)'
)
</template>
<script setup>

@ -193,7 +193,7 @@ q-page.column
@click='state.tagEditMode = !state.tagEditMode'
)
page-tags.q-mt-sm(:edit='state.tagEditMode')
template(v-if='pageStore.allowRatings && pageStore.ratingsMode !== `off`')
template(v-if='siteStore.features.ratingsMode !== `off` && pageStore.allowRatings')
q-separator(v-if='pageStore.showToc || pageStore.showTags')
//- Rating
.q-pa-md.flex.items-center
@ -201,13 +201,13 @@ q-page.column
.text-caption.text-grey-7 Rate this page
.q-px-md
q-rating(
v-if='pageStore.ratingsMode === `stars`'
v-if='siteStore.features.ratingsMode === `stars`'
v-model='state.currentRating'
icon='las la-star'
color='secondary'
size='sm'
)
.flex.items-center(v-else-if='pageStore.ratingsMode === `thumbs`')
.flex.items-center(v-else-if='siteStore.features.ratingsMode === `thumbs`')
q-btn.acrylic-btn(
flat
icon='las la-thumbs-down'
@ -237,26 +237,13 @@ q-page.column
)
q-tooltip(anchor='center left' self='center right') Page Data
q-separator.q-my-sm(inset)
q-btn.q-py-sm(
flat
icon='las la-history'
color='grey'
aria-label='Page History'
)
q-tooltip(anchor='center left' self='center right') Page History
q-btn.q-py-sm(
flat
icon='las la-code'
color='grey'
aria-label='Page Source'
)
q-tooltip(anchor='center left' self='center right') Page Source
q-btn.q-py-sm(
flat
icon='las la-ellipsis-h'
color='grey'
aria-label='Page Actions'
)
q-tooltip(anchor='center left' self='center right') Page Actions
q-menu(
anchor='top left'
self='top right'
@ -264,6 +251,16 @@ q-page.column
transition-show='jump-left'
)
q-list(padding, style='min-width: 225px;')
q-item(clickable)
q-item-section.items-center(avatar)
q-icon(color='deep-orange-9', name='las la-history', size='sm')
q-item-section
q-item-label View History
q-item(clickable)
q-item-section.items-center(avatar)
q-icon(color='deep-orange-9', name='las la-code', size='sm')
q-item-section
q-item-label View Source
q-item(clickable)
q-item-section.items-center(avatar)
q-icon(color='deep-orange-9', name='las la-atom', size='sm')
@ -285,6 +282,7 @@ q-page.column
icon='las la-copy'
color='grey'
aria-label='Duplicate Page'
@click='duplicatePage'
)
q-tooltip(anchor='center left' self='center right') Duplicate Page
q-btn.q-py-sm(
@ -292,6 +290,7 @@ q-page.column
icon='las la-share'
color='grey'
aria-label='Rename / Move Page'
@click='renamePage'
)
q-tooltip(anchor='center left' self='center right') Rename / Move Page
q-btn.q-py-sm(
@ -299,7 +298,7 @@ q-page.column
icon='las la-trash'
color='grey'
aria-label='Delete Page'
@click='savePage'
@click='deletePage'
)
q-tooltip(anchor='center left' self='center right') Delete Page
@ -313,13 +312,6 @@ q-page.column
no-shake
)
component(:is='sideDialogs[state.sideDialogComponent]')
q-dialog(
v-model='state.showGlobalDialog'
transition-show='jump-up'
transition-hide='jump-down'
)
component(:is='globalDialogs[state.globalDialogComponent]')
</template>
<script setup>
@ -349,9 +341,6 @@ const sideDialogs = {
loadingComponent: LoadingGeneric
})
}
const globalDialogs = {
PageSaveDialog: defineAsyncComponent(() => import('../components/PageSaveDialog.vue'))
}
// QUASAR
@ -472,9 +461,44 @@ function togglePageData () {
state.showSideDialog = true
}
function savePage () {
state.globalDialogComponent = 'PageSaveDialog'
state.showGlobalDialog = true
function duplicatePage () {
$q.dialog({
component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
componentProps: {
mode: 'duplicate',
pageId: pageStore.id,
pageName: pageStore.title,
pagePath: pageStore.path
}
}).onOk(() => {
// TODO: change route to new location
})
}
function renamePage () {
$q.dialog({
component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
componentProps: {
mode: 'rename',
pageId: pageStore.id,
pageName: pageStore.title,
pagePath: pageStore.path
}
}).onOk(() => {
// TODO: change route to new location
})
}
function deletePage () {
$q.dialog({
component: defineAsyncComponent(() => import('../components/PageDeleteDialog.vue')),
componentProps: {
pageId: pageStore.id,
pageName: pageStore.title
}
}).onOk(() => {
router.replace('/')
})
}
function refreshTocExpanded (baseToc, lvl) {

@ -21,11 +21,13 @@ export const useSiteStore = defineStore('site', {
searchRestrictLocale: false,
searchRestrictPath: false,
printView: false,
ratingsMode: 'thumbs',
pageDataTemplates: [],
showSideNav: true,
showSidebar: true,
overlay: null,
features: {
ratingsMode: 'off'
},
theme: {
dark: false,
injectCSS: '',
@ -76,6 +78,9 @@ export const useSiteStore = defineStore('site', {
company
contentLicense
footerExtra
features {
ratingsMode
}
theme {
dark
colorPrimary
@ -107,6 +112,10 @@ export const useSiteStore = defineStore('site', {
this.company = clone(siteInfo.company)
this.contentLicense = clone(siteInfo.contentLicense)
this.footerExtra = clone(siteInfo.footerExtra)
this.features = {
...this.features,
...clone(siteInfo.features)
}
this.theme = {
...this.theme,
...clone(siteInfo.theme)

Loading…
Cancel
Save