- inizio di modifiche all'editor di Pagine Web

This commit is contained in:
Surya Paolo
2025-09-05 01:05:36 +02:00
parent 574f389200
commit 63d0f865fd
55 changed files with 5356 additions and 3600 deletions

View File

@@ -11,6 +11,7 @@ import { CMyTeacher } from '@src/components/CMyTeacher'
// @ts-ignore
import MixinOperator from '../../mixins/mixin-operator'
import MixinUsers from '../../mixins/mixin-users'
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'CCardDiscipline',

View File

@@ -357,7 +357,7 @@ export default defineComponent({
recOrderCart.value = rissconto.mycart
};
}
} catch (error) {
} catch (error: any) {
console.log('error ApplicaSconto', error);
tools.showNegativeNotif($q, `Sconto Non Applicato! ${error?.message || ''}`);
codice_sconto.value = '';

View File

@@ -0,0 +1,19 @@
<template>
<div class="column">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'CColumn'
});
</script>
<style scoped>
.column {
flex: 1;
}
</style>

View File

@@ -426,6 +426,7 @@ export default defineComponent({
function isIMG() {
return props.filetype === shared_consts.FILETYPE.IMG;
}
/*
const uploadFactory = async (files: readonly File[]) => {
const userStore = useUserStore();
const url = getUrl();
@@ -451,11 +452,11 @@ export default defineComponent({
// usa la tua logica centralizzata
Api.checkTokenScaduto(
status,
/*evitaloop*/ false,
false,
url,
'POST',
null,
/*setAuthToken*/ true
true
);
if (ret !== null) {
// token aggiornato -> ritenta UNA volta
@@ -475,7 +476,7 @@ export default defineComponent({
throw err2;
}
}
};
}; */
onMounted(created);
@@ -514,7 +515,6 @@ export default defineComponent({
isIMG,
isPDF,
upl,
uploadFactory,
t,
};
},

View File

@@ -1,44 +1,35 @@
<template>
<div>
<div v-if="arrprovince" id="map" :style="`height:${myheight()}px; width:99%`">
</div>
<!--
<div :style="`height:${myheight()}px; width:99%`">
<l-map
v-model="zoom"
v-model:zoom="zoom"
:center="[42.71, 12.934]"
@move="log('move')"
@click="getCoordinates"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
></l-tile-layer>
<l-control-layers />
<span v-for="provincia in arrprovince" :key="provincia.nome">
<l-marker-cluster :options="clusterOptions">
<l-marker
v-if="provincia.userCount > 0"
:lat-lng="[provincia.lat, provincia.long]"
>
<l-popup
>{{ provincia.descr }}:
{{ provincia.userCount }} utenti</l-popup
>
</l-marker>
</l-marker-cluster>
</span>
</l-map>
<button @click="changeIcon">New kitten icon</button>
</div>
-->
<div class="map-container">
<div id="map" style="height: 500px;"></div>
</div>
</template>
<script lang="ts" src="./CMapUsers.ts">
<script lang="ts">
import { defineComponent, onMounted } from 'vue'
import { tools } from '@tools'
import { useGlobalStore } from 'app/src/store'
export default defineComponent({
name: 'CMapUsers',
setup() {
const globalStore = useGlobalStore()
onMounted(() => {
// Initialize map logic here
/*tools.initUserMap('map', {
center: { lat: 45.4642, lng: 9.1900 }, // Default to Milan coordinates
zoom: 6
})*/
})
return { tools, globalStore }
}
})
</script>
<style lang="scss" scoped>
@import './CMapUsers.scss';
<style scoped>
.map-container {
width: 100%;
height: 100%;
}
</style>

View File

@@ -628,6 +628,15 @@ export default defineComponent({
);
}
function isLayoutContainer() {
const t = myel.value?.type;
return (
t === shared_consts.ELEMTYPE.SECTION ||
t === shared_consts.ELEMTYPE.ROW ||
t === shared_consts.ELEMTYPE.COLUMN
);
}
/*function updateElem(myvalue: any) {
console.log('updateElem', myvalue)
if (myel.value.type === shared_consts.ELEMTYPE.IMGTITLE) {

View File

@@ -1,87 +1,122 @@
import type { PropType } from 'vue';
import {
computed,
defineComponent, onMounted, ref, toRef, watch, nextTick,
} from 'vue'
import { computed, defineComponent, onMounted, ref, toRef, watch, nextTick } from 'vue';
import type { IOptCatalogo, ICoordGPS, IMyElem, ISocial } from '@src/model';
import { IMyCard, IMyPage, IOperators } from '@src/model'
import { useGlobalStore } from '@store/globalStore'
import { IMyCard, IMyPage, IOperators } from '@src/model';
import { useGlobalStore } from '@store/globalStore';
import { CImgTitle } from '../CImgTitle/index'
import { CImgPoster } from '@src/components/CImgPoster'
import { CTitle } from '@src/components/CTitle/index'
import { CGridOriz } from '@src/components/CGridOriz/index'
import { ChatBot } from '@src/components/ChatBot/index'
import { CCatalogList } from '@src/components/CCatalogList/index'
import { CRaccoltaCataloghi } from '@src/components/CRaccoltaCataloghi/index'
import { tools } from '@tools'
import { shared_consts } from '@src/common/shared_vuejs'
import { LandingFooter } from '@src/components/LandingFooter'
import { CMyActivities } from '@src/components/CMyActivities'
import { CECommerce } from '@src/components/CECommerce'
import { CStatMacro } from '@src/components/CStatMacro'
import { CSearchProduct } from '@src/components/CSearchProduct'
import { CPageViewStats } from '@src/components/CPageViewStats'
import { CQRCode } from '@src/components/CQRCode'
import { CAITools } from '@src/components/CAITools'
import { CCatalogo } from '@src/components/CCatalogo'
import { CRaccolta } from '@src/components/CRaccolta'
import { CImgTitle } from '../CImgTitle/index';
import { CImgPoster } from '@src/components/CImgPoster';
import CSection from '@src/components/CSection/CSection.vue';
import CRow from '@src/components/CRow/CRow.vue';
import CColumn from '@src/components/CColumn/CColumn.vue';
import { CTitle } from '@src/components/CTitle/index';
import { CGridOriz } from '@src/components/CGridOriz/index';
import { ChatBot } from '@src/components/ChatBot/index';
import { CCatalogList } from '@src/components/CCatalogList/index';
import { CRaccoltaCataloghi } from '@src/components/CRaccoltaCataloghi/index';
import { tools } from '@tools';
import { shared_consts } from '@src/common/shared_vuejs';
import { LandingFooter } from '@src/components/LandingFooter';
import { CMyActivities } from '@src/components/CMyActivities';
import { CECommerce } from '@src/components/CECommerce';
import { CStatMacro } from '@src/components/CStatMacro';
import { CSearchProduct } from '@src/components/CSearchProduct';
import { CPageViewStats } from '@src/components/CPageViewStats';
import { CQRCode } from '@src/components/CQRCode';
import { CAITools } from '@src/components/CAITools';
import { CCatalogo } from '@src/components/CCatalogo';
import { CRaccolta } from '@src/components/CRaccolta';
// import { CMapMarker } from '@src/components/CMapMarker.off'
import { CMapUsers } from '@src/components/CMapUsers'
import { CMapGetCoordinates } from '@src/components/CMapGetCoordinates'
import { CMapEditAddressByCoord } from '@src/components/CMapEditAddressByCoord'
import { CMapComuni } from '@src/components/CMapComuni'
import { COpenStreetMap } from '@src/components/COpenStreetMap'
import { CCardCarousel } from '@src/components/CCardCarousel'
import { CMyPage } from '@src/components/CMyPage'
import { CMyPageIntro } from '@src/components/CMyPageIntro'
import { CEventsCalendar } from '@src/components/CEventsCalendar'
import { CMyEditor } from '@src/components/CMyEditor'
import { CMyFieldRec } from '@src/components/CMyFieldRec'
import { CSelectColor } from '@src/components/CSelectColor'
import { CMainView } from '@src/components/CMainView'
import { CMyProfileTutorial } from '@src/components/CMyProfileTutorial'
import { CSendRISTo } from '@src/components/CSendRISTo'
import { CDashboard } from '@src/components/CDashboard'
import { CDashGroup } from '@src/components/CDashGroup'
import { CMovements } from '@src/components/CMovements'
import { CCheckAppRunning } from '@src/components/CCheckAppRunning'
import { CStatusReg } from '@src/components/CStatusReg'
import { CTitleBanner } from '@src/components/CTitleBanner'
import { CCheckIfIsLogged } from '@src/components/CCheckIfIsLogged'
import { CSelectFontSize } from '@src/components/CSelectFontSize'
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 { CVisuVideoPromoAndPDF } from '@src/components/CVisuVideoPromoAndPDF'
import { CMapUsers } from '@src/components/CMapUsers';
import { CMapGetCoordinates } from '@src/components/CMapGetCoordinates';
import { CMapEditAddressByCoord } from '@src/components/CMapEditAddressByCoord';
import { CMapComuni } from '@src/components/CMapComuni';
import { COpenStreetMap } from '@src/components/COpenStreetMap';
import { CCardCarousel } from '@src/components/CCardCarousel';
import { CMyPage } from '@src/components/CMyPage';
import { CMyPageIntro } from '@src/components/CMyPageIntro';
import { CEventsCalendar } from '@src/components/CEventsCalendar';
import { CMyEditor } from '@src/components/CMyEditor';
import { CMyFieldRec } from '@src/components/CMyFieldRec';
import { CSelectColor } from '@src/components/CSelectColor';
import { CMainView } from '@src/components/CMainView';
import { CMyProfileTutorial } from '@src/components/CMyProfileTutorial';
import { CSendRISTo } from '@src/components/CSendRISTo';
import { CDashboard } from '@src/components/CDashboard';
import { CDashGroup } from '@src/components/CDashGroup';
import { CMovements } from '@src/components/CMovements';
import { CCheckAppRunning } from '@src/components/CCheckAppRunning';
import { CStatusReg } from '@src/components/CStatusReg';
import { CTitleBanner } from '@src/components/CTitleBanner';
import { CCheckIfIsLogged } from '@src/components/CCheckIfIsLogged';
import { CSelectFontSize } from '@src/components/CSelectFontSize';
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 { CVisuVideoPromoAndPDF } from '@src/components/CVisuVideoPromoAndPDF';
import MixinMetaTags from '@src/mixins/mixin-metatags'
import MixinBase from '@src/mixins/mixin-base'
import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { LatLng } from 'leaflet'
import { costanti } from '@costanti'
import MixinMetaTags from '@src/mixins/mixin-metatags';
import MixinBase from '@src/mixins/mixin-base';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { LatLng } from 'leaflet';
import { costanti } from '@costanti';
export default defineComponent({
name: 'CMyElem',
components: {
CImgTitle, CTitle, LandingFooter, CEventsCalendar,
CCardCarousel, COpenStreetMap, CMyPage, CMyPageIntro, CMyEditor, CMyFieldRec,
CSelectColor, CSelectFontSize, CImgPoster,
CCheckIfIsLogged, CStatusReg, CDashboard, CMainView, CNotifAtTop,
CPresentazione, CMyActivities,
CMyProfileTutorial, CSendRISTo,
CTitleBanner, CShareSocial, CCheckAppRunning, CRegistration,
CVisuVideoPromoAndPDF, CECommerce, CCatalogo, CRaccolta, CAITools, CStatMacro,
CMapComuni, CMapUsers, CMapGetCoordinates, CMapEditAddressByCoord,
CDashGroup, CMovements, CGridOriz, CQRCode, CCatalogList,
CSearchProduct, CRaccoltaCataloghi, CPageViewStats,
CImgTitle,
CTitle,
LandingFooter,
CEventsCalendar,
CCardCarousel,
COpenStreetMap,
CMyPage,
CMyPageIntro,
CMyEditor,
CMyFieldRec,
CSelectColor,
CSelectFontSize,
CImgPoster,
CCheckIfIsLogged,
CStatusReg,
CDashboard,
CMainView,
CNotifAtTop,
CPresentazione,
CMyActivities,
CMyProfileTutorial,
CSendRISTo,
CTitleBanner,
CShareSocial,
CCheckAppRunning,
CRegistration,
CVisuVideoPromoAndPDF,
CECommerce,
CCatalogo,
CRaccolta,
CAITools,
CStatMacro,
CMapComuni,
CMapUsers,
CMapGetCoordinates,
CMapEditAddressByCoord,
CDashGroup,
CMovements,
CGridOriz,
CQRCode,
CCatalogList,
CSearchProduct,
CRaccoltaCataloghi,
CPageViewStats,
ChatBot,
CSection,
CRow,
CColumn,
// , //CMapMarker,
},
emits: ['selElemClick'],
@@ -116,205 +151,212 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const globalStore = useGlobalStore()
const globalStore = useGlobalStore();
const { setmeta, getsrcbyimg } = MixinMetaTags()
const { setValDb, getValDb } = MixinBase()
const { setmeta, getsrcbyimg } = MixinMetaTags();
const { setValDb, getValDb } = MixinBase();
const $router = useRouter()
const $router = useRouter();
const $q = useQuasar()
const { t } = useI18n()
const $q = useQuasar();
const { t } = useI18n();
const animare = ref(0)
const animarecard = ref(0)
const slide = ref(0)
const slide2 = ref(0)
const disableSave = ref(true)
const enableEdit = ref(false)
const enableAdd = ref(true)
const visushare = ref(false)
const animare = ref(0);
const animarecard = ref(0);
const slide = ref(0);
const slide2 = ref(0);
const disableSave = ref(true);
const enableEdit = ref(false);
const enableAdd = ref(true);
const visushare = ref(false);
const tabcatalogo = ref('griglia')
const tabcatalogo = ref('griglia');
const social = ref(<ISocial>{})
const social = ref(<ISocial>{});
const neworder = ref(<number | undefined>0)
const neworder = ref(<number | undefined>0);
const myel = ref(<IMyElem>{})
const myel = ref(<IMyElem>{});
const newtype = ref(<any>'')
const newtype = ref(<any>'');
const isAppRunning = computed(() => globalStore.isAppRunning)
const isAppRunning = computed(() => globalStore.isAppRunning);
const currentCardsPerSlide = computed(() => {
return myel.value.num2 ? myel.value.num2 : 2 // cardsPerSlide
})
return myel.value.num2 ? myel.value.num2 : 2; // cardsPerSlide
});
// Raggruppa le card in base al numero di card per slide
const cardGroups = computed(() => {
const cards = myel.value.listcards || []
const groups = []
const cards = myel.value.listcards || [];
const groups = [];
for (let i = 0; i < cards.length; i += currentCardsPerSlide.value) {
groups.push(cards.slice(i, i + currentCardsPerSlide.value))
groups.push(cards.slice(i, i + currentCardsPerSlide.value));
}
return groups
})
return groups;
});
const coordaddr = ref(<ICoordGPS>{ address: '', coordinates: [0, 0] })
const coordaddr = ref(<ICoordGPS>{ address: '', coordinates: [0, 0] });
const speedSafe = computed(() => (myel.value as any).speed ?? 0);
const carouselRef = ref(<any>null)
const isAtStart = ref(true)
const isAtEnd = ref(false)
const activeIndex = ref(0)
const carouselRef = ref(<any>null);
const isAtStart = ref(true);
const isAtEnd = ref(false);
const activeIndex = ref(0);
watch(() => myel.value.order, (value, oldval) => {
mounted()
})
watch(
() => myel.value.order,
(value, oldval) => {
mounted();
}
);
function getArrDisciplines() {
return globalStore.disciplines.filter((rec: any) => rec.showinhome)
return globalStore.disciplines.filter((rec: any) => rec.showinhome);
}
function getheightgallery() {
if (tools.isMobile())
return '400px'
else
return '600px'
if (tools.isMobile()) return '400px';
else return '600px';
}
function saveElem(exit?: boolean) {
// Save Elem record
const myelem = props.myelem
myelem.order = neworder.value
const myelem = props.myelem;
myelem.order = neworder.value;
globalStore.saveMyElem($q, t, myelem).then((ris) => {
if (ris) {
// OK
disableSave.value = true
if (exit)
enableEdit.value = false
disableSave.value = true;
if (exit) enableEdit.value = false;
}
})
});
}
function addNewElem(order?: number) {
const newrec = globalStore.prepareAddNewElem(order, $q, t, props.myelem, newtype.value)
const newrec = globalStore.prepareAddNewElem(
order,
$q,
t,
props.myelem,
newtype.value
);
}
function dupElem(order?: number) {
const newrec = props.myelem;
const newrec = props.myelem
newrec._id = undefined;
newrec.order = order ? order : newrec.order! + 10;
newrec._id = undefined
newrec.order = order ? order : (newrec.order! + 10)
globalStore.addNewElem($q, t, newrec)
globalStore.addNewElem($q, t, newrec);
}
function modifElem() {
disableSave.value = false
disableSave.value = false;
}
const checkScrollPosition = () => {
const container = carouselRef.value
if (!container || !myel.value || !myel.value.listcards) return
const container = carouselRef.value;
if (!container || !myel.value || !myel.value.listcards) return;
isAtStart.value = container.scrollLeft <= 0
isAtEnd.value = container.scrollLeft + container.clientWidth >= container.scrollWidth - 1
isAtStart.value = container.scrollLeft <= 0;
isAtEnd.value =
container.scrollLeft + container.clientWidth >= container.scrollWidth - 1;
const cardWidth = container.scrollWidth / myel.value.listcards.length
activeIndex.value = Math.round(container.scrollLeft / cardWidth)
}
const cardWidth = container.scrollWidth / myel.value.listcards.length;
activeIndex.value = Math.round(container.scrollLeft / cardWidth);
};
function mounted() {
myel.value = props.myelem
neworder.value = props.myelem.order
myel.value = props.myelem;
neworder.value = props.myelem.order;
if (props.myelem)
newtype.value = props.myelem.type
if (props.myelem) newtype.value = props.myelem.type;
nextTick(() => {
checkScrollPosition()
carouselRef.value?.addEventListener('scroll', checkScrollPosition)
})
checkScrollPosition();
carouselRef.value?.addEventListener('scroll', checkScrollPosition);
});
}
function clickOnElem() {
if (props.editOn) {
enableEdit.value = true
enableEdit.value = true;
// console.log('selElemClick', props.myelem)
emit('selElemClick', props.myelem)
emit('selElemClick', props.myelem);
}
}
function getClass() {
let mycl = ''
let mycl = '';
if (props.myelem.align === shared_consts.ALIGNTYPE.CEHTER) {
mycl += ' align_center'
mycl += ' align_center';
} else if (props.myelem.align === shared_consts.ALIGNTYPE.RIGHT) {
mycl += ' align_right'
mycl += ' align_right';
} else if (props.myelem.align === shared_consts.ALIGNTYPE.LEFT) {
mycl += ' align_left'
mycl += ' align_left';
}
if (props.myelem.class2)
mycl += ' ' + props.myelem.class2
if (props.myelem.class2) mycl += ' ' + props.myelem.class2;
if (props.selElem && props.editOn) {
if (props.myelem._id === props.selElem._id)
mycl += ' selectedElem'
if (props.myelem._id === props.selElem._id) mycl += ' selectedElem';
}
return mycl
return mycl;
}
function showFit() {
if (props.myelem && props.myelem.type)
return [shared_consts.ELEMTYPE.TEXT].includes(props.myelem.type)
else
return false
return [shared_consts.ELEMTYPE.TEXT].includes(props.myelem.type);
else return false;
}
function PagLogin() {
$router.replace('/signin')
$router.replace('/signin');
}
async function clickshare() {
tools.addToTemporaryLinkReg()
tools.addToTemporaryLinkReg();
const mytext = await tools.sendMsgTelegramCmd(
$q,
t,
shared_consts.MsgTeleg.SHARE_MSGREG,
true
)
);
if (false) {
social.value.description = mytext
visushare.value = true
social.value.description = mytext;
visushare.value = true;
}
}
// Classe per le colonne delle card
function cardColumnClass() {
const width = 12 / currentCardsPerSlide.value
return `col-${width}`
const width = 12 / currentCardsPerSlide.value;
return `col-${width}`;
}
function updateCatalogoEmit(updatedCatalogo: IOptCatalogo) {
console.log('CMyElem: updateCatalogoEmit')
myel.value.catalogo = updatedCatalogo
function updateCatalogoEmit(updatedCatalogo?: IOptCatalogo) {
if (!updatedCatalogo) return;
console.log('CMyElem: updateCatalogoEmit');
myel.value.catalogo = updatedCatalogo;
}
function naviga(path?: string): void {
if (path) {
$router.push(path);
} else {
// default fallback route
$router.push('/');
}
}
function naviga(path: string) {
$router.push(path)
}
onMounted(mounted)
onMounted(mounted);
return {
tools,
@@ -359,8 +401,8 @@ export default defineComponent({
tabcatalogo,
costanti,
naviga,
speedSafe,
t,
}
};
},
})
});

View File

@@ -10,6 +10,37 @@
"
>
<div v-if="myel.type">
<div v-if="myel.children && myel.children.length">
<template v-for="(section, sidx) in myel.children">
<CSection
v-if="section.type === shared_consts.ELEMTYPE.SECTION"
:key="'sec' + sidx"
>
<template
v-for="(row, ridx) in section.rows || section.children || []"
:key="'row' + ridx"
>
<CRow
v-if="row.type === shared_consts.ELEMTYPE.ROW"
:key="'r' + ridx"
>
<template
v-for="(col, cidx) in row.columns || row.children || []"
:key="'col' + cidx"
>
<CColumn
v-if="col"
:key="'col' + cidx"
>
<div v-if="col.container">{{ col.container }}</div>
<div v-else-if="col.title">{{ col.title }}</div>
</CColumn>
</template>
</CRow>
</template>
</CSection>
</template>
</div>
<q-btn
v-if="editOn"
class="btn-edit-floating"
@@ -228,7 +259,7 @@
:title="myel.container"
:myheight="myel.heightimg"
:vertalign="myel.vertalign"
:speed="myel.speed"
:speed="speedSafe"
:elemsText="myel.elemsText"
:logo="tools.getImgFileByFilename(myel, myel.img)"
:logoheight="myel.height ? myel.height.toString() : '100'"
@@ -1100,6 +1131,7 @@
<div v-else-if="myel.type === shared_consts.ELEMTYPE.FOOTER">
<LandingFooter />
</div>
<div v-if="editOn">
<div class="q-ma-md"></div>
</div>

View File

@@ -57,7 +57,6 @@ export default defineComponent({
const load = async (): Promise<void> => {
// console.log('load', mypath.value)
if (mypath.value !== '') rec.value = await globalStore.loadPage('/' + mypath.value, 'cmypage')
}
watch(() => props.mypath, async (to: string, from: string) => {

View File

@@ -78,7 +78,7 @@ export default defineComponent({
const { t } = useI18n();
const globalStore = useGlobalStore();
const $router = useRouter();
const $route = useRoute()
const $route = useRoute();
const mywidthEditor = ref(400);
@@ -125,6 +125,12 @@ export default defineComponent({
async function load() {
console.log('load', mypathin.value, 'idapp', tools.getEnv('VITE_APP_ID'));
const query = $router.currentRoute.value.query
if (query.edit === '1') {
globalStore.editOn = true;
}
if (mypathin.value !== '') {
onloading.value = true;
await globalStore.loadPage('/' + mypathin.value, 'cmypageelem').then((ris) => {

View File

@@ -0,0 +1,20 @@
<template>
<div class="row-container" style="display:flex; flex-direction: row;">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'CRow'
});
</script>
<style scoped>
.row-container {
display: flex;
flex-direction: row;
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="section">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'CSection'
});
</script>
<style scoped>
.section {
padding: 8px 0;
}
</style>

View File

@@ -170,7 +170,7 @@ export default defineComponent({
function removeFromCard() {
$q.dialog({
title: order.value.product.productInfo.name,
title: order.value.product?.productInfo?.name,
message: 'Sicuro di voler rimuovere il prodotto dal carrello?',
ok: {
label: 'Rimuovi',
@@ -222,7 +222,7 @@ export default defineComponent({
function mounted() {
endload.value = false;
weight.value = props.order.product?.productInfo.weight;
weight.value = props.order.product?.productInfo?.weight;
price.value = props.order.price;
if (props.order.quantity !== 0) {
orderQuantity.value = props.order.quantity;

View File

@@ -1,10 +1,4 @@
import { defineComponent, ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import { useUserStore } from '@store/UserStore'
import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n'
import { tools } from '@tools'
import { useRouter } from 'vue-router'
import { defineComponent, ref, computed, watch } from 'vue'
export default defineComponent({
name: 'IconPicker',
@@ -12,49 +6,75 @@ export default defineComponent({
modelValue: { type: String, default: '' },
icons: {
type: Array as () => string[],
default: () => ([
// Estendi pure questo set
'fas fa-house',
// SOLO Font Awesome 5 (free)
default: () => [
'fas fa-home',
'fas fa-book',
'fas fa-star',
'fas fa-heart',
'fas fa-user',
'fas fa-gear',
'fas fa-circle-info',
'fas fa-newspaper',
'fas fa-cog',
'fas fa-info-circle',
'far fa-newspaper',
'fas fa-list',
'fas fa-tags',
'fas fa-chart-line',
'fas fa-briefcase',
'fas fa-envelope',
'fas fa-phone',
'fas fa-earth-europe',
])
'fas fa-globe-europe'
]
}
},
emits: ['update:modelValue', 'change'],
setup (props, { emit }) {
const keyword = ref('')
const local = ref(props.modelValue)
const local = ref(props.modelValue) // testo inserito/valore corrente
const dialog = ref(false) // mostra/nasconde il picker
const keyword = ref('') // filtro dentro il dialog
watch(() => props.modelValue, v => { local.value = v })
const filteredIcons = computed(() => {
const k = keyword.value.trim().toLowerCase()
if (!k) return props.icons
return props.icons.filter(i => i.toLowerCase().includes(k))
return props.icons.filter(i =>
i.toLowerCase().includes(k) ||
// match anche sul nome “breve” (es: 'home')
i.toLowerCase().split(' ').some(cls => cls.startsWith('fa-') && cls.includes(k))
)
})
function select (val: string) {
local.value = val
emit('update:modelValue', val)
emit('change', val)
// applica la stringa così comè; nessun fallback
emit('update:modelValue', val || '')
emit('change', val || '')
}
function onKeyword () {
// solo aggiorna la lista; il pulsante "Usa testo" applica
function choose (ic: string) {
local.value = ic || ''
select(local.value)
dialog.value = false
}
return { keyword, local, filteredIcons, select, onKeyword }
function clear () {
local.value = ''
select('')
}
function openPicker () {
keyword.value = ''
dialog.value = true
}
return {
local,
dialog,
keyword,
filteredIcons,
select,
choose,
clear,
openPicker
}
}
})

View File

@@ -1,67 +1,86 @@
<template>
<div class="q-gutter-sm">
<div class="row items-center q-col-gutter-sm">
<div class="col">
<div class="col-12">
<q-input
v-model="keyword"
v-model="local"
dense
clearable
label="Cerca icona (es: fas fa-house)"
@update:model-value="onKeyword"
label="Icona"
>
<template #prepend>
<q-icon :name="modelValue || 'fa-regular fa-face-smile'" />
<q-icon v-if="local" :name="local" />
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn
dense
outline
:disable="!keyword"
label="Usa testo"
@click="select(keyword)"
@click="select(local)"
/>
</div>
<div class="col-auto">
<q-btn
dense
color="primary"
outline
label="Scegli icona"
@click="openPicker"
/>
</div>
</div>
<q-separator spaced />
<!-- La griglia icone appare solo nel dialog -->
<q-dialog v-model="dialog">
<q-card style="min-width: 640px; max-width: 90vw;">
<q-toolbar>
<q-toolbar-title>Seleziona icona</q-toolbar-title>
<q-btn flat round dense icon="fas fa-times" v-close-popup />
</q-toolbar>
<div class="row q-col-gutter-sm">
<div
v-for="ic in filteredIcons"
:key="ic"
class="col-3 col-sm-2 col-md-1"
>
<q-btn
outline
class="full-width"
:icon="ic"
@click="select(ic)"
:color="ic === modelValue ? 'primary' : 'grey-7'"
>
<q-tooltip>{{ ic }}</q-tooltip>
</q-btn>
</div>
</div>
<div class="q-pa-md">
<q-input
v-model="keyword"
dense
clearable
autofocus
label="Cerca (es: home, user, info...)"
/>
<div class="q-mt-md">
<q-input
v-model="local"
dense
label="Valore attuale icona"
@update:model-value="select(local)"
>
<template #append>
<q-icon :name="local || 'fa-regular fa-circle-question'" />
</template>
</q-input>
</div>
<div class="row q-col-gutter-sm q-mt-sm">
<div
v-for="ic in filteredIcons"
:key="ic"
class="col-3 col-sm-2 col-md-1"
>
<q-btn
outline
class="full-width"
:icon="ic"
@click="choose(ic)"
>
<q-tooltip>{{ ic }}</q-tooltip>
</q-btn>
</div>
</div>
<div class="row q-mt-md q-gutter-sm">
<q-btn outline label="Rimuovi icona" color="negative" @click="choose('')" />
<q-space />
<q-btn color="primary" label="Chiudi" v-close-popup />
</div>
</div>
</q-card>
</q-dialog>
</div>
</template>
<script lang="ts" src="./IconPicker.ts"></script>
<style lang="scss" scoped>
@import './IconPicker.scss';
/* opzionale: spaziatura minima sulle celle */
</style>

View File

@@ -0,0 +1,50 @@
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,
};
},
});

View File

@@ -0,0 +1,144 @@
<template>
<q-item
clickable
:active="selected"
@click="emitSelect"
>
<q-item-section
v-if="showGrip"
avatar
>
<q-btn
flat
round
dense
:class="draggableHandleClass"
icon="fas fa-grip-vertical"
@click.stop
/>
</q-item-section>
<q-item-section
v-if="depth > 0"
avatar
class="q-pr-none"
>
<div :style="indentSpacerStyle" />
</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>
<q-item-section side>
<q-item-label caption>{{ item.order }}</q-item-label>
</q-item-section>
<q-item-section>
<q-item-label :class="{ 'text-grey-7': !active }">{{
item.title || '(senza titolo)'
}}</q-item-label>
<q-item-label caption>{{ displayPath(item.path) }}</q-item-label>
</q-item-section>
<q-item-section
v-if="active"
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="green"
size="xs"
/>
</q-item-section>
<q-item-section
v-if="item.only_admin"
side
class="float-right"
>
<q-icon
name="fas fa-circle"
color="red"
size="xs"
/>
</q-item-section>
<q-item-section side>
<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-item-section>
</q-item>
</template>
<script lang="ts" src="./MenuPageItem.ts"></script>
<style lang="scss" scoped>
@import './MenuPageItem.scss';
</style>

View File

@@ -0,0 +1 @@
export {default as MenuPageItem} from './MenuPageItem.vue'

View File

@@ -1,141 +1,167 @@
import {
defineComponent,
ref,
computed,
onMounted,
watch,
onBeforeUnmount,
toRaw,
nextTick,
} from 'vue';
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 { useUserStore } from '@store/UserStore';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { tools } from '@tools';
import { useRouter } from 'vue-router';
import { reactive } from 'vue';
import { IMyPage } from 'app/src/model';
import IconPicker from '../IconPicker/IconPicker.vue';
import { useGlobalStore } from 'app/src/store';
import { storeToRefs } from 'pinia';
import { CMyFieldRec } from '@src/components/CMyFieldRec'
export default defineComponent({
name: 'PageEditor',
components: { IconPicker },
components: { IconPicker, CMyFieldRec },
props: {
modelValue: {
type: Object as () => IMyPage,
required: true,
},
modelValue: { type: Object as () => IMyPage, required: true },
nuovaPagina: { type: Boolean, required: true } // <-- modalità "bozza"
},
emits: ['update:modelValue', 'apply'],
setup(props, { emit }) {
const $q = useQuasar();
const globalStore = useGlobalStore();
const { mypage } = storeToRefs(globalStore);
emits: ['update:modelValue', 'apply', 'hide'],
setup (props, { emit }) {
const $q = useQuasar()
// DRaft locale
const draft = reactive<IMyPage>({ ...props.modelValue });
const { t } = useI18n()
const globalStore = useGlobalStore()
const { mypage } = storeToRefs(globalStore)
// Draft locale indipendente dal parent (specie in nuovaPagina)
const draft = reactive<IMyPage>({ ...props.modelValue })
// UI helper: path mostrato con "/" iniziale
const ui = reactive({
pathText: toUiPath(draft.path),
});
const ui = reactive({ pathText: toUiPath(draft.path) })
const saving = ref(false)
const syncingFromProps = ref(false) // <-- FLAG anti-loop
const syncingFromProps = ref(false) // anti-loop
// --- Watch input esterno: ricarica draft e UI path
// --- Watch input esterno: ricarica draft e UI path (NO scritture nello store qui!)
// --- Sync IN: quando cambia il valore del parent, aggiorna solo il draft
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)
await nextTick()
syncingFromProps.value = false
},
{ deep: false }
);
)
// --- Ogni modifica del draft: aggiorna store.mypage e emetti update:modelValue (solo se modifica nasce da UI)
// --- Modifiche live: SE NON è nuovaPagina, aggiorna store e v-model del parent
watch(
draft,
(val) => {
if (syncingFromProps.value) return; // evita ricorsione
upsertIntoStore(val, mypage.value);
emit('update:modelValue', { ...val });
if (syncingFromProps.value) return
if (props.nuovaPagina) return // <-- blocca ogni propagazione durante "nuova pagina"
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 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 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 normalizeAndApplyPath() {
// normalizza: niente spazi, minuscole, trattini
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
}
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;
// --- 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) })
}
// --- Upsert nello store.mypage
function upsertIntoStore(page: IMyPage, arr: IMyPage[]) {
if (!page) return;
// chiave di matching: prima _id, altrimenti path
const keyId = page._id;
const keyPath = page.path || '';
let idx = -1;
if (keyId) {
idx = arr.findIndex((p) => p._id === keyId);
// --- VALIDAZIONE + COMMIT per nuova pagina (o anche per edit espliciti)
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
}
if (idx < 0 && keyPath) {
idx = arr.findIndex((p) => (p.path || '') === keyPath);
const pathText = (ui.pathText || '').trim()
if (!pathText) {
$q.notify({ message: 'Inserisci il percorso della pagina', type: 'warning' })
return
}
if (idx >= 0) {
// merge preservando reattività
arr[idx] = { ...arr[idx], ...toRaw(page) };
} else {
arr.push({ ...toRaw(page) });
const candidatePath = toStorePath(pathText).toLowerCase()
// unicità PATH (ignora se stesso quando editing)
const existPath = globalStore.mypage.find(
(r) => (r.path || '').toLowerCase() === candidatePath && r._id !== cur._id
)
if (existPath) {
$q.notify({ message: 'Esiste già unaltra pagina con questo percorso', type: 'warning' })
return
}
// unicità TITOLO (ignora se stesso quando editing)
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
}
await save() // esegue commit vero
emit('hide') // chiudi il dialog (se usi dialog)
}
async function save () {
// --- Salvataggio esplicito (commit). Qui propaghiamo SEMPRE al parent/store.
async function save () {
try {
saving.value = true
normalizeAndApplyPath() // assicura path coerente
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)
upsertIntoStore(draft, mypage.value) // ora è lecito anche per nuovaPagina
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' })
} catch (err: any) {
} catch (err) {
console.error(err)
$q.notify({ type: 'negative', message: 'Errore nel salvataggio' })
} finally {
@@ -143,7 +169,7 @@ async function save () {
}
}
// --- Ricarica da sorgente
// --- Ricarica (per editing). In modalità nuovaPagina non propaghiamo al parent.
async function reloadFromStore () {
try {
const absolute = ui.pathText || '/'
@@ -153,8 +179,7 @@ async function save () {
Object.assign(draft, page)
ui.pathText = toUiPath(draft.path)
upsertIntoStore(draft, mypage.value)
console.log('page', draft)
emit('update:modelValue', { ...draft })
if (!props.nuovaPagina) emit('update:modelValue', { ...draft }) // <-- no propagate in nuovaPagina
await nextTick()
syncingFromProps.value = false
$q.notify({ type: 'info', message: 'Pagina ricaricata' })
@@ -167,16 +192,18 @@ async function save () {
}
}
function resetDraft() {
console.log('resetDraft')
function resetDraft () {
syncingFromProps.value = true
Object.assign(draft, props.modelValue || {})
ui.pathText = toUiPath(draft.path)
// aggiorna i componenti
emit('update:modelValue', { ...draft })
if (!props.nuovaPagina) emit('update:modelValue', { ...draft }) // <-- no propagate in nuovaPagina
nextTick(() => { syncingFromProps.value = false })
}
function modifElem() {
}
const absolutePath = computed(() => toUiPath(draft.path))
return {
@@ -189,6 +216,10 @@ async function save () {
reloadFromStore,
resetDraft,
absolutePath,
};
},
});
checkAndSave,
t,
costanti,
modifElem,
}
}
})

View File

@@ -1,5 +1,9 @@
<template>
<q-card flat bordered class="q-pa-md">
<q-card
flat
bordered
class="q-pa-md"
>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input
@@ -20,53 +24,99 @@
v-model="draft.title"
label="Titolo"
dense
:rules="[v => !!v || 'Titolo richiesto']"
:rules="[(v) => !!v || 'Titolo richiesto']"
/>
</div>
<div class="col-12 col-md-6">
<icon-picker v-model="draft.icon" />
<q-input
v-model.number="draft.order"
label="Ordine"
dense
type="number"
:rules="[
(v) => v !== null || 'Ordine richiesto',
(v) => v >= 0 || 'Ordine deve essere positivo',
]"
/>
</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>
<div class="col-12 col-md-6">
<q-input v-model="draft.iconsize" label="Dimensione icona (es: 24px)" dense />
<icon-picker v-model="draft.icon" />
</div>
<div class="col-12">
<q-separator spaced />
<div class="row items-center q-col-gutter-md">
<div class="col-auto">
<q-toggle v-model="draft.active" label="Attivo" />
<q-toggle
v-model="draft.active"
label="Attivo"
/>
</div>
<div class="col-auto">
<q-toggle v-model="draft.inmenu" label="Presente nel menu" />
<q-toggle
v-model="draft.inmenu"
label="Presente nel menu"
/>
</div>
<div class="col-auto">
<q-toggle v-model="draft.onlyif_logged" label="Solo se loggati" />
<q-toggle
v-model="draft.onlyif_logged"
:label="t('pages.onlyif_logged')"
/>
</div>
<div class="col-auto">
<q-toggle
v-model="draft.only_admin"
:label="t('pages.only_admin')"
/>
</div>
</div>
</div>
</div>
<div class="row q-col-gutter-sm q-mt-md">
<div class="col-auto">
<q-btn color="primary" label="Salva" :loading="saving" @click="save" />
</div>
<div class="col-auto">
<q-btn outline label="Ricarica" @click="reloadFromStore" />
</div>
<div class="col-auto">
<q-btn color="grey-7" label="Chiudi" @click="$emit('close', draft.path)" />
</div>
<div class="col-auto">
<q-btn flat color="grey-7" label="Reset draft" @click="resetDraft" />
</div>
</div>
<q-card-actions
align="center"
class="q-pa-md q-gutter-md"
>
<q-btn
color="primary"
label="Salva"
:loading="saving"
@click="checkAndSave(draft)"
/>
<q-btn
outline
label="Ricarica"
@click="reloadFromStore"
/>
<q-btn
color="grey-7"
label="Chiudi"
v-close-popup
/>
<!--<q-btn flat color="grey-7" label="Reset draft" @click="resetDraft" />-->
</q-card-actions>
</q-card>
</template>
<script lang="ts" src="./PageEditor.ts">
</script>
<script lang="ts" src="./PageEditor.ts"></script>
<style lang="scss" scoped>
@import './PageEditor.scss';

View File

@@ -1,160 +1,542 @@
import { defineComponent, ref, computed, onMounted, watch, onBeforeUnmount } from 'vue';
import { IMyPage } from 'app/src/model';
import { PageEditor } from '../PageEditor';
import { defineComponent, ref, computed, watch } from 'vue';
import type { IMyPage } from 'app/src/model';
import PageEditor from '@src/components/PageEditor/PageEditor.vue';
import MenuPageItem from '@src/components/MenuPageItem/MenuPageItem.vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useGlobalStore } from 'app/src/store';
import draggable from 'vuedraggable';
type Bucket = 'menu' | 'off';
type PageWithKey = IMyPage & { __key?: string };
type PageRow = PageWithKey & { __depth: number }; // 0 = voce di menu, 1 = sottomenu
const byOrder = (a: IMyPage, b: IMyPage) => (a.order ?? 0) - (b.order ?? 0);
const norm = (p?: string) => (p || '').trim().replace(/^\//, '').toLowerCase();
let uidSeed = 1;
function ensureKeys(arr: PageWithKey[]) {
for (const p of arr) {
if (!p.__key) p.__key = p._id || `tmp_${uidSeed++}`;
}
}
export default defineComponent({
name: 'PagesConfigurator',
components: { PageEditor },
components: { PageEditor, MenuPageItem, draggable },
props: {
modelValue: {
type: Array as () => IMyPage[],
required: true,
},
modelValue: { type: Array as () => IMyPage[], required: true },
},
emits: ['update:modelValue', 'save', 'change-order'],
setup(props, { emit }) {
const pages = ref<IMyPage[]>(props.modelValue ? [...props.modelValue] : []);
const selectedIndex = ref<number>(-1);
const $q = useQuasar();
const $router = useRouter();
const globalStore = useGlobalStore();
const visualizzaEditor = ref(false);
const nuovaPagina = ref(false);
const ORDER_STEP = 10;
const MIN_GAP = 1;
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;
}
/**
* 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.
*/
function sparseAssignOrder(
rows: PageRow[],
movedIndex: number
): { 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;
const prevOrder = getOrderOfRow(rows[movedIndex - 1]);
const nextOrder = getOrderOfRow(rows[movedIndex + 1]);
const pushDelta = (p: PageWithKey, val: number) => {
if (p.order !== val) {
p.order = val;
if (p._id) deltas.push({ id: p._id, order: val });
}
};
// 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 dellelemento 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;
}
/** 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;
}
// ---- STATE BASE --------------------------------------------------------
const pages = ref<PageWithKey[]>(
props.modelValue ? props.modelValue.map((p) => ({ ...p })) : []
);
ensureKeys(pages.value);
pages.value.sort(byOrder)
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);
}
}
}
// 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
}
menuRows.value = rows;
}
function rebuildOffList() {
offList.value = pages.value.filter((p) => !p.inmenu).sort(byOrder);
}
function rebuildAllViews() {
rebuildMenuRows();
rebuildOffList();
globalStore.aggiornaMenu($router);
}
// ⬇️ Sostituisci completamente la funzione esistente
function applyMenuRows(
newRows: PageRow[],
movedIndex?: number
): { id: string; order: number }[] {
// 1) svuota i sottoMenu dei parent (ricostruiremo i link)
for (const p of pages.value) {
if (p.inmenu && !p.submenu) p.sottoMenu = [];
}
// 2) mappa chiave->page
const key2page = new Map<string, PageWithKey>();
for (const p of pages.value) if (p.__key) key2page.set(p.__key, p);
// 3) ricostruisci SOLO la struttura (inmenu/submenu e sottoMenu dei parent)
let currentParent: PageWithKey | null = null;
for (const row of newRows) {
const page = row.__key ? key2page.get(row.__key) : undefined;
if (!page) continue;
if (row.__depth <= 0 || !currentParent) {
// top-level
page.inmenu = true;
page.submenu = false;
currentParent = page;
if (!Array.isArray(page.sottoMenu)) page.sottoMenu = [];
} else {
// child
page.inmenu = true;
page.submenu = true;
page.mainMenu = true;
const pathStr = page.path || '';
if (currentParent) {
if (!Array.isArray(currentParent.sottoMenu)) currentParent.sottoMenu = [];
const exists = currentParent.sottoMenu.some((p) => norm(p) === norm(pathStr));
if (!exists) currentParent.sottoMenu.push(pathStr);
}
}
}
// 4) assegna order in modalità "sparsa" SOLO per lelemento spostato (e finestra vicina)
if (typeof movedIndex === 'number') {
return sparseAssignOrder(newRows, movedIndex);
}
return [];
}
// ⬇️ Sostituisci completamente la funzione esistente
function applyOffList(
newOff: PageWithKey[],
movedIndex?: number
): { id: string; order: number }[] {
// 1) togli riferimenti dai sottoMenu dei parent
const offPaths = new Set(newOff.map((x) => norm(x.path)));
for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter((p) => !offPaths.has(norm(p)));
}
}
// 2) marca inmenu/submenu=false per tutti gli "off" presenti
const offKeys = new Set(newOff.map((x) => x.__key));
for (const p of pages.value) {
if (p.__key && offKeys.has(p.__key)) {
p.inmenu = false;
p.submenu = false;
p.mainMenu = false;
}
}
// 3) assegna ordine "sparso" al solo elemento spostato
const deltas: { id: string; order: number }[] = [];
if (typeof movedIndex === 'number') {
const prev = newOff[movedIndex - 1];
const next = newOff[movedIndex + 1];
const cur = newOff[movedIndex];
if (cur?.__key) {
const curP = getPageByKey(cur.__key);
const prevO = prev ? getPageByKey(prev.__key!)?.order : undefined;
const nextO = next ? getPageByKey(next.__key!)?.order : undefined;
const pushDelta = (p: PageWithKey, val: number) => {
if (p.order !== val) {
p.order = val;
if (p._id) deltas.push({ id: p._id, order: val });
}
};
if (prevO !== undefined && nextO !== undefined && nextO - prevO > MIN_GAP) {
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 {
// 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);
}
}
}
}
return deltas;
}
// ---- WATCHERS ----------------------------------------------------------
watch(
() => props.modelValue,
(v) => {
pages.value = v ? [...v] : [];
if (selectedIndex.value >= pages.value.length) selectedIndex.value = -1;
}
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 }
);
const localPagesSorted = computed(() => {
return [...pages.value].sort((a, b) => (a.order || 0) - (b.order || 0));
});
// ricostruisci la vista quando pages cambia (evita durante apply)
watch(
() => pages.value,
() => {
if (applyingRows.value) return;
rebuildAllViews();
},
{ deep: true, immediate: true }
);
function originalIndex(sortedIdx: number) {
// mappa lindice sortato allindice reale in pages
const item = localPagesSorted.value[sortedIdx];
return pages.value.indexOf(item);
// ---- SELEZIONE / UTILS -------------------------------------------------
const currentIdx = computed(() =>
pages.value.findIndex((p) => p.__key === selectedKey.value)
);
function select(key?: string) {
selectedKey.value = key || null;
}
const current = computed(() => pages.value[selectedIndex.value]);
function displayPath(path?: string) {
if (!path) return '-';
return path.startsWith('/') ? path : '/' + path;
}
// Removed automatic order reindexing to preserve original order values
// ---- AZIONI UI ---------------------------------------------------------
function addSubmenu() {
const p = pages.value.find((x) => x.__key === selectedKey.value);
if (!p) return;
// Duplicate outer function removed, keeping the inner implementation
function addPage() {
const newOrder =
pages.value.reduce((max: number, p: IMyPage) => Math.max(max, p.order ?? 0), 0) +
1;
const np: IMyPage = {
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
const np: PageWithKey = {
title: '',
path: '/nuova-pagina-' + (Math.floor(Math.random() * 8999) + 1000).toString(),
icon: 'fa-regular fa-file-lines',
path: '/nuova-pagina',
icon: 'far fa-file-alt',
iconsize: '24px',
active: true,
inmenu: true,
inmenu: bucket === 'menu',
submenu: false,
onlyif_logged: false,
order: newOrder,
order:
bucket === 'menu' ? computeAppendOrderForMenu() : computeAppendOrderForOff(),
__key: `tmp_${uidSeed++}`,
};
pages.value.push(np);
selectedIndex.value = pages.value.length - 1;
emit('update:modelValue', [...pages.value]);
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
rebuildAllViews();
selectedKey.value = np.__key!;
}
function removePage(idx: number) {
if (idx < 0) return;
const page = pages.value[idx];
if (!page) return;
pages.value.splice(idx, 1);
if (selectedIndex.value === idx) selectedIndex.value = -1;
emit('update:modelValue', [...pages.value]);
// Add cleanup logic for page deletion
if (page._id) {
// Mark for deletion in backend
void deletePageFromServer(page);
}
}
function removeAt(bucket: Bucket, idx: number) {
const target = bucket === 'menu' ? menuRows.value[idx] : offList.value[idx];
if (!target) return;
async function deletePageFromServer(page: IMyPage) {
if (!page._id) return;
$q.dialog({
title: 'Conferma cancellazione',
message: `Sei sicuro di voler cancellare la pagina "${target.title || target.path}"?`,
cancel: true,
persistent: true,
}).onOk(async () => {
// rimuovi il record da pages
const key = target.__key;
const pathN = norm(target.path);
const i = pages.value.findIndex((p) => p.__key === key);
if (i >= 0) pages.value.splice(i, 1);
try {
const response = await fetch(`/api/pages/${page._id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to delete page from server');
// pulisci eventuali riferimenti nei sottoMenu dei parent
for (const parent of pages.value) {
if (parent.inmenu && !parent.submenu && Array.isArray(parent.sottoMenu)) {
parent.sottoMenu = parent.sottoMenu.filter((p) => norm(p) !== pathN);
}
}
} catch (error) {
console.error('Error deleting page:', error);
// Revert changes if deletion fails
pages.value.splice(originalIndex, 0, page);
emit('update:modelValue', [...pages.value]);
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
rebuildAllViews();
if (selectedKey.value === key) selectedKey.value = null;
// opzionale: elimina anche lato server
try {
await globalStore.deletePage($q, target._id || '');
} catch {}
});
}
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 select(idx: number) {
selectedIndex.value = idx;
}
// ⬇️ 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();
}
function swap(i: number, j: number) {
const a = pages.value[i];
const b = pages.value[j];
if (!a || !b) return;
const ao = a.order ?? i;
const bo = b.order ?? j;
a.order = bo;
b.order = ao;
emit('update:modelValue', [...pages.value]);
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 {}
}
}
// ⬇️ Sostituisci la tua onOffDragChange
function onOffDragChange(evt?: any) {
const movedIndex: number | undefined = evt?.moved?.newIndex;
const deltas = applyOffList(offList.value, movedIndex);
function moveUp(idx: number) {
if (idx <= 0) return;
const prev = idx - 1;
const a = pages.value[idx];
const b = pages.value[prev];
if (!a || !b) return;
const ao = a.order ?? idx;
const bo = b.order ?? prev;
a.order = bo;
b.order = ao;
emit('update:modelValue', [...pages.value]);
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 moveDown(idx: number) {
if (idx >= pages.value.length - 1) return;
const next = idx + 1;
const a = pages.value[idx];
const b = pages.value[next];
if (!a || !b) return;
const ao = a.order ?? idx;
const bo = b.order ?? next;
a.order = bo;
b.order = ao;
emit('update:modelValue', [...pages.value]);
}
function onApply() {
emit('update:modelValue', [...pages.value]);
emit('save', current.value);
}
function onClose() {
selectedIndex.value = -1;
emit(
'update:modelValue',
pages.value.map((p) => ({ ...p }))
);
const cur = currentIdx.value >= 0 ? pages.value[currentIdx.value] : undefined;
emit('save', cur);
rebuildAllViews();
visualizzaEditor.value = false; // ⬅️ aggiungi
nuovaPagina.value = false; // ⬅️ aggiungi
}
function editAt(idx: number) {
const key = (menuRows.value[idx] || offList.value[idx])?.__key;
selectedKey.value = key || selectedKey.value;
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`);
}
// ---- EXPOSE ------------------------------------------------------------
return {
pages,
selectedIndex,
localPagesSorted,
current,
menuRows,
offList,
selectedKey,
currentIdx,
// actions
select,
addPage,
removePage,
moveUp,
moveDown,
originalIndex,
addSubmenu,
removeAt,
move,
onMenuDragChange,
onOffDragChange,
onApply,
onClose,
displayPath,
editAt,
openKey,
visualizzaEditor, // ⬅️ aggiungi
nuovaPagina, // ⬅️ aggiungi
};
},
});

View File

@@ -1,67 +1,122 @@
<template>
<div class="row q-col-gutter-md">
<!-- Lista pagine -->
<div class="col-12 col-md-5">
<q-card flat bordered>
<!-- COLONNA: Nel menu -->
<div class="col-12 col-md-6">
<q-card
flat
bordered
>
<q-toolbar>
<q-toolbar-title>Pagine</q-toolbar-title>
<q-btn dense icon="fas fa-plus" label="Aggiungi" @click="addPage" />
<q-toolbar-title>Menu</q-toolbar-title>
<q-badge
color="primary"
:label="menuRows.length"
/>
<q-space />
<q-btn
dense
icon="fas fa-plus"
label="Nuovo"
@click="addPage('menu')"
/>
<q-btn
dense
icon="fas fa-sitemap"
label="SottoMenu"
:disable="!selectedKey"
@click="addSubmenu()"
/>
</q-toolbar>
<q-list separator>
<q-item
v-for="(p, idx) in localPagesSorted"
:key="p._id || idx"
clickable
:active="selectedIndex === originalIndex(idx)"
@click="select(originalIndex(idx))"
>
<q-item-section avatar>
<q-icon :name="p.icon || 'fa-regular fa-file-lines'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ p.title || '(senza titolo)' }}</q-item-label>
<q-item-label caption>{{ p.path || '-' }}</q-item-label>
</q-item-section>
<q-item-section side top>
<div class="row items-center q-gutter-xs">
<q-badge :color="p.active ? 'positive' : 'grey'">{{ p.active ? 'attiva' : 'spenta' }}</q-badge>
<q-badge :color="p.inmenu ? 'primary' : 'grey'">menu</q-badge>
</div>
</q-item-section>
<q-item-section side>
<div class="column q-gutter-xs">
<!--<q-btn dense round icon="fas fa-chevron-up" @click.stop="moveUp(originalIndex(idx))" />
<q-btn dense round icon="fas fa-chevron-down" @click.stop="moveDown(originalIndex(idx))" />-->
<q-btn dense round color="negative" icon="fas fa-trash" @click.stop="removePage(originalIndex(idx))" />
</div>
</q-item-section>
</q-item>
</q-list>
<draggable
v-model="menuRows"
item-key="__key"
group="pages"
handle=".drag-handle"
:animation="180"
ghost-class="bg-grey-2"
@change="onMenuDragChange($event)"
>
<template #item="{ element, index }">
<MenuPageItem
:item="element"
:selected="selectedKey === element.__key"
v-model:active="element.active"
:depth="element.__depth"
variant="menu"
@select="select(element.__key)"
@edit="editAt(index)"
@delete="removeAt('menu', index)"
@open="openKey(element.__key)"
/>
</template>
</draggable>
</q-card>
</div>
<!-- Editor pagina selezionata -->
<div class="col-12 col-md-7">
<!-- COLONNA: Fuori menu -->
<div class="col-12 col-md-6">
<q-card
flat
bordered
>
<q-toolbar>
<q-toolbar-title>Pagine</q-toolbar-title>
<q-badge
color="grey-7"
:label="offList.length"
/>
<q-space />
<q-btn
dense
icon="fas fa-plus"
label="Aggiungi"
@click="addPage('off')"
/>
</q-toolbar>
<draggable
v-model="offList"
item-key="__key"
group="pages"
handle=".drag-handle"
:animation="180"
ghost-class="bg-grey-2"
@change="onOffDragChange($event)"
>
<template #item="{ element, index }">
<MenuPageItem
:item="element"
:selected="selectedKey === element.__key"
v-model:active="element.active"
variant="off"
:depth="0"
@select="select(element.__key)"
@delete="removeAt('off', index)"
@open="openKey(element.__key)"
/>
</template>
</draggable>
</q-card>
</div>
<!-- Editor -->
<q-dialog
v-model="visualizzaEditor"
persistent
>
<page-editor
v-if="current"
v-model="pages[selectedIndex]"
v-if="currentIdx !== -1"
v-model="pages[currentIdx]"
@apply="onApply"
@close="onClose"
@hide="visualizzaEditor = false"
:nuovaPagina="nuovaPagina"
/>
<q-card v-else flat bordered class="q-pa-lg flex flex-center text-grey">
Seleziona o aggiungi una pagina.
</q-card>
</div>
</q-dialog>
</div>
</template>
<script lang="ts" src="./PagesConfigurator.ts">
</script>
<script lang="ts" src="./PagesConfigurator.ts"></script>
<style lang="scss" scoped>
@import './PagesConfigurator.scss';
</style>