-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;
}
.router-link-active {
color: #027be3;
background-color: #dadada !important;
border-right: 2px solid #027be3;
}
.list-label:first-child {
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 =>
path
.trim()
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
const toNormPath = (p: any): string => {
if (!p) return '';
if (typeof p === 'string') return norm(p);
return norm(p.path || '');
};
const norm = (path: string): string => tools.norm(path);
export default defineComponent({
name: 'CMenuItem',
@@ -21,52 +12,68 @@ export default defineComponent({
getmymenuclass: { type: Function, required: true },
getimgiconclass: { type: Function, required: true },
clBase: { type: String, default: '' },
level: { type: Number, default: 1 },
mainMenu: { type: Boolean, default: false },
level: { type: Number, default: 0 },
},
setup(props) {
const getmenuByPath = (input: any, depth = 0): any => {
if (depth > 5) return null;
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;
};
emits: ['click'],
setup(props, { emit }) {
// Funzione per ottenere i figli ordinati
const children = computed(() => {
const item: any = props.item;
const r2 = Array.isArray(item.routes2) ? item.routes2 : [];
const sm = Array.isArray(item.sottoMenu) ? item.sottoMenu : [];
return [...r2, ...sm]
.map((ref) =>
typeof ref === 'string' || !ref.path ? getmenuByPath(ref, props.level) : ref
)
.filter(Boolean)
.sort((a: any, b: any) => (a.order ?? 0) - (b.order ?? 0));
let ris = null;
if (r2.length > 0) {
ris = [...r2]
.map((rec) => {
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);
// Ottiene l'icona appropriata
const icon = computed(() => {
const item: any = props.item;
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 {
children,
hasChildren,
icon,
makeClick: () => {
// niente per ora
},
makeClick,
};
},
});

View File

@@ -1,12 +1,11 @@
<template>
<div :style="{ paddingLeft: `${level * 4}px` }">
<q-separator v-if="item.isseparator" />
<!-- Nodo con figli -->
<q-expansion-item
v-else-if="hasChildren"
:header-class="getmymenuclass(item)"
:icon="item.materialIcon"
:icon="icon"
:label="tools.getLabelByItem(item)"
expand-icon="fas fa-chevron-down"
active-class="my-menu-active"
@@ -26,7 +25,7 @@
<!-- Foglia -->
<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-avatar
: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({
name: 'IconPicker',
props: {
modelValue: { type: String, default: '' },
modelValue: {
type: Object as () => IconValue,
default: () => ({})
},
icons: {
type: Array as () => string[],
// SOLO Font Awesome 5 (free)
default: () => [
'fas fa-home',
'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-chart-bar',
'fas fa-briefcase',
'fas fa-calendar',
'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-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'],
setup (props, { emit }) {
const local = ref(props.modelValue) // testo inserito/valore corrente
const dialog = ref(false) // mostra/nasconde il picker
const keyword = ref('') // filtro dentro il dialog
emits: {
'update:modelValue': (val: IconValue) => true,
'change': (val: IconValue) => true
},
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 k = keyword.value.trim().toLowerCase()
if (!k) return props.icons
return props.icons.filter(i =>
i.toLowerCase().includes(k) ||
// match anche sul nome “breve” (es: 'home')
i.toLowerCase().split(' ').some(cls => cls.startsWith('fa-') && cls.includes(k))
)
})
const k = keyword.value.trim().toLowerCase();
if (!k) return props.icons;
return props.icons.filter(
(i) =>
i.toLowerCase().includes(k) ||
i
.toLowerCase()
.split(' ')
.some((cls) => cls.startsWith('fa-') && cls.includes(k))
);
});
function select (val: string) {
// applica la stringa così comè; nessun fallback
emit('update:modelValue', val || '')
emit('change', val || '')
function updateValue() {
const newVal: IconValue = {
icon: localIcon.value || undefined,
size: localSize.value || undefined
};
emit('update:modelValue', newVal);
emit('change', newVal);
}
function choose (ic: string) {
local.value = ic || ''
select(local.value)
dialog.value = false
function choose(ic: string) {
localIcon.value = ic;
updateValue();
dialog.value = false;
}
function clear () {
local.value = ''
select('')
function clear() {
localIcon.value = '';
updateValue();
}
function openPicker () {
keyword.value = ''
dialog.value = true
function openPicker() {
keyword.value = '';
dialog.value = true;
}
return {
local,
localIcon,
localSize,
dialog,
keyword,
filteredIcons,
select,
sizeOptions: props.sizeOptions,
choose,
clear,
openPicker
}
openPicker,
updateValue
};
}
})
});

View File

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

View File

@@ -1,4 +1,25 @@
.indent-spacer {
width: 20px;
display: inline-block;
.q-item {
transition: all 0.2s ease;
&.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
:active="selected"
@click="emitSelect"
:class="{ 'menu-item': variant === 'menu' }"
:class="{
'depth-0': depth === 0,
'depth-1': depth === 1
}"
>
<q-item-section
v-if="showGrip"
avatar
>
<q-item-section v-if="showGrip" avatar>
<q-btn
flat
round
@@ -19,11 +19,7 @@
/>
</q-item-section>
<q-item-section
v-if="depth > 0"
avatar
class="q-pr-none"
>
<q-item-section v-if="depth > 0" avatar class="q-pr-none">
<div :style="{ paddingLeft: `${depth * 20}px` }" />
</q-item-section>
@@ -36,33 +32,17 @@
</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)'
}}</q-item-label>
<q-item-label caption>{{ displayPath(item.path) }}</q-item-label>
</q-item-section>
<q-item-section
v-if="active"
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="green"
size="xs"
/>
<q-item-section v-if="item.active" side class="float-right">
<q-icon name="fas fa-circle" color="green" size="xs" />
</q-item-section>
<q-item-section
v-if="item.only_admin"
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="red"
size="xs"
/>
<q-item-section v-if="item.only_admin" side class="float-right">
<q-icon name="fas fa-circle" color="red" size="xs" />
</q-item-section>
<q-item-section side>
@@ -76,31 +56,17 @@
>
<q-menu>
<q-list style="min-width: 140px">
<q-item
clickable
v-close-popup
@click="emitOpen"
>
<q-item clickable v-close-popup @click="emitOpen">
<q-item-section side><q-icon name="fas fa-edit" /></q-item-section>
<q-item-section>Modifica</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="emitEdit"
>
<q-item clickable v-close-popup @click="emitEdit">
<q-item-section side><q-icon name="fas fa-cog" /></q-item-section>
<q-item-section>Impostazioni</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="emitDelete"
>
<q-item clickable v-close-popup @click="emitDelete">
<q-item-section side
><q-icon
name="fas fa-trash"
color="red"
><q-icon name="fas fa-trash" color="red"
/></q-item-section>
<q-item-section>Elimina</q-item-section>
</q-item>
@@ -111,39 +77,7 @@
</q-item>
</template>
<script lang="ts">
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>
<script lang="ts" src="./MenuPageItem.ts"></script>
<style lang="scss" scoped>
@import './MenuPageItem.scss';
</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 IconPicker from '../IconPicker/IconPicker.vue';
import { IMyPage } from 'app/src/model';
import { IconPicker } from '@src/components/IconPicker';
import { useGlobalStore } from 'app/src/store';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { costanti } from '@costanti';
import { CMyFieldRec } from '@src/components/CMyFieldRec';
const norm = (s?: string) => (s || '').trim().replace(/^\//, '').toLowerCase();
const withSlash = (s?: string) => {
const p = (s || '').trim();
if (!p) return '/';
return p.startsWith('/') ? p : `/${p}`;
return p ? (p.startsWith('/') ? p : `/${p}`) : '/';
};
export default defineComponent({
name: 'PageEditor',
components: { IconPicker, CMyFieldRec },
components: { IconPicker },
props: {
modelValue: { type: Object as () => IMyPage, required: true },
nuovaPagina: { type: Boolean, required: true },
@@ -27,480 +23,153 @@ export default defineComponent({
const $q = useQuasar();
const { t } = useI18n();
const globalStore = useGlobalStore();
const { mypage } = storeToRefs(globalStore);
const draft = reactive<IMyPage>({ ...props.modelValue });
const ui = reactive({
pathText: toUiPath(draft.path),
pathText: withSlash(draft.path),
isSubmenu: !!draft.submenu,
parentId: null as string | null,
childrenPaths: [] as string[],
});
watch(
() => ui.isSubmenu,
(isSub) => {
draft.submenu = !!isSub;
if (isSub) {
// una pagina figlia non gestisce figli propri
ui.childrenPaths = [];
// se non c'è un parent pre-selezionato, azzera
if (!ui.parentId) ui.parentId = findParentIdForChild(draft.path);
} else {
// tornando top-level, nessun parent selezionato
ui.parentId = null;
}
}
);
const iconModel = computed({
get() {
return {
icon: draft.icon,
size: draft.iconsize  || '20px',
};
},
set(value) {
draft.icon = value.icon || '';
draft.iconsize = value.size || '20px';
},
});
// Draft indipendente
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
// Inizializza i figli
ui.childrenPaths = Array.isArray(draft.sottoMenu)
? draft.sottoMenu.map((p) => withSlash(p))
: [];
// --- Sync IN: quando cambia il modelValue (esterno)
// Watch per sincronizzare con le modifiche esterne
watch(
() => props.modelValue,
async (v) => {
syncingFromProps.value = true;
Object.assign(draft, v || {});
ui.pathText = toUiPath(draft.path);
(v) => {
Object.assign(draft, v);
ui.pathText = withSlash(draft.path);
ui.isSubmenu = !!draft.submenu;
ui.parentId = findParentIdForChild(draft.path);
ui.childrenPaths = Array.isArray(draft.sottoMenu)
? 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 }
);
function onToggleSubmenu(val: boolean) {
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)
// Opzioni per i figli
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 opts: Array<{
value: string;
label: string;
level: number;
disabled?: boolean;
hint?: string;
}> = [];
(mypage.value || [])
.filter((p) => p._id !== draft._id && norm(p.path) !== norm(draft.path)) // escludi se stesso
.forEach((p) => {
return globalStore.mypage
.filter((p) => p._id !== draft._id && norm(p.path) !== norm(draft.path))
.map((p) => {
const disp = withSlash(p.path);
const dispKey = disp.toLowerCase();
const parentId = parentByChildPath.value.get(dispKey);
if (mine.has(dispKey)) {
// già selezionato come mio figlio
opts.push({
value: disp,
label: labelForPage(p),
level: 1,
hint: 'figlio di questa pagina',
});
} else if (!parentId || parentId === (draft._id || draft.path)) {
// orfano (o già mio → già gestito sopra)
opts.push({
value: disp,
label: labelForPage(p),
level: 0,
});
} 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));
return {
value: disp,
label: p.title || disp,
level: mine.has(dispKey) ? 1 : 0,
disabled: mine.has(dispKey)
? false
: globalStore.mypage.some(
(parent) =>
Array.isArray(parent.sottoMenu) &&
parent.sottoMenu.some((sp) => norm(sp) === norm(p.path))
),
hint: mine.has(dispKey) ? 'figlio di questa pagina' : undefined,
};
})
.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label));
});
function toUiPath(storePath?: string) {
const p = (storePath || '').trim();
if (!p) return '/';
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, '-');
// Normalizza il percorso
function normalizePath() {
let p = ui.pathText.trim().replace(/\s+/g, '-');
if (!p.startsWith('/')) p = '/' + 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.startsWith('/')) return 'Deve iniziare con /';
if (/\s/.test(v)) return 'Nessuno spazio nel path';
return true;
}
// ======= STORE UTILS =======
function upsertIntoStore(page: IMyPage, arr: IMyPage[]) {
if (!page) return;
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) });
}
// Salva la pagina
async function save() {
normalizePath();
function findParentIdForChild(childPath?: string | null): string | null {
const target = withSlash(childPath || '');
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()) {
// Validazioni
if (!draft.title?.trim()) {
$q.notify({ message: 'Inserisci il titolo della pagina', type: 'warning' });
return;
}
const pathText = (ui.pathText || '').trim();
if (!pathText) {
$q.notify({ message: 'Inserisci il percorso della pagina', type: 'warning' });
if (!validatePath(ui.pathText)) {
$q.notify({ message: 'Percorso non valido', type: 'warning' });
return;
}
const candidatePath = toStorePath(pathText).toLowerCase();
const existPath = globalStore.mypage.find(
(r) => (r.path || '').toLowerCase() === candidatePath && r._id !== cur._id
);
if (existPath) {
$q.notify({
message: "Esiste già un'altra pagina con questo percorso",
type: 'warning',
});
return;
// Aggiorna la struttura
draft.submenu = ui.isSubmenu;
if (!ui.isSubmenu) {
draft.sottoMenu = ui.childrenPaths.map((p) =>
p.startsWith('/') ? p.slice(1) : p
);
}
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 {
saving.value = true;
normalizeAndApplyPath();
// sync flag submenu
draft.submenu = !!ui.isSubmenu;
// se top-level, sincronizza anche i figli dal selettore
if (!ui.isSubmenu) {
draft.sottoMenu = ui.childrenPaths.slice();
const saved = await globalStore.savePage({ ...draft });
if (saved) {
emit('update:modelValue', { ...saved });
emit('apply', { ...saved });
$q.notify({ type: 'positive', message: 'Pagina salvata' });
}
// --- 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) {
console.error(err);
$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);
$q.notify({ type: 'negative', message: 'Errore nel ricaricare la pagina' });
}
}
function resetDraft() {
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 ===
// Funzioni per label
function labelForPage(p: IMyPage) {
return p.title || withSlash(p.path);
}
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;
}
function modifElem() {
/* per compat compat con CMyFieldRec */
}
const absolutePath = computed(() => toUiPath(draft.path));
return {
draft,
ui,
saving,
t,
costanti,
// helpers UI
pathRule,
normalizeAndApplyPath,
validatePath,
normalizePath,
labelForPath,
labelForPage,
withSlash,
// actions
checkAndSave,
save,
reloadFromStore,
resetDraft,
modifElem,
onToggleSubmenu,
// options
parentOptions,
childCandidateOptions,
// expose util
absolutePath,
iconModel,
};
},
});

View File

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

View File

@@ -1,3 +1,36 @@
.menu-container {
min-height: 100px;
.menu-item {
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 type { IMyPage } from 'app/src/model';
import PageEditor from '@src/components/PageEditor/PageEditor.vue';
import MenuPageItem from '@src/components/MenuPageItem/MenuPageItem.vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useGlobalStore } from 'app/src/store';
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 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();
let uidSeed = 1;
@@ -27,93 +25,142 @@ export default defineComponent({
props: {
modelValue: { type: Array as () => IMyPage[], required: true },
},
emits: ['update:modelValue', 'save', 'change-order'],
emits: ['update:modelValue', 'save'],
setup(props, { emit }) {
const $q = useQuasar();
const $router = useRouter();
const globalStore = useGlobalStore();
const $router = useRouter();
const visualizzaEditor = ref(false);
const nuovaPagina = ref(false);
const selectedKey = ref<string | null>(null);
// Configurazione ordinamento gerarchico
const ORDER_GROUP_STEP = 1000; // Passo per i top-level (1000, 2000, 3000...)
const ORDER_CHILD_STEP = 100; // Passo per i sottomenu (100, 200, 300...)
// Stato principale
const pages = ref<PageWithKey[]>([]);
const menuItems = ref<MenuItem[]>([]);
const offList = ref<PageWithKey[]>([]);
const isApplying = ref(false);
function getPageByKey(key?: string) {
return key ? pages.value.find((p) => p.__key === key) : undefined;
// Inizializzazione
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;
/**
* Calcola l'ordine gerarchico in base alla posizione nella struttura
* Formato: [gruppo].[ordine] dove:
* - 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];
// Pulisci le liste
menuItems.value = [];
offList.value = [];
// Se è un top-level (depth 0)
if (row.__depth === 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++;
}
// Ordina tutte le pagine per order
const sortedPages = [...pages.value].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
// Assegna un ordine multiplo di 1000 (1000, 2000, 3000, ...)
return topLevelCount * ORDER_GROUP_STEP;
}
// Se è un sottomenu (depth 1)
else {
// Trova il parent (l'ultimo top-level prima di questo elemento)
let parentIndex = -1;
for (let i = index - 1; i >= 0; i--) {
if (rows[i].__depth === 0) {
parentIndex = i;
break;
// Prima identifica i top-level (non sono sottomenu di nessuno)
const topLevel = sortedPages.filter(p =>
p.inmenu &&
!p.submenu
);
// Costruisci la struttura gerarchica
for (const parent of topLevel) {
// Aggiungi il parent come top-level
menuItems.value.push({ ...parent, depth: 0 });
// 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 gli ordini di TUTTI gli elementi in base alla loro posizione nella struttura
* Questo è il cuore del nuovo sistema di ordinamento
*/
function updateAllOrders(rows: PageRow[]): { id: string; order: number }[] {
const deltas: { id: string; order: number }[] = [];
// Aggiorna la struttura gerarchica dopo il drag
function updateHierarchyFromDrag(newItems: MenuItem[]) {
isApplying.value = true;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const page = getPageByKey(row.__key);
// Pulisci tutte le relazioni esistenti
for (const page of pages.value) {
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;
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;
if (page._id) {
deltas.push({ id: page._id, order: newOrder });
}
@@ -123,437 +170,106 @@ export default defineComponent({
return deltas;
}
/**
* Calcola la profondità (depth) di un elemento basandosi sulla posizione nel drag
* @param newRows - l'intera lista di righe dopo il drag
* @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;
// Gestisci il drag nel menu
function handleMenuDrag(evt: any) {
if (!evt.moved) return;
// Se è stato spostato verso l'alto, controlla l'elemento prima di esso
const prevRow = newRows[targetIndex - 1];
const { newIndex, oldIndex } = evt.moved;
const newItems = [...menuItems.value];
// Se l'elemento precedente è un top-level, potrebbe essere un sottomenu
if (prevRow.__depth === 0) {
// Calcola la distanza visiva tra l'elemento trascinato e il precedente
const visualDistance = calculateVisualDistance(targetIndex, sourceIndex);
// Se la distanza è piccola (es. < 30px), consideralo un sottomenu
if (visualDistance < 30) {
return 1;
}
// Determina la nuova profondità
if (newIndex > 0 && newItems[newIndex - 1].depth === 0) {
newItems[newIndex].depth = 1;
} else {
newItems[newIndex].depth = 0;
}
// Se è stato spostato tra due sottomenu dello stesso gruppo
if (prevRow.__depth === 1 && targetIndex > 1) {
const grandParentRow = newRows[targetIndex - 2];
if (grandParentRow && grandParentRow.__depth === 0) {
return 1;
}
// Aggiorna la struttura gerarchica
updateHierarchyFromDrag(newItems);
// Aggiorna gli ordini
const deltas = updateOrders(newItems);
// Aggiorna le viste
rebuildMenuStructure();
// Notifica i cambiamenti
emit('update:modelValue', pages.value);
if (deltas.length) {
emit('save', deltas);
}
return 0;
}
/**
* 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"
// Salva le modifiche
for (const page of pages.value) {
if (page._id) {
globalStore.savePage(page);
}
}
}
/**
* Aggiorna la struttura gerarchica e gli ordini
*/
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 = [];
}
// Gestisci il drag nella lista "fuori menu"
function handleOffDrag(evt: any) {
if (!evt.added) return;
// 2) mappa chiave->page
const key2page = new Map<string, PageWithKey>();
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;
const { element, newIndex } = evt.added;
const page = pages.value.find(p => p.__key === element.__key);
if (page) {
// Imposta come nel menu
const page = getPageByKey(element.__key);
if (page) {
page.inmenu = true;
page.submenu = false; // inizialmente top-level
page.inmenu = true;
page.submenu = false;
// 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);
}
}
/**
* Gestisce il drag nella lista "fuori menu"
*/
function onOffDragChange(evt: any) {
console.log('onOffDragChange...', evt);
applyingRows.value = true;
// Gestisci il drag dal menu alla lista "fuori menu"
function handleMenuToOffDrag(evt: any) {
if (!evt.removed) return;
// Creiamo una copia della lista corrente
const newOff = [...offList.value];
let deltas: { id: string; order: number }[] = [];
const { element } = evt.removed;
const page = pages.value.find(p => p.__key === element.__key);
// Gestisci la rimozione (da off a menu)
if (evt?.removed) {
const { element, oldIndex } = evt.removed;
const page = getPageByKey(element.__key);
if (page) {
// Imposta come nel menu
page.inmenu = true;
// Non impostiamo submenu qui, verrà gestito in onMenuDragChange
if (page) {
// Imposta come non nel menu
page.inmenu = false;
page.submenu = false;
// Aggiorna l'ordine per la nuova voce di menu
page.order = 10000; // Sarà ricalcolato quando verrà spostato nel menu
// Salva immediatamente la pagina per riflettere il cambiamento
if (page._id) {
globalStore.savePage(page);
// 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 menu a off)
if (evt?.added) {
const { element, newIndex } = evt.added;
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)
);
}
}
// Aggiorna la struttura
rebuildMenuStructure();
// Notifica i cambiamenti
emit('update:modelValue', pages.value);
// Salva la pagina
if (page._id) {
globalStore.savePage(page);
}
}
// 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 --------------------------------------------------------
const pages = ref<PageWithKey[]>(
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) {
// Azioni UI
function addPage(bucket: 'menu' | 'off') {
visualizzaEditor.value = true;
nuovaPagina.value = true;
// Calcola l'ordine in base al bucket
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 baseOrder = (pages.value.length + 1) * 10;
const np: PageWithKey = {
title: '',
@@ -564,124 +280,93 @@ export default defineComponent({
inmenu: bucket === 'menu',
submenu: false,
onlyif_logged: false,
order: order,
order: baseOrder,
__key: `tmp_${uidSeed++}`,
};
pages.value.push(np);
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
rebuildAllViews();
rebuildMenuStructure();
selectedKey.value = np.__key!;
}
function removeAt(bucket: Bucket, idx: number) {
const target = bucket === 'menu' ? menuRows.value[idx] : offList.value[idx];
if (!target) return;
function removePage(key: string) {
const page = pages.value.find(p => p.__key === key);
if (!page) return;
$q.dialog({
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,
persistent: true,
}).onOk(async () => {
// rimuovi il record da pages
const key = target.__key;
const pathN = norm(target.path);
const i = pages.value.findIndex((p) => p.__key === key);
if (i >= 0) pages.value.splice(i, 1);
// Rimuovi la pagina
const idx = pages.value.findIndex(p => p.__key === key);
if (idx >= 0) pages.value.splice(idx, 1);
// pulisci eventuali riferimenti nei sottoMenu dei parent
// Pulisci riferimenti nei sottoMenu
for (const parent of pages.value) {
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(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
rebuildAllViews();
if (selectedKey.value === key) selectedKey.value = null;
rebuildMenuStructure();
emit('update:modelValue', pages.value);
selectedKey.value = null;
// opzionale: elimina anche lato server
try {
await globalStore.deletePage($q, target._id || '');
} catch {}
// Elimina dal server se esiste già
if (page._id) {
try {
await globalStore.deletePage($q, page._id);
} catch (err) {
console.error('Errore eliminazione pagina:', err);
}
}
});
}
function editAt(idx: number) {
const key = (menuRows.value[idx] || offList.value[idx])?.__key;
selectedKey.value = key || selectedKey.value;
function editPage(key: string) {
selectedKey.value = key;
visualizzaEditor.value = true;
nuovaPagina.value = false;
}
function openKey(key?: string) {
const p = pages.value.find((x) => x.__key === key);
if (!p) return;
$router.push(`/${p.path}?edit=1`);
function openPage(key: string) {
const page = pages.value.find(p => p.__key === key);
if (page) {
window.open(`/${page.path}?edit=1`, '_blank');
}
}
function onApply() {
emit(
'update:modelValue',
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
watch(() => props.modelValue, init, { deep: true, immediate: true });
watch(pages, rebuildMenuStructure, { deep: true });
// ---- WATCHERS ----------------------------------------------------------
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 ------------------------------------------------------------
// Esposizione
return {
pages,
menuRows,
menuItems,
offList,
selectedKey,
currentIdx,
// actions
select,
addPage,
removeAt,
editAt,
onMenuDragChange,
onOffDragChange,
onApply,
displayPath,
openKey,
visualizzaEditor,
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>
<div class="row q-col-gutter-md">
<!-- COLONNA: Nel menu -->
<!-- COLONNA: Menu -->
<div class="col-12 col-md-6">
<q-card
flat
bordered
class="menu-container"
>
<q-card flat bordered>
<q-toolbar>
<q-toolbar-title>Menu</q-toolbar-title>
<q-badge
color="primary"
:label="menuRows.length"
/>
<q-badge color="primary" :label="menuItems.length" />
<q-space />
<q-btn
dense
icon="fas fa-plus"
label="Nuova Pagina"
@click="addPage('menu')"
/>
<q-btn dense icon="fas fa-plus" label="Nuova Pagina" @click="addPage('menu')" />
</q-toolbar>
<draggable
v-model="menuRows"
v-model="menuItems"
item-key="__key"
group="pages"
handle=".drag-handle"
:animation="180"
ghost-class="bg-grey-2"
@change="onMenuDragChange($event)"
@change="handleMenuDrag"
>
<template #item="{ element, index }">
<MenuPageItem
:item="element"
:selected="selectedKey === element.__key"
v-model:active="element.active"
:depth="element.__depth"
:depth="element.depth"
variant="menu"
class="menu-item"
@select="select(element.__key)"
@edit="editAt(index)"
@delete="removeAt('menu', index)"
@open="openKey(element.__key)"
@select="selectedKey = element.__key"
@edit="editPage(element.__key)"
@delete="removePage(element.__key)"
@open="openPage(element.__key)"
/>
</template>
</draggable>
</q-card>
</div>
<!-- COLONNA: Fuori menu -->
<!-- COLONNA: Pagine -->
<div class="col-12 col-md-6">
<q-card
flat
bordered
>
<q-card flat bordered>
<q-toolbar>
<q-toolbar-title>Pagine</q-toolbar-title>
<q-badge
color="grey-7"
:label="offList.length"
/>
<q-badge color="grey-7" :label="offList.length" />
<q-space />
<q-btn
dense
icon="fas fa-plus"
label="Aggiungi"
@click="addPage('off')"
/>
<q-btn dense icon="fas fa-plus" label="Aggiungi" @click="addPage('off')" />
</q-toolbar>
<draggable
@@ -77,18 +52,17 @@
handle=".drag-handle"
:animation="180"
ghost-class="bg-grey-2"
@change="onOffDragChange($event)"
@change="handleOffDrag"
>
<template #item="{ element, index }">
<MenuPageItem
:item="element"
:selected="selectedKey === element.__key"
v-model:active="element.active"
variant="off"
:depth="0"
@select="select(element.__key)"
@delete="removeAt('off', index)"
@open="openKey(element.__key)"
@select="selectedKey = element.__key"
@delete="removePage(element.__key)"
@open="openPage(element.__key)"
/>
</template>
</draggable>
@@ -96,15 +70,14 @@
</div>
<!-- Editor -->
<q-dialog
v-model="visualizzaEditor"
>
<q-dialog v-model="visualizzaEditor">
<page-editor
v-if="currentIdx !== -1"
v-model="pages[currentIdx]"
@apply="onApply"
v-if="selectedKey"
:model-value="getCurrentPage()"
@update:model-value="saveCurrentPage"
@apply="saveCurrentPage"
@hide="visualizzaEditor = false"
:nuovaPagina="nuovaPagina"
:nuova-pagina="nuovaPagina"
/>
</q-dialog>
</div>
@@ -113,8 +86,4 @@
<script lang="ts" src="./PagesConfigurator.ts"></script>
<style lang="scss" scoped>
@import './PagesConfigurator.scss';
.menu-container {
min-height: 100px;
}
</style>

View File

@@ -11191,18 +11191,16 @@ export const tools = {
getmenuByPath(path: string) {
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;
},
norm(path: string): string {
return path
.trim()
.replace(/^\/+|\/+$/g, '')
.toLowerCase();
norm(path?: string): string | undefined {
return typeof path === 'string' ? path.trim().replace(/^\/+|\/+$/g, '').toLowerCase() : undefined;
},
// FINE !

View File

@@ -1476,6 +1476,12 @@ export const useGlobalStore = defineStore('GlobalStore', {
return Api.SendReq('/savepage', 'POST', { page })
.then((res) => {
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;
} else {
return null;