diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index c873aad6..6ced3e3e 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -34,6 +34,24 @@ export async function registerSchemas(app: FastifyInstance): Promise { footerExtra: { type: 'string' }, + banner: { + type: 'object', + description: + 'A notice shown above the contents of every page of this site, styled as a caution admonition. The content is markdown, and is rendered by the app rather than stored as HTML.', + properties: { + isEnabled: { + type: 'boolean' + }, + title: { + type: 'string', + maxLength: 255 + }, + content: { + type: 'string', + maxLength: 8192 + } + } + }, pageExtensions: { type: 'array', items: { diff --git a/backend/api/sites.ts b/backend/api/sites.ts index 4e50afa3..6bb02c29 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -18,6 +18,7 @@ const SITE_CONFIG_KEYS = [ 'company', 'contentLicense', 'footerExtra', + 'banner', 'pageExtensions', 'logoText', 'sitemap', @@ -262,6 +263,7 @@ async function routes(app: FastifyInstance) { company?: string contentLicense?: string footerExtra?: string + banner?: { isEnabled?: boolean; title?: string; content?: string } pageExtensions?: string[] logoText?: boolean sitemap?: boolean @@ -329,6 +331,9 @@ async function routes(app: FastifyInstance) { footerExtra: { type: 'string' }, + banner: { + $ref: 'Site#/properties/banner' + }, pageExtensions: { type: 'array', items: { diff --git a/backend/locales/en.json b/backend/locales/en.json index 6532f453..ce9084ce 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -297,6 +297,13 @@ "admin.general.allowRatingsHint": "Can users leave ratings on pages? Can be restricted using Page Rules.", "admin.general.allowSearch": "Allow Search", "admin.general.allowSearchHint": "Can users search for content they have read access to?", + "admin.general.banner": "Site-wide Banner", + "admin.general.bannerContent": "Banner Contents", + "admin.general.bannerContentHint": "The text of the banner. Basic markdown is supported.", + "admin.general.bannerEnabled": "Show Banner", + "admin.general.bannerEnabledHint": "Display a notice at the top of every page of this site.", + "admin.general.bannerTitle": "Banner Title", + "admin.general.bannerTitleHint": "Heading shown above the banner contents. Leave empty to hide.", "admin.general.companyName": "Company / Organization Name", "admin.general.companyNameHint": "Name to use when displaying copyright notice in the footer. Leave empty to hide.", "admin.general.contentLicense": "Content License", diff --git a/backend/models/sites.ts b/backend/models/sites.ts index 7266e504..589d20b4 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -99,6 +99,11 @@ class Sites { company: '', contentLicense: '', footerExtra: '', + banner: { + isEnabled: false, + title: '', + content: '' + }, pageExtensions: ['md', 'html', 'txt'], discoverable: false, defaults: { @@ -351,6 +356,11 @@ class Sites { company: '', contentLicense: '', footerExtra: '', + banner: { + isEnabled: false, + title: '', + content: '' + }, pageExtensions: ['md', 'html', 'txt'], discoverable: false, defaults: { diff --git a/frontend/src/components/BlockPickerOverlay.vue b/frontend/src/components/BlockPickerOverlay.vue index 20108928..e897ad2a 100644 --- a/frontend/src/components/BlockPickerOverlay.vue +++ b/frontend/src/components/BlockPickerOverlay.vue @@ -128,6 +128,9 @@ import { useSiteStore } from '@/stores/site' * `` — the element the component registers itself as. */ +/** Blocks the editor's side toolbar inserts directly, so the picker leaves them out. */ +const TOOLBAR_BLOCKS = ['tabs'] + // STORES const siteStore = useSiteStore() @@ -148,8 +151,16 @@ const state = reactive({ // COMPUTED -/** Only blocks this site has switched on: the rest cannot render, so offering them is a trap. */ -const blocks = computed(() => state.blocks.filter((block) => block.isEnabled)) +/** + * Blocks offered here: the ones this site has switched on, minus the ones the editor inserts itself. + * + * A block that is off cannot render, so offering it is a trap. Tabs is on but has its own button in + * the editor's side toolbar, which inserts the very same markup — listing it here as well is a second + * way to the same place. + */ +const blocks = computed(() => + state.blocks.filter((block) => block.isEnabled && !TOOLBAR_BLOCKS.includes(block.block)) +) const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : '')) diff --git a/frontend/src/components/SiteBanner.vue b/frontend/src/components/SiteBanner.vue new file mode 100644 index 00000000..c9955395 --- /dev/null +++ b/frontend/src/components/SiteBanner.vue @@ -0,0 +1,195 @@ + + + + + + diff --git a/frontend/src/components/UtilCodeEditor.vue b/frontend/src/components/UtilCodeEditor.vue index 57e122ec..bc85e00b 100644 --- a/frontend/src/components/UtilCodeEditor.vue +++ b/frontend/src/components/UtilCodeEditor.vue @@ -49,6 +49,7 @@ import hljs from 'highlight.js/lib/core' import css from 'highlight.js/lib/languages/css' import javascript from 'highlight.js/lib/languages/javascript' import json from 'highlight.js/lib/languages/json' +import markdown from 'highlight.js/lib/languages/markdown' import xml from 'highlight.js/lib/languages/xml' import yaml from 'highlight.js/lib/languages/yaml' @@ -59,7 +60,10 @@ const props = defineProps({ type: String, default: '' }, - /** `css` | `html` | `javascript` | `json` | `yaml`; anything else renders unhighlighted. */ + /** + * `css` | `html` | `javascript` | `json` | `markdown` | `yaml`; anything else renders + * unhighlighted. + */ language: { type: String, default: 'plaintext' @@ -102,6 +106,7 @@ const inputEl = ref(null) hljs.registerLanguage('css', css) hljs.registerLanguage('javascript', javascript) hljs.registerLanguage('json', json) +hljs.registerLanguage('markdown', markdown) hljs.registerLanguage('xml', xml) hljs.registerLanguage('yaml', yaml) @@ -115,6 +120,7 @@ const HLJS_LANGUAGES = { html: 'xml', javascript: 'javascript', json: 'json', + markdown: 'markdown', yaml: 'yaml' } diff --git a/frontend/src/components/shared/WItem.vue b/frontend/src/components/shared/WItem.vue index 1681230b..f70e10a0 100644 --- a/frontend/src/components/shared/WItem.vue +++ b/frontend/src/components/shared/WItem.vue @@ -66,7 +66,14 @@ const props = defineProps({ type: Boolean, default: false }, - /** Underlying element when this is not a link. */ + /** + * Underlying element when this is not a link. + * + * Nullable, and null means the default rather than nothing. Callers write + * `:tag="condition ? 'label' : null"` to say "a label only when the row is operable", and a prop + * default does not cover that: Vue applies one for `undefined` alone, so the null arrived intact + * and `` rendered the row away to nothing. See `tagName`. + */ tag: { type: String, default: 'div' @@ -105,7 +112,8 @@ const showsAffordance = computed( // -> A disabled link must stop being a link, or the browser will still navigate on click const tagName = computed(() => { if (!isAnchor.value) { - return props.tag + // -> `?? 'div'` rather than the prop default, which Vue only applies to `undefined`; see `tag` + return props.tag ?? 'div' } return props.to ? 'router-link' : 'a' }) diff --git a/frontend/src/components/shared/WSelect.vue b/frontend/src/components/shared/WSelect.vue index 8c549957..8572a596 100644 --- a/frontend/src/components/shared/WSelect.vue +++ b/frontend/src/components/shared/WSelect.vue @@ -162,18 +162,19 @@ class="w-select-option flex w-full cursor-pointer flex-nowrap items-center gap-2 px-4 text-left hover:bg-black/5 dark:hover:bg-white/8" :class="[ optionsDense ? 'min-h-8 py-1 text-body2' : 'min-h-10 py-2', - isSelected(opt.value) ? 'text-primary' : '', + isSelected(opt.value) ? selectedOptionClass : '', idx === activeIndex ? 'bg-black/8 dark:bg-white/12' : '' ]" @click.stop="select(opt.value)" @mousemove="activeIndex = idx"> - + @@ -747,6 +748,17 @@ const floatColorClass = computed(() => { return isOpen.value ? 'text-primary dark:text-primary-light' : 'text-black/60 dark:text-white/70' }) +/* + Brand blue is a *dark* colour, so on the dark panel it sat at roughly 3:1 against `dark-3` and the + selected row read as the least legible one in the list. The lightened blue is the same colour the + floating label and `.w-section-header` switch to on dark, and it carries the emphasis without the + contrast loss. `dark` is checked separately from the `dark:` variant because a menu opened from a + dark surface renders dark whatever the app theme -- which is exactly the admin sidebar's case. +*/ +const selectedOptionClass = computed(() => + props.dark ? 'text-primary-light' : 'text-primary dark:text-primary-light' +) + const controlClasses = computed(() => [ props.dense ? 'w-input-control--dense min-h-9 px-2 py-1' : 'min-h-11 px-3 py-2', // -> Its own surface, white or a dark well, matching WInput; see the note there diff --git a/frontend/src/pages/AdminDashboard.vue b/frontend/src/pages/AdminDashboard.vue index ab857a1c..96f7a5ab 100644 --- a/frontend/src/pages/AdminDashboard.vue +++ b/frontend/src/pages/AdminDashboard.vue @@ -19,7 +19,7 @@ flat color="grey" :aria-label="t(`common.actions.viewDocs`)" - :href="siteStore.docsBase + `/admin`" + :href="siteStore.docsBase + `/admin/dashboard`" target="_blank"> {{ t(`common.actions.viewDocs`) }} diff --git a/frontend/src/pages/AdminGeneral.vue b/frontend/src/pages/AdminGeneral.vue index fa14f830..8f8a2378 100644 --- a/frontend/src/pages/AdminGeneral.vue +++ b/frontend/src/pages/AdminGeneral.vue @@ -415,6 +415,55 @@ + + + + {{ t('admin.general.banner') }} + + + + {{ t(`admin.general.bannerEnabled`) }} + {{ t(`admin.general.bannerEnabledHint`) }} + + + + + + + + + + {{ t(`admin.general.bannerTitle`) }} + {{ t(`admin.general.bannerTitleHint`) }} + + + + + + + + + + {{ t(`admin.general.bannerContent`) }} + {{ t(`admin.general.bannerContentHint`) }} + + + + + + + + + @@ -538,6 +587,8 @@ import { loading } from '@/composables/loading' import { useAdminStore } from '@/stores/admin' import { useSiteStore } from '@/stores/site' +import UtilCodeEditor from '@/components/UtilCodeEditor.vue' + import { clearSiteImage, isAcceptedSiteImage, @@ -576,6 +627,11 @@ function defaultConfig() { company: '', contentLicense: '', footerExtra: '', + banner: { + isEnabled: false, + title: '', + content: '' + }, pageExtensions: '', logoText: false, ratings: { @@ -695,6 +751,11 @@ async function save() { company: state.config.company ?? '', contentLicense: state.config.contentLicense ?? '', footerExtra: state.config.footerExtra ?? '', + banner: { + isEnabled: state.config.banner?.isEnabled ?? false, + title: state.config.banner?.title ?? '', + content: state.config.banner?.content ?? '' + }, pageExtensions: parsePageExtensions(state.config.pageExtensions), logoText: state.config.logoText ?? false, sitemap: state.config.sitemap ?? false, diff --git a/frontend/src/pages/Index.vue b/frontend/src/pages/Index.vue index 1805fc3f..c5acca8a 100644 --- a/frontend/src/pages/Index.vue +++ b/frontend/src/pages/Index.vue @@ -115,6 +115,11 @@
+ +