-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

@@ -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>