refactor: various UI fixes

scarlett
NGPixel 1 month ago
parent 937cecea05
commit 064e378b48
No known key found for this signature in database

@ -186,6 +186,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
},
forcePrefix: {
type: 'boolean'
},
showMenu: {
type: 'boolean',
description:
'Whether the sidebar offers a locale selector to switch between the active locales.'
}
}
},

@ -253,7 +253,12 @@ async function routes(app: FastifyInstance) {
defaults?: Record<string, any>
editors?: Record<string, { isActive?: boolean; config?: Record<string, any> }>
features?: Record<string, any>
locales?: { primary?: string; active?: string[]; forcePrefix?: boolean }
locales?: {
primary?: string
active?: string[]
forcePrefix?: boolean
showMenu?: boolean
}
robots?: Record<string, any>
theme?: Record<string, any>
uploads?: Record<string, any>

@ -480,6 +480,8 @@
"admin.locale.rtl": "RTL",
"admin.locale.saveSuccess": "Locale settings saved successfully.",
"admin.locale.settings": "Locale Settings",
"admin.locale.showMenu": "Show Locale Menu",
"admin.locale.showMenuHint": "Display a locale selector in the sidebar, so visitors can switch to another active locale.",
"admin.locale.sideload": "Sideload Locale Package",
"admin.locale.sideloadHelp": "If you are not connected to the internet or cannot download locale files using the method above, you can instead sideload packages manually by uploading them below.",
"admin.locale.subtitle": "Set localization options for your wiki",
@ -1862,12 +1864,14 @@
"history.restore.confirmTitle": "Restore page version?",
"history.restore.success": "Page version restored succesfully!",
"iconPicker.allSets": "All sets",
"iconPicker.custom": "Custom",
"iconPicker.customHint": "Any other icon reference, such as a webfont name (las la-home) or an image URL (img:/path/to/icon.svg).",
"iconPicker.icons": "Icons",
"iconPicker.image": "Image",
"iconPicker.imageHint": "Use an image instead of an icon. Enter the path of an image hosted on this wiki, or the full URL of one hosted elsewhere.",
"iconPicker.imageSizeHint": "A square image with a transparent background works best, as it is scaled down to icon size.",
"iconPicker.imageUrl": "Image path or URL",
"iconPicker.materializeFailed": "Could not store {icon} for offline use.",
"iconPicker.noResults": "No icon matches your search.",
"iconPicker.reference": "Icon reference",
"iconPicker.open": "Open Icon Picker...",
"iconPicker.search": "Search icons...",
"iconPicker.searchFailed": "Icon search failed.",
"iconPicker.searchHint": "Type at least 2 characters to search icons.",
@ -1907,17 +1911,17 @@
"pageDeleteDialog.title": "Confirm Page Deletion",
"pageDuplicateDialog.title": "Duplicate and Save As...",
"pageRenameDialog.title": "Rename / Move to...",
"pageSource.notFound": "This page does not exist.",
"pageSource.title": "Page Source",
"pageSource.unavailable": "You do not have permission to view the source of this page.",
"pageSaveDialog.displayModePath": "Browse Using Paths",
"pageSaveDialog.displayModeTitle": "Browse Using Titles",
"pageSaveDialog.loadFailed": "Failed to load folder tree.",
"pageSaveDialog.pageTitle": "Page Title",
"pageSaveDialog.pathInvalid": "Invalid Characters in Page Path Name. Lowercase alphanumerical and hyphen characters only.",
"pageSaveDialog.pathName": "Path Name",
"pageSaveDialog.pageTitle": "Page Title",
"pageSaveDialog.title": "Save As...",
"pageSaveDialog.titleMissing": "Missing Page Title",
"pageSource.notFound": "This page does not exist.",
"pageSource.title": "Page Source",
"pageSource.unavailable": "You do not have permission to view the source of this page.",
"profile.accessibility": "Accessibility",
"profile.activity": "Activity",
"profile.appearance": "Site Appearance",

@ -111,7 +111,8 @@ class Sites {
locales: {
primary: 'en',
active: ['en'],
forcePrefix: false
forcePrefix: false,
showMenu: true
},
assets: {
logo: false,
@ -307,7 +308,8 @@ class Sites {
locales: {
primary: 'en',
active: ['en'],
forcePrefix: false
forcePrefix: false,
showMenu: true
},
assets: {
logo: false,

@ -42,4 +42,4 @@ yarn-error.log*
!.yarn/versions
# Throwaway browser harness for verifying components/shared during the Quasar -> Tailwind migration
.harness/dist
.harness

@ -8,8 +8,6 @@
"name": "wiki-ux",
"version": "3.0.0",
"dependencies": {
"@lezer/common": "1.5.2",
"@mdi/font": "7.4.47",
"@quasar/extras": "2.0.1",
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3",
@ -17,8 +15,6 @@
"@xterm/xterm": "6.0.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
"codemirror": "5.65.11",
"codemirror-asciidoc": "1.0.4",
"dependency-graph": "1.0.0",
"es-toolkit": "1.50.0",
"filesize": "11.0.17",
@ -1683,18 +1679,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@mdi/font": {
"version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
"integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==",
"license": "Apache-2.0"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@ -4320,18 +4304,6 @@
"tiny-emitter": "^2.0.0"
}
},
"node_modules/codemirror": {
"version": "5.65.11",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.11.tgz",
"integrity": "sha512-Gp62g2eKSCHYt10axmGhKq3WoJSvVpvhXmowNq7pZdRVowwtvBR/hi2LSP5srtctKkRT33T6/n8Kv1UGp7JW4A==",
"license": "MIT"
},
"node_modules/codemirror-asciidoc": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/codemirror-asciidoc/-/codemirror-asciidoc-1.0.4.tgz",
"integrity": "sha512-U+G8+ToPONYFGkwTprxpFzV6EV1bCA9zChAA8uT2YAnKFn357JMWL2VkdIPy2yP5N/X13GzslMOGaAk1UNE3rA==",
"license": "BSD"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -5284,7 +5256,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
@ -7496,6 +7468,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"engines": {

@ -15,7 +15,6 @@
"icons:check": "node scripts/generate-icons.mjs --check"
},
"dependencies": {
"@lezer/common": "1.5.2",
"@quasar/extras": "2.0.1",
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3",
@ -23,8 +22,6 @@
"@xterm/xterm": "6.0.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
"codemirror": "5.65.11",
"codemirror-asciidoc": "1.0.4",
"dependency-graph": "1.0.0",
"es-toolkit": "1.50.0",
"filesize": "11.0.17",

@ -1,12 +1,17 @@
<template>
<w-btn class="account-avbtn ml-4" flat round dense color="custom-color">
<w-btn class="account-avbtn ml-4" flat round dense>
<w-icon v-if="!userStore.authenticated || !userStore.hasAvatar" name="la:user-circle" />
<w-avatar v-else size="32px"><img :src="`/_user/current/avatar`" /></w-avatar>
<w-menu class="translucent-menu" auto-close>
<w-card flat style="width: 300px;" :dark="false">
<w-card flat style="width: 300px" :dark="false">
<!--
-> The two greys are pitched for the light menu and go muddy on the dark one, where the
surface is nearly black: the name takes white and the address a light grey, keeping the
same relationship between them -- the name reads first, the address supports it
-->
<w-card-section align="center">
<div class="text-subtitle1 text-grey-7">{{userStore.name}}</div>
<div class="text-caption text-grey-8">{{userStore.email}}</div>
<div class="text-subtitle1 text-grey-7 dark:text-white">{{ userStore.name }}</div>
<div class="text-caption text-grey-8 dark:text-grey-5">{{ userStore.email }}</div>
</w-card-section>
<w-separator :dark="false" />
<w-card-actions align="center">
@ -46,6 +51,8 @@ const { t } = useI18n()
</script>
<style lang="scss">
// -> Where the button gets its colour, so it carries no `color` prop: `WBtn` emits an inline
// `color`, which would outrank this rule
.account-avbtn {
color: rgba(255, 255, 255, 0.6);
}

@ -12,31 +12,47 @@
color="white"
:label="commonStore.locale"
:aria-label="commonStore.locale"
style="height: 40px;">
style="height: 40px">
<locale-selector-menu />
</w-btn>
<w-input
dark
v-model="state.search"
standout="bg-white text-dark"
dense
ref="searchField"
style="width: 100%;"
:label="t(`fileman.searchFolder`)"
:debounce="500">
<template #prepend><w-icon name="la:search" /></template>
<template #append>
<w-icon
class="cursor-pointer"
name="la:times"
@click="state.search=``"
v-if="state.search.length > 0"
:color="dark.isActive ? `blue` : `grey-4`" />
</template>
</w-input>
<!--
The same pill the site header uses, rather than a `w-input`.
It was a `w-input` carrying `dark`, `standout="bg-white text-dark"` and `debounce`, none of
which that component has -- they fell through as bare attributes and styled nothing. What
rendered was the FILLED variant: a 4%-black wash holding white text, on a near-black header,
with its label stranded above the toolbar. Written out here so it matches HeaderSearch,
which is what a search field in this app looks like.
-->
<div class="fileman-search" :class="{ 'is-focused': state.searchIsFocused }">
<w-icon class="fileman-search-lead" name="la:search" />
<input
ref="searchField"
v-model="state.search"
type="text"
class="fileman-search-input"
:placeholder="t(`fileman.searchFolder`)"
:aria-label="t(`fileman.searchFolder`)"
autocomplete="off"
@focus="state.searchIsFocused = true"
@blur="state.searchIsFocused = false" />
<button
v-if="state.search.length > 0"
type="button"
class="fileman-search-clear"
:aria-label="t(`common.actions.clear`)"
@click="state.search = ``">
<w-icon name="la:times" />
</button>
</div>
</w-toolbar>
<w-toolbar dark>
<w-space />
<!--
-> No right margin needed: the toolbar's own 12px is already close to the 9-10px the header
leaves above and below. What made the button look pushed into the corner was the broken
search field inflating the header to 61px, which stretched those two gaps to 14/15.
-->
<w-btn
flat
dense
@ -45,13 +61,20 @@
:aria-label="t(`common.actions.close`)"
icon="la:times"
@click="close">
<w-tooltip anchor="bottom middle" self="top middle">{{t(`common.actions.close`)}}</w-tooltip>
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.close`)
}}</w-tooltip>
</w-btn>
</w-toolbar>
</w-header>
<w-drawer class="fileman-left" :model-value="true" :width="350">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%;">
<div class="px-4 pb-2">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<!--
-> No side padding: the tree's rows run the full width of the drawer, so a hovered or
selected row reads as a band across it rather than a floating pill. `pt-2` is the gap
above the root entry that the padding used to imply.
-->
<div class="pt-2 pb-2">
<tree
ref="treeComp"
:nodes="state.treeNodes"
@ -65,7 +88,7 @@
</w-scroll-area>
</w-drawer>
<w-drawer class="fileman-right" :model-value="screen.gt.md" :width="350" side="right">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%;">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="p-4">
<template v-if="currentFileDetails">
<img
@ -73,7 +96,7 @@
v-if="currentFileDetails.thumbnail"
:src="currentFileDetails.thumbnail"
width="100%"
:ratio="16/10" />
:ratio="16 / 10" />
<div
class="fileman-details-row"
v-for="item of currentFileDetails.items"
@ -102,7 +125,9 @@
<w-toolbar class="fileman-toolbar">
<template v-if="state.isUploading">
<div class="fileman-progressbar">
<div :style="`width: ` + state.uploadPercentage + `%`">{{ state.uploadPercentage }}%</div>
<div :style="`width: ` + state.uploadPercentage + `%`">
{{ state.uploadPercentage }}%
</div>
</div>
<w-btn
class="acrylic-btn ml-2"
@ -125,7 +150,9 @@
color="grey"
:aria-label="t(`fileman.viewOptions`)"
icon="la:th-list">
<w-tooltip anchor="bottom middle" self="top middle">{{ t(`fileman.viewOptions`) }}</w-tooltip>
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`fileman.viewOptions`)
}}</w-tooltip>
<w-menu
transition-show="jump-down"
transition-hide="jump-up"
@ -150,7 +177,9 @@
<w-item clickable @click="state.displayMode = `path`">
<w-item-section side>
<w-icon
:name="state.displayMode === `path` ? `la:check-circle` : `la:circle`"
:name="
state.displayMode === `path` ? `la:check-circle` : `la:circle`
"
:color="state.displayMode === `path` ? `positive` : `grey`"
size="xs" />
</w-item-section>
@ -159,7 +188,9 @@
<w-item clickable @click="state.displayMode = `title`">
<w-item-section side>
<w-icon
:name="state.displayMode === `title` ? `la:check-circle` : `la:circle`"
:name="
state.displayMode === `title` ? `la:check-circle` : `la:circle`
"
:color="state.displayMode === `title` ? `positive` : `grey`"
size="xs" />
</w-item-section>
@ -199,7 +230,9 @@
:aria-label="t(`common.actions.refresh`)"
icon="la:redo-alt"
@click="reloadFolder(state.currentFolderId)">
<w-tooltip anchor="bottom middle" self="top middle">{{ t(`common.actions.refresh`) }}</w-tooltip>
<w-tooltip anchor="bottom middle" self="top middle">{{
t(`common.actions.refresh`)
}}</w-tooltip>
</w-btn>
<w-separator class="mr-2" inset vertical />
<w-btn
@ -229,9 +262,9 @@
@click="uploadFile" />
</template>
</w-toolbar>
<div class="flex flex-wrap" style="flex: 1 1 100%;">
<div class="flex flex-wrap" style="flex: 1 1 100%">
<div class="min-w-0 flex-1">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%;">
<w-scroll-area :thumb-style="thumbStyle" :bar-style="barStyle" style="height: 100%">
<div class="fileman-loadinglist" v-if="state.fileListLoading">
<w-spinner class="mr-2" color="primary" size="64px" :thickness="1" />
<span class="text-primary">Fetching folder contents...</span>
@ -268,7 +301,7 @@
transition-show="jump-down"
transition-hide="jump-up">
<w-card class="p-2">
<w-list dense style="min-width: 150px;">
<w-list dense style="min-width: 150px">
<w-item
clickable
v-if="insertMode && item.type !== `folder`"
@ -344,7 +377,9 @@
<w-item-section side>
<w-icon name="la:trash-alt" color="negative" />
</w-item-section>
<w-item-section class="text-negative">{{ t(`common.actions.delete`) }}</w-item-section>
<w-item-section class="text-negative">{{
t(`common.actions.delete`)
}}</w-item-section>
</w-item>
</w-list>
</w-card>
@ -358,7 +393,7 @@
</w-page-container>
<w-footer>
<w-bar class="fileman-path">
<small class="text-caption text-grey-7">{{folderPath}}</small>
<small class="text-caption text-grey-7">{{ folderPath }}</small>
</w-bar>
</w-footer>
<input type="file" ref="fileIpt" multiple @change="uploadNewFiles" style="display: none" />
@ -399,7 +434,6 @@ import FolderRenameDialog from '@/components/FolderRenameDialog.vue'
import AssetRenameDialog from '@/components/AssetRenameDialog.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
// COMPOSABLES
const dark = useDark()
@ -425,6 +459,8 @@ const state = reactive({
loading: 0,
isFetching: false,
search: '',
/** Drives the search pill's inversion, as HeaderSearch does it. */
searchIsFocused: false,
currentFolderId: null,
currentFileId: null,
treeNodes: {},
@ -1141,6 +1177,72 @@ onMounted(async () => {
<style lang="scss">
.fileman {
/*
The search pill, mirroring `.header-search-field` in HeaderSearch: 40px tall, dark fill on the
dark header, inverting to white ink-on-white in use. Stated here rather than borrowing that
component's class, so a change to the site header cannot silently restyle this overlay -- but the
metrics are deliberately the same, because it is the same control in a different place.
*/
&-search {
display: flex;
flex: 1 1;
min-width: 0;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 8px 0 12px;
border-radius: 9999px;
background-color: #212121;
color: rgba(255, 255, 255, 0.85);
transition:
background-color 0.25s var(--ease-standard),
color 0.25s var(--ease-standard);
// -> Driven by a class rather than `:focus-within`, matching HeaderSearch
&.is-focused {
background-color: #fff;
color: rgba(0, 0, 0, 0.87);
}
&-lead {
flex-shrink: 0;
font-size: 20px;
opacity: 0.7;
}
&-input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
background: none;
color: inherit;
font: inherit;
outline: none;
&::placeholder {
color: currentColor;
opacity: 0.55;
}
}
&-clear {
flex-shrink: 0;
display: inline-flex;
padding: 4px;
border-radius: 9999px;
border: 0;
background: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
&:hover {
opacity: 1;
}
}
}
&-left {
@at-root .body--light & {
background-color: $blue-grey-1;

@ -17,7 +17,7 @@
hide-bottom-space
:label="t(`common.field.name`)"
lazy-rules="ondemand"
autofocus />
ref="iptGroupName" />
</w-item-section>
</w-item>
</w-form>
@ -55,7 +55,9 @@ defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptGroupName.value
})
// I18N
@ -71,6 +73,7 @@ const state = reactive({
// REFS
const createGroupForm = ref(null)
const iptGroupName = ref(null)
// VALIDATION RULES

@ -16,7 +16,7 @@
<w-toolbar style="height: 64px">
<w-space />
<transition name="syncing">
<w-spinner v-show="commonStore.routerLoading" size="24px" class="text-accent" />
<w-spinner v-show="commonStore.routerLoading" size="20px" class="text-accent" />
</transition>
<w-btn
v-if="userStore.can(`write:pages`)"

@ -2,7 +2,7 @@
<w-card class="icon-picker" flat style="width: 460px">
<w-tabs class="text-primary" v-model="state.currentTab" no-caps inline-label>
<w-tab name="icon" icon="la:icons" :label="t(`iconPicker.icons`)" />
<w-tab name="custom" icon="la:pen" :label="t(`iconPicker.custom`)" />
<w-tab name="image" icon="la:image" :label="t(`iconPicker.image`)" />
</w-tabs>
<w-separator />
<w-tab-panels v-model="state.currentTab">
@ -33,7 +33,7 @@
options-dense
emit-value
map-options
style="min-width: 130px;"
style="min-width: 130px"
:label="t(`iconPicker.set`)"
:aria-label="t(`iconPicker.set`)"
@update:model-value="search" />
@ -43,7 +43,11 @@
<w-inner-loading :showing="state.loading">
<w-spinner color="primary" size="md" />
</w-inner-loading>
<div class="text-center text-caption text-grey p-6" v-if="!state.loading && state.results.length < 1">{{ state.query?.length >= 2 ? t('iconPicker.noResults') : t('iconPicker.searchHint') }}</div>
<div
class="text-center text-caption text-grey p-6"
v-if="!state.loading && state.results.length < 1">
{{ state.query?.length >= 2 ? t('iconPicker.noResults') : t('iconPicker.searchHint') }}
</div>
<div class="icon-picker-grid" v-else>
<w-btn
class="icon-picker-cell"
@ -51,7 +55,7 @@
:key="icon"
flat
dense
:class='{ "icon-picker-cell--active": state.selected === icon }'
:class="{ 'icon-picker-cell--active': state.selected === icon }"
:aria-label="icon"
@click="state.selected = icon">
<w-icon :name="icon" size="24px" />
@ -61,18 +65,25 @@
</div>
</w-tab-panel>
<!-- ----------------------- -->
<!-- Anything else -->
<!-- An image file -->
<!-- ----------------------- -->
<w-tab-panel name="custom">
<div class="text-caption text-grey">{{ t('iconPicker.customHint') }}</div>
<w-tab-panel class="p-3" name="image">
<div class="text-caption text-grey">{{ t('iconPicker.imageHint') }}</div>
<!--
The field holds the path alone; the `img:` that marks it as an image is shown as a fixed
prefix and added on the way out. That is how the reference has to be stored -- see WIcon --
but it is not something anyone should have to know to type.
-->
<w-input
class="mt-2"
v-model="state.custom"
v-model="state.image"
outlined
dense
:label="t(`iconPicker.reference`)"
:aria-label="t(`iconPicker.reference`)"
placeholder="la:home" />
prefix="img:"
:label="t(`iconPicker.imageUrl`)"
:aria-label="t(`iconPicker.imageUrl`)"
placeholder="/_assets/icons/my-icon.svg" />
<div class="mt-2 text-caption text-grey">{{ t('iconPicker.imageSizeHint') }}</div>
</w-tab-panel>
</w-tab-panels>
<w-separator />
@ -100,7 +111,7 @@
unelevated
color="secondary"
:disable="!pendingValue"
@click="apply(); closePopup()" />
@click="applyAndClose" />
</w-card-actions>
</w-card>
</template>
@ -115,7 +126,6 @@ import { useDark } from '@/composables/dark'
import { debounce } from 'es-toolkit/function'
import { useClosePopup } from '@/composables/popup'
// I18N
const { t } = useI18n()
@ -144,13 +154,16 @@ const state = reactive({
sets: [],
results: [],
selected: '',
custom: '',
image: '',
loading: false
})
/** An Iconify reference, as opposed to a webfont name or an `img:` URL. */
/** An Iconify reference, as opposed to an `img:` URL. */
const ICONIFY_REF = /^[a-z0-9-]+:[a-z0-9.-]+$/
/** What marks a reference as an image rather than an icon, per WIcon. */
const IMAGE_PREFIX = 'img:'
// COMPOSABLES
const dark = useDark()
@ -165,7 +178,11 @@ const setOptions = computed(() => {
})
const pendingValue = computed(() => {
return state.currentTab === 'custom' ? (state.custom?.trim() ?? '') : state.selected
if (state.currentTab !== 'image') {
return state.selected
}
const url = state.image?.trim()
return url ? `${IMAGE_PREFIX}${url}` : ''
})
// METHODS
@ -236,7 +253,8 @@ async function apply() {
const value = pendingValue.value
emit('update:modelValue', value)
if (!ICONIFY_REF.test(value)) {
// -> An image is served from wherever it points; only an icon has to be stored
if (value.startsWith(IMAGE_PREFIX) || !ICONIFY_REF.test(value)) {
return
}
try {
@ -251,16 +269,22 @@ async function apply() {
}
}
/** Named, because oxfmt breaks a two-statement inline handler onto separate lines */
function applyAndClose() {
apply()
closePopup()
}
// MOUNTED
onMounted(async () => {
// -> An Iconify reference starts on the search tab, anything else on the custom one
if (ICONIFY_REF.test(props.modelValue ?? '')) {
// -> An image reference opens on the image tab, an Iconify one on the search tab
if (props.modelValue?.startsWith(IMAGE_PREFIX)) {
state.currentTab = 'image'
state.image = props.modelValue.slice(IMAGE_PREFIX.length)
} else if (ICONIFY_REF.test(props.modelValue ?? '')) {
state.selected = props.modelValue
state.results = [props.modelValue]
} else if (props.modelValue) {
state.currentTab = 'custom'
state.custom = props.modelValue
}
await loadSets()
})
@ -277,7 +301,8 @@ onMounted(async () => {
}
}
.q-tab-panels {
/* -> A shade off the card, so the fields and the results area read as sitting on a surface */
.w-tab-panels {
@at-root .body--light & {
background-color: $grey-1;
}
@ -286,16 +311,6 @@ onMounted(async () => {
}
}
.q-input .q-field__control,
.q-select .q-field__control {
@at-root .body--light & {
background-color: #fff;
}
@at-root .body--dark & {
background-color: $dark-5;
}
}
&-results {
position: relative;
height: 220px;

@ -2,7 +2,7 @@
<w-layout view="hHh lpR fFf" container>
<w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/fluent-sidebar-menu.svg" left size="md" />
<span>{{t(`navEdit.editMenuItems`)}}</span>
<span>{{ t(`navEdit.editMenuItems`) }}</span>
<w-space />
<transition name="syncing">
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" />
@ -45,7 +45,7 @@
item-key="id"
:options="sortableOptions"
@end="updateItemPosition">
<template #item="{element}">
<template #item="{ element }">
<div
class="nav-edit-item nav-edit-item-header"
v-if="element.type === `header`"
@ -60,7 +60,7 @@
<w-item
class="nav-edit-item nav-edit-item-link"
v-else-if="element.type === `link`"
:class='{ "is-active": state.selected === element.id, "is-nested": element.isNested }'
:class="{ 'is-active': state.selected === element.id, 'is-nested': element.isNested }"
@click="setItem(element)"
clickable>
<w-item-section side><w-icon :name="element.icon" color="white" /></w-item-section>
@ -74,7 +74,7 @@
v-else
:class="state.selected === element.id ? `is-active` : ``"
@click="setItem(element)">
<w-separator dark inset style="flex: 1; margin-top: 11px;" />
<w-separator dark inset style="flex: 1; margin-top: 11px" />
<w-item-section side>
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
</w-item-section>
@ -84,7 +84,7 @@
<div class="p-4 flex">
<w-btn
class="acrylic-btn"
style="flex: 1;"
style="flex: 1"
flat
color="positive"
:label="t(`common.actions.add`)"
@ -95,19 +95,19 @@
<w-item clickable @click="addItem(`header`)">
<w-item-section side><w-icon name="la:heading" /></w-item-section>
<w-item-section>
<w-item-label>{{t('navEdit.header')}}</w-item-label>
<w-item-label>{{ t('navEdit.header') }}</w-item-label>
</w-item-section>
</w-item>
<w-item clickable @click="addItem(`link`)">
<w-item-section side><w-icon name="la:link" /></w-item-section>
<w-item-section>
<w-item-label>{{t('navEdit.link')}}</w-item-label>
<w-item-label>{{ t('navEdit.link') }}</w-item-label>
</w-item-section>
</w-item>
<w-item clickable @click="addItem(`separator`)">
<w-item-section side><w-icon name="la:minus" /></w-item-section>
<w-item-section>
<w-item-label>{{t('navEdit.separator')}}</w-item-label>
<w-item-label>{{ t('navEdit.separator') }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
@ -127,7 +127,7 @@
<w-icon name="la:trash-alt" color="negative" />
</w-item-section>
<w-item-section>
<w-item-label>{{t('navEdit.clearItems')}}</w-item-label>
<w-item-label>{{ t('navEdit.clearItems') }}</w-item-label>
</w-item-section>
</w-item>
<!-- q-item(clickable) -->
@ -162,13 +162,13 @@
<template v-if="state.current.type === `header`">
<w-card class="pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('navEdit.header')}}</div>
<div class="text-subtitle1">{{ t('navEdit.header') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="typography" />
<w-item-section>
<w-item-label>{{t(`navEdit.label`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.labelHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.label`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.labelHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -182,8 +182,8 @@
<w-item>
<blueprint-icon icon="user-groups" />
<w-item-section>
<w-item-label>{{t(`navEdit.visibility`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.visibilityHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.visibility`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.visibilityHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-btn-toggle
@ -199,7 +199,7 @@
<w-space />
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
<w-select
style="width: 100%; max-width: calc(50% - 34px);"
style="width: 100%; max-width: calc(50% - 34px)"
outlined
v-model="state.current.visibilityGroups"
:options="state.groups"
@ -226,12 +226,14 @@
</template>
<template v-if="state.current.type === `link`">
<w-card class="pb-2">
<w-card-section><div class="text-subtitle1">{{t('navEdit.link')}}</div></w-card-section>
<w-card-section
><div class="text-subtitle1">{{ t('navEdit.link') }}</div></w-card-section
>
<w-item>
<blueprint-icon icon="typography" />
<w-item-section>
<w-item-label>{{t(`navEdit.label`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.labelHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.label`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.labelHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -246,8 +248,8 @@
<w-item>
<blueprint-icon icon="spring" />
<w-item-section>
<w-item-label>{{t(`navEdit.icon`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.iconHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.icon`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.iconHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -256,11 +258,24 @@
dense
:aria-label="t(`navEdit.icon`)">
<template #append>
<w-icon class="cursor-pointer" name="la:icons" color="primary">
<!--
A button, not a bare `w-icon`: for a bundled icon WIcon renders an <svg> whose
body is set through `v-html`, and that branch renders no slot -- so the menu
inside it never existed and the control did nothing. It was also just the glyph,
with no hit area of its own. Same fix as the page-properties dialog.
-->
<w-btn
flat
dense
round
icon="la:icons"
color="primary"
:aria-label="t(`iconPicker.open`)">
<w-tooltip>{{ t('iconPicker.open') }}</w-tooltip>
<w-menu content-class="shadow-7">
<icon-picker-dialog v-model="state.current.icon" />
</w-menu>
</w-icon>
</w-btn>
</template>
</w-input>
</w-item-section>
@ -269,8 +284,8 @@
<w-item>
<blueprint-icon icon="link" />
<w-item-section>
<w-item-label>{{t(`navEdit.target`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.targetHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.target`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.targetHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -285,8 +300,8 @@
<w-item tag="label">
<blueprint-icon icon="external-link" />
<w-item-section>
<w-item-label>{{t(`navEdit.openInNewWindow`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.openInNewWindowHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.openInNewWindow`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.openInNewWindowHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
@ -301,8 +316,8 @@
<w-item>
<blueprint-icon icon="user-groups" />
<w-item-section>
<w-item-label>{{t(`navEdit.visibility`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.visibilityHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.visibility`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.visibilityHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-btn-toggle
@ -318,7 +333,7 @@
<w-space />
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
<w-select
style="width: 100%; max-width: calc(50% - 34px);"
style="width: 100%; max-width: calc(50% - 34px)"
outlined
v-model="state.current.visibilityGroups"
:options="state.groups"
@ -367,13 +382,13 @@
<template v-if="state.current.type === `separator`">
<w-card class="pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('navEdit.separator')}}</div>
<div class="text-subtitle1">{{ t('navEdit.separator') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="user-groups" />
<w-item-section>
<w-item-label>{{t(`navEdit.visibility`)}}</w-item-label>
<w-item-label caption>{{t(`navEdit.visibilityHint`)}}</w-item-label>
<w-item-label>{{ t(`navEdit.visibility`) }}</w-item-label>
<w-item-label caption>{{ t(`navEdit.visibilityHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-btn-toggle
@ -389,7 +404,7 @@
<w-space />
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
<w-select
style="width: 100%; max-width: calc(50% - 34px);"
style="width: 100%; max-width: calc(50% - 34px)"
outlined
v-model="state.current.visibilityGroups"
:options="state.groups"
@ -434,7 +449,6 @@ import { pick } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3'
import IconPickerDialog from '@/components/IconPickerDialog.vue'
// STORES
const pageStore = usePageStore()

@ -1,10 +1,20 @@
<template>
<!--
`flex flex-col` in place of the `column` this carried: that was Quasar's flex helper and nothing
defines it any more, so the rail was a plain block. Which is why `items-stretch` never stretched
the buttons to its width, and why `<w-space />` -- a `flex-grow: 1` spacer -- could not push the
last group to the bottom.
-->
<!--
Page Properties keeps the rail's full square; every other button is 48px. The primary action for
the page reads as the largest target, and the rest sit quieter beneath it.
-->
<div
class="page-actions column items-stretch order-last"
class="page-actions flex flex-col items-stretch order-last"
:class="editorStore.isActive ? `is-editor` : ``">
<template v-if="userStore.can(`edit:pages`)">
<w-btn
class="py-4"
class="aspect-square"
flat
icon="la:pen-nib"
:color="editorStore.isActive ? `white` : `deep-orange-9`"
@ -13,7 +23,7 @@
<w-tooltip anchor="center left" self="center right">Page Properties</w-tooltip>
</w-btn>
<w-btn
class="py-4"
class="h-12"
v-if="flagsStore.experimental"
flat
icon="la:project-diagram"
@ -24,7 +34,7 @@
<w-tooltip anchor="center left" self="center right">Page Data</w-tooltip>
</w-btn>
<w-btn
class="py-4"
class="h-12"
v-if="editorStore.isActive"
flat
color="white"
@ -43,12 +53,14 @@
</w-icon>
<w-tooltip anchor="center left" self="center right">Pending Asset Uploads</w-tooltip>
<w-menu ref="menuPendingAssets" anchor="top left" self="top right" :offset="[10, 0]">
<w-card style="width: 450px;">
<w-card style="width: 450px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/color-data-pending.svg" left size="sm" />
<span>Pending Asset Uploads</span>
</w-card-section>
<w-card-section v-if="!hasPendingAssets">There are no assets pending uploads.</w-card-section>
<w-card-section v-if="!hasPendingAssets"
>There are no assets pending uploads.</w-card-section
>
<w-list v-else separator>
<w-item v-for="item of editorStore.pendingAssets" :key="item.id">
<w-item-section side><w-icon name="la:file-image" /></w-item-section>
@ -66,7 +78,10 @@
</w-item>
</w-list>
<w-card-section class="card-actions">
<em class="text-caption">Assets that are pasted or dropped onto this page will be held here until the page is saved.</em>
<em class="text-caption"
>Assets that are pasted or dropped onto this page will be held here until the page
is saved.</em
>
</w-card-section>
</w-card>
</w-menu>
@ -74,7 +89,7 @@
<w-separator class="my-2" inset />
</template>
<w-btn
class="py-4"
class="h-12"
flat
icon="la:history"
:color="editorStore.isActive ? `white` : `grey`"
@ -83,7 +98,7 @@
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
</w-btn>
<w-btn
class="py-4"
class="h-12"
flat
icon="la:code"
:color="editorStore.isActive ? `white` : `grey`"
@ -94,7 +109,7 @@
<template v-if="!(editorStore.isActive && editorStore.mode === `create`)">
<w-separator class="my-2" inset />
<w-btn
class="py-2"
class="h-12"
flat
icon="la:ellipsis-h"
:color="editorStore.isActive ? `deep-orange-2` : `grey`"
@ -106,7 +121,7 @@
self="top right"
auto-close
transition-show="jump-left">
<w-list padding style="min-width: 225px;">
<w-list padding style="min-width: 225px">
<w-item clickable disabled v-if="userStore.can(`manage:pages`)">
<w-item-section class="items-center" avatar>
<w-icon color="deep-orange-9" name="la:atom" size="sm" />
@ -132,7 +147,7 @@
<w-space />
<template v-if="!(editorStore.isActive && editorStore.mode === `create`)">
<w-btn
class="py-2"
class="h-12"
v-if="userStore.can(`create:pages`)"
flat
icon="la:copy"
@ -142,7 +157,7 @@
<w-tooltip anchor="center left" self="center right">Duplicate Page</w-tooltip>
</w-btn>
<w-btn
class="py-2"
class="h-12"
v-if="userStore.can(`manage:pages`)"
flat
icon="la:share"
@ -152,14 +167,13 @@
<w-tooltip anchor="center left" self="center right">Rename / Move Page</w-tooltip>
</w-btn>
<w-btn
class="py-2"
class="h-12"
v-if="userStore.can(`delete:pages`)"
flat
icon="la:trash"
:color="editorStore.isActive ? `deep-orange-2` : `grey`"
aria-label="Delete Page"
@click="deletePage"
:class="editorStore.isActive ? `q-pb-md` : ``">
@click="deletePage">
<w-tooltip anchor="center left" self="center right">Delete Page</w-tooltip>
</w-btn>
</template>
@ -181,7 +195,6 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import { useUserStore } from '@/stores/user'
// STORES
const editorStore = useEditorStore()

@ -1,7 +1,7 @@
<template>
<w-card class="page-data-dialog" style="width: 750px;">
<w-card class="page-data-dialog" style="width: 750px">
<w-toolbar class="bg-primary text-white flex">
<div class="text-subtitle2">{{t('editor.pageData.title')}}</div>
<div class="text-subtitle2">{{ t('editor.pageData.title') }}</div>
<w-space />
<w-btn icon="la:times" dense flat @click="siteStore.sideDialogShown = false" />
</w-toolbar>
@ -19,7 +19,7 @@
emit-value
standout
dense
style="flex: 1 0 auto;" />
style="flex: 1 0 auto" />
<w-btn
class="acrylic-btn"
dark
@ -38,7 +38,7 @@
<w-scroll-area
:thumb-style="siteStore.thumbStyle"
:bar-style="siteStore.barStyle"
style="height: calc(100% - 50px - 75px - 48px);">
style="height: calc(100% - 50px - 75px - 48px)">
<w-card-section v-if="state.mode === `visual`">
<div class="gap-2">
<w-input label="Attribute Text" dense outlined>
@ -56,13 +56,16 @@
The `v-else` moves onto the editor itself: the wrapper it sat on only deferred rendering
until hydration, which this app has no server renderer to need, so unwrapping it would
otherwise have taken the branch with it and orphaned the `v-if` above.
This was a `<codemirror>` tag, which no plugin or import ever registered -- so the editor was
never rendering at all, only warning about an unknown element.
-->
<codemirror
<util-code-editor
v-else
class="admin-theme-cm"
ref="cmData"
v-model="state.content"
:options="{ mode: `text/yaml` }" />
language="yaml"
:min-height="400"
aria-label="Page data (YAML)" />
</w-scroll-area>
<w-dialog v-model="state.showDataTemplateDialog">
<page-data-template-dialog @close="state.showDataTemplateDialog = false" />
@ -78,7 +81,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import PageDataTemplateDialog from './PageDataTemplateDialog.vue'
import UtilCodeEditor from './UtilCodeEditor.vue'
// STORES

@ -1,18 +1,21 @@
<template>
<w-card class="page-properties-dialog">
<div
class="floating-sidepanel-quickaccess animated fadeIn"
v-if="state.showQuickAccess"
style="right: 486px;">
<!--
`h-full` so the card fills the panel: the scroll area below is sized `calc(100% - 50px)`, which
against an auto-height card resolves to `auto` and let the card grow past the panel instead of
scrolling inside it -- the white surface and the panel's shadow ending in different places.
-->
<w-card class="page-properties-dialog h-full">
<!-- -> Offset comes from the stylesheet now, relative to this card; see SideDialog -->
<div class="floating-sidepanel-quickaccess animated fadeIn" v-if="state.showQuickAccess">
<template v-for="(qa, idx) of quickaccess" :key="`qa-` + qa.key">
<w-btn :icon="qa.icon" flat padding="sm xs" size="sm" @click="jumpToSection(qa.key)">
<w-tooltip anchor="center left" self="center right">{{qa.label}}</w-tooltip>
<w-tooltip anchor="center left" self="center right">{{ qa.label }}</w-tooltip>
</w-btn>
<w-separator dark v-if="idx < quickaccess.length - 1" />
</template>
</div>
<w-toolbar class="bg-primary text-white flex">
<div class="text-subtitle2">{{t('editor.props.pageProperties')}}</div>
<div class="text-subtitle2">{{ t('editor.props.pageProperties') }}</div>
<w-space />
<w-btn
class="mr-2"
@ -30,11 +33,18 @@
ref="scrollArea"
:thumb-style="siteStore.scrollStyle.thumb"
:bar-style="siteStore.scrollStyle.bar"
style="height: calc(100% - 50px);">
style="height: calc(100% - 50px)">
<w-card-section id="refCardInfo">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:info-circle" size="xs" /> {{t('editor.props.info')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:info-circle" size="xs" /> {{ t('editor.props.info') }}
</div>
<w-form class="gap-2">
<w-input v-model="pageStore.title" :label="t(`editor.props.title`)" outlined dense />
<w-input
ref="iptTitle"
v-model="pageStore.title"
:label="t(`editor.props.title`)"
outlined
dense />
<w-input
v-model="pageStore.description"
:label="t(`editor.props.shortDescription`)"
@ -45,16 +55,24 @@
<w-icon :name="pageStore.icon" size="20px" color="primary" />
</template>
<template #append>
<w-icon
class="cursor-pointer"
name="la:icons"
<!--
A button, not a bare `w-icon`: for a bundled icon WIcon renders an <svg> whose body is
set through `v-html`, which renders no slot -- so the menu inside it never existed and
the control did nothing. It was also just the 14px glyph, with no hit area of its own.
-->
<w-btn
flat
dense
round
icon="la:icons"
color="primary"
:aria-label="t(`editor.props.selectIcon`)">
:aria-label="t(`iconPicker.open`)">
<w-tooltip>{{ t('iconPicker.open') }}</w-tooltip>
<!-- The properties panel is docked to the right edge, so the picker has to grow leftwards -->
<w-menu content-class="shadow-7" anchor="bottom right" self="top right">
<icon-picker-dialog v-model="pageStore.icon" />
</w-menu>
</w-icon>
</w-btn>
</template>
</w-input>
<w-input
@ -67,7 +85,9 @@
</w-form>
</w-card-section>
<w-card-section class="alt-card" id="refCardPublishState">
<div class="text-overline pb-1 items-center flex"><w-icon class="mr-2" name="la:power-off" size="xs" /> {{t('editor.props.publishState')}}</div>
<div class="text-overline pb-1 items-center flex">
<w-icon class="mr-2" name="la:power-off" size="xs" /> {{ t('editor.props.publishState') }}
</div>
<w-form class="gap-4">
<div>
<w-btn-toggle
@ -77,39 +97,45 @@
no-caps
toggle-color="primary"
:options="[
{ label: t('editor.props.draft'), value: 'draft' },
{ label: t('editor.props.published'), value: 'published' },
{ label: t('editor.props.dateRange'), value: 'scheduled' }
]" />
{ label: t('editor.props.draft'), value: 'draft' },
{ label: t('editor.props.published'), value: 'published' },
{ label: t('editor.props.dateRange'), value: 'scheduled' }
]" />
</div>
<div class="text-caption" v-if="pageStore.publishState === `published`">
<em>{{t('editor.props.publishedHint')}}</em>
<em>{{ t('editor.props.publishedHint') }}</em>
</div>
<div class="text-caption" v-else-if="pageStore.publishState === `draft`">
<em>{{t('editor.props.draftHint')}}</em>
<em>{{ t('editor.props.draftHint') }}</em>
</div>
<template v-else-if="pageStore.publishState === `scheduled`">
<div class="text-caption"><em>{{t('editor.props.dateRangeHint')}}</em></div>
<div class="text-caption">
<em>{{ t('editor.props.dateRangeHint') }}</em>
</div>
<w-date v-model="publishingRange" range flat bordered landscape minimal />
</template>
</w-form>
</w-card-section>
<w-card-section id="refCardRelations">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:sun" size="xs" /> {{t('editor.props.relations')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:sun" size="xs" /> {{ t('editor.props.relations') }}
</div>
<w-list
class="rounded mb-2 bg-white"
class="rounded mb-2 bg-white dark:bg-black/20"
v-if="pageStore.relations.length > 0"
separator
bordered>
<w-item v-for="rel of pageStore.relations" :key="`rel-id-` + rel.id">
<w-item-section side><w-icon :name="rel.icon" /></w-item-section>
<w-item-section>
<w-item-label><strong>{{rel.label}}</strong></w-item-label>
<w-item-label caption>{{rel.caption}}</w-item-label>
<w-item-label
><strong>{{ rel.label }}</strong></w-item-label
>
<w-item-label caption>{{ rel.caption }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-chip class="px-2" dense square color="primary" text-color="white">
<div class="text-caption">{{rel.position}}</div>
<div class="text-caption">{{ rel.position }}</div>
</w-chip>
</w-item-section>
<w-item-section side>
@ -128,11 +154,13 @@
unelevated
color="secondary"
@click="newRelation">
<w-tooltip>{{t('editor.props.relationAddHint')}}</w-tooltip>
<w-tooltip>{{ t('editor.props.relationAddHint') }}</w-tooltip>
</w-btn>
</w-card-section>
<w-card-section class="alt-card" id="refCardScripts">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:code" size="xs" /> {{t('editor.props.scripts')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:code" size="xs" /> {{ t('editor.props.scripts') }}
</div>
<w-btn
class="w-full"
:label="t(`editor.props.jsLoad`)"
@ -141,7 +169,7 @@
unelevated
color="secondary"
@click="editScripts(`jsLoad`)">
<w-tooltip>{{t('editor.props.jsLoadHint')}}</w-tooltip>
<w-tooltip>{{ t('editor.props.jsLoadHint') }}</w-tooltip>
</w-btn>
<w-btn
class="w-full mt-2"
@ -151,7 +179,7 @@
unelevated
color="secondary"
@click="editScripts(`jsUnload`)">
<w-tooltip>{{t('editor.props.jsUnloadHint')}}</w-tooltip>
<w-tooltip>{{ t('editor.props.jsUnloadHint') }}</w-tooltip>
</w-btn>
<w-btn
class="w-full mt-2"
@ -161,11 +189,13 @@
unelevated
color="secondary"
@click="editScripts(`styles`)">
<w-tooltip>{{t('editor.props.stylesHint')}}</w-tooltip>
<w-tooltip>{{ t('editor.props.stylesHint') }}</w-tooltip>
</w-btn>
</w-card-section>
<w-card-section class="pb-6" id="refCardSidebar">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:ruler-vertical" size="xs" /> {{t('editor.props.sidebar')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:ruler-vertical" size="xs" /> {{ t('editor.props.sidebar') }}
</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -186,8 +216,11 @@
checked-icon="la:check"
unchecked-icon="la:times" />
</div>
<div v-if="pageStore.showSidebar && pageStore.showToc" style="padding-left: 40px;">
<div class="text-caption">{{t('editor.props.tocMinMaxDepth')}} <strong>(H{{pageStore.tocDepth.min}} &rarr; H{{pageStore.tocDepth.max}})</strong></div>
<div v-if="pageStore.showSidebar && pageStore.showToc" style="padding-left: 40px">
<div class="text-caption">
{{ t('editor.props.tocMinMaxDepth') }}
<strong>(H{{ pageStore.tocDepth.min }} &rarr; H{{ pageStore.tocDepth.max }})</strong>
</div>
<w-range
v-model="pageStore.tocDepth"
:min="1"
@ -212,7 +245,9 @@
</w-form>
</w-card-section>
<w-card-section class="alt-card pb-6" id="refCardSocial">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:comments" size="xs" /> {{t('editor.props.social')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:comments" size="xs" /> {{ t('editor.props.social') }}
</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -244,11 +279,15 @@
</w-form>
</w-card-section>
<w-card-section class="pb-6" id="refCardTags">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:tags" size="xs" /> {{t('editor.props.tags')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:tags" size="xs" /> {{ t('editor.props.tags') }}
</div>
<page-tags edit />
</w-card-section>
<w-card-section class="alt-card pb-6" id="refCardVisibility">
<div class="text-overline items-center flex"><w-icon class="mr-2" name="la:eye" size="xs" /> {{t('editor.props.visibility')}}</div>
<div class="text-overline items-center flex">
<w-icon class="mr-2" name="la:eye" size="xs" /> {{ t('editor.props.visibility') }}
</div>
<w-form class="gap-4 pt-2">
<div>
<w-toggle
@ -278,7 +317,7 @@
checked-icon="la:check"
unchecked-icon="la:times" />
</div>
<div v-if="state.requirePassword" style="padding-left: 40px;">
<div v-if="state.requirePassword" style="padding-left: 40px">
<w-input
ref="iptPagePassword"
v-model="pageStore.password"
@ -291,7 +330,9 @@
</w-card-section>
</w-scroll-area>
<w-dialog v-model="state.showRelationDialog">
<page-relation-dialog :edit-id="state.editRelationId" @close="state.showRelationDialog = false" />
<page-relation-dialog
:edit-id="state.editRelationId"
@close="state.showRelationDialog = false" />
</w-dialog>
<w-dialog v-model="state.showScriptsDialog">
<page-scripts-dialog :mode="state.pageScriptsMode" @close="state.showScriptsDialog = false" />
@ -312,7 +353,6 @@ import PageRelationDialog from './PageRelationDialog.vue'
import PageScriptsDialog from './PageScriptsDialog.vue'
import PageTags from './PageTags.vue'
// STORES
const editorStore = useEditorStore()
@ -347,6 +387,7 @@ const quickaccess = [
// REFS
const iptTitle = ref(null)
const iptPagePassword = ref(null)
// COMPUTED
@ -412,8 +453,35 @@ function toggleRequirePassword(newValue) {
onMounted(() => {
state.requirePassword = pageStore.password?.length > 0
// -> Title is the field this panel is opened to edit, so the caret starts there
nextTick(() => {
iptTitle.value?.focus()
})
setTimeout(() => {
state.showQuickAccess = true
}, 300)
})
</script>
<style lang="scss">
/*
The panel is inset from the window and rounded now, so the two children that reach its corners have
to be rounded too -- a square toolbar or scroll area paints straight over the radius. `inherit`
takes the card's own value, so these stay right if that radius ever changes.
The scroll area is what makes the BOTTOM corners work: it already clips its overflow, so giving it
the radius clips the last section (grey, `alt-card`) to the corner instead of letting it square off.
*/
.page-properties-dialog {
> .w-toolbar {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
> .w-scroll-area {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
}
</style>

@ -1,11 +1,16 @@
<template>
<w-card class="page-relation-dialog" style="width: 500px;">
<w-card class="page-relation-dialog" style="width: 500px">
<w-toolbar class="bg-primary text-white">
<div class="text-subtitle2" v-if="isEditMode">{{t('editor.pageRel.titleEdit')}}</div>
<div class="text-subtitle2" v-else>{{t('editor.pageRel.title')}}</div>
<div class="text-subtitle2" v-if="isEditMode">{{ t('editor.pageRel.titleEdit') }}</div>
<div class="text-subtitle2" v-else>{{ t('editor.pageRel.title') }}</div>
</w-toolbar>
<w-card-section>
<div class="text-overline">{{t('editor.pageRel.position')}}</div>
<!--
`self-start` on every button: WForm stacks with `flex-col`, so a button left to its own devices
stretches to the full width of the dialog. The section titles pull the item below them up, since
the form's `gap-4` belongs between sections rather than between a title and its own content.
-->
<div class="text-overline -mb-3">{{ t('editor.pageRel.position') }}</div>
<w-form class="gap-4 pt-4">
<div>
<w-btn-toggle
@ -15,50 +20,73 @@
no-caps
toggle-color="primary"
:options="[
{ label: t('editor.pageRel.left'), value: 'left' },
{ label: t('editor.pageRel.center'), value: 'center' },
{ label: t('editor.pageRel.right'), value: 'right' }
]" />
{ label: t('editor.pageRel.left'), value: 'left' },
{ label: t('editor.pageRel.center'), value: 'center' },
{ label: t('editor.pageRel.right'), value: 'right' }
]" />
</div>
<div class="text-overline -mb-3">{{ t('editor.pageRel.button') }}</div>
<!-- One item, so the two fields are only ever as far apart as their own margins -->
<div class="flex flex-col">
<w-input
ref="iptRelLabel"
outlined
dense
:label="t(`editor.pageRel.label`)"
v-model="state.label" />
<w-input
v-if="state.pos !== `center`"
outlined
dense
:label="t(`editor.pageRel.caption`)"
v-model="state.caption" />
</div>
<div class="text-overline">{{t('editor.pageRel.button')}}</div>
<w-input
ref="iptRelLabel"
outlined
dense
:label="t(`editor.pageRel.label`)"
v-model="state.label" />
<template v-if="state.pos !== `center`">
<w-input outlined dense :label="t(`editor.pageRel.caption`)" v-model="state.caption" />
</template>
<w-btn
class="rounded"
class="self-start rounded"
:label="t(`editor.pageRel.selectIcon`)"
color="primary"
outline>
<w-tooltip>{{ t('iconPicker.open') }}</w-tooltip>
<w-menu content-class="shadow-7"><icon-picker-dialog v-model="state.icon" /></w-menu>
</w-btn>
<div class="text-overline">{{t('editor.pageRel.target')}}</div>
<div class="text-overline -mb-3">{{ t('editor.pageRel.target') }}</div>
<w-btn
class="rounded"
class="self-start rounded"
:label="t(`editor.pageRel.selectPage`)"
color="primary"
outline />
<div class="text-overline">{{t('editor.pageRel.preview')}}</div>
<w-btn v-if="state.pos === `left`" padding="sm md" outline no-caps color="primary">
<div class="text-overline -mb-3">{{ t('editor.pageRel.preview') }}</div>
<w-btn
v-if="state.pos === `left`"
class="self-start"
padding="sm md"
outline
no-caps
color="primary">
<w-icon :name="state.icon" />
<div class="column text-left pl-4">
<div class="text-body2"><strong>{{state.label}}</strong></div>
<div class="text-caption">{{state.caption}}</div>
<div class="flex flex-col text-left pl-4">
<div class="text-body2">
<strong>{{ state.label }}</strong>
</div>
<div class="text-caption">{{ state.caption }}</div>
</div>
</w-btn>
<w-btn class="w-full" v-else-if="state.pos === `center`" color="primary" flat no-caps>
<w-icon class="mr-2" :name="state.icon" />
<span>{{ state.label }}</span>
</w-btn>
<w-btn v-else-if="state.pos === `right`" padding="sm md" outline no-caps color="primary">
<div class="column text-left pr-4">
<div class="text-body2"><strong>{{state.label}}</strong></div>
<div class="text-caption">{{state.caption}}</div>
<w-btn
v-else-if="state.pos === `right`"
class="self-start"
padding="sm md"
outline
no-caps
color="primary">
<div class="flex flex-col text-left pr-4">
<div class="text-body2">
<strong>{{ state.label }}</strong>
</div>
<div class="text-caption">{{ state.caption }}</div>
</div>
<w-icon :name="state.icon" />
</w-btn>
@ -82,7 +110,7 @@
unelevated
color="primary"
padding="xs md"
@click="persist(); $emit('close')" />
@click="saveAndClose" />
<w-btn
v-else
:disabled="!canSubmit"
@ -91,7 +119,7 @@
unelevated
color="primary"
padding="xs md"
@click="create(); $emit('close')" />
@click="createAndClose" />
</w-card-actions>
</w-card>
</template>
@ -116,7 +144,6 @@ const props = defineProps({
}
})
// STORES
const pageStore = usePageStore()
@ -169,7 +196,7 @@ watch(
// METHODS
defineEmits(['close'])
const emit = defineEmits(['close'])
function create() {
pageStore.$patch({
@ -203,6 +230,20 @@ function persist() {
})
}
/*
Named handlers: an inline `persist(); $emit('close')` is reformatted onto two lines by oxfmt, which
is no longer a valid template expression.
*/
function saveAndClose() {
persist()
emit('close')
}
function createAndClose() {
create()
emit('close')
}
// MOUNTED
onMounted(() => {

@ -1,19 +1,23 @@
<template>
<w-card class="page-scripts-dialog" style="width: 860px; max-width: 90vw;">
<w-card class="page-scripts-dialog" style="width: 860px; max-width: 90vw">
<w-toolbar class="bg-primary text-white">
<div class="text-subtitle2">{{t('editor.pageScripts.title')}} - {{t('editor.props.' + props.mode)}}</div>
<div class="text-subtitle2">
{{ t('editor.pageScripts.title') }} - {{ t('editor.props.' + props.mode) }}
</div>
<w-space />
<w-chip square style="background-color: rgba(0,0,0,.1)" text-color="white">
<div class="text-caption">{{languageLabel}}</div>
<w-chip square style="background-color: rgba(0, 0, 0, 0.1)" text-color="white">
<div class="text-caption">{{ languageLabel }}</div>
</w-chip>
</w-toolbar>
<div style="min-height: 450px;">
<div style="min-height: 450px">
<!-- -> Square: this one spans the dialog edge to edge, so a radius would cut across its corners -->
<util-code-editor
v-if="state.showEditor"
ref="editor"
v-model="state.content"
:language="language"
:min-height="450" />
:min-height="450"
:aria-label="languageLabel"
square />
</div>
<w-card-actions class="card-actions">
<w-space />
@ -31,13 +35,13 @@
unelevated
color="primary"
padding="xs md"
@click="persist(); $emit('close')" />
@click="saveAndClose" />
</w-card-actions>
</w-card>
</template>
<script setup>
import { computed, nextTick, onMounted, reactive } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageStore } from '@/stores/page'
@ -54,7 +58,6 @@ const props = defineProps({
}
})
// STORES
const pageStore = usePageStore()
@ -62,15 +65,16 @@ const siteStore = useSiteStore()
// I18N
defineEmits(['close'])
const emit = defineEmits(['close'])
const { t } = useI18n()
// DATA
const editor = ref(null)
const state = reactive({
content: '',
showEditor: false
content: ''
})
// COMPUTED
@ -110,14 +114,26 @@ function persist() {
})
}
/*
A named handler rather than `persist(); $emit('close')` inline: Vue parses an inline handler as an
EXPRESSION, and oxfmt reformats a semicolon-separated pair onto separate lines without the
semicolon, which stops being one. It broke the build twice while this file was being edited.
*/
function saveAndClose() {
persist()
emit('close')
}
// MOUNTED
// -> No deferred mount: the quarter-second wait was there to give the old editor a laid-out container
// to measure itself against, and a textarea needs no such thing
onMounted(() => {
state.content = pageStore[contentStoreKey.value]
// -> The editor is what this dialog is for, so the caret starts there. After the tick that renders
// the content above, so focus lands on a field that is already populated.
nextTick(() => {
setTimeout(() => {
state.showEditor = true
}, 250)
editor.value?.focus()
})
})
</script>

@ -34,7 +34,6 @@ const sideDialogs = {
})
}
// STORES
const editorStore = useEditorStore()
@ -69,18 +68,22 @@ const state = reactive({
<style lang="scss">
@use 'sass:color';
/*
The rules that used to sit here hung off `.q-dialog__inner` and `.w-card__section`, neither of which
this app renders any more -- so the inset, the radius and the panel's minimum width had all silently
stopped applying. The inset and radius now come from WDialog's own `right` variant, where they
belong; only the panel's floor width is a side-panel concern, and it is stated on the panel itself.
*/
.floating-sidepanel {
.q-dialog__inner {
right: 24px;
.w-card {
border-radius: 4px !important;
min-width: 450px;
.w-card__section {
border-radius: 0;
}
}
/*
A definite width, not the `min-width: 450px` this replaces. The panel's content arrives
asynchronously behind a loading placeholder, and while the width was content-driven it changed
when the real dialog swapped in -- which, on a right-justified panel, jumped the left edge 112px
mid-transition and made a 32px slide look like a lurch. 560px is the width the content settles at
anyway; measured, nothing inside asks for more, date picker included.
*/
.w-dialog-panel {
width: 560px;
}
.alt-card {
@ -102,27 +105,30 @@ const state = reactive({
}
}
/*
The quick-jump rail, which sits outside the panel's left edge.
Two things kept it off screen. It was `position: fixed` at a hard-coded `right: 486px`, a number
derived from a panel 450px wide with a 24px margin -- once the panel sized itself to its content
(562px) that offset landed the rail INSIDE the panel. And `z-index: -1` then painted it behind the
card's own background, so even overlapping it was invisible.
Anchored to the card instead (`WCard` is a positioned element), so it tracks whatever width the
panel ends up with. The two `.q-transition--jump-*` rules that hid it mid-animation are gone with
the Quasar transitions they named; the 300ms timer in the dialog already keeps it out of the slide.
*/
&-quickaccess {
position: absolute;
right: calc(100% + 12px);
top: 24px;
width: 40px;
border-radius: 4px !important;
display: flex;
flex-direction: column;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
color: #fff;
position: fixed;
right: 486px;
top: 74px;
z-index: -1;
display: flex;
flex-direction: column;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.5) !important;
@at-root .q-transition--jump-left-enter-active & {
display: none !important;
}
@at-root .q-transition--jump-right-leave-active & {
display: none !important;
}
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.5);
}
}
</style>

@ -17,7 +17,7 @@
hide-bottom-space
:label="t(`common.field.name`)"
lazy-rules="ondemand"
autofocus />
ref="iptSiteName" />
</w-item-section>
</w-item>
<w-item>
@ -71,7 +71,9 @@ defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptSiteName.value
})
// STORES
@ -92,6 +94,7 @@ const state = reactive({
// REFS
const createSiteForm = ref(null)
const iptSiteName = ref(null)
// VALIDATION RULES

@ -16,7 +16,9 @@
<div class="page-save-dialog-browser flex flex-nowrap">
<div class="w-1/3">
<w-scroll-area style="height: 300px">
<div class="px-2">
<!-- -> No side padding: the rows carry their own 12px and span the column, as in the
File Manager. Padding here would inset the highlight band as well. -->
<div>
<tree
ref="treeComp"
v-model:selected="state.currentFolderId"
@ -97,9 +99,7 @@
<w-item clickable @click="state.displayMode = `title`">
<w-item-section side>
<w-icon
:name="
state.displayMode === `title` ? `la:check-circle` : `la:circle`
"
:name="state.displayMode === `title` ? `la:check-circle` : `la:circle`"
:color="state.displayMode === `title` ? `positive` : `grey`"
size="xs" />
</w-item-section>

@ -183,10 +183,6 @@ onMounted(() => {
> .treeview-node {
border-left: none;
> .treeview-label {
border-radius: 5px;
}
}
}
@ -203,8 +199,12 @@ onMounted(() => {
}
&-label {
padding: 4px 8px;
border-radius: 0 5px 5px 0;
// -> 12px matches a toolbar's own side padding, which is what lines a row's folder icon up with
// the icon in the header above it. Applied here rather than on the container so the row's
// highlight still spans the full width.
padding: 4px 12px;
// -> Square: a row spans the full width of its container, and a radius on a full-width band reads
// as a pill that has been clipped rather than as a highlighted row
cursor: pointer;
display: flex;
align-items: center;

@ -17,8 +17,7 @@
:rules="userNameValidation"
hide-bottom-space
:label="t(`common.field.name`)"
lazy-rules="ondemand"
autofocus />
lazy-rules="ondemand" />
</w-item-section>
</w-item>
<w-item>
@ -32,8 +31,7 @@
:rules="userEmailValidation"
hide-bottom-space
:label="t(`admin.users.email`)"
lazy-rules="ondemand"
autofocus />
lazy-rules="ondemand" />
</w-item-section>
</w-item>
<w-item>
@ -46,8 +44,7 @@
:rules="userPasswordValidation"
hide-bottom-space
:label="t(`admin.users.password`)"
lazy-rules="ondemand"
autofocus>
lazy-rules="ondemand">
<template #append>
<div class="flex flex-nowrap items-center">
<w-badge :color="passwordStrength.color" :label="passwordStrength.label" />
@ -146,7 +143,12 @@
</w-item>
</w-form>
<w-card-actions class="card-actions">
<!--
-> `ml-2` lines the checkbox up with the blueprint icons above it: the rows are `w-item`s,
padded 16px, while this action bar is padded 8px, so it started 8px to their left
-->
<w-checkbox
class="ml-2"
v-model="state.keepOpened"
color="primary"
:label="t(`admin.users.createKeepOpened`)" />
@ -187,7 +189,9 @@ defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
autofocus: () => iptName.value
})
// STORES

@ -3,8 +3,8 @@
<w-header class="card-header px-4 py-2">
<w-icon name="img:/_assets/icons/fluent-account.svg" left size="md" />
<div>
<span>{{t(`admin.users.edit`)}}</span>
<div class="text-caption">{{state.user.name}}</div>
<span>{{ t(`admin.users.edit`) }}</span>
<div class="text-caption">{{ state.user.name }}</div>
</div>
<w-space />
<w-btn-group push>
@ -16,7 +16,9 @@
icon="la:redo-alt"
@click="fetchUser"
:loading="state.loading > 0">
<w-tooltip anchor="center left" self="center right">{{t(`common.actions.refresh`)}}</w-tooltip>
<w-tooltip anchor="center left" self="center right">{{
t(`common.actions.refresh`)
}}</w-tooltip>
</w-btn>
<w-btn
push
@ -47,7 +49,7 @@
active-class="bg-primary text-white"
:disabled="sc.disabled">
<w-item-section side><w-icon :name="sc.icon" color="white" /></w-item-section>
<w-item-section>{{sc.text}}</w-item-section>
<w-item-section>{{ sc.text }}</w-item-section>
</w-item>
</template>
</w-list>
@ -57,7 +59,7 @@
<div class="flex p-6 items-center">
<w-spinner color="primary" size="32px" :thickness="2" />
<div class="text-caption text-primary pl-4">
<strong>{{t('admin.users.loading')}}</strong>
<strong>{{ t('admin.users.loading') }}</strong>
</div>
</div>
</w-page>
@ -67,13 +69,13 @@
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.profile')}}</div>
<div class="text-subtitle1">{{ t('admin.users.profile') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="contact" />
<w-item-section>
<w-item-label>{{t(`admin.users.name`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.nameHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.name`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.nameHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -81,8 +83,8 @@
v-model="state.user.name"
dense
:rules="[
val => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
]"
(val) => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
]"
hide-bottom-space
:aria-label="t(`admin.users.name`)" />
</w-item-section>
@ -91,8 +93,8 @@
<w-item>
<blueprint-icon icon="envelope" />
<w-item-section>
<w-item-label>{{t(`admin.users.email`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.emailHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.email`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.emailHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -107,8 +109,8 @@
<w-item>
<blueprint-icon icon="address" />
<w-item-section>
<w-item-label>{{t(`admin.users.location`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.locationHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.location`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.locationHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -122,8 +124,8 @@
<w-item>
<blueprint-icon icon="new-job" />
<w-item-section>
<w-item-label>{{t(`admin.users.jobTitle`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.jobTitleHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.jobTitle`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.jobTitleHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -137,8 +139,8 @@
<w-item>
<blueprint-icon icon="gender" />
<w-item-section>
<w-item-label>{{t(`admin.users.pronouns`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.pronounsHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.pronouns`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.pronounsHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-input
@ -152,13 +154,13 @@
</w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.preferences')}}</div>
<div class="text-subtitle1">{{ t('admin.users.preferences') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="timezone" />
<w-item-section>
<w-item-label>{{t(`admin.users.timezone`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.timezoneHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.timezone`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.timezoneHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-select
@ -178,8 +180,8 @@
<w-item>
<blueprint-icon icon="calendar" />
<w-item-section>
<w-item-label>{{t(`admin.users.dateFormat`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.dateFormatHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.dateFormat`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.dateFormatHint`) }}</w-item-label>
</w-item-section>
<w-item-section>
<w-select
@ -190,21 +192,21 @@
dense
:aria-label="t(`admin.users.dateFormat`)"
:options="[
{ label: t('profile.localeDefault'), value: '' },
{ label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' },
{ label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
{ label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
{ label: 'YYYY/MM/DD', value: 'YYYY/MM/DD' }
]" />
{ label: t('profile.localeDefault'), value: '' },
{ label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' },
{ label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
{ label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
{ label: 'YYYY/MM/DD', value: 'YYYY/MM/DD' }
]" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="clock" />
<w-item-section>
<w-item-label>{{t(`admin.users.timeFormat`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.timeFormatHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.timeFormat`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.timeFormatHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn-toggle
@ -214,17 +216,17 @@
no-caps
toggle-color="primary"
:options="[
{ label: t('profile.timeFormat12h'), value: '12h' },
{ label: t('profile.timeFormat24h'), value: '24h' }
]" />
{ label: t('profile.timeFormat12h'), value: '12h' },
{ label: t('profile.timeFormat24h'), value: '24h' }
]" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="light-on" />
<w-item-section>
<w-item-label>{{t(`admin.users.appearance`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.darkModeHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.appearance`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.darkModeHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn-toggle
@ -234,18 +236,18 @@
no-caps
toggle-color="primary"
:options="[
{ label: t('profile.appearanceDefault'), value: 'site' },
{ label: t('profile.appearanceLight'), value: 'light' },
{ label: t('profile.appearanceDark'), value: 'dark' }
]" />
{ label: t('profile.appearanceDefault'), value: 'site' },
{ label: t('profile.appearanceLight'), value: 'light' },
{ label: t('profile.appearanceDark'), value: 'dark' }
]" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="visualy-impaired" />
<w-item-section>
<w-item-label>{{t(`profile.cvd`)}}</w-item-label>
<w-item-label caption>{{t(`profile.cvdHint`)}}</w-item-label>
<w-item-label>{{ t(`profile.cvd`) }}</w-item-label>
<w-item-label caption>{{ t(`profile.cvdHint`) }}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn-toggle
@ -255,11 +257,11 @@
no-caps
toggle-color="primary"
:options="[
{ value: 'none', label: t('profile.cvdNone') },
{ value: 'protanopia', label: t('profile.cvdProtanopia') },
{ value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
{ value: 'tritanopia', label: t('profile.cvdTritanopia') }
]" />
{ value: 'none', label: t('profile.cvdNone') },
{ value: 'protanopia', label: t('profile.cvdProtanopia') },
{ value: 'deuteranopia', label: t('profile.cvdDeuteranopia') },
{ value: 'tritanopia', label: t('profile.cvdTritanopia') }
]" />
</w-item-section>
</w-item>
</w-card>
@ -267,22 +269,24 @@
<div class="col-span-12 lg:col-span-4">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.info')}}</div>
<div class="text-subtitle1">{{ t('admin.users.info') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="person" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{t(`common.field.id`)}}</w-item-label>
<w-item-label><strong>{{state.user.id}}</strong></w-item-label>
<w-item-label>{{ t(`common.field.id`) }}</w-item-label>
<w-item-label
><strong>{{ state.user.id }}</strong></w-item-label
>
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="calendar-plus" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{t(`common.field.createdOn`)}}</w-item-label>
<w-item-label>{{ t(`common.field.createdOn`) }}</w-item-label>
<w-item-label>
<strong>{{formattedDate(state.user.createdAt)}}</strong>
<strong>{{ formattedDate(state.user.createdAt) }}</strong>
</w-item-label>
</w-item-section>
</w-item>
@ -290,9 +294,9 @@
<w-item>
<blueprint-icon icon="summertime" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{t(`common.field.lastUpdated`)}}</w-item-label>
<w-item-label>{{ t(`common.field.lastUpdated`) }}</w-item-label>
<w-item-label>
<strong>{{formattedDate(state.user.updatedAt)}}</strong>
<strong>{{ formattedDate(state.user.updatedAt) }}</strong>
</w-item-label>
</w-item-section>
</w-item>
@ -300,16 +304,16 @@
<w-item>
<blueprint-icon icon="enter" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{t(`admin.users.lastLoginAt`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.lastLoginAt`) }}</w-item-label>
<w-item-label>
<strong>{{formattedDate(state.user.lastLoginAt)}}</strong>
<strong>{{ formattedDate(state.user.lastLoginAt) }}</strong>
</w-item-label>
</w-item-section>
</w-item>
</w-card>
<w-card class="shadow-1 pb-2 mt-4" v-if="state.user.meta">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.notes')}}</div>
<div class="text-subtitle1">{{ t('admin.users.notes') }}</div>
<w-input
class="mt-2"
outlined
@ -331,15 +335,22 @@
<div class="col-span-12 lg:col-span-7">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.passAuth')}}</div>
<div class="text-subtitle1">{{ t('admin.users.passAuth') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="password" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{t(`admin.users.changePassword`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.changePasswordHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.changePassword`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.changePasswordHint`) }}</w-item-label>
<w-item-label caption>
<strong :class="localAuth.isPasswordSet ? `text-positive` : `text-negative`">{{localAuth.isPasswordSet ? t(`admin.users.pwdSet`) : t(`admin.users.pwdNotSet`)}}</strong>
<strong
:class="localAuth.isPasswordSet ? `text-positive` : `text-negative`"
>{{
localAuth.isPasswordSet
? t(`admin.users.pwdSet`)
: t(`admin.users.pwdNotSet`)
}}</strong
>
</w-item-label>
</w-item-section>
<w-item-section side>
@ -356,8 +367,8 @@
<w-item tag="label">
<blueprint-icon icon="password-reset" />
<w-item-section>
<w-item-label>{{t(`admin.users.mustChangePwd`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.mustChangePwdHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.mustChangePwd`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.mustChangePwdHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
@ -372,8 +383,8 @@
<w-item tag="label">
<blueprint-icon icon="key" />
<w-item-section>
<w-item-label>{{t(`admin.users.pwdAuthRestrict`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.pwdAuthRestrictHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.pwdAuthRestrict`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.pwdAuthRestrictHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
@ -387,13 +398,13 @@
</w-card>
<w-card class="shadow-1 pb-2 mt-4">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.tfa')}}</div>
<div class="text-subtitle1">{{ t('admin.users.tfa') }}</div>
</w-card-section>
<w-item tag="label">
<blueprint-icon icon="key" />
<w-item-section>
<w-item-label>{{t(`admin.users.tfaRequired`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.tfaRequiredHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.tfaRequired`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.tfaRequiredHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
@ -408,10 +419,12 @@
<w-item>
<blueprint-icon icon="password" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{t(`admin.users.tfaInvalidate`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.tfaInvalidateHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.tfaInvalidate`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.tfaInvalidateHint`) }}</w-item-label>
<w-item-label caption>
<strong :class="localAuth.isTfaSetup ? `text-positive` : `text-negative`">{{localAuth.isTfaSetup ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)}}</strong>
<strong :class="localAuth.isTfaSetup ? `text-positive` : `text-negative`">{{
localAuth.isTfaSetup ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)
}}</strong>
</w-item-label>
</w-item-section>
<w-item-section side>
@ -429,16 +442,22 @@
<div class="col-span-12 lg:col-span-5">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.linkedProviders')}}</div>
<w-banner class="mt-4" v-if="linkedAuthProviders.length < 1" rounded :class="dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`">{{t('admin.users.noLinkedProviders')}}</w-banner>
<div class="text-subtitle1">{{ t('admin.users.linkedProviders') }}</div>
<w-banner
class="mt-4"
v-if="linkedAuthProviders.length < 1"
rounded
:class="dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`"
>{{ t('admin.users.noLinkedProviders') }}</w-banner
>
</w-card-section>
<template v-for="(prv, idx) in linkedAuthProviders" :key="prv.authId">
<w-separator class="my-2" inset v-if="idx > 0" />
<w-item>
<blueprint-icon :icon="prv.strategyIcon" :hue-rotate="-45" />
<w-item-section>
<w-item-label>{{prv.authName}}</w-item-label>
<w-item-label caption>{{prv.config.key}}</w-item-label>
<w-item-label>{{ prv.authName }}</w-item-label>
<w-item-label caption>{{ prv.config.key }}</w-item-label>
</w-item-section>
</w-item>
</template>
@ -453,13 +472,15 @@
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.groups')}}</div>
<div class="text-subtitle1">{{ t('admin.users.groups') }}</div>
</w-card-section>
<template v-for="(grp, idx) of state.user.groups" :key="grp.id">
<w-separator class="my-2" inset v-if="idx > 0" />
<w-item>
<blueprint-icon icon="team" :hue-rotate="-45" />
<w-item-section><w-item-label>{{grp.name}}</w-item-label></w-item-section>
<w-item-section
><w-item-label>{{ grp.name }}</w-item-label></w-item-section
>
<w-item-section side>
<w-btn
class="acrylic-btn"
@ -468,7 +489,9 @@
color="accent"
@click="unassignGroup(grp.id)"
:aria-label="t(`admin.users.unassignGroup`)">
<w-tooltip anchor="center left" self="center right">{{t('admin.users.unassignGroup')}}</w-tooltip>
<w-tooltip anchor="center left" self="center right">{{
t('admin.users.unassignGroup')
}}</w-tooltip>
</w-btn>
</w-item-section>
</w-item>
@ -513,21 +536,21 @@
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section class="flex items-center">
<div class="text-subtitle1">{{t('admin.users.metadata')}}</div>
<div class="text-subtitle1">{{ t('admin.users.metadata') }}</div>
<w-space />
<w-badge v-if="state.metadataInvalidJSON" color="negative">
<w-icon class="mr-1" name="la:exclamation-triangle" size="20px" />
<span>{{t('admin.users.invalidJSON')}}</span>
<span>{{ t('admin.users.invalidJSON') }}</span>
</w-badge>
<w-badge class="py-1" v-else label="JSON" color="positive" />
</w-card-section>
<w-item>
<w-item-section>
<util-code-editor
class="admin-theme-cm"
v-model="metadata"
language="json"
:min-height="500" />
:min-height="500"
aria-label="Metadata (JSON)" />
</w-item-section>
</w-item>
</w-card>
@ -541,13 +564,15 @@
<div class="col-span-12 lg:col-span-8">
<w-card class="shadow-1 pb-2">
<w-card-section>
<div class="text-subtitle1">{{t('admin.users.operations')}}</div>
<div class="text-subtitle1">{{ t('admin.users.operations') }}</div>
</w-card-section>
<w-item>
<blueprint-icon icon="email-open" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{t(`admin.users.sendWelcomeEmail`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.sendWelcomeEmailAltHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.sendWelcomeEmail`) }}</w-item-label>
<w-item-label caption>{{
t(`admin.users.sendWelcomeEmailAltHint`)
}}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
@ -563,10 +588,20 @@
<w-item>
<blueprint-icon icon="apply" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{state.user.isVerified ? t(`admin.users.unverify`) : t(`admin.users.verify`)}}</w-item-label>
<w-item-label caption>{{state.user.isVerified ? t(`admin.users.unverifyHint`) : t(`admin.users.verifyHint`)}}</w-item-label>
<w-item-label>{{
state.user.isVerified ? t(`admin.users.unverify`) : t(`admin.users.verify`)
}}</w-item-label>
<w-item-label caption>{{
state.user.isVerified
? t(`admin.users.unverifyHint`)
: t(`admin.users.verifyHint`)
}}</w-item-label>
<w-item-label caption>
<strong :class="state.user.isVerified ? `text-positive` : `text-negative`">{{state.user.isVerified ? t(`admin.users.verified`) : t(`admin.users.unverified`)}}</strong>
<strong :class="state.user.isVerified ? `text-positive` : `text-negative`">{{
state.user.isVerified
? t(`admin.users.verified`)
: t(`admin.users.unverified`)
}}</strong>
</w-item-label>
</w-item-section>
<w-item-section side>
@ -583,10 +618,16 @@
<w-item>
<blueprint-icon icon="unfriend" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{state.user.isActive ? t(`admin.users.ban`) : t(`admin.users.unban`)}}</w-item-label>
<w-item-label caption>{{state.user.isActive ? t(`admin.users.banHint`) : t(`admin.users.unbanHint`)}}</w-item-label>
<w-item-label>{{
state.user.isActive ? t(`admin.users.ban`) : t(`admin.users.unban`)
}}</w-item-label>
<w-item-label caption>{{
state.user.isActive ? t(`admin.users.banHint`) : t(`admin.users.unbanHint`)
}}</w-item-label>
<w-item-label caption>
<strong :class="state.user.isActive ? `text-positive` : `text-negative`">{{state.user.isActive ? t(`admin.users.active`) : t(`admin.users.banned`)}}</strong>
<strong :class="state.user.isActive ? `text-positive` : `text-negative`">{{
state.user.isActive ? t(`admin.users.active`) : t(`admin.users.banned`)
}}</strong>
</w-item-label>
</w-item-section>
<w-item-section side>
@ -604,8 +645,8 @@
<w-item>
<blueprint-icon icon="denied" :hue-rotate="140" />
<w-item-section>
<w-item-label>{{t(`admin.users.delete`)}}</w-item-label>
<w-item-label caption>{{t(`admin.users.deleteHint`)}}</w-item-label>
<w-item-label>{{ t(`admin.users.delete`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.users.deleteHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
@ -914,12 +955,7 @@ onMounted(() => {
})
</script>
<style lang="scss" scoped>
.metadata-codemirror {
&:deep(.cm-editor) {
min-height: 150px;
border-radius: 5px;
border: 1px solid #ccc;
}
}
</style>
<!--
-> The `.metadata-codemirror` rules that were here targeted `.cm-editor`, a CodeMirror 6 class this
app never had, from a class no element in this file carries. Dead twice over.
-->

@ -1,23 +1,56 @@
<template lang="pug">
.util-code-editor
textarea(ref='cmRef')
<template>
<!--
A real <textarea> with a highlighted copy of its own text painted underneath, rather than an
editor library. The textarea's text is transparent, so what you read is the <pre> and what you
type into is the textarea, and the two stay registered because they share every metric that
affects where a glyph lands (see the style block).
Keeping the textarea is the point: native undo/redo, selection, spellcheck control, mobile
keyboards, form semantics and screen-reader behaviour all come for free, which neither a
contenteditable nor a canvas-drawn editor gives you.
-->
<div
class="util-code-editor"
:class="{ 'util-code-editor--square': square }"
:style="{ height: `${minHeight}px`, '--util-code-editor-gutter': gutterWidth }">
<pre class="util-code-editor-view" aria-hidden="true"><code v-html="highlighted" /></pre>
<textarea
ref="inputEl"
class="util-code-editor-input"
:value="modelValue"
:aria-label="ariaLabel"
spellcheck="false"
autocapitalize="off"
autocomplete="off"
autocorrect="off"
@input="onInput"
@scroll="onScroll"
@keydown.tab.exact.prevent="onTab" />
</div>
</template>
<script setup>
/* eslint no-unused-vars: "off" */
import { ref, shallowRef, onBeforeMount, onMounted, watch } from 'vue'
// Code Mirror
import CodeMirror from 'codemirror'
import 'codemirror/lib/codemirror.css'
// Language
import 'codemirror/mode/markdown/markdown.js'
import 'codemirror/mode/htmlmixed/htmlmixed.js'
import 'codemirror/mode/css/css.js'
/**
* Small code field for hand-entering CSS, HTML, JavaScript, JSON or YAML.
*
* Deliberately basic: no bracket matching, autocomplete, folding, multiple cursors or find. The
* places this appears are short config fields, and the editor it replaces cost 83 kB gzipped to
* provide those.
*
* Not in `components/shared/`, despite being a form control: that library is registered eagerly, so
* a `w-*` component here would pull the highlighter into the main bundle for every visitor. Imported
* per call site instead, it stays in the lazy chunks of the few screens that use it.
*/
import { computed, ref } from 'vue'
// Addons
import 'codemirror/addon/selection/active-line.js'
// -> `lib/core` plus named languages, NOT the `highlight.js` root: that root registers all ~190
// languages (563 kB gzipped) and is why the page renderer's chunk is the size it is
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 xml from 'highlight.js/lib/languages/xml'
import yaml from 'highlight.js/lib/languages/yaml'
// PROPS
@ -26,13 +59,31 @@ const props = defineProps({
type: String,
default: ''
},
/** `css` | `html` | `javascript` | `json` | `yaml`; anything else renders unhighlighted. */
language: {
type: String,
default: 'plaintext'
},
/** Height of the field in px. Content beyond it scrolls. */
minHeight: {
type: Number,
default: 150
},
/** Accessible name, since a code field rarely has a visible <label>. */
ariaLabel: {
type: String,
default: null
},
/**
* Sharp corners, for a field that spans its container edge to edge.
*
* Rounded corners read as a control inset in a surface; where the editor IS the surface -- filling
* the width of a dialog between its toolbar and its buttons -- they cut across the dialog's own
* edge instead. Named as `WChip` and `WAvatar` name the same idea.
*/
square: {
type: Boolean,
default: false
}
})
@ -42,93 +93,420 @@ const emit = defineEmits(['update:modelValue'])
// STATE
const cm = shallowRef(null)
const cmRef = ref(null)
const inputEl = ref(null)
// WATCHERS
/*
Registered once for the module, not per instance: hljs keeps a single global registry, so repeating
this per component would just overwrite the same entries.
*/
hljs.registerLanguage('css', css)
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('json', json)
hljs.registerLanguage('xml', xml)
hljs.registerLanguage('yaml', yaml)
watch(
() => props.modelValue,
(newVal) => {
// Ignore loopback changes while editing
if (!cm.value.hasFocus()) {
cm.value.setValue(newVal)
}
}
)
/*
The `language` prop names the language the way the call sites already do; `html` is hljs's `xml`.
A name that is absent here (`plaintext`, or anything unregistered) is escaped and left alone rather
than throwing, so a new call site cannot break the field by asking for a language nobody added.
*/
const HLJS_LANGUAGES = {
css: 'css',
html: 'xml',
javascript: 'javascript',
json: 'json',
yaml: 'yaml'
}
// MOUNTED
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;' }
onMounted(async () => {
let langMode = null
switch (props.language) {
case 'css': {
langMode = 'text/css'
break
}
case 'html': {
langMode = 'text/html'
break
}
case 'javascript': {
langMode = 'text/javascript'
break
}
case 'json': {
langMode = {
name: 'javascript',
json: true
/*
hljs emits nothing but `<span class="…">`, `</span>` and escaped text, which is what makes this
three-branch match sufficient.
*/
const HLJS_TOKENS = /<span class="[^"]*">|<\/span>|[^<]+/g
/**
* Split highlighted HTML into one string per source line, keeping the spans balanced.
*
* A token can legitimately run across a newline -- a block comment, a template literal, a YAML
* block scalar -- and its span then contains the break. Wrapping each line in an element of its own
* therefore cannot just cut the string at "\n": the open spans have to be closed at the end of each
* line and reopened at the start of the next, or the line elements nest inside one another and the
* numbering collapses to a single row.
*/
function splitHighlightedLines(html) {
const lines = []
/** The opening tags currently in effect, innermost last. */
const open = []
let current = ''
for (const [token] of html.matchAll(HLJS_TOKENS)) {
if (token === '</span>') {
open.pop()
current += token
} else if (token.startsWith('<span')) {
open.push(token)
current += token
} else {
// -> Text is the only branch that can hold a newline
const parts = token.split('\n')
for (const [index, part] of parts.entries()) {
if (index > 0) {
current += '</span>'.repeat(open.length)
lines.push(current)
current = open.join('')
}
current += part
}
break
}
case 'markdown': {
langMode = 'text/markdown'
break
}
default: {
langMode = null
break
}
}
// -> Initialize CodeMirror
cm.value = CodeMirror.fromTextArea(cmRef.value, {
tabSize: 2,
mode: langMode,
theme: 'wikijs-dark',
lineNumbers: true,
lineWrapping: true,
line: true,
styleActiveLine: true,
viewportMargin: 50,
inputStyle: 'contenteditable',
direction: 'ltr'
})
lines.push(current)
return lines
}
cm.value.setValue(props.modelValue)
cm.value.on('change', (c) => {
emit('update:modelValue', c.getValue())
})
// COMPUTED
cm.value.setSize(null, `${props.minHeight}px`)
const lines = computed(() => {
const value = props.modelValue ?? ''
const language = HLJS_LANGUAGES[props.language]
const html = language
? // -> Partial code is the normal state in a field being typed into, so illegal syntax must not
// abort the highlight and blank the view
hljs.highlight(value, { language, ignoreIllegals: true }).value
: value.replace(/[&<>]/g, (c) => ESCAPES[c])
return splitHighlightedLines(html)
})
onBeforeMount(() => {
if (cm.value) {
cm.value.destroy()
/*
One block element per line, carrying its own number as a data attribute for the gutter to draw with
`content: attr()`. Built as a single string rather than a v-for so the whole view is one innerHTML
write per keystroke instead of a element-by-element patch.
A trailing newline needs no special case here, unlike a plain <pre>: `'a\n'` splits into two lines,
the second empty, which is exactly the line the caret is sitting on.
*/
const highlighted = computed(() =>
lines.value
.map(
(line, index) => `<span class="util-code-editor-line" data-line="${index + 1}">${line}</span>`
)
.join('')
)
// -> Wide enough for the highest line number the field currently holds. `ch` is the width of a digit
// in the gutter's own font, which is why the container carries the mono font too
const gutterWidth = computed(() => `calc(${String(lines.value.length).length}ch + 1.35rem)`)
// METHODS
function onInput(ev) {
emit('update:modelValue', ev.target.value)
}
// -> The view is the element that scrolls out of sight, so it has to follow the one with the scrollbar
function onScroll(ev) {
const view = ev.target.previousElementSibling
view.scrollTop = ev.target.scrollTop
}
/*
Tab indents by two, as the editor this replaces did.
Shift+Tab is deliberately NOT handled, so it still moves focus and a keyboard user is never trapped
in the field.
*/
function onTab(ev) {
const el = ev.target
const { selectionStart: start, selectionEnd: end, value } = el
emit('update:modelValue', `${value.slice(0, start)} ${value.slice(end)}`)
// -> Vue writes the new value into the element, which drops the caret at the end unless it is put
// back; the plain input path never needs this because the DOM already holds what was emitted
requestAnimationFrame(() => {
el.selectionStart = start + 2
el.selectionEnd = start + 2
})
}
/*
Exposed so a host can put the caret in the field -- the scripts dialog focuses it on open. A method
rather than an `autofocus` prop, because focus is an action taken at a moment, not a state of the
component: a dialog that reopens with the same props has to be able to ask again.
*/
defineExpose({
focus() {
inputEl.value?.focus()
}
})
</script>
<style lang="scss">
/*
Unscoped, but every selector is under `.util-code-editor`. The highlighted markup arrives through
`v-html` and so carries no scope attribute, which a scoped rule could only reach through `:deep()`
on every line of the palette below.
*/
.util-code-editor {
min-height: 100px;
border: 1px solid #ccc;
position: relative;
overflow: hidden;
border-radius: 5px;
/* -> Same resting and focus edge as the other form controls; see `.w-input-control` */
border: 1px solid rgb(0 0 0 / 0.24);
background-color: #fff;
transition: border-color 0.36s cubic-bezier(0.4, 0, 0.2, 1);
/*
The text metrics live here as well as on the layers, for the `ch` in the gutter width: `ch` is
relative to the element's own font, so measured against the page font the gutter would be sized
for the wrong glyph.
*/
font-family: var(--font-mono);
font-size: 13px;
/*
The gutter's stripe, drawn on the container rather than inside the scrolling layer, so it stays
put while the numbers within it scroll -- which is what a gutter does.
*/
background-image: linear-gradient(
to right,
#f6f8fa 0,
#f6f8fa var(--util-code-editor-gutter),
rgb(0 0 0 / 0.09) var(--util-code-editor-gutter),
rgb(0 0 0 / 0.09) calc(var(--util-code-editor-gutter) + 1px),
transparent calc(var(--util-code-editor-gutter) + 1px)
);
&:focus-within {
border-color: var(--color-primary);
}
}
/*
-> A prop rather than a class the caller passes: the radius above is a single-class rule in an
unlayered stylesheet, so an override would come down to which file the bundler happened to emit
last. The component owning both states is the only version that cannot silently flip.
*/
.util-code-editor--square {
border-radius: 0;
}
/*
The two layers, and the reason this component works at all: any difference between them in a
property that affects glyph position -- font, size, line height, letter spacing, tab size, padding,
wrapping -- shows up as the highlight drifting out from under the text, further with every line.
Change one, change both.
*/
.util-code-editor-view,
.util-code-editor-input {
position: absolute;
inset: 0;
margin: 0;
/* -> Text starts clear of the gutter on BOTH layers, or the two disagree by the gutter's width */
padding: 8px 10px 8px calc(var(--util-code-editor-gutter) + 8px);
border: 0;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
tab-size: 2;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.util-code-editor-view {
overflow: hidden;
/* -> Clicks belong to the textarea underneath, including the click that places the caret */
pointer-events: none;
color: #24292f;
}
/*
One block per source line, each drawing its own number.
The number is an absolutely positioned pseudo-element on the line rather than a row in a parallel
gutter list, which is what keeps it correct under soft wrapping: a line that wraps to three rows is
one block three rows tall, and its number sits at the top of that block -- beside the row the line
actually starts on. A parallel list would drift by one row per wrap.
Being a pseudo-element also means the numbers cannot be selected or copied: the layer is
`aria-hidden` and unclickable, and the textarea underneath is what a selection actually addresses.
*/
.util-code-editor-line {
display: block;
position: relative;
/* -> An empty block would be zero rows high, so a blank line would collapse and take its number
with it. Matches the `line-height` above; the two have to move together. */
min-height: 1.5em;
&::before {
content: attr(data-line);
position: absolute;
/*
Out into the stripe the container paints. 8px of the offset only cancels the text's own left
padding, which lands the number hard against the divider; the other 10px is the gap that keeps
it off. No width or `text-align` needed -- an absolutely positioned box with `right` set and no
width shrinks to its content, so the digits right-align across lines by themselves, and because
the offset is constant while the gutter grows in `ch`, the gap holds at any number of digits.
*/
right: calc(100% + 18px);
color: rgb(0 0 0 / 0.38);
}
}
.util-code-editor-input {
overflow: auto;
resize: none;
outline: none;
background-color: transparent;
/* -> The text is read off the layer below; only the caret and the selection band come from here */
color: transparent;
caret-color: #24292f;
&::selection {
background-color: rgb(25 118 210 / 0.28);
}
}
> .CodeMirror {
height: 150px;
/*
Token palette. Two flat sets keyed off `body--dark` rather than a per-theme stylesheet: switching
appearance is then a class on <body>, which is what lets the three editors on admin/theme recolour
the instant the dark-mode toggle on that same page is thrown -- no fetch, no re-init, no JS.
*/
.util-code-editor {
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-doctag,
.hljs-formula {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #032f62;
}
.hljs-number,
.hljs-variable,
.hljs-template-variable,
.hljs-symbol,
.hljs-bullet,
.hljs-attr,
.hljs-meta {
color: #005cc5;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: #6f42c1;
}
.hljs-built_in,
.hljs-type,
.hljs-attribute,
.hljs-property,
.hljs-params {
color: #e36209;
}
.hljs-name,
.hljs-tag {
color: #22863a;
}
.hljs-deletion {
color: #b31d28;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 600;
}
}
body.body--dark {
.util-code-editor {
border-color: rgb(255 255 255 / 0.3);
background-color: $dark-5;
background-image: linear-gradient(
to right,
$dark-4 0,
$dark-4 var(--util-code-editor-gutter),
rgb(255 255 255 / 0.12) var(--util-code-editor-gutter),
rgb(255 255 255 / 0.12) calc(var(--util-code-editor-gutter) + 1px),
transparent calc(var(--util-code-editor-gutter) + 1px)
);
&:focus-within {
border-color: var(--color-primary);
}
}
.util-code-editor-view {
color: #e6edf3;
}
.util-code-editor-line::before {
color: rgb(255 255 255 / 0.34);
}
.util-code-editor-input {
caret-color: #e6edf3;
}
.util-code-editor {
.hljs-comment,
.hljs-quote {
color: #8b949e;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-doctag,
.hljs-formula {
color: #ff7b72;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #a5d6ff;
}
.hljs-number,
.hljs-variable,
.hljs-template-variable,
.hljs-symbol,
.hljs-bullet,
.hljs-attr,
.hljs-meta {
color: #79c0ff;
}
.hljs-title,
.hljs-section,
.hljs-selector-id,
.hljs-selector-class {
color: #d2a8ff;
}
.hljs-built_in,
.hljs-type,
.hljs-attribute,
.hljs-property,
.hljs-params {
color: #ffa657;
}
.hljs-name,
.hljs-tag {
color: #7ee787;
}
.hljs-deletion {
color: #ffa198;
}
}
}
</style>

@ -1,6 +1,16 @@
<template>
<!--
`relative` so a `<w-badge floating>` in the slot pins to THIS box. Without it the badge kept
looking for a positioned ancestor and found the surrounding card, which put the blueprint icon's
indicator dot in the card's far top-right corner instead of on the icon.
Deliberately NOT `overflow-hidden`, for the same reason: a floating badge is meant to overhang
the corner, and a clipping avatar would cut it in half. An image is instead clipped by taking
this box's own radius, as the avatar this replaces did -- see `.w-avatar > img` in
`css/tailwind.css`.
-->
<div
class="w-avatar inline-flex shrink-0 items-center justify-center overflow-hidden align-middle"
class="w-avatar relative inline-flex shrink-0 items-center justify-center align-middle"
:class="shapeClass"
:style="styles">
<w-icon v-if="icon" :name="icon" />

@ -1,6 +1,13 @@
<template>
<!--
`min-h-3` is what makes a badge with no label a round dot rather than a 12x4 sliver: the only
child here is often a tooltip, which renders nothing inline, so the box would otherwise be pure
padding -- 12px wide from `px-1.5` but only 4px tall. 12px matches the width, so with `rounded`
it comes out circular. A badge that does carry a label is taller than this on its own and is
unaffected.
-->
<div
class="w-badge inline-flex items-center justify-center px-1.5 py-0.5 text-xs leading-none font-medium"
class="w-badge inline-flex min-h-3 items-center justify-center px-1.5 py-0.5 text-xs leading-none font-medium"
:class="classes"
:style="styles">
<slot>{{ label }}</slot>

@ -1,13 +1,11 @@
<template>
<!--
`bg-[var(--color-white)]` rather than `bg-white`, and likewise for the text colour: Quasar defines
`.bg-white { background: #fff !important }` unlayered, which outranks every layered Tailwind rule
including the `dark:` variant -- so the plain utility would pin this surface to white in dark mode.
An arbitrary-value utility has a class name Quasar does not define, so the variant works.
Phase 5 can put the plain utilities back.
The surface colours live on `.w-card` in `@layer components` (see `css/tailwind.css`), not here.
As utilities they outranked the `bg-negative` / `bg-info` the admin pages tint their notice cards
with, since two background utilities are ordered within the layer rather than by who wrote them.
-->
<div
class="w-card relative rounded bg-[var(--color-white)] text-[var(--color-black)] dark:bg-dark-3 dark:text-white"
class="w-card relative rounded"
:class="[flat ? '' : 'shadow-card', horizontal ? 'flex flex-nowrap' : '']">
<slot />
</div>

@ -1,5 +1,5 @@
<template>
<div class="w-card-section" :class="horizontal ? 'flex flex-nowrap items-center px-4' : 'p-4'">
<div class="w-card-section" :class="horizontal ? 'flex flex-nowrap items-center' : 'p-4'">
<slot />
</div>
</template>
@ -9,7 +9,15 @@
* A band within a `WCard`. Provides the standard padding and nothing else.
*/
defineProps({
/** Row layout with horizontal-only padding, for sections that sit side by side. */
/**
* Row layout, for sections that sit side by side.
*
* Carries no padding of its own, matching the section this replaces. A horizontal section wraps
* other sections, and each of those already pads itself, so padding here adds to theirs rather
* than replacing it: the `px-4` it started with is why the icon in every notice banner sat 32px
* from the card edge instead of 16px. Zero is also what lets a horizontal card put an image flush
* against its edge.
*/
horizontal: {
type: Boolean,
default: false

@ -7,15 +7,29 @@
:aria-label="label ? undefined : ariaLabel"
:disabled="isDisabled"
class="w-checkbox w-unstyled inline-flex flex-nowrap items-center gap-2 rounded outline-offset-2 focus-visible:outline-2"
:class="isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer'"
:class="isDisabled ? 'w-checkbox--disabled pointer-events-none opacity-60' : 'cursor-pointer'"
@click="toggle">
<!--
A recessed well rather than an outlined square, matching the switch's track: the box reads as
cut into the surface, and ticking it fills that well rather than painting a flat block. The
rim and the shadows come from the style block; `size-5` and the radius stay here because they
are metrics rather than relief.
-->
<span
class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm border-2 transition-colors"
:class="isOn ? 'border-transparent text-white' : 'border-black/54 dark:border-white/70'"
class="w-checkbox__box inline-flex size-5 shrink-0 items-center justify-center rounded-sm transition-colors"
:class="isOn ? 'w-checkbox__box--on text-white' : ''"
:style="isOn ? { backgroundColor: `var(--color-${color})` } : undefined">
<w-icon v-if="isOn" name="mdi:check" size="0.9em" />
</span>
<span v-if="label" class="text-body2">{{ label }}</span>
<!--
Same treatment as the switch's label, down to the optical centring: `text-caption` rather than
`text-body2`, and no colour of its own so it takes the surface's, as the switch's does. The two
controls answer the same kind of question, so their labels should not be two different sizes.
`pt-px` is the compensation WToggle and WInput both make -- Roboto's ascent exceeds its descent,
so a line box centred by geometry sits above the middle of the control beside it.
-->
<span v-if="label" class="pt-px text-caption">{{ label }}</span>
</button>
</template>
@ -79,3 +93,86 @@ function toggle() {
}
}
</script>
<style scoped>
/*
Same soft relief as WToggle, and deliberately the same three devices, so a checkbox and a switch
sitting in one form look lit from the same place:
- a rim around the box, giving it an edge to catch the light instead of fading into the page;
- paired inset shadows, dark from the top left and light from the bottom right, which is what makes
it read as a well cut into the surface rather than a square drawn on it;
- a soft cast shadow below, lifting the control off the page.
Values are variables for the same reason they are there: a dark surface needs a faint highlight
rather than a strong one, or the relief turns to glare, and its rim has to be lighter than the well
where on a light surface it is white. Kept as its own set rather than shared with the switch --
a 20px square well needs shallower shadows than a 48px channel.
*/
.w-checkbox {
/*
Deeper than the switch's values, not shallower, which is the opposite of what the size suggests.
A 20px box only shows a couple of pixels of gradient either side of centre, so at the switch's
strength the recess disappeared at 1:1 and only read when magnified. Compared against three
stronger settings side by side at real size before landing here.
*/
--w-checkbox-well: #d5dbe4;
--w-checkbox-rim: #ffffff;
--w-checkbox-shadow: rgb(0 0 0 / 0.38);
--w-checkbox-highlight: rgb(255 255 255 / 1);
--w-checkbox-cast: rgb(0 0 0 / 0.2);
/* On the filled state the relief has to work over a saturated colour, not a pale grey */
--w-checkbox-fill-shadow: rgb(0 0 0 / 0.32);
--w-checkbox-fill-highlight: rgb(255 255 255 / 0.34);
/* Offsets and blur, kept in one place because the three box-shadows below must agree on them */
--w-checkbox-relief-offset: 2.5px;
--w-checkbox-relief-blur: 5px;
}
:global(body.body--dark .w-checkbox) {
--w-checkbox-well: #20252e;
--w-checkbox-rim: #39414f;
--w-checkbox-shadow: rgb(0 0 0 / 0.7);
/* -> Carries most of the relief here: a darker shadow on an already-dark well only muddies it */
--w-checkbox-highlight: rgb(255 255 255 / 0.11);
--w-checkbox-cast: rgb(0 0 0 / 0.5);
--w-checkbox-fill-shadow: rgb(0 0 0 / 0.5);
--w-checkbox-fill-highlight: rgb(255 255 255 / 0.26);
}
.w-checkbox__box {
background-color: var(--w-checkbox-well);
box-shadow:
0 0 0 2px var(--w-checkbox-rim),
inset var(--w-checkbox-relief-offset) var(--w-checkbox-relief-offset)
var(--w-checkbox-relief-blur) var(--w-checkbox-shadow),
inset calc(-1 * var(--w-checkbox-relief-offset)) calc(-1 * var(--w-checkbox-relief-offset))
var(--w-checkbox-relief-blur) var(--w-checkbox-highlight),
1px 3px 6px var(--w-checkbox-cast);
}
/*
Ticked: the well keeps its shape and gains a colour, so the tick reads as sitting IN the box. The
inset pair is restated at the heavier fill values -- the pale-grey ones disappear against a
saturated background -- while the rim and the cast shadow carry over unchanged.
*/
.w-checkbox__box--on {
box-shadow:
0 0 0 2px var(--w-checkbox-rim),
inset var(--w-checkbox-relief-offset) var(--w-checkbox-relief-offset)
var(--w-checkbox-relief-blur) var(--w-checkbox-fill-shadow),
inset calc(-1 * var(--w-checkbox-relief-offset)) calc(-1 * var(--w-checkbox-relief-offset))
var(--w-checkbox-relief-blur) var(--w-checkbox-fill-highlight),
1px 3px 6px var(--w-checkbox-cast);
}
/*
Disabled: flat, as the switch goes flat. The relief is what makes the control read as operable, so
it goes rather than merely dimming -- the wrapper's own opacity handles the fading.
*/
.w-checkbox--disabled .w-checkbox__box {
box-shadow:
0 0 0 2px var(--w-checkbox-rim),
inset 1px 1px 2px var(--w-checkbox-shadow);
}
</style>

@ -1,6 +1,12 @@
<template>
<!--
`leading-tight` rather than the inherited 1.5: a chip is a single nowrap line, so its height is
its line box plus padding, and body line-height made it noticeably taller than the text it wraps.
Tight still clears the glyph box, which matters because the label span clips its overflow -- any
less and descenders would be cut.
-->
<div
class="w-chip inline-flex max-w-full flex-nowrap items-center gap-1.5 align-middle"
class="w-chip inline-flex max-w-full flex-nowrap items-center gap-1.5 leading-tight align-middle"
:class="classes"
:style="styles"
:tabindex="clickable ? 0 : undefined"
@ -92,3 +98,38 @@ const styles = computed(() => ({
color: props.textColor ? `var(--color-${props.textColor})` : undefined
}))
</script>
<style scoped>
/*
An avatar inside a chip is sized by the chip, not by the 48px it takes standing on its own -- that
default made a 12px-font chip 56px tall, swallowing the row. Same reason WItemSection overrides it
for a flanking section, and the box has to be restated for the same reason too: the chip this
replaces derived an avatar's dimensions from its font size, ours does not.
Relative rather than the fixed px used for item metrics, because a chip's own font size is a prop
(10-16px) and the avatar has to track it.
`font-size: inherit` is load-bearing: it keeps the avatar on the CHIP's font size, so the `em`
lengths below are chip-ems. Were a font size set here instead, they would resolve against it.
1.25em matches the `leading-tight` line box on the tag above, so the avatar sits WITHIN the label's
line instead of setting the chip's height. Anything taller becomes the tallest thing in the box and
the chip grows around it, which is what made it stand a row tall.
A descendant selector, not a child one: everything slotted lands inside the truncating <span> in
the template, so an avatar written between the chip's tags is a grandchild. That also means the
chip's own `gap` does not separate it from the label -- they are inline siblings inside that span,
with nothing between them -- so the gap has to be a margin here.
*/
.w-chip :deep(.w-avatar) {
width: 1.25em;
height: 1.25em;
font-size: inherit;
margin-right: 0.45em;
}
/* -> Which leaves the glyph to scale separately, at a little over two thirds of the circle */
.w-chip :deep(.w-avatar > .w-icon) {
font-size: 0.85em;
}
</style>

@ -97,8 +97,13 @@ const TRANSITIONS = {
standard: 'w-dialog-scale'
}
/*
`p-3` on the right-hand viewport is what insets the side panel from the window edges instead of
butting it against them, which is also what lets its corners be rounded: a radius against the very
edge of the window reads as a rendering fault rather than a shape.
*/
const VIEWPORTS = {
right: 'items-stretch justify-end',
right: 'items-stretch justify-end p-3',
bottom: 'items-end justify-center',
standard: 'items-center justify-center p-4'
}
@ -108,7 +113,8 @@ const transitionName = computed(() => TRANSITIONS[props.position] ?? TRANSITIONS
const viewportClasses = computed(() => VIEWPORTS[props.position] ?? VIEWPORTS.standard)
const panelClasses = computed(() => [
props.position === 'right' ? 'h-full rounded-none' : '',
// -> `rounded`, not `rounded-none`: the panel no longer touches the window, see VIEWPORTS above
props.position === 'right' ? 'h-full rounded' : '',
props.position === 'bottom' ? 'rounded-b-none max-h-full rounded-t' : '',
props.position === 'standard' ? 'rounded max-h-full' : '',
props.fullHeight && props.position === 'standard' ? 'h-full' : '',
@ -206,13 +212,29 @@ onBeforeUnmount(() => {
transform: scale(0.94);
}
/*
A short slide in from the right, paired with a fade so a 32px move does not read as a pop.
32px and not `100%`: a percentage resolves against the panel's OWN width, and the side panel's
content arrives asynchronously -- so the width changed mid-transition, the percentage re-resolved
against the new value, and the panel lurched instead of sliding. A fixed distance cannot move
underneath the animation.
*/
.w-dialog-slide-right-enter-active,
.w-dialog-slide-right-leave-active {
transition: opacity 0.2s var(--ease-standard);
}
.w-dialog-slide-right-enter-from,
.w-dialog-slide-right-leave-to {
opacity: 0;
}
.w-dialog-slide-right-enter-active .w-dialog-panel,
.w-dialog-slide-right-leave-active .w-dialog-panel {
transition: transform 0.25s var(--ease-standard);
transition: transform 0.2s var(--ease-standard);
}
.w-dialog-slide-right-enter-from .w-dialog-panel,
.w-dialog-slide-right-leave-to .w-dialog-panel {
transform: translateX(100%);
transform: translateX(32px);
}
.w-dialog-slide-bottom-enter-active .w-dialog-panel,

@ -6,34 +6,40 @@
`v-show` rather than `v-if`: a `display: none` grid item generates no box, so the drawer's
column collapses to zero width exactly as if it were absent, while the element stays mounted so
its scroll position and any child state survive being closed.
its scroll position and any child state survive being closed. `Transition` drives `v-show` here,
which keeps that property -- it only defers the `display: none` until the slide has finished.
The width goes out as a custom property rather than an inline `width`, because the slide has to
be able to state its own geometry in terms of it and an inline style would outrank the class.
-->
<aside
v-show="isVisible"
class="w-drawer flex flex-col"
:class="[
side === 'right' ? 'w-drawer--right' : 'w-drawer--left',
isOverlay ? 'w-drawer--overlay fixed inset-y-0 z-40 shadow-dialog' : '',
isOverlay && side === 'right' ? 'right-0' : '',
isOverlay && side !== 'right' ? 'left-0' : '',
bordered ? borderClass : '',
dark ? 'text-white' : ''
]"
:style="{ width: `${width}px` }">
<teleport to="body">
<transition name="w-drawer-scrim">
<div
v-if="isVisible && isOverlay"
class="fixed inset-0 z-30 bg-black/40"
@click="$emit('update:modelValue', false)" />
</transition>
</teleport>
<slot />
</aside>
<transition name="w-drawer">
<aside
v-show="isVisible"
class="w-drawer flex flex-col"
:class="[
side === 'right' ? 'w-drawer--right' : 'w-drawer--left',
isOverlay ? 'w-drawer--overlay fixed inset-y-0 z-40 shadow-dialog' : '',
isOverlay && side === 'right' ? 'right-0' : '',
isOverlay && side !== 'right' ? 'left-0' : '',
bordered ? borderClass : '',
dark ? 'text-white' : ''
]"
:style="{ '--w-drawer-width': `${width}px` }">
<teleport to="body">
<transition name="w-drawer-scrim">
<div
v-if="isVisible && isOverlay"
class="fixed inset-0 z-30 bg-black/40"
@click="$emit('update:modelValue', false)" />
</transition>
</teleport>
<slot />
</aside>
</transition>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useMinWidth } from '@/composables/screen'
/**
@ -43,8 +49,8 @@ import { useMinWidth } from '@/composables/screen'
* - wide -- occupies its own grid column, pushing the page across
* - narrow -- overlays the page with a dismissable scrim
*
* `showIfAbove` reproduces the old behaviour where a drawer is open by default on a wide screen
* whatever its model value says, and obeys the model only once the viewport is narrow.
* `showIfAbove` reproduces the old behaviour where a drawer starts open on a wide screen whatever
* its model value says -- a starting state only, not a permanent override: see `defaultOpen`.
*/
const props = defineProps({
modelValue: {
@ -61,7 +67,7 @@ const props = defineProps({
default: 'left',
validator: (v) => ['left', 'right'].includes(v)
},
/** Force the drawer open on wide viewports, whatever the model says. */
/** Start the drawer open on wide viewports, whatever the model says at mount. */
showIfAbove: {
type: Boolean,
default: false
@ -98,11 +104,26 @@ const isWide = useMinWidth(1024)
const isOverlay = computed(() => !isWide.value)
const isVisible = computed(() =>
// -> On a wide screen `showIfAbove` wins; on a narrow one the model is the only thing that counts
isWide.value ? props.showIfAbove || props.modelValue : props.modelValue
/**
* `showIfAbove` only supplies the initial state, and stops applying the moment the model says
* anything of its own -- ORing it into `isVisible` for good made the model unable to CLOSE a drawer
* on a wide screen, which is how the editor lost its full width: it drops the site sidebar by
* flipping the model to false, and nothing happened.
*
* Cleared for good rather than per breakpoint, so widening the window mid-edit does not pop the
* sidebar back open.
*/
const defaultOpen = ref(props.showIfAbove)
watch(
() => props.modelValue,
() => {
defaultOpen.value = false
}
)
const isVisible = computed(() => props.modelValue || (isWide.value && defaultOpen.value))
const borderClass = computed(() =>
props.side === 'right'
? 'border-l border-black/12 dark:border-white/15'
@ -111,6 +132,10 @@ const borderClass = computed(() =>
</script>
<style scoped>
.w-drawer {
width: var(--w-drawer-width);
}
.w-drawer--left {
grid-area: ldrawer;
}
@ -123,6 +148,31 @@ const borderClass = computed(() =>
grid-area: unset;
}
/*
The slide is a negative outer margin, not a transform: the drawer's grid column is `auto`, so it is
sized by the item's MARGIN box, and pulling the margin to `-width` collapses the column to zero
while the border box keeps its full width and simply hangs off the edge. One animated property
therefore does both halves of the effect -- the panel slides out and the page grows into the space
it leaves, in step. A transform would have moved the panel but left the column at full width, and
animating `width` would have reflowed the nav on every frame instead of sliding it.
Nothing clips the panel on its way out, but nothing needs to: overflow past the left edge of the
document creates no scrollable area, and an overlaying drawer is `fixed`, which is outside the
document's overflow altogether.
*/
.w-drawer-enter-active,
.w-drawer-leave-active {
transition: margin 0.2s var(--ease-standard);
}
.w-drawer--left.w-drawer-enter-from,
.w-drawer--left.w-drawer-leave-to {
margin-left: calc(-1 * var(--w-drawer-width));
}
.w-drawer--right.w-drawer-enter-from,
.w-drawer--right.w-drawer-leave-to {
margin-right: calc(-1 * var(--w-drawer-width));
}
.w-drawer-scrim-enter-active,
.w-drawer-scrim-leave-active {
transition: opacity 0.2s var(--ease-standard);
@ -133,6 +183,8 @@ const borderClass = computed(() =>
}
@media (prefers-reduced-motion: reduce) {
.w-drawer-enter-active,
.w-drawer-leave-active,
.w-drawer-scrim-enter-active,
.w-drawer-scrim-leave-active {
transition-duration: 0.01ms;

@ -1,5 +1,14 @@
<template>
<form novalidate @submit.prevent="onSubmit">
<!--
A flex column, not the `<form>` default of `display: block`.
Six call sites already style themselves with `gap-2` / `gap-4` to space their fields, and `gap`
does nothing on a block container -- the toggles in the page-properties panel were sitting 1px
apart with `gap-4` set and ignored. Stacking is what every form here does, and flex children
stretch to full width exactly as block-level ones did, so this changes nothing for the call sites
that set no gap.
-->
<form novalidate class="flex flex-col" @submit.prevent="onSubmit">
<slot />
</form>
</template>

@ -65,6 +65,23 @@ const props = defineProps({
color: {
type: String,
default: null
},
/**
* Standard 8px gap on the side facing the text: `left` for an icon that precedes it, `right` for
* one that follows.
*
* Ported because the markup already asks for it -- a dozen headers and menu items pass `left` -- and
* without the prop it fell through as a bare attribute and spaced nothing, which is why the File
* Manager's title sat flush against its folder icon. A toolbar deliberately has no `gap` of its own
* (see WToolbar), so spacing there is each item's own business.
*/
left: {
type: Boolean,
default: false
},
right: {
type: Boolean,
default: false
}
})
@ -120,7 +137,15 @@ const bundled = computed(() => {
return { ...icon, transform: parts.length ? parts.join(' ') : undefined }
})
const colorClass = computed(() => (props.color ? `text-${props.color}` : undefined))
/*
Colour and the spacing props share one binding, since every branch of the template already applies
`colorClass` and the alternative is threading a second class through all three.
*/
const colorClass = computed(() => [
props.color ? `text-${props.color}` : '',
props.left ? 'mr-2' : '',
props.right ? 'ml-2' : ''
])
/**
* Both the inline SVG and `iconify-icon` size themselves in `em`, so one declaration covers them.

@ -7,8 +7,9 @@
still handles the growing.
-->
<div class="w-input max-w-full min-w-0">
<!-- -> Only the non-outlined variant still labels from above; see `hasFloatingLabel` -->
<label
v-if="label"
v-if="label && !hasFloatingLabel"
:for="inputId"
class="mb-1 block text-caption text-black/60 dark:text-white/70">
{{ label }}
@ -20,6 +21,34 @@
class="w-input-control flex flex-nowrap items-center gap-2 rounded"
:class="controlClasses"
:style="controlStyle">
<!--
The outline, as a fieldset whose legend cuts the notch the floated label sits in.
A fieldset is the only element that interrupts its own top border for a child, which is what
makes a real gap rather than a patch: the label needs no background of its own, so it works
over a white card, a grey `alt-card` section or a dark surface alike. The legend widens from
nothing to the label's width, and that transition IS the notch opening.
`aria-hidden`, and the accessible name stays on the real <label> below.
-->
<fieldset
v-if="hasFloatingLabel"
aria-hidden="true"
class="w-input-outline"
:style="outlineStyle">
<legend :class="isFloating ? 'w-input-outline-notch--open' : ''">
<span>{{ label }}</span>
</legend>
</fieldset>
<label
v-if="hasFloatingLabel"
:for="inputId"
class="w-input-float"
:class="[isFloating ? 'w-input-float--up' : '', floatColorClass]">
{{ label }}
</label>
<slot name="prepend" />
<!--
@ -98,7 +127,7 @@
</template>
<script setup>
import { computed, inject, ref, useId, watch } from 'vue'
import { computed, inject, ref, useId, useSlots, watch } from 'vue'
/**
* Text input.
@ -221,6 +250,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'keyup:enter', 'focus', 'blur'])
const slots = useSlots()
const inputEl = ref(null)
const inputId = useId()
const errorMessage = ref(null)
@ -257,10 +288,61 @@ const showsBottom = computed(
const describedBy = computed(() => (showsBottom.value ? `${inputId}-desc` : undefined))
/*
A label on an outlined field rides the outline, Material-style, instead of sitting above it: at rest
it stands in the middle of the field, and on focus or once there is a value it rises into the top
border. The non-outlined (filled) variant keeps its label above, since there is no outline to rise
into -- only three call sites in the app are labelled and not outlined.
*/
const hasFloatingLabel = computed(() => Boolean(props.label) && props.outlined)
const hasValue = computed(() => String(props.modelValue ?? '').length > 0)
/*
Floated whenever the resting position is unavailable or would collide.
A leading icon or prefix keeps it floated permanently: the resting label occupies the same place as
the field's text, which begins after those, so the two would overlap. MUI resolves this the same
way -- it asks the caller to pin the label up whenever there is a start adornment.
A placeholder likewise: it renders in the resting position the moment the field is empty.
*/
const hasLeadingAdornment = computed(() => Boolean(slots.prepend || props.prefix))
const isFloating = computed(
() => hasFocus.value || hasValue.value || Boolean(props.placeholder) || hasLeadingAdornment.value
)
const floatColorClass = computed(() => {
if (hasError.value) {
return 'text-negative'
}
return hasFocus.value ? 'text-primary' : 'text-black/60 dark:text-white/70'
})
const controlClasses = computed(() => [
props.dense ? 'min-h-9 px-2 py-1' : 'min-h-11 px-3 py-2',
props.outlined ? 'bg-transparent' : 'rounded-b-none bg-black/4 dark:bg-white/6',
props.disable || props.disabled ? 'pointer-events-none opacity-60' : ''
props.dense ? 'w-input-control--dense min-h-9 px-2 py-1' : 'min-h-11 px-3 py-2',
/*
An outlined field carries its own surface rather than borrowing whatever it sits on: white in
light mode, a darker well in dark mode. Transparent read fine on a white card and wrong
everywhere else -- a grey `alt-card` section, the admin page background, the profile card -- where
the field dissolved into its surroundings.
The dark value is translucent black rather than a fixed tone so it holds up on each of those
surfaces; the light one can be flat white because that IS the surface a field should present.
*/
props.outlined ? 'bg-white dark:bg-black/20' : 'rounded-b-none bg-black/4 dark:bg-white/6',
props.disable || props.disabled ? 'pointer-events-none opacity-60' : '',
/*
`relative` for the outline and the label. The margin is the room the floated label needs above the
control, and it is matched below so the field's box stays symmetric about the control -- otherwise
a top margin alone drops the control below the centre of whatever row it sits in, out of line with
a leading icon beside it.
Skipped underneath when a message line follows, since that already occupies the space and the gap
would only push the message away from the field it belongs to.
*/
hasFloatingLabel.value ? (showsBottom.value ? 'relative mt-2' : 'relative my-2') : ''
])
/**
@ -275,23 +357,36 @@ const controlClasses = computed(() => [
* arbitrary Tailwind class would be one more thing that has to survive the scanner and the
* Quasar cascade.
*/
const controlStyle = computed(() => {
const color = hasError.value
const frameColor = computed(() =>
hasError.value
? 'var(--color-negative)'
: hasFocus.value
? 'var(--color-primary)'
: isHovered.value && !props.disable && !props.disabled && !props.readonly
? 'var(--w-input-ring-hover)'
: 'var(--w-input-ring)'
// -> Error and focus both read as "active", and get the heavier 2px frame
const width = hasError.value || hasFocus.value ? 2 : 1
)
// -> Error and focus both read as "active", and get the heavier 2px frame
const frameWidth = computed(() => (hasError.value || hasFocus.value ? 2 : 1))
const controlStyle = computed(() => {
// -> A floating label needs a frame that can be interrupted, which the fieldset draws instead
if (hasFloatingLabel.value) {
return undefined
}
return {
boxShadow: props.outlined
? `inset 0 0 0 ${width}px ${color}`
: `inset 0 -${width}px 0 0 ${color}`
? `inset 0 0 0 ${frameWidth.value}px ${frameColor.value}`
: `inset 0 -${frameWidth.value}px 0 0 ${frameColor.value}`
}
})
const outlineStyle = computed(() => ({
borderColor: frameColor.value,
borderWidth: `${frameWidth.value}px`
}))
// METHODS
/**

@ -8,8 +8,9 @@
still handles the growing.
-->
<div class="w-select max-w-full min-w-0">
<!-- -> Only variants without an outline to rise into still label from above; see WInput -->
<label
v-if="label"
v-if="label && !hasFloatingLabel"
:for="selectId"
class="mb-1 block text-caption text-black/60 dark:text-white/70">
{{ label }}
@ -29,6 +30,7 @@
:aria-expanded="useInput ? undefined : String(isOpen)"
:aria-haspopup="useInput ? undefined : 'listbox'"
:aria-label="useInput || label ? undefined : ariaLabel"
:aria-labelledby="hasFloatingLabel && !useInput ? `${selectId}-label` : undefined"
:aria-readonly="!useInput && readonly ? true : undefined"
:aria-controls="!useInput && isOpen ? `${selectId}-listbox` : undefined"
:aria-activedescendant="
@ -42,6 +44,31 @@
@pointerenter="isHovered = true"
@pointerleave="isHovered = false"
@keydown="useInput || onKeydown($event)">
<!-- -> The notched outline and its label, exactly as WInput draws them; CSS is shared -->
<fieldset
v-if="hasFloatingLabel"
aria-hidden="true"
class="w-input-outline"
:style="outlineStyle">
<legend :class="isFloating ? 'w-input-outline-notch--open' : ''">
<span>{{ label }}</span>
</legend>
</fieldset>
<!--
A <span>, not a <label>: the control it names IS this element's ancestor for the plain
variant (a <button>), and a label cannot sit inside the thing it labels. The control points
at it with `aria-labelledby` instead, which names it without displacing the selected value
the way an `aria-label` would.
-->
<span
v-if="hasFloatingLabel"
:id="`${selectId}-label`"
class="w-input-float"
:class="[isFloating ? 'w-input-float--up' : '', floatColorClass]">
{{ label }}
</span>
<slot name="prepend" />
<!--
@ -70,6 +97,7 @@
:aria-expanded="String(isOpen)"
aria-haspopup="listbox"
:aria-label="label ? undefined : ariaLabel"
:aria-labelledby="hasFloatingLabel ? `${selectId}-label` : undefined"
:aria-controls="isOpen ? `${selectId}-listbox` : undefined"
:aria-activedescendant="isOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined"
:disabled="isDisabled"
@ -168,7 +196,7 @@
</template>
<script setup>
import { computed, inject, nextTick, ref, useId, watch } from 'vue'
import { computed, inject, nextTick, ref, useId, useSlots, watch } from 'vue'
import WChip from './WChip.vue'
import WMenu from './WMenu.vue'
import WSpinner from './WSpinner.vue'
@ -183,6 +211,8 @@ import WSpinner from './WSpinner.vue'
* Simplification: no free-text filtering or async search. Every current usage picks from a fixed,
* short list.
*/
const slots = useSlots()
const props = defineProps({
modelValue: {
type: null,
@ -618,13 +648,44 @@ const showsBottom = computed(
(!props.hideBottomSpace && (props.hint || props.rules.length > 0))
)
/*
Matches WInput: a labelled outlined field floats its label onto the outline. A `standout` control
carries its state in its fill and draws no outline at all, so there is nothing for a label to rise
into and it keeps its label above.
*/
const hasFloatingLabel = computed(() => Boolean(props.label) && props.outlined && !props.standout)
/*
Open counts as focus for this control. A selection, a placeholder or a leading icon each occupy the
resting position, so the label has to be up out of the way -- see the note in WInput.
*/
const isFloating = computed(
() =>
isOpen.value ||
hasSelection.value ||
Boolean(props.displayValue) ||
Boolean(props.placeholder) ||
Boolean(slots.prepend)
)
const floatColorClass = computed(() => {
if (errorMessage.value) {
return 'text-negative'
}
return isOpen.value ? 'text-primary' : 'text-black/60 dark:text-white/70'
})
const controlClasses = computed(() => [
props.dense ? 'min-h-9 px-2 py-1' : 'min-h-11 px-3 py-2',
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
standoutClass.value ??
(props.outlined ? 'bg-transparent' : 'rounded-b-none bg-black/4 dark:bg-white/6'),
(props.outlined ? 'bg-white dark:bg-black/20' : 'rounded-b-none bg-black/4 dark:bg-white/6'),
isDisabled.value ? 'pointer-events-none opacity-60' : '',
// -> readonly keeps full contrast; only the pointer affordance goes away
props.readonly ? 'cursor-default' : isDisabled.value ? '' : 'cursor-pointer'
props.readonly ? 'cursor-default' : isDisabled.value ? '' : 'cursor-pointer',
// -> Room for the floated label above, matched below so the control stays centred in its row; see
// the fuller note in WInput
hasFloatingLabel.value ? (showsBottom.value ? 'relative mt-2' : 'relative my-2') : ''
])
/** A standout control carries its state in its fill, so it takes no ring at all (see below). */
@ -643,22 +704,32 @@ const standoutClass = computed(() => {
* resting and active states. "Active" here is the open dropdown, which is this control's equivalent
* of focus. See WInput for why this is an inline style rather than classes.
*/
const controlStyle = computed(() => {
if (props.standout) {
return {}
}
const color = errorMessage.value
const frameColor = computed(() =>
errorMessage.value
? 'var(--color-negative)'
: isOpen.value
? 'var(--color-primary)'
: isHovered.value && !isDisabled.value && !props.readonly
? 'var(--w-input-ring-hover)'
: 'var(--w-input-ring)'
const width = errorMessage.value || isOpen.value ? 2 : 1
)
const frameWidth = computed(() => (errorMessage.value || isOpen.value ? 2 : 1))
const controlStyle = computed(() => {
// -> A floating label needs a frame it can interrupt, which the fieldset draws instead
if (props.standout || hasFloatingLabel.value) {
return {}
}
return {
boxShadow: props.outlined
? `inset 0 0 0 ${width}px ${color}`
: `inset 0 -${width}px 0 0 ${color}`
? `inset 0 0 0 ${frameWidth.value}px ${frameColor.value}`
: `inset 0 -${frameWidth.value}px 0 0 ${frameColor.value}`
}
})
const outlineStyle = computed(() => ({
borderColor: frameColor.value,
borderWidth: `${frameWidth.value}px`
}))
</script>

@ -25,10 +25,19 @@
</tr>
</thead>
<tbody>
<!--
`h-[52px]` is a floor, not a fixed height: on a table row CSS treats `height` as a minimum
and taller content still expands it. Without one, a row's height came purely from its
content, so the rows carrying action buttons stood 52px tall (a 36px flat button plus the
cell's 16px of vertical padding) while rows whose actions were hidden collapsed to 40px.
The table this replaces had the same floor at 48px, which is why the two never visibly
disagreed there; 52px is that same idea sized to what these cells actually hold, so no
existing row has to shrink to make them agree.
-->
<tr
v-for="(row, rowIndex) of visibleRows"
:key="rowKey ? row[rowKey] : rowIndex"
class="w-table__row">
class="w-table__row h-[52px]">
<!--
The cell slot is named per column (`body-cell-<name>`), which is what the pages already
provide, and receives one object so `#body-cell-x="props"` reads `props.value` /

@ -98,9 +98,14 @@ onMounted(() => {
Climb to the real control rather than stopping at the immediate parent. WBtn wraps its slot in
an inner <span> (so the label can be hidden while loading), so the naive parent would be that
span -- and clicking the button's padding, which is outside it, would do nothing.
`.w-badge` is in the list to STOP the climb, not to continue it: `closest` tests the element
itself first, so a tooltip written inside a badge resolves to that badge. Without it the climb
ran on to the enclosing `.w-item`, and the tooltip for a 12px indicator dot was measured against
the whole settings row -- appearing under the middle of the row rather than under the dot.
*/
const host = placeholderEl.value?.parentElement ?? null
triggerEl = host?.closest('button, a, .w-btn, .w-item') ?? host
triggerEl = host?.closest('button, a, .w-btn, .w-item, .w-badge') ?? host
if (!triggerEl) {
return
}

@ -105,8 +105,19 @@ export const dialogComponentEmits = ['ok', 'hide']
* const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
*
* and bind `v-model` / `@hide` on the root `<w-dialog>`.
*
* Pass `autofocus` to put the caret in a field once the dialog is up:
*
* const iptName = ref(null)
* useDialogComponent({ autofocus: () => iptName.value })
*
* A getter rather than the ref itself, so it can be written above the ref's own declaration and the
* component's sections stay in their usual order.
*
* @param {object} [opts]
* @param {() => { focus?: Function } | null} [opts.autofocus] Returns the control to focus on open.
*/
export function useDialogComponent() {
export function useDialogComponent({ autofocus } = {}) {
const { emit } = getCurrentInstance()
const dialogVisible = ref(false)
@ -115,6 +126,19 @@ export function useDialogComponent() {
onMounted(() =>
nextTick(() => {
dialogVisible.value = true
/*
Focus AFTER a second tick, which is the whole reason this lives here rather than in each
dialog: the field does not exist yet at this point. `WDialog` renders its panel only while
open, so flipping the flag above is what mounts the content -- a dialog calling `focus()` from
its own `onMounted` finds a null ref and silently does nothing, which is what every create
dialog in the app was doing.
*/
if (autofocus) {
nextTick(() => {
autofocus()?.focus()
})
}
})
)

@ -264,14 +264,30 @@ body::-webkit-scrollbar-thumb {
// DROPDOWN MENUS
// ------------------------------------------------------------------
// A gradient rather than one flat translucency: glassy at the top, fully opaque from halfway down.
//
// The flat version read well over a pale page and poorly over a dark or busy one, where whatever sat
// behind the menu showed through its text. Keeping the original alpha at the very top preserves the
// glass effect where the menu meets its trigger, and everything below the midpoint gets a solid
// surface to sit on -- which is where a menu's content actually is.
//
// The last stop carries on to the bottom edge, so a taller menu stays opaque rather than fading back
// out; only the position where it becomes solid is proportional.
.translucent-menu {
backdrop-filter: blur(10px) saturate(180%);
// `background-color: transparent` is load-bearing: WMenu paints an OPAQUE surface of its own with
// a `bg-[]` utility, and the flat rule this replaces worked by overriding that colour outright. A
// gradient is a background IMAGE, which composites over that colour rather than replacing it -- so
// on its own it changed nothing at all, the top included. Clearing the colour makes the gradient the
// only background layer, which is what lets the top be translucent.
@at-root .body--light & {
background-color: rgba(255, 255, 255, 0.8);
background-color: transparent;
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, #fff 50%);
}
@at-root .body--dark & {
background-color: rgba(theme.$dark, 0.7);
background-color: transparent;
background-image: linear-gradient(to bottom, rgba(theme.$dark, 0.7) 0%, theme.$dark 50%);
}
// The panel inside has to let the blur through. `!important` because WCard sets its background

@ -229,7 +229,9 @@
--color-indigo: #3f51b5;
--color-indigo-4: #7986cb;
--color-indigo-5: #5c6bc0;
--color-indigo-6: #3f51b5;
--color-indigo-7: #3949ab;
--color-indigo-9: #283593;
--color-amber: #ffc107;
@ -237,6 +239,7 @@
--color-amber-9: #ff8f00;
--color-brown: #795548;
--color-brown-4: #a1887f;
--color-brown-6: #795548;
--color-brown-9: #4e342e;
@ -353,6 +356,39 @@
box-sizing: border-box;
}
/*
The card surface, as a component class rather than utilities on `WCard`'s own tag, because it is
a DEFAULT that callers are expected to replace: the admin pages tint these cards into notices
with `bg-negative` / `bg-info` and `text-white`. Two background utilities on one element are
resolved by their order within the layer, not by which one the caller wrote, so as a utility this
one simply beat them and every notice came out white. Here it loses to any utility by layer
order, which is what makes `bg-*` on a `<w-card>` mean what it says.
*/
.w-card {
background-color: var(--color-white);
color: var(--color-black);
}
body.body--dark .w-card {
background-color: var(--color-dark-3);
color: var(--color-white);
}
/*
How an image in a `WAvatar` is cropped to the avatar's shape: by taking its radius and filling
the box, rather than by the avatar clipping its overflow. Same approach as the avatar this
replaces, and the reason is the same -- a clipping avatar would also cut off a floating badge,
which is meant to overhang the corner.
A DIRECT child only. An `img:`-style icon renders as `<i class="w-icon"><img></i>`, and that
inner image is sized by the icon, not by the avatar.
*/
.w-avatar > img {
border-radius: inherit;
width: 100%;
height: 100%;
}
/*
Shared frame for the form controls (WInput, WSelect). Lives here rather than in a component's
scoped style because both components use it, and a scoped rule would only match the one it was
@ -374,6 +410,143 @@
--w-input-ring-hover: var(--color-white);
}
/*
The Material notched outline, for an outlined field that carries a label: at rest the label stands
in the middle of the field, and on focus or once there is a value it rises into the top border.
Shared by WInput and WSelect, for the reason given above.
Drawn as a fieldset whose legend cuts the notch. A fieldset is the only element that interrupts
its own top border for a child, which is what makes a REAL gap rather than a patch -- so the label
needs no background of its own and works over a white card, a grey `alt-card` section or a dark
surface alike. The legend widening from nothing to the label's width IS the notch opening.
Three measurements interlock, so changing one means checking the others:
- the outline sits 6px above the control, putting its top border through the label's middle
- the outline's side padding plus the legend span's places the gap's left edge
- the label's own `left` has to land inside that gap, or it sits on solid border
*/
.w-input-outline {
position: absolute;
inset: -6px 0 0;
margin: 0;
padding: 0 0.5rem;
border-style: solid;
border-radius: inherit;
/* -> Clicks belong to the field underneath */
pointer-events: none;
min-inline-size: 0;
transition: border-color 0.36s cubic-bezier(0.4, 0, 0.2, 1);
}
.w-input-outline legend {
float: unset;
display: block;
width: auto;
height: 0.6875rem;
padding: 0;
font-size: 0.75rem;
/* -> Hidden, but still occupying the top border, which is what interrupts it */
visibility: hidden;
white-space: nowrap;
max-width: 0.01px;
transition: max-width 0.05s cubic-bezier(0.4, 0, 0.2, 1);
}
/*
3px, and it has to stay in step with the outline's side padding above.
This padding is what becomes the visible gap either side of the label, but it is measured around
the SPAN's text, which starts one border-width inboard of the label's own `left`. So the numbers
are chosen to make the span's text land exactly where the label's does -- outline padding + 1px
border + 3px equals the label's `left`, at both densities -- and then equal padding either side
gives an equal gap either side. Change one of the three and the gap goes lopsided.
*/
.w-input-outline legend > span {
display: inline-block;
padding: 0 0.1875rem;
opacity: 0;
}
/* -> Qualified with `legend` so it outranks the resting rule above rather than tying on specificity */
.w-input-outline legend.w-input-outline-notch--open {
max-width: 100%;
/* -> Opens a beat after the label starts moving, so the gap appears under it rather than ahead */
transition: max-width 0.1s 0.05s cubic-bezier(0.4, 0, 0.2, 1);
}
/*
The label itself. At rest it is centred in the field at the field's own text size, sitting exactly
where the text will appear; floated, it shrinks and rises onto the outline. Pointer events are off
so a click at rest reaches the field rather than the label.
*/
.w-input-float {
position: absolute;
left: 0.75rem;
top: 50%;
/*
Sized at the FLOATED size and scaled UP to rest, rather than the other way round.
The notch is cut from a legend laid out at this same 0.75rem, and a transform-scaled 0.875rem
label is not exactly the width of a 0.75rem one -- hinting differs -- so scaling down to float
left the gap wider than the text it was cut for. Floated is therefore the untransformed state,
where label and notch are the same string at the same size and cannot disagree; 1.1667 is 14/12,
which brings the resting label back to the field's own text size.
*/
font-size: 0.75rem;
transform: translateY(-50%) scale(1.1667);
/*
Scaling about the left edge at mid-height: the box is translated up by half its own height, and
growing it about that same midline keeps the resting text centred in the field and its left edge
aligned with where the field's text begins.
*/
transform-origin: left center;
max-width: calc(100% - 1.5rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
/*
`top` is in here deliberately. It carries the whole vertical move -- from the middle of the
field to its top edge -- and leaving it out is what made the label JUMP: only the transform and
the font size were animating, and the transform is identical in both states.
*/
transition:
top 0.2s var(--ease-standard),
transform 0.2s var(--ease-standard),
color 0.2s var(--ease-standard);
}
/*
Floated is simply the unscaled state.
Animating `font-size` instead would re-lay the text out on every frame, and since `translateY(-50%)`
resolves against the element's own height, that height changing under the transform made the label
drift as well as shrink. A scale is one operation on a box whose layout never changes.
*/
.w-input-float--up {
top: 0;
transform: translateY(-50%) scale(1);
}
/* -> A dense field's text starts 8px in rather than 12px, so its label and notch follow */
.w-input-control--dense .w-input-outline {
padding: 0 0.25rem;
}
.w-input-control--dense .w-input-float {
left: 0.5rem;
max-width: calc(100% - 1rem);
}
@media (prefers-reduced-motion: reduce) {
.w-input-outline,
.w-input-outline legend,
.w-input-outline legend.w-input-outline-notch--open,
.w-input-float {
transition-duration: 0.01ms;
}
}
/*
A rule that stays exactly one device pixel thick under fractional display scaling.

@ -15,7 +15,7 @@
<w-toolbar style="height: 64px">
<w-space />
<transition name="syncing">
<w-spinner v-show="commonStore.routerLoading" color="accent" size="24px" />
<w-spinner v-show="commonStore.routerLoading" color="accent" size="20px" />
</transition>
<w-btn
class="ml-4"
@ -570,6 +570,9 @@ onMounted(async () => {
padding-top: 16px;
}
}
// -> No `.w-card` rule here: WCard already paints its own surface with these exact colours, and an
// unlayered rule in an SFC stylesheet outranks every Tailwind utility however specific, so this
// restatement did nothing except stop the admin pages tinting a card with `bg-negative` / `bg-info`
.admin-container {
@at-root .body--light & {
background-color: $grey-1;
@ -577,15 +580,6 @@ onMounted(async () => {
@at-root .body--dark & {
background-color: $dark-4;
}
.w-card {
@at-root .body--light & {
background-color: #fff;
}
@at-root .body--dark & {
background-color: $dark-3;
}
}
}
.admin-overlay {

@ -10,7 +10,13 @@
:width="isSidebarMini ? 56 : 255"
:side="siteStore.theme.sidebarPosition === `right` ? `right` : `left`">
<div v-if="isSidebarMini" class="sidebar-mini flex flex-col items-stretch">
<w-btn class="py-4" flat icon="la:globe" color="white" aria-label="Switch Locale">
<w-btn
v-if="siteStore.locales.showMenu"
class="py-4"
flat
icon="la:globe"
color="white"
aria-label="Switch Locale">
<locale-selector-menu anchor="top right" self="top left" />
<w-tooltip anchor="center right" self="center left">Switch Locale</w-tooltip>
</w-btn>
@ -52,26 +58,25 @@
</div>
<template v-else>
<div class="sidebar-actions flex flex-nowrap items-stretch">
<w-btn
class="flex-1 px-2"
flat
dense
icon="la:globe"
color="blue-7"
text-color="custom-color"
:label="commonStore.locale"
:aria-label="commonStore.locale"
size="sm">
<locale-selector-menu :offset="[-5, 5]" />
</w-btn>
<w-separator vertical />
<!-- -> Both the button and its separator go, so Browse spans the row on its own -->
<template v-if="siteStore.locales.showMenu">
<w-btn
class="flex-1 px-2"
flat
dense
icon="la:globe"
:label="commonStore.locale"
:aria-label="commonStore.locale"
size="sm">
<locale-selector-menu :offset="[-5, 5]" />
</w-btn>
<w-separator vertical />
</template>
<w-btn
class="flex-1 px-2"
flat
dense
icon="la:sitemap"
color="blue-7"
text-color="custom-color"
label="Browse"
aria-label="Browse"
size="sm"
@ -89,12 +94,7 @@
</w-btn>
<w-separator vertical />
</template>
<w-btn
class="flex-1"
icon="la:bookmark"
label="Bookmarks"
flat
@click="notImplemented" />
<w-btn class="flex-1" icon="la:bookmark" label="Bookmarks" flat @click="notImplemented" />
</w-bar>
</template>
</w-drawer>
@ -200,6 +200,8 @@ function notImplemented() {
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
height: 38px;
// -> Where the two buttons above get their colour, so neither carries a `color` prop: `WBtn` emits
// an inline `color`, which would outrank this rule
.w-btn {
color: rgba(255, 255, 255, 0.8);
}

@ -198,11 +198,21 @@ watch(
// layout grid rather than measured at runtime.
min-height: calc(100% - 100px);
/*
A foreground to go with the background.
This card is a plain div rather than a WCard, and a WCard is what declares BOTH halves of a
surface. Setting only the background meant everything inside inherited the document's black --
row titles, input values, select values alike -- which is invisible against the dark surface.
The light value is the black it was already inheriting, so only dark mode changes.
*/
@at-root .body--light & {
background-color: #fff;
color: var(--color-black);
}
@at-root .body--dark & {
background-color: $dark-3;
color: var(--color-white);
}
}
@ -254,7 +264,7 @@ watch(
}
}
.text-header {
.section-header {
font-weight: 500;
font-size: 17px;
padding: 0 16px 6px 16px;

@ -30,7 +30,7 @@
:label="t(`common.actions.new`)"
:disable="!userStore.can(`manage:sites`)"
@click="newSite" />
<w-separator class="mx-2" vertical />
<w-separator vertical />
<w-btn
flat
color="primary"
@ -59,7 +59,7 @@
:label="t(`common.actions.new`)"
:disable="!userStore.can(`manage:users`)"
@click="newGroup" />
<w-separator class="mx-2" vertical />
<w-separator vertical />
<w-btn
flat
color="primary"
@ -88,7 +88,7 @@
:label="t(`common.actions.new`)"
:disable="!userStore.can(`manage:users`)"
@click="newUser" />
<w-separator class="mx-2" vertical />
<w-separator vertical />
<w-btn
flat
color="primary"

@ -49,7 +49,7 @@
<w-card class="bg-negative text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:exclamation-triangle" size="sm" />
<w-icon name="la:exclamation-triangle" size="lg" />
</w-card-section>
<w-card-section>
<span>{{ t('admin.flags.warn.label') }}</span>

@ -16,8 +16,8 @@
outlined
v-model="state.search"
dense
:class="dark.isActive ? `bg-dark` : `bg-white`">
<template #prepend><w-icon name="la:search" /></template>
:class="dark.isActive ? `bg-dark text-white` : `bg-white`">
<template #prepend><w-icon class="opacity-50" name="la:search" size="20px" /></template>
</w-input>
<w-btn
class="acrylic-btn mr-2"
@ -60,19 +60,13 @@
:loading="state.loading > 0"
:filter="state.search">
<template v-slot:body-cell-id="props">
<w-td :props="props"
><w-icon name="la:users" color="primary" size="sm"
/></w-td>
<w-td :props="props"><w-icon name="la:users" color="primary" size="sm" /></w-td>
</template>
<template v-slot:body-cell-name="props">
<w-td :props="props">
<div class="flex items-center">
<strong>{{ props.value }}</strong>
<w-icon
class="ml-2"
v-if="props.row.isSystem"
name="la:lock"
color="pink" />
<w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" />
</div>
</w-td>
</template>
@ -95,7 +89,7 @@
flat
:to="`/_admin/groups/` + props.row.id"
icon="la:pen"
color="indigo"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
no-caps />
<w-btn

@ -92,7 +92,7 @@
class="acrylic-btn"
icon="la:external-link-square-alt"
:label="t(`admin.icons.reference`)"
color="indigo"
:color="dark.isActive ? `indigo-4` : `indigo`"
flat
no-caps
padding="xs md"

@ -80,6 +80,17 @@
:aria-label="t(`admin.locale.forcePrefixHint`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="geography" />
<w-item-section>
<w-item-label>{{ t(`admin.locale.showMenu`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.locale.showMenuHint`) }}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle v-model="state.showMenu" :aria-label="t(`admin.locale.showMenuHint`)" />
</w-item-section>
</w-item>
</w-card>
<!-- ----------------------- -->
<!-- Active Locales -->
@ -157,6 +168,7 @@ const state = reactive({
locales: [],
primary: 'en',
forcePrefix: false,
showMenu: true,
active: []
})
@ -191,6 +203,7 @@ async function load() {
state.locales = sortBy(locales ?? [], ['nativeName', 'name'])
state.primary = site?.locales?.primary ?? 'en'
state.forcePrefix = site?.locales?.forcePrefix ?? false
state.showMenu = site?.locales?.showMenu ?? true
state.active = [...(site?.locales?.active ?? [])]
// -> The primary locale is always active, and its toggle is disabled to keep it that way
if (!state.active.includes(state.primary)) {
@ -224,7 +237,8 @@ async function save() {
locales: {
primary: state.primary,
active,
forcePrefix: state.forcePrefix
forcePrefix: state.forcePrefix,
showMenu: state.showMenu
}
}
}).json()

@ -195,7 +195,7 @@
<w-card class="bg-info text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:info-circle" size="sm" />
<w-icon name="la:info-circle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">{{
t('admin.login.providersVisbleWarning')

@ -212,7 +212,7 @@
<w-card class="bg-info text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:info-circle" size="sm" />
<w-icon name="la:info-circle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">{{
t('admin.mail.dkimHint')

@ -77,7 +77,16 @@
:loading="state.loading > 0">
<template v-slot:body-cell-id="props">
<w-td :props="props">
<w-spinner class="mr-2" color="indigo" size="xs" />
<!--
-> A calendar, not the plain `clock` the Upcoming tab uses: these rows are recurring
cron entries rather than single runs waiting on a time, and the two tabs should
not be telling them apart by nothing.
From `la` rather than `mdi` for weight: every other icon on this page is Line
Awesome, which is an outline set, and MDI's solid glyph sat noticeably heavier
beside them.
-->
<w-icon class="mr-2" name="la:calendar" color="indigo" size="sm" />
</w-td>
</template>
<template v-slot:body-cell-task="props">
@ -159,9 +168,7 @@
flat
:loading="state.loading > 0">
<template v-slot:body-cell-id="props">
<w-td :props="props"
><w-icon name="la:clock" color="primary" size="sm"
/></w-td>
<w-td :props="props"><w-icon name="la:clock" color="primary" size="sm" /></w-td>
</template>
<template v-slot:body-cell-task="props">
<w-td :props="props">

@ -73,10 +73,11 @@
<w-item-section>
<w-item-label>{{ t(`admin.search.dictOverrides`) }}</w-item-label>
<util-code-editor
class="admin-theme-cm my-2"
class="my-2"
v-model="state.config.dictOverrides"
language="json"
:min-height="250" />
:min-height="250"
:aria-label="t(`admin.search.dictOverrides`)" />
<w-item-label caption>
<i18n-t keypath="admin.search.dictOverridesHint" tag="span">
<span>{ "en": "english" }</span>

@ -53,7 +53,7 @@
<w-card class="bg-negative text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:exclamation-triangle" size="sm" />
<w-icon name="la:exclamation-triangle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">
<div>{{ t('admin.security.warn') }}</div>
@ -198,7 +198,7 @@
<w-card class="bg-info text-white rounded" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="flex-none pr-0">
<w-icon name="la:info-circle" size="sm" />
<w-icon name="la:info-circle" size="lg" />
</w-card-section>
<w-card-section class="text-caption">
<div>{{ t('admin.security.uploadsInfo') }}</div>

@ -86,7 +86,7 @@
flat
@click="editSite(site)"
icon="la:pen"
color="indigo"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
no-caps />
<w-btn
@ -110,6 +110,7 @@ import { useI18n } from 'vue-i18n'
import { nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { dialog } from '@/composables/dialog'
@ -121,6 +122,10 @@ import SiteActivateDialog from '../components/SiteActivateDialog.vue'
import SiteCreateDialog from '../components/SiteCreateDialog.vue'
import SiteDeleteDialog from '../components/SiteDeleteDialog.vue'
// COMPOSABLES
const dark = useDark()
// STORES
const adminStore = useAdminStore()

@ -77,7 +77,7 @@
<w-btn
class="acrylic-btn"
flat
color="purple"
:color="dark.isActive ? `purple-3` : `purple`"
@click="checkForUpdates"
:label="t(`admin.system.checkUpdate`)" />
</div>
@ -271,6 +271,7 @@
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading'
@ -282,6 +283,10 @@ import { cloneDeep } from 'lodash-es'
import ClipboardJS from 'clipboard'
import CheckUpdateDialog from '../components/CheckUpdateDialog.vue'
// COMPOSABLES
const dark = useDark()
// STORES
const siteStore = useSiteStore()

@ -294,10 +294,9 @@
<w-item>
<w-item-section>
<util-code-editor
class="admin-theme-cm"
ref="cmCSS"
v-model="state.config.injectCSS"
language="css" />
language="css"
:aria-label="t(`admin.theme.cssOverride`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
@ -311,10 +310,9 @@
<w-item>
<w-item-section>
<util-code-editor
class="admin-theme-cm"
ref="cmHead"
v-model="state.config.injectHead"
language="html" />
language="html"
:aria-label="t(`admin.theme.headHtmlInjection`)" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
@ -328,10 +326,9 @@
<w-item>
<w-item-section>
<util-code-editor
class="admin-theme-cm"
ref="cmBody"
v-model="state.config.injectBody"
language="html" />
language="html"
:aria-label="t(`admin.theme.bodyHtmlInjection`)" />
</w-item-section>
</w-item>
</w-card>
@ -790,14 +787,4 @@ onMounted(() => {
})
</script>
<style lang="scss">
.admin-theme-cm {
border: 1px solid #ccc;
border-radius: 5px;
overflow: hidden;
> .CodeMirror {
height: 150px;
}
}
</style>
<!-- -> The `.admin-theme-cm` rules that were here framed the old editor; UtilCodeEditor draws its own -->

@ -16,8 +16,8 @@
outlined
v-model="state.search"
dense
:class="dark.isActive ? `bg-dark` : `bg-white`">
<template #prepend><w-icon name="la:search" /></template>
:class="dark.isActive ? `bg-dark text-white` : `bg-white`">
<template #prepend><w-icon class="opacity-50" name="la:search" size="20px" /></template>
</w-input>
<w-btn
class="acrylic-btn mr-2"
@ -75,16 +75,8 @@
<w-td :props="props">
<div class="flex items-center">
<strong>{{ props.value }}</strong>
<w-icon
class="ml-2"
v-if="props.row.isSystem"
name="la:lock"
color="pink" />
<w-icon
class="ml-2"
v-if="!props.row.isActive"
name="la:ban"
color="pink" />
<w-icon class="ml-2" v-if="props.row.isSystem" name="la:lock" color="pink" />
<w-icon class="ml-2" v-if="!props.row.isActive" name="la:ban" color="pink" />
</div>
</w-td>
</template>
@ -119,7 +111,7 @@
flat
:to="`/_admin/users/` + props.row.id"
icon="la:pen"
color="indigo"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)"
no-caps />
<w-btn

@ -1,6 +1,6 @@
<template>
<w-page class="py-4">
<div class="text-header">{{ t('profile.auth') }}</div>
<div class="section-header">{{ t('profile.auth') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('profile.authInfo') }}</div>
<w-list class="mt-6" bordered separator>
@ -44,7 +44,7 @@
</w-list>
</div>
<div class="text-header mt-4">{{ t('profile.passkeys') }}</div>
<div class="section-header mt-4">{{ t('profile.passkeys') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('profile.passkeysIntro') }}</div>
<w-list v-if="state.passkeys?.length > 0" class="mt-6" bordered separator>

@ -1,6 +1,6 @@
<template>
<w-page class="py-4">
<div class="text-header">{{ t('profile.avatar') }}</div>
<div class="section-header">{{ t('profile.avatar') }}</div>
<div class="mt-10 flex flex-wrap gap-6">
<div class="flex-1 text-center">
<w-avatar

@ -1,6 +1,6 @@
<template>
<w-page class="py-4">
<div class="text-header">{{ t('profile.groups') }}</div>
<div class="section-header">{{ t('profile.groups') }}</div>
<div class="p-4">
<div class="text-body2">{{ t('profile.groupsInfo') }}</div>
<w-list class="mt-6" bordered separator>

@ -1,12 +1,12 @@
<template>
<w-page class="py-4">
<div class="text-header">{{ t('profile.myInfo') }}</div>
<div class="section-header">{{ t('profile.myInfo') }}</div>
<w-item v-if="!canEdit">
<w-item-section>
<w-card class="bg-negative rounded text-white" flat>
<w-card-section class="items-center" horizontal>
<w-card-section class="shrink-0 pr-0">
<w-icon name="la:ban" size="sm" />
<w-icon name="la:ban" size="lg" />
</w-card-section>
<w-card-section>
<span>{{ t('profile.editDisabledTitle') }}</span>
@ -99,7 +99,7 @@
:readonly="!canEdit" />
</w-item-section>
</w-item>
<div class="text-header mt-6">{{ t('profile.preferences') }}</div>
<div class="section-header mt-6">{{ t('profile.preferences') }}</div>
<w-item>
<blueprint-icon icon="timezone" />
<w-item-section>
@ -181,7 +181,7 @@
:aria-label="t(`profile.appearance`)" />
</w-item-section>
</w-item>
<div class="text-header mt-6">{{ t('profile.accessibility') }}</div>
<div class="section-header mt-6">{{ t('profile.accessibility') }}</div>
<w-item>
<blueprint-icon icon="visualy-impaired" />
<w-item-section>

@ -10,10 +10,12 @@
flat
round
@click="goBack">
<w-tooltip anchor="center left" self="center right">{{ t('common.actions.goback') }}</w-tooltip>
<w-tooltip anchor="center left" self="center right">{{
t('common.actions.goback')
}}</w-tooltip>
</w-btn>
<div class="layout-search-sd">
<div class="text-header">{{ t('search.sortBy') }}</div>
<div class="section-header">{{ t('search.sortBy') }}</div>
<w-list dense padding>
<w-item
v-for="item of orderByOptions"
@ -25,16 +27,22 @@
:name="item.icon"
:color="item.value === state.params.orderBy ? `primary` : ``" />
</w-item-section>
<w-item-section><w-item-label>{{ item.label }}</w-item-label></w-item-section>
<w-item-section
><w-item-label>{{ item.label }}</w-item-label></w-item-section
>
<w-item-section v-if="item.value === state.params.orderBy" side>
<w-icon
:name="state.params.orderByDirection === `desc` ? `mdi:transfer-down` : `mdi:transfer-up`"
:name="
state.params.orderByDirection === `desc`
? `mdi:transfer-down`
: `mdi:transfer-up`
"
size="sm"
color="primary" />
</w-item-section>
</w-item>
</w-list>
<div class="text-header">{{ t('search.filters') }}</div>
<div class="section-header">{{ t('search.filters') }}</div>
<div class="p-2">
<w-input
outlined
@ -58,7 +66,7 @@
multiple
hide-dropdown-icon
:aria-label="t(`search.filterTags`)"
@update:model-value="v => syncTags(v)"
@update:model-value="(v) => syncTags(v)"
:placeholder="state.selectedTags.length < 1 ? t(`search.filterTags`) : ``"
:loading="state.loading > 0">
<template #prepend><w-icon name="la:hashtag" size="xs" /></template>
@ -90,7 +98,18 @@
option-label="name"
options-dense
multiple
:display-value="t(`search.filterLocaleDisplay`, { n: state.params.filterLocale.length > 0 ? state.params.filterLocale[0].toUpperCase() : state.params.filterLocale.length }, state.params.filterLocale.length)">
:display-value="
t(
`search.filterLocaleDisplay`,
{
n:
state.params.filterLocale.length > 0
? state.params.filterLocale[0].toUpperCase()
: state.params.filterLocale.length
},
state.params.filterLocale.length
)
">
<template #prepend><w-icon name="la:language" size="xs" /></template>
</w-select>
<w-select
@ -118,8 +137,8 @@
</div>
</div>
<w-page>
<div class="text-header flex">
<span>{{t('search.results')}}</span>
<div class="section-header flex">
<span>{{ t('search.results') }}</span>
<w-space />
<transition name="slide-up" mode="out-in">
<i18n-t
@ -139,7 +158,9 @@
v-if="siteStore.search && siteStore.searchLastQuery">
<strong>{{ siteStore.searchLastQuery }}</strong>
</i18n-t>
<span v-else><em>{{ t('search.emptyQuery') }}</em></span>
<span v-else
><em>{{ t('search.emptyQuery') }}</em></span
>
</div>
<w-list separator>
<w-item v-for="item of state.results" clickable :to="`/` + item.path">
@ -157,7 +178,15 @@
</w-item-section>
<w-item-section side>
<div class="flex layout-search-itemtags">
<w-chip v-for="tag of item.tags" square color="secondary" text-color="white" icon="la:hashtag" size="sm">{{ tag }}</w-chip>
<w-chip
v-for="tag of item.tags"
square
color="secondary"
text-color="white"
icon="la:hashtag"
size="sm"
>{{ tag }}</w-chip
>
</div>
<div class="flex">
<div class="text-caption mr-2 text-grey">/{{ item.path }}</div>
@ -469,11 +498,20 @@ onUnmounted(() => {
align-items: stretch;
height: 100%;
/*
A foreground to go with the background, as `.layout-profile-card` needs for the same reason:
this card is a plain div rather than a WCard, and a WCard is what declares BOTH halves of a
surface. With only the background set, everything inside inherited the document's black --
headings, result titles, input and select values alike -- which is invisible on the dark one.
The light value is the black it was already inheriting, so only dark mode changes.
*/
@at-root .body--light & {
background-color: #fff;
color: var(--color-black);
}
@at-root .body--dark & {
background-color: $dark-3;
color: var(--color-white);
}
}
@ -494,9 +532,18 @@ onUnmounted(() => {
}
}
.text-header {
/*
Primary, matching the section headers on the profile screen.
This class was `text-header` until the rename, which collided with a GENERATED Tailwind utility:
the palette carries a `header` colour, so `text-header` also meant `color: var(--color-header)` --
#000 in both themes -- and that beat the card's inherited white. Renamed rather than fought, so the
colour below is now a plain design choice instead of an override.
*/
.section-header {
padding: 0.75rem 1rem;
font-weight: 500;
color: $primary;
@at-root .body--light & {
background-color: $grey-1;
@ -520,7 +567,7 @@ onUnmounted(() => {
.w-page {
flex: 1 1;
.text-header:first-child {
.section-header:first-child {
border-top-right-radius: 7px;
}

@ -4,6 +4,42 @@ import { sortBy } from 'es-toolkit/array'
import { useUserStore } from './user'
/**
* Turn the site's active locale CODES into the descriptors the UI reads.
*
* The API stores and returns `locales.active` as bare codes -- `['en']` -- because that is what the
* admin screen writes back and what the server validates against its installed set. Everything that
* DISPLAYS a locale, though, wants a name for it: the sidebar's locale menu, the language filter on
* the search screen, and the check in App.vue that a requested locale is one this site offers. They
* were each reading `.code` / `.language` / `.nativeName` off a string, so every one of them rendered
* blank -- the locale menu showed an empty row rather than "English".
*
* Resolved here rather than server-side so the write shape stays a plain list of codes, and with
* `Intl.DisplayNames` rather than a table, which gives the name in the reader's own language for
* free. Asking for a code's name IN that code is what produces the native spelling.
*/
function describeLocales(codes) {
const localized = new Intl.DisplayNames(undefined, { type: 'language' })
return (codes ?? []).map((code) => {
let name = code
let nativeName = code
try {
name = localized.of(code) ?? code
nativeName = new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code
} catch {
// -> An unregistered or malformed tag throws rather than returning nothing; show the code
}
return {
code,
// -> The bare language, for the two-letter badge beside each entry
language: code.split('-')[0],
name,
nativeName
}
})
}
export const useSiteStore = defineStore('site', {
state: () => ({
id: null,
@ -37,6 +73,7 @@ export const useSiteStore = defineStore('site', {
},
locales: {
primary: 'en',
showMenu: true,
active: [
{
code: 'en',
@ -133,9 +170,12 @@ export const useSiteStore = defineStore('site', {
markdown: siteInfo.editors.markdown.isActive,
wysiwyg: siteInfo.editors.wysiwyg.isActive
},
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the
// site config has never been saved with reads as its default rather than undefined
locales: {
primary: siteInfo.locales.primary,
active: sortBy(siteInfo.locales.active, ['nativeName', 'name'])
...this.locales,
...siteInfo.locales,
active: sortBy(describeLocales(siteInfo.locales.active), ['nativeName', 'name'])
},
tags: [],
tagsLoaded: false,

Loading…
Cancel
Save