diff --git a/blocks/.gitignore b/blocks/.gitignore
index de4d1f007d..55ae882a2f 100644
--- a/blocks/.gitignore
+++ b/blocks/.gitignore
@@ -1,2 +1,3 @@
+compiled
dist
node_modules
diff --git a/blocks/block-index/component.js b/blocks/block-index/component.js
index e24f3a9724..5e4c4e60e7 100644
--- a/blocks/block-index/component.js
+++ b/blocks/block-index/component.js
@@ -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}
*/
- name: {type: String},
+ path: {type: String},
/**
- * The number of times the button has been clicked.
+ * A comma-separated list of tags to filter with
+ * @type {string}
+ */
+ tags: {type: String},
+
+ /**
+ * 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`
-
${this.sayHello(this.name)}!
-
+
- `;
- }
-
- _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)
diff --git a/blocks/block-index/folderByPath.graphql b/blocks/block-index/folderByPath.graphql
index 4d7aec7c91..d9257455ec 100644
--- a/blocks/block-index/folderByPath.graphql
+++ b/blocks/block-index/folderByPath.graphql
@@ -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
}
diff --git a/blocks/package-lock.json b/blocks/package-lock.json
index 598bd12210..dcc1537052 100644
--- a/blocks/package-lock.json
+++ b/blocks/package-lock.json
@@ -7,7 +7,7 @@
"": {
"name": "blocks",
"version": "1.0.0",
- "license": "ISC",
+ "license": "AGPL-3.0",
"dependencies": {
"lit": "^2.8.0"
},
diff --git a/blocks/rollup.config.mjs b/blocks/rollup.config.mjs
index 201f31212d..f3007b8207 100644
--- a/blocks/rollup.config.mjs
+++ b/blocks/rollup.config.mjs
@@ -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()
]
diff --git a/server/db/migrations/3.0.0.mjs b/server/db/migrations/3.0.0.mjs
index 2d83c27309..81ce33d489 100644
--- a/server/db/migrations/3.0.0.mjs
+++ b/server/db/migrations/3.0.0.mjs
@@ -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({
diff --git a/server/graph/resolvers/block.mjs b/server/graph/resolvers/block.mjs
new file mode 100644
index 0000000000..a1408f99d0
--- /dev/null
+++ b/server/graph/resolvers/block.mjs
@@ -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)
+ }
+ }
+ }
+}
diff --git a/server/graph/schemas/block.graphql b/server/graph/schemas/block.graphql
new file mode 100644
index 0000000000..430e0f06b8
--- /dev/null
+++ b/server/graph/schemas/block.graphql
@@ -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!
+}
diff --git a/server/locales/en.json b/server/locales/en.json
index 2bdaa87c6b..956e3b203e 100644
--- a/server/locales/en.json
+++ b/server/locales/en.json
@@ -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",
diff --git a/server/models/blocks.mjs b/server/models/blocks.mjs
new file mode 100644
index 0000000000..1e51568eeb
--- /dev/null
+++ b/server/models/blocks.mjs
@@ -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)
+ }
+}
diff --git a/server/models/index.mjs b/server/models/index.mjs
index 3e36af7ab4..910b984e66 100644
--- a/server/models/index.mjs
+++ b/server/models/index.mjs
@@ -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,
diff --git a/server/web.mjs b/server/web.mjs
index 285283749b..502c72271e 100644
--- a/server/web.mjs
+++ b/server/web.mjs
@@ -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'
}))
diff --git a/ux/public/_assets/icons/ultraviolet-plugin.svg b/ux/public/_assets/icons/ultraviolet-plugin.svg
new file mode 100644
index 0000000000..a41d6b0e5d
--- /dev/null
+++ b/ux/public/_assets/icons/ultraviolet-plugin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ux/public/_assets/icons/ultraviolet-rules.svg b/ux/public/_assets/icons/ultraviolet-rules.svg
new file mode 100644
index 0000000000..7b92730b21
--- /dev/null
+++ b/ux/public/_assets/icons/ultraviolet-rules.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/ux/quasar.config.js b/ux/quasar.config.js
index 2f0f878093..931f5302d7 100644
--- a/ux/quasar.config.js
+++ b/ux/quasar.config.js
@@ -38,6 +38,7 @@ module.exports = configure(function (ctx) {
boot: [
'apollo',
'components',
+ 'externals',
'eventbus',
'i18n',
{
diff --git a/ux/src/boot/externals.js b/ux/src/boot/externals.js
new file mode 100644
index 0000000000..f13a5819fc
--- /dev/null
+++ b/ux/src/boot/externals.js
@@ -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()
+ }
+ }
+})
diff --git a/ux/src/components/EditorMarkdown.vue b/ux/src/components/EditorMarkdown.vue
index 94549d1c3c..15c32ef917 100644
--- a/ux/src/components/EditorMarkdown.vue
+++ b/ux/src/components/EditorMarkdown.vue
@@ -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 () {
diff --git a/ux/src/layouts/AdminLayout.vue b/ux/src/layouts/AdminLayout.vue
index c7ed44b395..1c92af41a6 100644
--- a/ux/src/layouts/AdminLayout.vue
+++ b/ux/src/layouts/AdminLayout.vue
@@ -98,10 +98,10 @@ 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-section(avatar)
- q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
- q-item-section {{ t('admin.blocks.title') }}
+ 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') }}
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/editors`', 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-cashbook.svg')
diff --git a/ux/src/pages/AdminBlocks.vue b/ux/src/pages/AdminBlocks.vue
new file mode 100644
index 0000000000..814f7d399a
--- /dev/null
+++ b/ux/src/pages/AdminBlocks.vue
@@ -0,0 +1,235 @@
+
+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 <block-{{ block.block }}>
+ 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`)'
+ )
+
+
+
+
+
diff --git a/ux/src/pages/Index.vue b/ux/src/pages/Index.vue
index 3fb4af56df..f26da53244 100644
--- a/ux/src/pages/Index.vue
+++ b/ux/src/pages/Index.vue
@@ -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