diff --git a/blocks/.gitignore b/blocks/.gitignore index de4d1f00..55ae882a 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 e24f3a97..5e4c4e60 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 4d7aec7c..d9257455 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 598bd122..dcc15370 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 201f3121..f3007b82 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 2d83c273..81ce33d4 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 00000000..a1408f99 --- /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 00000000..430e0f06 --- /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 2bdaa87c..956e3b20 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 00000000..1e51568e --- /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 3e36af7a..910b984e 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 28528374..502c7227 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 00000000..a41d6b0e --- /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 00000000..7b92730b --- /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 2f0f8780..931f5302 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 00000000..f13a5819 --- /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 94549d1c..15c32ef9 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 c7ed44b3..1c92af41 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 00000000..814f7d39 --- /dev/null +++ b/ux/src/pages/AdminBlocks.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/ux/src/pages/Index.vue b/ux/src/pages/Index.vue index 3fb4af56..f26da532 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