- gestione dell'editor delle pagine (non funzionante!)
This commit is contained in:
@@ -144,5 +144,6 @@
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-style: italic;
|
||||
}
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -1,63 +1,72 @@
|
||||
import { computed, defineComponent, onMounted, PropType, ref, toRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@store/UserStore'
|
||||
import { useGlobalStore } from '@store/globalStore'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { costanti } from '@costanti'
|
||||
import { fieldsTable } from '@store/Modules/fieldsTable'
|
||||
import { shared_consts } from '@src/common/shared_vuejs'
|
||||
import { IColGridTable, IOperators } from 'model'
|
||||
import { tools } from '@tools'
|
||||
import { static_data } from '@src/db/static_data'
|
||||
import { computed, defineComponent, PropType } 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 || '');
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMenuItem',
|
||||
props: {
|
||||
item: Object,
|
||||
getroute: Function,
|
||||
getmymenuclass: Function,
|
||||
getimgiconclass: Function,
|
||||
clBase: String,
|
||||
mainMenu: Boolean,
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
item: { type: Object, required: true },
|
||||
tools: { type: Object, required: true },
|
||||
getroute: { type: Function, required: true },
|
||||
getmymenuclass: { type: Function, required: true },
|
||||
getimgiconclass: { type: Function, required: true },
|
||||
clBase: { type: String, default: '' },
|
||||
level: { type: Number, default: 1 },
|
||||
},
|
||||
setup(props) {
|
||||
const getmenuByPath = (input: any, depth = 0): any => {
|
||||
if (depth > 5) return null;
|
||||
|
||||
components: {},
|
||||
setup(props, { emit }) {
|
||||
const $q = useQuasar()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const path = toNormPath(input);
|
||||
if (!path) return null;
|
||||
|
||||
function mounted() {
|
||||
// ...
|
||||
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;
|
||||
|
||||
function getmenuByPath(pathoobj: any) {
|
||||
let mymenufind = null
|
||||
if (tools.isObject(pathoobj)) {
|
||||
mymenufind = pathoobj
|
||||
} else {
|
||||
mymenufind = static_data.routes.find((menu: any) => menu.path === '/' + pathoobj)
|
||||
}
|
||||
return page;
|
||||
};
|
||||
|
||||
return mymenufind
|
||||
}
|
||||
const children = computed(() => {
|
||||
const item: any = props.item;
|
||||
const r2 = Array.isArray(item.routes2) ? item.routes2 : [];
|
||||
const sm = Array.isArray(item.sottoMenu) ? item.sottoMenu : [];
|
||||
|
||||
onMounted(mounted)
|
||||
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));
|
||||
});
|
||||
|
||||
function makeClick() {
|
||||
}
|
||||
const hasChildren = computed(() => children.value.length > 0);
|
||||
|
||||
const icon = computed(() => {
|
||||
const item: any = props.item;
|
||||
return item.materialIcon || item.icon || 'far fa-file-alt';
|
||||
})
|
||||
|
||||
return {
|
||||
tools,
|
||||
getmenuByPath,
|
||||
makeClick,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
children,
|
||||
hasChildren,
|
||||
icon,
|
||||
makeClick: () => {
|
||||
// niente per ora
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,57 +1,42 @@
|
||||
<template>
|
||||
<div :style="{ paddingLeft: `${level * 4}px` }">
|
||||
|
||||
<q-separator v-if="item.isseparator" />
|
||||
|
||||
<!-- Nodo con figli -->
|
||||
<q-expansion-item
|
||||
v-else-if="item.routes2 || (item.sottoMenu && item.sottoMenu.length > 0)"
|
||||
:content-inset-level="item.level_parent"
|
||||
v-else-if="hasChildren"
|
||||
:header-class="getmymenuclass(item)"
|
||||
:header-inset-level="item.level_parent"
|
||||
:icon="item.materialIcon"
|
||||
:label="tools.getLabelByItem(item)"
|
||||
expand-icon="fas fa-chevron-down"
|
||||
active-class="my-menu-active"
|
||||
:expand-icon-class="item.mainMenu ? 'my-menu-separat' : ''"
|
||||
:expand-icon="
|
||||
item.mainMenu || item.routes2 ? 'fas fa-chevron-down' : 'none'
|
||||
"
|
||||
>
|
||||
<router-link :to="getroute(item)" custom>
|
||||
<c-menu-item
|
||||
v-for="(childItem, childIndex) in item.routes2 || (item.sottoMenu && item.sottoMenu.length > 0)"
|
||||
:key="childIndex"
|
||||
:item="getmenuByPath(childItem)"
|
||||
:tools="tools"
|
||||
:getroute="getroute"
|
||||
:getmymenuclass="getmymenuclass"
|
||||
:getimgiconclass="getimgiconclass"
|
||||
:clBase="clBase"
|
||||
:mainMenu="item.mainMenu"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</router-link>
|
||||
<CMenuItem
|
||||
v-for="(child, idx) in children"
|
||||
:key="child._id || child.path || idx"
|
||||
:item="child"
|
||||
:tools="tools"
|
||||
:getroute="getroute"
|
||||
:getmymenuclass="getmymenuclass"
|
||||
:getimgiconclass="getimgiconclass"
|
||||
:clBase="clBase"
|
||||
:level="level + 1"
|
||||
/>
|
||||
</q-expansion-item>
|
||||
<router-link v-else :to="getroute(item)" custom>
|
||||
<q-item
|
||||
clickable
|
||||
:to="getroute(item)"
|
||||
@click="makeClick"
|
||||
:content-inset-level="item.level_parent"
|
||||
:header-inset-level="item.level_parent"
|
||||
active-class="my-menu-active"
|
||||
expand-icon="none"
|
||||
>
|
||||
|
||||
<!-- Foglia -->
|
||||
<router-link v-else :to="getroute(item)">
|
||||
<q-item clickable :to="getroute(item)" @click="makeClick" active-class="my-menu-active">
|
||||
<q-item-section thumbnail>
|
||||
<q-avatar
|
||||
:icon="item.materialIcon"
|
||||
:size="!!item.iconsize ? item.iconsize : '2rem'"
|
||||
:font-size="!!item.iconsize ? item.iconsize : '2rem'"
|
||||
:size="item.iconsize || '2rem'"
|
||||
:font-size="item.iconsize || '2rem'"
|
||||
text-color="primary"
|
||||
square
|
||||
rounded
|
||||
>
|
||||
</q-avatar>
|
||||
/>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<span :class="item.extraclass">{{ tools.getLabelByItem(item) }}</span>
|
||||
<span v-if="item.subtitle" class="subtitle">{{ item.subtitle }}</span>
|
||||
|
||||
16
src/components/CMyCode/CMyCode.scss
Executable file
16
src/components/CMyCode/CMyCode.scss
Executable file
@@ -0,0 +1,16 @@
|
||||
.cmy-code {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.code-toolbar { background: rgba(255,255,255,0.04); }
|
||||
.code-pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px 14px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
27
src/components/CMyCode/CMyCode.ts
Executable file
27
src/components/CMyCode/CMyCode.ts
Executable file
@@ -0,0 +1,27 @@
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { copyToClipboard } from 'quasar';
|
||||
import './CmyCode.scss';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CmyCode',
|
||||
props: {
|
||||
code: { type: String, required: true },
|
||||
language: { type: String, default: 'text' }
|
||||
},
|
||||
setup(props) {
|
||||
const copied = ref(false);
|
||||
const language = computed(() => props.language || 'text');
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await copyToClipboard(props.code || '');
|
||||
copied.value = true;
|
||||
setTimeout(() => (copied.value = false), 1200);
|
||||
} catch (e) {
|
||||
// opzionale: notifica $q.notify
|
||||
}
|
||||
}
|
||||
|
||||
return { copy, copied, language, code: props.code };
|
||||
}
|
||||
});
|
||||
19
src/components/CMyCode/CMyCode.vue
Executable file
19
src/components/CMyCode/CMyCode.vue
Executable file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="cmy-code">
|
||||
<div class="code-toolbar row items-center justify-between q-px-sm q-pt-sm">
|
||||
<div class="text-caption">{{ language?.toUpperCase() || 'CODE' }}</div>
|
||||
<q-btn flat dense icon="content_copy" @click="copy" :label="copied ? 'Copiato' : 'Copia'" />
|
||||
</div>
|
||||
<pre class="code-pre"><code :class="`lang-${language || 'text'}`">{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" src="./CMyCode.ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CMyCode.scss';
|
||||
</style>
|
||||
|
||||
|
||||
1
src/components/CMyCode/index.ts
Executable file
1
src/components/CMyCode/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export {default as CMyCode} from './CMyCode.vue'
|
||||
12
src/components/CMyDivider/CMyDivider.scss
Executable file
12
src/components/CMyDivider/CMyDivider.scss
Executable file
@@ -0,0 +1,12 @@
|
||||
.cmy-divider {
|
||||
.q-separator {
|
||||
&[class~="is-dotted"] { border-top-style: dotted !important; }
|
||||
border-color: var(--divider-color, currentColor);
|
||||
}
|
||||
.divider-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
text-align: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
30
src/components/CMyDivider/CMyDivider.ts
Executable file
30
src/components/CMyDivider/CMyDivider.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import './CMyDivider.scss';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMyDivider',
|
||||
props: {
|
||||
label: { type: String, default: '' },
|
||||
color: { type: String, default: '' }, // quasar key o hex
|
||||
size: { type: Number, default: 1 }, // px
|
||||
dotted: { type: Boolean, default: false },
|
||||
inset: { type: Boolean, default: false },
|
||||
marginY: { type: Number, default: 0 }
|
||||
},
|
||||
setup(props) {
|
||||
const qColor = computed(() =>
|
||||
props.color && !props.color.startsWith('#') ? props.color : undefined
|
||||
);
|
||||
const sizePx = computed(() => props.size + 'px');
|
||||
const sepClass = computed(() => (props.dotted ? 'is-dotted' : ''));
|
||||
|
||||
const styleVars = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
if (props.marginY != null) style.margin = `${props.marginY}px 0`;
|
||||
if (props.color && props.color.startsWith('#')) style['--divider-color'] = props.color;
|
||||
return style;
|
||||
});
|
||||
|
||||
return { styleVars, qColor, sepClass, sizePx, inset: props.inset, label: props.label };
|
||||
}
|
||||
});
|
||||
16
src/components/CMyDivider/CMyDivider.vue
Executable file
16
src/components/CMyDivider/CMyDivider.vue
Executable file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="cmy-divider" :style="styleVars">
|
||||
<q-separator :spaced="false" :inset="inset" :color="qColor" :size="sizePx" :class="sepClass" />
|
||||
<div v-if="label" class="divider-label">{{ label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" src="./CMyDivider.ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CMyDivider.scss';
|
||||
</style>
|
||||
|
||||
|
||||
1
src/components/CMyDivider/index.ts
Executable file
1
src/components/CMyDivider/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export {default as CMyDivider} from './CMyDivider.vue'
|
||||
@@ -41,6 +41,7 @@ import { CMySize } from '@src/components/CMySize';
|
||||
import { CBorders } from '@src/components/CBorders';
|
||||
import { CMyDimensioni } from '@src/components/CMyDimensioni';
|
||||
import { CMyText } from '@src/components/CMyText';
|
||||
import { CPickColor } from '@src/components/CPickColor';
|
||||
|
||||
import MixinMetaTags from '@src/mixins/mixin-metatags';
|
||||
import MixinBase from '@src/mixins/mixin-base';
|
||||
@@ -76,6 +77,7 @@ export default defineComponent({
|
||||
CMyText,
|
||||
CMySlideNumber,
|
||||
CMyElemAdd,
|
||||
CPickColor,
|
||||
},
|
||||
emits: [
|
||||
'saveElem',
|
||||
@@ -138,6 +140,8 @@ export default defineComponent({
|
||||
|
||||
const Products = useProducts();
|
||||
|
||||
const colorPicker = ref(null);
|
||||
|
||||
const neworder = ref(<number | undefined>0);
|
||||
|
||||
const idSchedaDaCopiare = ref('');
|
||||
@@ -811,6 +815,11 @@ export default defineComponent({
|
||||
emit('selElemClick', newrec);
|
||||
}
|
||||
|
||||
function openColorPicker() {
|
||||
// Apre la finestra del picker
|
||||
colorPicker.value.openDialog();
|
||||
}
|
||||
|
||||
onMounted(mounted);
|
||||
|
||||
return {
|
||||
@@ -879,6 +888,8 @@ export default defineComponent({
|
||||
naviga,
|
||||
isElementoSpecifico,
|
||||
AddedNewElem,
|
||||
openColorPicker,
|
||||
colorPicker,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1097,6 +1097,47 @@
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.HEADING">
|
||||
<div
|
||||
v-if="enableEdit"
|
||||
class="row q-col-gutter-sm"
|
||||
>
|
||||
<q-input
|
||||
dense
|
||||
label="Titolo:"
|
||||
@update:model-value="modifElem"
|
||||
v-model="myel.container"
|
||||
filled
|
||||
v-on:keyup.enter="saveElem"
|
||||
>
|
||||
</q-input>
|
||||
<q-input
|
||||
dense
|
||||
label="Livello:"
|
||||
@update:model-value="modifElem"
|
||||
v-model="myel.number"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
filled
|
||||
v-on:keyup.enter="saveElem"
|
||||
>
|
||||
</q-input>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
icon="fas fa-palette"
|
||||
color="primary"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
</q-btn>
|
||||
<CPickColor
|
||||
ref="colorPicker"
|
||||
v-model="myel.color"
|
||||
@update:model-value="modifElem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.VIDEO_YOUTUBE">
|
||||
<div
|
||||
v-if="enableEdit"
|
||||
@@ -1141,7 +1182,6 @@
|
||||
v-on:keyup.enter="saveElem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.FOOTER"></div>
|
||||
|
||||
@@ -56,6 +56,11 @@ import { CNotifAtTop } from '@src/components/CNotifAtTop';
|
||||
import { CPresentazione } from '@src/components/CPresentazione';
|
||||
import { CRegistration } from '@src/components/CRegistration';
|
||||
import { CShareSocial } from '@src/components/CShareSocial';
|
||||
import { CMyImageGallery } from '@src/components/CMyImageGallery';
|
||||
import { CMyHeading } from '@src/components/CMyHeading';
|
||||
import { CMyList } from '@src/components/CMyList';
|
||||
import { CMyCode } from '@src/components/CMyCode';
|
||||
import { CMyDivider } from '@src/components/CMyDivider';
|
||||
import { CVisuVideoPromoAndPDF } from '@src/components/CVisuVideoPromoAndPDF';
|
||||
|
||||
import MixinMetaTags from '@src/mixins/mixin-metatags';
|
||||
@@ -119,6 +124,11 @@ export default defineComponent({
|
||||
CRow,
|
||||
CColumn,
|
||||
CMyVideoYoutube,
|
||||
CMyDivider,
|
||||
CMyImageGallery,
|
||||
CMyHeading,
|
||||
CMyList,
|
||||
CMyCode,
|
||||
// , //CMapMarker,
|
||||
},
|
||||
emits: ['selElemClick'],
|
||||
|
||||
@@ -355,6 +355,26 @@
|
||||
:ccLoad="myelem.ccLoad ?? false"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.IMAGE_GALLERY">
|
||||
<CMyImageGallery> </CMyImageGallery>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.HEADING">
|
||||
<CMyHeading
|
||||
:text="myel.container"
|
||||
:level="myelem.number"
|
||||
:color="myelem.color"
|
||||
>
|
||||
</CMyHeading>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.LIST">
|
||||
<CMyList> </CMyList>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.CODE">
|
||||
<CMyCode> </CMyCode>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.DIVIDER">
|
||||
<CMyDivider> </CMyDivider>
|
||||
</div>
|
||||
<div v-else-if="myel.type === shared_consts.ELEMTYPE.PAGE">
|
||||
<div
|
||||
:class="myel.class + (editOn ? ` clEdit` : ``) + getClass()"
|
||||
|
||||
4
src/components/CMyHeading/CMyHeading.scss
Executable file
4
src/components/CMyHeading/CMyHeading.scss
Executable file
@@ -0,0 +1,4 @@
|
||||
.cmy-heading {
|
||||
margin: 0;
|
||||
&.underline { text-decoration: underline; text-underline-offset: 4px; }
|
||||
}
|
||||
30
src/components/CMyHeading/CMyHeading.ts
Executable file
30
src/components/CMyHeading/CMyHeading.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import './CMyHEADING.scss';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMyHeading',
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
level: { type: Number as () => 1|2|3|4|5|6, default: 2 },
|
||||
align: { type: String as () => 'left'|'center'|'right'|'justify', default: 'center' },
|
||||
color: { type: String, default: '' }, // quasar key o hex
|
||||
weight: { type: [String, Number] as () => 'light'|'normal'|'medium'|'bold'|number, default: 'bold' },
|
||||
underline: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const tagName = computed(() => `h${props.level}`);
|
||||
const alignClass = computed(() => {
|
||||
switch (props.align) {
|
||||
case 'center': return 'text-center';
|
||||
case 'right': return 'text-right';
|
||||
case 'justify': return 'text-justify';
|
||||
default: return 'text-left';
|
||||
}
|
||||
});
|
||||
const styleVars = computed(() => {
|
||||
const color = props.color ? (props.color.startsWith('#') ? props.color : `var(--q-${props.color})`) : 'inherit';
|
||||
return { color, fontWeight: String(props.weight) } as Record<string, string>;
|
||||
});
|
||||
return { tagName, alignClass, styleVars };
|
||||
}
|
||||
});
|
||||
19
src/components/CMyHeading/CMyHeading.vue
Executable file
19
src/components/CMyHeading/CMyHeading.vue
Executable file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<component
|
||||
:is="tagName"
|
||||
class="cmy-heading"
|
||||
:class="[{ underline }, alignClass]"
|
||||
:style="styleVars"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./CMyHeading.ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CMyHeading.scss';
|
||||
</style>
|
||||
|
||||
|
||||
1
src/components/CMyHeading/index.ts
Executable file
1
src/components/CMyHeading/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export {default as CMyHeading} from './CMyHeading.vue'
|
||||
15
src/components/CMyImageGallery/CMyImageGallery.scss
Executable file
15
src/components/CMyImageGallery/CMyImageGallery.scss
Executable file
@@ -0,0 +1,15 @@
|
||||
.cmy-image-gallery {
|
||||
&.layout-grid {
|
||||
.gallery-grid { display: grid; }
|
||||
.gallery-item {
|
||||
cursor: pointer;
|
||||
.gallery-img { border-radius: 10px; overflow: hidden; }
|
||||
.gallery-caption { font-size: 0.9rem; opacity: 0.8; margin-top: 6px; }
|
||||
}
|
||||
}
|
||||
&.layout-carousel {
|
||||
.carousel-img { max-width: 100%; border-radius: 12px; }
|
||||
}
|
||||
.lightbox-card { width: min(92vw, 1100px); }
|
||||
.lightbox-img { max-height: 76vh; object-fit: contain; }
|
||||
}
|
||||
59
src/components/CMyImageGallery/CMyImageGallery.ts
Executable file
59
src/components/CMyImageGallery/CMyImageGallery.ts
Executable file
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, ref, computed, type Ref } from 'vue';
|
||||
|
||||
export type GalleryImage = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMyImageGallery',
|
||||
props: {
|
||||
images: { type: Array as () => GalleryImage[], required: true },
|
||||
layout: { type: String as () => 'grid' | 'carousel', default: 'grid' },
|
||||
cols: { type: Number, default: 3 },
|
||||
gap: { type: Number, default: 12 },
|
||||
ratio: { type: Number, default: 1 },
|
||||
lightbox: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['imageClick'],
|
||||
setup(props, { emit }) {
|
||||
const slide: Ref<number> = ref(0);
|
||||
const lightboxOpen = ref(false);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${props.cols}, 1fr)`,
|
||||
gap: props.gap + 'px'
|
||||
}));
|
||||
|
||||
const currentImage = computed(() => props.images?.[currentIndex.value] ?? null);
|
||||
|
||||
function onImageClick(idx: number) {
|
||||
emit('imageClick', idx, props.images[idx]);
|
||||
if (props.lightbox) {
|
||||
currentIndex.value = idx;
|
||||
lightboxOpen.value = true;
|
||||
}
|
||||
}
|
||||
function next() {
|
||||
if (!props.images?.length) return;
|
||||
currentIndex.value = (currentIndex.value + 1) % props.images.length;
|
||||
}
|
||||
function prev() {
|
||||
if (!props.images?.length) return;
|
||||
currentIndex.value = (currentIndex.value - 1 + props.images.length) % props.images.length;
|
||||
}
|
||||
|
||||
return {
|
||||
slide,
|
||||
lightboxOpen,
|
||||
currentIndex,
|
||||
currentImage,
|
||||
gridStyle,
|
||||
onImageClick,
|
||||
next,
|
||||
prev
|
||||
};
|
||||
}
|
||||
});
|
||||
67
src/components/CMyImageGallery/CMyImageGallery.vue
Executable file
67
src/components/CMyImageGallery/CMyImageGallery.vue
Executable file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="cmy-image-gallery" :class="[`layout-${layout}`]">
|
||||
<!-- GRID -->
|
||||
<div v-if="layout === 'grid'" class="gallery-grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
class="gallery-item"
|
||||
@click="onImageClick(idx)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<q-img :src="img.src" :alt="img.alt || ''" :ratio="ratio" class="gallery-img" />
|
||||
<div v-if="img.caption" class="gallery-caption">{{ img.caption }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAROUSEL -->
|
||||
<q-carousel
|
||||
v-else
|
||||
v-model="slide"
|
||||
animated
|
||||
arrows
|
||||
swipeable
|
||||
infinite
|
||||
class="gallery-carousel"
|
||||
height="auto"
|
||||
>
|
||||
<q-carousel-slide
|
||||
v-for="(img, idx) in images"
|
||||
:key="idx"
|
||||
:name="idx"
|
||||
class="column items-center q-pa-md"
|
||||
>
|
||||
<q-img :src="img.src" :alt="img.alt || ''" :ratio="ratio" class="carousel-img" />
|
||||
<div v-if="img.caption" class="gallery-caption q-mt-sm">{{ img.caption }}</div>
|
||||
</q-carousel-slide>
|
||||
</q-carousel>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<q-dialog v-model="lightboxOpen" persistent>
|
||||
<q-card class="lightbox-card">
|
||||
<q-card-section class="row items-center justify-between">
|
||||
<div class="text-subtitle1">{{ currentImage?.caption }}</div>
|
||||
<q-btn flat round icon="close" @click="lightboxOpen = false" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-img :src="currentImage?.src" :alt="currentImage?.alt || ''" class="lightbox-img" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="between">
|
||||
<q-btn flat icon="chevron_left" @click="prev" />
|
||||
<q-btn flat icon="chevron_right" @click="next" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./CMyImageGallery.ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CMyImageGallery.scss';
|
||||
</style>
|
||||
|
||||
|
||||
1
src/components/CMyImageGallery/index.ts
Executable file
1
src/components/CMyImageGallery/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export {default as CMyImageGallery} from './CMyImageGallery.vue'
|
||||
9
src/components/CMyList/CMyList.scss
Executable file
9
src/components/CMyList/CMyList.scss
Executable file
@@ -0,0 +1,9 @@
|
||||
.cmy-list {
|
||||
padding-left: 1.2rem;
|
||||
margin: 0;
|
||||
&.dense .list-item { margin-bottom: 2px; }
|
||||
.list-item {
|
||||
margin-bottom: 6px;
|
||||
.list-text { line-height: 1.5; }
|
||||
}
|
||||
}
|
||||
22
src/components/CMyList/CMyList.ts
Executable file
22
src/components/CMyList/CMyList.ts
Executable file
@@ -0,0 +1,22 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import './CMyList.scss';
|
||||
|
||||
export type ListItem = { text: string; icon?: string };
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMyList',
|
||||
props: {
|
||||
items: { type: Array as () => Array<string | ListItem>, required: true },
|
||||
ordered: { type: Boolean, default: false },
|
||||
dense: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props) {
|
||||
const listTag = computed(() => (props.ordered ? 'ol' : 'ul'));
|
||||
const normalized = computed<ListItem[]>(() =>
|
||||
(props.items || []).map(it => (typeof it === 'string' ? { text: it } : it))
|
||||
);
|
||||
const ordered = computed(() => !!props.ordered);
|
||||
const dense = computed(() => !!props.dense);
|
||||
return { listTag, normalized, ordered, dense };
|
||||
}
|
||||
});
|
||||
18
src/components/CMyList/CMyList.vue
Executable file
18
src/components/CMyList/CMyList.vue
Executable file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<component :is="listTag" class="cmy-list" :class="[dense ? 'dense' : '']">
|
||||
<li v-for="(item, idx) in normalized" :key="idx" class="list-item row items-start no-wrap">
|
||||
<q-icon v-if="item.icon && !ordered" :name="item.icon" class="q-mr-sm" />
|
||||
<span class="list-text" v-html="item.text"></span>
|
||||
</li>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
|
||||
<script lang="ts" src="./CMyList.ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CMyList.scss';
|
||||
</style>
|
||||
|
||||
|
||||
1
src/components/CMyList/index.ts
Executable file
1
src/components/CMyList/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export {default as CMyList} from './CMyList.vue'
|
||||
@@ -0,0 +1,4 @@
|
||||
.indent-spacer {
|
||||
width: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { defineComponent, ref, computed, onMounted, watch, onBeforeUnmount } from 'vue';
|
||||
import { IMyPage } from 'app/src/model';
|
||||
|
||||
type PageWithKey = IMyPage & { __key?: string };
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuPageItem',
|
||||
props: {
|
||||
item: { type: Object as () => PageWithKey, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
active: { type: Boolean, default: false }, // v-model:active
|
||||
variant: { type: String as () => 'menu' | 'off', default: 'menu' },
|
||||
showGrip: { type: Boolean, default: true },
|
||||
draggableHandleClass: { type: String, default: 'drag-handle' },
|
||||
depth: { type: Number, default: 0 },
|
||||
},
|
||||
emits: ['select', 'edit', 'delete', 'open', 'update:active', 'update:item'],
|
||||
setup(props, { emit }) {
|
||||
function displayPath(path?: string) {
|
||||
if (!path) return '-';
|
||||
return path.startsWith('/') ? path : '/' + path;
|
||||
}
|
||||
function emitSelect() {
|
||||
emit('select', props.item.__key);
|
||||
}
|
||||
function emitEdit() {
|
||||
emit('edit', props.item.__key);
|
||||
}
|
||||
function emitDelete() {
|
||||
emit('delete', props.item.__key);
|
||||
}
|
||||
function emitOpen() {
|
||||
emit('open', props.item.__key);
|
||||
}
|
||||
|
||||
const indentSpacerStyle = computed(() => {
|
||||
const px = Math.min(props.depth, 6) * 16; // max 6 livelli x 16px
|
||||
return { width: `${px}px`, minWidth: `${px}px` };
|
||||
});
|
||||
|
||||
return {
|
||||
displayPath,
|
||||
emitSelect,
|
||||
emitEdit,
|
||||
emitDelete,
|
||||
emitOpen,
|
||||
indentSpacerStyle,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
clickable
|
||||
:active="selected"
|
||||
@click="emitSelect"
|
||||
:class="{ 'menu-item': variant === 'menu' }"
|
||||
>
|
||||
<q-item-section
|
||||
v-if="showGrip"
|
||||
@@ -12,7 +13,7 @@
|
||||
flat
|
||||
round
|
||||
dense
|
||||
:class="draggableHandleClass"
|
||||
class="drag-handle"
|
||||
icon="fas fa-grip-vertical"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -23,18 +24,9 @@
|
||||
avatar
|
||||
class="q-pr-none"
|
||||
>
|
||||
<div :style="indentSpacerStyle" />
|
||||
<div :style="{ paddingLeft: `${depth * 20}px` }" />
|
||||
</q-item-section>
|
||||
|
||||
<!--<q-item-section side>
|
||||
<q-toggle
|
||||
:model-value="active"
|
||||
:color="active ? 'green' : 'grey'"
|
||||
@update:model-value="val => $emit('update:active', val)"
|
||||
@click.stop
|
||||
/>
|
||||
</q-item-section>-->
|
||||
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="item.icon || 'far fa-file-alt'" />
|
||||
</q-item-section>
|
||||
@@ -74,71 +66,84 @@
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<slot name="actions">
|
||||
<div
|
||||
class="column q-gutter-xs"
|
||||
v-if="true"
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="fas fa-ellipsis-v"
|
||||
class="q-mr-xs"
|
||||
@click.stop
|
||||
>
|
||||
<q-menu>
|
||||
<q-list style="min-width: 140px">
|
||||
<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-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-section side
|
||||
><q-icon
|
||||
name="fas fa-trash"
|
||||
color="red"
|
||||
/></q-item-section>
|
||||
<q-item-section>Elimina</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div
|
||||
class="column q-gutter-xs"
|
||||
v-else
|
||||
>
|
||||
<q-btn
|
||||
dense
|
||||
round
|
||||
color="negative"
|
||||
icon="fas fa-trash"
|
||||
@click.stop="emitDelete"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
<q-btn
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="fas fa-ellipsis-v"
|
||||
class="q-mr-xs"
|
||||
@click.stop
|
||||
>
|
||||
<q-menu>
|
||||
<q-list style="min-width: 140px">
|
||||
<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-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-section side
|
||||
><q-icon
|
||||
name="fas fa-trash"
|
||||
color="red"
|
||||
/></q-item-section>
|
||||
<q-item-section>Elimina</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./MenuPageItem.ts"></script>
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './MenuPageItem.scss';
|
||||
</style>
|
||||
|
||||
@@ -1,225 +1,506 @@
|
||||
import {
|
||||
defineComponent, ref, computed, watch, reactive, toRaw, nextTick
|
||||
} from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import IconPicker from '../IconPicker/IconPicker.vue'
|
||||
import { IMyPage } from 'app/src/model'
|
||||
import { useGlobalStore } from 'app/src/store'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { costanti } from '@costanti'
|
||||
import { defineComponent, ref, computed, watch, reactive, toRaw, nextTick } from 'vue';
|
||||
import { useQuasar } from 'quasar';
|
||||
import IconPicker from '../IconPicker/IconPicker.vue';
|
||||
import { IMyPage } from 'app/src/model';
|
||||
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';
|
||||
|
||||
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}`;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageEditor',
|
||||
components: { IconPicker, CMyFieldRec },
|
||||
props: {
|
||||
modelValue: { type: Object as () => IMyPage, required: true },
|
||||
nuovaPagina: { type: Boolean, required: true } // <-- modalità "bozza"
|
||||
nuovaPagina: { type: Boolean, required: true },
|
||||
},
|
||||
emits: ['update:modelValue', 'apply', 'hide'],
|
||||
setup (props, { emit }) {
|
||||
const $q = useQuasar()
|
||||
setup(props, { emit }) {
|
||||
const $q = useQuasar();
|
||||
const { t } = useI18n();
|
||||
const globalStore = useGlobalStore();
|
||||
const { mypage } = storeToRefs(globalStore);
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalStore = useGlobalStore()
|
||||
const { mypage } = storeToRefs(globalStore)
|
||||
const draft = reactive<IMyPage>({ ...props.modelValue });
|
||||
|
||||
// Draft locale indipendente dal parent (specie in nuovaPagina)
|
||||
const draft = reactive<IMyPage>({ ...props.modelValue })
|
||||
const ui = reactive({
|
||||
pathText: toUiPath(draft.path),
|
||||
isSubmenu: !!draft.submenu,
|
||||
parentId: null as string | null,
|
||||
childrenPaths: [] as string[],
|
||||
});
|
||||
|
||||
// UI helper: path mostrato con "/" iniziale
|
||||
const ui = reactive({ pathText: toUiPath(draft.path) })
|
||||
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 saving = ref(false)
|
||||
const syncingFromProps = ref(false) // anti-loop
|
||||
// Draft indipendente
|
||||
const saving = ref(false);
|
||||
const syncingFromProps = ref(false);
|
||||
const previousPath = ref<string>(draft.path || '');
|
||||
|
||||
// --- Sync IN: quando cambia il valore del parent, aggiorna solo il draft
|
||||
// ===== 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)
|
||||
? draft.sottoMenu.map((p) => withSlash(p))
|
||||
: [];
|
||||
|
||||
// --- Sync IN: quando cambia il modelValue (esterno)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async v => {
|
||||
syncingFromProps.value = true
|
||||
Object.assign(draft, v || {})
|
||||
ui.pathText = toUiPath(draft.path)
|
||||
await nextTick()
|
||||
syncingFromProps.value = false
|
||||
async (v) => {
|
||||
syncingFromProps.value = true;
|
||||
Object.assign(draft, v || {});
|
||||
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))
|
||||
: [];
|
||||
previousPath.value = draft.path || '';
|
||||
await nextTick();
|
||||
syncingFromProps.value = false;
|
||||
},
|
||||
{ deep: false }
|
||||
)
|
||||
);
|
||||
|
||||
// --- Modifiche live: SE NON è nuovaPagina, aggiorna store e v-model del parent
|
||||
// --- Propagazione live (solo se NON nuovaPagina)
|
||||
watch(
|
||||
draft,
|
||||
(val) => {
|
||||
if (syncingFromProps.value) return
|
||||
if (props.nuovaPagina) return // <-- blocca ogni propagazione durante "nuova pagina"
|
||||
upsertIntoStore(val, mypage.value)
|
||||
emit('update:modelValue', { ...val })
|
||||
if (syncingFromProps.value) return;
|
||||
if (props.nuovaPagina) return;
|
||||
upsertIntoStore(val, mypage.value);
|
||||
emit('update:modelValue', { ...val });
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
);
|
||||
|
||||
// --- Helpers path
|
||||
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 () {
|
||||
// normalizza: niente spazi → trattini
|
||||
let p = (ui.pathText || '/').trim()
|
||||
p = p.replace(/\s+/g, '-')
|
||||
if (!p.startsWith('/')) p = '/' + p
|
||||
ui.pathText = p
|
||||
draft.path = toStorePath(p) // NB: scrive sul draft (watch sopra gestisce la propagazione)
|
||||
function onToggleSubmenu(val: boolean) {
|
||||
draft.submenu = !!val;
|
||||
if (val) {
|
||||
draft.inmenu = true;
|
||||
ui.childrenPaths = []; // sicurezza
|
||||
}
|
||||
}
|
||||
|
||||
function pathRule (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
|
||||
// ======= 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 selfPathDisp = 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) => {
|
||||
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));
|
||||
});
|
||||
|
||||
function toUiPath(storePath?: string) {
|
||||
const p = (storePath || '').trim();
|
||||
if (!p) return '/';
|
||||
return p.startsWith('/') ? p : `/${p}`;
|
||||
}
|
||||
|
||||
// --- Upsert nello store.mypage (usato solo quando NON è nuovaPagina o dopo il save)
|
||||
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) })
|
||||
function toStorePath(uiPath?: string) {
|
||||
const p = (uiPath || '').trim();
|
||||
if (!p) return '';
|
||||
return p.startsWith('/') ? p.slice(1) : p;
|
||||
}
|
||||
|
||||
// --- VALIDAZIONE + COMMIT per nuova pagina (o anche per edit espliciti)
|
||||
async function checkAndSave (payloadDraft?: IMyPage) {
|
||||
const cur = payloadDraft || draft
|
||||
function normalizeAndApplyPath() {
|
||||
let p = (ui.pathText || '/').trim();
|
||||
p = p.replace(/\s+/g, '-');
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
ui.pathText = p;
|
||||
draft.path = toStorePath(p);
|
||||
}
|
||||
|
||||
function pathRule(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) });
|
||||
}
|
||||
|
||||
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()) {
|
||||
$q.notify({ message: 'Inserisci il titolo della pagina', type: 'warning' })
|
||||
return
|
||||
$q.notify({ message: 'Inserisci il titolo della pagina', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pathText = (ui.pathText || '').trim()
|
||||
const pathText = (ui.pathText || '').trim();
|
||||
if (!pathText) {
|
||||
$q.notify({ message: 'Inserisci il percorso della pagina', type: 'warning' })
|
||||
return
|
||||
$q.notify({ message: 'Inserisci il percorso della pagina', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const candidatePath = toStorePath(pathText).toLowerCase()
|
||||
|
||||
// unicità PATH (ignora se stesso quando editing)
|
||||
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
|
||||
$q.notify({
|
||||
message: "Esiste già un'altra pagina con questo percorso",
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// unicità TITOLO (ignora se stesso quando editing)
|
||||
const candidateTitle = (cur.title || '').toLowerCase()
|
||||
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
|
||||
$q.notify({ message: 'Il nome della pagina esiste già', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
await save() // esegue commit vero
|
||||
emit('hide') // chiudi il dialog (se usi dialog)
|
||||
if (ui.isSubmenu && !ui.parentId) {
|
||||
$q.notify({
|
||||
message: 'Seleziona la pagina padre per questo sottomenu',
|
||||
type: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await save();
|
||||
emit('hide');
|
||||
}
|
||||
|
||||
// --- Salvataggio esplicito (commit). Qui propaghiamo SEMPRE al parent/store.
|
||||
async function save () {
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true
|
||||
normalizeAndApplyPath() // assicura path coerente
|
||||
saving.value = true;
|
||||
normalizeAndApplyPath();
|
||||
|
||||
const payload: IMyPage = { ...toRaw(draft), path: draft.path || '' }
|
||||
const saved = await globalStore.savePage(payload)
|
||||
// sync flag submenu
|
||||
draft.submenu = !!ui.isSubmenu;
|
||||
|
||||
// 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) // ora è lecito anche per nuovaPagina
|
||||
await nextTick()
|
||||
syncingFromProps.value = false
|
||||
syncingFromProps.value = true;
|
||||
Object.assign(draft, saved);
|
||||
upsertIntoStore(draft, mypage.value);
|
||||
await nextTick();
|
||||
syncingFromProps.value = false;
|
||||
}
|
||||
|
||||
// IMPORTANTISSIMO: in nuovaPagina non abbiamo mai emesso prima → emettiamo ora
|
||||
emit('update:modelValue', { ...draft })
|
||||
emit('apply', { ...draft })
|
||||
$q.notify({ type: 'positive', message: 'Pagina salvata' })
|
||||
// --- 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' })
|
||||
console.error(err);
|
||||
$q.notify({ type: 'negative', message: 'Errore nel salvataggio' });
|
||||
} finally {
|
||||
saving.value = false
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ricarica (per editing). In modalità nuovaPagina non propaghiamo al parent.
|
||||
async function reloadFromStore () {
|
||||
async function reloadFromStore() {
|
||||
try {
|
||||
const absolute = ui.pathText || '/'
|
||||
const page = await globalStore.loadPage(absolute, '', true)
|
||||
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)
|
||||
upsertIntoStore(draft, mypage.value)
|
||||
if (!props.nuovaPagina) emit('update:modelValue', { ...draft }) // <-- no propagate in nuovaPagina
|
||||
await nextTick()
|
||||
syncingFromProps.value = false
|
||||
$q.notify({ type: 'info', message: 'Pagina ricaricata' })
|
||||
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' })
|
||||
$q.notify({ type: 'warning', message: 'Pagina non trovata' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
$q.notify({ type: 'negative', message: 'Errore nel ricaricare la pagina' })
|
||||
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)
|
||||
if (!props.nuovaPagina) emit('update:modelValue', { ...draft }) // <-- no propagate in nuovaPagina
|
||||
nextTick(() => { syncingFromProps.value = false })
|
||||
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 ===
|
||||
function labelForPage(p: IMyPage) {
|
||||
return p.title || withSlash(p.path);
|
||||
}
|
||||
|
||||
function labelForPath(dispPath: string) {
|
||||
const p = pageByDisplayPath.value.get(dispPath.toLowerCase());
|
||||
return p ? labelForPage(p) : dispPath;
|
||||
}
|
||||
|
||||
function modifElem() {
|
||||
|
||||
/* per compat compat con CMyFieldRec */
|
||||
}
|
||||
|
||||
const absolutePath = computed(() => toUiPath(draft.path))
|
||||
const absolutePath = computed(() => toUiPath(draft.path));
|
||||
|
||||
return {
|
||||
draft,
|
||||
ui,
|
||||
saving,
|
||||
t,
|
||||
costanti,
|
||||
// helpers UI
|
||||
pathRule,
|
||||
normalizeAndApplyPath,
|
||||
labelForPath,
|
||||
labelForPage,
|
||||
withSlash,
|
||||
// actions
|
||||
checkAndSave,
|
||||
save,
|
||||
reloadFromStore,
|
||||
resetDraft,
|
||||
absolutePath,
|
||||
checkAndSave,
|
||||
t,
|
||||
costanti,
|
||||
modifElem,
|
||||
}
|
||||
}
|
||||
})
|
||||
onToggleSubmenu,
|
||||
// options
|
||||
parentOptions,
|
||||
childCandidateOptions,
|
||||
// expose util
|
||||
absolutePath,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
class="q-pa-md"
|
||||
>
|
||||
<div class="row q-col-gutter-md">
|
||||
<!-- PATH -->
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="ui.pathText"
|
||||
@@ -13,12 +14,11 @@
|
||||
:rules="[pathRule]"
|
||||
@blur="normalizeAndApplyPath"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="fas fa-link" />
|
||||
</template>
|
||||
<template #prepend><q-icon name="fas fa-link" /></template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- TITOLO -->
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model="draft.title"
|
||||
@@ -28,6 +28,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ORDINE -->
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
v-model.number="draft.order"
|
||||
@@ -41,25 +42,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
SottoMenu:
|
||||
<CMyFieldRec
|
||||
title="SottoMenu:"
|
||||
table="pages"
|
||||
:id="draft._id"
|
||||
:rec="draft"
|
||||
field="sottoMenu"
|
||||
@update:model-value="modifElem"
|
||||
:canEdit="true"
|
||||
:canModify="true"
|
||||
:nosaveToDb="true"
|
||||
:fieldtype="costanti.FieldType.multiselect"
|
||||
>
|
||||
</CMyFieldRec>
|
||||
|
||||
<!-- ICONA -->
|
||||
<div class="col-12 col-md-6">
|
||||
<icon-picker v-model="draft.icon" />
|
||||
</div>
|
||||
|
||||
<!-- STATO & VISIBILITÀ -->
|
||||
<div class="col-12">
|
||||
<q-separator spaced />
|
||||
<div class="row items-center q-col-gutter-md">
|
||||
@@ -89,8 +77,91 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GESTIONE FIGLI (solo se questa pagina è TOP-LEVEL) -->
|
||||
<div
|
||||
class="col-12"
|
||||
v-if="!ui.isSubmenu"
|
||||
>
|
||||
<q-separator spaced />
|
||||
<div class="text-subtitle2 q-mb-sm">SottoMenu</div>
|
||||
|
||||
<!-- Selettore multivalore dei figli (con label indentata nell'option slot) -->
|
||||
<q-select
|
||||
v-model="ui.childrenPaths"
|
||||
:options="childCandidateOptions"
|
||||
multiple
|
||||
use-chips
|
||||
dense
|
||||
filled
|
||||
emit-value
|
||||
map-options
|
||||
label="Pagine figlie"
|
||||
>
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<div :style="{ paddingLeft: scope.opt.level * 14 + 'px' }">
|
||||
{{ scope.opt.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="scope.opt.hint"
|
||||
class="text-caption text-grey-7"
|
||||
>
|
||||
{{ scope.opt.hint }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
side
|
||||
v-if="scope.opt.disabled"
|
||||
>
|
||||
<q-icon
|
||||
name="lock"
|
||||
class="text-grey-6"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<!-- Preview gerarchica reale (indentata) -->
|
||||
<div class="q-mt-md">
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Anteprima struttura</div>
|
||||
<q-list
|
||||
bordered
|
||||
class="rounded-borders"
|
||||
>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<div class="text-weight-medium">
|
||||
{{ draft.title || withSlash(draft.path) }}
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<template v-if="ui.childrenPaths.length">
|
||||
<q-item
|
||||
v-for="(cp, i) in ui.childrenPaths"
|
||||
:key="i"
|
||||
class="q-pl-md"
|
||||
>
|
||||
<q-item-section>
|
||||
<div class="text-body2">— {{ labelForPath(cp) }}</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="q-pa-sm text-grey-7 text-caption"
|
||||
>
|
||||
Nessun sottomenu selezionato.
|
||||
</div>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AZIONI -->
|
||||
<q-card-actions
|
||||
align="center"
|
||||
class="q-pa-md q-gutter-md"
|
||||
@@ -111,7 +182,6 @@
|
||||
label="Chiudi"
|
||||
v-close-popup
|
||||
/>
|
||||
<!--<q-btn flat color="grey-7" label="Reset draft" @click="resetDraft" />-->
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.menu-container {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
@@ -35,164 +35,168 @@ export default defineComponent({
|
||||
|
||||
const visualizzaEditor = ref(false);
|
||||
const nuovaPagina = ref(false);
|
||||
const selectedKey = ref<string | null>(null);
|
||||
|
||||
const ORDER_STEP = 10;
|
||||
const MIN_GAP = 1;
|
||||
// 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...)
|
||||
|
||||
function getPageByKey(key?: string) {
|
||||
return key ? pages.value.find((p) => p.__key === key) : undefined;
|
||||
}
|
||||
|
||||
function getOrderOfRow(row?: PageRow) {
|
||||
const p = row ? getPageByKey(row.__key) : undefined;
|
||||
return typeof p?.order === 'number' ? (p!.order as number) : undefined;
|
||||
// ===== NUOVO SISTEMA DI ORDINAMENTO GERARCHICO =====
|
||||
|
||||
/**
|
||||
* 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];
|
||||
|
||||
// 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++;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assegna l'order SOLO all'elemento spostato (e, se serve, a una piccola finestra attorno)
|
||||
* usando un ordinamento "sparso": media fra i vicini, oppure reseed locale.
|
||||
* Ritorna i delta {id, order} da salvare.
|
||||
* Aggiorna gli ordini di TUTTI gli elementi in base alla loro posizione nella struttura
|
||||
* Questo è il cuore del nuovo sistema di ordinamento
|
||||
*/
|
||||
function sparseAssignOrder(
|
||||
rows: PageRow[],
|
||||
movedIndex: number
|
||||
): { id: string; order: number }[] {
|
||||
function updateAllOrders(rows: PageRow[]): { id: string; order: number }[] {
|
||||
const deltas: { id: string; order: number }[] = [];
|
||||
const curRow = rows[movedIndex];
|
||||
if (!curRow?.__key) return deltas;
|
||||
|
||||
const cur = getPageByKey(curRow.__key);
|
||||
if (!cur) return deltas;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const page = getPageByKey(row.__key);
|
||||
|
||||
const prevOrder = getOrderOfRow(rows[movedIndex - 1]);
|
||||
const nextOrder = getOrderOfRow(rows[movedIndex + 1]);
|
||||
if (!page) continue;
|
||||
|
||||
const pushDelta = (p: PageWithKey, val: number) => {
|
||||
if (p.order !== val) {
|
||||
p.order = val;
|
||||
if (p._id) deltas.push({ id: p._id, order: val });
|
||||
const newOrder = calculateHierarchicalOrder(rows, i);
|
||||
|
||||
if (page.order !== newOrder) {
|
||||
page.order = newOrder;
|
||||
if (page._id) {
|
||||
deltas.push({ id: page._id, order: newOrder });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Caso 1: abbiamo spazio tra prev e next → usa media
|
||||
if (
|
||||
prevOrder !== undefined &&
|
||||
nextOrder !== undefined &&
|
||||
nextOrder - prevOrder > MIN_GAP
|
||||
) {
|
||||
const mid = prevOrder + Math.floor((nextOrder - prevOrder) / 2);
|
||||
pushDelta(cur, mid);
|
||||
return deltas;
|
||||
}
|
||||
|
||||
// Caso 2: in testa → prima del first
|
||||
if (prevOrder === undefined && nextOrder !== undefined) {
|
||||
pushDelta(cur, nextOrder - ORDER_STEP);
|
||||
return deltas;
|
||||
}
|
||||
|
||||
// Caso 3: in coda → dopo l'ultimo
|
||||
if (prevOrder !== undefined && nextOrder === undefined) {
|
||||
pushDelta(cur, prevOrder + ORDER_STEP);
|
||||
return deltas;
|
||||
}
|
||||
|
||||
// Caso 4: nessuno spazio (o ordini uguali) → reseed locale (finestra stretta)
|
||||
const start = Math.max(0, movedIndex - 3);
|
||||
const end = Math.min(rows.length - 1, movedIndex + 3);
|
||||
|
||||
// base = order dell’elemento appena prima della finestra (se esiste), altrimenti 0
|
||||
let base = getOrderOfRow(rows[start - 1]) ?? 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const r = rows[i];
|
||||
const p = getPageByKey(r.__key!);
|
||||
if (!p) continue;
|
||||
base += ORDER_STEP;
|
||||
pushDelta(p, base);
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/** order per append in fondo a "menu" */
|
||||
function computeAppendOrderForMenu(): number {
|
||||
let max = 0;
|
||||
for (const p of pages.value)
|
||||
if (p.inmenu && typeof p.order === 'number') max = Math.max(max, p.order!);
|
||||
return max + ORDER_STEP;
|
||||
}
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** order per append in fondo a "off" (dopo tutti) */
|
||||
function computeAppendOrderForOff(): number {
|
||||
let max = 0;
|
||||
for (const p of pages.value)
|
||||
if (typeof p.order === 'number') max = Math.max(max, p.order!);
|
||||
return max + ORDER_STEP;
|
||||
}
|
||||
// Se è stato spostato verso l'alto, controlla l'elemento prima di esso
|
||||
const prevRow = newRows[targetIndex - 1];
|
||||
|
||||
// ---- STATE BASE --------------------------------------------------------
|
||||
const pages = ref<PageWithKey[]>(
|
||||
props.modelValue ? props.modelValue.map((p) => ({ ...p })) : []
|
||||
);
|
||||
ensureKeys(pages.value);
|
||||
pages.value.sort(byOrder)
|
||||
// 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);
|
||||
|
||||
const selectedKey = ref<string | null>(null);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Se la distanza è piccola (es. < 30px), consideralo un sottomenu
|
||||
if (visualDistance < 30) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
menuRows.value = rows;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function rebuildOffList() {
|
||||
offList.value = pages.value.filter((p) => !p.inmenu).sort(byOrder);
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildAllViews() {
|
||||
rebuildMenuRows();
|
||||
rebuildOffList();
|
||||
globalStore.aggiornaMenu($router);
|
||||
}
|
||||
// ⬇️ Sostituisci completamente la funzione esistente
|
||||
function applyMenuRows(
|
||||
newRows: PageRow[],
|
||||
movedIndex?: number
|
||||
): { id: string; order: number }[] {
|
||||
/**
|
||||
* 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 = [];
|
||||
@@ -228,14 +232,10 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
// 4) assegna order in modalità "sparsa" SOLO per l’elemento spostato (e finestra vicina)
|
||||
if (typeof movedIndex === 'number') {
|
||||
return sparseAssignOrder(newRows, movedIndex);
|
||||
}
|
||||
return [];
|
||||
// 4) aggiorna TUTTI gli ordini in base alla posizione nella struttura
|
||||
return updateAllOrders(newRows);
|
||||
}
|
||||
|
||||
// ⬇️ Sostituisci completamente la funzione esistente
|
||||
function applyOffList(
|
||||
newOff: PageWithKey[],
|
||||
movedIndex?: number
|
||||
@@ -276,54 +276,242 @@ export default defineComponent({
|
||||
}
|
||||
};
|
||||
|
||||
if (prevO !== undefined && nextO !== undefined && nextO - prevO > MIN_GAP) {
|
||||
// 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 && nextO === undefined) {
|
||||
pushDelta(curP!, prevO + ORDER_STEP);
|
||||
} else if (prevO === undefined && nextO !== undefined) {
|
||||
pushDelta(curP!, nextO - ORDER_STEP);
|
||||
} else if (prevO !== undefined) {
|
||||
pushDelta(curP!, prevO + 10);
|
||||
} else if (nextO !== undefined) {
|
||||
pushDelta(curP!, nextO - 10);
|
||||
} else {
|
||||
// reseed locale nell'offList
|
||||
const start = Math.max(0, movedIndex - 3);
|
||||
const end = Math.min(newOff.length - 1, movedIndex + 3);
|
||||
let base =
|
||||
start > 0 ? (getPageByKey(newOff[start - 1].__key!)?.order ?? 0) : 0;
|
||||
for (let i = start; i <= end; i++) {
|
||||
const r = newOff[i];
|
||||
const p = getPageByKey(r.__key!);
|
||||
if (!p) continue;
|
||||
base += ORDER_STEP;
|
||||
pushDelta(p, base);
|
||||
}
|
||||
pushDelta(curP!, 10000); // Default per elementi in fondo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deltas;
|
||||
}
|
||||
// ---- 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 }
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Imposta come nel menu
|
||||
const page = getPageByKey(element.__key);
|
||||
if (page) {
|
||||
page.inmenu = true;
|
||||
page.submenu = false; // inizialmente top-level
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Creiamo una copia della lista corrente
|
||||
const newOff = [...offList.value];
|
||||
let deltas: { id: string; order: number }[] = [];
|
||||
|
||||
// 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
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(() =>
|
||||
@@ -340,34 +528,33 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
// ---- AZIONI UI ---------------------------------------------------------
|
||||
function addSubmenu() {
|
||||
const p = pages.value.find((x) => x.__key === selectedKey.value);
|
||||
if (!p) return;
|
||||
|
||||
if (!Array.isArray(p.sottoMenu)) p.sottoMenu = [];
|
||||
if (p.submenu !== true) p.submenu = true;
|
||||
if (p.mainMenu !== true) p.mainMenu = true;
|
||||
|
||||
// placeholder path
|
||||
const base = '/nuova-voce';
|
||||
let name = base;
|
||||
let i = 1;
|
||||
while (p.sottoMenu.includes(name)) {
|
||||
i++;
|
||||
name = `${base}-${i}`;
|
||||
}
|
||||
p.sottoMenu.push(name);
|
||||
|
||||
emit(
|
||||
'update:modelValue',
|
||||
pages.value.map((x) => ({ ...x }))
|
||||
);
|
||||
rebuildAllViews();
|
||||
}
|
||||
|
||||
function addPage(bucket: Bucket) {
|
||||
visualizzaEditor.value = true; // ⬅️ aggiungi
|
||||
nuovaPagina.value = true; // ⬅️ aggiungi
|
||||
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 np: PageWithKey = {
|
||||
title: '',
|
||||
path: '/nuova-pagina',
|
||||
@@ -377,8 +564,7 @@ export default defineComponent({
|
||||
inmenu: bucket === 'menu',
|
||||
submenu: false,
|
||||
onlyif_logged: false,
|
||||
order:
|
||||
bucket === 'menu' ? computeAppendOrderForMenu() : computeAppendOrderForOff(),
|
||||
order: order,
|
||||
__key: `tmp_${uidSeed++}`,
|
||||
};
|
||||
pages.value.push(np);
|
||||
@@ -427,68 +613,20 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
|
||||
function move(bucket: Bucket, idx: number, delta: number) {
|
||||
if (bucket === 'menu') {
|
||||
const list = menuRows.value.slice();
|
||||
const to = idx + delta;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [it] = list.splice(idx, 1);
|
||||
list.splice(to, 0, it);
|
||||
menuRows.value = list;
|
||||
onMenuDragChange({ moved: { newIndex: to } }); // ⬅️ usa handler con indice
|
||||
selectedKey.value = it.__key!;
|
||||
} else {
|
||||
const list = offList.value.slice();
|
||||
const to = idx + delta;
|
||||
if (to < 0 || to >= list.length) return;
|
||||
const [it] = list.splice(idx, 1);
|
||||
list.splice(to, 0, it);
|
||||
offList.value = list;
|
||||
onOffDragChange({ moved: { newIndex: to } }); // ⬅️ idem
|
||||
selectedKey.value = it.__key!;
|
||||
}
|
||||
function editAt(idx: number) {
|
||||
const key = (menuRows.value[idx] || offList.value[idx])?.__key;
|
||||
selectedKey.value = key || selectedKey.value;
|
||||
|
||||
visualizzaEditor.value = true;
|
||||
nuovaPagina.value = false;
|
||||
}
|
||||
|
||||
// ⬇️ Sostituisci la tua onMenuDragChange
|
||||
function onMenuDragChange(evt?: any) {
|
||||
const movedIndex: number | undefined = evt?.moved?.newIndex;
|
||||
applyingRows.value = true;
|
||||
let deltas: { id: string; order: number }[] = [];
|
||||
try {
|
||||
deltas = applyMenuRows(menuRows.value, movedIndex);
|
||||
} finally {
|
||||
applyingRows.value = false;
|
||||
rebuildAllViews();
|
||||
}
|
||||
|
||||
emit(
|
||||
'update:modelValue',
|
||||
pages.value.map((p) => ({ ...p }))
|
||||
);
|
||||
if (deltas.length) emit('change-order', deltas);
|
||||
if (typeof globalStore.aggiornaMenu === 'function') {
|
||||
try {
|
||||
globalStore.aggiornaMenu($router);
|
||||
} catch {}
|
||||
}
|
||||
function openKey(key?: string) {
|
||||
const p = pages.value.find((x) => x.__key === key);
|
||||
if (!p) return;
|
||||
$router.push(`/${p.path}?edit=1`);
|
||||
}
|
||||
// ⬇️ Sostituisci la tua onOffDragChange
|
||||
function onOffDragChange(evt?: any) {
|
||||
const movedIndex: number | undefined = evt?.moved?.newIndex;
|
||||
const deltas = applyOffList(offList.value, movedIndex);
|
||||
|
||||
rebuildAllViews();
|
||||
emit(
|
||||
'update:modelValue',
|
||||
pages.value.map((p) => ({ ...p }))
|
||||
);
|
||||
if (deltas.length) emit('change-order', deltas);
|
||||
if (typeof globalStore.aggiornaMenu === 'function') {
|
||||
try {
|
||||
globalStore.aggiornaMenu($router);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
function onApply() {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
@@ -497,24 +635,33 @@ export default defineComponent({
|
||||
const cur = currentIdx.value >= 0 ? pages.value[currentIdx.value] : undefined;
|
||||
emit('save', cur);
|
||||
rebuildAllViews();
|
||||
|
||||
visualizzaEditor.value = false; // ⬅️ aggiungi
|
||||
nuovaPagina.value = false; // ⬅️ aggiungi
|
||||
visualizzaEditor.value = false;
|
||||
nuovaPagina.value = false;
|
||||
}
|
||||
|
||||
function editAt(idx: number) {
|
||||
const key = (menuRows.value[idx] || offList.value[idx])?.__key;
|
||||
selectedKey.value = key || selectedKey.value;
|
||||
// ---- 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 }
|
||||
);
|
||||
|
||||
visualizzaEditor.value = true; // ⬅️ aggiungi
|
||||
nuovaPagina.value = false; // ⬅️ aggiungi
|
||||
}
|
||||
|
||||
function openKey(key?: string) {
|
||||
const p = pages.value.find((x) => x.__key === key);
|
||||
if (!p) return;
|
||||
$router.push(`/${p.path}?edit=1`);
|
||||
}
|
||||
// ricostruisci la vista quando pages cambia (evita durante apply)
|
||||
watch(
|
||||
() => pages.value,
|
||||
() => {
|
||||
if (applyingRows.value) return;
|
||||
rebuildAllViews();
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
// ---- EXPOSE ------------------------------------------------------------
|
||||
return {
|
||||
@@ -526,17 +673,15 @@ export default defineComponent({
|
||||
// actions
|
||||
select,
|
||||
addPage,
|
||||
addSubmenu,
|
||||
removeAt,
|
||||
move,
|
||||
editAt,
|
||||
onMenuDragChange,
|
||||
onOffDragChange,
|
||||
onApply,
|
||||
displayPath,
|
||||
editAt,
|
||||
openKey,
|
||||
visualizzaEditor, // ⬅️ aggiungi
|
||||
nuovaPagina, // ⬅️ aggiungi
|
||||
visualizzaEditor,
|
||||
nuovaPagina,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<q-card
|
||||
flat
|
||||
bordered
|
||||
class="menu-container"
|
||||
>
|
||||
<q-toolbar>
|
||||
<q-toolbar-title>Menu</q-toolbar-title>
|
||||
@@ -16,16 +17,9 @@
|
||||
<q-btn
|
||||
dense
|
||||
icon="fas fa-plus"
|
||||
label="Nuovo"
|
||||
label="Nuova Pagina"
|
||||
@click="addPage('menu')"
|
||||
/>
|
||||
<q-btn
|
||||
dense
|
||||
icon="fas fa-sitemap"
|
||||
label="SottoMenu"
|
||||
:disable="!selectedKey"
|
||||
@click="addSubmenu()"
|
||||
/>
|
||||
</q-toolbar>
|
||||
|
||||
<draggable
|
||||
@@ -44,6 +38,7 @@
|
||||
v-model:active="element.active"
|
||||
:depth="element.__depth"
|
||||
variant="menu"
|
||||
class="menu-item"
|
||||
@select="select(element.__key)"
|
||||
@edit="editAt(index)"
|
||||
@delete="removeAt('menu', index)"
|
||||
@@ -103,7 +98,6 @@
|
||||
<!-- Editor -->
|
||||
<q-dialog
|
||||
v-model="visualizzaEditor"
|
||||
persistent
|
||||
>
|
||||
<page-editor
|
||||
v-if="currentIdx !== -1"
|
||||
@@ -119,4 +113,8 @@
|
||||
<script lang="ts" src="./PagesConfigurator.ts"></script>
|
||||
<style lang="scss" scoped>
|
||||
@import './PagesConfigurator.scss';
|
||||
|
||||
.menu-container {
|
||||
min-height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user