mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
858 lines
27 KiB
858 lines
27 KiB
<template>
|
|
<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>
|
|
<w-space />
|
|
<transition name="syncing">
|
|
<w-spinner class="mr-2" v-show="state.loading > 0" color="accent" size="24px" />
|
|
</transition>
|
|
<w-btn
|
|
class="mr-2"
|
|
flat
|
|
rounded
|
|
color="white"
|
|
:aria-label="t(`common.actions.viewDocs`)"
|
|
icon="la:question-circle"
|
|
:href="siteStore.docsBase + `/admin/editors/markdown`"
|
|
target="_blank"
|
|
type="a" />
|
|
<w-btn-group push>
|
|
<w-btn
|
|
push
|
|
color="white"
|
|
text-color="grey-7"
|
|
:label="t(`common.actions.cancel`)"
|
|
:aria-label="t(`common.actions.cancel`)"
|
|
icon="la:times"
|
|
@click="close" />
|
|
<w-btn
|
|
push
|
|
color="positive"
|
|
text-color="white"
|
|
:label="t(`common.actions.save`)"
|
|
:aria-label="t(`common.actions.save`)"
|
|
icon="la:check"
|
|
:disabled="state.loading > 0"
|
|
@click="save" />
|
|
</w-btn-group>
|
|
</w-header>
|
|
<w-drawer class="bg-dark-6" :model-value="true" :width="295" dark>
|
|
<w-scroll-area class="nav-edit" :thumb-style="thumbStyle" :bar-style="barStyle">
|
|
<!--
|
|
The `q-list q-list--dense q-list--dark` this carried were the old framework's classes and
|
|
nothing defines them any more, which is why the rows had drifted to full height: the density
|
|
now comes from `dense` on each item, matching what NavSidebar renders.
|
|
-->
|
|
<sortable
|
|
class="nav-edit-list"
|
|
:list="state.items"
|
|
item-key="id"
|
|
:options="sortableOptions"
|
|
@end="updateItemPosition">
|
|
<template #item="{ element }">
|
|
<div
|
|
class="nav-edit-item nav-edit-item-header"
|
|
v-if="element.type === `header`"
|
|
:class="state.selected === element.id ? `is-active` : ``"
|
|
@click="setItem(element)">
|
|
<w-item-label class="text-caption" header>{{ element.label }}</w-item-label>
|
|
<w-space />
|
|
<w-item-section side>
|
|
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
|
|
</w-item-section>
|
|
</div>
|
|
<w-item
|
|
class="nav-edit-item nav-edit-item-link"
|
|
v-else-if="element.type === `link`"
|
|
dense
|
|
: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>
|
|
<w-item-section class="text-wordbreak-all">{{ element.label }}</w-item-section>
|
|
<w-item-section side>
|
|
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<div
|
|
class="nav-edit-item nav-edit-item-separator"
|
|
v-else
|
|
:class="state.selected === element.id ? `is-active` : ``"
|
|
@click="setItem(element)">
|
|
<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>
|
|
</div>
|
|
</template>
|
|
</sortable>
|
|
<div class="p-4 flex">
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
style="flex: 1"
|
|
flat
|
|
color="positive"
|
|
:label="t(`common.actions.add`)"
|
|
:aria-label="t(`common.actions.add`)"
|
|
icon="la:plus-circle">
|
|
<w-menu fit :offset="[0, 10]" auto-close>
|
|
<w-list separator>
|
|
<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-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-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-section>
|
|
</w-item>
|
|
</w-list>
|
|
</w-menu>
|
|
</w-btn>
|
|
<w-btn
|
|
class="ml-2 acrylic-btn"
|
|
flat
|
|
color="grey"
|
|
:aria-label="t(`common.actions.add`)"
|
|
icon="la:ellipsis-v"
|
|
padding="xs sm">
|
|
<w-menu :offset="[0, 10]" anchor="bottom right" self="top right" auto-close>
|
|
<w-list separator>
|
|
<w-item clickable @click="clearItems" :disable="state.items.length < 1">
|
|
<w-item-section side>
|
|
<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-section>
|
|
</w-item>
|
|
<!-- q-item(clickable) -->
|
|
<!-- q-item-section(side) -->
|
|
<!-- q-icon(name='mdi:import') -->
|
|
<!-- q-item-section -->
|
|
<!-- q-item-label Copy from... -->
|
|
</w-list>
|
|
</w-menu>
|
|
</w-btn>
|
|
</div>
|
|
</w-scroll-area>
|
|
</w-drawer>
|
|
<w-page-container>
|
|
<w-page class="p-4">
|
|
<template v-if="state.items.length < 1">
|
|
<w-card>
|
|
<w-card-section>
|
|
<w-icon class="mr-2" name="la:arrow-left" size="xs" />
|
|
<span>{{ t('navEdit.emptyMenuText') }}</span>
|
|
</w-card-section>
|
|
</w-card>
|
|
</template>
|
|
<template v-else-if="!state.selected">
|
|
<w-card>
|
|
<w-card-section>
|
|
<w-icon class="mr-2" name="la:arrow-left" size="xs" />
|
|
<span>{{ t('navEdit.noSelection') }}</span>
|
|
</w-card-section>
|
|
</w-card>
|
|
</template>
|
|
<template v-if="state.current.type === `header`">
|
|
<w-card class="pb-2">
|
|
<w-card-section>
|
|
<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-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.current.label"
|
|
dense
|
|
hide-bottom-space
|
|
:aria-label="t(`navEdit.label`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<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-section>
|
|
<w-item-section avatar>
|
|
<w-btn-toggle
|
|
v-model="state.current.visibilityLimited"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="visibilityOptions" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-item class="items-center" v-if="state.current.visibilityLimited">
|
|
<w-space />
|
|
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
|
|
<w-select
|
|
style="width: 100%; max-width: calc(50% - 34px)"
|
|
outlined
|
|
v-model="state.current.visibilityGroups"
|
|
:options="state.groups"
|
|
option-value="id"
|
|
option-label="name"
|
|
emit-value
|
|
map-options
|
|
dense
|
|
multiple
|
|
:aria-label="t(`navEdit.selectGroups`)" />
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="p-4 mt-4 flex">
|
|
<w-space />
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:trash-alt"
|
|
:label="t(`common.actions.delete`)"
|
|
color="negative"
|
|
padding="xs md"
|
|
@click="removeItem(state.current.id)" />
|
|
</w-card>
|
|
</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-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-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.current.label"
|
|
dense
|
|
hide-bottom-space
|
|
:aria-label="t(`navEdit.label`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<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-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.current.icon"
|
|
dense
|
|
:aria-label="t(`navEdit.icon`)">
|
|
<template #append>
|
|
<!--
|
|
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-btn>
|
|
</template>
|
|
</w-input>
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<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-section>
|
|
<w-item-section>
|
|
<w-input
|
|
outlined
|
|
v-model="state.current.target"
|
|
dense
|
|
hide-bottom-space
|
|
:aria-label="t(`navEdit.target`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<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-section>
|
|
<w-item-section avatar>
|
|
<w-toggle
|
|
v-model="state.current.openInNewWindow"
|
|
color="primary"
|
|
checked-icon="la:check"
|
|
unchecked-icon="la:times"
|
|
:aria-label="t(`navEdit.openInNewWindow`)" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-separator class="my-2" inset />
|
|
<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-section>
|
|
<w-item-section avatar>
|
|
<w-btn-toggle
|
|
v-model="state.current.visibilityLimited"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="visibilityOptions" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-item class="items-center" v-if="state.current.visibilityLimited">
|
|
<w-space />
|
|
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
|
|
<w-select
|
|
style="width: 100%; max-width: calc(50% - 34px)"
|
|
outlined
|
|
v-model="state.current.visibilityGroups"
|
|
:options="state.groups"
|
|
option-value="id"
|
|
option-label="name"
|
|
emit-value
|
|
map-options
|
|
dense
|
|
multiple
|
|
:aria-label="t(`navEdit.selectGroups`)" />
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="p-4 mt-4 flex items-start">
|
|
<div>
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
v-if="state.current.isNested"
|
|
flat
|
|
:label="t(`navEdit.unnestItem`)"
|
|
icon="mdi:format-indent-decrease"
|
|
color="teal"
|
|
padding="xs md"
|
|
@click="state.current.isNested = false" />
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
v-else
|
|
flat
|
|
:label="t(`navEdit.nestItem`)"
|
|
icon="mdi:format-indent-increase"
|
|
color="teal"
|
|
padding="xs md"
|
|
@click="state.current.isNested = true" />
|
|
<div class="text-caption mt-4 text-grey-7">{{ t('navEdit.nestingWarn') }}</div>
|
|
</div>
|
|
<w-space />
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:trash-alt"
|
|
:label="t(`common.actions.delete`)"
|
|
color="negative"
|
|
padding="xs md"
|
|
@click="removeItem(state.current.id)" />
|
|
</w-card>
|
|
</template>
|
|
<template v-if="state.current.type === `separator`">
|
|
<w-card class="pb-2">
|
|
<w-card-section>
|
|
<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-section>
|
|
<w-item-section avatar>
|
|
<w-btn-toggle
|
|
v-model="state.current.visibilityLimited"
|
|
push
|
|
glossy
|
|
no-caps
|
|
toggle-color="primary"
|
|
:options="visibilityOptions" />
|
|
</w-item-section>
|
|
</w-item>
|
|
<w-item class="items-center" v-if="state.current.visibilityLimited">
|
|
<w-space />
|
|
<div class="text-caption mr-4">{{ t('navEdit.selectGroups') }}</div>
|
|
<w-select
|
|
style="width: 100%; max-width: calc(50% - 34px)"
|
|
outlined
|
|
v-model="state.current.visibilityGroups"
|
|
:options="state.groups"
|
|
option-value="id"
|
|
option-label="name"
|
|
emit-value
|
|
map-options
|
|
dense
|
|
multiple
|
|
:aria-label="t(`navEdit.selectGroups`)" />
|
|
</w-item>
|
|
</w-card>
|
|
<w-card class="p-4 mt-4 flex">
|
|
<w-space />
|
|
<w-btn
|
|
class="acrylic-btn"
|
|
flat
|
|
icon="la:trash-alt"
|
|
:label="t(`common.actions.delete`)"
|
|
color="negative"
|
|
padding="xs md"
|
|
@click="removeItem(state.current.id)" />
|
|
</w-card>
|
|
</template>
|
|
</w-page>
|
|
</w-page-container>
|
|
</w-layout>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { useI18n } from 'vue-i18n'
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
|
|
|
import { loading } from '@/composables/loading'
|
|
import { notify } from '@/composables/notify'
|
|
|
|
import { usePageStore } from '@/stores/page'
|
|
import { useSiteStore } from '@/stores/site'
|
|
|
|
import { v4 as uuid } from 'uuid'
|
|
import { pick } from 'es-toolkit/object'
|
|
import { Sortable } from 'sortablejs-vue3'
|
|
import IconPickerDialog from '@/components/IconPickerDialog.vue'
|
|
import { apiErrorMessage } from '@/helpers/apiError'
|
|
|
|
// STORES
|
|
|
|
const pageStore = usePageStore()
|
|
const siteStore = useSiteStore()
|
|
|
|
// I18N
|
|
|
|
const { t } = useI18n()
|
|
|
|
// DATA
|
|
|
|
const state = reactive({
|
|
loading: 0,
|
|
selected: null,
|
|
items: [],
|
|
current: {
|
|
label: '',
|
|
icon: '',
|
|
target: '/',
|
|
openInNewWindow: false,
|
|
visibilityGroups: [],
|
|
visibilityLimited: false,
|
|
isNested: false
|
|
},
|
|
groups: []
|
|
})
|
|
|
|
/**
|
|
* The icon a new link item starts with.
|
|
*
|
|
* An Iconify reference, so that the icon picker opens on its search tab with this one selected, and so
|
|
* that the sidebar draws it through `w-icon` like every other item. Kept to `mdi`, a set seeded on
|
|
* every instance.
|
|
*/
|
|
const DEFAULT_LINK_ICON = 'mdi:text-box-outline'
|
|
|
|
const sortableOptions = {
|
|
handle: '.handle',
|
|
animation: 150
|
|
}
|
|
|
|
const visibilityOptions = [
|
|
{ value: false, label: t('navEdit.visibilityAll') },
|
|
{ value: true, label: t('navEdit.visibilityLimited') }
|
|
]
|
|
|
|
// COMPUTED
|
|
|
|
/**
|
|
* The menu being edited.
|
|
*
|
|
* The home page edits the site-wide menu — the one every other page inherits — which is why it goes
|
|
* through its resolved id rather than its own. Any other page owns a menu keyed by its own id, which
|
|
* the server creates on the first save.
|
|
*/
|
|
const navId = computed(() => (pageStore.isHome ? pageStore.navigationId : pageStore.id))
|
|
|
|
const thumbStyle = {
|
|
right: '2px',
|
|
borderRadius: '5px',
|
|
backgroundColor: '#FFF',
|
|
width: '5px',
|
|
opacity: 0.5
|
|
}
|
|
const barStyle = {
|
|
backgroundColor: '#000',
|
|
width: '9px',
|
|
opacity: 0.1
|
|
}
|
|
|
|
// METHODS
|
|
|
|
function setItem(item) {
|
|
state.selected = item.id
|
|
state.current = item
|
|
}
|
|
|
|
function addItem(type) {
|
|
const newItem = {
|
|
id: uuid(),
|
|
type,
|
|
visibilityGroups: [],
|
|
visibilityLimited: false
|
|
}
|
|
switch (type) {
|
|
case 'header': {
|
|
newItem.label = t('navEdit.header')
|
|
break
|
|
}
|
|
case 'link': {
|
|
newItem.label = t('navEdit.link')
|
|
newItem.icon = DEFAULT_LINK_ICON
|
|
newItem.target = '/'
|
|
newItem.openInNewWindow = false
|
|
newItem.isNested = false
|
|
break
|
|
}
|
|
}
|
|
state.items.push(newItem)
|
|
state.selected = newItem.id
|
|
state.current = newItem
|
|
}
|
|
|
|
function removeItem(id) {
|
|
state.items = state.items.filter((item) => item.id !== id)
|
|
state.selected = null
|
|
state.current = {}
|
|
}
|
|
|
|
function clearItems() {
|
|
state.items = []
|
|
state.selected = null
|
|
state.current = {}
|
|
}
|
|
|
|
function updateItemPosition(ev) {
|
|
const item = state.items.splice(ev.oldIndex, 1)[0]
|
|
state.items.splice(ev.newIndex, 0, item)
|
|
}
|
|
|
|
function close() {
|
|
siteStore.$patch({ overlay: '' })
|
|
}
|
|
|
|
async function loadGroups() {
|
|
state.loading++
|
|
try {
|
|
const groups = await API_CLIENT.get('groups').json()
|
|
state.groups = (groups ?? []).map((g) => ({ id: g.id, name: g.name }))
|
|
} catch (err) {
|
|
// -> Without the list, per-group visibility cannot be set, but the rest of the editor still works
|
|
notify({
|
|
type: 'warning',
|
|
message: t('navEdit.groupsFailed'),
|
|
caption: apiErrorMessage(err, 'An unexpected error occured.')
|
|
})
|
|
}
|
|
state.loading--
|
|
}
|
|
|
|
async function loadMenuItems() {
|
|
state.loading++
|
|
loading.show()
|
|
try {
|
|
// -> `full`, because the editor has to see items limited to groups the editor is not in: saving
|
|
// without them would delete them
|
|
const items = await API_CLIENT.get(`sites/${siteStore.id}/navigation/${navId.value}`, {
|
|
searchParams: { full: true }
|
|
}).json()
|
|
for (const item of items ?? []) {
|
|
state.items.push({
|
|
...pick(item, [
|
|
'id',
|
|
'type',
|
|
'label',
|
|
'icon',
|
|
'target',
|
|
'openInNewWindow',
|
|
'visibilityGroups'
|
|
]),
|
|
visibilityLimited: item.visibilityGroups?.length > 0
|
|
})
|
|
for (const child of item?.children ?? []) {
|
|
state.items.push({
|
|
...pick(child, [
|
|
'id',
|
|
'type',
|
|
'label',
|
|
'icon',
|
|
'target',
|
|
'openInNewWindow',
|
|
'visibilityGroups'
|
|
]),
|
|
visibilityLimited: child.visibilityGroups?.length > 0,
|
|
isNested: true
|
|
})
|
|
}
|
|
}
|
|
} catch (err) {
|
|
notify({
|
|
type: 'negative',
|
|
message: apiErrorMessage(err, 'An unexpected error occured.')
|
|
})
|
|
close()
|
|
}
|
|
loading.hide()
|
|
state.loading--
|
|
}
|
|
|
|
function cleanMenuItem(item, isNested = false) {
|
|
switch (item.type) {
|
|
case 'header': {
|
|
return {
|
|
...pick(item, ['id', 'type', 'label']),
|
|
visibilityGroups: item.visibilityLimited ? item.visibilityGroups : []
|
|
}
|
|
}
|
|
case 'link': {
|
|
return {
|
|
...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow']),
|
|
visibilityGroups: item.visibilityLimited ? item.visibilityGroups : [],
|
|
...(!isNested && { children: [] })
|
|
}
|
|
}
|
|
case 'separator': {
|
|
return {
|
|
...pick(item, ['id', 'type', 'label', 'icon', 'target', 'openInNewWindow']),
|
|
visibilityGroups: item.visibilityLimited ? item.visibilityGroups : []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
state.loading++
|
|
loading.show()
|
|
try {
|
|
const items = []
|
|
for (const item of state.items) {
|
|
if (item.isNested) {
|
|
if (items.length < 1 || items.at(-1)?.type !== 'link') {
|
|
throw new Error('One or more nested link items are not under a parent link!')
|
|
}
|
|
items[items.length - 1].children.push(cleanMenuItem(item, true))
|
|
} else {
|
|
items.push(cleanMenuItem(item))
|
|
}
|
|
}
|
|
|
|
// -> The mode goes with the items: saving a menu for a page that only inherits would store items
|
|
// nothing points at
|
|
const resp = await API_CLIENT.put(`sites/${siteStore.id}/navigation/pages/${pageStore.id}`, {
|
|
json: {
|
|
mode: siteStore.overlayOpts.mode ?? pageStore.navigationMode,
|
|
items
|
|
}
|
|
}).json()
|
|
// -> The API client does not throw on 400, so a refusal comes back as a parsed error
|
|
if (resp?.ok === false) {
|
|
throw new Error(resp.message || 'An unexpected error occured.')
|
|
}
|
|
notify({
|
|
type: 'positive',
|
|
message: t('navEdit.saveSuccess')
|
|
})
|
|
pageStore.$patch({
|
|
navigationMode: resp.navigationMode,
|
|
navigationId: resp.navigationId ?? null
|
|
})
|
|
// -> Redraw the sidebar from what was just saved, rather than waiting for a navigation
|
|
await siteStore.fetchNavigation(resp.navigationId ?? navId.value)
|
|
close()
|
|
} catch (err) {
|
|
notify({
|
|
type: 'negative',
|
|
message: apiErrorMessage(err, 'An unexpected error occured.')
|
|
})
|
|
}
|
|
loading.hide()
|
|
state.loading--
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadMenuItems()
|
|
loadGroups()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
siteStore.overlayOpts = {}
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@use 'sass:color';
|
|
|
|
/*
|
|
Light ink on an always-dark surface.
|
|
|
|
This drawer is dark whatever the site theme is, but the shared components' own dark treatments are
|
|
`dark:` variants -- keyed off `body.body--dark`, i.e. the APP theme. On a light-themed site their
|
|
light-mode colours therefore applied here: `WItemLabel`'s header variant resolved to black at 54%
|
|
and `WItemSection`'s side variant likewise, which on `dark-6` is invisible. WDrawer's `dark` prop
|
|
covers plain inherited text, not a component that states a colour of its own, so each one that does
|
|
is restated here at the value its dark variant would have used.
|
|
*/
|
|
.nav-edit {
|
|
height: 100%;
|
|
|
|
.handle {
|
|
cursor: grab;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
/*
|
|
Same padding NavSidebar gives its own headings: `WItemLabel`'s uniform `p-4` made this row 52px
|
|
against the sidebar's 40px, so a heading looked considerably heavier here than the thing being
|
|
edited.
|
|
*/
|
|
.w-item-label--header {
|
|
color: rgba(255, 255, 255, 0.7);
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
/* -> A rule between nav items is content here, not trim: 15% white is too faint to aim at */
|
|
.nav-edit-item-separator .w-separator {
|
|
--w-hairline-color: rgb(255 255 255 / 0.32);
|
|
}
|
|
}
|
|
|
|
.nav-edit-item {
|
|
position: relative;
|
|
&.is-active {
|
|
background-color: $blue-8;
|
|
}
|
|
|
|
&.sortable-chosen {
|
|
background-color: $blue-5;
|
|
}
|
|
}
|
|
|
|
.nav-edit-item-header {
|
|
display: flex;
|
|
cursor: pointer;
|
|
}
|
|
.nav-edit-item-link {
|
|
&.is-nested {
|
|
border-left: 10px solid $dark-1;
|
|
background-color: $dark-4;
|
|
&.is-active {
|
|
background-color: $primary;
|
|
}
|
|
|
|
& + div:not(.is-nested) {
|
|
&::before {
|
|
content: '';
|
|
display: 'block';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-style: solid;
|
|
border-color: $dark-1 transparent transparent $dark-1;
|
|
border-width: 10px 10px 10px 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
&:not(.is-nested) + &.is-nested {
|
|
&::before {
|
|
content: '';
|
|
display: 'block';
|
|
position: absolute;
|
|
top: -10px;
|
|
left: -10px;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-style: solid;
|
|
border-color: transparent transparent $dark-1 $dark-1;
|
|
border-width: 0 10px 10px 0;
|
|
}
|
|
}
|
|
}
|
|
.nav-edit-item-separator {
|
|
display: flex;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-edit-item-header,
|
|
.nav-edit-item-separator {
|
|
& + .nav-edit-item-link.is-nested {
|
|
background-color: $negative !important;
|
|
border-left-color: color.adjust($negative, $lightness: -10%) !important;
|
|
|
|
& + div:not(.is-nested) {
|
|
&::before {
|
|
display: none !important;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.nav-edit-list {
|
|
.nav-edit-item-separator + .nav-edit-item-header > .w-item-label {
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.is-nested:first-child {
|
|
background-color: $negative !important;
|
|
border-left-color: color.adjust($negative, $lightness: -10%) !important;
|
|
|
|
& + div:not(.is-nested) {
|
|
&::before {
|
|
display: none !important;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|