4 Commits

Author SHA1 Message Date
Surya Paolo
d1a66ef4ea - altro aggiornamento restying
- Invio RIS aggiornato
- Eventi
- Home Page restyling
2025-12-18 16:58:06 +01:00
Surya Paolo
7c1946debe aa 2025-12-17 10:26:12 +01:00
Surya Paolo
7aeced4232 ok 2025-12-17 10:20:07 +01:00
Surya Paolo
6d78f82099 - aggiornamento di tante cose...
- generazione Volantini
- pagina RIS
2025-12-17 10:07:42 +01:00
159 changed files with 27672 additions and 8492 deletions

28
migrate-repos.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# Salva come migrate-repos.sh
GITEA_URL="http://95.216.147.38:3000"
USERNAME="surya"
TOKEN="8ed0622aac269414f4d333d0c89e22b1c42dd4d1" # Crea su Gitea: Settings → Applications → Generate Token
SEARCH_PATH="$HOME/myproject"
# Trova tutti i repository
find "$SEARCH_PATH" -name ".git" -type d 2>/dev/null | while read gitdir; do
REPO_PATH=$(dirname "$gitdir")
REPO_NAME=$(basename "$REPO_PATH")
echo "Processing: $REPO_NAME"
# Crea repository su Gitea via API
curl -X POST "$GITEA_URL/api/v1/user/repos" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"$REPO_NAME\",\"private\":false}"
# Push
cd "$REPO_PATH"
git remote remove origin 2>/dev/null
git remote add origin "$GITEA_URL/$USERNAME/$REPO_NAME.git"
git push -u origin --all
git push -u origin --tags
done

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -35,7 +35,6 @@ export default defineConfig((ctx) => {
node: 'node20', node: 'node20',
}, },
// ✅ AGGIUNTO: Importa automaticamente variables.scss ovunque
sassVariables: 'src/css/variables.scss', sassVariables: 'src/css/variables.scss',
vueRouterMode: 'history', vueRouterMode: 'history',

View File

@@ -147,7 +147,6 @@ export const shared_consts = {
DASHBOARD: 140, DASHBOARD: 140,
DASHGROUP: 145, DASHGROUP: 145,
MOVEMENTS: 148, MOVEMENTS: 148,
CSENDRISTO: 150,
STATUSREG: 160, STATUSREG: 160,
CHECKIFISLOGGED: 170, CHECKIFISLOGGED: 170,
INFO_VERSION: 180, INFO_VERSION: 180,
@@ -191,8 +190,10 @@ export const shared_consts = {
PROFILE_COMPLETITION: 1510, PROFILE_COMPLETITION: 1510,
RISOHOME: 1600, RISOHOME: 1600,
RISOHOME_MODERN: 1610, RISOHOME_MODERN: 1610,
RISOHOME_PAGFINALE: 1615,
PAGERIS: 1620, PAGERIS: 1620,
CMYCIRCUITS: 1630, CMYCIRCUITS: 1630,
CREA_VOLANTINO: 1700,
}, },
QUERYTYPE_MYGROUP: 1, QUERYTYPE_MYGROUP: 1,
@@ -2009,11 +2010,6 @@ export const shared_consts = {
label: 'Lista Movimenti', label: 'Lista Movimenti',
icon: 'fas fa-list', icon: 'fas fa-list',
}, },
{
value: 150, // CSENDRISTO
label: 'Bott (Invia/Ricevi RIS)',
icon: 'fas fa-wallet',
},
{ {
value: 280, value: 280,
label: 'Tutorial', label: 'Tutorial',
@@ -2057,6 +2053,11 @@ export const shared_consts = {
label: 'Check Email', label: 'Check Email',
icon: 'fas fa-envelope', icon: 'fas fa-envelope',
}, },
{
value: 1700, // CREA_VOLANTINO
label: 'Genera Volantini',
icon: 'fas fa-user-tie',
},
{ {
value: 120, value: 120,
label: 'OpenStreetMap', label: 'OpenStreetMap',
@@ -2122,6 +2123,11 @@ export const shared_consts = {
label: 'RISO Home Modern', label: 'RISO Home Modern',
icon: 'fas fa-home', icon: 'fas fa-home',
}, },
{
value: 1615, // RISOHOME_PAGFINALE
label: 'RISO Home (Parte Finale))',
icon: 'fas fa-home',
},
{ {
value: 1620, // PAGERIS value: 1620, // PAGERIS
label: 'Pagina RIS', label: 'Pagina RIS',
@@ -2481,6 +2487,7 @@ export const shared_consts = {
link_group: 1, link_group: 1,
totCircolante: 1, totCircolante: 1,
totTransato: 1, totTransato: 1,
numTransazioni: 1,
systemUserId: 1, systemUserId: 1,
createdBy: 1, createdBy: 1,
date_created: 1, date_created: 1,

View File

@@ -0,0 +1,749 @@
<template>
<q-card class="ai-generator-dialog">
<!-- Header -->
<q-card-section class="dialog-header">
<div class="header-content">
<q-icon name="auto_awesome" size="32px" color="amber" />
<div>
<h2>Genera Immagine con AI</h2>
<p>{{ assetTypeLabel }}</p>
</div>
</div>
<q-btn flat round icon="close" @click="$emit('close')" />
</q-card-section>
<q-separator />
<!-- Content -->
<q-card-section class="dialog-body">
<div class="generator-layout">
<!-- Left: Form -->
<div class="form-panel">
<!-- Provider Selection -->
<div class="form-section">
<label class="section-label">Provider AI</label>
<div class="provider-options">
<div
v-for="provider in providers"
:key="provider.value"
class="provider-option"
:class="{ 'is-selected': selectedProvider === provider.value }"
@click="selectedProvider = provider.value"
>
<q-icon :name="provider.icon" size="24px" />
<div class="provider-info">
<span class="provider-name">{{ provider.label }}</span>
<q-badge
v-if="provider.free"
color="green"
text-color="white"
label="Gratis"
/>
</div>
</div>
</div>
</div>
<!-- Prompt -->
<div class="form-section">
<label class="section-label">
Descrivi l'immagine
<q-btn
flat
dense
size="sm"
icon="help_outline"
@click="showPromptTips = true"
>
<q-tooltip>Suggerimenti per prompt efficaci</q-tooltip>
</q-btn>
</label>
<q-input
v-model="prompt"
filled
type="textarea"
rows="4"
placeholder="Descrivi l'immagine che vuoi generare..."
counter
maxlength="1000"
/>
<!-- Quick prompts -->
<div class="quick-prompts">
<span class="quick-label">Suggerimenti rapidi:</span>
<q-chip
v-for="(suggestion, idx) in promptSuggestions"
:key="idx"
clickable
size="sm"
@click="appendToPrompt(suggestion)"
>
{{ suggestion }}
</q-chip>
</div>
</div>
<!-- Negative Prompt -->
<div class="form-section">
<label class="section-label">
Prompt Negativo
<span class="optional">(opzionale)</span>
</label>
<q-input
v-model="negativePrompt"
filled
type="textarea"
rows="2"
placeholder="Cosa NON vuoi vedere nell'immagine..."
/>
<div class="negative-presets">
<q-btn
flat
dense
size="sm"
label="Usa preset standard"
@click="useStandardNegative"
/>
</div>
</div>
<!-- Aspect Ratio -->
<div class="form-section">
<label class="section-label">Formato</label>
<q-btn-toggle
v-model="aspectRatio"
:options="aspectRatioOptions"
spread
no-caps
toggle-color="primary"
class="full-width"
/>
</div>
<!-- Advanced Options -->
<q-expansion-item
icon="tune"
label="Opzioni Avanzate"
header-class="advanced-header"
>
<div class="advanced-options">
<div class="options-row">
<q-input
v-model.number="seed"
type="number"
filled
dense
label="Seed"
hint="Lascia vuoto per casuale"
/>
<q-input
v-model.number="steps"
type="number"
filled
dense
label="Steps"
:min="10"
:max="50"
/>
<q-input
v-model.number="cfg"
type="number"
filled
dense
label="CFG Scale"
:min="1"
:max="20"
step="0.5"
/>
</div>
</div>
</q-expansion-item>
<!-- Generate Button -->
<div class="generate-actions">
<q-btn
color="primary"
icon="auto_awesome"
label="Genera Immagine"
size="lg"
:loading="isGenerating"
:disable="!prompt.trim()"
class="full-width"
@click="generateImage"
/>
</div>
</div>
<!-- Right: Preview -->
<div class="preview-panel">
<div class="preview-container" :class="{ 'has-image': !!generatedImage }">
<template v-if="isGenerating">
<div class="generating-state">
<q-spinner-orbit size="80px" color="primary" />
<p>Generazione in corso...</p>
<span class="time-estimate">Tempo stimato: ~15-30 secondi</span>
</div>
</template>
<template v-else-if="generatedImage">
<img :src="generatedImage" alt="Generated image" class="preview-image" />
<div class="preview-actions">
<q-btn
round
color="white"
text-color="primary"
icon="refresh"
@click="generateImage"
>
<q-tooltip>Rigenera</q-tooltip>
</q-btn>
<q-btn
round
color="green"
icon="check"
@click="confirmImage"
>
<q-tooltip>Usa questa immagine</q-tooltip>
</q-btn>
</div>
</template>
<template v-else>
<div class="empty-preview">
<q-icon name="image" size="80px" color="grey-4" />
<p>L'immagine generata apparirà qui</p>
</div>
</template>
</div>
<!-- Generation History -->
<div v-if="history.length > 0" class="generation-history">
<h4>Generazioni recenti</h4>
<div class="history-grid">
<div
v-for="(item, idx) in history"
:key="idx"
class="history-item"
@click="selectFromHistory(item)"
>
<img :src="item.url" :alt="`Generation ${idx + 1}`" />
</div>
</div>
</div>
</div>
</div>
</q-card-section>
<!-- Prompt Tips Dialog -->
<q-dialog v-model="showPromptTips">
<q-card style="max-width: 500px">
<q-card-section>
<div class="text-h6">💡 Suggerimenti per Prompt Efficaci</div>
</q-card-section>
<q-card-section>
<ul class="tips-list">
<li><strong>Sii specifico:</strong> Descrivi dettagli come colori, stile, atmosfera</li>
<li><strong>Indica lo stile:</strong> "fotorealistico", "illustrazione", "acquerello"</li>
<li><strong>Specifica la qualità:</strong> "high quality", "4k", "detailed"</li>
<li><strong>Evita il testo:</strong> Aggiungi sempre "no text, no letters"</li>
<li><strong>Composizione:</strong> Indica "central composition", "clean layout"</li>
</ul>
<div class="example-prompt q-mt-md">
<strong>Esempio:</strong>
<p class="q-mt-sm">"Mystical autumn forest at golden hour, morning mist between oak trees, photorealistic, cinematic lighting, warm colors, high quality, no text, no letters"</p>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Capito!" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { Api } from 'src/store/Api';
import { PROVIDER_OPTIONS, ASPECT_RATIO_OPTIONS } from '../../types/poster.types';
const props = defineProps<{
assetType: 'backgroundImage' | 'mainImage';
template: any;
initialPrompt?: string;
}>();
const emit = defineEmits<{
(e: 'generated', result: { url: string; aiParams: any }): void;
(e: 'close'): void;
}>();
const $q = useQuasar();
// State
const selectedProvider = ref('hf');
const prompt = ref('');
const negativePrompt = ref('');
const aspectRatio = ref('9:16');
const seed = ref<number | null>(null);
const steps = ref(28);
const cfg = ref(7.5);
const isGenerating = ref(false);
const generatedImage = ref<string | null>(null);
const history = ref<{ url: string; prompt: string }[]>([]);
const showPromptTips = ref(false);
// Computed
const assetTypeLabel = computed(() => {
return props.assetType === 'backgroundImage' ? 'Immagine di sfondo' : 'Immagine principale';
});
const providers = computed(() => PROVIDER_OPTIONS);
const aspectRatioOptions = computed(() =>
ASPECT_RATIO_OPTIONS.map(opt => ({
label: opt.label,
value: opt.value
}))
);
const promptSuggestions = computed(() => {
const suggestions = [
'high quality, 4k',
'cinematic lighting',
'no text, no letters',
'photorealistic',
'warm colors',
'dramatic atmosphere'
];
// Add template-specific suggestions
if (props.template?.defaultAiPromptHints?.[props.assetType]) {
const hint = props.template.defaultAiPromptHints[props.assetType];
const words = hint.split(',').slice(0, 3).map((w: string) => w.trim());
return [...words, ...suggestions.slice(0, 3)];
}
return suggestions;
});
// Methods
const appendToPrompt = (text: string) => {
if (prompt.value) {
prompt.value += ', ' + text;
} else {
prompt.value = text;
}
};
const useStandardNegative = () => {
negativePrompt.value = 'text, letters, words, watermark, signature, blurry, low quality, distorted, ugly, bad anatomy, disfigured';
};
const generateImage = async () => {
if (!prompt.value.trim()) {
$q.notify({
type: 'warning',
message: 'Inserisci una descrizione per l\'immagine'
});
return;
}
isGenerating.value = true;
generatedImage.value = null;
try {
const res = await Api.SendReq('/api/assets/generate-ai', 'POST', {
prompt: prompt.value,
negativePrompt: negativePrompt.value,
provider: selectedProvider.value,
aspectRatio: aspectRatio.value,
category: props.assetType === 'backgroundImage' ? 'background' : 'main',
seed: seed.value,
steps: steps.value,
cfg: cfg.value
});
if (res?.data?.success) {
generatedImage.value = res.data.data.file.url;
// Add to history
history.value.unshift({
url: res.data.data.file.url,
prompt: prompt.value
});
// Keep only last 6
if (history.value.length > 6) {
history.value = history.value.slice(0, 6);
}
$q.notify({
type: 'positive',
message: 'Immagine generata con successo!',
icon: 'auto_awesome'
});
} else {
throw new Error(res?.data?.error || 'Errore durante la generazione');
}
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'Errore durante la generazione',
icon: 'error'
});
} finally {
isGenerating.value = false;
}
};
const confirmImage = () => {
if (!generatedImage.value) return;
emit('generated', {
url: generatedImage.value,
aiParams: {
prompt: prompt.value,
negativePrompt: negativePrompt.value,
provider: selectedProvider.value,
aspectRatio: aspectRatio.value,
seed: seed.value,
steps: steps.value,
cfg: cfg.value
}
});
};
const selectFromHistory = (item: { url: string; prompt: string }) => {
generatedImage.value = item.url;
prompt.value = item.prompt;
};
// Initialize with hint
onMounted(() => {
if (props.initialPrompt) {
prompt.value = props.initialPrompt;
}
useStandardNegative();
});
</script>
<style lang="scss" scoped>
.ai-generator-dialog {
width: 100%;
max-width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
.header-content {
display: flex;
align-items: center;
gap: 1rem;
h2 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.85rem;
color: #888;
}
}
}
.dialog-body {
flex: 1;
overflow: hidden;
padding: 0;
}
.generator-layout {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
// Form Panel
.form-panel {
padding: 1.5rem;
overflow-y: auto;
border-right: 1px solid #e0e0e0;
}
.form-section {
margin-bottom: 1.5rem;
}
.section-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: #555;
margin-bottom: 0.5rem;
.optional {
font-weight: 400;
color: #999;
font-size: 0.8rem;
}
}
// Provider Options
.provider-options {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.provider-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 140px;
&:hover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
&.is-selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.provider-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.provider-name {
font-weight: 500;
font-size: 0.9rem;
}
}
// Quick Prompts
.quick-prompts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
.quick-label {
font-size: 0.8rem;
color: #888;
}
}
.negative-presets {
margin-top: 0.5rem;
}
.advanced-header {
background: #fafafa;
}
.advanced-options {
padding: 1rem;
}
.options-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.generate-actions {
margin-top: 2rem;
}
.full-width {
width: 100%;
}
// Preview Panel
.preview-panel {
padding: 1.5rem;
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 16px;
overflow: hidden;
min-height: 400px;
position: relative;
&.has-image {
padding: 0;
}
}
.generating-state,
.empty-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: #888;
p {
margin: 1rem 0 0.5rem;
font-size: 1.1rem;
}
.time-estimate {
font-size: 0.85rem;
color: #aaa;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-actions {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: rgba(0, 0, 0, 0.7);
border-radius: 30px;
}
// History
.generation-history {
margin-top: 1.5rem;
h4 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: #666;
}
}
.history-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
@media (max-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.history-item {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s;
&:hover {
opacity: 1;
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Tips Dialog
.tips-list {
margin: 0;
padding-left: 1.25rem;
li {
margin-bottom: 0.75rem;
}
}
.example-prompt {
background: #f5f5f5;
padding: 1rem;
border-radius: 8px;
p {
margin: 0;
font-style: italic;
color: #666;
}
}
// Dark mode
.body--dark {
.form-panel {
border-color: #404040;
}
.provider-option {
border-color: #444;
&:hover,
&.is-selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.15);
}
}
.preview-panel {
background: #252525;
}
.preview-container {
background: #333;
}
.advanced-header {
background: #333;
}
.example-prompt {
background: #333;
}
}
</style>

View File

@@ -31,6 +31,11 @@ export default defineComponent({
required: false, required: false,
default: '', default: '',
}, },
circuitSel: {
type: String,
required: false,
default: '',
},
causalDest: { causalDest: {
type: String, type: String,
required: false, required: false,

View File

@@ -96,6 +96,7 @@
:to_user="myuser" :to_user="myuser"
:sendRIS="sendRIS" :sendRIS="sendRIS"
:causalDest="causalDest" :causalDest="causalDest"
:circuitname="circuitSel"
@close=" @close="
showsendCoinTo = false; showsendCoinTo = false;
loading = false; loading = false;

View File

@@ -1,9 +1,215 @@
.my-custom-container { // Variables
max-width: 100%; /* Imposta la larghezza massima per il contenitore */ $border-radius-sm: 8px;
$border-radius-md: 12px;
$transition-fast: 0.2s ease;
.copy-share-container {
width: 100%;
&.small-variant {
font-size: 13px;
}
&.btn-only {
display: inline-flex;
}
} }
.wrapword { // ═══════════════════════════════════════════
overflow: hidden; /* Nasconde il contenuto in eccesso */ // Link Display Wrapper
white-space: nowrap; /* Impedisce il wrapping del testo */ // ═══════════════════════════════════════════
text-overflow: ellipsis; /* Mostra "..." se il testo è troppo lungo */ .link-display-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
// Link Preview Box
.link-preview-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #f8fafc;
border: 1.5px solid #e2e8f0;
border-radius: $border-radius-sm;
transition: all $transition-fast;
&:hover {
border-color: #cbd5e1;
background: #f1f5f9;
}
&.compact {
padding: 8px 10px;
}
.link-icon {
flex-shrink: 0;
opacity: 0.7;
}
.link-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #475569;
font-size: 13px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.copy-icon-btn {
flex-shrink: 0;
transition: all $transition-fast;
&:hover {
transform: scale(1.1);
}
}
}
// ═══════════════════════════════════════════
// Action Buttons
// ═══════════════════════════════════════════
.action-buttons {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
&.compact {
gap: 8px;
.q-btn {
min-width: auto;
}
}
.share-btn,
.whatsapp-btn,
.telegram-btn {
flex: 1;
max-width: 160px;
text-transform: none;
font-weight: 600;
font-size: 13px;
&:hover {
transform: translateY(-1px);
}
}
.whatsapp-btn {
&:not(.q-btn--flat) {
background: linear-gradient(135deg, #25d366 0%, #128c7e 100%) !important;
border: none !important;
color: white !important;
}
}
.telegram-btn {
&:not(.q-btn--flat) {
background: linear-gradient(135deg, #0088cc 0%, #0077b5 100%) !important;
border: none !important;
color: white !important;
}
}
}
// ═══════════════════════════════════════════
// Default Layout
// ═══════════════════════════════════════════
.default-layout {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
.text-preview {
text-align: center;
color: #64748b;
font-size: 14px;
word-break: break-all;
padding: 0 8px;
&.small {
font-size: 12px;
}
}
.share-main-btn {
text-transform: none;
font-weight: 600;
padding: 10px 24px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
transition: all $transition-fast;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35);
}
}
}
// ═══════════════════════════════════════════
// Copied State Animation
// ═══════════════════════════════════════════
.copied-state {
animation: pulse-success 0.3s ease;
}
@keyframes pulse-success {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
// ═══════════════════════════════════════════
// Dark Mode
// ═══════════════════════════════════════════
.body--dark {
.link-preview-box {
background: #1e293b;
border-color: #334155;
&:hover {
border-color: #475569;
background: #273449;
}
.link-text {
color: #cbd5e1;
}
}
.text-preview {
color: #94a3b8;
}
}
// ═══════════════════════════════════════════
// Responsive
// ═══════════════════════════════════════════
@media (max-width: 400px) {
.action-buttons:not(.compact) {
flex-direction: column;
.share-btn,
.whatsapp-btn,
.telegram-btn {
max-width: 100%;
}
}
} }

View File

@@ -1,55 +1,208 @@
import { tools } from '../../store/Modules/tools' import { tools } from '../../store/Modules/tools'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useUserStore } from '@store/UserStore' import { defineComponent, ref, computed } from 'vue'
import { useGlobalStore } from '@store/globalStore'
import { defineComponent } from 'vue'
import { shared_consts } from '@/common/shared_vuejs' import { shared_consts } from '@/common/shared_vuejs'
export default defineComponent({ export default defineComponent({
name: 'CCopyBtnSmall', name: 'CCopyBtnSmall',
props: { props: {
// Testo/link da copiare
texttocopy: { texttocopy: {
type: String, type: String,
required: true, required: true,
}, },
// Titolo per la condivisione
title: { title: {
type: String, type: String,
required: false, default: 'Condividi questo link',
},
// Messaggio personalizzato per la condivisione
shareMessage: {
type: String,
default: '', default: '',
}, },
// Lunghezza massima del testo visualizzato
maxLength: {
type: Number,
default: 400,
},
// Dimensione compatta
small: { small: {
type: Boolean, type: Boolean,
required: false,
default: false, default: false,
} },
// Mostra solo il pulsante (senza link preview)
btn: {
type: Boolean,
default: false,
},
// Mostra solo icona (senza label)
iconOnly: {
type: Boolean,
default: false,
},
// Stile flat
flat: {
type: Boolean,
default: false,
},
// Stile outline
outline: {
type: Boolean,
default: false,
},
// Colore del pulsante
btnColor: {
type: String,
default: 'primary',
},
// Label personalizzata
label: {
type: String,
default: 'Copia link',
},
// Mostra il link preview
showLink: {
type: Boolean,
default: false,
},
// Mostra i pulsanti di azione
showActions: {
type: Boolean,
default: true,
},
// Mostra pulsante Share nativo
showShareBtn: {
type: Boolean,
default: true,
},
// Mostra pulsante WhatsApp
showWhatsApp: {
type: Boolean,
default: true,
},
// Mostra pulsante Telegram
showTelegram: {
type: Boolean,
default: false,
},
}, },
components: {},
setup(props) { emits: ['copied', 'shared'],
setup(props, { emit }) {
const $q = useQuasar() const $q = useQuasar()
const { t } = useI18n() const { t } = useI18n()
async function copytoclipandsend() { // State
tools.copyStringToClipboard($q, props.texttocopy, true) const copied = ref(false)
let copyTimeout: ReturnType<typeof setTimeout> | null = null
let msg = 'Questo è il link che puoi condividere per farti inviare i RIS:<br><br>👉🏻 ' + props.texttocopy // Computed
const truncatedText = computed(() => {
if (!props.texttocopy) return ''
if (props.texttocopy.length <= props.maxLength) return props.texttocopy
return props.texttocopy.substring(0, props.maxLength) + '...'
})
tools.sendMsgTelegramCmd($q, t, shared_consts.MsgTeleg.SHARE_TEXT, false, msg) const containerClasses = computed(() => ({
'small-variant': props.small,
'btn-only': props.btn && !props.showLink,
}))
} const shareText = computed(() => {
if (props.shareMessage) return props.shareMessage
return `${props.title}\n\n👉 ${props.texttocopy}`
})
function getclass() { // Methods
if (props.small) { async function handleCopy() {
return 'text-h7' try {
} else { await navigator.clipboard.writeText(props.texttocopy)
return 'text-h5'
copied.value = true
emit('copied', props.texttocopy)
$q.notify({
type: 'positive',
message: 'Link copiato negli appunti!',
icon: 'check',
timeout: 2000,
position: 'bottom',
})
// Reset stato dopo 2 secondi
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
// Fallback per browser più vecchi
tools.copyStringToClipboard($q, props.texttocopy, true)
copied.value = true
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => {
copied.value = false
}, 2000)
} }
} }
async function handleShare() {
// Prima copia negli appunti
await handleCopy()
// Prova Web Share API
if (navigator.share) {
try {
await navigator.share({
title: props.title,
text: props.shareMessage || 'Ecco il link:',
url: props.texttocopy,
})
emit('shared', 'native')
} catch (err) {
// L'utente ha annullato o errore
console.log('Share cancelled or failed')
}
} else {
// Fallback: usa Telegram come default
handleTelegram()
}
}
function handleWhatsApp() {
const text = encodeURIComponent(shareText.value)
window.open(`https://wa.me/?text=${text}`, '_blank')
emit('shared', 'whatsapp')
}
function handleTelegram() {
const msg = props.shareMessage ||
`Questo è il link che puoi condividere:\n\n👉 ${props.texttocopy}`
tools.sendMsgTelegramCmd($q, t, shared_consts.MsgTeleg.SHARE_TEXT, false, msg)
emit('shared', 'telegram')
}
return { return {
copytoclipandsend, // State
copied,
// Computed
truncatedText,
containerClasses,
// Methods
handleCopy,
handleShare,
handleWhatsApp,
handleTelegram,
// Utils
tools, tools,
getclass,
t, t,
} }
}, },

View File

@@ -1,11 +1,127 @@
<template> <template>
<div class="" style="width: 100%; overflow: hidden;"> <div class="copy-share-container" :class="containerClasses">
<div class="row justify-center"> <!-- Variante: Solo pulsante -->
{{ tools.firstchars(texttocopy, 80) }} <template v-if="btn && !showLink">
</div> <q-btn
<div class="row justify-center q-mt-sm"> :size="small ? 'sm' : 'md'"
<q-btn rounded label="Condividi link" color="primary" @click="copytoclipandsend" /> :round="iconOnly"
</div> :rounded="!iconOnly"
:flat="flat"
:outline="outline"
:unelevated="!flat && !outline"
:color="btnColor"
:icon="copied ? 'check' : 'content_copy'"
:label="iconOnly ? undefined : (copied ? 'Copiato!' : label)"
:class="{ 'copied-state': copied }"
@click="handleCopy"
>
<q-tooltip v-if="iconOnly">{{ copied ? 'Copiato!' : 'Copia link' }}</q-tooltip>
</q-btn>
</template>
<!-- Variante: Link + Azioni -->
<template v-else-if="showLink">
<div class="link-display-wrapper">
<!-- Link Preview -->
<div class="link-preview-box" :class="{ 'compact': small }">
<q-icon name="link" :size="small ? '18px' : '20px'" color="primary" class="link-icon" />
<span class="link-text" :title="texttocopy">
{{ truncatedText }}
</span>
<q-btn
flat
round
dense
:size="small ? 'sm' : 'md'"
:icon="copied ? 'check' : 'content_copy'"
:color="copied ? 'positive' : 'grey-7'"
class="copy-icon-btn"
@click="handleCopy"
>
<q-tooltip>{{ copied ? 'Copiato!' : 'Copia' }}</q-tooltip>
</q-btn>
</div>
<!-- Action Buttons -->
<div v-if="showActions" class="action-buttons" :class="{ 'compact': small }">
<q-btn
v-if="showShareBtn"
:outline="!small"
:flat="small"
:rounded="!small"
:round="small"
:size="small ? 'sm' : 'md'"
color="primary"
:icon="small ? 'share' : undefined"
:label="small ? undefined : 'Condividi'"
class="share-btn"
@click="handleShare"
>
<template v-if="!small" v-slot:prepend>
<q-icon name="share" />
</template>
<q-tooltip v-if="small">Condividi</q-tooltip>
</q-btn>
<q-btn
v-if="showWhatsApp"
:outline="!small"
:flat="small"
:rounded="!small"
:round="small"
:size="small ? 'sm' : 'md'"
color="green"
:icon="small ? 'fab fa-whatsapp' : undefined"
:label="small ? undefined : 'WhatsApp'"
class="whatsapp-btn"
@click="handleWhatsApp"
>
<template v-if="!small" v-slot:prepend>
<q-icon name="fab fa-whatsapp" />
</template>
<q-tooltip v-if="small">WhatsApp</q-tooltip>
</q-btn>
<q-btn
v-if="showTelegram"
:outline="!small"
:flat="small"
:rounded="!small"
:round="small"
:size="small ? 'sm' : 'md'"
color="light-blue"
:icon="small ? 'fab fa-telegram' : undefined"
:label="small ? undefined : 'Telegram'"
class="telegram-btn"
@click="handleTelegram"
>
<template v-if="!small" v-slot:prepend>
<q-icon name="fab fa-telegram" />
</template>
<q-tooltip v-if="small">Telegram</q-tooltip>
</q-btn>
</div>
</div>
</template>
<!-- Variante: Default (testo + bottone) -->
<template v-else>
<div class="default-layout">
<div class="text-preview" :class="{ 'small': small }">
{{ truncatedText }}
</div>
<q-btn
rounded
unelevated
:size="small ? 'sm' : 'md'"
:label="copied ? 'Copiato!' : 'Condividi link'"
:color="copied ? 'positive' : 'primary'"
:icon="copied ? 'check' : 'share'"
class="share-main-btn"
@click="handleShare"
/>
</div>
</template>
</div> </div>
</template> </template>

View File

@@ -67,6 +67,11 @@ export default defineComponent({
required: false, required: false,
default: false, default: false,
}, },
prop_compatto: {
type: Boolean,
required: false,
default: false,
},
finder: { finder: {
type: Boolean, type: Boolean,
required: false, required: false,

View File

@@ -22,6 +22,7 @@
labelElemFind="trovati" labelElemFind="trovati"
:choose_visutype="visuType" :choose_visutype="visuType"
:butt_modif_new="prop_modif" :butt_modif_new="prop_modif"
:compatto="prop_compatto"
:noresultLabel=" :noresultLabel="
t('grid.nosearchfound') + ' ' + (showMap ? t('grid.intheareamap') : '') t('grid.nosearchfound') + ' ' + (showMap ? t('grid.intheareamap') : '')
" "

View File

@@ -1,3 +1,6 @@
// ========================================
// SHADOW VARIABLES
// ========================================
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); $shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
$shadow-md: 0 2px 6px rgba(0, 0, 0, 0.08); $shadow-md: 0 2px 6px rgba(0, 0, 0, 0.08);
$shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15); $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
@@ -20,22 +23,23 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
margin-top: 3px; margin-top: 3px;
} }
.q-table td {
padding-left: 1px;
padding-right: 2px;
padding-top: 0;
padding-bottom: 0;
&__title {
font-size: 1rem;
}
}
.q-table { .q-table {
td {
padding: 0 2px 0 1px;
&__title {
font-size: 1rem;
}
}
&__col { &__col {
font-size: 1rem; font-size: 1rem;
color: gray; color: gray;
} }
&__top {
padding-top: 0 !important;
}
} }
.newrec_fields { .newrec_fields {
@@ -51,10 +55,6 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
padding: 3px 6px !important; padding: 3px 6px !important;
} }
.q-table__top {
padding-top: 0 !important;
}
// ======================================== // ========================================
// DIALOG & LAYOUT // DIALOG & LAYOUT
// ======================================== // ========================================
@@ -88,6 +88,7 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
overflow-y: auto; overflow-y: auto;
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
// Scrollbar styling
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -148,15 +149,20 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
} }
// ======================================== // ========================================
// INFINITE SCROLL // GRID LAYOUT PER CARD (INFINITE SCROLL)
// ========================================
// ========================================
// GRID LAYOUT PER CARD
// ======================================== // ========================================
.q-infinite-scroll { .q-infinite-scroll {
padding: 6px; padding: 6px;
gap: 8px; gap: 8px;
// Mobile: colonna singola
@media (max-width: $mobile-breakpoint) {
display: flex;
flex-direction: column;
padding: 5px;
gap: 6px;
}
// Desktop: layout a griglia 2 colonne // Desktop: layout a griglia 2 colonne
@media (min-width: $mobile-breakpoint) { @media (min-width: $mobile-breakpoint) {
display: grid; display: grid;
@@ -165,19 +171,11 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
padding: 8px; padding: 8px;
} }
// Desktop large: 3 colonne per schermi molto larghi // Desktop large: mantiene 2 colonne con gap maggiore
@media (min-width: 1400px) { @media (min-width: 1400px) {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 12px; gap: 12px;
} }
// Mobile: colonna singola
@media (max-width: $mobile-breakpoint) {
display: flex;
flex-direction: column;
padding: 5px;
gap: 6px;
}
} }
// ======================================== // ========================================
@@ -209,6 +207,10 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
padding: 3px 6px; padding: 3px 6px;
} }
} }
// ========================================
// GRID CARD ITEM
// ========================================
.grid-card-item { .grid-card-item {
width: 100%; width: 100%;
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
@@ -216,14 +218,16 @@ $shadow-hover: 0 4px 12px rgba(25, 118, 210, 0.15);
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 8px; // Spazio tra carousel e card padding: 8px;
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
width: 100%; padding: 4px;
padding: 4px; // Ridotto su mobile
} }
} }
// ========================================
// UTILITY CLASSES
// ========================================
.fill-all-width { .fill-all-width {
width: 100%; width: 100%;
} }

View File

@@ -40,6 +40,7 @@ import { CMyUser } from '../CMyUser';
import { CMyGroups } from '../CMyGroups'; import { CMyGroups } from '../CMyGroups';
import { CMyFieldDb } from '../CMyFieldDb'; import { CMyFieldDb } from '../CMyFieldDb';
import { CMyRecCard } from '../CMyRecCard'; import { CMyRecCard } from '../CMyRecCard';
import { CMyRecEventi } from '../CMyRecEventi';
import { CMyRecCatalog } from '../CMyRecCatalog'; import { CMyRecCatalog } from '../CMyRecCatalog';
import { CMyRecRaccoltaCataloghi } from '../CMyRecRaccoltaCataloghi'; import { CMyRecRaccoltaCataloghi } from '../CMyRecRaccoltaCataloghi';
import { CMapByTable } from '../CMapByTable'; import { CMapByTable } from '../CMapByTable';
@@ -176,6 +177,11 @@ export default defineComponent({
required: false, required: false,
default: '', default: '',
}, },
compatto: {
type: Boolean,
required: false,
default: false,
},
actionType: { actionType: {
type: Number, type: Number,
required: false, required: false,
@@ -381,6 +387,7 @@ export default defineComponent({
CMyGroups, CMyGroups,
CMyUser, CMyUser,
CMyRecCard, CMyRecCard,
CMyRecEventi,
CMyRecCatalog, CMyRecCatalog,
CMyCardPopup, CMyCardPopup,
CMyRecGrpCard, CMyRecGrpCard,
@@ -1341,14 +1348,20 @@ export default defineComponent({
obj2.idSkill = idSkill; obj2.idSkill = idSkill;
filtersearch2.push(obj2); filtersearch2.push(obj2);
} }
} else if (item.table === toolsext.TABGOODS && item.value === costanti.FILTER_TUTTI) { } else if (
item.table === toolsext.TABGOODS &&
item.value === costanti.FILTER_TUTTI
) {
const obj2: any = {}; const obj2: any = {};
if (idSectorGood > 0) { if (idSectorGood > 0) {
// idSectorGood // idSectorGood
obj2['sectorGood._id'] = idSectorGood; obj2['sectorGood._id'] = idSectorGood;
filtersearch2.push(obj2); filtersearch2.push(obj2);
} }
} else if (item.table === toolsext.TABBACHECAS && item.value === costanti.FILTER_TUTTI) { } else if (
item.table === toolsext.TABBACHECAS &&
item.value === costanti.FILTER_TUTTI
) {
const obj2: any = {}; const obj2: any = {};
if (idSectorBacheca > 0) { if (idSectorBacheca > 0) {
// idSectorBacheca // idSectorBacheca

View File

@@ -89,6 +89,8 @@
> >
</CTitleBanner> </CTitleBanner>
<div v-if="shared_consts.VERTIC_SHOW_GRID.includes(myvertical)"> <div v-if="shared_consts.VERTIC_SHOW_GRID.includes(myvertical)">
<div <div
v-if="(prop_search || canEdit) && finder" v-if="(prop_search || canEdit) && finder"
@@ -444,7 +446,29 @@
:style="heightcarousel ? `height: ${heightcarousel}` : ''" :style="heightcarousel ? `height: ${heightcarousel}` : ''"
v-intersection="onIntersection" v-intersection="onIntersection"
> >
<div v-if="tablesel === shared_consts.TABLES_MYBACHECAS && compatto">
<div
v-for="(row, indexrow) in serverData.slice(0, 5)"
:key="row._id || indexrow"
:class="{
row: opt.rowclass,
'items-stretch': opt.rowclass,
}"
>
<CMyRecEventi
:table="tablesel"
:prop_myrec="row"
@cmdext="cmdExt"
:editOn="editOn"
:margin_right="margin_right"
:compatto="compatto"
>
</CMyRecEventi>
</div>
</div>
<q-carousel <q-carousel
v-else
swipeable swipeable
animated animated
:autoplay="autoplay" :autoplay="autoplay"
@@ -507,6 +531,7 @@
@cmdext="cmdExt" @cmdext="cmdExt"
:editOn="editOn" :editOn="editOn"
:margin_right="margin_right" :margin_right="margin_right"
:compatto="compatto"
> >
</CMyRecCard> </CMyRecCard>
</q-carousel-slide> </q-carousel-slide>
@@ -523,10 +548,7 @@
<q-infinite-scroll <q-infinite-scroll
ref="myinfscroll" ref="myinfscroll"
v-else-if=" v-else-if="shared_consts.VERTIC_SHOW_GRID.includes(myvertical) && alreadymounting"
shared_consts.VERTIC_SHOW_GRID.includes(myvertical) &&
alreadymounting
"
:initial-index="0" :initial-index="0"
@load="onLoadScroll" @load="onLoadScroll"
:offset="350" :offset="350"
@@ -631,6 +653,7 @@
@cmdext="cmdExt" @cmdext="cmdExt"
:editOn="editOn" :editOn="editOn"
:margin_right="margin_right" :margin_right="margin_right"
:compatto="compatto"
> >
</CMyRecCard> </CMyRecCard>
</div> </div>
@@ -699,9 +722,7 @@
</q-infinite-scroll> </q-infinite-scroll>
<q-table <q-table
v-else-if=" v-else-if="
!shared_consts.VERTIC_SHOW_GRID.includes(myvertical) && !shared_consts.VERTIC_SHOW_GRID.includes(myvertical) && serverData && mycolumns
serverData &&
mycolumns
" "
:grid="shared_consts.VERTIC_SHOW_GRID.includes(myvertical)" :grid="shared_consts.VERTIC_SHOW_GRID.includes(myvertical)"
:grid-header=" :grid-header="
@@ -728,55 +749,38 @@
selection="single" selection="single"
v-model:selected="selected" v-model:selected="selected"
> >
<template <template v-slot:header="props">
v-if="
!(
myvertical === costanti.VISUTABLE_SCHEDA_USER ||
myvertical === 2 ||
myvertical === costanti.VISUTABLE_SCHEDA_GROUP
)
"
v-slot:header="props"
>
<q-tr :props="props"> <q-tr :props="props">
<q-th> </q-th> <q-th />
<q-th <q-th
v-for="col in props.cols" v-for="(col, index) in (props.cols || []).filter(
:key="col.name" (c) => c !== undefined && c !== null
)"
:key="col.name || index"
:props="props" :props="props"
class="text-italic text-weight-bold" class="text-italic text-weight-bold"
> >
<span v-if="col && showColCheck(col, tools.TIPOVIS_SHOW_RECORD, true)"> <span v-if="col">
{{ col.label }} <template
v-if="
!(
myvertical === costanti.VISUTABLE_SCHEDA_USER ||
myvertical === 2 ||
myvertical === costanti.VISUTABLE_SCHEDA_GROUP
) &&
showColCheck &&
showColCheck(col, tools.TIPOVIS_SHOW_RECORD, true)
"
>
{{ col.label }}
</template>
<template v-else-if="col.sortable !== undefined">
{{ col.label }}
</template>
</span> </span>
</q-th> </q-th>
</q-tr> </q-tr>
</template> </template>
<template
v-else
v-slot:header="props"
>
<q-tr :props="props">
<q-th> </q-th>
<span
v-for="col in props.cols"
:key="col.name"
>
<q-th
v-if="col.sortable"
:key="col.name"
:props="props"
class="text-italic text-weight-bold"
>
<span>
{{ col.label }}
</span>
</q-th>
</span>
</q-tr>
</template>
<template <template
v-slot:top-right v-slot:top-right
v-if="tablesList || arrfilters || enableExport" v-if="tablesList || arrfilters || enableExport"

View File

@@ -219,7 +219,7 @@
" "
> >
<q-item-section>{{ <q-item-section>{{
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.enter') circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')
}}</q-item-section> }}</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@@ -436,7 +436,7 @@
" "
icon="fas fa-user-plus" icon="fas fa-user-plus"
color="primary" color="primary"
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.enter')" :label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')"
rounded rounded
size="lg" size="lg"
@click=" @click="

View File

@@ -14,12 +14,11 @@ import { tools } from '@tools'
import { CUserNonVerif } from '@/components/CUserNonVerif' import { CUserNonVerif } from '@/components/CUserNonVerif'
import { CTitleBanner } from '@/components/CTitleBanner' import { CTitleBanner } from '@/components/CTitleBanner'
import { CMovements } from '@/components/CMovements' import { CMovements } from '@/components/CMovements'
import { CSendRISTo } from '@/components/CSendRISTo'
export default defineComponent({ export default defineComponent({
name: 'CMyCircuits', name: 'CMyCircuits',
components: { CMyCircuit, CUserNonVerif, CTitleBanner, CMovements, CSendRISTo }, components: { CMyCircuit, CUserNonVerif, CTitleBanner, CMovements },
emits: ['update:modelValue'], emits: ['update:modelValue'],
props: { props: {
modelValue: { modelValue: {

View File

@@ -5,7 +5,6 @@
<div v-if="tools.isUserOk() && finishloading"> <div v-if="tools.isUserOk() && finishloading">
<div v-if="finder && showfinder" class="q-gutter-sm q-pa-sm q-pb-sm"> <div v-if="finder && showfinder" class="q-gutter-sm q-pa-sm q-pb-sm">
<div class="q-mt-md"> <div class="q-mt-md">
<CSendRISTo></CSendRISTo>
<q-tabs <q-tabs
v-model="mytab" v-model="mytab"

View File

@@ -1539,6 +1539,41 @@
label="Bottone Modifica" label="Bottone Modifica"
@update:model-value="modifElem" @update:model-value="modifElem"
></q-toggle> ></q-toggle>
<q-toggle
v-model="myel.parambool5"
color="positive"
label="Compatto"
@update:model-value="modifElem"
></q-toggle>
<q-input
dense
label="Titolo Principale"
@update:model-value="modifElem"
v-model="myel.stiletit_str"
filled
v-on:keyup.enter="saveElem"
>
</q-input>
<q-input
dense
label="Icona Titolo Normale:"
@update:model-value="modifElem"
v-model="myel.stiletit_icon"
filled
v-on:keyup.enter="saveElem"
>
</q-input>
<q-input
dense
label="Link Principale: /..."
@update:model-value="modifElem"
v-model="myel.container2"
filled
v-on:keyup.enter="saveElem"
>
</q-input>
</div> </div>
</div> </div>
<div v-else-if="myel.type === shared_consts.ELEMTYPE.RACCOLTE_CATALOGHI"></div> <div v-else-if="myel.type === shared_consts.ELEMTYPE.RACCOLTE_CATALOGHI"></div>

View File

@@ -1,44 +1,241 @@
// ========================================
// COLOR VARIABLES
// ========================================
$grayshadow: #555; $grayshadow: #555;
$textcol: blue; $textcol: blue;
$textcol_scuro: darkblue; $textcol_scuro: darkblue;
// Dark mode variants
$grayshadow-dark: #222;
$textcol-dark: #64b5f6;
$textcol_scuro-dark: #90caf9;
// ========================================
// TYPOGRAPHY BASE
// ========================================
p { p {
margin: 0 0 1.25rem; margin: 0 0 1.25rem;
//text-shadow: .125rem .125rem .25rem $grayshadow;
} }
h4 { h4 {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.5;
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow;
.body--dark & {
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow-dark;
}
} }
// ========================================
// HEADING STYLES
// ========================================
.text-h1,
h1 {
font-size: 3rem;
font-weight: bold;
line-height: 3rem;
letter-spacing: -0.01562em;
margin-bottom: 8px !important;
}
.text-h2 {
font-size: 3.75rem;
font-weight: 300;
line-height: 3.75rem;
letter-spacing: -0.00833em;
}
.text-weight-bold {
font-weight: 700;
}
.text-vers {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.75rem;
letter-spacing: 0.00937em;
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow;
.body--dark & {
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow-dark;
}
}
// ========================================
// CARD & UTILITY
// ========================================
.mycard { .mycard {
visibility: hidden; visibility: hidden;
} }
.shadow {
text-shadow: 0.125rem 0.125rem 0.25rem $grayshadow;
.body--dark & {
text-shadow: 0.125rem 0.125rem 0.25rem $grayshadow-dark;
}
}
// ========================================
// LANDING PAGE - BACKGROUND & LAYOUT
// ========================================
.landing_background { .landing_background {
background: #000 url(/images/foto1.jpg) no-repeat 50% fixed; background: #000 url("/images/foto1.jpg") no-repeat 50% fixed;
background-size: cover background-size: cover;
} }
.landing>section { .landing > section {
display: -webkit-box;
display: -ms-flexbox;
display: flex; display: flex;
-webkit-box-pack: center;
-ms-flex-pack: center;
justify-content: center; justify-content: center;
//padding: 0 16px
> div {
position: relative;
width: 100%;
}
&.padding {
padding: 5.62rem 1rem;
}
&.padding_testo {
padding-top: 1.25rem;
padding-bottom: 1rem;
}
&.padding_gallery {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
}
} }
// ========================================
// LANDING TOOLBAR
// ========================================
.landing__toolbar {
background: linear-gradient(180deg, #000, transparent);
padding: 0 !important;
.q-btn {
border-radius: 0 0 0.315rem 0.315rem;
align-self: stretch;
}
}
// ========================================
// LANDING HERO
// ========================================
.landing__hero {
min-height: 50vh;
}
.landing__header {
height: 18vh;
}
.landing__arrow {
bottom: 1.5rem;
opacity: 0.4;
}
.landing__front {
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.6) 15%);
}
.landing__logo {
width: 9.4rem;
height: 9.4rem;
margin-top: 1.315rem;
}
// ========================================
// LANDING FEATURES
// ========================================
.landing__features {
.q-icon {
font-size: 4rem;
}
h4,
h6 {
margin: 1rem 0;
}
p {
opacity: 0.7;
font-size: 1rem;
line-height: 1.5;
}
}
.feat-descr {
font-size: 1.15rem;
&:hover {
transition: opacity 0.5s ease-in-out;
opacity: 0.9;
}
}
// ========================================
// LANDING FOOTER
// ========================================
.landing__footer {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.8) 95%, #fff);
padding: 4.5rem 1.25rem !important;
color: #9f9f9f;
.body--dark & {
background: linear-gradient(180deg, rgba(0, 0, 0, 0.9) 95%, #1a1a1a);
color: #b0b0b0;
}
.doc-link {
color: $textcol;
&:hover {
opacity: 0.8;
}
.body--dark & {
color: $textcol-dark;
}
}
}
.landing__footer-icons {
font-size: 1.75rem;
a {
margin: 0 0.5rem 0.5rem;
text-decoration: none;
outline: 0;
color: $textcol;
transition: color 0.28s;
&:hover {
color: $textcol_scuro;
}
.body--dark & {
color: $textcol-dark;
&:hover {
color: $textcol_scuro-dark;
}
}
}
}
// ========================================
// INTRO SECTION
// ========================================
.intro { .intro {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: stretch; align-items: stretch;
/* flex-flow: row nowrap; */ padding: 1.25rem 0;
margin: 0.125rem;
padding: 1.25rem 0 1.25rem 0;
margin: .125rem;
* { * {
width: 100%; width: 100%;
@@ -47,10 +244,7 @@ h4 {
margin-right: auto; margin-right: auto;
} }
&__associazione { &__associazione,
min-width: 350px;
}
&__comeassociarsi { &__comeassociarsi {
min-width: 350px; min-width: 350px;
} }
@@ -64,160 +258,20 @@ h4 {
font-size: 1rem; font-size: 1rem;
} }
.landing>section.padding { // ========================================
padding: 5.62rem 1rem; // LAYOUT UTILITIES
} // ========================================
.landing>section.padding_testo {
padding-top: 1.25rem;
padding-bottom: 1rem;
}
.landing>section.padding_gallery {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
}
.landing>section>div {
position: relative;
width: 100%
}
.maxwidth1200 { .maxwidth1200 {
max-width: 1200px; max-width: 1200px;
} }
.landing__toolbar {
background: -webkit-gradient(linear, left top, left bottom, from(#000), to(transparent));
background: linear-gradient(180deg, #000, transparent);
padding: 0 !important
}
.landing__toolbar .q-btn {
border-radius: 0 0 .315rem .315rem;
-ms-flex-item-align: stretch;
align-self: stretch
}
.landing__hero {
min-height: 50vh
}
.landing__header {
height: 18vh
}
.landing__arrow {
bottom: 1.5rem;
opacity: .4
}
.landing__front {
background: -webkit-gradient(linear, left top, left bottom, from(transparent), color-stop(15%, rgba(0, 0, 0, .6)));
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .6) 15%)
}
.landing__logo {
width: 9.40rem;
height: 9.40rem;
margin-top: 1.315rem;
//-webkit-animation: logo-rotate 240s linear infinite;
//animation: logo-rotate 240s linear infinite
}
.landing__features .q-icon {
font-size: 4rem
}
h4 {
line-height: 1.5;
text-shadow: .25rem .25rem .5rem $grayshadow;
}
.landing__features h4,
.landing__features h6 {
margin: 1rem 0
}
.landing__features p {
opacity: .7;
font-size: 1rem;
line-height: 1.5;
}
.landing__footer {
//background: -webkit-gradient(linear, left top, left bottom, color-stop(65%, rgba(0, 0, 0, .1)), to(#000));
background: linear-gradient(180deg, rgba(0, 0, 0, .8) 95%, #FFF);
padding-top: 4.5rem !important;
padding-bottom: 4.5rem !important;
padding-left: 1.25rem;
padding-right: 1.25rem;
color: #9f9f9f;
}
.icon_contact:hover {
color: blue;
border-color: white;
border-width: .0625rem;
}
.landing__footer .doc-link {
color: $textcol;
}
.landing__footer .doc-link:hover {
opacity: .8
}
.feat-descr {
font-size: 1.15rem;
}
.feat-descr:hover {
transition: opacity 0.5s ease-in-out;
opacity: 0.9;
}
.q-col-gutter-sm { .q-col-gutter-sm {
padding: 3.125rem 3.125rem; padding: 3.125rem;
//margin-left: -48px
} }
body.mobile .landing:before {
content: "";
position: fixed;
top: 0;
height: 100vh;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
//background: #000 url(/images/cover.jpg) 50%;
background-size: cover
}
/*
@-webkit-keyframes logo-rotate {
to {
-webkit-transform: rotate(-1turn);
transform: rotate(-1turn)
}
}
@keyframes logo-rotate {
to {
-webkit-transform: rotate(-1turn);
transform: rotate(-1turn)
}
}
*/
.home { .home {
//background-color: rgb(250, 250, 250);
padding: 3.125rem; padding: 3.125rem;
display: flex; display: flex;
//flex-wrap: nowrap;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -227,86 +281,291 @@ body.mobile .landing:before {
margin: 3.125rem; margin: 3.125rem;
} }
.shadow { // ========================================
//color: white; // MOBILE BACKGROUND
text-shadow: 0.125rem 0.125rem 0.25rem $grayshadow; // ========================================
} body.mobile .landing::before {
content: "";
.text-h1, position: fixed;
h1 { top: 0;
font-size: 3rem; height: 100vh;
font-weight: bold; left: 0;
line-height: 3rem; right: 0;
letter-spacing: -.01562em; bottom: 0;
margin-bottom: 8px !important; z-index: -1;
} background-size: cover;
.text-h2 {
font-size: 3.75rem;
font-weight: 300;
line-height: 3.75rem;
letter-spacing: -.00833em;
}
.text-weight-bold {
font-weight: 700;
}
.text-vers {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.75rem;
letter-spacing: .00937em;
text-shadow: .25rem .25rem .5rem $grayshadow;
}
.landing__footer-icons {
font-size: 1.75rem
}
.landing__footer-icons a {
margin: 0 .5rem .5rem;
text-decoration: none;
outline: 0;
color: $textcol;
transition: color .28s
}
.landing__footer-icons a:hover {
color: $textcol_scuro;
}
.doc-img {
max-width: 100%;
}
.mylist {
background: #3fdaff;
padding-left: 1.25rem;
}
.clgutter {
margin-top: 1.25rem;
padding: .62rem;
} }
// ========================================
// GALLERY & CAROUSEL
// ========================================
.carousel_img_3 { .carousel_img_3 {
//background-image: url(/images/cibo_sano.jpg);
background-size: cover !important; background-size: cover !important;
background-position: 50% center !important; background-position: 50% center !important;
background-repeat: no-repeat !important; background-repeat: no-repeat !important;
} }
@media (max-width: 718px) { .custom-caption {
// PER VERSIONE MOBILE text-align: center;
padding: 0.75rem;
color: $textcol;
background-color: rgba(0, 0, 0, 0.3);
.body--dark & {
color: $textcol-dark;
background-color: rgba(0, 0, 0, 0.5);
}
}
.sfondo-grigio {
padding: 1rem;
color: $textcol;
background-color: rgba(0, 0, 0, 0.35);
.body--dark & {
color: $textcol-dark;
background-color: rgba(0, 0, 0, 0.5);
}
}
// ========================================
// CONTACTS
// ========================================
.mycontacts {
color: gray;
letter-spacing: 0.078rem;
.body--dark & {
color: #aaa;
}
}
.mycontacts_title {
text-shadow: 0.125rem 0.125rem 0.125rem #555;
font-weight: bold;
color: #999;
letter-spacing: 0.125rem;
.body--dark & {
text-shadow: 0.125rem 0.125rem 0.125rem #222;
color: #bbb;
}
}
.mycontacts_text {
color: #999;
letter-spacing: 0.093rem;
.body--dark & {
color: #bbb;
}
}
.icon_contact {
&:hover {
color: blue;
border-color: white;
border-width: 0.0625rem;
.body--dark & {
color: $textcol-dark;
}
}
}
// ========================================
// GUTTER & SPACING
// ========================================
.clgutter {
margin-top: 1.25rem;
padding: 0.62rem;
}
.mylist {
background: #3fdaff;
padding-left: 1.25rem;
.body--dark & {
background: #1a8a9e;
}
}
// ========================================
// IMAGE
// ========================================
.doc-img {
max-width: 100%;
}
// ========================================
// EDITOR STYLES
// ========================================
.clEditDiv {
border: 2px solid #c8c9cb;
padding: 2px;
.body--dark & {
border-color: #555;
}
}
.clEditNotActive {
background-color: #e6e6e6;
.body--dark & {
background-color: #3a3a3a;
}
}
.clEdit {
border: 2px solid #f69f09;
padding: 2px;
&:hover {
border-color: #11f609;
cursor: pointer;
}
}
.selectedElem {
border: 3px solid #b91111 !important;
}
.elemEdit {
margin: 3px;
padding: 3px;
text-align: center;
font-weight: bold;
&:hover {
border: 2px solid #11f609;
cursor: pointer;
}
}
.myElemBase {
margin-bottom: 16px;
}
// ========================================
// ALIGNMENT UTILITIES
// ========================================
.align_center {
text-align: center;
}
.align_right {
text-align: right;
}
.align_left {
text-align: left;
}
// ========================================
// FLEX UTILITIES
// ========================================
.flex3 {
display: flex;
justify-content: space-between;
background-color: green;
.body--dark & {
background-color: #1b5e20;
}
}
// ========================================
// CONTENT SECTION
// ========================================
.content-section {
background: white;
border-radius: $r-lg;
padding: $s-sm;
margin-bottom: $s-sm;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.body--dark & {
background: #2d2d2d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
&:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $s-sm;
border-bottom: 2px solid rgba(0, 0, 0, 0.05);
.body--dark & {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
}
}
// ========================================
// SECTION TITLE
// ========================================
.section-title {
font-size: 1.2rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: $s-sm;
background: $gradient-primary;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.q-icon {
background: $gradient-primary;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.body--dark & {
background: $gradient-primary;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
.q-icon {
background: $gradient-primary;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
}
// ========================================
// MOBILE RESPONSIVE
// ========================================
@media (max-width: 718px) {
.landing__hero { .landing__hero {
text-align: center text-align: center;
.text-h1,
h1 {
font-size: 2rem;
line-height: 2.05rem;
margin-bottom: 1.25rem;
}
} }
.landing__header { .landing__header {
height: 7vh height: 7vh;
} }
.clgutter { .clgutter {
@@ -314,43 +573,42 @@ h1 {
padding: 0; padding: 0;
} }
.landing__hero .text-h1, .landing > section {
h1 { &.padding {
font-size: 2rem; padding: 2.5rem 1rem;
line-height: 2.05rem; }
margin-bottom: 1.25rem
&.padding_testo {
padding-top: 1.25rem;
padding-bottom: 1rem;
}
&.padding_gallery {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
max-width: 800px;
> div {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
}
}
} }
.landing>section.padding { .landing__features {
padding: 2.5rem 1rem; h4,
} h6 {
margin: 1.25rem 0;
.landing>section.padding_testo { }
padding-top: 1.25rem;
padding-bottom: 1rem;
}
.landing>section.padding_gallery {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
max-width: 800px;
}
.landing>section.padding_gallery>div {
padding-top: 3.125rem;
padding-bottom: 5.625rem;
}
.landing__features h4,
.landing__features h6 {
margin: 1.25rem 0
} }
h4 { h4 {
line-height: 1.4; line-height: 1.4;
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow; text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow;
.body--dark & {
text-shadow: 0.25rem 0.25rem 0.5rem $grayshadow-dark;
}
} }
.landing .feature-item { .landing .feature-item {
@@ -367,13 +625,11 @@ h1 {
} }
.landing__hero-btns { .landing__hero-btns {
-webkit-box-pack: center; justify-content: center;
-ms-flex-pack: center;
justify-content: center
} }
.q-col-gutter-sm { .q-col-gutter-sm {
padding: .625rem .315rem; padding: 0.625rem 0.315rem;
} }
.text-subtitle1 { .text-subtitle1 {
@@ -383,91 +639,4 @@ h1 {
.text-vers { .text-vers {
font-size: 0.6rem; font-size: 0.6rem;
} }
}
.custom-caption {
text-align: center;
padding: .75rem;
color: $textcol;
background-color: rgba(0, 0, 0, .3);
}
.sfondo-grigio {
padding: 1rem;
color: $textcol;
background-color: rgba(0, 0, 0, .35);
}
.mycontacts {
color: gray;
letter-spacing: 0.078rem;
}
.mycontacts_title {
text-shadow: 0.125rem 0.125rem 0.125rem #555;
font-weight: bold;
color: #999;
letter-spacing: 0.125rem;
}
.mycontacts_text {
color: #999;
letter-spacing: 0.093rem;
}
.clEditDiv {
border: #c8c9cb solid 2px;
padding: 2px;
}
.clEditNotActive {
background-color: #e6e6e6;
}
.clEdit {
border: #f69f09 solid 2px;
padding: 2px;
}
.clEdit:hover {
border: #11f609 solid 2px;
cursor: pointer;
}
.selectedElem {
border: #b91111 solid 3px !important;
}
.align_center {
text-align: center;
}
.align_right {
text-align: right;
}
.align_left {
text-align: left;
}
.flex3 {
display: flex;
justify-content: space-between;
background-color: green;
}
.elemEdit {
margin: 3px;
padding: 3px;
text-align: center;
font-weight: bold;
}
.elemEdit:hover {
border: #11f609 solid 2px;
cursor: pointer;
}
.myElemBase{
margin-bottom: 16px;
} }

View File

@@ -32,11 +32,13 @@ import { shared_consts } from '@/common/shared_vuejs';
import { LandingFooter } from '@/components/LandingFooter'; import { LandingFooter } from '@/components/LandingFooter';
import { CMyActivities } from '@/components/CMyActivities'; import { CMyActivities } from '@/components/CMyActivities';
import { CECommerce } from '@/components/CECommerce'; import { CECommerce } from '@/components/CECommerce';
import { EventPosterGenerator } from '@/components/EventPosterGenerator';
import { CheckEmail } from '@/components/CheckEmail'; import { CheckEmail } from '@/components/CheckEmail';
import { HomeRiso } from '@/components/HomeRiso'; import { HomeRiso } from '@/components/HomeRiso';
import mycircuits from '@/views/user/mycircuits/mycircuits.vue'; import mycircuits from '@/views/user/mycircuits/mycircuits.vue';
import PageRis from '@/components/pageris/pageris.vue'; import PageRis from '@/components/pageris/pageris.vue';
import { Riso_Home_Modern } from '@/components/Riso_Home_Modern'; import { Riso_Home_Modern } from '@/components/Riso_Home_Modern';
import { Riso_Home_ParteFinale } from '@/components/Riso_Home_ParteFinale';
import { InvitaAmico } from '@/components/InvitaAmico'; import { InvitaAmico } from '@/components/InvitaAmico';
import { CMyVideoYoutube } from '@/components/CMyVideoYoutube'; import { CMyVideoYoutube } from '@/components/CMyVideoYoutube';
import { editprofile } from '@/components/editprofile'; import { editprofile } from '@/components/editprofile';
@@ -63,7 +65,6 @@ import { CMyFieldRec } from '@/components/CMyFieldRec';
import { CSelectColor } from '@/components/CSelectColor'; import { CSelectColor } from '@/components/CSelectColor';
import { CMainView } from '@/components/CMainView'; import { CMainView } from '@/components/CMainView';
import { CMyProfileTutorial } from '@/components/CMyProfileTutorial'; import { CMyProfileTutorial } from '@/components/CMyProfileTutorial';
import { CSendRISTo } from '@/components/CSendRISTo';
import { CDashboard } from '@/components/CDashboard'; import { CDashboard } from '@/components/CDashboard';
import { CDashGroup } from '@/components/CDashGroup'; import { CDashGroup } from '@/components/CDashGroup';
import { CMovements } from '@/components/CMovements'; import { CMovements } from '@/components/CMovements';
@@ -103,12 +104,14 @@ export default defineComponent({
CEventsCalendar, CEventsCalendar,
CCardCarousel, CCardCarousel,
CProfileCompletitionBanner, CProfileCompletitionBanner,
EventPosterGenerator,
COpenStreetMap, COpenStreetMap,
CMyPage, CMyPage,
CMyPageIntro, CMyPageIntro,
InvitaAmico, InvitaAmico,
HomeRiso, HomeRiso,
Riso_Home_Modern, Riso_Home_Modern,
Riso_Home_ParteFinale,
CMyEditor, CMyEditor,
mycircuits, mycircuits,
editprofile, editprofile,
@@ -126,7 +129,6 @@ export default defineComponent({
CCardCarouselComp, CCardCarouselComp,
CMyActivities, CMyActivities,
CMyProfileTutorial, CMyProfileTutorial,
CSendRISTo,
CTitleBanner, CTitleBanner,
CShareSocial, CShareSocial,
CCheckAppRunning, CCheckAppRunning,

View File

@@ -109,6 +109,12 @@
> >
<Riso_Home_Modern /> <Riso_Home_Modern />
</div> </div>
<div
v-else-if="myel.type === shared_consts.ELEMTYPE.RISOHOME_PAGFINALE"
class="myElemBase"
>
<Riso_Home_ParteFinale />
</div>
<div <div
v-else-if="myel.type === shared_consts.ELEMTYPE.IMGTITLE" v-else-if="myel.type === shared_consts.ELEMTYPE.IMGTITLE"
class="myElemBase" class="myElemBase"
@@ -134,6 +140,20 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-else-if="myel.type === shared_consts.ELEMTYPE.CREA_VOLANTINO"
class="myElemBase"
>
˚
<div
v-if="editOn"
class="elemEdit"
>
CREA POSTER VOLANTINI:
</div>
<EventPosterGenerator></EventPosterGenerator>
</div>
<div <div
v-else-if="myel.type === shared_consts.ELEMTYPE.IMGPOSTER" v-else-if="myel.type === shared_consts.ELEMTYPE.IMGPOSTER"
class="myElemBase" class="myElemBase"
@@ -607,37 +627,50 @@
</div> </div>
<CMovements :showbuttolastmov="true"></CMovements> <CMovements :showbuttolastmov="true"></CMovements>
</div> </div>
<div
v-else-if="myel.type === shared_consts.ELEMTYPE.CSENDRISTO"
class="myElemBase"
>
<div
v-if="editOn"
class="elemEdit"
>
Bottoni (Invia/Ricevi RIS) CSendRISTo
</div>
<CSendRISTo></CSendRISTo>
</div>
<div <div
v-else-if="myel.type === shared_consts.ELEMTYPE.GRID_ORIZ" v-else-if="myel.type === shared_consts.ELEMTYPE.GRID_ORIZ"
class="myElemBase"
> >
<div <section
v-if="editOn" class="content-section"
class="elemEdit"
> >
Visualizzatore Tabelle <div
</div> v-if="myel.stiletit_str"
<CGridOriz class="section-header"
:table="myel.container" >
:tipovisu="myel.number" <h2 class="section-title">
:prop_search="myel.parambool" <q-icon
:finder="myel.parambool2" v-if="myel.stiletit_icon"
:showMap="myel.parambool3" :name="myel.stiletit_icon || 'event'"
:heightcarousel="myel.heightcarousel" />
:prop_modif="myel.parambool4" {{ myel.stiletit_str }}
></CGridOriz> </h2>
<q-btn
v-if="myel.container2"
flat
dense
round
icon="arrow_forward"
@click="naviga(myel.container2)"
/>
</div>
<div
v-if="editOn"
class="elemEdit"
>
Visualizzatore Tabelle
</div>
<CGridOriz
:table="myel.container"
:tipovisu="myel.number"
:prop_search="myel.parambool"
:finder="myel.parambool2"
:showMap="myel.parambool3"
:heightcarousel="myel.heightcarousel"
:prop_modif="myel.parambool4"
:prop_compatto="myel.parambool5"
></CGridOriz>
</section>
</div> </div>
<div <div
v-else-if="myel.type === shared_consts.ELEMTYPE.SEARCHPRODUCT" v-else-if="myel.type === shared_consts.ELEMTYPE.SEARCHPRODUCT"
@@ -700,6 +733,7 @@
:showMap="false" :showMap="false"
:prop_modif="tools.isAdmin()" :prop_modif="tools.isAdmin()"
:enableExport="true" :enableExport="true"
:prop_compatto="myel.number === -1"
></CGridOriz> ></CGridOriz>
</q-tab-panel> </q-tab-panel>
@@ -783,6 +817,7 @@
:showMap="false" :showMap="false"
:prop_modif="tools.isAdmin()" :prop_modif="tools.isAdmin()"
:enableExport="true" :enableExport="true"
:prop_compatto="myel.number === -1"
></CGridOriz> ></CGridOriz>
</q-tab-panel> </q-tab-panel>

View File

@@ -4,7 +4,7 @@
.rec-card-wrapper { .rec-card-wrapper {
margin: 3px auto; margin: 3px auto;
padding: 0; padding: 0;
width: 100%; // Aggiungi questa riga width: 100%;
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
margin: 2px auto; margin: 2px auto;
@@ -12,19 +12,55 @@
&.is-even { &.is-even {
.modern-rec-card { .modern-rec-card {
background: linear-gradient(135deg, rgba(49, 153, 239, 0.249) 0%, rgba(25, 118, 210, 0.03) 100%); background: linear-gradient(
135deg,
rgba(49, 153, 239, 0.15) 0%,
rgba(25, 118, 210, 0.03) 100%
);
border-color: rgba(66, 165, 245, 0.12); border-color: rgba(66, 165, 245, 0.12);
} }
} }
&.is-odd { &.is-odd {
.modern-rec-card { .modern-rec-card {
background: linear-gradient(135deg, rgba(38, 197, 218, 0.344) 0%, rgba(0, 150, 136, 0.03) 100%); background: linear-gradient(
135deg,
rgba(38, 197, 218, 0.2) 0%,
rgba(0, 150, 136, 0.03) 100%
);
border-color: rgba(38, 198, 218, 0.12); border-color: rgba(38, 198, 218, 0.12);
} }
} }
} }
// Dark mode alternating colors
body.body--dark,
.body--dark {
.rec-card-wrapper {
&.is-even {
.modern-rec-card {
background: linear-gradient(
135deg,
rgba(49, 153, 239, 0.2) 0%,
rgba(25, 118, 210, 0.08) 100%
);
border-color: rgba(66, 165, 245, 0.2);
}
}
&.is-odd {
.modern-rec-card {
background: linear-gradient(
135deg,
rgba(38, 197, 218, 0.25) 0%,
rgba(0, 150, 136, 0.08) 100%
);
border-color: rgba(38, 198, 218, 0.2);
}
}
}
}
// ======================================== // ========================================
// CARD EVENTO (Calendar View) // CARD EVENTO (Calendar View)
// ======================================== // ========================================
@@ -32,14 +68,14 @@
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-bottom: 6px; margin-bottom: 6px;
background: white; background: var(--app-bg-card);
border-radius: $border-radius; border-radius: $border-radius;
overflow: hidden; overflow: hidden;
box-shadow: $shadow-md; box-shadow: var(--app-shadow-md);
transition: box-shadow $transition-speed ease; transition: box-shadow $transition-speed ease;
&:hover { &:hover {
box-shadow: $shadow-hover; box-shadow: var(--app-shadow-lg);
} }
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
@@ -135,7 +171,7 @@
.user-avatar { .user-avatar {
margin-top: 6px; margin-top: 6px;
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: $shadow-sm; box-shadow: var(--app-shadow-sm);
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
margin-top: 5px; margin-top: 5px;
@@ -148,8 +184,6 @@
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
// L'altezza viene gestita dinamicamente dal Vue
} }
.event-image { .event-image {
@@ -171,10 +205,10 @@
// CARD RECORD PRINCIPALE // CARD RECORD PRINCIPALE
// ======================================== // ========================================
.modern-rec-card { .modern-rec-card {
background: white; background: var(--app-bg-card);
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid var(--app-border-color);
border-radius: $border-radius; border-radius: $border-radius;
box-shadow: $shadow-sm; box-shadow: var(--app-shadow-sm);
padding: 6px; padding: 6px;
transition: all $transition-speed ease; transition: all $transition-speed ease;
margin-bottom: 3px; margin-bottom: 3px;
@@ -182,7 +216,7 @@
overflow: hidden; overflow: hidden;
&:hover { &:hover {
box-shadow: $shadow-md; box-shadow: var(--app-shadow-md);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -222,8 +256,8 @@
} }
.record-avatar { .record-avatar {
box-shadow: $shadow-sm; box-shadow: var(--app-shadow-sm);
border: 2px solid rgba(0, 0, 0, 0.05); border: 2px solid var(--app-border-color);
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
width: 48px !important; width: 48px !important;
@@ -269,22 +303,20 @@
.tag-chip { .tag-chip {
height: 20px; height: 20px;
font-size: 0.9rem; font-size: 0.9rem;
padding: 0 px; padding: 0 6px;
border-radius: 4px; border-radius: 4px;
box-shadow: none; box-shadow: none;
font-weight: 500; font-weight: 500;
// Categoria principale - più scura e intensa
&.sector { &.sector {
background: linear-gradient(135deg, $primary-color, #1976d2); background: linear-gradient(135deg, $primary-color, #1976d2);
color: white; color: white;
} }
// Sottocategoria - stessa base ma più chiara
&.subsector { &.subsector {
background: linear-gradient(135deg, color.adjust($primary-color, $lightness: 10%), #42a5f5); background: linear-gradient(135deg, $primary-light, #42a5f5);
color: white; color: white;
opacity: 0.9; // Opzionale: leggera trasparenza opacity: 0.9;
} }
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
@@ -315,7 +347,7 @@
.description-text { .description-text {
font-size: 0.9375rem; font-size: 0.9375rem;
color: #2c3e50; color: var(--app-text-primary);
font-weight: 500; font-weight: 500;
line-height: 1.4; line-height: 1.4;
margin: 3px 0; margin: 3px 0;
@@ -323,7 +355,6 @@
&.event-title { &.event-title {
font-weight: 600; font-weight: 600;
color: #1a1a1a;
} }
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
@@ -333,7 +364,7 @@
} }
// ======================================== // ========================================
// PREFERENCES ROW (dopo descrizione) // PREFERENCES ROW
// ======================================== // ========================================
.preferences-row { .preferences-row {
margin: 8px 0 6px 0; margin: 8px 0 6px 0;
@@ -357,11 +388,11 @@
.preference-icon-avatar { .preference-icon-avatar {
cursor: pointer; cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); box-shadow: var(--app-shadow-sm);
&:hover { &:hover {
transform: translateY(-2px) scale(1.08); transform: translateY(-2px) scale(1.08);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25); box-shadow: var(--app-shadow-md);
} }
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
@@ -384,11 +415,11 @@
.user-name { .user-name {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
color: $mainColor; color: var(--app-text-primary);
font-weight: 600; font-weight: 600;
font-size: 0.9rem; font-size: 0.9rem;
@media (max-width: $mobile) { @media (max-width: $mobile-breakpoint) {
font-size: 0.85rem; font-size: 0.85rem;
} }
} }
@@ -398,8 +429,9 @@
white-space: nowrap; white-space: nowrap;
margin-left: auto; margin-left: auto;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--app-text-secondary);
@media (max-width: $mobile) { @media (max-width: $mobile-breakpoint) {
font-size: 0.75rem; font-size: 0.75rem;
.stat-item { .stat-item {
@@ -434,8 +466,14 @@
} }
} }
// Dark mode attending badge
body.body--dark .attending-badge,
.body--dark .attending-badge {
background: rgba(33, 186, 69, 0.2);
}
.cities-text { .cities-text {
color: rgba(44, 62, 80, 0.7); // Grigio bluastro più caldo del grigio puro color: var(--app-text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
display: flex; display: flex;
margin-left: auto; margin-left: auto;
@@ -443,12 +481,13 @@
gap: 4px; gap: 4px;
i { i {
color: rgba($mainColor, 0.6); // Icona location con colore primario attenuato color: var(--app-text-muted);
} }
.cities-text-bold { .cities-text-bold {
font-weight: bold; font-weight: bold;
font-size: 1.1rem; font-size: 1.1rem;
color: var(--app-text-primary);
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
font-size: 1rem; font-size: 1rem;
@@ -484,11 +523,11 @@
.action-menu-btn { .action-menu-btn {
width: 30px; width: 30px;
height: 30px; height: 30px;
color: $grey-color; color: var(--app-text-muted);
transition: all $transition-speed ease; transition: all $transition-speed ease;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.05); background: var(--app-border-color);
color: $primary-color; color: $primary-color;
} }
@@ -511,15 +550,15 @@
white-space: wrap; white-space: wrap;
overflow: hidden; overflow: hidden;
font-size: 0.8125rem; font-size: 0.8125rem;
color: $grey-color; color: var(--app-text-muted);
} }
.cardrec { .cardrec {
border: 1px solid rgba(0, 0, 0, 0.08); border: 1px solid var(--app-border-color);
box-shadow: $shadow-sm; box-shadow: var(--app-shadow-sm);
border-radius: $border-radius; border-radius: $border-radius;
padding: 6px; padding: 6px;
background-color: white; background-color: var(--app-bg-card);
margin: 3px; margin: 3px;
@media (min-width: 500px) { @media (min-width: 500px) {
@@ -532,9 +571,16 @@
color: $primary-color; color: $primary-color;
} }
// Dark mode text_title
body.body--dark .text_title,
.body--dark .text_title {
color: $primary-light;
}
.event_date { .event_date {
font-style: italic; font-style: italic;
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--app-text-secondary);
} }
// ======================================== // ========================================
@@ -543,12 +589,16 @@
.q-separator { .q-separator {
margin: 3px 0; margin: 3px 0;
opacity: 0.5; opacity: 0.5;
background: var(--app-divider-color);
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
margin: 2px 0; margin: 2px 0;
} }
} }
// ========================================
// CATEGORY HIERARCHY
// ========================================
.category-hierarchy { .category-hierarchy {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -558,23 +608,28 @@
.hierarchy-arrow { .hierarchy-arrow {
opacity: 0.5; opacity: 0.5;
font-size: 16px; font-size: 16px;
color: var(--app-text-muted);
} }
@media (max-width: $mobile-breakpoint) { @media (max-width: $mobile-breakpoint) {
gap: 0px; gap: 0px;
} }
} }
// ========================================
// CATEGORIES DIALOG
// ========================================
.categories-dialog { .categories-dialog {
.dialog-header { .dialog-header {
padding: 16px 20px; padding: 16px 20px;
background: var(--app-bg-card);
} }
.dialog-content { .dialog-content {
padding: 20px; padding: 20px;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
background: var(--app-bg-card);
} }
.categories-grid { .categories-grid {
@@ -590,9 +645,12 @@
} }
} }
// ========================================
// ANNUNCIO LOCATION
// ========================================
.annuncio-location { .annuncio-location {
font-size: 1rem; font-size: 1rem;
color: #718096; color: var(--app-text-secondary);
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -601,35 +659,148 @@
} }
} }
// Il contenitore padre (q-item o card) deve avere position relative // ========================================
// OVERLAY BUTTONS
// ========================================
.q-item, .q-item,
.event-card { .event-card {
position: relative; position: relative;
} }
// Bottone overlay fisso a destra
.action-menu-btn-overlay { .action-menu-btn-overlay {
position: absolute !important; position: absolute !important;
top: 8px; top: 8px;
right: 0px; right: 0px;
transform: translateY(-50%); transform: translateY(-50%);
z-index: 10; z-index: 10;
background: var(--app-bg-card) !important;
// Sfondo semi-trasparente per visibilità box-shadow: var(--app-shadow-sm);
background: rgba(255, 255, 255, 0.8) !important; transition: all $transition-speed ease;
// Ombra leggera
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover { &:hover {
background: rgba(255, 255, 255, 1) !important; background: var(--app-bg-card-hover) !important;
box-shadow: var(--app-shadow-md);
} }
} }
// Alternativa: in alto a destra
.action-menu-btn-overlay--top { .action-menu-btn-overlay--top {
position: absolute !important; position: absolute !important;
top: 4px; top: 4px;
right: 4px; right: 4px;
z-index: 10; z-index: 10;
background: var(--app-bg-card) !important;
}
// ========================================
// DARK MODE SPECIFIC OVERRIDES
// ========================================
body.body--dark,
.body--dark {
// Event date column - leggermente più scuro in dark mode
.event-date-column {
background: linear-gradient(135deg, #1565c0, #1976d2);
}
// Tag chips
.tag-chip {
&.sector {
background: linear-gradient(135deg, #1565c0, #1976d2);
}
&.subsector {
background: linear-gradient(135deg, #1976d2, #2196f3);
}
}
// Time badges nel calendar
.time-start,
.time-end {
background: rgba(255, 255, 255, 0.15);
}
// User avatar border
.user-avatar {
border-color: rgba(255, 255, 255, 0.2);
}
// Record avatar
.record-avatar {
border-color: var(--app-border-color-strong);
}
// Preference icon avatar
.preference-icon-avatar {
&:hover {
box-shadow: var(--app-shadow-lg);
}
}
// Action menu button hover
.action-menu-btn {
&:hover {
background: var(--app-border-color-strong);
color: $primary-light;
}
}
}
// ========================================
// EVENT CARD - Theme Aware
// ========================================
.event-card {
display: flex;
align-items: center;
gap: $s-md;
// Background con gradiente viola - usa opacità che funziona in entrambi i temi
background: linear-gradient(
135deg,
rgba(102, 126, 234, 0.08) 0%,
rgba(118, 75, 162, 0.08) 100%
);
border: 2px solid rgba(102, 126, 234, 0.2);
border-radius: $r-md;
padding: $s-md;
min-width: 280px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.4);
}
// ========================================
// EVENT DATE BADGE
// ========================================
.event-date {
display: flex;
flex-direction: column;
align-items: center;
background: $gradient-primary;
color: white;
border-radius: $r-sm;
padding: $s-sm;
min-width: 50px;
z-index: 1;
// Posizionamento assoluto (dalla seconda definizione)
position: absolute;
top: $s-sm;
left: $s-sm;
.event-day {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
}
.event-month {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
}
}
} }

View File

@@ -44,6 +44,11 @@ export default defineComponent({
required: false, required: false,
default: 0, default: 0,
}, },
compatto: {
type: Boolean,
required: false,
default: false,
},
}, },
setup(props, { emit }) { setup(props, { emit }) {

View File

@@ -65,7 +65,6 @@
/> />
</div> </div>
</div> </div>
<!-- Card Record Principale --> <!-- Card Record Principale -->
<q-item <q-item
v-if="myrec" v-if="myrec"
@@ -343,7 +342,11 @@
class="annuncio-location" class="annuncio-location"
> >
<span v-if="ind > 0"></span> <span v-if="ind > 0"></span>
<span v-if="table === shared_consts.TABLES_MYHOSPS" class="cities-text-bold">{{ rec.comune }} ({{ rec.prov }})</span> <span
v-if="table === shared_consts.TABLES_MYHOSPS"
class="cities-text-bold"
>{{ rec.comune }} ({{ rec.prov }})</span
>
<span v-else>{{ rec.comune }} ({{ rec.prov }})</span> <span v-else>{{ rec.comune }} ({{ rec.prov }})</span>
</span> </span>
</div> </div>

View File

@@ -0,0 +1,759 @@
// ========================================
// WRAPPER CARD - ALTERNATING COLORS
// ========================================
.rec-card-wrapper {
margin: 3px auto;
padding: 0;
width: 100%; // Aggiungi questa riga
@media (max-width: $mobile-breakpoint) {
margin: 2px auto;
}
&.is-even {
.modern-rec-card {
background: linear-gradient(135deg, rgba(49, 153, 239, 0.249) 0%, rgba(25, 118, 210, 0.03) 100%);
border-color: rgba(66, 165, 245, 0.12);
}
}
&.is-odd {
.modern-rec-card {
background: linear-gradient(135deg, rgba(38, 197, 218, 0.344) 0%, rgba(0, 150, 136, 0.03) 100%);
border-color: rgba(38, 198, 218, 0.12);
}
}
}
// ========================================
// CARD EVENTO (Calendar View)
// ========================================
.event-card {
display: flex;
gap: 6px;
margin-bottom: 6px;
background: white;
border-radius: $border-radius;
overflow: hidden;
box-shadow: $shadow-md;
transition: box-shadow $transition-speed ease;
&:hover {
box-shadow: $shadow-hover;
}
@media (max-width: $mobile-breakpoint) {
gap: 5px;
margin-bottom: 5px;
}
}
.event-date-column {
flex: 0 0 70px;
background: linear-gradient(135deg, $primary-color, #42a5f5);
padding: 6px 3px;
display: flex;
align-items: center;
justify-content: center;
@media (max-width: $mobile-breakpoint) {
flex: 0 0 65px;
padding: 5px 2px;
}
}
.date-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: white;
text-align: center;
}
.day-of-week {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
@media (max-width: $mobile-breakpoint) {
font-size: 0.5625rem;
}
}
.day-number {
font-size: 1.875rem;
font-weight: 700;
line-height: 1;
margin: 3px 0;
@media (max-width: $mobile-breakpoint) {
font-size: 1.625rem;
margin: 2px 0;
}
}
.month-name {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
@media (max-width: $mobile-breakpoint) {
font-size: 0.75rem;
}
}
.time-start,
.time-end {
font-size: 0.6875rem;
font-weight: 500;
padding: 2px 5px;
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
margin-top: 2px;
@media (max-width: $mobile-breakpoint) {
font-size: 0.625rem;
padding: 1px 4px;
}
}
.date-separator {
font-size: 0.9375rem;
margin: 3px 0;
opacity: 0.8;
@media (max-width: $mobile-breakpoint) {
font-size: 0.875rem;
margin: 2px 0;
}
}
.user-avatar {
margin-top: 6px;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: $shadow-sm;
@media (max-width: $mobile-breakpoint) {
margin-top: 5px;
width: 36px !important;
height: 36px !important;
}
}
.event-image-container {
flex: 1;
position: relative;
overflow: hidden;
// L'altezza viene gestita dinamicamente dal Vue
}
.event-image {
width: 100%;
height: 100%;
cursor: pointer;
transition: transform $transition-speed ease;
&:hover {
transform: scale(1.03);
}
:deep(.q-img__image) {
object-fit: cover;
}
}
// ========================================
// CARD RECORD PRINCIPALE
// ========================================
.modern-rec-card {
background: white;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: $border-radius;
box-shadow: $shadow-sm;
padding: 6px;
transition: all $transition-speed ease;
margin-bottom: 3px;
position: relative;
overflow: hidden;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-1px);
}
@media (max-width: $mobile-breakpoint) {
padding: 5px;
margin-bottom: 2px;
}
}
// ========================================
// AVATAR SECTION
// ========================================
.avatar-section {
position: relative;
padding: 0 !important;
margin-right: 6px;
@media (max-width: $mobile-breakpoint) {
margin-right: 5px;
}
}
.type-badge {
position: absolute;
top: -3px;
left: -3px;
z-index: 1;
font-size: 0.625rem;
padding: 2px 5px;
border-radius: 4px;
font-weight: 600;
@media (max-width: $mobile-breakpoint) {
font-size: 0.5625rem;
padding: 1px 4px;
}
}
.record-avatar {
box-shadow: $shadow-sm;
border: 2px solid rgba(0, 0, 0, 0.05);
@media (max-width: $mobile-breakpoint) {
width: 48px !important;
height: 48px !important;
}
:deep(.q-img) {
border-radius: 50%;
}
}
// ========================================
// CONTENT SECTION
// ========================================
.content-section {
padding: 0 6px 0 0 !important;
min-width: 0;
@media (max-width: $mobile-breakpoint) {
padding: 0 5px 0 0 !important;
}
}
.tags-row {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 3px;
.status-row {
display: flex;
gap: $s-xs;
flex-wrap: wrap;
}
@media (max-width: $mobile-breakpoint) {
gap: 2px;
margin-bottom: 2px;
}
}
.tag-chip {
height: 20px;
font-size: 0.9rem;
padding: 0 px;
border-radius: 4px;
box-shadow: none;
font-weight: 500;
// Categoria principale - più scura e intensa
&.sector {
background: linear-gradient(135deg, $primary-color, #1976d2);
color: white;
}
// Sottocategoria - stessa base ma più chiara
&.subsector {
background: linear-gradient(135deg, color.adjust($primary-color, $lightness: 10%), #42a5f5);
color: white;
opacity: 0.9; // Opzionale: leggera trasparenza
}
@media (max-width: $mobile-breakpoint) {
height: 19px;
font-size: 0.8rem;
padding: 0 4px;
}
:deep(.q-chip__content) {
padding: 0;
}
}
.status-badge {
font-size: 0.6875rem;
padding: 2px 5px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
@media (max-width: $mobile-breakpoint) {
font-size: 0.625rem;
padding: 2px 4px;
gap: 2px;
}
}
.description-text {
font-size: 0.9375rem;
color: $text-secondary; // Era #2c3e50
font-weight: 500;
line-height: 1.4;
margin: 3px 0;
word-break: break-word;
.body--dark & {
color: $text-secondary-dark;
}
&.event-title {
font-weight: 600;
color: $text-primary; // Era #1a1a1a
.body--dark & {
color: $text-primary-dark;
}
}
@media (max-width: $mobile-breakpoint) {
font-size: 0.875rem;
margin: 2px 0;
}
}
// ========================================
// PREFERENCES ROW (dopo descrizione)
// ========================================
.preferences-row {
margin: 8px 0 6px 0;
@media (max-width: $mobile-breakpoint) {
margin: 6px 0 4px 0;
}
}
.preferences-icons-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
@media (max-width: $mobile-breakpoint) {
gap: 5px;
}
}
.preference-icon-avatar {
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
&:hover {
transform: translateY(-2px) scale(1.08);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25);
}
@media (max-width: $mobile-breakpoint) {
width: 28px !important;
height: 28px !important;
.q-icon {
font-size: 16px !important;
}
}
}
.user-stats-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $s-sm;
flex-wrap: wrap;
.user-name {
flex: 1 1 auto;
min-width: 0;
color: $mainColor;
font-weight: 600;
font-size: 0.9rem;
@media (max-width: $mobile) {
font-size: 0.85rem;
}
}
.stats-container {
flex: 0 0 auto;
white-space: nowrap;
margin-left: auto;
font-size: 0.85rem;
@media (max-width: $mobile) {
font-size: 0.75rem;
.stat-item {
margin-left: 4px;
.q-icon {
font-size: 12px !important;
}
}
}
}
}
.attending-badge {
display: inline-flex;
align-items: center;
gap: 3px;
color: $positive-color;
font-weight: 600;
font-size: 0.8125rem;
margin: 3px 0;
padding: 3px 6px;
background: rgba(33, 186, 69, 0.1);
border-radius: 6px;
width: fit-content;
@media (max-width: $mobile-breakpoint) {
font-size: 0.75rem;
margin: 2px 0;
padding: 2px 5px;
gap: 2px;
}
}
.cities-text {
color: rgba(44, 62, 80, 0.7); // Grigio bluastro più caldo del grigio puro
font-size: 0.85rem;
display: flex;
margin-left: auto;
flex-flow: wrap;
gap: 4px;
i {
color: rgba($mainColor, 0.6); // Icona location con colore primario attenuato
}
.cities-text-bold {
font-weight: bold;
font-size: 1.1rem;
@media (max-width: $mobile-breakpoint) {
font-size: 1rem;
}
}
}
.cities-contrib-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
@media (max-width: $mobile-breakpoint) {
gap: 6px;
}
}
// ========================================
// ACTIONS SECTION
// ========================================
.actions-section {
padding: 0 !important;
align-self: flex-start;
margin-left: 3px;
@media (max-width: $mobile-breakpoint) {
margin-left: 2px;
}
}
.action-menu-btn {
width: 30px;
height: 30px;
color: $grey-color;
transition: all $transition-speed ease;
&:hover {
background: rgba(0, 0, 0, 0.05);
color: $primary-color;
}
@media (max-width: $mobile-breakpoint) {
width: 28px;
height: 28px;
}
}
// ========================================
// LEGACY SUPPORT
// ========================================
.myflex {
display: flex;
flex: 1;
}
.text_user_city {
text-overflow: ellipsis;
white-space: wrap;
overflow: hidden;
font-size: 0.8125rem;
color: $grey-color;
}
.cardrec {
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: $shadow-sm;
border-radius: $border-radius;
padding: 6px;
background-color: white;
margin: 3px;
@media (min-width: 500px) {
margin: 2px;
padding: 5px;
}
}
.text_title {
color: $primary-color;
}
.event_date {
font-style: italic;
font-size: 0.8125rem;
}
// ========================================
// SEPARATOR
// ========================================
.q-separator {
margin: 3px 0;
opacity: 0.5;
@media (max-width: $mobile-breakpoint) {
margin: 2px 0;
}
}
.category-hierarchy {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
.hierarchy-arrow {
opacity: 0.5;
font-size: 16px;
}
@media (max-width: $mobile-breakpoint) {
gap: 0px;
}
}
.categories-dialog {
.dialog-header {
padding: 16px 20px;
}
.dialog-content {
padding: 20px;
max-height: 60vh;
overflow-y: auto;
}
.categories-grid {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.category-chip {
font-size: 1.1rem;
padding: 8px 12px;
font-weight: 500;
}
}
.annuncio-location {
font-size: 1rem;
color: #718096;
align-items: center;
gap: 4px;
&::before {
content: '📍';
}
}
// Il contenitore padre (q-item o card) deve avere position relative
.q-item,
.event-card {
position: relative;
}
// Bottone overlay fisso a destra
.action-menu-btn-overlay {
position: absolute !important;
top: 8px;
right: 0px;
transform: translateY(-50%);
z-index: 10;
// Sfondo semi-trasparente per visibilità
background: rgba(255, 255, 255, 0.8) !important;
// Ombra leggera
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
&:hover {
background: rgba(255, 255, 255, 1) !important;
}
}
// Alternativa: in alto a destra
.action-menu-btn-overlay--top {
position: absolute !important;
top: 4px;
right: 4px;
z-index: 10;
}
// ==========================================
// EVENT CARD
// ==========================================
.event-card {
position: relative;
display: flex;
align-items: center;
gap: $s-md;
background: $gradient-hover;
border: 2px solid rgba($primary-color, 0.2);
border-radius: $r-md;
padding: $s-md;
min-width: 280px;
cursor: pointer;
transition: all 0.3s ease;
.body--dark & {
background: $gradient-hover-dark;
border-color: rgba($primary-color-dark, 0.3);
}
// Hover state
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba($primary-color, 0.2);
border-color: rgba($primary-color, 0.4);
.body--dark & {
box-shadow: 0 4px 16px rgba($primary-color-dark, 0.3);
border-color: rgba($primary-color-dark, 0.5);
}
}
// ========== EVENT DATE BADGE ==========
.event-date {
position: absolute;
top: $s-sm;
left: $s-sm;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
background: $gradient-primary;
color: $text-on-primary;
border-radius: $r-sm;
padding: $s-sm;
min-width: 50px;
.body--dark & {
background: $gradient-primary-dark;
}
.event-day {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
}
.event-month {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
}
}
// ========== EVENT INFO ==========
.event-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.event-title {
font-size: 1rem;
font-weight: 600;
color: $text-primary;
.body--dark & {
color: $text-primary-dark;
}
}
.event-location {
font-size: 1rem;
color: $text-muted;
display: flex;
align-items: center;
gap: 4px;
.body--dark & {
color: $text-muted-dark;
}
}
}
// ========== EVENT IMAGE ==========
.event-image {
width: 80px;
height: 80px;
border-radius: $r-sm;
flex-shrink: 0;
}
.event-image-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: $gradient-placeholder;
.body--dark & {
background: $gradient-placeholder-dark;
}
}
}

View File

@@ -0,0 +1,198 @@
import type { PropType } from 'vue';
import { defineComponent, onMounted, ref, watch, computed } from 'vue';
import { useUserStore } from '@store/UserStore';
import type { IUserFields } from 'model';
import { IImgGallery, IUserProfile } from 'model';
import { costanti } from '@costanti';
import { shared_consts } from '@/common/shared_vuejs';
import { fieldsTable } from '@store/Modules/fieldsTable';
import { tools } from '@tools';
import { toolsext } from '@store/Modules/toolsext';
import { useQuasar } from 'quasar';
import { useI18n } from 'vue-i18n';
import { CMyCardPopup } from '@/components/CMyCardPopup';
import { useRouter } from 'vue-router';
import { useCalendarStore } from '@/store/CalendarStore';
import { useGlobalStore } from '@/store/globalStore';
export default defineComponent({
name: 'CMyRecEventi',
components: { CMyCardPopup },
emits: ['setCmd', 'cmdext'],
props: {
table: {
type: String,
required: true,
},
prop_myrec: {
type: Object as PropType<any>,
required: false,
default: null,
},
indexRow: {
type: Number,
required: false,
default: 0,
},
editOn: {
type: Boolean,
required: false,
default: false,
},
margin_right: {
type: Number,
required: false,
default: 0,
},
compatto: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, { emit }) {
const userStore = useUserStore();
const calendarStore = useCalendarStore();
const globalStore = useGlobalStore();
const $q = useQuasar();
const { t } = useI18n();
const $router = useRouter();
const myrec = ref(<any>null);
const showPreferencesDialog = ref(false);
const visupage = ref(false);
const disabilita = computed(() => {
return props.table === shared_consts.TABLES_MYBACHECAS;
});
const arrSubSector = computed(() => {
return tools.getArrSubSector(props.table, myrec.value);
});
const arrSector = computed(() => {
return tools.getArrSector(props.table, myrec.value);
});
const getColorSubSector = computed(() => {
return arrSector.value && arrSector.value.length == 1 ? arrSector.value[0].color : 'primary'
})
watch(
() => props.prop_myrec,
(newval, oldval) => {
mounted();
}
);
function mounted() {
if (props.prop_myrec) {
myrec.value = props.prop_myrec;
}
}
function showBadge() {
if (shared_consts.TABLES_SHOW_ADTYPE.includes(props.table)) {
return true;
}
return false;
}
function getImgUser(profile: IUserFields) {
return userStore.getImgByProfile(profile);
}
function naviga(path: string) {
$router.push(path);
}
function setCmd(
$q: any,
cmd: number,
myusername: string,
value: any,
groupname: string
) {
emit('setCmd', $q, cmd, myusername, value, groupname);
}
function cmdExt(cmd: any, val1: any, val2: any) {
emit('cmdext', cmd, val1, val2);
}
function navigaExt(obj: any) {
cmdExt(costanti.CMD_SHOW_PAGE, null, obj);
//let link = shared_consts.getDirectoryByTable(props.table) + '/' + obj._id
//console.log('link', link)
//$router.push(link)
}
function getNameToShow(user: IUserFields, col = null) {
if (myrec.value.groupname) return myrec.value.groupname;
else return userStore.getNameToShow(user, col);
}
function isPartecipero() {
return (
props.table === shared_consts.TABLES_MYBACHECAS &&
calendarStore.isPartecipero(myrec.value._id, props.table)
);
}
function computedWidth() {
//const width = tools.getwidth($q) - 20;
//return `${Math.min(width, 600)}px`; // Limita la larghezza massima a 600px
const width = tools.getwidth($q);
// Limita la larghezza per evitare overflow
return `${Math.min(width - 40, 600)}px`;
//return '100%'; // Rimuovi la limitazione a 600px
}
function computedEventImageHeight() {
const width = tools.getwidth($q);
const isMobile = width < 768;
const cardWidth = Math.min(width - 20, 600);
if (isMobile) {
// Mobile: aspect ratio 16:9, minimo 200px
const calculatedHeight = Math.round((cardWidth - 70) * 0.5625);
return `${Math.max(calculatedHeight, 200)}px`;
} else {
// Desktop: altezza fissa ottimale 220px
return '220px';
}
}
onMounted(mounted);
return {
t,
myrec,
costanti,
getImgUser,
naviga,
navigaExt,
setCmd,
shared_consts,
userStore,
tools,
toolsext,
fieldsTable,
cmdExt,
visupage,
showBadge,
getNameToShow,
isPartecipero,
calendarStore,
disabilita,
globalStore,
computedWidth,
computedEventImageHeight,
arrSubSector,
arrSector,
getColorSubSector,
showPreferencesDialog,
};
},
});

View File

@@ -0,0 +1,104 @@
<template>
<div
v-if="myrec"
class="event-card"
@click="navigaExt(myrec)"
>
<q-img
:src="
myrec.photos
? tools.getFullFileName(myrec.photos, table, myrec.username, '')
: '/images/event-placeholder.png'
"
:alt="myrec.descr"
class="event-image"
@click="cmdExt(costanti.CMD_SHOW_PAGE, null, myrec)"
>
<template v-slot:error>
<div class="event-image-placeholder">
<q-icon
name="event"
size="2rem"
color="grey-5"
/>
</div>
</template>
</q-img>
<div class="event-date">
<span class="event-day">{{ tools.getstrDay(myrec.dateTimeStart) }}</span>
<span class="event-month">{{
tools.getstrMonth3Letters(myrec.dateTimeStart)
}}</span>
</div>
<div class="event-info">
<span class="event-title">{{ myrec.descr }}</span>
<span class="event-location">
<div class="cities-text">
<span
v-for="(rec, ind) of myrec.mycities"
:key="ind"
class="annuncio-location"
>
<span v-if="ind > 0"></span>
<span
v-if="table === shared_consts.TABLES_MYHOSPS"
class="cities-text-bold"
>{{ rec.comune }} ({{ rec.prov }})</span
>
<span v-else>{{ rec.comune }} ({{ rec.prov }})</span>
</span>
</div>
</span>
</div>
<q-icon name="chevron_right" size="lg" />
</div>
<!-- Categories Dialog -->
<q-dialog v-model="showPreferencesDialog">
<q-card
class="categories-dialog"
:style="{ minWidth: $q.screen.lt.sm ? '90vw' : '400px' }"
>
<q-card-section class="dialog-header">
<div class="text-h6">Categorie</div>
</q-card-section>
<q-separator />
<q-card-section class="dialog-content">
<div class="categories-grid">
<q-chip
v-for="(rec, ind) of myrec.preferences"
:key="ind"
class="category-chip shadow-3"
:style="`background-color: ${tools.getPreferenceById(rec).color}; opacity: 0.85; color: white;`"
>
<q-icon
:name="tools.getPreferenceById(rec).icon"
left
size="18px"
/>
{{ tools.getPreferenceById(rec).label }}
</q-chip>
</div>
</q-card-section>
<q-separator />
<q-card-actions align="right">
<q-btn
flat
label="Chiudi"
color="primary"
v-close-popup
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script lang="ts" src="./CMyRecEventi.ts"></script>
<style lang="scss" scoped>
@import './CMyRecEventi.scss';
</style>

View File

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

View File

@@ -367,7 +367,7 @@ export default defineComponent({
} }
function update() { function update() {
console.log('update', props.value, props);
// console.log(' #### mounted myselect', props.options, 'arrvalue', myarrvalue.value) // console.log(' #### mounted myselect', props.options, 'arrvalue', myarrvalue.value)
let rec: any; let rec: any;
if (optionsreal.value) { if (optionsreal.value) {

View File

@@ -35,7 +35,6 @@ const username = computed(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import 'src/css/variables.scss';
.user-not-found-container { .user-not-found-container {
min-height: calc(100vh - #{$headerHeight}); min-height: calc(100vh - #{$headerHeight});

View File

@@ -1,17 +1,357 @@
.qrcode-container {
.button_download{ width: 100%;
display: inline-block !important; display: flex;
padding: 10px 20px !important; flex-direction: column;
font-size: 16px !important; align-items: center;
font-weight: 500; padding: 16px;
color: #ffffff !important; }
background-color: #027be3 !important;
/* Colore primario di Quasar */ // ============================================
border: none !important; // QR READER SECTION
border-radius: 4px !important; // ============================================
cursor: pointer; .qr-reader-section {
text-align: center; width: 100%;
text-decoration: none; max-width: 400px;
transition: background-color 0.3s, box-shadow 0.3s; }
.stream-container {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 16px;
overflow: hidden;
background: #000;
}
.qr-stream {
width: 100%;
height: 100%;
:deep(video) {
object-fit: cover;
}
}
.scan-frame {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70%;
height: 70%;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
.corner {
position: absolute;
width: 24px;
height: 24px;
border-color: #4caf50;
border-style: solid;
border-width: 0;
&.top-left {
top: -2px;
left: -2px;
border-top-width: 4px;
border-left-width: 4px;
border-top-left-radius: 12px;
}
&.top-right {
top: -2px;
right: -2px;
border-top-width: 4px;
border-right-width: 4px;
border-top-right-radius: 12px;
}
&.bottom-left {
bottom: -2px;
left: -2px;
border-bottom-width: 4px;
border-left-width: 4px;
border-bottom-left-radius: 12px;
}
&.bottom-right {
bottom: -2px;
right: -2px;
border-bottom-width: 4px;
border-right-width: 4px;
border-bottom-right-radius: 12px;
}
}
// Animazione scan line
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent,
#4caf50,
transparent);
animation: scan 2s ease-in-out infinite;
}
}
@keyframes scan {
0%,
100% {
top: 0;
opacity: 0;
}
50% {
top: 100%;
opacity: 1;
}
}
.upload-section {
.upload-input {
:deep(.q-field__control) {
border-radius: 12px;
}
}
}
.scan-result {
width: 100%;
}
.result-card {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.result-content {
display: flex;
align-items: center;
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
}
.result-text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
.result-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.result-value {
font-size: 14px;
font-weight: 500;
color: #333;
word-break: break-all;
}
}
.result-actions {
padding: 12px 16px;
background: #fff;
}
// ============================================
// QR GENERATOR SECTION
// ============================================
.qr-generator-section {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
}
.link-info {
width: 100%;
text-align: center;
padding: 16px;
background: #f5f5f5;
border-radius: 12px;
.info-label {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.info-link {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #666;
word-break: break-all;
.q-icon {
flex-shrink: 0;
color: #027be3;
}
}
}
.qr-display {
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
background: #fff;
border-radius: 20px;
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.05),
0 10px 40px rgba(0, 0, 0, 0.1);
.qr-code {
display: block;
:deep(canvas) {
display: block;
border-radius: 8px;
}
}
.qr-logo {
border-radius: 50%;
}
}
.download-section {
width: 100%;
.download-btn {
width: 100%;
padding: 12px 24px;
border-radius: 12px;
font-weight: 600;
text-transform: none;
font-size: 16px;
:deep(.q-btn__content) {
gap: 8px;
}
}
}
.share-section {
width: 100%;
.share-btn {
width: 100%;
padding: 12px 24px;
border-radius: 12px;
font-weight: 500;
text-transform: none;
border-width: 2px;
:deep(.q-btn__content) {
gap: 8px;
}
}
}
// ============================================
// LOADING STATE
// ============================================
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
.loading-text {
font-size: 14px;
color: #666;
}
}
// ============================================
// ANIMATIONS
// ============================================
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// ============================================
// RESPONSIVE
// ============================================
@media (max-width: 599px) {
.qrcode-container {
padding: 12px;
}
.qr-display {
padding: 16px;
border-radius: 16px;
}
.link-info {
padding: 12px;
.info-label {
font-size: 13px;
}
.info-link {
font-size: 11px;
}
}
}
// ============================================
// DARK MODE (opzionale)
// ============================================
.body--dark {
.qrcode-container {
.link-info {
background: #1e1e1e;
.info-label {
color: #fff;
}
.info-link {
color: #aaa;
}
}
.qr-display {
background: #fff; // QR sempre su sfondo bianco per leggibilità
}
.result-content {
background: linear-gradient(135deg, #1b5e20 0%, #2e7d32 100%);
.result-text {
.result-label {
color: #c8e6c9;
}
.result-value {
color: #fff;
}
}
}
.result-actions {
background: #2d2d2d;
}
}
} }

View File

@@ -1,25 +1,38 @@
import { import {
computed, computed,
provide, defineComponent, onBeforeMount, onBeforeUnmount, onMounted, ref, toRef, toRefs, watch, reactive defineComponent,
} from 'vue' onMounted,
ref,
toRefs,
reactive,
nextTick,
} from 'vue';
import { tools } from '@tools' import { tools } from '@tools';
import { costanti } from '@costanti' import { costanti } from '@costanti';
import { useGlobalStore } from '@store/globalStore' import { useGlobalStore } from '@store/globalStore';
import { useUserStore } from '@store/UserStore' import { useUserStore } from '@store/UserStore';
import { useI18n } from 'vue-i18n';
import { toolsext } from '@store/Modules/toolsext';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import { shared_consts } from '@/common/shared_vuejs' // Import per lettura QR
import { useI18n } from 'vue-i18n' import { QrStream, QrCapture, QrDropzone } from 'vue3-qr-reader';
import { toolsext } from '@store/Modules/toolsext'
import { useQuasar } from 'quasar'
import { QrStream, QrCapture, QrDropzone } from 'vue3-qr-reader'
import { useRouter } from 'vue-router'
// Import per generazione QR
import QRCodeVue3 from 'qrcode-vue3';
export default defineComponent({ export default defineComponent({
name: 'CQRCode', name: 'CQRCode',
emits: [''],
components: {
QrStream,
QrCapture,
QrDropzone,
QRCodeVue3,
},
props: { props: {
link: { link: {
type: String, type: String,
@@ -41,51 +54,361 @@ export default defineComponent({
required: false, required: false,
default: false, default: false,
}, },
size: {
type: Number,
required: false,
default: 250,
},
primaryColor: {
type: String,
required: false,
default: '#26249a',
},
}, },
components: {
QrStream,
QrCapture,
QrDropzone
},
setup(props, { attrs, slots, emit }) {
const { t } = useI18n()
const $q = useQuasar()
const globalStore = useGlobalStore()
const userStore = useUserStore()
const $router = useRouter()
emits: ['decoded', 'error'],
setup(props, { emit }) {
const { t } = useI18n();
const $q = useQuasar();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const $router = useRouter();
const qrDisplayRef = ref<HTMLElement | null>(null);
// State
const state = reactive({ const state = reactive({
data: null data: null as string | null,
}) isDownloading: false,
uploadedFile: null as File | null,
});
function onDecode(data: any) { // Computed
if (data) const qrSize = computed(() => {
state.data = data // Responsive size
if ($q.screen.lt.sm) {
return Math.min(props.size, window.innerWidth - 80);
}
return props.size;
});
const logoImage = computed(() => {
return props.imglogo || tools.getimglogo();
});
const dotsOptions = computed(() => ({
type: 'rounded' as const,
color: props.primaryColor,
gradient: {
type: 'linear' as const,
rotation: 0,
colorStops: [
{ offset: 0, color: props.primaryColor },
{ offset: 1, color: lightenColor(props.primaryColor, 20) },
],
},
}));
const canShare = computed(() => {
return !!navigator.share;
});
const downloadFilename = computed(() => {
const username = userStore.my?.username || 'user';
const timestamp = Date.now();
return `qrcode-${username}-${timestamp}`;
});
// Methods
function onDecode(data: string) {
if (data) {
state.data = data;
emit('decoded', data);
// Vibrazione feedback su mobile
if (navigator.vibrate) {
navigator.vibrate(100);
}
$q.notify({
type: 'positive',
message: 'QR Code rilevato!',
position: 'top',
timeout: 2000,
});
}
} }
const text = ref(''); function handleFileUpload(file: File | null) {
if (!file) return;
// Qui potresti usare una libreria per decodificare QR da immagine
// Per esempio: jsQR
$q.notify({
type: 'info',
message: 'Analisi immagine in corso...',
position: 'top',
});
}
async function findCanvas(maxAttempts = 10): Promise<HTMLCanvasElement | null> {
for (let i = 0; i < maxAttempts; i++) {
// Cerca in vari modi
let canvas: HTMLCanvasElement | null = null;
// Metodo 1: cerca nel ref
if (qrDisplayRef.value) {
canvas = qrDisplayRef.value.querySelector('canvas');
}
// Metodo 2: cerca con classe specifica
if (!canvas) {
const qrCode = document.querySelector('.qr-code');
canvas = qrCode?.querySelector('canvas') || null;
}
// Metodo 3: cerca nel container .qr-display
if (!canvas) {
const container = document.querySelector('.qr-display');
canvas = container?.querySelector('canvas') || null;
}
if (canvas && canvas.width > 0 && canvas.height > 0) {
return canvas;
}
// Aspetta prima del prossimo tentativo
await new Promise((resolve) => setTimeout(resolve, 100));
}
return null;
}
// Nuova funzione downloadQR
async function downloadQR() {
state.isDownloading = true;
try {
await nextTick();
await new Promise((resolve) => setTimeout(resolve, 200));
let dataUrl: string | null = null;
// Metodo 1: Cerca l'immagine (QRCodeVue3 genera un <img> con base64)
const qrDisplay = qrDisplayRef.value || document.querySelector('.qr-display');
if (qrDisplay) {
const img = qrDisplay.querySelector('img') as HTMLImageElement;
if (img && img.src && img.src.startsWith('data:image')) {
console.log('✅ Immagine base64 trovata!');
dataUrl = img.src;
}
}
// Metodo 2: Se non trova img, cerca canvas (fallback)
if (!dataUrl) {
const canvas = document.querySelector(
'.qr-display canvas'
) as HTMLCanvasElement;
if (canvas && canvas.width > 0) {
console.log('✅ Canvas trovato come fallback');
dataUrl = canvas.toDataURL('image/png');
}
}
if (!dataUrl) {
debugCanvas();
throw new Error('QR Code non trovato. Riprova.');
}
// Download
const link = document.createElement('a');
link.href = dataUrl;
link.download = `${downloadFilename.value}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
$q.notify({
type: 'positive',
message: 'QR Code scaricato!',
icon: 'download_done',
position: 'top',
});
} catch (error: any) {
console.error('Errore download QR:', error);
$q.notify({
type: 'negative',
message: error.message || 'Errore durante il download',
position: 'top',
});
emit('error', error);
} finally {
state.isDownloading = false;
}
}
async function shareQR() {
try {
// Cerca l'immagine base64
const qrDisplay = qrDisplayRef.value || document.querySelector('.qr-display');
const img = qrDisplay?.querySelector('img') as HTMLImageElement;
if (img && img.src && img.src.startsWith('data:image')) {
// Converti base64 in blob
const response = await fetch(img.src);
const blob = await response.blob();
const file = new File([blob], `${downloadFilename.value}.png`, {
type: 'image/png',
});
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({
title: props.textlink || 'QR Code',
files: [file],
});
$q.notify({
type: 'positive',
message: 'Condivisione avviata!',
position: 'top',
});
return;
}
}
// Fallback: condividi solo il link
await navigator.share({
title: props.textlink || 'QR Code',
url: props.link,
});
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('Errore condivisione:', error);
}
}
}
function copyToClipboard(text: string) {
navigator.clipboard
.writeText(text)
.then(() => {
$q.notify({
type: 'positive',
message: 'Copiato negli appunti!',
icon: 'content_copy',
position: 'top',
timeout: 1500,
});
})
.catch((err) => {
console.error('Errore copia:', err);
});
}
function isValidUrl(text: string): boolean {
try {
new URL(text);
return true;
} catch {
return false;
}
}
function truncateUrl(url: string, maxLength: number = 400): string {
if (!url || url.length <= maxLength) return url;
return url.substring(0, maxLength) + '...';
}
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = (num >> 16) + amt;
const G = ((num >> 8) & 0x00ff) + amt;
const B = (num & 0x0000ff) + amt;
return (
'#' +
(
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
)
.toString(16)
.slice(1)
);
}
function naviga(path: string) { function naviga(path: string) {
$router.push(path) $router.push(path);
} }
onMounted(mounted) function debugCanvas() {
console.log('=== DEBUG QR ===');
console.log('1. qrDisplayRef.value:', qrDisplayRef.value);
function mounted() { const qrDisplay = qrDisplayRef.value || document.querySelector('.qr-display');
// ...
if (qrDisplay) {
// Cerca immagini
const imgs = qrDisplay.querySelectorAll('img');
console.log('2. Immagini trovate:', imgs.length);
imgs.forEach((img, i) => {
const imgEl = img as HTMLImageElement;
console.log(
` Img ${i}: src starts with data:image = ${imgEl.src?.startsWith('data:image')}`
);
});
// Cerca canvas
const canvases = qrDisplay.querySelectorAll('canvas');
console.log('3. Canvas trovati:', canvases.length);
console.log(
'4. innerHTML (primi 200 char):',
qrDisplay.innerHTML.substring(0, 200)
);
}
} }
// Lifecycle
onMounted(() => {
// Inizializzazione se necessaria
});
return { return {
// Stores
globalStore,
userStore,
// Utils
t, t,
tools, tools,
costanti, costanti,
toolsext, toolsext,
text,
userStore, // State
...toRefs(state), ...toRefs(state),
// Computed
qrSize,
logoImage,
dotsOptions,
canShare,
downloadFilename,
primaryColor: props.primaryColor,
// Methods
onDecode, onDecode,
handleFileUpload,
qrDisplayRef,
downloadQR,
shareQR,
copyToClipboard,
isValidUrl,
truncateUrl,
naviga, naviga,
globalStore, };
}
}, },
}) });

View File

@@ -1,71 +1,215 @@
<template> <template>
<div v-if="globalStore.finishLoading"> <div
<div v-if="read"> v-if="globalStore.finishLoading"
<div class="stream"> class="qrcode-container"
<qr-stream @decode="onDecode" class="mb"> >
<div style="color: red" class="frame"></div> <!-- Modalità Lettura QR -->
</qr-stream> <div
v-if="read"
<br /> class="qr-reader-section"
<qr-capture @decode="onDecode" class="mb"></qr-capture> >
</div> <div class="stream-container">
<div class="row justify-center q-ma-sm"> <qr-stream
<q-btn @decode="onDecode"
v-if="data && data.startsWith('http')" class="qr-stream"
class="q-ma-sm"
dense
color="positive"
@click="tools.openUrl(data)"
label="APRI PAGINA"
> >
</q-btn> <div class="scan-frame">
<br /> <div class="corner top-left"></div>
<div v-if="data && data.startsWith('http')" class="result"> <div class="corner top-right"></div>
Link: {{ data }} <div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
</qr-stream>
</div>
<!-- Fallback per upload immagine -->
<div class="upload-section q-mt-md">
<q-file
v-model="uploadedFile"
outlined
dense
label="Oppure carica un'immagine QR"
accept="image/*"
class="upload-input"
@update:model-value="handleFileUpload"
>
<template v-slot:prepend>
<q-icon
name="image"
color="primary"
/>
</template>
</q-file>
</div>
<!-- Risultato scansione -->
<transition
name="fade"
mode="out-in"
>
<div
v-if="data"
class="scan-result q-mt-md"
>
<q-card class="result-card">
<q-card-section class="result-content">
<q-icon
name="check_circle"
color="positive"
size="32px"
class="q-mr-sm"
/>
<div class="result-text">
<span class="result-label">QR Code rilevato:</span>
<span class="result-value">{{ truncateUrl(data) }}</span>
</div>
</q-card-section>
<q-card-actions
v-if="isValidUrl(data)"
class="result-actions"
>
<q-btn
color="primary"
icon="open_in_new"
label="Apri Link"
class="full-width"
@click="tools.openUrl(data)"
/>
</q-card-actions>
<q-card-actions
v-else
class="result-actions"
>
<q-btn
color="grey-7"
icon="content_copy"
label="Copia testo"
class="full-width"
@click="copyToClipboard(data)"
/>
</q-card-actions>
</q-card>
</div>
</transition>
</div>
<!-- Modalità Generazione QR -->
<div
v-else
class="qr-generator-section"
>
<!-- Info link -->
<div
v-if="textlink || link"
class="link-info q-mb-md"
>
<div
v-if="textlink"
class="info-label"
>
{{ textlink }}
</div>
<div
v-if="link"
class="info-link"
>
<q-icon
name="link"
size="16px"
class="q-mr-xs"
/>
<span>{{ truncateUrl(link) }}</span>
<q-btn
flat
round
dense
size="sm"
icon="content_copy"
@click="copyToClipboard(link)"
>
<q-tooltip>Copia link</q-tooltip>
</q-btn>
</div> </div>
</div> </div>
</div>
<div v-else> <!-- QR Code Display -->
<div class="q-ma-sm"> <div class="qr-display" ref="qrDisplayRef">
{{ textlink }}<br /> <QRCodeVue3
{{ link }}<br /> :value="link"
Logo: {{imglogo}}<br /> :width="qrSize"
:height="qrSize"
:qr-options="{
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
}"
:image-options="{
hideBackgroundDots: true,
imageSize: 0.4,
margin: 10,
}"
:dots-options="dotsOptions"
:corners-square-options="{
type: 'extra-rounded',
color: primaryColor,
}"
:corners-dot-options="{
type: 'dot',
color: primaryColor,
}"
:background-options="{
color: '#ffffff',
}"
:image="logoImage"
/>
</div>
<!-- Download B utton -->
<div class="download-section q-mt-lg">
<q-btn
color="primary"
icon="download"
label="Scarica QR Code"
class="download-btn"
:loading="isDownloading"
@click="downloadQR"
/>
</div>
<!-- Share Options -->
<div
v-if="canShare"
class="share-section q-mt-md"
>
<q-btn
outline
color="primary"
icon="share"
label="Condividi"
class="share-btn"
@click="shareQR"
/>
</div> </div>
<qrcode-vue
:width="250"
:height="250"
:qrOptions="{ typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'H' }"
:imageOptions="{ hideBackgroundDots: true, imageSize: 0.4, margin: 0 }"
:dotsOptions="{
type: 'dots',
color: '#26249a',
gradient: {
type: 'linear',
rotation: 0,
colorStops: [
{ offset: 0, color: '#26249a' },
{ offset: 1, color: '#26249a' },
],
},
}"
:image="imglogo ? imglogo : tools.getimglogo()"
:cornersSquareOptions="{ type: 'dot', color: '#000000' }"
:cornersDotOptions="{ type: undefined, color: '#000000' }"
fileExt="png"
:download="true"
:value="link"
downloadButton="button_download"
:downloadOptions="{
name: 'qrcode-riso-' + userStore.my.username,
extension: 'png',
}"
/>
</div> </div>
</div> </div>
<!-- Loading State -->
<div
v-else
class="loading-container"
>
<q-spinner-dots
color="primary"
size="40px"
/>
<span class="loading-text q-mt-sm">Caricamento...</span>
</div>
</template> </template>
<script lang="ts" src="./CQRCode.ts"> <script lang="ts" src="./CQRCode.ts"></script>
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import './CQRCode.scss'; @import './CQRCode.scss';
</style> </style>

View File

@@ -11,7 +11,7 @@ $r-md: 10px;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
border-radius: $r-md; border-radius: $r-md;
padding: $s-md; padding: $s-sm;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
@@ -27,7 +27,7 @@ $r-md: 10px;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: $s-md; margin-bottom: $s-lg;
.balance-label { .balance-label {
font-size: 0.85rem; font-size: 0.85rem;
@@ -56,7 +56,7 @@ $r-md: 10px;
// Container progressione // Container progressione
.progress-container { .progress-container {
position: relative; position: relative;
margin-bottom: $s-lg; margin-bottom: $s-xs;
} }
// Track di sfondo // Track di sfondo
@@ -160,6 +160,7 @@ $r-md: 10px;
opacity: 0.8; opacity: 0.8;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
color: white;
} }
} }
} }
@@ -169,7 +170,7 @@ $r-md: 10px;
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: $s-sm; gap: $s-sm;
padding-top: $s-md; padding-top: $s-sm;
border-top: 1px solid rgba(255, 255, 255, 0.2); border-top: 1px solid rgba(255, 255, 255, 0.2);
// Layout inline quando !small // Layout inline quando !small

View File

@@ -1,4 +1,5 @@
import { defineComponent, computed } from 'vue'; import { defineComponent, computed } from 'vue';
import { useI18n } from 'vue-i18n';
export default defineComponent({ export default defineComponent({
name: 'CRISBalanceBar', name: 'CRISBalanceBar',
@@ -24,14 +25,24 @@ export default defineComponent({
// Label opzionale // Label opzionale
label: { label: {
type: String, type: String,
default: 'Range disponibile', default: '',
}, },
small: { small: {
type: Boolean, type: Boolean,
default: false, default: false,
},
color: {
type: String,
default: '',
},
info: {
type: Boolean,
default: true,
} }
}, },
setup(props) { setup(props) {
const { t } = useI18n();
// Range totale // Range totale
const totalRange = computed(() => { const totalRange = computed(() => {
return Math.abs(props.minLimit) + props.maxLimit; return Math.abs(props.minLimit) + props.maxLimit;
@@ -86,6 +97,7 @@ export default defineComponent({
canGive, canGive,
canReceive, canReceive,
zeroPosition, zeroPosition,
t,
}; };
}, },
}); });

View File

@@ -1,8 +1,15 @@
<template> <template>
<div class="ris-balance-bar"> <div
class="ris-balance-bar"
:style="color ? { color } : {}"
>
<!-- Label e valore corrente --> <!-- Label e valore corrente -->
<div class="balance-header"> <div class="balance-header">
<span class="balance-label">{{ label }}</span> <span
v-if="label"
class="balance-label text-white text-bold"
>{{ label }}</span
>
<span :class="['balance-current', balanceClass]"> <span :class="['balance-current', balanceClass]">
<span <span
@@ -15,7 +22,7 @@
</div> </div>
<!-- Barra di progressione --> <!-- Barra di progressione -->
<div class="progress-container"> <div class="progress-container q-pt-xs">
<!-- Linea di sfondo --> <!-- Linea di sfondo -->
<div class="progress-track"> <div class="progress-track">
<!-- Zona negativa (rossa) --> <!-- Zona negativa (rossa) -->
@@ -47,28 +54,31 @@
:style="{ '--zero-position': zeroPosition + '%' }" :style="{ '--zero-position': zeroPosition + '%' }"
> >
<div class="marker min-marker"> <div class="marker min-marker">
<span class="marker-value">{{ minLimit.toFixed(2) }}</span> <span class="marker-value">{{ minLimit }}</span>
<span class="marker-label">Fido</span> <span class="marker-label">FIDO</span>
</div> </div>
<div class="marker zero-marker-label"> <div class="marker zero-marker-label">
<span class="marker-value">0</span> <span class="marker-value">0</span>
</div> </div>
<div class="marker max-marker"> <div class="marker max-marker">
<span class="marker-value">+{{ maxLimit.toFixed(2) }}</span> <span class="marker-value">+{{ maxLimit }}</span>
<span class="marker-label">Max</span> <span class="marker-label">Max</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Info disponibilità --> <!-- Info disponibilità -->
<div :class="['availability-info', { 'inline-layout': !small }]"> <div
v-if="info"
:class="['availability-info', { 'inline-layout': !small }]"
>
<div class="availability-item"> <div class="availability-item">
<q-icon <q-icon
name="arrow_downward" name="arrow_downward"
size="xs" size="xs"
color="negative" color="negative"
/> />
<span class="availability-text"> <span class="availability-text text-white">
Puoi dare ancora: <strong>{{ canGive.toFixed(2) }} RIS</strong> Puoi dare ancora: <strong>{{ canGive.toFixed(2) }} RIS</strong>
</span> </span>
</div> </div>
@@ -78,7 +88,7 @@
size="xs" size="xs"
color="positive" color="positive"
/> />
<span class="availability-text"> <span class="availability-text text-white">
Puoi ricevere: <strong>{{ canReceive.toFixed(2) }} RIS</strong> Puoi ricevere: <strong>{{ canReceive.toFixed(2) }} RIS</strong>
</span> </span>
</div> </div>

View File

@@ -1,5 +1,6 @@
// Variables // Variables
$primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); $primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
$receive-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
$orange-gradient: linear-gradient(135deg, #f97316 0%, #ea580c 100%); $orange-gradient: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
$ris-color: #ff5500; $ris-color: #ff5500;
$border-radius-lg: 16px; $border-radius-lg: 16px;
@@ -8,36 +9,15 @@ $border-radius-sm: 8px;
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08); $shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
$shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12); $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
// Main Dialog // Header Gradients
.send-coins-dialog {
border-radius: $border-radius-lg $border-radius-lg 0 0;
overflow: hidden;
max-width: 420px;
width: 100%;
@media (min-width: 600px) {
border-radius: $border-radius-lg;
max-height: 90vh;
}
}
.mobile-fullheight {
height: 100vh;
max-height: 100vh;
display: flex;
flex-direction: column;
border-radius: 0;
}
// Header
.dialog-header {
position: relative;
}
.header-gradient { .header-gradient {
background: $primary-gradient; background: $primary-gradient;
padding: 12px 16px 14px; padding: 12px 16px 14px;
position: relative; position: relative;
&.receive-gradient {
background: $receive-gradient;
}
} }
.header-top-bar { .header-top-bar {
@@ -62,6 +42,33 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
gap: 10px; gap: 10px;
} }
// Mode Icons
.mode-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
&.send-icon {
background: $orange-gradient;
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
}
&.receive-icon {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
}
.ris-logo {
width: 20px;
height: 20px;
object-fit: contain;
}
}
.ris-coin-icon { .ris-coin-icon {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -145,17 +152,203 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
font-weight: 600; font-weight: 600;
} }
// Content // ═══════════════════════════════════════════
.dialog-content { // Receive Tabs
padding: 14px 16px; // ═══════════════════════════════════════════
flex: 1; .receive-tabs {
overflow-y: auto; margin-top: 8px;
&::-webkit-scrollbar { .receive-tabs-inner {
display: none; background: rgba(255, 255, 255, 0.1);
border-radius: $border-radius-sm;
:deep(.q-tab) {
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
min-height: 40px;
&.q-tab--active {
color: white;
}
}
:deep(.q-tab__indicator) {
height: 3px;
border-radius: 2px;
}
} }
-ms-overflow-style: none; }
scrollbar-width: none;
// ═══════════════════════════════════════════
// Receive Panels
// ═══════════════════════════════════════════
.receive-panels {
background: transparent;
:deep(.q-tab-panel) {
padding: 0;
}
}
.link-panel,
.qrcode-panel,
.showonlist-panel {
padding: 16px 0;
}
.panel-description {
display: flex;
align-items: flex-start;
padding: 12px;
background: #fef3c7;
border-radius: $border-radius-sm;
color: #92400e;
font-size: 13px;
line-height: 1.5;
}
// Request Form
.request-form {
margin-top: 16px;
}
.amount-section {
margin-top: 16px;
}
.amount-input-wrapper {
margin-top: 8px;
}
.receive-amount {
:deep(.q-field__control) {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
border: none;
min-height: 48px;
}
:deep(.q-field__native) {
color: white !important;
font-size: 22px !important;
font-weight: 700 !important;
}
}
// Generated Link Section
.generated-link-section {
padding: 16px;
background: #f3f4f6;
border-radius: $border-radius-md;
}
.link-preview {
display: flex;
align-items: center;
justify-content: space-between;
background: white;
padding: 12px;
border-radius: $border-radius-sm;
border: 1px solid #e5e7eb;
margin-top: 8px;
}
.link-text {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #374151;
font-size: 13px;
}
}
.share-buttons {
display: flex;
gap: 12px;
.share-btn,
.whatsapp-btn {
flex: 1;
text-transform: none;
font-weight: 600;
font-size: 13px;
padding: 10px 16px;
}
}
// QR Code Panel
.qrcode-panel {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
}
.qr-description {
max-width: 280px;
}
.qr-code-wrapper {
margin: 16px 0;
:deep(canvas),
:deep(img) {
border-radius: $border-radius-md;
box-shadow: $shadow-md;
}
}
// Quick Amounts
.quick-amounts {
width: 100%;
max-width: 320px;
}
.amount-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 12px;
.q-chip {
font-weight: 600;
font-size: 13px;
}
}
// Showonlist Panel
.showonlist-panel {
padding: 48px 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
}
// Info Banner
.info-banner {
background: #eff6ff;
color: #1e40af;
border-radius: $border-radius-sm;
}
// Empty State
.empty-state,
.empty-state-circuit {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
} }
// Section Block - Più compatto // Section Block - Più compatto
@@ -185,10 +378,6 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
} }
} }
:deep(.q-field__control-container) {
}
:deep(.q-field--focused .q-field__control) { :deep(.q-field--focused .q-field__control) {
border-color: #667eea; border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15); box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
@@ -348,25 +537,7 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
} }
} }
// Actions // Action Buttons
.dialog-actions {
display: flex;
gap: 10px;
padding: 12px 16px;
background: white;
border-top: 1px solid #f3f4f6;
}
.fixed-bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
.cancel-btn { .cancel-btn {
flex: 1; flex: 1;
background: #f3f4f6; background: #f3f4f6;
@@ -403,6 +574,15 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
&.receive-btn {
background: $receive-gradient;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
&:hover:not(.btn-disabled) {
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
}
} }
.send-btn-text { .send-btn-text {
@@ -479,7 +659,51 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important; padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px)) !important;
} }
// ═══════════════════════════════════════════
// Dialog Layout
// ═══════════════════════════════════════════
.send-coins-dialog {
display: flex;
flex-direction: column;
max-height: 100vh;
&.mobile-fullheight {
height: 100vh;
max-height: 100vh;
}
}
.dialog-header {
flex-shrink: 0;
}
.dialog-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 16px;
}
.dialog-actions {
flex-shrink: 0;
position: sticky;
bottom: 0;
background: white;
z-index: 10;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
padding: 12px 16px;
display: flex;
gap: 12px;
}
.section-block:last-child {
margin-bottom: 20px;
}
// ═══════════════════════════════════════════
// Dark mode support // Dark mode support
// ═══════════════════════════════════════════
.body--dark { .body--dark {
.send-coins-dialog { .send-coins-dialog {
background: #1f2937; background: #1f2937;
@@ -526,10 +750,6 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
border-top-color: #374151; border-top-color: #374151;
} }
.fixed-bottom-actions {
background: #1f2937;
}
.cancel-btn { .cancel-btn {
background: #374151; background: #374151;
color: #d1d5db; color: #d1d5db;
@@ -547,9 +767,34 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
.keyboard-title { .keyboard-title {
color: #d1d5db; color: #d1d5db;
} }
.generated-link-section {
background: #374151;
}
.link-preview {
background: #1f2937;
border-color: #4b5563;
}
.link-text span {
color: #f3f4f6;
}
.panel-description {
background: #78350f;
color: #fef3c7;
}
.info-banner {
background: #1e3a5f;
color: #93c5fd;
}
} }
// Responsive - Ultra compatto per mobile piccoli // ═══════════════════════════════════════════
// Responsive
// ═══════════════════════════════════════════
@media (max-width: 360px) { @media (max-width: 360px) {
.header-gradient { .header-gradient {
padding: 10px 12px 12px; padding: 10px 12px 12px;
@@ -572,4 +817,8 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
font-size: 20px !important; font-size: 20px !important;
} }
} }
.share-buttons {
flex-direction: column;
}
} }

View File

@@ -13,14 +13,24 @@ import { CNumericKeyboard } from '@/components/CNumericKeyboard';
import { CMyUserOnlyView } from '@/components/CMyUserOnlyView'; import { CMyUserOnlyView } from '@/components/CMyUserOnlyView';
import { CMyGroupOnlyView } from '@/components/CMyGroupOnlyView'; import { CMyGroupOnlyView } from '@/components/CMyGroupOnlyView';
import { CCheckCircuitsEnabled } from '@/components/CCheckCircuitsEnabled'; import { CCheckCircuitsEnabled } from '@/components/CCheckCircuitsEnabled';
import { CQRCode } from '@/components/CQRCode';
import { CCopyBtnSmall } from '@/components/CCopyBtnSmall';
import { costanti } from '@costanti'; import { costanti } from '@costanti';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { shared_consts } from '@/common/shared_vuejs'; import { shared_consts } from '@/common/shared_vuejs';
export type DialogMode = 'send' | 'receive';
export type ReceiveTabType = 'link' | 'qrcode' | 'showonlist';
export default defineComponent({ export default defineComponent({
name: 'CSendCoins', name: 'CSendCoins',
emits: ['close', 'showed'], emits: ['close', 'showed'],
props: { props: {
// Modalità: 'send' per inviare, 'receive' per ricevere
mode: {
type: String as PropType<DialogMode>,
default: 'send',
},
loadprofile: { loadprofile: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -75,6 +85,8 @@ export default defineComponent({
CMyGroupOnlyView, CMyGroupOnlyView,
CCheckCircuitsEnabled, CCheckCircuitsEnabled,
CNumericKeyboard, CNumericKeyboard,
CQRCode,
CCopyBtnSmall,
}, },
setup(props, { emit }) { setup(props, { emit }) {
@@ -85,23 +97,24 @@ export default defineComponent({
const circuitStore = useCircuitStore(); const circuitStore = useCircuitStore();
const $router = useRouter(); const $router = useRouter();
const to_user_real = ref(<IUserFields>{}); // ═══════════════════════════════════════════════════════════════
// COMPUTED - Mode
// ═══════════════════════════════════════════════════════════════
const isReceiveMode = computed(() => props.mode === 'receive');
// ═══════════════════════════════════════════════════════════════
// REFS - Common
// ═══════════════════════════════════════════════════════════════
const to_user_real = ref(<IUserFields>{});
const from_username = ref(userStore.my.username); const from_username = ref(userStore.my.username);
const from_groupname = ref(''); const from_groupname = ref('');
const from_contocom = ref(''); const from_contocom = ref('');
const circuitsel = ref(''); const circuitsel = ref('');
const qty = ref(<string | number>'');
const causal = ref('');
const loading = ref(false); const loading = ref(false);
const visubanner = ref(true);
const bothcircuits = ref(<any>[]); const bothcircuits = ref(<any>[]);
const showProvinceToSelect = ref(false); const showProvinceToSelect = ref(false);
const showKeyboard = ref(false);
const groupSel = ref(<IMyGroup | null | undefined>null); const groupSel = ref(<IMyGroup | null | undefined>null);
const datasaved = ref(<any>null); const datasaved = ref(<any>null);
const step = ref(0); const step = ref(0);
const sendCoinDialog = ref(null); const sendCoinDialog = ref(null);
@@ -116,19 +129,61 @@ export default defineComponent({
const numstep = ref(0); const numstep = ref(0);
const arrTypesAccounts = ref(<any>[]); const arrTypesAccounts = ref(<any>[]);
const tipoConto = ref(shared_consts.AccountType.USER); const tipoConto = ref(shared_consts.AccountType.USER);
const arrGroupsList = ref(<any[]>[]);
const groupsListAdmin = ref(<IMyGroup[]>[]);
// ═══════════════════════════════════════════════════════════════
// REFS - Send Mode
// ═══════════════════════════════════════════════════════════════
const qty = ref(<string | number>'');
const causal = ref('');
const showKeyboard = ref(false);
const qtyRef = ref(<any>null);
const causalRef = ref(<any>null);
const visubanner = ref(true);
const arrayMarkerLabel = ref(<any>[]);
// ═══════════════════════════════════════════════════════════════
// REFS - Receive Mode
// ═══════════════════════════════════════════════════════════════
const receiveType = ref<ReceiveTabType>('link');
const receiveQty = ref('');
const receiveCausal = ref('');
const riscallrec = ref('');
const showonreclist = ref(false);
const quickAmounts = ref([5, 10, 20, 50, 100]);
// ═══════════════════════════════════════════════════════════════
// COMPUTED
// ═══════════════════════════════════════════════════════════════
const priceLabel = computed(() => const priceLabel = computed(() =>
circuitloaded.value ? `${qty.value} ` + circuitloaded.value.symbol : '' circuitloaded.value ? `${qty.value} ` + circuitloaded.value.symbol : ''
); );
const arrayMarkerLabel = ref(<any>[]);
const qtyRef = ref(<any>null); // Circuiti disponibili (diversi per SEND e RECEIVE)
const causalRef = ref(<any>null); const availableCircuits = computed(() => {
if (isReceiveMode.value) {
// Per RECEIVE: tutti i miei circuiti
return userStore.getMyCircuits();
} else {
// Per SEND: circuiti in comune con il destinatario
return bothcircuits.value;
}
});
const groupsListAdmin = ref(<IMyGroup[]>[]); // Link generato per la ricezione
const generatedLink = computed(() => {
const arrGroupsList = ref(<any[]>[]); return userStore.getLinkProfileAndRIS(
circuitsel.value,
'',
receiveQty.value,
receiveCausal.value
);
});
// ═══════════════════════════════════════════════════════════════
// WATCHERS
// ═══════════════════════════════════════════════════════════════
watch( watch(
() => circuitsel.value, () => circuitsel.value,
(newval, oldval) => { (newval, oldval) => {
@@ -144,9 +199,7 @@ export default defineComponent({
if (arrGroupsList.value.length >= 1) if (arrGroupsList.value.length >= 1)
from_groupname.value = arrGroupsList.value[0].value; from_groupname.value = arrGroupsList.value[0].value;
} }
tools.setCookie(tools.COOK_TIPOCONTO, tipoConto.value.toString()); tools.setCookie(tools.COOK_TIPOCONTO, tipoConto.value.toString());
aggiorna(true); aggiorna(true);
} }
); );
@@ -172,6 +225,17 @@ export default defineComponent({
} }
); );
// Watch per aggiornare il link quando cambiano i valori di ricezione
watch(
() => receiveQty.value,
() => {
limitReceiveQuantity();
}
);
// ═══════════════════════════════════════════════════════════════
// METHODS - Common
// ═══════════════════════════════════════════════════════════════
async function aggiorna(load: boolean = false) { async function aggiorna(load: boolean = false) {
if (load) { if (load) {
inizio_caricamento(); inizio_caricamento();
@@ -219,7 +283,6 @@ export default defineComponent({
groupSel.value = userStore.my.profile.manage_mygroups.find( groupSel.value = userStore.my.profile.manage_mygroups.find(
(group: IMyGroup) => from_groupname.value === group.groupname (group: IMyGroup) => from_groupname.value === group.groupname
); );
accountloaded.value = groupSel.value ? groupSel.value.account : null; accountloaded.value = groupSel.value ? groupSel.value.account : null;
} else if (tipoConto.value === shared_consts.AccountType.COMMUNITY_ACCOUNT) { } else if (tipoConto.value === shared_consts.AccountType.COMMUNITY_ACCOUNT) {
from_contocom.value = circuitloaded.value.path; from_contocom.value = circuitloaded.value.path;
@@ -237,10 +300,8 @@ export default defineComponent({
if (groupsListAdmin.value) { if (groupsListAdmin.value) {
for (const group of groupsListAdmin.value) { for (const group of groupsListAdmin.value) {
let aggiungi = true; let aggiungi = true;
if (props.to_group && props.to_group.groupname === group.groupname) if (props.to_group && props.to_group.groupname === group.groupname)
aggiungi = false; aggiungi = false;
if (aggiungi) if (aggiungi)
arrGroupsList.value.push({ arrGroupsList.value.push({
label: group.groupname, label: group.groupname,
@@ -317,6 +378,15 @@ export default defineComponent({
}); });
} }
function invio_ris_caricamento() {
$q.loading.show({
message: 'Invio RIS in corso...',
spinnerColor: 'green',
backgroundColor: 'primary',
messageColor: 'white',
});
}
function fine_caricamento() { function fine_caricamento() {
$q.loading.hide(); $q.loading.hide();
} }
@@ -324,6 +394,19 @@ export default defineComponent({
async function mounted() { async function mounted() {
inizio_caricamento(); inizio_caricamento();
if (isReceiveMode.value) {
// RECEIVE MODE INIT
await initReceiveMode();
} else {
// SEND MODE INIT
await initSendMode();
}
loading.value = false;
emit('showed');
}
async function initSendMode() {
to_user_real.value = props.to_user; to_user_real.value = props.to_user;
loading.value = true; loading.value = true;
@@ -360,7 +443,7 @@ export default defineComponent({
} }
if ( if (
bothcircuits.value && bothcircuits.value &&
bothcircuits.value.find((name: any) => name !== circuitsel.value) !bothcircuits.value.some((name: any) => name === circuitsel.value)
) { ) {
circuitsel.value = bothcircuits.value[0]; circuitsel.value = bothcircuits.value[0];
} }
@@ -414,9 +497,39 @@ export default defineComponent({
showpage.value = true; showpage.value = true;
} }
}
loading.value = false; async function initReceiveMode() {
emit('showed'); // Per RECEIVE mode: mostra tutti i circuiti dell'utente
bothcircuits.value = userStore.getMyCircuits();
// Seleziona il primo circuito o quello salvato
if (props.circuitname) {
circuitsel.value = props.circuitname;
} else {
const savedCircuit = tools.getCookie(tools.CIRCUIT_USE, '');
if (savedCircuit && bothcircuits.value.includes(savedCircuit)) {
circuitsel.value = savedCircuit;
} else if (bothcircuits.value.length > 0) {
circuitsel.value = bothcircuits.value[0];
}
}
if (circuitsel.value) {
await aggiorna();
}
// Aggiungi alla lista dei riceventi temporanei
await clickAddtoRecList();
showpage.value = true;
fine_caricamento();
}
function onDialogShow() {
if (!isReceiveMode.value && qtyRef.value) {
qtyRef.value.focus();
}
} }
function hide() { function hide() {
@@ -424,7 +537,10 @@ export default defineComponent({
showpage.value = false; showpage.value = false;
} }
function sendCoin() { // ═══════════════════════════════════════════════════════════════
// METHODS - Send Mode
// ═══════════════════════════════════════════════════════════════
async function sendCoin() {
const ok = const ok =
(to_user_real.value && to_user_real.value.username) || (to_user_real.value && to_user_real.value.username) ||
(props.to_group && props.to_group.groupname) || (props.to_group && props.to_group.groupname) ||
@@ -456,12 +572,55 @@ export default defineComponent({
myrecsendcoin.dest = to_user_real.value ? to_user_real.value.username : ''; myrecsendcoin.dest = to_user_real.value ? to_user_real.value.username : '';
if (myrecsendcoin) { if (myrecsendcoin) {
tools const orig = myrecsendcoin.grouporig || myrecsendcoin.contoComOrig || '';
.sendCoinsByCircuit($q, $router, circuitloaded.value, myrecsendcoin) const dest =
.then((ris: any) => { myrecsendcoin.groupdest || myrecsendcoin.contoComDest || myrecsendcoin.dest;
// Seleziona il messaggio appropriato
const messageKey = orig
? 'circuit.question_sendcoinsto_from'
: 'circuit.question_sendcoinsto';
// Parametri comuni per i messaggi
const msgParams = {
coin: circuitloaded.value.symbol,
dest,
qty: myrecsendcoin.qty,
circuit: circuitloaded.value.name,
...(orig && { from: orig }),
};
// Mostra dialog di conferma
$q.dialog({
title: t('db.domanda'),
message: t(messageKey, msgParams),
ok: {
label: t('dialog.yes'),
color: 'primary',
push: true,
},
cancel: {
label: t('dialog.cancel'),
color: 'secondary',
},
})
.onOk(async () => {
invio_ris_caricamento();
const ris = await tools.sendCoinsByCircuit(
$q,
circuitloaded.value,
myrecsendcoin
);
if (ris) { if (ris) {
showpage.value = false; showpage.value = false;
} }
fine_caricamento()
})
.onCancel(() => {
return false;
})
.onDismiss(() => {
return false;
}); });
} }
} }
@@ -501,7 +660,6 @@ export default defineComponent({
function getQty(): number { function getQty(): number {
let myqty: number | null = null; let myqty: number | null = null;
try { try {
if (qty.value) { if (qty.value) {
myqty = parseFloat(String(qty.value)); myqty = parseFloat(String(qty.value));
@@ -509,48 +667,106 @@ export default defineComponent({
} catch (e) { } catch (e) {
return 0; return 0;
} }
return myqty ? myqty : 0; return myqty ? myqty : 0;
} }
function getTitle(step: number) {
if (step === 0) {
return 'Circuito';
} else if (step === 1) {
return 'Quantità';
} else if (step === 2) {
return 'Causale';
}
}
function getIcon(step: number) {
if (step === 0) {
return 'circuit';
} else if (step === 1) {
return 'attach_money';
} else if (step === 2) {
return 'description';
}
}
function setQty(value: string | number) { function setQty(value: string | number) {
qty.value = value; qty.value = value;
} }
// ═══════════════════════════════════════════════════════════════
// METHODS - Receive Mode
// ═══════════════════════════════════════════════════════════════
function limitReceiveQuantity() {
if (receiveQty.value && receiveQty.value.length > 5) {
receiveQty.value = receiveQty.value.substring(0, 5);
}
if (receiveQty.value) {
receiveQty.value = receiveQty.value.replace(',', '.');
}
}
function setQuickAmount(amount: number) {
receiveQty.value = amount.toString();
}
function truncateLink(link: string, maxLength: number = 35): string {
if (!link || link.length <= maxLength) return link;
return link.substring(0, maxLength) + '...';
}
async function shareLink() {
if (navigator.share) {
try {
await navigator.share({
title: 'Ricevi pagamento',
text: receiveCausal.value || 'Paga con questo link',
url: generatedLink.value,
});
} catch (err) {
console.log('Share cancelled');
}
} else {
// Fallback: copia negli appunti
navigator.clipboard.writeText(generatedLink.value);
$q.notify({
type: 'positive',
message: 'Link copiato!',
});
}
}
function shareWhatsApp() {
const text = encodeURIComponent(
`${receiveCausal.value ? receiveCausal.value + '\n' : ''}Paga qui: ${generatedLink.value}`
);
window.open(`https://wa.me/?text=${text}`, '_blank');
}
function configureShowcase() {
showpage.value = false;
$router.push('/showcase/settings');
}
async function clickAddtoRecList() {
const risultato = await tools.addToTemporaryReceiverRIS(t);
if (risultato) {
// riscallrec.value = risultato.msg;
showonreclist.value = risultato.ris;
}
}
// ═══════════════════════════════════════════════════════════════
// LIFECYCLE
// ═══════════════════════════════════════════════════════════════
onMounted(mounted); onMounted(mounted);
return { return {
// Common
t, t,
tools, tools,
showpage, showpage,
bothcircuits,
from_username,
circuitsel, circuitsel,
circuitloaded, circuitloaded,
accountloaded, accountloaded,
accountdest, accountdest,
qty,
hide, hide,
loading,
circuitStore,
costanti,
userStore,
step,
sendCoinDialog,
showProvinceToSelect,
shared_consts,
availableCircuits,
isReceiveMode,
onDialogShow,
// Send Mode
bothcircuits,
from_username,
qty,
sendCoin, sendCoin,
causal, causal,
priceLabel, priceLabel,
@@ -559,27 +775,31 @@ export default defineComponent({
qtyRef, qtyRef,
causalRef, causalRef,
maxsendable, maxsendable,
circuitStore,
numstep, numstep,
costanti,
userStore,
tipoConto, tipoConto,
arrGroupsList, arrGroupsList,
from_groupname, from_groupname,
from_contocom, from_contocom,
arrTypesAccounts, arrTypesAccounts,
loading,
showProvinceToSelect,
shared_consts,
step,
ifNextCheck, ifNextCheck,
getTitle,
getIcon,
sendCoinDialog,
visubanner, visubanner,
showKeyboard, showKeyboard,
setQty, setQty,
to_user_real, to_user_real,
// Receive Mode
receiveType,
receiveQty,
receiveCausal,
riscallrec,
quickAmounts,
generatedLink,
limitReceiveQuantity,
setQuickAmount,
truncateLink,
shareLink,
shareWhatsApp,
configureShowcase,
}; };
}, },
}); });

View File

@@ -4,30 +4,40 @@
ref="sendCoinDialog" ref="sendCoinDialog"
:maximized="$q.screen.lt.sm" :maximized="$q.screen.lt.sm"
@hide="hide" @hide="hide"
@show="qtyRef ? qtyRef.focus() : ''" @show="onDialogShow"
transition-show="slide-up" transition-show="slide-up"
transition-hide="slide-down" transition-hide="slide-down"
> >
<q-card <q-card
class="send-coins-dialog" class="send-coins-dialog column no-wrap"
:class="{ 'mobile-fullheight': $q.screen.lt.sm }" :class="{ 'mobile-fullheight': $q.screen.lt.sm }"
> >
<!-- Header con gradiente --> <!-- -->
<q-card-section class="dialog-header q-pa-none"> <!-- HEADER -->
<div class="header-gradient"> <!-- -->
<q-card-section class="dialog-header q-pa-none col-shrink">
<div class="header-gradient" :class="{ 'receive-gradient': isReceiveMode }">
<!-- Top bar --> <!-- Top bar -->
<div class="header-top-bar"> <div class="header-top-bar">
<div class="header-title-wrapper"> <div class="header-title-wrapper">
<div class="ris-coin-icon"> <!-- Icon based on mode -->
<div class="mode-icon" :class="isReceiveMode ? 'receive-icon' : 'send-icon'">
<q-icon
:name="isReceiveMode ? 'south_west' : 'north_east'"
size="20px"
v-if="!circuitloaded.symbol || circuitloaded.symbol !== 'RIS'"
/>
<img <img
v-if="circuitloaded.symbol === 'RIS'" v-else
src="/images/1ris_rosso_100.png" src="/images/1ris_rosso_100.png"
alt="RIS" alt="RIS"
class="ris-logo" class="ris-logo"
/> />
<span v-else class="coin-symbol">{{ circuitloaded.symbol }}</span>
</div> </div>
<span class="header-title">Invia {{ circuitloaded.symbol || 'Crediti' }}</span> <span class="header-title">
{{ isReceiveMode ? 'Ricevi' : 'Invia' }}
{{ circuitloaded.symbol || 'Crediti' }}
</span>
</div> </div>
<q-btn <q-btn
flat flat
@@ -40,41 +50,61 @@
/> />
</div> </div>
<!-- Balance Card compatto --> <!-- Balance Card (solo per SEND mode) -->
<div class="balance-card"> <div v-if="!isReceiveMode && accountloaded" class="balance-card">
<div class="balance-info"> <div class="balance-info">
<div class="balance-main"> <div class="balance-main">
<span class="balance-label">Saldo disponibile</span> <span class="balance-label">Saldo disponibile</span>
<span class="balance-value"> <span class="balance-value">
{{ accountloaded ? circuitStore.getRemainingCoinsToSend(accountloaded).toFixed(2) : '0.00' }} {{ circuitStore.getRemainingCoinsToSend(accountloaded).toFixed(2) }}
<span class="balance-symbol">{{ circuitloaded.symbol }}</span> <span class="balance-symbol">{{ circuitloaded.symbol }}</span>
</span> </span>
</div> </div>
<div class="balance-fido" v-if="accountloaded?.fidoConcesso > 0"> <div class="balance-fido" v-if="accountloaded?.fidoConcesso > 0">
<span class="fido-label">Fido</span> <span class="fido-label">{{ t('circuit.fido_scoperto_default') }}</span>
<span class="fido-value">+{{ accountloaded.fidoConcesso.toFixed(2) }}</span> <span class="fido-value">+{{ accountloaded.fidoConcesso.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Receive Type Tabs (solo per RECEIVE mode e circuito selezionato) -->
<div v-if="isReceiveMode && circuitsel" class="receive-tabs">
<q-tabs
v-model="receiveType"
dense
active-color="white"
indicator-color="white"
align="justify"
narrow-indicator
class="receive-tabs-inner"
>
<q-tab name="link" icon="link" label="Link" />
<q-tab name="qrcode" icon="qr_code_2" label="QR Code" />
<!--<q-tab name="showonlist" icon="storefront" label="Vetrina" />-->
</q-tabs>
</div>
</div> </div>
</q-card-section> </q-card-section>
<!-- Content --> <!-- -->
<q-card-section <!-- CONTENT -->
class="dialog-content scroll" <!-- -->
:style="$q.screen.lt.sm ? 'padding-bottom: 80px;' : ''" <q-card-section class="dialog-content col scroll">
>
<!-- Circuit Check --> <!-- Circuit Check -->
<CCheckCircuitsEnabled <CCheckCircuitsEnabled
v-if="!isReceiveMode"
:to_user="to_user_real" :to_user="to_user_real"
:to_group="to_group" :to_group="to_group"
/> />
<!-- Circuit Selector --> <!-- -->
<div v-if="circuitloaded.symbol && circuitname === ''" class="section-block"> <!-- STEP 1: Circuit Selector (comune a entrambi) -->
<!-- -->
<div v-if="circuitloaded.symbol" class="section-block">
<label class="section-label">Seleziona Circuito</label>
<q-select <q-select
v-model="circuitsel" v-model="circuitsel"
:options="bothcircuits" :options="availableCircuits"
:behavior="$q.platform.is.ios === true ? 'dialog' : 'menu'" :behavior="$q.platform.is.ios === true ? 'dialog' : 'menu'"
outlined outlined
dense dense
@@ -85,6 +115,16 @@
<template v-slot:prepend> <template v-slot:prepend>
<q-icon name="account_balance_wallet" color="primary" /> <q-icon name="account_balance_wallet" color="primary" />
</template> </template>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section avatar>
<q-icon name="account_balance" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label>{{ scope.opt }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select> </q-select>
</div> </div>
@@ -100,196 +140,383 @@
{{ $t('circuit.insertprovince_text') }} {{ $t('circuit.insertprovince_text') }}
</q-banner> </q-banner>
<!-- Sender Section --> <!-- -->
<div v-if="circuitsel" class="section-block"> <!-- SEND MODE CONTENT -->
<q-select <!-- -->
v-if="arrTypesAccounts.length > 0" <template v-if="!isReceiveMode && circuitsel">
v-model="tipoConto" <!-- Sender Section -->
:options="arrTypesAccounts" <div class="section-block">
outlined <q-select
dense v-if="arrTypesAccounts.length > 0"
emit-value v-model="tipoConto"
map-options :options="arrTypesAccounts"
label="Mittente"
class="modern-select"
popup-content-class="modern-select-popup"
>
<template v-slot:prepend>
<q-icon name="person" color="primary" />
</template>
</q-select>
<!-- Group Account Selector -->
<q-select
v-if="tipoConto === shared_consts.AccountType.CONTO_DI_GRUPPO"
v-model="from_groupname"
:options="arrGroupsList"
:label="$t('circuit.choosecontocom')"
outlined
dense
emit-value
map-options
class="modern-select q-mt-sm"
>
<template v-slot:prepend>
<q-icon name="groups" color="primary" />
</template>
</q-select>
<!-- Community Account -->
<q-input
v-if="tipoConto === shared_consts.AccountType.COMMUNITY_ACCOUNT"
v-model="from_contocom"
:label="$t('circuit.contocom')"
outlined
dense
readonly
class="modern-input q-mt-sm"
>
<template v-slot:prepend>
<q-icon name="account_balance" color="primary" />
</template>
</q-input>
</div>
<!-- Recipient Section -->
<div v-if="circuitsel" class="section-block">
<label class="section-label">Destinatario</label>
<div class="recipient-card">
<div class="recipient-content">
<!-- User Recipient -->
<CMyUserOnlyView
v-if="to_user_real"
:mycontact="to_user_real"
:visu="costanti.FIND_PEOPLE"
@setCmd="tools.setCmd"
class="recipient-view"
/>
<!-- Group Recipient -->
<CMyGroupOnlyView
v-if="to_group"
:mygrp="to_group"
:visu="costanti.USER_GROUPS"
:circuitname="circuitloaded.name"
class="recipient-view"
/>
<!-- Community Account Recipient -->
<CMyGroupOnlyView
v-if="circuitloaded && !!circuitloaded._id && to_contocom"
:mygrp="{ groupname: to_contocom }"
:visu="costanti.USER_GROUPS"
:circuitname="circuitloaded.name"
class="recipient-view"
/>
</div>
</div>
</div>
<!-- Amount Section - Compatto -->
<div v-if="circuitsel" class="section-block">
<label class="section-label">Importo</label>
<div class="amount-input-row" @click="$q.screen.lt.sm ? showKeyboard = true : qtyRef?.focus()">
<q-input
ref="qtyRef"
v-model="qty"
:type="$q.screen.lt.sm ? 'text' : 'number'"
outlined outlined
dense dense
input-class="amount-input-field" emit-value
:readonly="$q.screen.lt.sm" map-options
:rules="[ label="Mittente"
(val) => !isNaN(parseFloat(val)) || t('circuit.qta_not_valid'), class="modern-select"
(val) => parseFloat(val) <= circuitStore.getRemainingCoinsToSend(accountloaded) || t('circuit.qta_remaining_to_send', { maxqta: circuitStore.getRemainingCoinsToSend(accountloaded), symbol: circuitloaded.symbol }), popup-content-class="modern-select-popup"
(val) => parseFloat(val) > 0 || t('circuit.qta_not_valid'),
]"
hide-bottom-space
class="amount-input"
@keyup.enter="causalRef?.focus()"
> >
<template v-slot:prepend> <template v-slot:prepend>
<span class="currency-symbol"></span> <q-icon name="person" color="primary" />
</template> </template>
<template v-slot:append> </q-select>
<div class="coin-badge" :style="`background: ${circuitloaded.color || '#ff5500'}`">
{{ circuitloaded.symbol }} <!-- Group Account Selector -->
</div> <q-select
<q-btn v-if="tipoConto === shared_consts.AccountType.CONTO_DI_GRUPPO"
v-if="$q.screen.lt.sm" v-model="from_groupname"
flat :options="arrGroupsList"
round :label="$t('circuit.choosecontocom')"
dense outlined
icon="keyboard" dense
size="sm" emit-value
class="keyboard-btn q-ml-xs" map-options
@click.stop="showKeyboard = true" class="modern-select q-mt-sm"
/> >
<template v-slot:prepend>
<q-icon name="groups" color="primary" />
</template>
</q-select>
<!-- Community Account -->
<q-input
v-if="tipoConto === shared_consts.AccountType.COMMUNITY_ACCOUNT"
v-model="from_contocom"
:label="$t('circuit.contocom')"
outlined
dense
readonly
class="modern-input q-mt-sm"
>
<template v-slot:prepend>
<q-icon name="account_balance" color="primary" />
</template> </template>
</q-input> </q-input>
</div> </div>
</div>
<!-- Transactions Disabled Banner --> <!-- Recipient Section -->
<q-banner <div class="section-block">
v-if="circuitloaded && !!circuitloaded._id && !circuitloaded.transactionsEnabled" <label class="section-label">Destinatario</label>
rounded <div class="recipient-card">
class="error-banner q-mb-sm" <div class="recipient-content">
> <CMyUserOnlyView
<template v-slot:avatar> v-if="to_user_real"
<q-icon name="error" color="white" /> :mycontact="to_user_real"
</template> :visu="costanti.FIND_PEOPLE"
{{ $t('circuit.transactionsEnabled_text') }} @setCmd="tools.setCmd"
</q-banner> class="recipient-view"
<!-- Note Section - Compatto -->
<div v-if="circuitsel" class="section-block">
<q-input
ref="causalRef"
v-model="causal"
outlined
dense
type="textarea"
rows="3"
maxlength="200"
counter
label="Nota per il destinatario"
placeholder="Scrivi un messaggio..."
class="modern-textarea"
:rules="[
(val) => !!val?.trim() || 'Inserisci un messaggio',
(val) => val.trim().length >= 2 || 'Minimo 2 caratteri',
]"
lazy-rules
>
<template v-slot:prepend>
<q-icon name="message" color="grey-6" class="self-start q-mt-xs" />
</template>
<template v-slot:after>
<q-avatar size="32px">
<img
:src="userStore.my.profile ? userStore.getImgByProfile(userStore.my) : ''"
:alt="userStore.my.username"
/> />
</q-avatar> <CMyGroupOnlyView
v-if="to_group"
:mygrp="to_group"
:visu="costanti.USER_GROUPS"
:circuitname="circuitloaded.name"
class="recipient-view"
/>
<CMyGroupOnlyView
v-if="circuitloaded && !!circuitloaded._id && to_contocom"
:mygrp="{ groupname: to_contocom }"
:visu="costanti.USER_GROUPS"
:circuitname="circuitloaded.name"
class="recipient-view"
/>
</div>
</div>
</div>
<!-- Amount Section -->
<div class="section-block">
<label class="section-label">Importo</label>
<div
class="amount-input-row"
@click="$q.screen.lt.sm ? (showKeyboard = true) : qtyRef?.focus()"
>
<q-input
ref="qtyRef"
v-model="qty"
:type="$q.screen.lt.sm ? 'text' : 'number'"
outlined
dense
input-class="amount-input-field"
:readonly="$q.screen.lt.sm"
:rules="[
(val) => !isNaN(parseFloat(val)) || t('circuit.qta_not_valid'),
(val) =>
parseFloat(val) <= circuitStore.getRemainingCoinsToSend(accountloaded) ||
t('circuit.qta_remaining_to_send', {
maxqta: circuitStore.getRemainingCoinsToSend(accountloaded),
symbol: circuitloaded.symbol,
}),
(val) => parseFloat(val) > 0 || t('circuit.qta_not_valid'),
]"
hide-bottom-space
class="amount-input"
@keyup.enter="causalRef?.focus()"
>
<template v-slot:prepend>
<span class="currency-symbol"></span>
</template>
<template v-slot:append>
<div
class="coin-badge"
:style="`background: ${circuitloaded.color || '#ff5500'}`"
>
{{ circuitloaded.symbol }}
</div>
<q-btn
v-if="$q.screen.lt.sm"
flat
round
dense
icon="keyboard"
size="sm"
class="keyboard-btn q-ml-xs"
@click.stop="showKeyboard = true"
/>
</template>
</q-input>
</div>
</div>
<!-- Transactions Disabled Banner -->
<q-banner
v-if="circuitloaded && !!circuitloaded._id && !circuitloaded.transactionsEnabled"
rounded
class="error-banner q-mb-sm"
>
<template v-slot:avatar>
<q-icon name="error" color="white" />
</template> </template>
</q-input> {{ $t('circuit.transactionsEnabled_text') }}
</div> </q-banner>
<!-- Note Section -->
<div class="section-block">
<q-input
ref="causalRef"
v-model="causal"
outlined
dense
type="textarea"
rows="3"
maxlength="200"
counter
label="Nota per il destinatario"
placeholder="Scrivi un messaggio..."
class="modern-textarea"
:rules="[
(val) => !!val?.trim() || 'Inserisci un messaggio',
(val) => val.trim().length >= 2 || 'Minimo 2 caratteri',
]"
lazy-rules
>
<template v-slot:prepend>
<q-icon name="message" color="grey-6" class="self-start q-mt-xs" />
</template>
<template v-slot:after>
<q-avatar size="32px">
<img
:src="userStore.my.profile ? userStore.getImgByProfile(userStore.my) : ''"
:alt="userStore.my.username"
/>
</q-avatar>
</template>
</q-input>
</div>
</template>
<!-- -->
<!-- RECEIVE MODE CONTENT -->
<!-- -->
<template v-if="isReceiveMode && circuitsel">
<!-- Info Banner -->
<q-banner v-if="riscallrec" class="info-banner q-mb-md">
<template v-slot:avatar>
<q-icon name="info" color="primary" />
</template>
{{ riscallrec }}
</q-banner>
<!-- Tab Panels -->
<q-tab-panels
v-model="receiveType"
animated
keep-alive
class="receive-panels"
>
<!-- -->
<!-- Panel: Link -->
<!-- -->
<q-tab-panel name="link" class="q-pa-none">
<div class="link-panel">
<!-- Descrizione -->
<div class="panel-description q-mb-md">
<q-icon name="lightbulb" color="amber" size="20px" class="q-mr-sm" />
<span v-html="t('circuit.compila_il_tuo_link_per_ricevere_ris')"></span>
</div>
<!-- Form richiesta -->
<div class="request-form">
<!-- Nota/Causale -->
<q-input
v-model="receiveCausal"
outlined
dense
maxlength="120"
counter
:label="$t('circuit.note_richiedente')"
class="q-mb-md modern-textarea"
autogrow
:input-style="{ minHeight: '60px' }"
>
<template v-slot:prepend>
<q-icon name="message" color="grey-6" />
</template>
</q-input>
<!-- Importo richiesto -->
<div class="amount-section">
<label class="section-label">Importo richiesto (opzionale)</label>
<div class="amount-input-wrapper">
<q-input
v-model="receiveQty"
outlined
dense
type="number"
:rules="[(val) => !val || val > 0 || 'Importo non valido']"
class="amount-input receive-amount"
@update:model-value="limitReceiveQuantity"
>
<template v-slot:prepend>
<span class="currency-symbol"></span>
</template>
<template v-slot:append>
<div
class="coin-badge"
:style="`background: ${circuitloaded?.color || '#ff5500'}`"
>
{{ circuitloaded?.symbol || 'RIS' }}
</div>
</template>
</q-input>
</div>
</div>
<!-- Link generato -->
<div class="generated-link-section q-mt-sm">
<label class="section-label">Il tuo link di pagamento</label>
<div class="link-preview">
<div class="link-text">
<q-icon name="link" color="primary" size="20px" class="q-mr-sm" />
<span>{{ truncateLink(generatedLink) }}</span>
</div>
<CCopyBtnSmall
:showLink="true"
:texttocopy="generatedLink"
:showTelegram="true"
:small="false"
:btn="true"
class="copy-btn"
/>
</div>
</div>
</div>
</div>
</q-tab-panel>
<!-- -->
<!-- Panel: QR Code -->
<!-- -->
<q-tab-panel name="qrcode" class="q-pa-none">
<div class="qrcode-panel">
<div class="qr-description q-mb-sm text-center">
<q-icon name="qr_code_scanner" size="32px" color="primary" class="q-mb-sm" />
<div class="text-subtitle1">Mostra questo QR Code</div>
<div class="text-caption text-grey">
Chi vuole pagarti può scansionarlo con la fotocamera
</div>
</div>
<CQRCode
:read="false"
textlink="Link Profilo"
:link="generatedLink"
:size="280"
class="qr-code-wrapper"
/>
<!-- Quick amount presets -->
<div class="quick-amounts">
<label class="section-label text-center">Importo preimpostato</label>
<div class="amount-chips">
<q-chip
v-for="amount in quickAmounts"
:key="amount"
clickable
:outline="receiveQty !== amount.toString()"
:color="receiveQty === amount.toString() ? 'primary' : 'grey-4'"
:text-color="receiveQty === amount.toString() ? 'white' : 'grey-8'"
@click="setQuickAmount(amount)"
>
{{ amount }} {{ circuitloaded?.symbol || 'RIS' }}
</q-chip>
</div>
</div>
</div>
</q-tab-panel>
<!-- -->
<!-- Panel: Vetrina -->
<!-- -->
<q-tab-panel name="showonlist" class="q-pa-none">
<div class="showonlist-panel">
<div class="empty-state">
<q-icon name="storefront" size="64px" color="grey-4" />
<div class="text-h6 text-grey-6 q-mt-sm">Vetrina</div>
<div class="text-body2 text-grey-5 q-mt-sm text-center">
Mostra i tuoi prodotti e servizi per ricevere pagamenti
</div>
<q-btn
color="primary"
label="Configura vetrina"
class="q-mt-sm"
@click="configureShowcase"
/>
</div>
</div>
</q-tab-panel>
</q-tab-panels>
</template>
<!-- Stato iniziale - Nessun circuito selezionato (RECEIVE) -->
<template v-if="isReceiveMode && !circuitsel">
<div class="empty-state-circuit">
<q-icon name="account_balance_wallet" size="64px" color="grey-4" />
<div class="text-h6 text-grey-6 q-mt-sm">Seleziona un Circuito</div>
<div class="text-body2 text-grey-5 q-mt-sm text-center">
Per ricevere pagamenti, prima seleziona il circuito
</div>
</div>
</template>
</q-card-section> </q-card-section>
<!-- Fixed Bottom Actions --> <!-- -->
<q-card-actions <!-- ACTIONS -->
class="dialog-actions" <!-- -->
:class="{ 'fixed-bottom-actions': $q.screen.lt.sm }" <q-card-actions class="dialog-actions col-shrink">
>
<q-btn <q-btn
flat flat
:label="t('dialog.cancel')" :label="t('dialog.cancel')"
class="cancel-btn" class="cancel-btn"
v-close-popup v-close-popup
/> />
<!-- Send Button (solo SEND mode) -->
<q-btn <q-btn
v-if="!isReceiveMode"
:disable="!ifNextCheck(step)" :disable="!ifNextCheck(step)"
class="send-btn" class="send-btn"
:class="{ 'btn-disabled': !ifNextCheck(step) }" :class="{ 'btn-disabled': !ifNextCheck(step) }"
@@ -304,12 +531,23 @@
/> />
<q-icon v-else name="send" class="q-ml-sm" /> <q-icon v-else name="send" class="q-ml-sm" />
</q-btn> </q-btn>
<!-- Share Button (solo RECEIVE mode con circuito selezionato) -->
<q-btn
v-if="isReceiveMode && circuitsel"
class="send-btn receive-btn"
@click="shareLink"
>
<span class="send-btn-text">Condividi Link</span>
<q-icon name="share" class="q-ml-sm" />
</q-btn>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Numeric Keyboard Dialog --> <!-- Numeric Keyboard Dialog (solo SEND mode) -->
<q-dialog <q-dialog
v-if="!isReceiveMode"
v-model="showKeyboard" v-model="showKeyboard"
position="bottom" position="bottom"
seamless seamless
@@ -319,19 +557,14 @@
<q-card-section class="keyboard-header"> <q-card-section class="keyboard-header">
<div class="keyboard-header-content"> <div class="keyboard-header-content">
<span class="keyboard-title">Inserisci importo</span> <span class="keyboard-title">Inserisci importo</span>
<q-btn
flat
dense
label="Fatto"
color="primary"
class="done-btn"
v-close-popup
/>
</div> </div>
<div class="keyboard-display"> <div class="keyboard-display">
<span class="keyboard-amount">{{ qty || '0' }}</span> <span class="keyboard-amount">{{ qty || '0' }}</span>
<div class="keyboard-coin-badge" :style="`background: ${circuitloaded.color || '#ff5500'}`"> <div
class="keyboard-coin-badge"
:style="`background: ${circuitloaded.color || '#ff5500'}`"
>
{{ circuitloaded.symbol }} {{ circuitloaded.symbol }}
</div> </div>
</div> </div>
@@ -344,6 +577,14 @@
@update:model-value="setQty" @update:model-value="setQty"
/> />
</q-card-section> </q-card-section>
<div class="row justify-center q-my-sm">
<q-btn
label="Chiudi"
color="primary"
class="done-btn"
v-close-popup
/>
</div>
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>

View File

@@ -1,6 +0,0 @@
.my-custom-border {
border: 1px solid #ccc; /* Imposta il colore del bordo (puoi personalizzare) */
background-color: #fff; /* Colore sfondo per il contenitore (puoi personalizzare) */
border-radius: 10px;
}

View File

@@ -1,185 +0,0 @@
import { computed, defineComponent, onMounted, PropType, ref, watch } from 'vue';
import { ICalcStat, IOperators } from '../../model';
import { useUserStore } from '../../store/UserStore';
import { useRouter } from 'vue-router';
import { useGlobalStore } from '../../store/globalStore';
import { useCircuitStore } from '../../store/CircuitStore';
import { useI18n } from 'vue-i18n';
import { shared_consts } from '@/common/shared_vuejs';
import { costanti, IMainCard } from '@store/Modules/costanti';
import { CMyUser } from '../CMyUser';
import { CTitleBanner } from '../CTitleBanner';
import { CMyGroup } from '../CMyGroup';
import { CCopyBtnSmall } from '../CCopyBtnSmall';
import { CContactUser } from '../CContactUser';
import { CQRCode } from '../CQRCode';
import { CFindUsers } from '../CFindUsers';
import { CUserInfoAccount } from '../CUserInfoAccount';
import { tools } from '@tools';
import { useQuasar } from 'quasar';
export default defineComponent({
name: 'CSendRISTo',
props: {},
components: {
CMyUser,
CMyGroup,
CUserInfoAccount,
CCopyBtnSmall,
CTitleBanner,
CContactUser,
CFindUsers,
CQRCode,
},
setup(props) {
const userStore = useUserStore();
const globalStore = useGlobalStore();
const circuitStore = useCircuitStore();
const { t } = useI18n();
const $q = useQuasar();
const $router = useRouter();
const showSendCoin = ref(false);
const showReceiveCoin = ref(false);
const receiveType = ref('link');
const riscallrec = ref(<string>'');
const btnInviaRIS = ref(null);
const optionsReceive = ref([
{
label: 'Condividi il tuo Link',
value: 'link',
},
/*{
label: 'Rendi visibile il tuo profilo per 8 ore',
value: 'showonlist',
},*/
{
label: 'Genera il QR Code',
value: 'qrcode',
},
]);
const tipoConto = ref(shared_consts.AccountType.USER);
const loading = ref(false);
const miolink = ref('');
const sendRIS = ref('');
const qtyRIS = ref('');
const causal = ref('');
const circuitpath = computed(() => {
const circ = circuitStore.getCircuitByProvinceAndCard(
userStore.my.profile.resid_province,
userStore.my.profile.resid_card
);
return circ && circ.path ? circ.path : '';
});
watch(
() => qtyRIS.value,
(to: any, from: any) => {
limitQuantity();
miolink.value = userStore.getLinkProfileAndRIS('', qtyRIS.value, causal.value);
}
);
watch(
() => causal.value,
(to: any, from: any) => {
miolink.value = userStore.getLinkProfileAndRIS('', qtyRIS.value, causal.value);
}
);
const showonreclist = ref(false);
const contact = computed(() => userStore.my);
const arrTypesAccounts = ref(<any>[
{
label: t('circuit.user'),
value: shared_consts.AccountType.USER,
},
{
label: t('circuit.conticollettivi'),
value: shared_consts.AccountType.CONTO_DI_GRUPPO,
},
]);
function mounted() {
miolink.value = userStore.getLinkProfileAndRIS('', qtyRIS.value);
}
function limitQuantity() {
// Converte qtyRIS in stringa per verificare la lunghezza
if (qtyRIS.value.length > 5) {
qtyRIS.value = qtyRIS.value.substring(0, 5); // Limita a 5 caratteri
}
qtyRIS.value = qtyRIS.value.replace(',', '.');
}
function scrollaBottone() {
const btnInviaRIS = document.getElementById('btnInviaRIS');
if (btnInviaRIS) {
const offset = 30; // spazio desiderato in pixel sopra il bottone
const y = btnInviaRIS.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}
function clickInviaRIS() {
scrollaBottone();
showSendCoin.value = !showSendCoin.value;
if (showSendCoin.value) showReceiveCoin.value = !showSendCoin.value;
}
async function clickriceviRIS() {
scrollaBottone();
showReceiveCoin.value = !showReceiveCoin.value;
if (showReceiveCoin.value) showSendCoin.value = !showReceiveCoin.value;
if (showReceiveCoin.value) clickAddtoRecList();
}
async function clickAddtoRecList() {
const risultato = await tools.addToTemporaryReceiverRIS(t);
if (risultato) {
riscallrec.value = risultato.msg;
showonreclist.value = risultato.ris;
}
}
onMounted(mounted);
return {
userStore,
tools,
costanti,
shared_consts,
arrTypesAccounts,
tipoConto,
loading,
contact,
circuitpath,
sendRIS,
miolink,
qtyRIS,
t,
causal,
limitQuantity,
showSendCoin,
optionsReceive,
receiveType,
showReceiveCoin,
showonreclist,
riscallrec,
clickAddtoRecList,
clickInviaRIS,
clickriceviRIS,
btnInviaRIS,
};
},
});

View File

@@ -1,178 +0,0 @@
<template>
<div
v-if="tools.visualizzaHomeApp()"
class="row text-center justify-evenly items-center"
>
<div class="q-mb-sm">
<q-btn
id="btnInviaRIS"
icon="fas fa-upload"
color="positive"
size="md"
rounded
:label="$t('circuit.sendcoins_toso')"
@click="clickInviaRIS"
:push="showSendCoin"
>
</q-btn>
&nbsp;
<q-btn
icon="fas fa-download"
color="accent"
size="md"
rounded
:label="$t('circuit.receive_coins')"
@click="clickriceviRIS"
:push="showReceiveCoin"
>
</q-btn>
<q-slide-transition>
<CTitleBanner
v-show="showReceiveCoin"
:class="`q-pa-xs `"
:title="$t('circuit.receive_coins')"
bgcolor="white"
bgcolor2="lightblue"
:clcolor="`text-indigo`"
:canopen="true"
:small="true"
:open="true"
>
<q-banner
v-show="riscallrec"
rounded
class="bg-blue text-white"
style="text-align: center"
>
{{ riscallrec }}
<br />
</q-banner>
<q-option-group
class="q-ma-xs"
style="text-align: left !important"
v-model="receiveType"
:options="optionsReceive"
color="primary"
/>
<q-tab-panels
v-model="receiveType"
animated
keep-alive
class="shadow-2 rounded-borders"
>
<q-tab-panel name="link">
<div class="row justify-center">
<div
class="q-ma-xs q-pa-xs"
v-html="t('circuit.compila_il_tuo_link_per_ricevere_ris')"
></div>
<q-input
v-model="causal"
rounded
filled
maxlength="120"
counter
:label="$t('circuit.note_richiedente')"
class="q-ma-sm full-width q-px-xs"
autogrow
dense
>
<template v-slot:prepend>
<q-icon name="description" class="q-mr-xs" />
</template>
</q-input>
<q-input
class="q-mb-sm text-h5"
style="width: 180px"
outlined
dense
v-model="qtyRIS"
:type="'number'"
@input="limitQuantity"
:label="t('circuit.quantita')"
input-class="text-right"
input-style="padding-bottom: 14px !important;"
v-on:keyup.enter="$event.target.nextElementSibling.focus()"
>
<template v-slot:append>
<div class="text-h6">
<em
class="q-px-sm text-white rounded-borders"
style="background-color: #ff5500"
>RIS</em
>
</div>
</template>
</q-input>
</div>
<CCopyBtnSmall :texttocopy="miolink" :small="true" :btn="true">
</CCopyBtnSmall>
</q-tab-panel>
<q-tab-panel name="showonlist">
<!--<q-btn
v-if="!showonreclist"
icon="fas fa-upload"
color="positive"
size="md"
rounded
:label="$t('circuit.addtothereceiverlist')"
@click="clickAddtoRecList"
>
</q-btn>-->
<q-slide-transition> </q-slide-transition>
</q-tab-panel>
<q-tab-panel name="qrcode">
<CQRCode
:read="false"
textlink="Link Profilo"
:link="userStore.getLinkProfileAndRIS('', '', '')"
></CQRCode>
</q-tab-panel>
</q-tab-panels>
</CTitleBanner>
</q-slide-transition>
<!--<div class="q-mb-sm"></div>
<CUserInfoAccount
:user="contact"
:circuitpath="circuitpath"
:admin="false"
:onlysaldo="true"
/>-->
</div>
</div>
<div>
<q-slide-transition>
<CTitleBanner
v-show="showSendCoin"
:class="`q-pa-xs `"
:title="$t('circuit.sendcoins')"
bgcolor="white"
bgcolor2="lightblue"
:clcolor="`text-indigo`"
:canopen="true"
:small="true"
:open="true"
>
<CFindUsers
:actionType="costanti.ACTIONTYPE.SEND_RIS"
:sendRIS="sendRIS"
>
</CFindUsers>
</CTitleBanner>
</q-slide-transition>
</div>
</template>
<script lang="ts" src="./CSendRISTo.ts">
</script>
<style lang="scss" scoped>
@import './CSendRISTo.scss';
</style>

View File

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

View File

@@ -222,7 +222,7 @@
</q-expansion-item> </q-expansion-item>
</q-list> </q-list>
<div class="chart-wrapper"> <div v-if="tools.isAdmin()" class="chart-wrapper">
<CLineChart :mydata="datastat.reg_daily" :title="t('stat.reg_daily')" <CLineChart :mydata="datastat.reg_daily" :title="t('stat.reg_daily')"
color="blue" bordercolor="blue" :sum="true" :showMedia="true" /> color="blue" bordercolor="blue" :sum="true" :showMedia="true" />
</div> </div>

View File

@@ -0,0 +1,156 @@
<template>
<div class="activity-feed">
<div v-if="activities.length === 0" class="empty-feed">
<q-icon name="history" size="32px" color="grey-4" />
<p>Nessuna attività recente</p>
</div>
<div v-else class="feed-list">
<div
v-for="activity in activities"
:key="activity.id"
class="activity-item"
>
<div class="activity-icon" :class="`icon-${activity.color}`">
<q-icon :name="activity.icon" size="16px" />
</div>
<div class="activity-content">
<p class="activity-title">{{ activity.title }}</p>
<span class="activity-time">{{ formatTime(activity.timestamp) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Activity {
id: string;
type: string;
title: string;
timestamp: string;
icon: string;
color: string;
}
defineProps<{
activities: Activity[];
}>();
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Adesso';
if (minutes < 60) return `${minutes} min fa`;
if (hours < 24) return `${hours} ore fa`;
if (days < 7) return `${days} giorni fa`;
return date.toLocaleDateString('it-IT', { day: 'numeric', month: 'short' });
};
</script>
<style lang="scss" scoped>
.activity-feed {
background: white;
border-radius: 16px;
padding: 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.empty-feed {
text-align: center;
padding: 2rem 1rem;
color: #888;
p {
margin: 0.5rem 0 0;
font-size: 0.85rem;
}
}
.feed-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.icon-positive {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
&.icon-primary {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
&.icon-amber {
background: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
&.icon-negative {
background: rgba(244, 67, 54, 0.1);
color: #f44336;
}
}
.activity-content {
flex: 1;
min-width: 0;
.activity-title {
margin: 0;
font-size: 0.85rem;
color: #333;
line-height: 1.4;
}
.activity-time {
font-size: 0.75rem;
color: #999;
}
}
// Dark mode
.body--dark {
.activity-feed {
background: #1e1e1e;
}
.activity-item {
border-color: #333;
}
.activity-title {
color: #eee !important;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div class="quick-actions">
<div
v-for="action in actions"
:key="action.id"
class="action-card"
:class="action.colorClass"
@click="$emit('action', action.id)"
>
<div class="action-icon">
<q-icon :name="action.icon" size="32px" />
</div>
<div class="action-content">
<h3>{{ action.title }}</h3>
<p>{{ action.description }}</p>
</div>
<q-icon name="arrow_forward" size="20px" class="action-arrow" />
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: 'action', actionId: string): void;
}>();
const actions = [
{
id: 'new-poster',
icon: 'add_photo_alternate',
title: 'Nuova Locandina',
description: 'Crea un poster da zero o da template',
colorClass: 'action-primary'
},
{
id: 'ai-generate',
icon: 'auto_awesome',
title: 'Genera con AI',
description: 'Crea immagini uniche con intelligenza artificiale',
colorClass: 'action-amber'
},
{
id: 'browse-templates',
icon: 'dashboard_customize',
title: 'Esplora Template',
description: 'Sfoglia la libreria di modelli pronti',
colorClass: 'action-teal'
},
{
id: 'my-posters',
icon: 'collections',
title: 'Le Mie Locandine',
description: 'Visualizza e gestisci i tuoi poster',
colorClass: 'action-purple'
}
];
</script>
<style lang="scss" scoped>
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
@media (max-width: 1100px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.action-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem;
background: white;
border-radius: 16px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
border: 2px solid transparent;
&:hover {
transform: translateY(-3px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
.action-arrow {
transform: translateX(4px);
}
}
&.action-primary:hover { border-color: #667eea; }
&.action-amber:hover { border-color: #ff9800; }
&.action-teal:hover { border-color: #009688; }
&.action-purple:hover { border-color: #9c27b0; }
}
.action-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.action-primary & {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.action-amber & {
background: linear-gradient(135deg, #ff9800, #f57c00);
color: white;
}
.action-teal & {
background: linear-gradient(135deg, #009688, #00796b);
color: white;
}
.action-purple & {
background: linear-gradient(135deg, #9c27b0, #7b1fa2);
color: white;
}
}
.action-content {
flex: 1;
min-width: 0;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #333;
}
p {
margin: 0.25rem 0 0;
font-size: 0.8rem;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.action-arrow {
color: #ccc;
transition: transform 0.3s ease;
}
// Dark mode
.body--dark {
.action-card {
background: #1e1e1e;
&:hover {
background: #252525;
}
}
.action-content h3 {
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<template>
<div class="recent-posters">
<!-- Loading State -->
<div v-if="isLoading" class="posters-grid">
<div v-for="i in 6" :key="i" class="poster-skeleton">
<q-skeleton type="rect" class="skeleton-image" />
<div class="skeleton-content">
<q-skeleton type="text" width="70%" />
<q-skeleton type="text" width="40%" />
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="posters.length === 0" class="empty-state">
<q-icon name="image" size="64px" color="grey-4" />
<h3>Nessuna locandina</h3>
<p>Crea la tua prima locandina per vederla qui</p>
<q-btn
color="primary"
icon="add"
label="Crea Locandina"
@click="$router.push('/posters/poster-generator')"
/>
</div>
<!-- Posters Grid -->
<div v-else class="posters-grid">
<div
v-for="poster in posters"
:key="poster._id"
class="poster-card"
>
<div class="poster-image" @click="$emit('view', poster)">
<img
:src="poster.renderOutput?.png?.url || poster.renderOutput?.jpg?.url || '/placeholder.png'"
:alt="poster.name"
loading="lazy"
/>
<div class="poster-overlay">
<q-btn round color="white" text-color="dark" icon="visibility" size="sm" />
</div>
<q-badge
v-if="poster.metadata?.isFavorite"
color="amber"
floating
class="favorite-badge"
>
<q-icon name="star" size="12px" />
</q-badge>
</div>
<div class="poster-info">
<h3 :title="poster.name">{{ poster.name }}</h3>
<p class="poster-date">
<q-icon name="schedule" size="14px" />
{{ formatDate(poster.createdAt) }}
</p>
</div>
<div class="poster-actions">
<q-btn flat dense round icon="download" size="sm" @click="$emit('download', poster)">
<q-tooltip>Scarica</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" size="sm" @click="$emit('edit', poster)">
<q-tooltip>Modifica</q-tooltip>
</q-btn>
<q-btn flat dense round icon="more_vert" size="sm">
<q-menu>
<q-list dense>
<q-item clickable v-close-popup @click="$emit('view', poster)">
<q-item-section avatar><q-icon name="visibility" size="20px" /></q-item-section>
<q-item-section>Visualizza</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="sharePoster(poster)">
<q-item-section avatar><q-icon name="share" size="20px" /></q-item-section>
<q-item-section>Condividi</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="deletePoster(poster)" class="text-negative">
<q-item-section avatar><q-icon name="delete" size="20px" /></q-item-section>
<q-item-section>Elimina</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
const props = defineProps<{
posters: any[];
isLoading: boolean;
}>();
const emit = defineEmits<{
(e: 'view', poster: any): void;
(e: 'download', poster: any): void;
(e: 'edit', poster: any): void;
}>();
const $q = useQuasar();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 60) return `${minutes} min fa`;
if (hours < 24) return `${hours} ore fa`;
if (days < 7) return `${days} giorni fa`;
return date.toLocaleDateString('it-IT', {
day: 'numeric',
month: 'short'
});
};
const sharePoster = async (poster: any) => {
try {
if (navigator.share) {
await navigator.share({
title: poster.name,
text: `Guarda la mia locandina: ${poster.content?.title || poster.name}`,
url: window.location.origin + `/posters/${poster._id}`
});
} else {
await navigator.clipboard.writeText(window.location.origin + `/posters/${poster._id}`);
$q.notify({ type: 'positive', message: 'Link copiato!' });
}
} catch (error) {
console.error('Share error:', error);
}
};
const deletePoster = (poster: any) => {
$q.dialog({
title: 'Elimina Locandina',
message: `Vuoi eliminare "${poster.name}"?`,
cancel: true
}).onOk(() => {
$q.notify({ type: 'info', message: 'Eliminazione...' });
// Implement delete
});
};
</script>
<style lang="scss" scoped>
.recent-posters {
min-height: 200px;
}
.posters-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
@media (max-width: 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.poster-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
.poster-overlay {
opacity: 1;
}
}
}
.poster-image {
position: relative;
aspect-ratio: 3/4;
cursor: pointer;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
&:hover img {
transform: scale(1.05);
}
}
.poster-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.favorite-badge {
top: 0.5rem !important;
right: 0.5rem !important;
}
.poster-info {
padding: 1rem;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poster-date {
display: flex;
align-items: center;
gap: 0.25rem;
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: #888;
}
}
.poster-actions {
display: flex;
justify-content: flex-end;
padding: 0 0.5rem 0.75rem;
gap: 0.25rem;
}
// Skeleton
.poster-skeleton {
background: white;
border-radius: 16px;
overflow: hidden;
.skeleton-image {
height: 200px;
}
.skeleton-content {
padding: 1rem;
}
}
// Empty State
.empty-state {
text-align: center;
padding: 3rem 1rem;
background: white;
border-radius: 16px;
h3 {
margin: 1rem 0 0.5rem;
font-size: 1.2rem;
color: #555;
}
p {
color: #888;
margin-bottom: 1.5rem;
}
}
// Dark mode
.body--dark {
.poster-card,
.poster-skeleton,
.empty-state {
background: #1e1e1e;
}
.poster-info h3 {
color: #fff;
}
.empty-state h3 {
color: #ddd;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div class="stats-cards">
<div
v-for="card in statsCards"
:key="card.id"
class="stat-card"
:class="card.colorClass"
>
<div class="stat-icon">
<q-icon :name="card.icon" size="28px" />
</div>
<div class="stat-content">
<div class="stat-value">
<template v-if="isLoading">
<q-skeleton type="text" width="60px" />
</template>
<template v-else>
{{ card.value }}
</template>
</div>
<div class="stat-label">{{ card.label }}</div>
</div>
<div class="stat-trend" v-if="card.trend">
<q-icon
:name="card.trend > 0 ? 'trending_up' : 'trending_down'"
:color="card.trend > 0 ? 'positive' : 'negative'"
/>
<span :class="card.trend > 0 ? 'text-positive' : 'text-negative'">
{{ Math.abs(card.trend) }}%
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface Stats {
totalPosters: number;
postersThisMonth: number;
aiGenerations: number;
favoriteTemplates: number;
}
const props = defineProps<{
stats: Stats;
isLoading: boolean;
}>();
const statsCards = computed(() => [
{
id: 'total',
icon: 'image',
label: 'Locandine Totali',
value: props.stats.totalPosters,
colorClass: 'card-primary',
trend: null
},
{
id: 'month',
icon: 'calendar_month',
label: 'Questo Mese',
value: props.stats.postersThisMonth,
colorClass: 'card-success',
trend: 15
},
{
id: 'ai',
icon: 'auto_awesome',
label: 'Generazioni AI',
value: props.stats.aiGenerations,
colorClass: 'card-warning',
trend: null
},
{
id: 'templates',
icon: 'dashboard',
label: 'Template Usati',
value: props.stats.favoriteTemplates,
colorClass: 'card-info',
trend: null
}
]);
</script>
<style lang="scss" scoped>
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-top: -3rem;
position: relative;
z-index: 10;
@media (max-width: 1100px) {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
margin-top: -2rem;
}
}
.stat-card {
background: white;
border-radius: 16px;
padding: 1.5rem;
display: flex;
align-items: flex-start;
gap: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
}
&.card-primary::before { background: #667eea; }
&.card-success::before { background: #4caf50; }
&.card-warning::before { background: #ff9800; }
&.card-info::before { background: #2196f3; }
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.card-primary & {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
.card-success & {
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
}
.card-warning & {
background: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
.card-info & {
background: rgba(33, 150, 243, 0.1);
color: #2196f3;
}
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: #333;
line-height: 1.2;
}
.stat-label {
font-size: 0.85rem;
color: #888;
margin-top: 0.25rem;
}
.stat-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
position: absolute;
top: 1rem;
right: 1rem;
}
// Dark mode
.body--dark {
.stat-card {
background: #1e1e1e;
}
.stat-value {
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="template-showcase">
<!-- Loading -->
<div v-if="isLoading" class="templates-list">
<div v-for="i in 4" :key="i" class="template-skeleton">
<q-skeleton type="rect" width="60px" height="80px" />
<div class="skeleton-content">
<q-skeleton type="text" width="80%" />
<q-skeleton type="text" width="50%" />
</div>
</div>
</div>
<!-- Templates List -->
<div v-else class="templates-list">
<div
v-for="template in templates"
:key="template._id"
class="template-item"
@click="$emit('select', template)"
>
<div class="template-preview" :style="getPreviewStyle(template)" />
<div class="template-info">
<h4>{{ template.name }}</h4>
<p>{{ formatType(template.templateType) }}</p>
<div class="usage-count" v-if="template.metadata?.usageCount">
<q-icon name="trending_up" size="12px" />
{{ template.metadata.usageCount }} usi
</div>
</div>
<q-icon name="chevron_right" size="20px" class="item-arrow" />
</div>
</div>
<!-- Browse All -->
<q-btn
flat
color="primary"
label="Vedi tutti i template"
icon-right="arrow_forward"
class="browse-all-btn"
@click="$router.push('/templates')"
/>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
templates: any[];
isLoading: boolean;
}>();
const emit = defineEmits<{
(e: 'select', template: any): void;
}>();
const formatType = (type: string) => {
return type.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
const getPreviewStyle = (template: any) => {
if (template.thumbnailUrl) {
return {
backgroundImage: `url(${template.thumbnailUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
};
}
const palette = template.palette || {};
return {
background: `linear-gradient(135deg, ${palette.primary || '#667eea'}, ${palette.secondary || '#764ba2'})`
};
};
</script>
<style lang="scss" scoped>
.template-showcase {
background: white;
border-radius: 16px;
padding: 1rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.templates-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.template-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f5f5f5;
.item-arrow {
transform: translateX(4px);
}
}
}
.template-preview {
width: 50px;
height: 70px;
border-radius: 8px;
flex-shrink: 0;
}
.template-info {
flex: 1;
min-width: 0;
h4 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
margin: 0.15rem 0 0;
font-size: 0.75rem;
color: #888;
}
.usage-count {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.35rem;
font-size: 0.7rem;
color: #4caf50;
font-weight: 500;
}
}
.item-arrow {
color: #ccc;
transition: transform 0.2s ease;
}
.template-skeleton {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
.skeleton-content {
flex: 1;
}
}
.browse-all-btn {
width: 100%;
margin-top: 0.5rem;
}
// Dark mode
.body--dark {
.template-showcase {
background: #1e1e1e;
}
.template-item:hover {
background: #2a2a2a;
}
.template-info h4 {
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,128 @@
// src/components/EventPosterGenerator.scss
.event-poster-generator {
max-width: 1000px;
margin: 2rem auto;
padding: 0 1rem;
}
.poster-card {
border-radius: 28px !important;
overflow: hidden;
background: var(--q-surface, #fafafa);
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.18);
}
.header-section {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 3rem 2rem !important;
text-align: center;
.q-icon { text-shadow: 0 4px 15px rgba(0,0,0,0.4); }
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.8rem;
margin-bottom: 1rem;
@media (max-width: 1024px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
.col-span-full { grid-column: 1 / -1; }
}
.generate-btn-wrapper {
text-align: center;
margin: 2rem 0 2rem;
}
.generate-btn {
border-radius: 50px;
padding: 1.2rem 2rem !important;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: 1.5px;
box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
}
.result-section {
padding: 2rem 1rem 0 !important;
}
.poster-preview {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.4);
max-height: 85vh;
margin: 0 auto;
display: block;
}
.actions-row {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 2.5rem;
flex-wrap: wrap;
}
.favorites-section {
background: rgba(0,0,0,0.03);
padding: 3rem 2rem !important;
margin-top: 2rem;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.5rem;
}
.favorite-item {
position: relative;
border-radius: 16px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
&:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
}
}
.favorite-img {
height: 280px;
object-fit: cover;
}
.favorite-title {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
color: white;
padding: 1.5rem 1rem 1rem;
font-weight: 600;
font-size: 0.9rem;
}
.delete-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.6) !important;
backdrop-filter: blur(8px);
}
.fade-enter-active, .fade-leave-active { transition: all 0.6s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(40px); }

View File

@@ -0,0 +1,218 @@
// src/components/EventPosterGenerator.ts
import { ref, reactive, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { Api } from 'app/src/store/Api';
interface PosterFavorite {
url: string;
title: string;
form: any;
date: string;
}
export default function useEventPosterGenerator() {
const $q = useQuasar();
interface FormData {
titolo: string;
descrizione: string;
data: string;
ora: string;
luogo: string;
contatti: string;
fotoDescrizione: string;
provider: string;
stile: string;
aspectRatio: '9:16' | '1:1' | '16:9';
}
const form = reactive<FormData>({
titolo: '',
descrizione: '',
data: '',
ora: '',
luogo: '',
contatti: '',
fotoDescrizione: '',
stile: 'techno dark futuristico, colori neon viola verde nero, cyberpunk',
aspectRatio: '9:16',
provider: 'hf',
});
const stileOptions = [
{
label: 'Techno Dark Cyberpunk',
value:
'vertical event poster design, techno dark cyberpunk style, neon purple green black color palette, futuristic city at night, dramatic lighting, glitch effects, industrial atmosphere, bold graphic design, clean empty area for title and event details, no text, high quality, 4k. Locandina verticale per evento techno dark futuristico.',
},
{
label: 'Latino Caliente',
value:
'vertical event poster design, latin party style, vibrant red gold yellow colors, warm lighting, festive and caliente atmosphere, dynamic abstract shapes suggesting dance and music, glossy textures, bold curved graphic elements, clear central area for title, date and location, no text, high quality. Locandina per evento latino, atmosfera caliente.',
},
{
label: 'Elegante Black & Gold',
value:
'vertical luxury event poster, elegant black and gold minimal style, deep black background, metallic gold accents, soft reflections, clean and symmetrical composition, premium graphic design, stylish and refined look, large empty space for logo, main title and event info, no text, ultra high quality. Locandina elegante nero e oro, stile luxury premium.',
},
{
label: 'Vintage Retrò',
value:
'vertical retro poster design, 70s 80s vintage style, warm pastel colors, slightly faded texture, paper grain, geometric shapes and wavy forms, nostalgic mood, groovy layout, central area left blank for text, no text, high quality graphic design. Locandina in stile retrò anni 70 80, atmosfera nostalgica.',
},
{
label: 'Reggaeton Tropical',
value:
'vertical party poster design, reggaeton tropical style, palm trees, beach vibes, sunset sky, vibrant neon pink turquoise yellow colors, summer atmosphere, dynamic and fluid shapes, fun and energetic look, clear space reserved for title, DJs and event info, no text, high quality. Locandina per festa reggaeton tropicale, mood estivo.',
},
{
label: 'Minimalista Moderno',
value:
'vertical minimalist modern poster, swiss graphic design style, black and white, grid layout, a lot of white space, thin lines and simple geometric shapes, clean vector design, professional and contemporary look, central area kept empty for title, date and logo, no text, ultra clean, high quality. Locandina minimalista moderna bianco e nero.',
},
{
label: 'Psichedelico Fluo',
value:
'vertical psychedelic poster design, bright neon fluorescent colors, acid palette, abstract liquid and swirling patterns, trippy visual effect, club and rave atmosphere, slightly grainy texture, highly detailed, composition with central zone left empty for event text, no text, high quality, 4k. Locandina psichedelica fluo, trip visivo.',
},
{
label: 'Cinema Hollywood',
value:
'vertical cinematic poster design, Hollywood movie poster style, dramatic lighting, strong contrasts, rich deep colors, epic composition with central focus, slight vignette, professional film poster look, space reserved for big title and credits area, no text, high quality. Locandina cinematografica in stile Hollywood, atmosfera epica.',
},
];
const providersList = [
{ label: 'Ideogram', value: 'ideogram' },
{ label: 'FAL (a Pagamento)', value: 'fal' },
{ label: 'Hugging Face', value: 'hf' },
];
const aspectRatios = [
{ label: 'Verticale Instagram (9:16)', value: '9:16' },
{ label: 'Quadrata (1:1)', value: '1:1' },
{ label: 'Orizzontale (16:9)', value: '16:9' },
];
const isGenerating = ref(false);
const posterUrl = ref('');
const favorites = ref<PosterFavorite[]>([]);
const requiredRule = (val: string) => !!val || 'Campo obbligatorio';
const isFavorite = computed(() => {
return favorites.value.some((f) => f.url === posterUrl.value);
});
const loadSavedData = () => {
const savedForm = localStorage.getItem('lastPosterForm');
const savedFavorites = localStorage.getItem('posterFavorites');
if (savedForm) Object.assign(form, JSON.parse(savedForm));
if (savedFavorites) favorites.value = JSON.parse(savedFavorites);
};
const saveForm = () => {
localStorage.setItem('lastPosterForm', JSON.stringify(form));
};
const generatePoster = async () => {
if (!form.titolo || !form.data || !form.luogo) {
$q.notify({ type: 'negative', message: 'Compila i campi obbligatori!' });
return;
}
saveForm();
isGenerating.value = true;
const prompt = `Locandina evento italiana ${form.aspectRatio === '9:16' ? 'verticale Instagram' : form.aspectRatio === '1:1' ? 'quadrata' : 'orizzontale'},
stile ${form.stile},
titolo grande leggibile: "${form.titolo.toUpperCase()}",
sottotitolo: "${form.descrizione}",
DATA: ${form.data}${form.ora ? ' - ORE ' + form.ora : ''},
LUOGO: ${form.luogo},
in basso: "${form.contatti}",
immagine principale: ${form.fotoDescrizione || "atmosfera spettacolare dell'evento"},
design premium italiano, testo perfetto, qualità altissima, niente errori`;
try {
const res = await Api.SendReq('/api/generateposter', 'POST', { ...form, prompt });
if (res && res.data?.imageUrl) {
posterUrl.value = res.data.imageUrl;
$q.notify({
type: 'positive',
message: 'Locandina creata! ✨',
icon: 'auto_awesome',
});
} else {
$q.notify({ type: 'negative', message: res.data?.msgerr });
}
} catch (err: any) {
$q.notify({ type: 'negative', message: err.message || 'Errore AI' });
} finally {
isGenerating.value = false;
}
};
const toggleFavorite = () => {
if (isFavorite.value) {
const index = favorites.value.findIndex((f) => f.url === posterUrl.value);
if (index > -1) favorites.value.splice(index, 1);
} else {
favorites.value.unshift({
url: posterUrl.value,
title: form.titolo,
form: { ...form },
date: new Date().toLocaleDateString('it-IT'),
});
}
localStorage.setItem('posterFavorites', JSON.stringify(favorites.value));
};
const removeFavorite = (index: number) => {
favorites.value.splice(index, 1);
localStorage.setItem('posterFavorites', JSON.stringify(favorites.value));
};
const loadFavorite = (fav: PosterFavorite) => {
Object.assign(form, fav.form);
posterUrl.value = fav.url;
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const downloadPoster = () => {
const a = document.createElement('a');
a.href = posterUrl.value;
a.download = `${form.titolo.replace(/[^a-z0-9]/gi, '_')}_locandina_2025.png`;
a.click();
};
const resetResult = () => {
posterUrl.value = '';
};
onMounted(() => {
console.log('Mounted EventPosterGenerator');
loadSavedData();
});
return {
form,
stileOptions,
aspectRatios,
isGenerating,
posterUrl,
favorites,
isFavorite,
requiredRule,
generatePoster,
toggleFavorite,
removeFavorite,
loadFavorite,
downloadPoster,
resetResult,
providersList,
};
}

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import useEventPosterGenerator from './EventPosterGenerator';
const {
form,
stileOptions,
providersList,
aspectRatios,
isGenerating,
posterUrl,
favorites,
isFavorite,
requiredRule,
generatePoster,
toggleFavorite,
removeFavorite,
loadFavorite,
downloadPoster,
resetResult,
} = useEventPosterGenerator();
</script>
<template>
<div class="event-poster-generator">
<q-card
flat
bordered
class="poster-card"
>
<q-card-section class="header-section">
<div class="text-h5 text-weight-bold flex items-center justify-center gap-3">
<q-icon
name="auto_awesome"
size="36px"
/>
Generatore Locandine AI 2025
</div>
<div class="text-subtitle1 opacity-90">
Gratis per sempre Grok Image + Flux Schnell
</div>
</q-card-section>
<q-card-section class="q-pa-sm">
<q-form
@submit.prevent="generatePoster"
class="form-grid"
>
<q-input
v-model="form.titolo"
filled
label="Titolo Evento *"
:rules="[requiredRule]"
/>
<q-input
v-model="form.descrizione"
filled
label="Sottotitolo / Claim"
/>
<q-input
v-model="form.data"
filled
label="Data *"
:rules="[requiredRule]"
/>
<q-input
v-model="form.ora"
filled
label="Ora"
mask="##:##"
/>
<q-input
v-model="form.luogo"
filled
label="Luogo *"
:rules="[requiredRule]"
/>
<q-input
v-model="form.contatti"
filled
label="Contatti & Prenotazioni"
class="col-span-full"
/>
<q-input
v-model="form.fotoDescrizione"
filled
type="textarea"
rows="3"
label="Immagine principale (descrivila bene!)"
class="col-span-full"
/>
<q-select
v-model="form.stile"
filled
:options="stileOptions"
label="Stile grafico"
emit-value
map-options
/>
<q-select
v-model="form.aspectRatio"
filled
:options="aspectRatios"
label="Formato"
/>
<q-select
v-model="form.provider"
filled
:options="providersList"
label="Generatore Immagini"
emit-value
map-options
/>
</q-form>
<div class="generate-btn-wrapper">
<q-btn
:loading="isGenerating"
type="submit"
color="primary"
icon="auto_awesome"
label="GENERA LOCANDINA MAGICA"
class="generate-btn"
@click="generatePoster"
/>
</div>
</q-card-section>
<!-- Risultato -->
<transition
name="fade"
mode="out-in"
>
<q-card-section
v-if="posterUrl"
class="result-section q-pb-xl"
>
<q-img
:src="posterUrl"
class="poster-preview"
loading="lazy"
/>
<div class="actions-row">
<q-btn
color="positive"
icon="download"
label="Scarica HD"
size="lg"
@click="downloadPoster"
/>
<q-btn
color="amber"
icon="favorite"
:label="isFavorite ? 'Preferita ✓' : 'Aggiungi ai Preferiti'"
size="lg"
:color="isFavorite ? 'amber-7' : 'grey-6'"
@click="toggleFavorite"
/>
<q-btn
color="grey-7"
icon="refresh"
label="Nuova"
size="lg"
flat
@click="resetResult"
/>
</div>
</q-card-section>
</transition>
<!-- Galleria Preferiti -->
<q-card-section
v-if="favorites.length > 0"
class="favorites-section"
>
<div class="text-h6 text-center q-mb-lg">
Le tue locandine preferite ({{ favorites.length }})
</div>
<div class="favorites-grid">
<div
v-for="(fav, index) in favorites"
:key="index"
class="favorite-item"
@click="loadFavorite(fav)"
>
<q-img
:src="fav.url"
class="favorite-img"
/>
<div class="favorite-title">{{ fav.title }}</div>
<q-btn
icon="delete"
flat
round
size="sm"
color="negative"
class="delete-btn"
@click.stop="removeFavorite(index)"
/>
</div>
</div>
</q-card-section>
<q-inner-loading
:showing="isGenerating"
color="primary"
>
<q-spinner-ios size="80px" />
<div class="text-h6 q-mt-lg text-white">
Sto creando la tua locandina perfetta...
</div>
</q-inner-loading>
</q-card>
</div>
</template>
<style lang="scss" scoped>
@import './EventPosterGenerator.scss';
</style>

View File

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

View File

@@ -0,0 +1,271 @@
.poster-generator {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
// Header
.generator-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 100;
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-title {
h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
font-size: 0.8rem;
color: #888;
}
}
.header-actions {
display: flex;
gap: 0.75rem;
}
}
// Steps Indicator
.steps-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem 2rem;
background: white;
border-bottom: 1px solid #e0e0e0;
position: relative;
gap: 0;
.step-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 2rem;
position: relative;
z-index: 2;
cursor: default;
transition: all 0.2s;
&.is-clickable {
cursor: pointer;
&:hover .step-number {
transform: scale(1.1);
}
}
&.is-completed {
.step-number {
background: #4caf50;
border-color: #4caf50;
color: white;
}
.step-label {
color: #4caf50;
}
}
&.is-active {
.step-number {
background: #667eea;
border-color: #667eea;
color: white;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.step-label {
color: #667eea;
font-weight: 600;
}
}
}
.step-number {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid #ddd;
background: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
color: #888;
transition: all 0.3s;
}
.step-label {
margin-top: 0.5rem;
font-size: 0.8rem;
color: #888;
transition: all 0.2s;
}
.step-line {
position: absolute;
left: 15%;
right: 15%;
top: calc(1.5rem + 20px);
height: 3px;
background: linear-gradient(90deg, #4caf50, #667eea);
border-radius: 2px;
z-index: 1;
transition: width 0.4s ease;
&::before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
height: 100%;
background: #e0e0e0;
z-index: -1;
border-radius: 2px;
}
}
}
// Content
.generator-content {
flex: 1;
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
.step-content {
animation: fadeIn 0.3s ease;
}
.step-with-preview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
}
.form-section {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.preview-section {
position: sticky;
top: 100px;
height: fit-content;
&.preview-large {
@media (min-width: 1200px) {
grid-column: span 1;
}
}
}
// Animations
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-30px);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Responsive
@media (max-width: 768px) {
.generator-header {
flex-wrap: wrap;
gap: 1rem;
.header-actions {
width: 100%;
justify-content: flex-end;
}
}
.steps-indicator {
padding: 1rem;
overflow-x: auto;
.step-item {
padding: 0 1rem;
}
.step-label {
display: none;
}
}
.generator-content {
padding: 1rem;
}
}
// Dark mode
.body--dark {
.poster-generator {
background: #1a1a1a;
}
.generator-header {
background: #2d2d2d;
}
.steps-indicator {
background: #2d2d2d;
border-color: #404040;
.step-number {
background: #333;
border-color: #555;
}
}
.form-section {
background: #2d2d2d;
}
}

View File

@@ -0,0 +1,391 @@
import { ref, reactive, computed, watch, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useQuasar } from 'quasar';
import { Api } from 'src/store/Api';
import type {
PosterContent,
PosterAssets,
PosterAsset,
PosterData,
GenerationStep
} from '../../types/poster.types';
interface Step {
id: string;
label: string;
icon: string;
}
const createDefaultContent = (): PosterContent => ({
title: '',
subtitle: '',
eventDate: '',
eventTime: '',
location: '',
contacts: '',
extraText: [],
customFields: {}
});
const createDefaultAssets = (): PosterAssets => ({
backgroundImage: null,
mainImage: null,
logos: []
});
export function usePosterGenerator() {
const router = useRouter();
const route = useRoute();
const $q = useQuasar();
// Steps configuration
const steps: Step[] = [
{ id: 'template', label: 'Template', icon: 'dashboard' },
{ id: 'content', label: 'Contenuti', icon: 'edit' },
{ id: 'images', label: 'Immagini', icon: 'image' },
{ id: 'export', label: 'Esporta', icon: 'download' }
];
// State
const currentStep = ref(0);
const maxReachedStep = ref(0);
const selectedTemplate = ref<any>(null);
const isLoading = ref(false);
const isGenerating = ref(false);
const showAiGenerator = ref(false);
const aiGeneratorTarget = ref<'backgroundImage' | 'mainImage'>('backgroundImage');
const posterData = reactive<PosterData>({
templateId: '',
name: '',
content: createDefaultContent(),
assets: createDefaultAssets(),
layerOverrides: {}
});
const generationSteps = ref<GenerationStep[]>([]);
const generationResult = ref<{ imageUrl: string; posterId: string } | null>(null);
// Computed
const stepLineStyle = computed(() => {
const progress = (currentStep.value / (steps.length - 1)) * 100;
return {
width: `${progress}%`
};
});
const canProceed = computed(() => {
switch (currentStep.value) {
case 0: // Template
return !!selectedTemplate.value;
case 1: // Content
return !!(posterData.content.title && posterData.content.eventDate && posterData.content.location);
case 2: // Images
return true; // Immagini opzionali
case 3: // Export
return true;
default:
return false;
}
});
// Methods
const handleBack = () => {
if (currentStep.value > 0) {
prevStep();
} else {
$q.dialog({
title: 'Uscire?',
message: 'I dati non salvati andranno persi. Vuoi uscire?',
cancel: true
}).onOk(() => {
router.push('/posters/Dashboard');
});
}
};
const nextStep = () => {
if (currentStep.value < steps.length - 1 && canProceed.value) {
currentStep.value++;
maxReachedStep.value = Math.max(maxReachedStep.value, currentStep.value);
}
};
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--;
}
};
const goToStep = (index: number) => {
if (index <= maxReachedStep.value) {
currentStep.value = index;
}
};
const selectTemplate = async (template: any) => {
selectedTemplate.value = template;
posterData.templateId = template._id;
posterData.name = `Poster - ${template.name}`;
// Auto-proceed se cliccato
setTimeout(() => {
if (canProceed.value) {
nextStep();
}
}, 300);
};
const updateContent = (content: Partial<PosterContent>) => {
Object.assign(posterData.content, content);
// Auto-update name
if (content.title && !posterData.name.includes(content.title)) {
posterData.name = content.title;
}
};
const updateAssets = (assets: Partial<PosterAssets>) => {
Object.assign(posterData.assets, assets);
};
const openAiGenerator = (assetType: 'backgroundImage' | 'mainImage') => {
aiGeneratorTarget.value = assetType;
showAiGenerator.value = true;
};
const getAiPromptHint = (assetType: string): string => {
if (!selectedTemplate.value?.defaultAiPromptHints) return '';
return selectedTemplate.value.defaultAiPromptHints[assetType] || '';
};
const onAiImageGenerated = (result: { url: string; aiParams: any }) => {
const asset: PosterAsset = {
sourceType: 'ai',
url: result.url,
aiParams: result.aiParams
};
if (aiGeneratorTarget.value === 'backgroundImage') {
posterData.assets.backgroundImage = asset;
} else if (aiGeneratorTarget.value === 'mainImage') {
posterData.assets.mainImage = asset;
}
showAiGenerator.value = false;
$q.notify({
type: 'positive',
message: 'Immagine generata con successo!',
icon: 'auto_awesome'
});
};
const generatePoster = async () => {
isGenerating.value = true;
generationResult.value = null;
// Setup generation steps
generationSteps.value = [
{ id: 'validate', label: 'Validazione dati', status: 'pending' },
{ id: 'upload', label: 'Upload assets', status: 'pending' },
{ id: 'render', label: 'Rendering poster', status: 'pending' },
{ id: 'save', label: 'Salvataggio', status: 'pending' }
];
try {
// Step 1: Validate
updateGenerationStep('validate', 'processing');
await delay(500);
if (!posterData.content.title || !posterData.content.eventDate) {
throw new Error('Compila tutti i campi obbligatori');
}
updateGenerationStep('validate', 'completed');
// Step 2: Upload assets
updateGenerationStep('upload', 'processing');
await delay(800);
updateGenerationStep('upload', 'completed');
// Step 3: Render
updateGenerationStep('render', 'processing');
const res = await Api.SendReq('/api/posters', 'POST', {
templateId: posterData.templateId,
name: posterData.name,
content: posterData.content,
assets: posterData.assets,
layerOverrides: posterData.layerOverrides,
autoRender: true
});
if (!res?.data?.success) {
throw new Error(res?.data?.error || 'Errore durante la generazione');
}
updateGenerationStep('render', 'completed');
// Step 4: Save
updateGenerationStep('save', 'processing');
await delay(500);
updateGenerationStep('save', 'completed');
// Success
generationResult.value = {
imageUrl: res.data.data.renderOutput?.png?.url || res.data.data.renderOutput?.jpg?.url,
posterId: res.data.data._id
};
$q.notify({
type: 'positive',
message: 'Locandina creata con successo! 🎉',
icon: 'celebration'
});
} catch (error: any) {
const failedStep = generationSteps.value.find(s => s.status === 'processing');
if (failedStep) {
updateGenerationStep(failedStep.id, 'error', error.message);
}
$q.notify({
type: 'negative',
message: error.message || 'Errore durante la generazione',
icon: 'error'
});
} finally {
isGenerating.value = false;
}
};
const updateGenerationStep = (
stepId: string,
status: GenerationStep['status'],
message?: string
) => {
const step = generationSteps.value.find(s => s.id === stepId);
if (step) {
step.status = status;
if (message) step.message = message;
}
};
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const downloadPoster = async (format: 'png' | 'jpg' = 'png') => {
if (!generationResult.value?.posterId) return;
try {
const url = `/api/posters/${generationResult.value.posterId}/download/${format}`;
// Trigger download
const link = document.createElement('a');
link.href = url;
link.download = `${posterData.name.replace(/[^a-z0-9]/gi, '_')}_poster.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
$q.notify({
type: 'positive',
message: 'Download avviato!',
icon: 'download'
});
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore durante il download'
});
}
};
const savePoster = async () => {
if (!generationResult.value?.posterId) return;
try {
await Api.SendReq(`/api/posters/${generationResult.value.posterId}/favorite`, 'POST');
$q.notify({
type: 'positive',
message: 'Poster salvato nei preferiti!',
icon: 'favorite'
});
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore durante il salvataggio'
});
}
};
const resetGenerator = () => {
$q.dialog({
title: 'Nuovo Poster',
message: 'Vuoi creare un nuovo poster? I dati attuali andranno persi.',
cancel: true
}).onOk(() => {
currentStep.value = 0;
maxReachedStep.value = 0;
selectedTemplate.value = null;
posterData.templateId = '';
posterData.name = '';
posterData.content = createDefaultContent();
posterData.assets = createDefaultAssets();
posterData.layerOverrides = {};
generationSteps.value = [];
generationResult.value = null;
});
};
// Load template if ID in URL
onMounted(async () => {
const templateId = route.query.template as string;
if (templateId) {
isLoading.value = true;
try {
const res = await Api.SendReq(`/api/templates/${templateId}`, 'GET');
if (res?.data?.success) {
selectTemplate(res.data.data);
}
} catch (error) {
console.error('Error loading template:', error);
} finally {
isLoading.value = false;
}
}
});
return {
// State
currentStep,
maxReachedStep,
steps,
stepLineStyle,
selectedTemplate,
posterData,
isLoading,
isGenerating,
generationSteps,
generationResult,
showAiGenerator,
aiGeneratorTarget,
canProceed,
// Methods
handleBack,
nextStep,
prevStep,
goToStep,
selectTemplate,
updateContent,
updateAssets,
openAiGenerator,
getAiPromptHint,
onAiImageGenerated,
generatePoster,
downloadPoster,
savePoster,
resetGenerator
};
}

View File

@@ -0,0 +1,207 @@
<template>
<div class="poster-generator">
<!-- Header -->
<header class="generator-header">
<div class="header-left">
<q-btn flat round icon="arrow_back" @click="handleBack" />
<div class="header-title">
<h1>Crea Locandina</h1>
<span class="subtitle" v-if="selectedTemplate">
Template: {{ selectedTemplate.name }}
</span>
</div>
</div>
<div class="header-actions">
<q-btn
v-if="currentStep > 0"
flat
icon="arrow_back"
label="Indietro"
@click="prevStep"
/>
<q-btn
v-if="currentStep < steps.length - 1"
color="primary"
icon-right="arrow_forward"
label="Avanti"
:disable="!canProceed"
@click="nextStep"
/>
<q-btn
v-if="currentStep === steps.length - 1"
color="positive"
icon="download"
label="Genera & Scarica"
:loading="isGenerating"
@click="generatePoster"
/>
</div>
</header>
<!-- Progress Steps -->
<div class="steps-indicator">
<div
v-for="(step, index) in steps"
:key="step.id"
class="step-item"
:class="{
'is-active': index === currentStep,
'is-completed': index < currentStep,
'is-clickable': index <= maxReachedStep
}"
@click="goToStep(index)"
>
<div class="step-number">
<q-icon v-if="index < currentStep" name="check" size="18px" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-label">{{ step.label }}</div>
</div>
<div class="step-line" :style="stepLineStyle" />
</div>
<!-- Main Content -->
<div class="generator-content">
<!-- Step 1: Template Selection -->
<transition name="slide-fade" mode="out-in">
<div v-if="currentStep === 0" key="step-1" class="step-content">
<TemplateSelector
:selected-template="selectedTemplate"
@select="selectTemplate"
/>
</div>
<!-- Step 2: Content -->
<div v-else-if="currentStep === 1" key="step-2" class="step-content step-with-preview">
<div class="form-section">
<ContentForm
:content="posterData.content"
:template="selectedTemplate"
@update="updateContent"
/>
</div>
<div class="preview-section">
<LivePreview
:template="selectedTemplate"
:content="posterData.content"
:assets="posterData.assets"
:layer-overrides="posterData.layerOverrides"
/>
</div>
</div>
<!-- Step 3: Images -->
<div v-else-if="currentStep === 2" key="step-3" class="step-content step-with-preview">
<div class="form-section">
<AssetManager
:assets="posterData.assets"
:template="selectedTemplate"
@update="updateAssets"
@generate-ai="openAiGenerator"
/>
</div>
<div class="preview-section">
<LivePreview
:template="selectedTemplate"
:content="posterData.content"
:assets="posterData.assets"
:layer-overrides="posterData.layerOverrides"
/>
</div>
</div>
<!-- Step 4: Review & Export -->
<div v-else-if="currentStep === 3" key="step-4" class="step-content step-with-preview">
<div class="form-section">
<ExportPanel
:poster-data="posterData"
:template="selectedTemplate"
:is-generating="isGenerating"
:generation-steps="generationSteps"
:result="generationResult"
@generate="generatePoster"
@download="downloadPoster"
@save="savePoster"
@reset="resetGenerator"
/>
</div>
<div class="preview-section preview-large">
<LivePreview
:template="selectedTemplate"
:content="posterData.content"
:assets="posterData.assets"
:layer-overrides="posterData.layerOverrides"
:final-image-url="generationResult?.imageUrl"
size="large"
/>
</div>
</div>
</transition>
</div>
<!-- AI Image Generator Dialog -->
<q-dialog v-model="showAiGenerator" persistent maximized transition-show="slide-up">
<AiImageGenerator
:asset-type="aiGeneratorTarget"
:template="selectedTemplate"
:initial-prompt="getAiPromptHint(aiGeneratorTarget)"
@generated="onAiImageGenerated"
@close="showAiGenerator = false"
/>
</q-dialog>
<!-- Loading Overlay -->
<q-inner-loading :showing="isLoading">
<q-spinner-gears size="80px" color="primary" />
<p class="q-mt-md text-h6">Caricamento...</p>
</q-inner-loading>
</div>
</template>
<script setup lang="ts">
import { usePosterGenerator } from './PosterGenerator';
import TemplateSelector from './components/TemplateSelector/TemplateSelector.vue';
import ContentForm from './components/ContentForm/ContentForm.vue';
import AssetManager from './components/AssetManager/AssetManager.vue';
import AiImageGenerator from './components/AiImageGenerator/AiImageGenerator.vue';
import LivePreview from './components/LivePreview/LivePreview.vue';
import ExportPanel from './components/ExportPanel/ExportPanel.vue';
const {
// State
currentStep,
maxReachedStep,
steps,
stepLineStyle,
selectedTemplate,
posterData,
isLoading,
isGenerating,
generationSteps,
generationResult,
showAiGenerator,
aiGeneratorTarget,
canProceed,
// Methods
handleBack,
nextStep,
prevStep,
goToStep,
selectTemplate,
updateContent,
updateAssets,
openAiGenerator,
getAiPromptHint,
onAiImageGenerated,
generatePoster,
downloadPoster,
savePoster,
resetGenerator
} = usePosterGenerator();
</script>
<style lang="scss" scoped>
@import './PosterGenerator.scss';
</style>

View File

@@ -0,0 +1,749 @@
<template>
<q-card class="ai-generator-dialog">
<!-- Header -->
<q-card-section class="dialog-header">
<div class="header-content">
<q-icon name="auto_awesome" size="32px" color="amber" />
<div>
<h2>Genera Immagine con AI</h2>
<p>{{ assetTypeLabel }}</p>
</div>
</div>
<q-btn flat round icon="close" @click="$emit('close')" />
</q-card-section>
<q-separator />
<!-- Content -->
<q-card-section class="dialog-body">
<div class="generator-layout">
<!-- Left: Form -->
<div class="form-panel">
<!-- Provider Selection -->
<div class="form-section">
<label class="section-label">Provider AI</label>
<div class="provider-options">
<div
v-for="provider in providers"
:key="provider.value"
class="provider-option"
:class="{ 'is-selected': selectedProvider === provider.value }"
@click="selectedProvider = provider.value"
>
<q-icon :name="provider.icon" size="24px" />
<div class="provider-info">
<span class="provider-name">{{ provider.label }}</span>
<q-badge
v-if="provider.free"
color="green"
text-color="white"
label="Gratis"
/>
</div>
</div>
</div>
</div>
<!-- Prompt -->
<div class="form-section">
<label class="section-label">
Descrivi l'immagine
<q-btn
flat
dense
size="sm"
icon="help_outline"
@click="showPromptTips = true"
>
<q-tooltip>Suggerimenti per prompt efficaci</q-tooltip>
</q-btn>
</label>
<q-input
v-model="prompt"
filled
type="textarea"
rows="4"
placeholder="Descrivi l'immagine che vuoi generare..."
counter
maxlength="1000"
/>
<!-- Quick prompts -->
<div class="quick-prompts">
<span class="quick-label">Suggerimenti rapidi:</span>
<q-chip
v-for="(suggestion, idx) in promptSuggestions"
:key="idx"
clickable
size="sm"
@click="appendToPrompt(suggestion)"
>
{{ suggestion }}
</q-chip>
</div>
</div>
<!-- Negative Prompt -->
<div class="form-section">
<label class="section-label">
Prompt Negativo
<span class="optional">(opzionale)</span>
</label>
<q-input
v-model="negativePrompt"
filled
type="textarea"
rows="2"
placeholder="Cosa NON vuoi vedere nell'immagine..."
/>
<div class="negative-presets">
<q-btn
flat
dense
size="sm"
label="Usa preset standard"
@click="useStandardNegative"
/>
</div>
</div>
<!-- Aspect Ratio -->
<div class="form-section">
<label class="section-label">Formato</label>
<q-btn-toggle
v-model="aspectRatio"
:options="aspectRatioOptions"
spread
no-caps
toggle-color="primary"
class="full-width"
/>
</div>
<!-- Advanced Options -->
<q-expansion-item
icon="tune"
label="Opzioni Avanzate"
header-class="advanced-header"
>
<div class="advanced-options">
<div class="options-row">
<q-input
v-model.number="seed"
type="number"
filled
dense
label="Seed"
hint="Lascia vuoto per casuale"
/>
<q-input
v-model.number="steps"
type="number"
filled
dense
label="Steps"
:min="10"
:max="50"
/>
<q-input
v-model.number="cfg"
type="number"
filled
dense
label="CFG Scale"
:min="1"
:max="20"
step="0.5"
/>
</div>
</div>
</q-expansion-item>
<!-- Generate Button -->
<div class="generate-actions">
<q-btn
color="primary"
icon="auto_awesome"
label="Genera Immagine"
size="lg"
:loading="isGenerating"
:disable="!prompt.trim()"
class="full-width"
@click="generateImage"
/>
</div>
</div>
<!-- Right: Preview -->
<div class="preview-panel">
<div class="preview-container" :class="{ 'has-image': !!generatedImage }">
<template v-if="isGenerating">
<div class="generating-state">
<q-spinner-orbit size="80px" color="primary" />
<p>Generazione in corso...</p>
<span class="time-estimate">Tempo stimato: ~15-30 secondi</span>
</div>
</template>
<template v-else-if="generatedImage">
<img :src="generatedImage" alt="Generated image" class="preview-image" />
<div class="preview-actions">
<q-btn
round
color="white"
text-color="primary"
icon="refresh"
@click="generateImage"
>
<q-tooltip>Rigenera</q-tooltip>
</q-btn>
<q-btn
round
color="green"
icon="check"
@click="confirmImage"
>
<q-tooltip>Usa questa immagine</q-tooltip>
</q-btn>
</div>
</template>
<template v-else>
<div class="empty-preview">
<q-icon name="image" size="80px" color="grey-4" />
<p>L'immagine generata apparirà qui</p>
</div>
</template>
</div>
<!-- Generation History -->
<div v-if="history.length > 0" class="generation-history">
<h4>Generazioni recenti</h4>
<div class="history-grid">
<div
v-for="(item, idx) in history"
:key="idx"
class="history-item"
@click="selectFromHistory(item)"
>
<img :src="item.url" :alt="`Generation ${idx + 1}`" />
</div>
</div>
</div>
</div>
</div>
</q-card-section>
<!-- Prompt Tips Dialog -->
<q-dialog v-model="showPromptTips">
<q-card style="max-width: 500px">
<q-card-section>
<div class="text-h6">💡 Suggerimenti per Prompt Efficaci</div>
</q-card-section>
<q-card-section>
<ul class="tips-list">
<li><strong>Sii specifico:</strong> Descrivi dettagli come colori, stile, atmosfera</li>
<li><strong>Indica lo stile:</strong> "fotorealistico", "illustrazione", "acquerello"</li>
<li><strong>Specifica la qualità:</strong> "high quality", "4k", "detailed"</li>
<li><strong>Evita il testo:</strong> Aggiungi sempre "no text, no letters"</li>
<li><strong>Composizione:</strong> Indica "central composition", "clean layout"</li>
</ul>
<div class="example-prompt q-mt-md">
<strong>Esempio:</strong>
<p class="q-mt-sm">"Mystical autumn forest at golden hour, morning mist between oak trees, photorealistic, cinematic lighting, warm colors, high quality, no text, no letters"</p>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Capito!" color="primary" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</q-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import { Api } from 'src/store/Api';
import { PROVIDER_OPTIONS, ASPECT_RATIO_OPTIONS } from '../../../../types/poster.types';
const props = defineProps<{
assetType: 'backgroundImage' | 'mainImage';
template: any;
initialPrompt?: string;
}>();
const emit = defineEmits<{
(e: 'generated', result: { url: string; aiParams: any }): void;
(e: 'close'): void;
}>();
const $q = useQuasar();
// State
const selectedProvider = ref('hf');
const prompt = ref('');
const negativePrompt = ref('');
const aspectRatio = ref('9:16');
const seed = ref<number | null>(null);
const steps = ref(28);
const cfg = ref(7.5);
const isGenerating = ref(false);
const generatedImage = ref<string | null>(null);
const history = ref<{ url: string; prompt: string }[]>([]);
const showPromptTips = ref(false);
// Computed
const assetTypeLabel = computed(() => {
return props.assetType === 'backgroundImage' ? 'Immagine di sfondo' : 'Immagine principale';
});
const providers = computed(() => PROVIDER_OPTIONS);
const aspectRatioOptions = computed(() =>
ASPECT_RATIO_OPTIONS.map(opt => ({
label: opt.label,
value: opt.value
}))
);
const promptSuggestions = computed(() => {
const suggestions = [
'high quality, 4k',
'cinematic lighting',
'no text, no letters',
'photorealistic',
'warm colors',
'dramatic atmosphere'
];
// Add template-specific suggestions
if (props.template?.defaultAiPromptHints?.[props.assetType]) {
const hint = props.template.defaultAiPromptHints[props.assetType];
const words = hint.split(',').slice(0, 3).map((w: string) => w.trim());
return [...words, ...suggestions.slice(0, 3)];
}
return suggestions;
});
// Methods
const appendToPrompt = (text: string) => {
if (prompt.value) {
prompt.value += ', ' + text;
} else {
prompt.value = text;
}
};
const useStandardNegative = () => {
negativePrompt.value = 'text, letters, words, watermark, signature, blurry, low quality, distorted, ugly, bad anatomy, disfigured';
};
const generateImage = async () => {
if (!prompt.value.trim()) {
$q.notify({
type: 'warning',
message: 'Inserisci una descrizione per l\'immagine'
});
return;
}
isGenerating.value = true;
generatedImage.value = null;
try {
const res = await Api.SendReq('/api/assets/generate-ai', 'POST', {
prompt: prompt.value,
negativePrompt: negativePrompt.value,
provider: selectedProvider.value,
aspectRatio: aspectRatio.value,
category: props.assetType === 'backgroundImage' ? 'background' : 'main',
seed: seed.value,
steps: steps.value,
cfg: cfg.value
});
if (res?.data?.success) {
generatedImage.value = res.data.data.file.url;
// Add to history
history.value.unshift({
url: res.data.data.file.url,
prompt: prompt.value
});
// Keep only last 6
if (history.value.length > 6) {
history.value = history.value.slice(0, 6);
}
$q.notify({
type: 'positive',
message: 'Immagine generata con successo!',
icon: 'auto_awesome'
});
} else {
throw new Error(res?.data?.error || 'Errore durante la generazione');
}
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'Errore durante la generazione',
icon: 'error'
});
} finally {
isGenerating.value = false;
}
};
const confirmImage = () => {
if (!generatedImage.value) return;
emit('generated', {
url: generatedImage.value,
aiParams: {
prompt: prompt.value,
negativePrompt: negativePrompt.value,
provider: selectedProvider.value,
aspectRatio: aspectRatio.value,
seed: seed.value,
steps: steps.value,
cfg: cfg.value
}
});
};
const selectFromHistory = (item: { url: string; prompt: string }) => {
generatedImage.value = item.url;
prompt.value = item.prompt;
};
// Initialize with hint
onMounted(() => {
if (props.initialPrompt) {
prompt.value = props.initialPrompt;
}
useStandardNegative();
});
</script>
<style lang="scss" scoped>
.ai-generator-dialog {
width: 100%;
max-width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
.header-content {
display: flex;
align-items: center;
gap: 1rem;
h2 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.85rem;
color: #888;
}
}
}
.dialog-body {
flex: 1;
overflow: hidden;
padding: 0;
}
.generator-layout {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
// Form Panel
.form-panel {
padding: 1.5rem;
overflow-y: auto;
border-right: 1px solid #e0e0e0;
}
.form-section {
margin-bottom: 1.5rem;
}
.section-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: #555;
margin-bottom: 0.5rem;
.optional {
font-weight: 400;
color: #999;
font-size: 0.8rem;
}
}
// Provider Options
.provider-options {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.provider-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
flex: 1;
min-width: 140px;
&:hover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
&.is-selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.provider-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.provider-name {
font-weight: 500;
font-size: 0.9rem;
}
}
// Quick Prompts
.quick-prompts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
.quick-label {
font-size: 0.8rem;
color: #888;
}
}
.negative-presets {
margin-top: 0.5rem;
}
.advanced-header {
background: #fafafa;
}
.advanced-options {
padding: 1rem;
}
.options-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.generate-actions {
margin-top: 2rem;
}
.full-width {
width: 100%;
}
// Preview Panel
.preview-panel {
padding: 1.5rem;
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 16px;
overflow: hidden;
min-height: 400px;
position: relative;
&.has-image {
padding: 0;
}
}
.generating-state,
.empty-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: #888;
p {
margin: 1rem 0 0.5rem;
font-size: 1.1rem;
}
.time-estimate {
font-size: 0.85rem;
color: #aaa;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.preview-actions {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: rgba(0, 0, 0, 0.7);
border-radius: 30px;
}
// History
.generation-history {
margin-top: 1.5rem;
h4 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: #666;
}
}
.history-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
@media (max-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.history-item {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
opacity: 0.7;
transition: all 0.2s;
&:hover {
opacity: 1;
transform: scale(1.05);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Tips Dialog
.tips-list {
margin: 0;
padding-left: 1.25rem;
li {
margin-bottom: 0.75rem;
}
}
.example-prompt {
background: #f5f5f5;
padding: 1rem;
border-radius: 8px;
p {
margin: 0;
font-style: italic;
color: #666;
}
}
// Dark mode
.body--dark {
.form-panel {
border-color: #404040;
}
.provider-option {
border-color: #444;
&:hover,
&.is-selected {
border-color: #667eea;
background: rgba(102, 126, 234, 0.15);
}
}
.preview-panel {
background: #252525;
}
.preview-container {
background: #333;
}
.advanced-header {
background: #333;
}
.example-prompt {
background: #333;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<div class="asset-manager">
<div class="panel-header">
<q-icon name="collections" size="28px" color="primary" />
<div>
<h3>Immagini</h3>
<p>Carica o genera con AI le immagini del poster</p>
</div>
</div>
<div class="assets-body">
<!-- Background Image -->
<div class="asset-section">
<div class="section-header">
<div class="section-title">
<q-icon name="wallpaper" />
<span>Sfondo</span>
</div>
<q-chip size="sm" :color="assets.backgroundImage ? 'green' : 'grey'" text-color="white">
{{ assets.backgroundImage ? 'Impostato' : 'Opzionale' }}
</q-chip>
</div>
<AssetUploader
:asset="assets.backgroundImage"
placeholder-icon="wallpaper"
placeholder-text="Trascina un'immagine o clicca per caricare"
accept="image/*"
@upload="file => uploadAsset('backgroundImage', file)"
@remove="removeAsset('backgroundImage')"
@generate-ai="$emit('generate-ai', 'backgroundImage')"
/>
</div>
<!-- Main Image -->
<div class="asset-section">
<div class="section-header">
<div class="section-title">
<q-icon name="image" />
<span>Immagine Principale</span>
</div>
<q-chip size="sm" :color="assets.mainImage ? 'green' : 'grey'" text-color="white">
{{ assets.mainImage ? 'Impostato' : 'Opzionale' }}
</q-chip>
</div>
<AssetUploader
:asset="assets.mainImage"
placeholder-icon="add_photo_alternate"
placeholder-text="Aggiungi immagine principale"
accept="image/*"
@upload="file => uploadAsset('mainImage', file)"
@remove="removeAsset('mainImage')"
@generate-ai="$emit('generate-ai', 'mainImage')"
/>
</div>
<!-- Logos -->
<div class="asset-section" v-if="hasLogoSlots">
<div class="section-header">
<div class="section-title">
<q-icon name="branding_watermark" />
<span>Loghi (max {{ maxLogos }})</span>
</div>
<q-chip size="sm" :color="assets.logos.length > 0 ? 'green' : 'grey'" text-color="white">
{{ assets.logos.length }} / {{ maxLogos }}
</q-chip>
</div>
<div class="logos-grid">
<div
v-for="(logo, index) in assets.logos"
:key="index"
class="logo-item"
>
<img :src="logo.url" :alt="`Logo ${index + 1}`" />
<q-btn
round
flat
size="sm"
icon="close"
color="negative"
class="remove-btn"
@click="removeLogo(index)"
/>
</div>
<div
v-if="assets.logos.length < maxLogos"
class="logo-add"
@click="triggerLogoUpload"
>
<q-icon name="add" size="32px" color="grey-5" />
<span>Aggiungi logo</span>
<input
ref="logoInputRef"
type="file"
accept="image/*"
hidden
@change="handleLogoUpload"
/>
</div>
</div>
</div>
<!-- AI Generation Tip -->
<div class="ai-tip">
<q-icon name="auto_awesome" size="24px" color="amber" />
<div>
<strong>Suggerimento AI</strong>
<p>Clicca "Genera con AI" per creare immagini uniche basate sul tema del template.</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useQuasar } from 'quasar';
import { Api } from 'src/store/Api';
import type { PosterAssets, PosterAsset } from '../../../../types/poster.types';
import AssetUploader from './AssetUploader.vue';
const props = defineProps<{
assets: PosterAssets;
template: any;
}>();
const emit = defineEmits<{
(e: 'update', assets: Partial<PosterAssets>): void;
(e: 'generate-ai', type: 'backgroundImage' | 'mainImage'): void;
}>();
const $q = useQuasar();
const logoInputRef = ref<HTMLInputElement | null>(null);
const hasLogoSlots = computed(() => props.template?.logoSlots?.enabled);
const maxLogos = computed(() => props.template?.logoSlots?.maxCount || 3);
const uploadAsset = async (type: 'backgroundImage' | 'mainImage', file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('category', type === 'backgroundImage' ? 'background' : 'main');
const res = await Api.SendReq('/api/assets/upload', 'POST', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res?.data?.success) {
const asset: PosterAsset = {
sourceType: 'upload',
url: res.data.data.file.url,
thumbnailUrl: res.data.data.file.thumbnailUrl,
originalName: file.name,
mimeType: file.type,
size: file.size
};
emit('update', { [type]: asset });
$q.notify({
type: 'positive',
message: 'Immagine caricata!'
});
}
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'Errore durante il caricamento'
});
}
};
const removeAsset = (type: 'backgroundImage' | 'mainImage') => {
emit('update', { [type]: null });
};
const triggerLogoUpload = () => {
logoInputRef.value?.click();
};
const handleLogoUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('category', 'logo');
const res = await Api.SendReq('/api/assets/upload', 'POST', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (res?.data?.success) {
const logo: PosterAsset = {
sourceType: 'upload',
url: res.data.data.file.url,
originalName: file.name
};
emit('update', {
logos: [...props.assets.logos, logo]
});
$q.notify({
type: 'positive',
message: 'Logo aggiunto!'
});
}
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.message || 'Errore durante il caricamento'
});
}
// Reset input
input.value = '';
};
const removeLogo = (index: number) => {
const newLogos = [...props.assets.logos];
newLogos.splice(index, 1);
emit('update', { logos: newLogos });
};
</script>
<style lang="scss" scoped>
.asset-manager {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(118, 75, 162, 0.05));
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.85rem;
color: #888;
}
}
.assets-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.asset-section {
margin-bottom: 2rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #333;
}
.logos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 1rem;
}
.logo-item {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
border: 2px solid #e0e0e0;
img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 0.5rem;
}
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
background: white;
}
}
.logo-add {
aspect-ratio: 1;
border: 2px dashed #ddd;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s;
span {
font-size: 0.75rem;
color: #888;
}
&:hover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
}
.ai-tip {
display: flex;
gap: 1rem;
padding: 1rem;
background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 152, 0, 0.1));
border-radius: 12px;
margin-top: 1rem;
strong {
display: block;
margin-bottom: 0.25rem;
}
p {
margin: 0;
font-size: 0.85rem;
color: #666;
}
}
// Dark mode
.body--dark {
.panel-header,
.section-header {
border-color: #404040;
}
.section-title {
color: #eee;
}
.logo-item {
background: #333;
border-color: #555;
}
.logo-add {
border-color: #555;
&:hover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
}
}
</style>

View File

@@ -0,0 +1,293 @@
<template>
<div
class="asset-uploader"
:class="{
'has-asset': !!asset,
'is-dragging': isDragging,
'is-uploading': isUploading
}"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerUpload"
>
<!-- Has Asset -->
<template v-if="asset">
<div class="asset-preview">
<img :src="asset.thumbnailUrl || asset.url" :alt="asset.originalName" />
<div class="asset-overlay">
<div class="asset-actions">
<q-btn
round
color="white"
text-color="primary"
icon="refresh"
@click.stop="triggerUpload"
>
<q-tooltip>Sostituisci</q-tooltip>
</q-btn>
<q-btn
round
color="white"
text-color="negative"
icon="delete"
@click.stop="$emit('remove')"
>
<q-tooltip>Rimuovi</q-tooltip>
</q-btn>
</div>
</div>
<div class="asset-badge" v-if="asset.sourceType === 'ai'">
<q-icon name="auto_awesome" size="14px" />
AI Generated
</div>
</div>
<div class="asset-info">
<span class="asset-name">{{ asset.originalName || 'Immagine' }}</span>
<span class="asset-size" v-if="asset.size">{{ formatSize(asset.size) }}</span>
</div>
</template>
<!-- No Asset -->
<template v-else>
<div class="upload-placeholder">
<q-icon :name="placeholderIcon" size="48px" color="grey-5" />
<p>{{ placeholderText }}</p>
<div class="upload-actions">
<q-btn
outline
color="primary"
icon="upload"
label="Carica"
@click.stop="triggerUpload"
/>
<q-btn
color="amber"
text-color="dark"
icon="auto_awesome"
label="Genera con AI"
@click.stop="$emit('generate-ai')"
/>
</div>
</div>
</template>
<!-- Hidden File Input -->
<input
ref="fileInputRef"
type="file"
:accept="accept"
hidden
@change="handleFileSelect"
/>
<!-- Upload Progress -->
<div v-if="isUploading" class="upload-progress">
<q-spinner-dots size="40px" color="primary" />
<span>Caricamento in corso...</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { PosterAsset } from '../../../../types/poster.types';
const props = defineProps<{
asset: PosterAsset | null;
placeholderIcon?: string;
placeholderText?: string;
accept?: string;
}>();
const emit = defineEmits<{
(e: 'upload', file: File): void;
(e: 'remove'): void;
(e: 'generate-ai'): void;
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const isDragging = ref(false);
const isUploading = ref(false);
const triggerUpload = () => {
fileInputRef.value?.click();
};
const handleFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
emit('upload', file);
}
input.value = '';
};
const handleDrop = (event: DragEvent) => {
isDragging.value = false;
const file = event.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) {
emit('upload', file);
}
};
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
</script>
<style lang="scss" scoped>
.asset-uploader {
border: 2px dashed #ddd;
border-radius: 16px;
overflow: hidden;
transition: all 0.3s;
cursor: pointer;
position: relative;
&:hover:not(.has-asset) {
border-color: #667eea;
background: rgba(102, 126, 234, 0.03);
}
&.is-dragging {
border-color: #667eea;
background: rgba(102, 126, 234, 0.1);
transform: scale(1.02);
}
&.has-asset {
border-style: solid;
border-color: #e0e0e0;
cursor: default;
}
}
.upload-placeholder {
padding: 2.5rem 1.5rem;
text-align: center;
p {
margin: 1rem 0;
color: #888;
font-size: 0.9rem;
}
.upload-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
}
}
.asset-preview {
position: relative;
aspect-ratio: 16/9;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.asset-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
.asset-uploader:hover & {
opacity: 1;
}
}
.asset-actions {
display: flex;
gap: 1rem;
}
.asset-badge {
position: absolute;
top: 0.75rem;
left: 0.75rem;
background: rgba(255, 193, 7, 0.9);
color: #333;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.25rem;
}
.asset-info {
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
background: #fafafa;
border-top: 1px solid #e0e0e0;
.asset-name {
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
}
.asset-size {
font-size: 0.8rem;
color: #888;
}
}
.upload-progress {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
span {
color: #666;
font-size: 0.9rem;
}
}
// Dark mode
.body--dark {
.asset-uploader {
border-color: #555;
&.has-asset {
border-color: #444;
}
}
.asset-info {
background: #333;
border-color: #444;
}
.upload-progress {
background: rgba(30, 30, 30, 0.95);
}
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<div class="content-form">
<div class="form-header">
<q-icon name="edit_note" size="28px" color="primary" />
<div>
<h3>Contenuti Locandina</h3>
<p>Inserisci i testi che appariranno nel poster</p>
</div>
</div>
<q-form class="form-body" @submit.prevent>
<!-- Title -->
<div class="form-section">
<label class="section-label required">Titolo Evento</label>
<q-input
:model-value="content.title"
@update:model-value="update('title', $event)"
filled
placeholder="es. SAGRA DEL FUNGO PORCINO"
:rules="[val => !!val || 'Il titolo è obbligatorio']"
counter
maxlength="100"
>
<template #prepend>
<q-icon name="title" />
</template>
</q-input>
</div>
<!-- Subtitle -->
<div class="form-section">
<label class="section-label">Sottotitolo / Claim</label>
<q-input
:model-value="content.subtitle"
@update:model-value="update('subtitle', $event)"
filled
placeholder="es. XXV Edizione - Tradizione e Sapori"
counter
maxlength="150"
>
<template #prepend>
<q-icon name="short_text" />
</template>
</q-input>
</div>
<!-- Date & Time Row -->
<div class="form-row">
<div class="form-section flex-2">
<label class="section-label required">Data Evento</label>
<q-input
:model-value="content.eventDate"
@update:model-value="update('eventDate', $event)"
filled
placeholder="es. 15-16-17 Ottobre 2025"
:rules="[val => !!val || 'La data è obbligatoria']"
>
<template #prepend>
<q-icon name="event" />
</template>
<template #append>
<q-btn flat dense icon="calendar_today" @click="showDatePicker = true">
<q-popup-proxy>
<q-date
:model-value="parsedDate"
@update:model-value="setDateFromPicker"
mask="DD MMMM YYYY"
:locale="dateLocale"
/>
</q-popup-proxy>
</q-btn>
</template>
</q-input>
</div>
<div class="form-section flex-1">
<label class="section-label">Ora</label>
<q-input
:model-value="content.eventTime"
@update:model-value="update('eventTime', $event)"
filled
placeholder="es. 10:00 - 23:00"
mask="##:##"
>
<template #prepend>
<q-icon name="schedule" />
</template>
</q-input>
</div>
</div>
<!-- Location -->
<div class="form-section">
<label class="section-label required">Luogo</label>
<q-input
:model-value="content.location"
@update:model-value="update('location', $event)"
filled
placeholder="es. Parco delle Querce, Borgo Montano (PG)"
:rules="[val => !!val || 'Il luogo è obbligatorio']"
>
<template #prepend>
<q-icon name="place" />
</template>
</q-input>
</div>
<!-- Contacts -->
<div class="form-section">
<label class="section-label">Contatti</label>
<q-input
:model-value="content.contacts"
@update:model-value="update('contacts', $event)"
filled
placeholder="es. Tel: 0742 123456 | info@evento.it"
type="textarea"
rows="2"
autogrow
>
<template #prepend>
<q-icon name="contact_phone" />
</template>
</q-input>
</div>
<!-- Extra Text -->
<div class="form-section">
<label class="section-label">Informazioni Aggiuntive</label>
<q-input
:model-value="extraTextJoined"
@update:model-value="updateExtraText($event)"
filled
placeholder="es. Ingresso Libero • Stand Gastronomici • Musica dal Vivo"
hint="Separa con • oppure vai a capo per più righe"
type="textarea"
rows="2"
autogrow
>
<template #prepend>
<q-icon name="notes" />
</template>
</q-input>
</div>
<!-- Custom Fields (if template has customText layers) -->
<template v-if="customTextLayers.length > 0">
<q-separator class="q-my-lg" />
<div class="section-title">
<q-icon name="extension" />
Campi Personalizzati
</div>
<div
v-for="layer in customTextLayers"
:key="layer.id"
class="form-section"
>
<label class="section-label">{{ layer.defaultValue || layer.id }}</label>
<q-input
:model-value="content.customFields[layer.id] || ''"
@update:model-value="updateCustomField(layer.id, $event)"
filled
:placeholder="layer.defaultValue"
/>
</div>
</template>
</q-form>
<!-- Quick Fill Button -->
<div class="form-footer">
<q-btn
flat
color="grey"
icon="auto_fix_high"
label="Compila con esempio"
@click="fillWithExample"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { PosterContent } from '../../../../types/poster.types';
const props = defineProps<{
content: PosterContent;
template: any;
}>();
const emit = defineEmits<{
(e: 'update', content: Partial<PosterContent>): void;
}>();
const showDatePicker = ref(false);
const dateLocale = {
days: ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'],
daysShort: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'],
months: ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'],
monthsShort: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic']
};
const customTextLayers = computed(() => {
if (!props.template?.layers) return [];
return props.template.layers.filter((l: any) => l.type === 'customText');
});
const extraTextJoined = computed(() => {
return props.content.extraText?.join(' • ') || '';
});
const parsedDate = computed(() => {
// Try to parse existing date
return null;
});
const update = (key: keyof PosterContent, value: any) => {
emit('update', { [key]: value });
};
const updateExtraText = (value: string) => {
const parts = value.split(/[•\n]/).map(s => s.trim()).filter(Boolean);
emit('update', { extraText: parts });
};
const updateCustomField = (fieldId: string, value: string) => {
emit('update', {
customFields: {
...props.content.customFields,
[fieldId]: value
}
});
};
const setDateFromPicker = (date: string) => {
update('eventDate', date);
};
const fillWithExample = () => {
const templateType = props.template?.templateType || '';
const examples: Record<string, Partial<PosterContent>> = {
'outdoor-nature': {
title: 'ESCURSIONE NEL BOSCO',
subtitle: 'Alla scoperta dei segreti della natura',
eventDate: '21 Settembre 2025',
eventTime: '09:00',
location: 'Riserva Naturale Monte Cucco, Sigillo (PG)',
contacts: 'Prenotazioni: 333 1234567 | info@escursioni.it',
extraText: ['Pranzo al sacco incluso', 'Difficoltà: media', 'Posti limitati']
},
'workshop-craft': {
title: 'LABORATORIO DI CERAMICA',
subtitle: 'Impara l\'arte del tornio',
eventDate: '8-9 Novembre 2025',
eventTime: '10:00 - 17:00',
location: 'Bottega Creativa, Via Roma 45, Deruta (PG)',
contacts: 'Info: ceramica@bottega.it | 075 9876543',
extraText: ['Materiali inclusi', 'Adatto a principianti', 'Max 8 partecipanti']
},
default: {
title: 'FESTA DI PAESE',
subtitle: 'Tradizione, musica e buon cibo',
eventDate: '15-16-17 Agosto 2025',
eventTime: '18:00 - 24:00',
location: 'Piazza Centrale, Borgo Antico',
contacts: 'Pro Loco: 333 9876543 | festa@proloco.it',
extraText: ['Ingresso Libero', 'Stand Gastronomici', 'Musica dal Vivo']
}
};
const example = examples[templateType] || examples.default;
emit('update', example);
};
</script>
<style lang="scss" scoped>
.content-form {
display: flex;
flex-direction: column;
height: 100%;
}
.form-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(118, 75, 162, 0.05));
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.85rem;
color: #888;
}
}
.form-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.form-section {
margin-bottom: 1.25rem;
}
.form-row {
display: flex;
gap: 1rem;
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
@media (max-width: 600px) {
flex-direction: column;
}
}
.section-label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: #555;
margin-bottom: 0.5rem;
&.required::after {
content: ' *';
color: #e74c3c;
}
}
.section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #667eea;
margin-bottom: 1rem;
}
.form-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
}
// Dark mode
.body--dark {
.form-header {
border-color: #404040;
}
.section-label {
color: #bbb;
}
.form-footer {
border-color: #404040;
}
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="export-panel">
<div class="panel-header">
<q-icon name="download" size="28px" color="primary" />
<div>
<h3>Esporta Locandina</h3>
<p>Genera e scarica il tuo poster</p>
</div>
</div>
<div class="panel-body">
<!-- Summary -->
<div class="summary-section">
<h4>Riepilogo</h4>
<div class="summary-grid">
<div class="summary-item">
<q-icon name="title" color="primary" />
<div>
<label>Titolo</label>
<span>{{ posterData.content.title || '—' }}</span>
</div>
</div>
<div class="summary-item">
<q-icon name="event" color="primary" />
<div>
<label>Data</label>
<span>{{ posterData.content.eventDate || '—' }}</span>
</div>
</div>
<div class="summary-item">
<q-icon name="place" color="primary" />
<div>
<label>Luogo</label>
<span>{{ posterData.content.location || '—' }}</span>
</div>
</div>
<div class="summary-item">
<q-icon name="dashboard" color="primary" />
<div>
<label>Template</label>
<span>{{ template?.name || '—' }}</span>
</div>
</div>
<div class="summary-item">
<q-icon name="aspect_ratio" color="primary" />
<div>
<label>Formato</label>
<span>{{ template?.format?.width }} × {{ template?.format?.height }} px</span>
</div>
</div>
<div class="summary-item">
<q-icon name="image" color="primary" />
<div>
<label>Immagini</label>
<span>
{{ imageCount }} immagine/i
<template v-if="aiImageCount > 0">
({{ aiImageCount }} AI)
</template>
</span>
</div>
</div>
</div>
</div>
<q-separator class="q-my-lg" />
<!-- Generation Status -->
<div v-if="generationSteps.length > 0" class="generation-status">
<h4>Stato Generazione</h4>
<div class="steps-list">
<div
v-for="step in generationSteps"
:key="step.id"
class="step-item"
:class="`status-${step.status}`"
>
<div class="step-icon">
<q-spinner-dots v-if="step.status === 'processing'" size="20px" color="primary" />
<q-icon v-else-if="step.status === 'completed'" name="check_circle" color="positive" />
<q-icon v-else-if="step.status === 'error'" name="error" color="negative" />
<q-icon v-else name="radio_button_unchecked" color="grey" />
</div>
<div class="step-info">
<span class="step-label">{{ step.label }}</span>
<span v-if="step.message" class="step-message">{{ step.message }}</span>
</div>
</div>
</div>
</div>
<!-- Result -->
<div v-if="result" class="result-section">
<div class="result-success">
<q-icon name="celebration" size="48px" color="amber" />
<h3>Locandina Pronta! 🎉</h3>
<p>La tua locandina è stata generata con successo</p>
</div>
<div class="download-options">
<q-btn
color="positive"
icon="download"
label="Scarica PNG"
size="lg"
@click="$emit('download', 'png')"
/>
<q-btn
outline
color="primary"
icon="download"
label="Scarica JPG"
size="lg"
@click="$emit('download', 'jpg')"
/>
</div>
<div class="extra-actions">
<q-btn
flat
color="amber"
icon="favorite"
label="Salva nei preferiti"
@click="$emit('save')"
/>
<q-btn
flat
color="grey"
icon="share"
label="Condividi"
@click="shareResult"
/>
</div>
</div>
<!-- Generate Button (if not yet generated) -->
<div v-else class="generate-section">
<q-btn
color="primary"
icon="auto_awesome"
label="Genera Locandina"
size="xl"
:loading="isGenerating"
class="generate-btn"
@click="$emit('generate')"
/>
<p class="generate-hint">
<q-icon name="info" size="16px" />
La generazione richiede circa 10-30 secondi
</p>
</div>
<!-- New Poster -->
<div v-if="result" class="new-poster-section">
<q-separator class="q-my-lg" />
<q-btn
flat
color="grey"
icon="add"
label="Crea nuovo poster"
@click="$emit('reset')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useQuasar } from 'quasar';
import type { PosterData, GenerationStep } from '../../../../types/poster.types';
const props = defineProps<{
posterData: PosterData;
template: any;
isGenerating: boolean;
generationSteps: GenerationStep[];
result: { imageUrl: string; posterId: string } | null;
}>();
const emit = defineEmits<{
(e: 'generate'): void;
(e: 'download', format: 'png' | 'jpg'): void;
(e: 'save'): void;
(e: 'reset'): void;
}>();
const $q = useQuasar();
const imageCount = computed(() => {
let count = 0;
if (props.posterData.assets.backgroundImage) count++;
if (props.posterData.assets.mainImage) count++;
count += props.posterData.assets.logos.length;
return count;
});
const aiImageCount = computed(() => {
let count = 0;
if (props.posterData.assets.backgroundImage?.sourceType === 'ai') count++;
if (props.posterData.assets.mainImage?.sourceType === 'ai') count++;
return count;
});
const shareResult = async () => {
if (!props.result?.imageUrl) return;
try {
if (navigator.share) {
await navigator.share({
title: props.posterData.name,
text: `Guarda la mia locandina: ${props.posterData.content.title}`,
url: window.location.href
});
} else {
// Fallback: copy link
await navigator.clipboard.writeText(window.location.href);
$q.notify({
type: 'positive',
message: 'Link copiato negli appunti!'
});
}
} catch (error) {
console.error('Share error:', error);
}
};
</script>
<style lang="scss" scoped>
.export-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05), rgba(118, 75, 162, 0.05));
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.85rem;
color: #888;
}
}
.panel-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
// Summary
.summary-section {
h4 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #555;
}
}
.summary-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
.summary-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
background: #f8f8f8;
border-radius: 10px;
.q-icon {
margin-top: 2px;
}
label {
display: block;
font-size: 0.75rem;
color: #888;
margin-bottom: 0.25rem;
}
span {
font-weight: 500;
font-size: 0.9rem;
}
}
// Generation Status
.generation-status {
h4 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #555;
}
}
.steps-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.step-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f8f8f8;
border-radius: 10px;
transition: all 0.3s;
&.status-completed {
background: rgba(76, 175, 80, 0.1);
}
&.status-processing {
background: rgba(102, 126, 234, 0.1);
}
&.status-error {
background: rgba(244, 67, 54, 0.1);
}
.step-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.step-info {
flex: 1;
.step-label {
display: block;
font-weight: 500;
}
.step-message {
display: block;
font-size: 0.8rem;
color: #888;
margin-top: 0.25rem;
}
}
}
// Result
.result-section {
text-align: center;
padding: 1.5rem 0;
}
.result-success {
margin-bottom: 2rem;
h3 {
margin: 1rem 0 0.5rem;
font-size: 1.5rem;
}
p {
color: #888;
margin: 0;
}
}
.download-options {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.extra-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1.5rem;
}
// Generate
.generate-section {
text-align: center;
padding: 2rem 0;
}
.generate-btn {
padding: 1rem 3rem !important;
font-size: 1.1rem !important;
}
.generate-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1rem;
color: #888;
font-size: 0.85rem;
}
.new-poster-section {
text-align: center;
}
// Dark mode
.body--dark {
.panel-header {
border-color: #404040;
}
.summary-item,
.step-item {
background: #333;
}
}
</style>

View File

@@ -0,0 +1,403 @@
<template>
<div class="live-preview" :class="{ 'size-large': size === 'large' }">
<div class="preview-header">
<span class="preview-title">Anteprima Live</span>
<div class="preview-actions">
<q-btn flat dense round icon="zoom_in" @click="zoomIn" />
<q-btn flat dense round icon="zoom_out" @click="zoomOut" />
<q-btn flat dense round icon="fit_screen" @click="zoomFit" />
</div>
</div>
<div class="preview-container" ref="containerRef">
<div
class="preview-canvas"
:style="canvasStyle"
>
<!-- Final Image (if available) -->
<img
v-if="finalImageUrl"
:src="finalImageUrl"
alt="Final poster"
class="final-image"
/>
<!-- Live Rendered Preview -->
<template v-else>
<!-- Background -->
<div class="layer layer-background" :style="backgroundStyle" />
<!-- Overlay -->
<div class="layer layer-overlay" :style="overlayStyle" />
<!-- Main Image -->
<div
v-if="hasMainImage"
class="layer layer-main-image"
:style="getLayerPosition('mainImage')"
>
<img :src="assets.mainImage?.url" alt="" :style="mainImageStyle" />
</div>
<!-- Text Layers -->
<div
v-for="layer in textLayers"
:key="layer.id"
class="layer layer-text"
:style="getLayerPosition(layer.id)"
>
<div :style="getTextStyle(layer)">
{{ getLayerText(layer) }}
</div>
</div>
<!-- Logos -->
<div
v-for="(logo, idx) in visibleLogos"
:key="`logo-${idx}`"
class="layer layer-logo"
:style="getLogoPosition(idx)"
>
<img :src="logo.url" alt="" />
</div>
</template>
</div>
</div>
<div class="preview-footer">
<span class="dimensions">
{{ template?.format?.width || 1080 }} × {{ template?.format?.height || 1920 }} px
</span>
<span class="zoom-level">{{ Math.round(zoom * 100) }}%</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import type { PosterContent, PosterAssets } from '../../../../types/poster.types';
const props = defineProps<{
template: any;
content: PosterContent;
assets: PosterAssets;
layerOverrides?: Record<string, any>;
finalImageUrl?: string;
size?: 'normal' | 'large';
}>();
const containerRef = ref<HTMLElement | null>(null);
const zoom = ref(0.3);
// Computed
const canvasStyle = computed(() => {
const width = props.template?.format?.width || 1080;
const height = props.template?.format?.height || 1920;
return {
width: `${width * zoom.value}px`,
height: `${height * zoom.value}px`,
background: props.template?.backgroundColor || '#1a1a2e'
};
});
const hasMainImage = computed(() => !!props.assets.mainImage?.url);
const backgroundStyle = computed(() => {
if (props.assets.backgroundImage?.url) {
return {
backgroundImage: `url(${props.assets.backgroundImage.url})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
};
}
return {};
});
const overlayStyle = computed(() => {
const bgLayer = props.template?.layers?.find((l: any) => l.type === 'backgroundImage');
const overlay = bgLayer?.style?.overlay;
if (!overlay?.enabled) return { display: 'none' };
if (overlay.type === 'gradient' && overlay.stops) {
const stops = overlay.stops
.map((s: any) => `${s.color} ${s.position * 100}%`)
.join(', ');
return {
background: `linear-gradient(180deg, ${stops})`
};
}
return {
background: overlay.color || 'rgba(0,0,0,0.5)'
};
});
const mainImageStyle = computed(() => {
const layer = props.template?.layers?.find((l: any) => l.type === 'mainImage');
const style = layer?.style || {};
return {
width: '100%',
height: '100%',
objectFit: style.objectFit || 'cover',
borderRadius: `${style.borderRadius || 0}px`
};
});
const textLayers = computed(() => {
if (!props.template?.layers) return [];
return props.template.layers.filter((l: any) =>
['title', 'subtitle', 'eventDate', 'eventTime', 'location', 'contacts', 'extraText'].includes(l.type)
);
});
const visibleLogos = computed(() => {
if (!props.template?.logoSlots?.enabled) return [];
return props.assets.logos.slice(0, props.template.logoSlots.maxCount || 3);
});
// Methods
const getLayerPosition = (layerIdOrType: string) => {
const layer = props.template?.layers?.find((l: any) =>
l.id === layerIdOrType || l.type === layerIdOrType
);
if (!layer) return {};
const pos = layer.position;
const anchor = layer.anchor || 'center';
let transform = '';
switch (anchor) {
case 'center':
transform = 'translate(-50%, -50%)';
break;
case 'top-center':
transform = 'translateX(-50%)';
break;
case 'bottom-center':
transform = 'translate(-50%, -100%)';
break;
// Add more as needed
}
return {
left: `${pos.x * 100}%`,
top: `${pos.y * 100}%`,
width: `${pos.w * 100}%`,
height: `${pos.h * 100}%`,
transform,
zIndex: layer.zIndex || 1
};
};
const getLogoPosition = (index: number) => {
const slots = props.template?.logoSlots?.slots || [];
const slot = slots[index];
if (!slot) return { display: 'none' };
const pos = slot.position;
return {
left: `${pos.x * 100}%`,
top: `${pos.y * 100}%`,
width: `${pos.w * 100}%`,
height: `${pos.h * 100}%`,
transform: 'translate(-50%, -50%)'
};
};
const getTextStyle = (layer: any) => {
const style = layer.style || {};
const palette = props.template?.palette || {};
const css: Record<string, any> = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: style.textAlign === 'left' ? 'flex-start' :
style.textAlign === 'right' ? 'flex-end' : 'center',
fontFamily: `"${style.fontFamily || 'Open Sans'}", sans-serif`,
fontWeight: style.fontWeight || 400,
fontSize: `${(style.fontSize || 32) * zoom.value}px`,
color: style.color || palette.text || '#ffffff',
textAlign: style.textAlign || 'center',
textTransform: style.textTransform || 'none',
letterSpacing: `${(style.letterSpacing || 0) * zoom.value}px`,
lineHeight: style.lineHeight || 1.2,
padding: `0 ${8 * zoom.value}px`
};
if (style.shadow?.enabled) {
const s = style.shadow;
css.textShadow = `${s.offsetX || 0}px ${s.offsetY || 0}px ${s.blur || 0}px ${s.color || 'rgba(0,0,0,0.5)'}`;
}
return css;
};
const getLayerText = (layer: any) => {
switch (layer.type) {
case 'title':
return props.content.title || 'Titolo Evento';
case 'subtitle':
return props.content.subtitle || '';
case 'eventDate':
const time = props.content.eventTime ? `${props.content.eventTime}` : '';
return (props.content.eventDate || 'Data Evento') + time;
case 'eventTime':
return props.content.eventTime || '';
case 'location':
return props.content.location || 'Luogo Evento';
case 'contacts':
return props.content.contacts || '';
case 'extraText':
return props.content.extraText?.join(' • ') || '';
default:
return '';
}
};
const zoomIn = () => {
zoom.value = Math.min(zoom.value + 0.1, 1);
};
const zoomOut = () => {
zoom.value = Math.max(zoom.value - 0.1, 0.1);
};
const zoomFit = () => {
if (!containerRef.value) return;
const container = containerRef.value;
const width = props.template?.format?.width || 1080;
const height = props.template?.format?.height || 1920;
const scaleX = (container.clientWidth - 40) / width;
const scaleY = (container.clientHeight - 40) / height;
zoom.value = Math.min(scaleX, scaleY, 0.5);
};
// Auto-fit on mount
onMounted(() => {
setTimeout(zoomFit, 100);
});
watch(() => props.template, () => {
setTimeout(zoomFit, 100);
});
</script>
<style lang="scss" scoped>
.live-preview {
display: flex;
flex-direction: column;
background: #2a2a2a;
border-radius: 16px;
overflow: hidden;
height: 100%;
min-height: 500px;
&.size-large {
min-height: 600px;
}
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #222;
border-bottom: 1px solid #333;
.preview-title {
font-size: 0.9rem;
font-weight: 600;
color: #fff;
}
.preview-actions {
display: flex;
gap: 0.25rem;
.q-btn {
color: #aaa;
&:hover {
color: #fff;
}
}
}
}
.preview-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
overflow: auto;
background:
linear-gradient(45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(-45deg, #1a1a1a 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1a1a 75%),
linear-gradient(-45deg, transparent 75%, #1a1a1a 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
background-color: #252525;
}
.preview-canvas {
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.final-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.layer {
position: absolute;
&.layer-background,
&.layer-overlay {
inset: 0;
}
&.layer-main-image img,
&.layer-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
&.layer-text {
overflow: hidden;
> div {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.preview-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: #222;
border-top: 1px solid #333;
font-size: 0.8rem;
color: #888;
}
</style>

View File

@@ -0,0 +1,474 @@
<template>
<div class="template-selector-panel">
<div class="panel-header">
<h2>Scegli un Template</h2>
<p>Seleziona il modello base per la tua locandina</p>
</div>
<!-- Filters -->
<div class="filters-bar">
<q-select
v-model="selectedType"
:options="typeOptions"
label="Categoria"
filled
dense
emit-value
map-options
clearable
style="min-width: 180px"
/>
<q-input
v-model="searchQuery"
label="Cerca template..."
filled
dense
clearable
class="flex-1"
>
<template #prepend>
<q-icon name="search" />
</template>
</q-input>
</div>
<!-- Loading -->
<div v-if="isLoading" class="loading-state">
<q-spinner-dots size="60px" color="primary" />
<p>Caricamento template...</p>
</div>
<!-- Templates Grid -->
<div v-else class="templates-grid">
<div
v-for="template in filteredTemplates"
:key="template._id"
class="template-card"
:class="{ 'is-selected': selectedTemplate?._id === template._id }"
@click="$emit('select', template)"
>
<div class="template-preview" :style="getPreviewStyle(template)">
<div class="template-overlay">
<q-icon
v-if="selectedTemplate?._id === template._id"
name="check_circle"
size="56px"
color="white"
/>
<q-btn
v-else
round
color="white"
text-color="primary"
icon="visibility"
@click.stop="previewTemplate(template)"
>
<q-tooltip>Anteprima</q-tooltip>
</q-btn>
</div>
</div>
<div class="template-info">
<h3>{{ template.name }}</h3>
<p class="template-type">{{ formatType(template.templateType) }}</p>
<div class="template-meta">
<div class="meta-item">
<q-icon name="layers" size="14px" />
{{ template.layers?.length || 0 }} layer
</div>
<div class="meta-item">
<q-icon name="aspect_ratio" size="14px" />
{{ template.format?.preset || 'Custom' }}
</div>
</div>
<div class="template-tags" v-if="template.metadata?.tags?.length">
<q-chip
v-for="tag in template.metadata.tags.slice(0, 3)"
:key="tag"
size="sm"
dense
color="grey-3"
text-color="grey-8"
>
{{ tag }}
</q-chip>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!isLoading && filteredTemplates.length === 0" class="empty-state">
<q-icon name="search_off" size="80px" color="grey-4" />
<h3>Nessun template trovato</h3>
<p>Prova a modificare i filtri di ricerca</p>
</div>
<!-- Template Preview Dialog -->
<q-dialog v-model="showPreviewDialog" maximized>
<q-card class="preview-dialog">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ previewingTemplate?.name }}</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-card-section class="preview-content">
<div class="preview-canvas" :style="getPreviewStyle(previewingTemplate, true)" />
<div class="preview-details">
<h3>{{ previewingTemplate?.name }}</h3>
<p>{{ previewingTemplate?.description || 'Nessuna descrizione' }}</p>
<div class="detail-row">
<strong>Formato:</strong>
{{ previewingTemplate?.format?.width }} × {{ previewingTemplate?.format?.height }} px
</div>
<div class="detail-row">
<strong>Layer:</strong>
{{ previewingTemplate?.layers?.length || 0 }}
</div>
<div class="detail-row" v-if="previewingTemplate?.metadata?.tags?.length">
<strong>Tag:</strong>
<div class="tags-list">
<q-chip
v-for="tag in previewingTemplate.metadata.tags"
:key="tag"
size="sm"
color="primary"
text-color="white"
>
{{ tag }}
</q-chip>
</div>
</div>
<q-btn
color="primary"
icon="check"
label="Usa questo template"
class="q-mt-lg full-width"
size="lg"
@click="selectAndClose(previewingTemplate)"
/>
</div>
</q-card-section>
</q-card>
</q-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Api } from 'src/store/Api';
const props = defineProps<{
selectedTemplate: any;
}>();
const emit = defineEmits<{
(e: 'select', template: any): void;
}>();
const templates = ref<any[]>([]);
const isLoading = ref(true);
const searchQuery = ref('');
const selectedType = ref<string | null>(null);
const showPreviewDialog = ref(false);
const previewingTemplate = ref<any>(null);
const typeOptions = computed(() => {
const types = [...new Set(templates.value.map(t => t.templateType))];
return [
{ label: 'Tutte le categorie', value: null },
...types.map(t => ({
label: formatType(t),
value: t
}))
];
});
const filteredTemplates = computed(() => {
let result = templates.value;
if (selectedType.value) {
result = result.filter(t => t.templateType === selectedType.value);
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(t =>
t.name.toLowerCase().includes(query) ||
t.templateType.toLowerCase().includes(query) ||
t.metadata?.tags?.some((tag: string) => tag.toLowerCase().includes(query))
);
}
return result;
});
const formatType = (type: string) => {
return type
.replace(/-/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
};
const getPreviewStyle = (template: any, large = false) => {
if (!template) return {};
if (template.thumbnailUrl || template.previewUrl) {
return {
backgroundImage: `url(${template.previewUrl || template.thumbnailUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center'
};
}
// Fallback: gradient from palette
const palette = template.palette || {};
return {
background: `linear-gradient(135deg,
${palette.primary || '#667eea'} 0%,
${palette.secondary || '#764ba2'} 50%,
${palette.background || '#1a1a2e'} 100%)`
};
};
const previewTemplate = (template: any) => {
previewingTemplate.value = template;
showPreviewDialog.value = true;
};
const selectAndClose = (template: any) => {
emit('select', template);
showPreviewDialog.value = false;
};
const loadTemplates = async () => {
isLoading.value = true;
try {
const res = await Api.SendReq('/api/templates', 'GET');
if (res?.data?.success) {
templates.value = res.data.data;
}
} catch (error) {
console.error('Error loading templates:', error);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
loadTemplates();
});
</script>
<style lang="scss" scoped>
.template-selector-panel {
padding: 1.5rem;
}
.panel-header {
text-align: center;
margin-bottom: 2rem;
h2 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 700;
}
p {
color: #666;
margin: 0;
}
}
.filters-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
.flex-1 {
flex: 1;
min-width: 200px;
}
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem;
color: #888;
}
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.template-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
cursor: pointer;
transition: all 0.3s ease;
border: 3px solid transparent;
&:hover {
transform: translateY(-6px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
.template-overlay {
opacity: 1;
}
}
&.is-selected {
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.2);
.template-overlay {
opacity: 1;
background: rgba(102, 126, 234, 0.85);
}
}
}
.template-preview {
height: 200px;
position: relative;
background: #f0f0f0;
}
.template-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
}
.template-info {
padding: 1rem 1.25rem 1.25rem;
h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.template-type {
color: #888;
font-size: 0.85rem;
margin: 0 0 0.75rem;
}
}
.template-meta {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: #666;
}
}
.template-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #888;
h3 {
margin: 1rem 0 0.5rem;
}
}
// Preview Dialog
.preview-dialog {
.preview-content {
display: grid;
grid-template-columns: 1fr 400px;
gap: 2rem;
height: calc(100vh - 100px);
@media (max-width: 900px) {
grid-template-columns: 1fr;
}
}
.preview-canvas {
border-radius: 12px;
height: 100%;
min-height: 400px;
}
.preview-details {
padding: 1rem;
h3 {
margin: 0 0 1rem;
font-size: 1.5rem;
}
p {
color: #666;
margin-bottom: 1.5rem;
}
.detail-row {
margin-bottom: 1rem;
strong {
display: block;
margin-bottom: 0.25rem;
color: #888;
font-size: 0.85rem;
}
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
}
}
// Dark mode
.body--dark {
.template-card {
background: #2d2d2d;
}
.panel-header p,
.template-type,
.meta-item {
color: #aaa;
}
}
</style>

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More