-drag menu continua

This commit is contained in:
Surya Paolo
2025-09-16 22:18:21 +02:00
parent 95fa0b9ac0
commit e40bf8b73d
16 changed files with 746 additions and 1233 deletions

View File

@@ -12,11 +12,6 @@
height: 0.5px; height: 0.5px;
} }
.router-link-active {
color: #027be3;
background-color: #dadada !important;
border-right: 2px solid #027be3;
}
.list-label:first-child { .list-label:first-child {
line-height: 20px; line-height: 20px;

View File

@@ -1,16 +1,7 @@
import { computed, defineComponent, PropType } from 'vue'; import { tools } from 'app/src/store/Modules/tools';
import { defineComponent, PropType, computed } from 'vue';
const norm = (path: string): string => const norm = (path: string): string => tools.norm(path);
path
.trim()
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
const toNormPath = (p: any): string => {
if (!p) return '';
if (typeof p === 'string') return norm(p);
return norm(p.path || '');
};
export default defineComponent({ export default defineComponent({
name: 'CMenuItem', name: 'CMenuItem',
@@ -21,52 +12,68 @@ export default defineComponent({
getmymenuclass: { type: Function, required: true }, getmymenuclass: { type: Function, required: true },
getimgiconclass: { type: Function, required: true }, getimgiconclass: { type: Function, required: true },
clBase: { type: String, default: '' }, clBase: { type: String, default: '' },
level: { type: Number, default: 1 }, mainMenu: { type: Boolean, default: false },
level: { type: Number, default: 0 },
}, },
setup(props) { emits: ['click'],
const getmenuByPath = (input: any, depth = 0): any => { setup(props, { emit }) {
if (depth > 5) return null; // Funzione per ottenere i figli ordinati
const path = toNormPath(input);
if (!path) return null;
let page = props.tools.getmenuByPath ? props.tools.getmenuByPath(path) : null;
if (!page) return null;
// Evita loop
const selfPath = toNormPath(props.item);
if (selfPath && path === selfPath) return null;
return page;
};
const children = computed(() => { const children = computed(() => {
const item: any = props.item; const item: any = props.item;
const r2 = Array.isArray(item.routes2) ? item.routes2 : []; const r2 = Array.isArray(item.routes2) ? item.routes2 : [];
const sm = Array.isArray(item.sottoMenu) ? item.sottoMenu : []; const sm = Array.isArray(item.sottoMenu) ? item.sottoMenu : [];
return [...r2, ...sm] let ris = null;
.map((ref) =>
typeof ref === 'string' || !ref.path ? getmenuByPath(ref, props.level) : ref if (r2.length > 0) {
) ris = [...r2]
.filter(Boolean) .map((rec) => {
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0)); const norm = tools.norm(rec.path);
return props.tools.getmenuByPath(norm);
})
.filter(Boolean)
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0));
} else {
ris = [...sm]
.map((path) => {
const norm = tools.norm(path);
return props.tools.getmenuByPath(norm);
})
.filter(Boolean)
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0));
}
// console.log('RIS', ris);
return ris;
}); });
// Determina se ha figli
const hasChildren = computed(() => children.value.length > 0); const hasChildren = computed(() => children.value.length > 0);
// Ottiene l'icona appropriata
const icon = computed(() => { const icon = computed(() => {
const item: any = props.item; const item: any = props.item;
return item.materialIcon || item.icon || 'far fa-file-alt'; return item.materialIcon || item.icon || 'far fa-file-alt';
}) });
// Gestisce il click
function makeClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
const route = props.getroute(props.item);
if (route) {
window.location.href = typeof route === 'string' ? route : route.path;
}
emit('click', props.item);
}
return { return {
children, children,
hasChildren, hasChildren,
icon, icon,
makeClick: () => { makeClick,
// niente per ora
},
}; };
}, },
}); });

View File

@@ -1,12 +1,11 @@
<template> <template>
<div :style="{ paddingLeft: `${level * 4}px` }"> <div :style="{ paddingLeft: `${level * 4}px` }">
<q-separator v-if="item.isseparator" /> <q-separator v-if="item.isseparator" />
<!-- Nodo con figli --> <!-- Nodo con figli -->
<q-expansion-item <q-expansion-item
v-else-if="hasChildren" v-else-if="hasChildren"
:header-class="getmymenuclass(item)" :header-class="getmymenuclass(item)"
:icon="item.materialIcon" :icon="icon"
:label="tools.getLabelByItem(item)" :label="tools.getLabelByItem(item)"
expand-icon="fas fa-chevron-down" expand-icon="fas fa-chevron-down"
active-class="my-menu-active" active-class="my-menu-active"
@@ -26,7 +25,7 @@
<!-- Foglia --> <!-- Foglia -->
<router-link v-else :to="getroute(item)"> <router-link v-else :to="getroute(item)">
<q-item clickable :to="getroute(item)" @click="makeClick" active-class="my-menu-active"> <q-item clickable :to="getroute(item)" active-class="my-menu-active">
<q-item-section thumbnail> <q-item-section thumbnail>
<q-avatar <q-avatar
:icon="item.materialIcon" :icon="item.materialIcon"

View File

@@ -1,80 +1,205 @@
import { defineComponent, ref, computed, watch } from 'vue' // IconPicker.ts
import { defineComponent, ref, computed, watch } from 'vue';
// Tipo per il valore del modello
interface IconValue {
icon?: string;
size?: string;
}
export default defineComponent({ export default defineComponent({
name: 'IconPicker', name: 'IconPicker',
props: { props: {
modelValue: { type: String, default: '' }, modelValue: {
type: Object as () => IconValue,
default: () => ({})
},
icons: { icons: {
type: Array as () => string[], type: Array as () => string[],
// SOLO Font Awesome 5 (free)
default: () => [ default: () => [
'fas fa-home', 'fas fa-chart-bar',
'fas fa-book',
'fas fa-star',
'fas fa-heart',
'fas fa-user',
'fas fa-cog',
'fas fa-info-circle',
'far fa-newspaper',
'fas fa-list',
'fas fa-tags',
'fas fa-chart-line',
'fas fa-briefcase', 'fas fa-briefcase',
'fas fa-calendar',
'fas fa-envelope', 'fas fa-envelope',
'fas fa-image',
'fas fa-list',
'fas fa-tasks',
'fas fa-th-large',
'fas fa-th-list',
'fas fa-thumbs-up',
'fas fa-thumbs-down',
'fas fa-star',
'fas fa-star-half-alt',
'fas fa-heart',
'fas fa-comment',
'fas fa-bell',
'fas fa-search',
'fas fa-filter',
'fas fa-sort',
'fas fa-plus',
'fas fa-minus',
'fas fa-edit',
'fas fa-trash',
'fas fa-copy',
'fas fa-download',
'fas fa-upload',
'fas fa-share',
'fas fa-print',
'fas fa-eye',
'fas fa-lock',
'fas fa-unlock',
'fas fa-key',
'fas fa-shield-alt',
'fas fa-globe',
'fas fa-language',
'fas fa-flag',
'fas fa-map-marker-alt',
'fas fa-phone', 'fas fa-phone',
'fas fa-globe-europe' 'fas fa-mobile-alt',
'fas fa-clock',
'fas fa-history',
'fas fa-undo',
'fas fa-redo',
'fas fa-exclamation-triangle',
'fas fa-check',
'fas fa-times',
'fas fa-info-circle',
'fas fa-question-circle',
'fas fa-exclamation-circle',
'fas fa-check-circle',
'fas fa-times-circle',
'fas fa-info',
'fas fa-question',
'fas fa-bug',
'fas fa-wrench',
'fas fa-cogs',
'fas fa-database',
'fas fa-server',
'fas fa-code',
'fas fa-terminal',
'fas fa-file',
'fas fa-file-image',
'fas fa-file-audio',
'fas fa-file-video',
'fas fa-file-pdf',
'fas fa-file-word',
'fas fa-file-excel',
'fas fa-file-powerpoint',
'fas fa-file-archive',
'fas fa-file-code',
'fas fa-folder',
'fas fa-folder-open',
'fas fa-folder-plus',
'fas fa-folder-minus',
'fas fa-tags',
'fas fa-tag',
'fas fa-bookmark',
'fas fa-book',
'fas fa-newspaper',
'fas fa-magazine',
'fas fa-camera',
'fas fa-video',
'fas fa-music',
'fas fa-paint-brush',
'fas fa-palette',
'fas fa-font',
'fas fa-bold',
'fas fa-italic',
'fas fa-underline',
'fas fa-align-left',
'fas fa-align-center',
'fas fa-align-right',
'fas fa-list-ul',
'fas fa-list-ol',
'fas fa-outdent',
'fas fa-indent',
'fas fa-link',
'fas fa-unlink',
'fas fa-paperclip',
'fas fa-paper-plane',
'fas fa-reply',
'fas fa-reply-all',
'fas fa-forward',
'fas fa-share-square',
] ]
},
// Dimensioni disponibili
sizeOptions: {
type: Array as () => string[],
default: () => ['16px', '20px', '24px', '32px', '48px']
} }
}, },
emits: ['update:modelValue', 'change'], emits: {
setup (props, { emit }) { 'update:modelValue': (val: IconValue) => true,
const local = ref(props.modelValue) // testo inserito/valore corrente 'change': (val: IconValue) => true
const dialog = ref(false) // mostra/nasconde il picker },
const keyword = ref('') // filtro dentro il dialog setup(props, { emit }) {
const localIcon = ref(props.modelValue?.icon || '');
const localSize = ref(props.modelValue?.size || '20px');
const dialog = ref(false);
const keyword = ref('');
watch(() => props.modelValue, v => { local.value = v }) // Sincronizza i valori locali quando cambia modelValue
watch(
() => props.modelValue,
(val) => {
if (val) {
localIcon.value = val.icon || '';
localSize.value = val.size || '20px';
}
},
{ immediate: true }
);
const filteredIcons = computed(() => { const filteredIcons = computed(() => {
const k = keyword.value.trim().toLowerCase() const k = keyword.value.trim().toLowerCase();
if (!k) return props.icons if (!k) return props.icons;
return props.icons.filter(i => return props.icons.filter(
i.toLowerCase().includes(k) || (i) =>
// match anche sul nome “breve” (es: 'home') i.toLowerCase().includes(k) ||
i.toLowerCase().split(' ').some(cls => cls.startsWith('fa-') && cls.includes(k)) i
) .toLowerCase()
}) .split(' ')
.some((cls) => cls.startsWith('fa-') && cls.includes(k))
);
});
function select (val: string) { function updateValue() {
// applica la stringa così comè; nessun fallback const newVal: IconValue = {
emit('update:modelValue', val || '') icon: localIcon.value || undefined,
emit('change', val || '') size: localSize.value || undefined
};
emit('update:modelValue', newVal);
emit('change', newVal);
} }
function choose (ic: string) { function choose(ic: string) {
local.value = ic || '' localIcon.value = ic;
select(local.value) updateValue();
dialog.value = false dialog.value = false;
} }
function clear () { function clear() {
local.value = '' localIcon.value = '';
select('') updateValue();
} }
function openPicker () { function openPicker() {
keyword.value = '' keyword.value = '';
dialog.value = true dialog.value = true;
} }
return { return {
local, localIcon,
localSize,
dialog, dialog,
keyword, keyword,
filteredIcons, filteredIcons,
select, sizeOptions: props.sizeOptions,
choose, choose,
clear, clear,
openPicker openPicker,
} updateValue
};
} }
}) });

View File

@@ -1,16 +1,23 @@
<!-- IconPicker.vue -->
<template> <template>
<div class="q-gutter-sm"> <div class="q-gutter-sm">
<!-- Campo principale -->
<div class="row items-center q-col-gutter-sm"> <div class="row items-center q-col-gutter-sm">
<div class="col-12"> <div class="col-12">
<q-input <q-input
v-model="local" v-model="localIcon"
dense dense
clearable clearable
label="Icona" label="Icona"
> >
<template #prepend> <template #prepend>
<q-icon v-if="local" :name="local" /> <q-icon
v-if="localIcon"
:name="localIcon"
:size="localSize"
/>
</template> </template>
<template #hint> Dimensione: {{ localSize }} </template>
</q-input> </q-input>
</div> </div>
@@ -18,8 +25,8 @@
<q-btn <q-btn
dense dense
outline outline
label="Usa testo" label="Applica"
@click="select(local)" @click="updateValue"
/> />
</div> </div>
@@ -28,30 +35,52 @@
dense dense
color="primary" color="primary"
outline outline
label="Scegli icona" label="Scegli"
@click="openPicker" @click="openPicker"
/> />
</div> </div>
</div> </div>
<!-- La griglia icone appare solo nel dialog --> <!-- Dialog di selezione -->
<q-dialog v-model="dialog"> <q-dialog v-model="dialog">
<q-card style="min-width: 640px; max-width: 90vw;"> <q-card style="min-width: 640px; max-width: 90vw">
<q-toolbar> <q-toolbar>
<q-toolbar-title>Seleziona icona</q-toolbar-title> <q-toolbar-title>Scegli icona e dimensione</q-toolbar-title>
<q-btn flat round dense icon="fas fa-times" v-close-popup /> <q-btn
flat
round
dense
icon="fas fa-times"
v-close-popup
/>
</q-toolbar> </q-toolbar>
<div class="q-pa-md"> <div class="q-pa-md">
<!-- Cerca icona -->
<q-input <q-input
v-model="keyword" v-model="keyword"
dense dense
clearable clearable
autofocus autofocus
label="Cerca (es: home, user, info...)" label="Cerca icona..."
class="q-mb-sm"
/> />
<div class="row q-col-gutter-sm q-mt-sm"> <!-- Selettore dimensione -->
<div class="q-mt-lg">
<q-select
v-model="localSize"
:options="sizeOptions"
label="Dimensione icona"
dense
options-dense
emit-value
map-options
/>
</div>
<!-- Griglia icone -->
<div class="row q-col-gutter-sm q-mb-sm">
<div <div
v-for="ic in filteredIcons" v-for="ic in filteredIcons"
:key="ic" :key="ic"
@@ -61,6 +90,7 @@
outline outline
class="full-width" class="full-width"
:icon="ic" :icon="ic"
:size="localSize"
@click="choose(ic)" @click="choose(ic)"
> >
<q-tooltip>{{ ic }}</q-tooltip> <q-tooltip>{{ ic }}</q-tooltip>
@@ -68,10 +98,21 @@
</div> </div>
</div> </div>
<!-- Pulsanti di azione -->
<div class="row q-mt-md q-gutter-sm"> <div class="row q-mt-md q-gutter-sm">
<q-btn outline label="Rimuovi icona" color="negative" @click="choose('')" /> <q-btn
outline
label="Nessuna icona"
color="negative"
@click="choose('')"
/>
<q-space /> <q-space />
<q-btn color="primary" label="Chiudi" v-close-popup /> <q-btn
color="primary"
label="Chiudi"
v-close-popup
@click="updateValue"
/>
</div> </div>
</div> </div>
</q-card> </q-card>
@@ -82,5 +123,5 @@
<script lang="ts" src="./IconPicker.ts"></script> <script lang="ts" src="./IconPicker.ts"></script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* opzionale: spaziatura minima sulle celle */ /* Puoi aggiungere stili personalizzati qui */
</style> </style>

View File

@@ -1,4 +1,25 @@
.indent-spacer { .q-item {
width: 20px; transition: all 0.2s ease;
display: inline-block;
&.active {
background-color: $primary !important;
color: white;
.q-item__section--side {
color: rgba(255, 255, 255, 0.8);
}
}
&:hover {
background-color: $grey-2;
.drag-handle {
opacity: 1;
}
}
}
.drag-handle {
opacity: 0;
transition: opacity 0.2s;
} }

View File

@@ -0,0 +1,29 @@
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'MenuPageItem',
props: {
item: { type: Object, required: true },
selected: { type: Boolean, default: false },
depth: { type: Number, default: 0 },
variant: { type: String, required: true },
},
emits: ['select', 'edit', 'delete', 'open'],
setup(props, { emit }) {
const showGrip = computed(() => props.variant === 'menu');
const displayPath = (path?: string) => {
if (!path) return '-';
return path.startsWith('/') ? path : '/' + path;
};
return {
showGrip,
displayPath,
emitSelect: () => emit('select', props.item.__key),
emitEdit: () => emit('edit', props.item.__key),
emitDelete: () => emit('delete', props.item.__key),
emitOpen: () => emit('open', props.item.__key),
};
},
});

View File

@@ -3,12 +3,12 @@
clickable clickable
:active="selected" :active="selected"
@click="emitSelect" @click="emitSelect"
:class="{ 'menu-item': variant === 'menu' }" :class="{
'depth-0': depth === 0,
'depth-1': depth === 1
}"
> >
<q-item-section <q-item-section v-if="showGrip" avatar>
v-if="showGrip"
avatar
>
<q-btn <q-btn
flat flat
round round
@@ -19,11 +19,7 @@
/> />
</q-item-section> </q-item-section>
<q-item-section <q-item-section v-if="depth > 0" avatar class="q-pr-none">
v-if="depth > 0"
avatar
class="q-pr-none"
>
<div :style="{ paddingLeft: `${depth * 20}px` }" /> <div :style="{ paddingLeft: `${depth * 20}px` }" />
</q-item-section> </q-item-section>
@@ -36,33 +32,17 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label :class="{ 'text-grey-7': !active }">{{ <q-item-label :class="{ 'text-grey-7': !item.active }">{{
item.title || '(senza titolo)' item.title || '(senza titolo)'
}}</q-item-label> }}</q-item-label>
<q-item-label caption>{{ displayPath(item.path) }}</q-item-label> <q-item-label caption>{{ displayPath(item.path) }}</q-item-label>
</q-item-section> </q-item-section>
<q-item-section <q-item-section v-if="item.active" side class="float-right">
v-if="active" <q-icon name="fas fa-circle" color="green" size="xs" />
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="green"
size="xs"
/>
</q-item-section> </q-item-section>
<q-item-section <q-item-section v-if="item.only_admin" side class="float-right">
v-if="item.only_admin" <q-icon name="fas fa-circle" color="red" size="xs" />
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="red"
size="xs"
/>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
@@ -76,31 +56,17 @@
> >
<q-menu> <q-menu>
<q-list style="min-width: 140px"> <q-list style="min-width: 140px">
<q-item <q-item clickable v-close-popup @click="emitOpen">
clickable
v-close-popup
@click="emitOpen"
>
<q-item-section side><q-icon name="fas fa-edit" /></q-item-section> <q-item-section side><q-icon name="fas fa-edit" /></q-item-section>
<q-item-section>Modifica</q-item-section> <q-item-section>Modifica</q-item-section>
</q-item> </q-item>
<q-item <q-item clickable v-close-popup @click="emitEdit">
clickable
v-close-popup
@click="emitEdit"
>
<q-item-section side><q-icon name="fas fa-cog" /></q-item-section> <q-item-section side><q-icon name="fas fa-cog" /></q-item-section>
<q-item-section>Impostazioni</q-item-section> <q-item-section>Impostazioni</q-item-section>
</q-item> </q-item>
<q-item <q-item clickable v-close-popup @click="emitDelete">
clickable
v-close-popup
@click="emitDelete"
>
<q-item-section side <q-item-section side
><q-icon ><q-icon name="fas fa-trash" color="red"
name="fas fa-trash"
color="red"
/></q-item-section> /></q-item-section>
<q-item-section>Elimina</q-item-section> <q-item-section>Elimina</q-item-section>
</q-item> </q-item>
@@ -111,39 +77,7 @@
</q-item> </q-item>
</template> </template>
<script lang="ts"> <script lang="ts" src="./MenuPageItem.ts"></script>
import { defineComponent, computed } from 'vue';
export default defineComponent({
name: 'MenuPageItem',
props: {
item: { type: Object, required: true },
selected: { type: Boolean, default: false },
active: { type: Boolean, default: true },
depth: { type: Number, default: 0 },
variant: { type: String, required: true },
},
emits: ['select', 'update:active', 'edit', 'delete', 'open'],
setup(props, { emit }) {
const showGrip = true // computed(() => props.variant === 'menu');
const displayPath = (path?: string) => {
if (!path) return '-';
return path.startsWith('/') ? path : '/' + path;
};
return {
showGrip,
displayPath,
emitSelect: () => emit('select', props.item.__key),
emitEdit: () => emit('edit', props.item.__key),
emitDelete: () => emit('delete', props.item.__key),
emitOpen: () => emit('open', props.item.__key),
};
},
});
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './MenuPageItem.scss'; @import './MenuPageItem.scss';
</style> </style>

View File

@@ -0,0 +1,9 @@
.q-select {
.q-item {
min-height: 36px;
}
.q-item__section--main {
padding-left: 8px;
}
}

View File

@@ -1,23 +1,19 @@
import { defineComponent, ref, computed, watch, reactive, toRaw, nextTick } from 'vue'; import { defineComponent, reactive, computed, watch } from 'vue';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import IconPicker from '../IconPicker/IconPicker.vue';
import { IMyPage } from 'app/src/model'; import { IMyPage } from 'app/src/model';
import { IconPicker } from '@src/components/IconPicker';
import { useGlobalStore } from 'app/src/store'; import { useGlobalStore } from 'app/src/store';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { costanti } from '@costanti';
import { CMyFieldRec } from '@src/components/CMyFieldRec';
const norm = (s?: string) => (s || '').trim().replace(/^\//, '').toLowerCase(); const norm = (s?: string) => (s || '').trim().replace(/^\//, '').toLowerCase();
const withSlash = (s?: string) => { const withSlash = (s?: string) => {
const p = (s || '').trim(); const p = (s || '').trim();
if (!p) return '/'; return p ? (p.startsWith('/') ? p : `/${p}`) : '/';
return p.startsWith('/') ? p : `/${p}`;
}; };
export default defineComponent({ export default defineComponent({
name: 'PageEditor', name: 'PageEditor',
components: { IconPicker, CMyFieldRec }, components: { IconPicker },
props: { props: {
modelValue: { type: Object as () => IMyPage, required: true }, modelValue: { type: Object as () => IMyPage, required: true },
nuovaPagina: { type: Boolean, required: true }, nuovaPagina: { type: Boolean, required: true },
@@ -27,480 +23,153 @@ export default defineComponent({
const $q = useQuasar(); const $q = useQuasar();
const { t } = useI18n(); const { t } = useI18n();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const { mypage } = storeToRefs(globalStore);
const draft = reactive<IMyPage>({ ...props.modelValue }); const draft = reactive<IMyPage>({ ...props.modelValue });
const ui = reactive({ const ui = reactive({
pathText: toUiPath(draft.path), pathText: withSlash(draft.path),
isSubmenu: !!draft.submenu, isSubmenu: !!draft.submenu,
parentId: null as string | null,
childrenPaths: [] as string[], childrenPaths: [] as string[],
}); });
watch( const iconModel = computed({
() => ui.isSubmenu, get() {
(isSub) => { return {
draft.submenu = !!isSub; icon: draft.icon,
if (isSub) { size: draft.iconsize  || '20px',
// una pagina figlia non gestisce figli propri };
ui.childrenPaths = []; },
// se non c'è un parent pre-selezionato, azzera set(value) {
if (!ui.parentId) ui.parentId = findParentIdForChild(draft.path); draft.icon = value.icon || '';
} else { draft.iconsize = value.size || '20px';
// tornando top-level, nessun parent selezionato },
ui.parentId = null; });
}
}
);
// Draft indipendente // Inizializza i figli
const saving = ref(false);
const syncingFromProps = ref(false);
const previousPath = ref<string>(draft.path || '');
// ===== INIT =====
// parent corrente (se questa pagina è sottomenu)
ui.parentId = findParentIdForChild(draft.path);
// inizializza lista figli (per TOP-LEVEL) con i path presenti nel draft
ui.childrenPaths = Array.isArray(draft.sottoMenu) ui.childrenPaths = Array.isArray(draft.sottoMenu)
? draft.sottoMenu.map((p) => withSlash(p)) ? draft.sottoMenu.map((p) => withSlash(p))
: []; : [];
// --- Sync IN: quando cambia il modelValue (esterno) // Watch per sincronizzare con le modifiche esterne
watch( watch(
() => props.modelValue, () => props.modelValue,
async (v) => { (v) => {
syncingFromProps.value = true; Object.assign(draft, v);
Object.assign(draft, v || {}); ui.pathText = withSlash(draft.path);
ui.pathText = toUiPath(draft.path);
ui.isSubmenu = !!draft.submenu; ui.isSubmenu = !!draft.submenu;
ui.parentId = findParentIdForChild(draft.path);
ui.childrenPaths = Array.isArray(draft.sottoMenu) ui.childrenPaths = Array.isArray(draft.sottoMenu)
? draft.sottoMenu.map((p) => withSlash(p)) ? draft.sottoMenu.map((p) => withSlash(p))
: []; : [];
previousPath.value = draft.path || '';
await nextTick();
syncingFromProps.value = false;
},
{ deep: false }
);
// --- Propagazione live (solo se NON nuovaPagina)
watch(
draft,
(val) => {
if (syncingFromProps.value) return;
if (props.nuovaPagina) return;
upsertIntoStore(val, mypage.value);
emit('update:modelValue', { ...val });
}, },
{ deep: true } { deep: true }
); );
function onToggleSubmenu(val: boolean) { // Opzioni per i figli
draft.submenu = !!val;
if (val) {
draft.inmenu = true;
ui.childrenPaths = []; // sicurezza
}
}
// ======= OPTIONS =======
const parentOptions = computed(() =>
(mypage.value || [])
.filter(
(p) =>
p &&
p.inmenu &&
!p.submenu &&
norm(p.path) !== norm(draft.path) &&
p._id !== draft._id // <-- escludi se stesso
)
.map((p) => ({
value: (p._id || p.path || '') as string,
label: `${p.title || withSlash(p.path)}`,
}))
);
// Mappa path (display) -> page
const pageByDisplayPath = computed(() => {
const m = new Map<string, IMyPage>();
(mypage.value || []).forEach((p) => {
m.set(withSlash(p.path).toLowerCase(), p);
});
return m;
});
// Mappa childPathDisplay -> parentId
const parentByChildPath = computed(() => {
const map = new Map<string, string>();
(mypage.value || []).forEach((p) => {
if (p && p.inmenu && !p.submenu && Array.isArray(p.sottoMenu)) {
p.sottoMenu.forEach((sp) => {
map.set(withSlash(sp).toLowerCase(), (p._id || p.path) as string);
});
}
});
return map;
});
// Candidati figli per il selettore (indent: 0 per "disponibili", 1 per "già figli di altri", 1 anche per già figli miei)
const childCandidateOptions = computed(() => { const childCandidateOptions = computed(() => {
const selfPathDisp = withSlash(draft.path).toLowerCase(); const selfPath = withSlash(draft.path).toLowerCase();
const mine = new Set(ui.childrenPaths.map((x) => x.toLowerCase())); const mine = new Set(ui.childrenPaths.map((x) => x.toLowerCase()));
const opts: Array<{
value: string;
label: string;
level: number;
disabled?: boolean;
hint?: string;
}> = [];
(mypage.value || []) return globalStore.mypage
.filter((p) => p._id !== draft._id && norm(p.path) !== norm(draft.path)) // escludi se stesso .filter((p) => p._id !== draft._id && norm(p.path) !== norm(draft.path))
.forEach((p) => { .map((p) => {
const disp = withSlash(p.path); const disp = withSlash(p.path);
const dispKey = disp.toLowerCase(); const dispKey = disp.toLowerCase();
const parentId = parentByChildPath.value.get(dispKey);
if (mine.has(dispKey)) { return {
// già selezionato come mio figlio value: disp,
opts.push({ label: p.title || disp,
value: disp, level: mine.has(dispKey) ? 1 : 0,
label: labelForPage(p), disabled: mine.has(dispKey)
level: 1, ? false
hint: 'figlio di questa pagina', : globalStore.mypage.some(
}); (parent) =>
} else if (!parentId || parentId === (draft._id || draft.path)) { Array.isArray(parent.sottoMenu) &&
// orfano (o già mio → già gestito sopra) parent.sottoMenu.some((sp) => norm(sp) === norm(p.path))
opts.push({ ),
value: disp, hint: mine.has(dispKey) ? 'figlio di questa pagina' : undefined,
label: labelForPage(p), };
level: 0, })
}); .sort((a, b) => a.level - b.level || a.label.localeCompare(b.label));
} else {
// figlio di un altro parent → lo mostro ma lo disabilito
const parent = (mypage.value || []).find(
(pp) => (pp._id || pp.path) === parentId
);
opts.push({
value: disp,
label: labelForPage(p),
level: 1,
disabled: true,
hint: `già sotto " ${parent?.title || withSlash(parent?.path)}"`,
});
}
});
// Ordina per livello e label
return opts.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label));
}); });
function toUiPath(storePath?: string) { // Normalizza il percorso
const p = (storePath || '').trim(); function normalizePath() {
if (!p) return '/'; let p = ui.pathText.trim().replace(/\s+/g, '-');
return p.startsWith('/') ? p : `/${p}`;
}
function toStorePath(uiPath?: string) {
const p = (uiPath || '').trim();
if (!p) return '';
return p.startsWith('/') ? p.slice(1) : p;
}
function normalizeAndApplyPath() {
let p = (ui.pathText || '/').trim();
p = p.replace(/\s+/g, '-');
if (!p.startsWith('/')) p = '/' + p; if (!p.startsWith('/')) p = '/' + p;
ui.pathText = p; ui.pathText = p;
draft.path = toStorePath(p); draft.path = p.startsWith('/') ? p.slice(1) : p;
} }
function pathRule(v: string) { // Validazione percorso
function validatePath(v: string) {
if (!v) return 'Percorso richiesto'; if (!v) return 'Percorso richiesto';
if (!v.startsWith('/')) return 'Deve iniziare con /'; if (!v.startsWith('/')) return 'Deve iniziare con /';
if (/\s/.test(v)) return 'Nessuno spazio nel path'; if (/\s/.test(v)) return 'Nessuno spazio nel path';
return true; return true;
} }
// ======= STORE UTILS ======= // Salva la pagina
function upsertIntoStore(page: IMyPage, arr: IMyPage[]) { async function save() {
if (!page) return; normalizePath();
const keyId = page._id;
const keyPath = page.path || '';
let idx = -1;
if (keyId) idx = arr.findIndex((p) => p._id === keyId);
if (idx < 0 && keyPath) idx = arr.findIndex((p) => (p.path || '') === keyPath);
if (idx >= 0) arr[idx] = { ...arr[idx], ...toRaw(page) };
else arr.push({ ...toRaw(page) });
}
function findParentIdForChild(childPath?: string | null): string | null { // Validazioni
const target = withSlash(childPath || ''); if (!draft.title?.trim()) {
for (const p of mypage.value) {
if (p && p.inmenu && !p.submenu && Array.isArray(p.sottoMenu)) {
if (
p.sottoMenu.some((sp) => withSlash(sp).toLowerCase() === target.toLowerCase())
) {
return (p._id || p.path) as string;
}
}
}
return null;
}
function findParentsReferencing(childDisplayPath: string): IMyPage[] {
const target = withSlash(childDisplayPath).toLowerCase();
return (mypage.value || []).filter(
(p) =>
p &&
p.inmenu &&
!p.submenu &&
Array.isArray(p.sottoMenu) &&
p.sottoMenu.some((sp) => withSlash(sp).toLowerCase() === target)
);
}
function addChildToParent(parent: IMyPage, childDisplayPath: string) {
if (!Array.isArray(parent.sottoMenu)) parent.sottoMenu = [];
const target = withSlash(childDisplayPath);
if (
!parent.sottoMenu.some(
(sp) => withSlash(sp).toLowerCase() === target.toLowerCase()
)
) {
parent.sottoMenu.push(target);
}
}
function removeChildFromParent(parent: IMyPage, childDisplayPath: string) {
if (!Array.isArray(parent.sottoMenu)) return;
const target = withSlash(childDisplayPath).toLowerCase();
parent.sottoMenu = parent.sottoMenu.filter(
(sp) => withSlash(sp).toLowerCase() !== target
);
}
// ======= SAVE =======
async function checkAndSave(payloadDraft?: IMyPage) {
const cur = payloadDraft || draft;
// validazioni base
if (!cur.title?.trim()) {
$q.notify({ message: 'Inserisci il titolo della pagina', type: 'warning' }); $q.notify({ message: 'Inserisci il titolo della pagina', type: 'warning' });
return; return;
} }
const pathText = (ui.pathText || '').trim();
if (!pathText) { if (!validatePath(ui.pathText)) {
$q.notify({ message: 'Inserisci il percorso della pagina', type: 'warning' }); $q.notify({ message: 'Percorso non valido', type: 'warning' });
return; return;
} }
const candidatePath = toStorePath(pathText).toLowerCase(); // Aggiorna la struttura
const existPath = globalStore.mypage.find( draft.submenu = ui.isSubmenu;
(r) => (r.path || '').toLowerCase() === candidatePath && r._id !== cur._id if (!ui.isSubmenu) {
); draft.sottoMenu = ui.childrenPaths.map((p) =>
if (existPath) { p.startsWith('/') ? p.slice(1) : p
$q.notify({ );
message: "Esiste già un'altra pagina con questo percorso",
type: 'warning',
});
return;
} }
const candidateTitle = (cur.title || '').toLowerCase();
const existName = globalStore.mypage.find(
(r) => (r.title || '').toLowerCase() === candidateTitle && r._id !== cur._id
);
if (existName) {
$q.notify({ message: 'Il nome della pagina esiste già', type: 'warning' });
return;
}
if (ui.isSubmenu && !ui.parentId) {
$q.notify({
message: 'Seleziona la pagina padre per questo sottomenu',
type: 'warning',
});
return;
}
await save();
emit('hide');
}
async function save() {
try { try {
saving.value = true; const saved = await globalStore.savePage({ ...draft });
normalizeAndApplyPath(); if (saved) {
emit('update:modelValue', { ...saved });
// sync flag submenu emit('apply', { ...saved });
draft.submenu = !!ui.isSubmenu; $q.notify({ type: 'positive', message: 'Pagina salvata' });
// se top-level, sincronizza anche i figli dal selettore
if (!ui.isSubmenu) {
draft.sottoMenu = ui.childrenPaths.slice();
} }
// --- salva/aggiorna pagina corrente
const payload: IMyPage = { ...toRaw(draft), path: draft.path || '' };
const saved = await globalStore.savePage(payload);
if (saved && typeof saved === 'object') {
syncingFromProps.value = true;
Object.assign(draft, saved);
upsertIntoStore(draft, mypage.value);
await nextTick();
syncingFromProps.value = false;
}
// --- aggiorna legami parentali
const prevDisplay = withSlash(previousPath.value);
const newDisplay = withSlash(draft.path);
// 1) questa pagina è figlia? collega/sgancia dal parent
const parentsPrev = findParentsReferencing(prevDisplay);
for (const p of parentsPrev) {
// se la pagina è ancora sottomenu con lo stesso parent e path invariato, mantieni
const keep =
ui.isSubmenu &&
ui.parentId === (p._id || p.path) &&
prevDisplay.toLowerCase() === newDisplay.toLowerCase();
if (!keep) {
removeChildFromParent(p, prevDisplay);
await globalStore.savePage(p);
}
}
if (ui.isSubmenu && ui.parentId) {
const parent =
mypage.value.find((pp) => (pp._id || pp.path) === ui.parentId) || null;
if (parent) {
parent.inmenu = true;
parent.submenu = false;
addChildToParent(parent, newDisplay);
await globalStore.savePage(parent);
}
}
// 2) se questa pagina è TOP-LEVEL, salva i riferimenti dei figli
if (!ui.isSubmenu) {
// rimuovi da tutti i parent eventuali vecchi riferimenti ai miei figli (che non sono più nella lista)
const still = new Set(ui.childrenPaths.map((x) => x.toLowerCase()));
const parentsTouch = (mypage.value || []).filter(
(p) => p.inmenu && !p.submenu && Array.isArray(p.sottoMenu)
);
for (const pr of parentsTouch) {
const before = (pr.sottoMenu || []).slice();
pr.sottoMenu = (pr.sottoMenu || []).filter((sp) => {
const spKey = withSlash(sp).toLowerCase();
// tieni solo i figli che non appartengono a me oppure appartengono a me e sono ancora in lista
const belongsToMe = (pr._id || pr.path) === (draft._id || draft.path);
return !belongsToMe || still.has(spKey);
});
if (JSON.stringify(before) !== JSON.stringify(pr.sottoMenu)) {
await globalStore.savePage(pr);
}
}
}
emit('update:modelValue', { ...draft });
emit('apply', { ...draft });
$q.notify({ type: 'positive', message: 'Pagina salvata' });
previousPath.value = draft.path || '';
} catch (err) { } catch (err) {
console.error(err);
$q.notify({ type: 'negative', message: 'Errore nel salvataggio' }); $q.notify({ type: 'negative', message: 'Errore nel salvataggio' });
} finally {
saving.value = false;
}
}
async function reloadFromStore() {
try {
const absolute = ui.pathText || '/';
const page = await globalStore.loadPage(absolute, '', true);
if (page) {
syncingFromProps.value = true;
Object.assign(draft, page);
ui.pathText = toUiPath(draft.path);
ui.isSubmenu = !!draft.submenu;
ui.parentId = findParentIdForChild(draft.path);
ui.childrenPaths = Array.isArray(draft.sottoMenu)
? draft.sottoMenu.map((p) => withSlash(p))
: [];
upsertIntoStore(draft, mypage.value);
if (!props.nuovaPagina) emit('update:modelValue', { ...draft });
await nextTick();
syncingFromProps.value = false;
previousPath.value = draft.path || '';
$q.notify({ type: 'info', message: 'Pagina ricaricata' });
} else {
$q.notify({ type: 'warning', message: 'Pagina non trovata' });
}
} catch (err) {
console.error(err); console.error(err);
$q.notify({ type: 'negative', message: 'Errore nel ricaricare la pagina' });
} }
} }
function resetDraft() { // Funzioni per label
syncingFromProps.value = true;
Object.assign(draft, props.modelValue || {});
ui.pathText = toUiPath(draft.path);
ui.isSubmenu = !!draft.submenu;
ui.parentId = findParentIdForChild(draft.path);
ui.childrenPaths = Array.isArray(draft.sottoMenu)
? draft.sottoMenu.map((p) => withSlash(p))
: [];
if (!props.nuovaPagina) emit('update:modelValue', { ...draft });
nextTick(() => {
syncingFromProps.value = false;
});
previousPath.value = draft.path || '';
}
// === LABEL UTILS ===
function labelForPage(p: IMyPage) { function labelForPage(p: IMyPage) {
return p.title || withSlash(p.path); return p.title || withSlash(p.path);
} }
function labelForPath(dispPath: string) { function labelForPath(dispPath: string) {
const p = pageByDisplayPath.value.get(dispPath.toLowerCase()); const p = globalStore.mypage.find(
(page) => withSlash(page.path).toLowerCase() === dispPath.toLowerCase()
);
return p ? labelForPage(p) : dispPath; return p ? labelForPage(p) : dispPath;
} }
function modifElem() {
/* per compat compat con CMyFieldRec */
}
const absolutePath = computed(() => toUiPath(draft.path));
return { return {
draft, draft,
ui, ui,
saving,
t, t,
costanti,
// helpers UI // helpers UI
pathRule, validatePath,
normalizeAndApplyPath, normalizePath,
labelForPath, labelForPath,
labelForPage, labelForPage,
withSlash, withSlash,
// actions // actions
checkAndSave,
save, save,
reloadFromStore,
resetDraft,
modifElem,
onToggleSubmenu,
// options // options
parentOptions,
childCandidateOptions, childCandidateOptions,
// expose util iconModel,
absolutePath,
}; };
}, },
}); });

View File

@@ -11,8 +11,8 @@
v-model="ui.pathText" v-model="ui.pathText"
label="Percorso (relativo, es: /about)" label="Percorso (relativo, es: /about)"
dense dense
:rules="[pathRule]" :rules="[validatePath]"
@blur="normalizeAndApplyPath" @blur="normalizePath"
> >
<template #prepend><q-icon name="fas fa-link" /></template> <template #prepend><q-icon name="fas fa-link" /></template>
</q-input> </q-input>
@@ -44,7 +44,7 @@
<!-- ICONA --> <!-- ICONA -->
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<icon-picker v-model="draft.icon" /> <icon-picker v-model="iconModel"/>
</div> </div>
<!-- STATO & VISIBILITÀ --> <!-- STATO & VISIBILITÀ -->
@@ -81,12 +81,12 @@
<!-- GESTIONE FIGLI (solo se questa pagina è TOP-LEVEL) --> <!-- GESTIONE FIGLI (solo se questa pagina è TOP-LEVEL) -->
<div <div
class="col-12" class="col-12"
v-if="!ui.isSubmenu" v-if="draft.inmenu && !ui.isSubmenu"
> >
<q-separator spaced /> <q-separator spaced />
<div class="text-subtitle2 q-mb-sm">SottoMenu</div> <div class="text-subtitle2 q-mb-sm">SottoMenu</div>
<!-- Selettore multivalore dei figli (con label indentata nell'option slot) --> <!-- Selettore multivalore dei figli -->
<q-select <q-select
v-model="ui.childrenPaths" v-model="ui.childrenPaths"
:options="childCandidateOptions" :options="childCandidateOptions"
@@ -124,7 +124,7 @@
</template> </template>
</q-select> </q-select>
<!-- Preview gerarchica reale (indentata) --> <!-- Preview gerarchica -->
<div class="q-mt-md"> <div class="q-mt-md">
<div class="text-caption text-grey-7 q-mb-xs">Anteprima struttura</div> <div class="text-caption text-grey-7 q-mb-xs">Anteprima struttura</div>
<q-list <q-list
@@ -169,13 +169,7 @@
<q-btn <q-btn
color="primary" color="primary"
label="Salva" label="Salva"
:loading="saving" @click="save"
@click="checkAndSave(draft)"
/>
<q-btn
outline
label="Ricarica"
@click="reloadFromStore"
/> />
<q-btn <q-btn
color="grey-7" color="grey-7"
@@ -187,7 +181,6 @@
</template> </template>
<script lang="ts" src="./PageEditor.ts"></script> <script lang="ts" src="./PageEditor.ts"></script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './PageEditor.scss'; @import './PageEditor.scss';
</style> </style>

View File

@@ -1,3 +1,36 @@
.menu-container { .menu-item {
min-height: 100px; transition: all 0.3s ease;
&.dragging {
opacity: 0.8;
transform: scale(1.02);
z-index: 100;
.q-item {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
&.drag-handle {
cursor: move;
cursor: grab;
opacity: 0.6;
&:active {
cursor: grabbing;
}
}
} }
.depth-0 {
border-left: 3px solid transparent;
}
.depth-1 {
border-left: 3px solid $primary;
margin-left: 15px;
.q-item {
padding-left: 20px;
}
}

View File

@@ -1,17 +1,15 @@
import { defineComponent, ref, computed, watch } from 'vue'; import { defineComponent, ref, computed, watch } from 'vue';
import type { IMyPage } from 'app/src/model';
import PageEditor from '@src/components/PageEditor/PageEditor.vue'; import PageEditor from '@src/components/PageEditor/PageEditor.vue';
import MenuPageItem from '@src/components/MenuPageItem/MenuPageItem.vue'; import MenuPageItem from '@src/components/MenuPageItem/MenuPageItem.vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useGlobalStore } from 'app/src/store'; import { useGlobalStore } from 'app/src/store';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { useRouter } from 'vue-router';
import { IMyPage } from 'app/src/model';
type Bucket = 'menu' | 'off';
type PageWithKey = IMyPage & { __key?: string }; type PageWithKey = IMyPage & { __key?: string };
type PageRow = PageWithKey & { __depth: number }; // 0 = voce di menu, 1 = sottomenu type MenuItem = PageWithKey & { depth: number };
const byOrder = (a: IMyPage, b: IMyPage) => (a.order ?? 0) - (b.order ?? 0);
const norm = (p?: string) => (p || '').trim().replace(/^\//, '').toLowerCase(); const norm = (p?: string) => (p || '').trim().replace(/^\//, '').toLowerCase();
let uidSeed = 1; let uidSeed = 1;
@@ -27,93 +25,142 @@ export default defineComponent({
props: { props: {
modelValue: { type: Array as () => IMyPage[], required: true }, modelValue: { type: Array as () => IMyPage[], required: true },
}, },
emits: ['update:modelValue', 'save', 'change-order'], emits: ['update:modelValue', 'save'],
setup(props, { emit }) { setup(props, { emit }) {
const $q = useQuasar(); const $q = useQuasar();
const $router = useRouter();
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
const $router = useRouter();
const visualizzaEditor = ref(false); const visualizzaEditor = ref(false);
const nuovaPagina = ref(false); const nuovaPagina = ref(false);
const selectedKey = ref<string | null>(null); const selectedKey = ref<string | null>(null);
// Configurazione ordinamento gerarchico // Stato principale
const ORDER_GROUP_STEP = 1000; // Passo per i top-level (1000, 2000, 3000...) const pages = ref<PageWithKey[]>([]);
const ORDER_CHILD_STEP = 100; // Passo per i sottomenu (100, 200, 300...) const menuItems = ref<MenuItem[]>([]);
const offList = ref<PageWithKey[]>([]);
const isApplying = ref(false);
function getPageByKey(key?: string) { // Inizializzazione
return key ? pages.value.find((p) => p.__key === key) : undefined; function init() {
pages.value = props.modelValue.map(p => ({ ...p }));
ensureKeys(pages.value);
rebuildMenuStructure();
} }
// ===== NUOVO SISTEMA DI ORDINAMENTO GERARCHICO ===== // Ricostruisci la struttura del menu
function rebuildMenuStructure() {
if (isApplying.value) return;
/** // Pulisci le liste
* Calcola l'ordine gerarchico in base alla posizione nella struttura menuItems.value = [];
* Formato: [gruppo].[ordine] dove: offList.value = [];
* - gruppo: identifica il livello gerarchico (1000 per top-level, 1100 per sottomenu del primo top-level, ecc.)
* - ordine: posizione all'interno del gruppo
*/
function calculateHierarchicalOrder(rows: PageRow[], index: number): number {
const row = rows[index];
// Se è un top-level (depth 0) // Ordina tutte le pagine per order
if (row.__depth === 0) { const sortedPages = [...pages.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// Conta quanti top-level ci sono prima di questo
let topLevelCount = 0;
for (let i = 0; i <= index; i++) {
if (rows[i].__depth === 0) topLevelCount++;
}
// Assegna un ordine multiplo di 1000 (1000, 2000, 3000, ...) // Prima identifica i top-level (non sono sottomenu di nessuno)
return topLevelCount * ORDER_GROUP_STEP; const topLevel = sortedPages.filter(p =>
} p.inmenu &&
// Se è un sottomenu (depth 1) !p.submenu
else { );
// Trova il parent (l'ultimo top-level prima di questo elemento)
let parentIndex = -1; // Costruisci la struttura gerarchica
for (let i = index - 1; i >= 0; i--) { for (const parent of topLevel) {
if (rows[i].__depth === 0) { // Aggiungi il parent come top-level
parentIndex = i; menuItems.value.push({ ...parent, depth: 0 });
break;
// Aggiungi i figli diretti
if (Array.isArray(parent.sottoMenu)) {
for (const childPath of parent.sottoMenu) {
const child = sortedPages.find(p => norm(p.path) === norm(childPath));
if (child && child.inmenu) {
menuItems.value.push({ ...child, depth: 1 });
}
} }
} }
if (parentIndex === -1) {
// Nessun parent trovato - fallback a top-level
return calculateHierarchicalOrder(rows, index);
}
// Calcola l'ordine del parent
const parentOrder = calculateHierarchicalOrder(rows, parentIndex);
// Conta quanti sottomenu ci sono sotto lo stesso parent fino a questo punto
let childCount = 0;
for (let i = parentIndex + 1; i <= index; i++) {
if (rows[i].__depth === 1) childCount++;
}
// Assegna un ordine nel formato parentOrder + childCount * 100 (es. 1000 + 100 = 1100)
return parentOrder + childCount * ORDER_CHILD_STEP;
} }
// Aggiungi pagine orfane (sottomenu ma non referenziate)
const orphaned = sortedPages.filter(p =>
p.inmenu &&
p.submenu &&
!menuItems.value.some(mi => mi._id === p._id)
);
for (const orphan of orphaned) {
menuItems.value.push({ ...orphan, depth: 0 });
}
// Popola la lista "fuori menu"
offList.value = sortedPages.filter(p => !p.inmenu);
// Aggiorna il menu globale
globalStore.aggiornaMenu($router);
} }
/** // Aggiorna la struttura gerarchica dopo il drag
* Aggiorna gli ordini di TUTTI gli elementi in base alla loro posizione nella struttura function updateHierarchyFromDrag(newItems: MenuItem[]) {
* Questo è il cuore del nuovo sistema di ordinamento isApplying.value = true;
*/
function updateAllOrders(rows: PageRow[]): { id: string; order: number }[] {
const deltas: { id: string; order: number }[] = [];
for (let i = 0; i < rows.length; i++) { // Pulisci tutte le relazioni esistenti
const row = rows[i]; for (const page of pages.value) {
const page = getPageByKey(row.__key); if (page.inmenu && !page.submenu) {
page.sottoMenu = [];
}
}
// Ricostruisci le relazioni
let currentParent: PageWithKey | null = null;
for (const item of newItems) {
const page = pages.value.find(p => p.__key === item.__key);
if (!page) continue; if (!page) continue;
const newOrder = calculateHierarchicalOrder(rows, i); if (item.depth === 0) {
// Top-level
page.inmenu = true;
page.submenu = false;
currentParent = page;
if (page.order !== newOrder) { if (!Array.isArray(page.sottoMenu)) {
page.sottoMenu = [];
}
} else {
// Sottomenu
page.inmenu = true;
page.submenu = true;
if (currentParent) {
if (!Array.isArray(currentParent.sottoMenu)) {
currentParent.sottoMenu = [];
}
const pathNorm = norm(page.path);
if (!currentParent.sottoMenu.some(p => norm(p) === pathNorm)) {
currentParent.sottoMenu.push(page.path || '');
}
}
}
}
isApplying.value = false;
}
// Aggiorna gli ordini in modo semplice e sequenziale
function updateOrders(items: MenuItem[]) {
const deltas: { id: string; order: number }[] = [];
// Assegna ordini incrementali (10, 20, 30, ...)
for (let i = 0; i < items.length; i++) {
const item = items[i];
const page = pages.value.find(p => p.__key === item.__key);
if (page && page.order !== (i + 1) * 10) {
const newOrder = (i + 1) * 10;
page.order = newOrder; page.order = newOrder;
if (page._id) { if (page._id) {
deltas.push({ id: page._id, order: newOrder }); deltas.push({ id: page._id, order: newOrder });
} }
@@ -123,437 +170,106 @@ export default defineComponent({
return deltas; return deltas;
} }
/** // Gestisci il drag nel menu
* Calcola la profondità (depth) di un elemento basandosi sulla posizione nel drag function handleMenuDrag(evt: any) {
* @param newRows - l'intera lista di righe dopo il drag if (!evt.moved) return;
* @param targetIndex - l'indice di destinazione
* @param sourceIndex - l'indice di origine
* @returns la profondità calcolata (0 per top-level, 1 per sottomenu)
*/
function calculateDepth(
newRows: PageRow[],
targetIndex: number,
sourceIndex: number
): number {
// Se è il primo elemento, non può essere un sottomenu
if (targetIndex === 0) return 0;
// Se è stato spostato verso l'alto, controlla l'elemento prima di esso const { newIndex, oldIndex } = evt.moved;
const prevRow = newRows[targetIndex - 1]; const newItems = [...menuItems.value];
// Se l'elemento precedente è un top-level, potrebbe essere un sottomenu // Determina la nuova profondità
if (prevRow.__depth === 0) { if (newIndex > 0 && newItems[newIndex - 1].depth === 0) {
// Calcola la distanza visiva tra l'elemento trascinato e il precedente newItems[newIndex].depth = 1;
const visualDistance = calculateVisualDistance(targetIndex, sourceIndex); } else {
newItems[newIndex].depth = 0;
// Se la distanza è piccola (es. < 30px), consideralo un sottomenu
if (visualDistance < 30) {
return 1;
}
} }
// Se è stato spostato tra due sottomenu dello stesso gruppo // Aggiorna la struttura gerarchica
if (prevRow.__depth === 1 && targetIndex > 1) { updateHierarchyFromDrag(newItems);
const grandParentRow = newRows[targetIndex - 2];
if (grandParentRow && grandParentRow.__depth === 0) { // Aggiorna gli ordini
return 1; const deltas = updateOrders(newItems);
}
// Aggiorna le viste
rebuildMenuStructure();
// Notifica i cambiamenti
emit('update:modelValue', pages.value);
if (deltas.length) {
emit('save', deltas);
} }
return 0; // Salva le modifiche
} for (const page of pages.value) {
if (page._id) {
/** globalStore.savePage(page);
* Calcola la distanza visiva tra l'elemento trascinato e l'elemento precedente }
* @param targetIndex - indice di destinazione
* @param sourceIndex - indice di origine
* @returns distanza in pixel
*/
function calculateVisualDistance(targetIndex: number, sourceIndex: number): number {
try {
const menuContainer = document.querySelector('.menu-container');
if (!menuContainer) return Infinity;
const items = menuContainer.querySelectorAll('.menu-item');
if (targetIndex >= items.length || targetIndex <= 0) return Infinity;
const prevItem = items[targetIndex - 1] as HTMLElement;
const draggedItem = items[sourceIndex] as HTMLElement;
if (!prevItem || !draggedItem) return Infinity;
const prevRect = prevItem.getBoundingClientRect();
const draggedRect = draggedItem.getBoundingClientRect();
// Calcola la distanza verticale tra il fondo del precedente e l'inizio del trascinato
return draggedRect.top - prevRect.bottom;
} catch (e) {
console.error('Errore nel calcolo della distanza visiva:', e);
return 30; // Valore di default che indica "non troppo vicino"
} }
} }
/** // Gestisci il drag nella lista "fuori menu"
* Aggiorna la struttura gerarchica e gli ordini function handleOffDrag(evt: any) {
*/ if (!evt.added) return;
function applyMenuRows(newRows: PageRow[]): { id: string; order: number }[] {
// 1) svuota i sottoMenu dei parent (ricostruiremo i link)
for (const p of pages.value) {
if (p.inmenu && !p.submenu) p.sottoMenu = [];
}
// 2) mappa chiave->page const { element, newIndex } = evt.added;
const key2page = new Map<string, PageWithKey>(); const page = pages.value.find(p => p.__key === element.__key);
for (const p of pages.value) if (p.__key) key2page.set(p.__key, p);
// 3) ricostruisci SOLO la struttura (inmenu/submenu e sottoMenu dei parent)
let currentParent: PageWithKey | null = null;
for (const row of newRows) {
const page = row.__key ? key2page.get(row.__key) : undefined;
if (!page) continue;
if (row.__depth <= 0 || !currentParent) {
// top-level
page.inmenu = true;
page.submenu = false;
currentParent = page;
if (!Array.isArray(page.sottoMenu)) page.sottoMenu = [];
} else {
// child
page.inmenu = true;
page.submenu = true;
page.mainMenu = true;
const pathStr = page.path || '';
if (currentParent) {
if (!Array.isArray(currentParent.sottoMenu)) currentParent.sottoMenu = [];
const exists = currentParent.sottoMenu.some((p) => norm(p) === norm(pathStr));
if (!exists) currentParent.sottoMenu.push(pathStr);
}
}
}
// 4) aggiorna TUTTI gli ordini in base alla posizione nella struttura
return updateAllOrders(newRows);
}
function applyOffList(
newOff: PageWithKey[],
movedIndex?: number
): { id: string; order: number }[] {
// 1) togli riferimenti dai sottoMenu dei parent
const offPaths = new Set(newOff.map((x) => norm(x.path)));
for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter((p) => !offPaths.has(norm(p)));
}
}
// 2) marca inmenu/submenu=false per tutti gli "off" presenti
const offKeys = new Set(newOff.map((x) => x.__key));
for (const p of pages.value) {
if (p.__key && offKeys.has(p.__key)) {
p.inmenu = false;
p.submenu = false;
p.mainMenu = false;
}
}
// 3) assegna ordine "sparso" al solo elemento spostato
const deltas: { id: string; order: number }[] = [];
if (typeof movedIndex === 'number') {
const prev = newOff[movedIndex - 1];
const next = newOff[movedIndex + 1];
const cur = newOff[movedIndex];
if (cur?.__key) {
const curP = getPageByKey(cur.__key);
const prevO = prev ? getPageByKey(prev.__key!)?.order : undefined;
const nextO = next ? getPageByKey(next.__key!)?.order : undefined;
const pushDelta = (p: PageWithKey, val: number) => {
if (p.order !== val) {
p.order = val;
if (p._id) deltas.push({ id: p._id, order: val });
}
};
// Calcola un nuovo ordine per l'elemento spostato
if (prevO !== undefined && nextO !== undefined) {
pushDelta(curP!, prevO + Math.floor((nextO - prevO) / 2));
} else if (prevO !== undefined) {
pushDelta(curP!, prevO + 10);
} else if (nextO !== undefined) {
pushDelta(curP!, nextO - 10);
} else {
pushDelta(curP!, 10000); // Default per elementi in fondo
}
}
}
return deltas;
}
/**
* Gestisce il drag nel menu
*/
function onMenuDragChange(evt: any) {
console.log('onMenuDragChange', evt);
applyingRows.value = true;
let deltas: { id: string; order: number }[] = [];
const removed = evt?.removed;
const added = evt?.added;
const moved = evt?.moved;
// Creiamo una copia della lista corrente
const newRows = [...menuRows.value];
// Gestisci la rimozione (da menu a fuori menu)
if (removed) {
const { element, oldIndex } = removed;
const page = getPageByKey(element.__key);
if (page) {
// Imposta come non nel menu
page.inmenu = false;
page.submenu = false;
// Rimuovi dai sottoMenu di tutti i parent
for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter(
(p) => norm(p) !== norm(page.path)
);
}
}
}
}
// Gestisci l'aggiunta (da off a menu)
if (added) {
const { element, newIndex } = added;
// Determina la profondità iniziale (0 = top-level)
newRows[newIndex].__depth = 0;
if (page) {
// Imposta come nel menu // Imposta come nel menu
const page = getPageByKey(element.__key); page.inmenu = true;
if (page) { page.submenu = false;
page.inmenu = true;
page.submenu = false; // inizialmente top-level // Aggiorna la struttura
rebuildMenuStructure();
// Notifica i cambiamenti
emit('update:modelValue', pages.value);
// Salva la pagina
if (page._id) {
globalStore.savePage(page);
} }
} }
// Gestisci lo spostamento interno
if (moved) {
const { newIndex, oldIndex } = moved;
// Calcola la nuova profondità basata sulla posizione
const newDepth = calculateDepth(newRows, newIndex, oldIndex);
newRows[newIndex].__depth = newDepth;
}
// Aggiorna la struttura gerarchica e calcola i delta di ordine
deltas = applyMenuRows(newRows);
// Aggiorna le liste
menuRows.value = newRows;
applyingRows.value = false;
rebuildAllViews();
// Comunica i cambiamenti
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
if (deltas.length) emit('change-order', deltas);
try {
globalStore.aggiornaMenu($router);
} catch (e) {
console.error("Errore nell'aggiornamento del menu:", e);
}
} }
/** // Gestisci il drag dal menu alla lista "fuori menu"
* Gestisce il drag nella lista "fuori menu" function handleMenuToOffDrag(evt: any) {
*/ if (!evt.removed) return;
function onOffDragChange(evt: any) {
console.log('onOffDragChange...', evt);
applyingRows.value = true;
// Creiamo una copia della lista corrente const { element } = evt.removed;
const newOff = [...offList.value]; const page = pages.value.find(p => p.__key === element.__key);
let deltas: { id: string; order: number }[] = [];
// Gestisci la rimozione (da off a menu) if (page) {
if (evt?.removed) { // Imposta come non nel menu
const { element, oldIndex } = evt.removed; page.inmenu = false;
const page = getPageByKey(element.__key); page.submenu = false;
if (page) {
// Imposta come nel menu
page.inmenu = true;
// Non impostiamo submenu qui, verrà gestito in onMenuDragChange
// Aggiorna l'ordine per la nuova voce di menu // Rimuovi dai sottoMenu di tutti i parent
page.order = 10000; // Sarà ricalcolato quando verrà spostato nel menu for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
// Salva immediatamente la pagina per riflettere il cambiamento parent.sottoMenu = parent.sottoMenu.filter(p => norm(p) !== norm(page.path));
if (page._id) {
globalStore.savePage(page);
} }
} }
}
// Gestisci l'aggiunta (da menu a off) // Aggiorna la struttura
if (evt?.added) { rebuildMenuStructure();
const { element, newIndex } = evt.added;
const page = getPageByKey(element.__key); // Notifica i cambiamenti
if (page) { emit('update:modelValue', pages.value);
// Imposta come non nel menu
page.inmenu = false; // Salva la pagina
page.submenu = false; if (page._id) {
// Rimuovi dai sottoMenu di tutti i parent globalStore.savePage(page);
for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter(
(p) => norm(p) !== norm(page.path)
);
}
}
} }
} }
// Gestiamo gli eventi di spostamento
if (evt?.moved) {
const { newIndex, oldIndex } = evt.moved;
deltas = applyOffList(newOff, newIndex);
}
// Aggiorna la lista
offList.value = newOff;
applyingRows.value = false;
rebuildAllViews();
// Comunica i cambiamenti
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
if (deltas.length) emit('change-order', deltas);
try {
globalStore.aggiornaMenu($router);
} catch (e) {
console.error("Errore nell'aggiornamento del menu:", e);
}
} }
// ---- STATE BASE -------------------------------------------------------- // Azioni UI
const pages = ref<PageWithKey[]>( function addPage(bucket: 'menu' | 'off') {
props.modelValue ? props.modelValue.map((p) => ({ ...p })) : []
);
ensureKeys(pages.value);
pages.value.sort(byOrder);
// Liste derivate per UI
const menuRows = ref<PageRow[]>([]); // lista piatta (top + figli) con depth
const offList = ref<PageWithKey[]>([]); // voci fuori menu (inmenu=false)
const applyingRows = ref(false); // guard per evitare rientri
// ---- BUILDERS (no side-effects) ---------------------------------------
function rebuildMenuRows() {
const mapByPath = new Map<string, PageWithKey>();
for (const p of pages.value) mapByPath.set(norm(p.path), p);
const tops = pages.value
.filter((p) => p.inmenu && !p.submenu)
.sort(byOrder) as PageWithKey[];
const rows: PageRow[] = [];
const usedChildKeys = new Set<string>();
for (const parent of tops) {
rows.push({ ...(parent as any), __depth: 0 });
const arr = Array.isArray(parent.sottoMenu) ? parent.sottoMenu : [];
for (const childPath of arr) {
const child = mapByPath.get(norm(childPath));
if (child && child.inmenu !== false && child.submenu === true) {
rows.push({ ...(child as any), __depth: 1 });
if (child.__key) usedChildKeys.add(child.__key);
}
}
}
// Orfani: sottomenu==true ma non referenziati da alcun parent
const orphans = (pages.value as PageWithKey[])
.filter((p) => p.inmenu && p.submenu && p.__key && !usedChildKeys.has(p.__key))
.sort(byOrder);
for (const ch of orphans) {
rows.push({ ...(ch as any), __depth: 0 }); // fallback: top-level
}
// Ordina le righe in base all'ordine gerarchico
rows.sort((a, b) => {
// Prima per ordine
const orderDiff = (a.order ?? 0) - (b.order ?? 0);
if (orderDiff !== 0) return orderDiff;
// Poi per profondità (top-level prima dei sottomenu)
return a.__depth - b.__depth;
});
menuRows.value = rows;
}
function rebuildOffList() {
offList.value = pages.value.filter((p) => !p.inmenu).sort(byOrder);
}
function rebuildAllViews() {
rebuildMenuRows();
rebuildOffList();
globalStore.aggiornaMenu($router);
}
// ---- SELEZIONE / UTILS -------------------------------------------------
const currentIdx = computed(() =>
pages.value.findIndex((p) => p.__key === selectedKey.value)
);
function select(key?: string) {
selectedKey.value = key || null;
}
function displayPath(path?: string) {
if (!path) return '-';
return path.startsWith('/') ? path : '/' + path;
}
// ---- AZIONI UI ---------------------------------------------------------
function addPage(bucket: Bucket) {
visualizzaEditor.value = true; visualizzaEditor.value = true;
nuovaPagina.value = true; nuovaPagina.value = true;
// Calcola l'ordine in base al bucket const baseOrder = (pages.value.length + 1) * 10;
let order = 0;
if (bucket === 'menu') {
// Per il menu, usa il sistema gerarchico
const newRows = [
...menuRows.value,
{
__key: `tmp_${uidSeed}`,
__depth: 0,
inmenu: true,
submenu: false,
path: '/nuova-pagina',
title: '',
order: 0,
} as any,
];
order = calculateHierarchicalOrder(newRows, newRows.length - 1);
} else {
// Per "off", usa l'ordine massimo + 10
order = Math.max(...pages.value.map((p) => p.order || 0)) + 10;
}
const np: PageWithKey = { const np: PageWithKey = {
title: '', title: '',
@@ -564,124 +280,93 @@ export default defineComponent({
inmenu: bucket === 'menu', inmenu: bucket === 'menu',
submenu: false, submenu: false,
onlyif_logged: false, onlyif_logged: false,
order: order, order: baseOrder,
__key: `tmp_${uidSeed++}`, __key: `tmp_${uidSeed++}`,
}; };
pages.value.push(np); pages.value.push(np);
emit( rebuildMenuStructure();
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
rebuildAllViews();
selectedKey.value = np.__key!; selectedKey.value = np.__key!;
} }
function removeAt(bucket: Bucket, idx: number) { function removePage(key: string) {
const target = bucket === 'menu' ? menuRows.value[idx] : offList.value[idx]; const page = pages.value.find(p => p.__key === key);
if (!target) return; if (!page) return;
$q.dialog({ $q.dialog({
title: 'Conferma cancellazione', title: 'Conferma cancellazione',
message: `Sei sicuro di voler cancellare la pagina "${target.title || target.path}"?`, message: `Sei sicuro di voler cancellare la pagina "${page.title || page.path}"?`,
cancel: true, cancel: true,
persistent: true, persistent: true,
}).onOk(async () => { }).onOk(async () => {
// rimuovi il record da pages // Rimuovi la pagina
const key = target.__key; const idx = pages.value.findIndex(p => p.__key === key);
const pathN = norm(target.path); if (idx >= 0) pages.value.splice(idx, 1);
const i = pages.value.findIndex((p) => p.__key === key);
if (i >= 0) pages.value.splice(i, 1);
// pulisci eventuali riferimenti nei sottoMenu dei parent // Pulisci riferimenti nei sottoMenu
for (const parent of pages.value) { for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) { if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter((p) => norm(p) !== pathN); parent.sottoMenu = parent.sottoMenu.filter(p => norm(p) !== norm(page.path));
} }
} }
emit( rebuildMenuStructure();
'update:modelValue', emit('update:modelValue', pages.value);
pages.value.map((p) => ({ ...p })) selectedKey.value = null;
);
rebuildAllViews();
if (selectedKey.value === key) selectedKey.value = null;
// opzionale: elimina anche lato server // Elimina dal server se esiste già
try { if (page._id) {
await globalStore.deletePage($q, target._id || ''); try {
} catch {} await globalStore.deletePage($q, page._id);
} catch (err) {
console.error('Errore eliminazione pagina:', err);
}
}
}); });
} }
function editAt(idx: number) { function editPage(key: string) {
const key = (menuRows.value[idx] || offList.value[idx])?.__key; selectedKey.value = key;
selectedKey.value = key || selectedKey.value;
visualizzaEditor.value = true; visualizzaEditor.value = true;
nuovaPagina.value = false; nuovaPagina.value = false;
} }
function openKey(key?: string) { function openPage(key: string) {
const p = pages.value.find((x) => x.__key === key); const page = pages.value.find(p => p.__key === key);
if (!p) return; if (page) {
$router.push(`/${p.path}?edit=1`); window.open(`/${page.path}?edit=1`, '_blank');
}
} }
function onApply() { // Watchers
emit( watch(() => props.modelValue, init, { deep: true, immediate: true });
'update:modelValue', watch(pages, rebuildMenuStructure, { deep: true });
pages.value.map((p) => ({ ...p }))
);
const cur = currentIdx.value >= 0 ? pages.value[currentIdx.value] : undefined;
emit('save', cur);
rebuildAllViews();
visualizzaEditor.value = false;
nuovaPagina.value = false;
}
// ---- WATCHERS ---------------------------------------------------------- // Esposizione
watch(
() => props.modelValue,
(v) => {
pages.value = (v || []).map((p) => ({ ...p }));
ensureKeys(pages.value);
pages.value.sort(byOrder);
rebuildAllViews();
if (!pages.value.find((p) => p.__key === selectedKey.value))
selectedKey.value = null;
},
{ deep: true }
);
// ricostruisci la vista quando pages cambia (evita durante apply)
watch(
() => pages.value,
() => {
if (applyingRows.value) return;
rebuildAllViews();
},
{ deep: true, immediate: true }
);
// ---- EXPOSE ------------------------------------------------------------
return { return {
pages, menuItems,
menuRows,
offList, offList,
selectedKey, selectedKey,
currentIdx,
// actions
select,
addPage,
removeAt,
editAt,
onMenuDragChange,
onOffDragChange,
onApply,
displayPath,
openKey,
visualizzaEditor, visualizzaEditor,
nuovaPagina, nuovaPagina,
// Azioni
addPage,
removePage,
editPage,
openPage,
// Gestori drag
handleMenuDrag,
handleOffDrag,
handleMenuToOffDrag,
// Metodi per PageEditor
getCurrentPage: () => pages.value.find(p => p.__key === selectedKey.value),
saveCurrentPage: () => {
const page = pages.value.find(p => p.__key === selectedKey.value);
if (page && page._id) {
globalStore.savePage(page);
rebuildMenuStructure();
}
}
}; };
}, },
}); });

View File

@@ -1,73 +1,48 @@
<template> <template>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<!-- COLONNA: Nel menu --> <!-- COLONNA: Menu -->
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-card <q-card flat bordered>
flat
bordered
class="menu-container"
>
<q-toolbar> <q-toolbar>
<q-toolbar-title>Menu</q-toolbar-title> <q-toolbar-title>Menu</q-toolbar-title>
<q-badge <q-badge color="primary" :label="menuItems.length" />
color="primary"
:label="menuRows.length"
/>
<q-space /> <q-space />
<q-btn <q-btn dense icon="fas fa-plus" label="Nuova Pagina" @click="addPage('menu')" />
dense
icon="fas fa-plus"
label="Nuova Pagina"
@click="addPage('menu')"
/>
</q-toolbar> </q-toolbar>
<draggable <draggable
v-model="menuRows" v-model="menuItems"
item-key="__key" item-key="__key"
group="pages" group="pages"
handle=".drag-handle" handle=".drag-handle"
:animation="180" :animation="180"
ghost-class="bg-grey-2" ghost-class="bg-grey-2"
@change="onMenuDragChange($event)" @change="handleMenuDrag"
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<MenuPageItem <MenuPageItem
:item="element" :item="element"
:selected="selectedKey === element.__key" :selected="selectedKey === element.__key"
v-model:active="element.active" :depth="element.depth"
:depth="element.__depth"
variant="menu" variant="menu"
class="menu-item" @select="selectedKey = element.__key"
@select="select(element.__key)" @edit="editPage(element.__key)"
@edit="editAt(index)" @delete="removePage(element.__key)"
@delete="removeAt('menu', index)" @open="openPage(element.__key)"
@open="openKey(element.__key)"
/> />
</template> </template>
</draggable> </draggable>
</q-card> </q-card>
</div> </div>
<!-- COLONNA: Fuori menu --> <!-- COLONNA: Pagine -->
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-card <q-card flat bordered>
flat
bordered
>
<q-toolbar> <q-toolbar>
<q-toolbar-title>Pagine</q-toolbar-title> <q-toolbar-title>Pagine</q-toolbar-title>
<q-badge <q-badge color="grey-7" :label="offList.length" />
color="grey-7"
:label="offList.length"
/>
<q-space /> <q-space />
<q-btn <q-btn dense icon="fas fa-plus" label="Aggiungi" @click="addPage('off')" />
dense
icon="fas fa-plus"
label="Aggiungi"
@click="addPage('off')"
/>
</q-toolbar> </q-toolbar>
<draggable <draggable
@@ -77,18 +52,17 @@
handle=".drag-handle" handle=".drag-handle"
:animation="180" :animation="180"
ghost-class="bg-grey-2" ghost-class="bg-grey-2"
@change="onOffDragChange($event)" @change="handleOffDrag"
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<MenuPageItem <MenuPageItem
:item="element" :item="element"
:selected="selectedKey === element.__key" :selected="selectedKey === element.__key"
v-model:active="element.active"
variant="off" variant="off"
:depth="0" :depth="0"
@select="select(element.__key)" @select="selectedKey = element.__key"
@delete="removeAt('off', index)" @delete="removePage(element.__key)"
@open="openKey(element.__key)" @open="openPage(element.__key)"
/> />
</template> </template>
</draggable> </draggable>
@@ -96,15 +70,14 @@
</div> </div>
<!-- Editor --> <!-- Editor -->
<q-dialog <q-dialog v-model="visualizzaEditor">
v-model="visualizzaEditor"
>
<page-editor <page-editor
v-if="currentIdx !== -1" v-if="selectedKey"
v-model="pages[currentIdx]" :model-value="getCurrentPage()"
@apply="onApply" @update:model-value="saveCurrentPage"
@apply="saveCurrentPage"
@hide="visualizzaEditor = false" @hide="visualizzaEditor = false"
:nuovaPagina="nuovaPagina" :nuova-pagina="nuovaPagina"
/> />
</q-dialog> </q-dialog>
</div> </div>
@@ -113,8 +86,4 @@
<script lang="ts" src="./PagesConfigurator.ts"></script> <script lang="ts" src="./PagesConfigurator.ts"></script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './PagesConfigurator.scss'; @import './PagesConfigurator.scss';
.menu-container {
min-height: 100px;
}
</style> </style>

View File

@@ -11191,18 +11191,16 @@ export const tools = {
getmenuByPath(path: string) { getmenuByPath(path: string) {
const myroutes = static_data.routes; const myroutes = static_data.routes;
const normalized = this.norm(path); const norm = path ? (path.startsWith('/') ? path : `/${path}`).trim().toLowerCase() : undefined;
const mymenus = myroutes.find((menu: any) => menu.path === normalized); const mymenus = myroutes.find((menu: any) => menu.path === norm);
// console.log('mymenus', mymenus, 'path', path, 'norm', norm);
return mymenus; return mymenus;
}, },
norm(path?: string): string | undefined {
norm(path: string): string { return typeof path === 'string' ? path.trim().replace(/^\/+|\/+$/g, '').toLowerCase() : undefined;
return path
.trim()
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
}, },
// FINE ! // FINE !

View File

@@ -1476,6 +1476,12 @@ export const useGlobalStore = defineStore('GlobalStore', {
return Api.SendReq('/savepage', 'POST', { page }) return Api.SendReq('/savepage', 'POST', { page })
.then((res) => { .then((res) => {
if (res && res.data && res.data.mypage) { if (res && res.data && res.data.mypage) {
const index = this.mypage.findIndex((rec) => rec.path === res.data.mypage.path);
if (index >= 0) {
this.mypage[index] = res.data.mypage;
} else {
this.mypage.push(res.data.mypage);
}
return res.data.mypage; return res.data.mypage;
} else { } else {
return null; return null;