mirror of
https://github.com/requarks/wiki.git
synced 2026-08-19 01:04:45 -05:00
feat: site-wide banner
This commit is contained in:
@@ -34,6 +34,24 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -128,6 +128,9 @@ import { useSiteStore } from '@/stores/site'
|
||||
* `<block-name prop="value">` — 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) : ''))
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<!--
|
||||
Two classes doing two different jobs. `page-contents` is what draws the markdown INSIDE the notice
|
||||
-- its paragraphs, lists, links and code -- exactly as the same markdown would read on a page, and
|
||||
is also what zeroes the notice's own outer margins (`> :first-child`, `> :last-child`).
|
||||
|
||||
The notice itself is drawn by `site-banner-alert` in the style block below, which is a COPY of the
|
||||
caution admonition rather than a use of it: a banner is chrome an administrator raises over a
|
||||
whole site, an admonition is something an author wrote in one page, and the two only happen to
|
||||
look alike today. Restyle either without touching the other.
|
||||
-->
|
||||
<div class="page-contents site-banner" v-if="html" v-html="html" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { escape } from 'es-toolkit/string'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* The site-wide banner, above the contents of every page.
|
||||
*
|
||||
* Site configuration rather than content: it is set in the admin area's General section, is the same
|
||||
* on every page, and so never goes through the page renderer -- which renders ONE page, on the server,
|
||||
* and stores the result. This renders here instead, on every view, which is also what lets a banner
|
||||
* raised during an incident appear without every page being re-rendered.
|
||||
*
|
||||
* Only the inline parts of markdown are worth anything in a notice a few lines long, but the full
|
||||
* block grammar comes free with the parser, so the whole of markdown-it is what runs -- with `html`
|
||||
* OFF, unlike the page renderer. An administrator writing a banner is not writing a page, and
|
||||
* markdown-it's own link validation (`javascript:` and friends) is then the only escape left to
|
||||
* worry about, which it handles itself.
|
||||
*/
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// DATA
|
||||
|
||||
/**
|
||||
* The parser, built on first use and kept.
|
||||
*
|
||||
* Imported dynamically so that markdown-it stays out of the bundle a reader downloads for a site with
|
||||
* no banner, which is nearly all of them -- and so that it is fetched alongside the banner rather than
|
||||
* ahead of the page.
|
||||
*/
|
||||
let md = null
|
||||
|
||||
/** Which render is the current one, so that a slower earlier import cannot land on top of a later. */
|
||||
let generation = 0
|
||||
|
||||
const html = ref('')
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const banner = computed(() => siteStore.banner)
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(banner, render, { deep: true, immediate: true })
|
||||
|
||||
// METHODS
|
||||
|
||||
async function render() {
|
||||
const gen = ++generation
|
||||
const { isEnabled, title, content } = banner.value
|
||||
if (!isEnabled) {
|
||||
html.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// -> Text, not markdown: a title is one line, and a heading inside a heading is not what an
|
||||
// administrator typing a sentence there means
|
||||
const parts = title ? [`<p class="site-banner-title">${escape(title)}</p>`] : []
|
||||
|
||||
if (content) {
|
||||
md ??= new (await import('markdown-it')).default({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true
|
||||
})
|
||||
parts.push(md.render(content))
|
||||
}
|
||||
|
||||
/*
|
||||
Built as a string rather than written in the template around a `v-html` element, because the
|
||||
title has to be a DIRECT child of the notice: an element in the template could only hold the
|
||||
render in a wrapper of its own, and every `>` selector below would then miss.
|
||||
*/
|
||||
if (gen === generation) {
|
||||
html.value = parts.length > 0 ? `<div class="site-banner-alert">${parts.join('')}</div>` : ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Not scoped: the notice and its title are written by `v-html`, and scoped styles reach only the
|
||||
elements Vue itself renders. Every selector is under `.site-banner`, which is what keeps it to this
|
||||
component -- and what carries the specificity, since these rules sit inside `.page-contents` and
|
||||
have to out-weigh its own (`.page-contents p` and friends are two components, so one class is not
|
||||
enough).
|
||||
-->
|
||||
<style lang="scss">
|
||||
.site-banner {
|
||||
/*
|
||||
Flush to the top of the column, a hairline of it left showing either side, and the article's own
|
||||
distance below.
|
||||
|
||||
All three are measured off the padding `page-container-body` puts around the article -- 1rem, or
|
||||
0.5rem on a phone, which is why the breakpoint here is the 600px `Index.vue` switches that padding
|
||||
at. The band takes the whole of it back at the top and all but a pixel of it at the sides, so the
|
||||
property below is that padding and the margins are what is left of it.
|
||||
*/
|
||||
--site-banner-pad: 1rem;
|
||||
|
||||
margin: calc(-1 * var(--site-banner-pad)) calc(1px - var(--site-banner-pad)) 1.5rem;
|
||||
|
||||
@media (max-width: $breakpoint-xs-max) {
|
||||
--site-banner-pad: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
Descended from the caution admonition in `css/_page-contents.scss` and now drawn on its own terms:
|
||||
a wash, a heavier rule and an icon -- colour alone would leave a reader who cannot separate the
|
||||
hues with nothing to go on, and the icon is a masked SVG rather than a glyph because the app ships
|
||||
no icon webfont.
|
||||
|
||||
The rule runs along the BOTTOM and the corners are square, which is where it parts company with an
|
||||
admonition: this is a band across the top of the page rather than a block within it, so it reads
|
||||
as a strip the article begins under -- squared off to the column's own edges, and closed by a line
|
||||
that says where the page's own content starts.
|
||||
|
||||
Its colours are declared here as the banner's OWN two properties rather than read from the content
|
||||
palette, so that re-tinting the banner is these two lines (plus the two in the dark block) and
|
||||
reaches nothing else.
|
||||
*/
|
||||
.site-banner-alert {
|
||||
--site-banner-hue: #c02636;
|
||||
--site-banner-wash: rgba(192, 38, 54, 0.08);
|
||||
|
||||
position: relative;
|
||||
padding: 0.9em 1.1em 0.9em 3.1em;
|
||||
border-bottom: 4px solid var(--site-banner-hue);
|
||||
background-color: var(--site-banner-wash);
|
||||
|
||||
/* -> Lighter, because the notice sits on a dark page rather than in the flow of one */
|
||||
@at-root .body--dark & {
|
||||
--site-banner-hue: #ff8b8b;
|
||||
--site-banner-wash: rgba(255, 139, 139, 0.12);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
/*
|
||||
Centred on the CAP BAND of the first line rather than on the line box holding it: a line box
|
||||
is ascent plus descent, and a title inks only what is between the cap line and the baseline,
|
||||
so an icon centred on the box reads as sitting a pixel high. `0.9em` is the padding above the
|
||||
first line, `0.79em` the middle of the cap band within it, and `0.625em` half the icon.
|
||||
*/
|
||||
top: calc(0.9em + 0.79em - 0.625em);
|
||||
left: 1.1em;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
/*
|
||||
The warning triangle, drawn here as a mask rather than referenced as an icon: a name would be
|
||||
resolved at runtime through `/_icons`, and a banner raised because something is wrong is the
|
||||
last thing that should depend on a fetch.
|
||||
*/
|
||||
background-color: var(--site-banner-hue);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z'/%3E%3C/svg%3E");
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
/* -> The notice ends where its box does; the last block's own margin would show as a gap */
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
The banner's heading, in the notice's colour since that is what it names, and close above the
|
||||
text it introduces rather than a paragraph's distance from it.
|
||||
*/
|
||||
> .site-banner-title {
|
||||
margin-bottom: 0.3em;
|
||||
color: var(--site-banner-hue);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<component :is="null">` 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'
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
<!--
|
||||
A check, not a checkbox. The icon takes the row's own font size unless told otherwise,
|
||||
which made a 14px square that read as a rendering fault rather than a control -- and the
|
||||
row already announces its state by colouring itself. The column is held open when
|
||||
nothing is drawn, so labels line up whatever is selected.
|
||||
which made a 14px square that read as a rendering fault rather than a control. Drawn for
|
||||
a single selection too: colour alone is a weak marker, and the one the row used to rely
|
||||
on could not be strong enough on a dark panel to carry the state by itself. The column is
|
||||
held open when nothing is drawn, so labels line up whatever is selected.
|
||||
-->
|
||||
<span v-if="multiple" class="flex w-5 shrink-0 justify-center">
|
||||
<span class="flex w-5 shrink-0 justify-center">
|
||||
<w-icon v-if="isSelected(opt.value)" name="mdi:check" size="20px" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
flat
|
||||
color="grey"
|
||||
:aria-label="t(`common.actions.viewDocs`)"
|
||||
:href="siteStore.docsBase + `/admin`"
|
||||
:href="siteStore.docsBase + `/admin/dashboard`"
|
||||
target="_blank">
|
||||
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
|
||||
</w-btn>
|
||||
|
||||
@@ -415,6 +415,55 @@
|
||||
</w-item>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- Site-wide Banner -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2 mt-4">
|
||||
<w-card-header>{{ t('admin.general.banner') }}</w-card-header>
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="flag-filled" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.general.bannerEnabled`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.general.bannerEnabledHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle
|
||||
v-model="state.config.banner.isEnabled"
|
||||
:aria-label="t(`admin.general.bannerEnabled`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="typography" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.general.bannerTitle`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.general.bannerTitleHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-input
|
||||
outlined
|
||||
v-model="state.config.banner.title"
|
||||
dense
|
||||
:aria-label="t(`admin.general.bannerTitle`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="markdown" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{ t(`admin.general.bannerContent`) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`admin.general.bannerContentHint`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-item>
|
||||
<w-item-section>
|
||||
<util-code-editor
|
||||
v-model="state.config.banner.content"
|
||||
language="markdown"
|
||||
:aria-label="t(`admin.general.bannerContent`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
<!-- ----------------------- -->
|
||||
<!-- Discovery -->
|
||||
<!-- ----------------------- -->
|
||||
<w-card class="pb-2 mt-4">
|
||||
@@ -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,
|
||||
|
||||
@@ -115,6 +115,11 @@
|
||||
<!-- -> Half the padding on a phone, where 16px a side is 8% of the window spent on margin;
|
||||
the stylesheet has `--content-bleed` to match -->
|
||||
<div class="page-container-body p-2 sm:p-4">
|
||||
<!--
|
||||
Above the article rather than above the toolbars: what an administrator raises a banner
|
||||
about is the content, and this is where a reader is already looking.
|
||||
-->
|
||||
<site-banner />
|
||||
<!--
|
||||
Delegated rather than bound per link: the anchors are written by `v-html`, so there is
|
||||
nothing here to put a handler on, and they are replaced wholesale on every render.
|
||||
@@ -363,6 +368,7 @@ import PageTags from '@/components/PageTags.vue'
|
||||
import PageToc from '@/components/PageToc.vue'
|
||||
import PageUnlockDialog from '@/components/PageUnlockDialog.vue'
|
||||
import SideDialog from '@/components/SideDialog.vue'
|
||||
import SiteBanner from '@/components/SiteBanner.vue'
|
||||
|
||||
const editorComponents = {
|
||||
markdown: defineAsyncComponent({
|
||||
@@ -1058,13 +1064,22 @@ $toc-overlay-max: 749.98px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A hairline of the page's OWN background between the header and whatever the column starts with, in
|
||||
each theme's colour -- so it is invisible against the article, which is that colour, and reads as one
|
||||
pixel of daylight under anything that starts flush to the top of the column. A site banner does
|
||||
exactly that, and against the header's bottom border it needs the gap.
|
||||
|
||||
Both themes: with the dark one left out the banner butted straight into the header there and not in
|
||||
the light theme, which is the sort of difference that reads as a bug in whichever one you see second.
|
||||
*/
|
||||
.page-container {
|
||||
@at-root .body--light & {
|
||||
border-top: 1px solid #fff;
|
||||
}
|
||||
// @at-root .body--dark & {
|
||||
// border-top: 1px solid $dark-6;
|
||||
// }
|
||||
@at-root .body--dark & {
|
||||
border-top: 1px solid $dark-6;
|
||||
}
|
||||
}
|
||||
/*
|
||||
The Tags heading's edit toggle. `visibility` is transitioned alongside the opacity so it still fades
|
||||
|
||||
@@ -47,6 +47,16 @@ export const useSiteStore = defineStore('site', {
|
||||
company: '',
|
||||
contentLicense: '',
|
||||
footerExtra: '',
|
||||
/**
|
||||
* The notice an administrator can raise above the contents of every page — an outage, a freeze,
|
||||
* a wiki being moved. `content` is markdown, rendered by `SiteBanner.vue` at display time rather
|
||||
* than stored as HTML: it is site configuration, and never goes through the page renderer.
|
||||
*/
|
||||
banner: {
|
||||
isEnabled: false,
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
dark: false,
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -191,6 +201,10 @@ export const useSiteStore = defineStore('site', {
|
||||
company: siteInfo.company,
|
||||
contentLicense: siteInfo.contentLicense,
|
||||
footerExtra: siteInfo.footerExtra,
|
||||
banner: {
|
||||
...this.banner,
|
||||
...siteInfo.banner
|
||||
},
|
||||
features: {
|
||||
...this.features,
|
||||
...siteInfo.features
|
||||
|
||||
Reference in New Issue
Block a user