feat: admin blocks page + lazy load blocks + block-index

pull/6775/head
NGPixel 1 year ago
parent fdc45f6b49
commit 46fb25c1e9
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

1
blocks/.gitignore vendored

@ -1,2 +1,3 @@
compiled
dist
node_modules

@ -1,70 +1,109 @@
import { LitElement, html, css } from 'lit'
import folderByPath from './folderByPath.graphql'
import treeQuery from './folderByPath.graphql'
/**
* An example element.
*
* @fires count-changed - Indicates when the count changes
* @slot - This element has a slot
* @csspart button - The button
* Block Index
*/
export class BlockIndexElement extends LitElement {
static get styles() {
return css`
:host {
display: block;
border: solid 1px gray;
padding: 16px;
max-width: 800px;
margin-bottom: 16px;
}
`;
:host-context(body.body--dark) {
background-color: #F00;
}
ul {
padding: 0;
margin: 0;
list-style: none;
}
li {
background-color: #fafafa;
background-image: linear-gradient(180deg,#fff,#fafafa);
border-right: 1px solid #eee;
border-bottom: 1px solid #eee;
border-left: 5px solid #e0e0e0;
box-shadow: 0 3px 8px 0 rgba(116,129,141,.1);
padding: 0;
border-radius: 5px;
font-weight: 500;
}
li:hover {
background-image: linear-gradient(180deg,#fff,#f6fbfe);
border-left-color: #2196f3;
cursor: pointer;
}
li + li {
margin-top: .5rem;
}
li a {
display: block;
color: #1976d2;
padding: 1rem;
text-decoration: none;
}
`
}
static get properties() {
return {
/**
* The name to say "Hello" to.
* The base path to fetch pages from
* @type {string}
*/
path: {type: String},
/**
* A comma-separated list of tags to filter with
* @type {string}
*/
name: {type: String},
tags: {type: String},
/**
* The number of times the button has been clicked.
* The maximum number of items to fetch
* @type {number}
*/
count: {type: Number},
};
limit: {type: Number}
}
}
constructor() {
super();
this.name = 'World';
this.count = 0;
super()
this.pages = []
}
async connectedCallback() {
super.connectedCallback()
const resp = await APOLLO_CLIENT.query({
query: treeQuery,
variables: {
siteId: WIKI_STORES.site.id,
locale: 'en',
parentPath: ''
}
})
this.pages = resp.data.tree
this.requestUpdate()
}
render() {
return html`
<h1>${this.sayHello(this.name)}!</h1>
<button @click=${this._onClick} part="button">
Click Count: ${this.count}
</button>
<ul>
${this.pages.map(p =>
html`<li><a href="#">${p.title}</a></li>`
)}
</ul>
<slot></slot>
`;
`
}
_onClick() {
this.count++;
this.dispatchEvent(new CustomEvent('count-changed'));
}
/**
* Formats a greeting
* @param name {string} The name to say "Hello" to
* @returns {string} A greeting directed at `name`
*/
sayHello(name) {
return `Hello, ${name}`;
}
// createRenderRoot() {
// return this;
// }
}
window.customElements.define('block-index', BlockIndexElement);
window.customElements.define('block-index', BlockIndexElement)

@ -1,5 +1,13 @@
query folderByPath($siteId: UUID!, $locale: String!, $path: String!) {
folderByPath(siteId: $siteId, locale: $locale, path: $path) {
query blockIndexFetchPages (
$siteId: UUID!
$locale: String!
$parentPath: String!
) {
tree(
siteId: $siteId,
locale: $locale,
parentPath: $parentPath
) {
id
title
}

@ -7,7 +7,7 @@
"": {
"name": "blocks",
"version": "1.0.0",
"license": "ISC",
"license": "AGPL-3.0",
"dependencies": {
"lit": "^2.8.0"
},

@ -21,7 +21,7 @@ export default {
})
),
output: {
dir: 'dist',
dir: 'compiled',
format: 'es',
globals: {
APOLLO_CLIENT: 'APOLLO_CLIENT'
@ -31,9 +31,8 @@ export default {
resolve(),
graphql(),
terser({
ecma: 2017,
module: true,
warnings: true
ecma: 2019,
module: true
}),
summary()
]

@ -70,6 +70,17 @@ export async function up (knex) {
table.string('allowedEmailRegex')
table.specificType('autoEnrollGroups', 'uuid[]')
})
.createTable('blocks', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('block').notNullable()
table.string('name').notNullable()
table.string('description').notNullable()
table.string('icon')
table.boolean('isEnabled').notNullable().defaultTo(false)
table.boolean('isCustom').notNullable().defaultTo(false)
table.json('config').notNullable()
})
// COMMENT PROVIDERS -------------------
.createTable('commentProviders', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('module').notNullable()
@ -363,6 +374,9 @@ export async function up (knex) {
table.uuid('authorId').notNullable().references('id').inTable('users')
table.uuid('siteId').notNullable().references('id').inTable('sites').index()
})
.table('blocks', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
.table('commentProviders', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
@ -774,6 +788,19 @@ export async function up (knex) {
}
])
// -> BLOCKS
await knex('blocks').insert({
block: 'index',
name: 'Index',
description: 'Show a list of pages matching a path or set of tags.',
icon: 'rules',
isCustom: false,
isEnabled: true,
config: {},
siteId: siteId
})
// -> NAVIGATION
await knex('navigation').insert({

@ -0,0 +1,23 @@
import { generateError, generateSuccess } from '../../helpers/graph.mjs'
export default {
Query: {
async blocks (obj, args, context) {
return WIKI.db.blocks.query().where({
siteId: args.siteId
})
}
},
Mutation: {
async setBlocksState(obj, args, context) {
try {
// TODO: update blocks state
return {
operation: generateSuccess('Blocks state updated successfully')
}
} catch (err) {
return generateError(err)
}
}
}
}

@ -0,0 +1,40 @@
# ===============================================
# BLOCKS
# ===============================================
extend type Query {
blocks(
siteId: UUID!
): [Block]
}
extend type Mutation {
setBlocksState(
siteId: UUID!
states: [BlockStateInput]!
): DefaultResponse
deleteBlock(
id: UUID!
): DefaultResponse
}
# -----------------------------------------------
# TYPES
# -----------------------------------------------
type Block {
id: UUID
block: String
name: String
description: String
icon: String
isEnabled: Boolean
isCustom: Boolean
config: JSON
}
input BlockStateInput {
id: UUID!
isEnabled: Boolean!
}

@ -109,6 +109,12 @@
"admin.auth.title": "Authentication",
"admin.auth.vendor": "Vendor",
"admin.auth.vendorWebsite": "Website",
"admin.blocks.add": "Add Block",
"admin.blocks.builtin": "Built-in component",
"admin.blocks.custom": "Custom component",
"admin.blocks.isEnabled": "Enabled",
"admin.blocks.saveSuccess": "Blocks state saved successfully.",
"admin.blocks.subtitle": "Manage dynamic components available for use inside pages.",
"admin.blocks.title": "Content Blocks",
"admin.comments.provider": "Provider",
"admin.comments.providerConfig": "Provider Configuration",

@ -0,0 +1,28 @@
import { Model } from 'objection'
/**
* Block model
*/
export class Block extends Model {
static get tableName () { return 'blocks' }
static get jsonAttributes () {
return ['config']
}
static async addBlock (data) {
return WIKI.db.blocks.query().insertAndFetch({
block: data.block,
name: data.name,
description: data.description,
icon: data.icon,
isEnabled: true,
isCustom: true,
config: {}
})
}
static async deleteBlock (id) {
return WIKI.db.blocks.query().deleteById(id)
}
}

@ -2,6 +2,7 @@ import { Analytics } from './analytics.mjs'
import { ApiKey } from './apiKeys.mjs'
import { Asset } from './assets.mjs'
import { Authentication } from './authentication.mjs'
import { Block } from './blocks.mjs'
import { CommentProvider } from './commentProviders.mjs'
import { Comment } from './comments.mjs'
import { Group } from './groups.mjs'
@ -25,6 +26,7 @@ export default {
apiKeys: ApiKey,
assets: Asset,
authentication: Authentication,
blocks: Block,
commentProviders: CommentProvider,
comments: Comment,
groups: Group,

@ -120,7 +120,7 @@ export async function init () {
// Blocks
// ----------------------------------------
app.use('/_blocks', express.static(path.join(WIKI.ROOTPATH, 'blocks/dist'), {
app.use('/_blocks', express.static(path.join(WIKI.ROOTPATH, 'blocks/compiled'), {
index: false,
maxAge: '7d'
}))

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M5.5 5.5H16.5V11.5H5.5z"/><path fill="#4788c7" d="M16,6v5H6V6H16 M17,5H5v7h12V5L17,5z"/><path fill="#98ccfd" d="M23.5 5.5H34.5V12.5H23.5z"/><path fill="#4788c7" d="M34,6v6H24V6H34 M35,5H23v8h12V5L35,5z"/><g><path fill="#dff0fe" d="M1.5 11.5H38.5V34.5H1.5z"/><path fill="#4788c7" d="M38,12v22H2V12H38 M39,11H1v24h38V11L39,11z"/></g></svg>

After

Width:  |  Height:  |  Size: 449 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M5.5 2.5H34.5V30.577H5.5z"/><path fill="#4788c7" d="M34,3v27.077H6V3H34 M35,2H5v29.077h30V2L35,2z"/><path fill="#b6dcfe" d="M7.538,37.5c-2.108,0-4.893-0.738-5.032-7h34.988c-0.14,6.262-2.924,7-5.032,7H7.538z"/><path fill="#4788c7" d="M36.977 31c-.25 5.292-2.522 6-4.516 6H7.538c-1.994 0-4.266-.708-4.516-6H36.977M38 30H2c0 6.635 2.775 8 5.538 8 0 0 22.145 0 24.923 0C35.24 38 38 36.67 38 30L38 30zM28.5 12h-13c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5v0C29 11.776 28.776 12 28.5 12zM11.5 10.5A1 1 0 1 0 11.5 12.5 1 1 0 1 0 11.5 10.5zM28.5 18h-13c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5l0 0C29 17.776 28.776 18 28.5 18zM11.5 16.5A1 1 0 1 0 11.5 18.5 1 1 0 1 0 11.5 16.5zM28.5 24h-13c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5l0 0C29 23.776 28.776 24 28.5 24zM11.5 22.5A1 1 0 1 0 11.5 24.5 1 1 0 1 0 11.5 22.5z"/></svg>

After

Width:  |  Height:  |  Size: 996 B

@ -38,6 +38,7 @@ module.exports = configure(function (ctx) {
boot: [
'apollo',
'components',
'externals',
'eventbus',
'i18n',
{

@ -0,0 +1,17 @@
import { boot } from 'quasar/wrappers'
import { useSiteStore } from 'src/stores/site'
import { useUserStore } from 'src/stores/user'
export default boot(() => {
if (import.meta.env.SSR) {
global.WIKI_STORES = {
site: useSiteStore(),
user: useUserStore()
}
} else {
window.WIKI_STORES = {
site: useSiteStore(),
user: useUserStore()
}
}
})

@ -258,6 +258,7 @@ import { DateTime } from 'luxon'
import * as monaco from 'monaco-editor'
import { Position, Range } from 'monaco-editor'
import { useCommonStore } from 'src/stores/common'
import { useEditorStore } from 'src/stores/editor'
import { usePageStore } from 'src/stores/page'
import { useSiteStore } from 'src/stores/site'
@ -271,6 +272,7 @@ const $q = useQuasar()
// STORES
const commonStore = useCommonStore()
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
@ -472,6 +474,11 @@ function processContent (newContent) {
pageStore.$patch({
render: md.render(newContent)
})
nextTick(() => {
for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) {
commonStore.loadBlocks([block.tagName.toLowerCase()])
}
})
}
function openEditorSettings () {

@ -98,7 +98,7 @@ q-layout.admin(view='hHh Lpr lff')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-comments.svg')
q-item-section {{ t('admin.comments.title') }}
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', disabled)
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
q-item-section {{ t('admin.blocks.title') }}

@ -0,0 +1,235 @@
<template lang='pug'>
q-page.admin-flags
.row.q-pa-md.items-center
.col-auto
img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-rfid-tag.svg')
.col.q-pl-md
.text-h5.text-primary.animated.fadeInLeft {{ t('admin.blocks.title') }}
.text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.blocks.subtitle') }}
.col-auto.flex
template(v-if='flagsStore.experimental')
q-btn.q-mr-sm.acrylic-btn(
unelevated
icon='las la-plus'
:label='t(`admin.blocks.add`)'
color='primary'
@click='addBlock'
)
q-separator.q-mr-sm(vertical)
q-btn.q-mr-sm.acrylic-btn(
icon='las la-question-circle'
flat
color='grey'
:aria-label='t(`common.actions.viewDocs`)'
:href='siteStore.docsBase + `/admin/editors`'
target='_blank'
type='a'
)
q-tooltip {{ t(`common.actions.viewDocs`) }}
q-btn.q-mr-sm.acrylic-btn(
icon='las la-redo-alt'
flat
color='secondary'
:loading='state.loading > 0'
:aria-label='t(`common.actions.refresh`)'
@click='refresh'
)
q-tooltip {{ t(`common.actions.refresh`) }}
q-btn(
unelevated
icon='mdi-check'
:label='t(`common.actions.apply`)'
color='secondary'
@click='save'
:disabled='state.loading > 0'
)
q-separator(inset)
.q-pa-md.q-gutter-md
q-card
q-list(separator)
q-item(v-for='block of state.blocks', :key='block.id')
blueprint-icon(:icon='block.isCustom ? `plugin` : block.icon')
q-item-section
q-item-label: strong {{block.name}}
q-item-label(caption) {{ block.description}}
q-item-label.flex.items-center(caption)
q-chip.q-ma-none(square, dense, :color='$q.dark.isActive ? `pink-8` : `pink-1`', :text-color='$q.dark.isActive ? `white` : `pink-9`'): span.text-caption &lt;block-{{ block.block }}&gt;
q-separator.q-mx-sm.q-my-xs(vertical)
em.text-purple(v-if='block.isCustom') {{ t('admin.blocks.custom') }}
em.text-teal-7(v-else) {{ t('admin.blocks.builtin') }}
template(v-if='block.isCustom')
q-item-section(
side
)
q-btn(
icon='las la-trash'
:aria-label='t(`common.actions.delete`)'
color='negative'
outline
no-caps
padding='xs sm'
@click='deleteBlock(block.id)'
)
q-separator.q-ml-lg(vertical)
q-item-section(side)
q-toggle.q-pr-sm(
v-model='block.isEnabled'
color='primary'
checked-icon='las la-check'
unchecked-icon='las la-times'
:label='t(`admin.blocks.isEnabled`)'
:aria-label='t(`admin.blocks.isEnabled`)'
)
</template>
<script setup>
import { useMeta, useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent, onMounted, reactive, watch } from 'vue'
import gql from 'graphql-tag'
import { cloneDeep, pick } from 'lodash-es'
import { useAdminStore } from 'src/stores/admin'
import { useFlagsStore } from 'src/stores/flags'
import { useSiteStore } from 'src/stores/site'
// QUASAR
const $q = useQuasar()
// STORES
const adminStore = useAdminStore()
const flagsStore = useFlagsStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// META
useMeta({
title: t('admin.editors.title')
})
const state = reactive({
loading: 0,
blocks: []
})
// WATCHERS
watch(() => adminStore.currentSiteId, (newValue) => {
$q.loading.show()
load()
})
// METHODS
async function load () {
state.loading++
try {
const resp = await APOLLO_CLIENT.query({
query: gql`
query getSiteBlocks (
$siteId: UUID!
) {
blocks (
siteId: $siteId
) {
id
block
name
description
icon
isEnabled
isCustom
config
}
}`,
variables: {
siteId: adminStore.currentSiteId
},
fetchPolicy: 'network-only'
})
state.blocks = cloneDeep(resp?.data?.blocks)
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to fetch blocks state.'
})
}
$q.loading.hide()
state.loading--
}
async function save () {
state.loading++
try {
const respRaw = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation saveSiteBlocks (
$siteId: UUID!
$states: [BlockStateInput]!
) {
setBlocksState (
siteId: $siteId,
states: $states
) {
operation {
succeeded
slug
message
}
}
}
`,
variables: {
siteId: adminStore.currentSiteId,
states: state.blocks.map(bl => pick(bl, ['id', 'isEnabled']))
}
})
if (respRaw?.data?.setBlocksState?.operation?.succeeded) {
$q.notify({
type: 'positive',
message: t('admin.blocks.saveSuccess')
})
} else {
throw new Error(respRaw?.data?.setBlocksState?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to save site blocks state',
caption: err.message
})
}
state.loading--
}
async function refresh () {
await load()
}
function addBlock () {
}
function deleteBlock (id) {
}
// MOUNTED
onMounted(async () => {
$q.loading.show()
if (adminStore.currentSiteId) {
await load()
}
})
</script>
<style lang='scss'>
</style>

@ -43,7 +43,7 @@ q-page.column
style='height: 100%;'
)
.q-pa-md
.page-contents(v-html='pageStore.render')
.page-contents(ref='pageContents', v-html='pageStore.render')
template(v-if='pageStore.relations && pageStore.relations.length > 0')
q-separator.q-my-lg
.row.align-center
@ -158,11 +158,12 @@ q-page.column
<script setup>
import { useMeta, useQuasar } from 'quasar'
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { DateTime } from 'luxon'
import { useCommonStore } from 'src/stores/common'
import { useEditorStore } from 'src/stores/editor'
import { useFlagsStore } from 'src/stores/flags'
import { usePageStore } from 'src/stores/page'
@ -194,6 +195,7 @@ const $q = useQuasar()
// STORES
const commonStore = useCommonStore()
const editorStore = useEditorStore()
const flagsStore = useFlagsStore()
const pageStore = usePageStore()
@ -241,6 +243,8 @@ const barStyle = {
opacity: 1
}
const pageContents = ref(null)
// COMPUTED
const showSidebar = computed(() => {
@ -312,6 +316,12 @@ watch(() => route.path, async (newValue) => {
isActive: false
})
}
// -> Load Blocks
nextTick(() => {
for (const block of pageContents.value.querySelectorAll(':not(:defined)')) {
commonStore.loadBlocks([block.tagName.toLowerCase()])
}
})
} catch (err) {
if (err.message === 'ERR_PAGE_NOT_FOUND') {
if (newValue === '/') {

@ -45,6 +45,7 @@ const routes = [
{ path: 'sites', component: () => import('pages/AdminSites.vue') },
// -> Site
{ path: ':siteid/general', component: () => import('pages/AdminGeneral.vue') },
{ path: ':siteid/blocks', component: () => import('pages/AdminBlocks.vue') },
{ path: ':siteid/editors', component: () => import('pages/AdminEditors.vue') },
{ path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
{ path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },

@ -1,11 +1,13 @@
import { defineStore } from 'pinia'
import gql from 'graphql-tag'
import { difference } from 'lodash-es'
export const useCommonStore = defineStore('common', {
state: () => ({
routerLoading: false,
locale: localStorage.getItem('locale') || 'en',
desiredLocale: localStorage.getItem('locale')
desiredLocale: localStorage.getItem('locale'),
blocksLoaded: []
}),
getters: {},
actions: {
@ -38,6 +40,17 @@ export const useCommonStore = defineStore('common', {
desiredLocale: locale
})
localStorage.setItem('locale', locale)
},
async loadBlocks (blocks = []) {
const toLoad = difference(blocks, this.blocksLoaded)
for (const block of toLoad) {
try {
await import(/* @vite-ignore */ `/_blocks/${block}.js`)
this.blocksLoaded.push(block)
} catch (err) {
console.warn(`Failed to load ${block}: ${err.message}`)
}
}
}
}
})

Loading…
Cancel
Save