- aggiornamento di tante cose...
- generazione Volantini - pagina RIS
28
migrate-repos.sh
Executable 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
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
public/images/riso_quadrato.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
@@ -147,7 +147,6 @@ export const shared_consts = {
|
||||
DASHBOARD: 140,
|
||||
DASHGROUP: 145,
|
||||
MOVEMENTS: 148,
|
||||
CSENDRISTO: 150,
|
||||
STATUSREG: 160,
|
||||
CHECKIFISLOGGED: 170,
|
||||
INFO_VERSION: 180,
|
||||
@@ -193,6 +192,7 @@ export const shared_consts = {
|
||||
RISOHOME_MODERN: 1610,
|
||||
PAGERIS: 1620,
|
||||
CMYCIRCUITS: 1630,
|
||||
CREA_VOLANTINO: 1700,
|
||||
},
|
||||
|
||||
QUERYTYPE_MYGROUP: 1,
|
||||
@@ -2009,11 +2009,6 @@ export const shared_consts = {
|
||||
label: 'Lista Movimenti',
|
||||
icon: 'fas fa-list',
|
||||
},
|
||||
{
|
||||
value: 150, // CSENDRISTO
|
||||
label: 'Bott (Invia/Ricevi RIS)',
|
||||
icon: 'fas fa-wallet',
|
||||
},
|
||||
{
|
||||
value: 280,
|
||||
label: 'Tutorial',
|
||||
@@ -2057,6 +2052,11 @@ export const shared_consts = {
|
||||
label: 'Check Email',
|
||||
icon: 'fas fa-envelope',
|
||||
},
|
||||
{
|
||||
value: 1700, // CREA_VOLANTINO
|
||||
label: 'Genera Volantini',
|
||||
icon: 'fas fa-user-tie',
|
||||
},
|
||||
{
|
||||
value: 120,
|
||||
label: 'OpenStreetMap',
|
||||
@@ -2481,6 +2481,7 @@ export const shared_consts = {
|
||||
link_group: 1,
|
||||
totCircolante: 1,
|
||||
totTransato: 1,
|
||||
numTransazioni: 1,
|
||||
systemUserId: 1,
|
||||
createdBy: 1,
|
||||
date_created: 1,
|
||||
|
||||
749
src/components/AIImageGenerator/AIImageGenerator.vue
Normal 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>
|
||||
@@ -31,6 +31,11 @@ export default defineComponent({
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
circuitSel: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
causalDest: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
:to_user="myuser"
|
||||
:sendRIS="sendRIS"
|
||||
:causalDest="causalDest"
|
||||
:circuitname="circuitSel"
|
||||
@close="
|
||||
showsendCoinTo = false;
|
||||
loading = false;
|
||||
|
||||
@@ -1,9 +1,215 @@
|
||||
.my-custom-container {
|
||||
max-width: 100%; /* Imposta la larghezza massima per il contenitore */
|
||||
// Variables
|
||||
$border-radius-sm: 8px;
|
||||
$border-radius-md: 12px;
|
||||
$transition-fast: 0.2s ease;
|
||||
|
||||
.copy-share-container {
|
||||
width: 100%;
|
||||
|
||||
&.small-variant {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wrapword {
|
||||
overflow: hidden; /* Nasconde il contenuto in eccesso */
|
||||
white-space: nowrap; /* Impedisce il wrapping del testo */
|
||||
text-overflow: ellipsis; /* Mostra "..." se il testo è troppo lungo */
|
||||
&.btn-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Link Display Wrapper
|
||||
// ═══════════════════════════════════════════
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,208 @@
|
||||
import { tools } from '../../store/Modules/tools'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@store/UserStore'
|
||||
import { useGlobalStore } from '@store/globalStore'
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, ref, computed } from 'vue'
|
||||
import { shared_consts } from '@/common/shared_vuejs'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CCopyBtnSmall',
|
||||
props: {
|
||||
// Testo/link da copiare
|
||||
texttocopy: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// Titolo per la condivisione
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'Condividi questo link',
|
||||
},
|
||||
// Messaggio personalizzato per la condivisione
|
||||
shareMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// Lunghezza massima del testo visualizzato
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 400,
|
||||
},
|
||||
// Dimensione compatta
|
||||
small: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
components: {},
|
||||
setup(props) {
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['copied', 'shared'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const $q = useQuasar()
|
||||
const { t } = useI18n()
|
||||
|
||||
async function copytoclipandsend() {
|
||||
tools.copyStringToClipboard($q, props.texttocopy, true)
|
||||
// State
|
||||
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) + '...'
|
||||
})
|
||||
|
||||
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}`
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.texttocopy)
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
function getclass() {
|
||||
if (props.small) {
|
||||
return 'text-h7'
|
||||
} else {
|
||||
return 'text-h5'
|
||||
}
|
||||
emit('shared', 'telegram')
|
||||
}
|
||||
|
||||
return {
|
||||
copytoclipandsend,
|
||||
// State
|
||||
copied,
|
||||
|
||||
// Computed
|
||||
truncatedText,
|
||||
containerClasses,
|
||||
|
||||
// Methods
|
||||
handleCopy,
|
||||
handleShare,
|
||||
handleWhatsApp,
|
||||
handleTelegram,
|
||||
|
||||
// Utils
|
||||
tools,
|
||||
getclass,
|
||||
t,
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,14 +1,130 @@
|
||||
<template>
|
||||
<div class="" style="width: 100%; overflow: hidden;">
|
||||
<div class="row justify-center">
|
||||
{{ tools.firstchars(texttocopy, 80) }}
|
||||
<div class="copy-share-container" :class="containerClasses">
|
||||
<!-- Variante: Solo pulsante -->
|
||||
<template v-if="btn && !showLink">
|
||||
<q-btn
|
||||
:size="small ? 'sm' : 'md'"
|
||||
:round="iconOnly"
|
||||
: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>
|
||||
<div class="row justify-center q-mt-sm">
|
||||
<q-btn rounded label="Condividi link" color="primary" @click="copytoclipandsend" />
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./CCopyBtnSmall.ts"></script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
"
|
||||
>
|
||||
<q-item-section>{{
|
||||
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.enter')
|
||||
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')
|
||||
}}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -436,7 +436,7 @@
|
||||
"
|
||||
icon="fas fa-user-plus"
|
||||
color="primary"
|
||||
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.enter')"
|
||||
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')"
|
||||
rounded
|
||||
size="lg"
|
||||
@click="
|
||||
|
||||
@@ -14,12 +14,11 @@ import { tools } from '@tools'
|
||||
import { CUserNonVerif } from '@/components/CUserNonVerif'
|
||||
import { CTitleBanner } from '@/components/CTitleBanner'
|
||||
import { CMovements } from '@/components/CMovements'
|
||||
import { CSendRISTo } from '@/components/CSendRISTo'
|
||||
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CMyCircuits',
|
||||
components: { CMyCircuit, CUserNonVerif, CTitleBanner, CMovements, CSendRISTo },
|
||||
components: { CMyCircuit, CUserNonVerif, CTitleBanner, CMovements },
|
||||
emits: ['update:modelValue'],
|
||||
props: {
|
||||
modelValue: {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<div v-if="tools.isUserOk() && finishloading">
|
||||
<div v-if="finder && showfinder" class="q-gutter-sm q-pa-sm q-pb-sm">
|
||||
<div class="q-mt-md">
|
||||
<CSendRISTo></CSendRISTo>
|
||||
|
||||
<q-tabs
|
||||
v-model="mytab"
|
||||
|
||||
@@ -32,6 +32,7 @@ import { shared_consts } from '@/common/shared_vuejs';
|
||||
import { LandingFooter } from '@/components/LandingFooter';
|
||||
import { CMyActivities } from '@/components/CMyActivities';
|
||||
import { CECommerce } from '@/components/CECommerce';
|
||||
import { EventPosterGenerator } from '@/components/EventPosterGenerator';
|
||||
import { CheckEmail } from '@/components/CheckEmail';
|
||||
import { HomeRiso } from '@/components/HomeRiso';
|
||||
import mycircuits from '@/views/user/mycircuits/mycircuits.vue';
|
||||
@@ -63,7 +64,6 @@ import { CMyFieldRec } from '@/components/CMyFieldRec';
|
||||
import { CSelectColor } from '@/components/CSelectColor';
|
||||
import { CMainView } from '@/components/CMainView';
|
||||
import { CMyProfileTutorial } from '@/components/CMyProfileTutorial';
|
||||
import { CSendRISTo } from '@/components/CSendRISTo';
|
||||
import { CDashboard } from '@/components/CDashboard';
|
||||
import { CDashGroup } from '@/components/CDashGroup';
|
||||
import { CMovements } from '@/components/CMovements';
|
||||
@@ -103,6 +103,7 @@ export default defineComponent({
|
||||
CEventsCalendar,
|
||||
CCardCarousel,
|
||||
CProfileCompletitionBanner,
|
||||
EventPosterGenerator,
|
||||
COpenStreetMap,
|
||||
CMyPage,
|
||||
CMyPageIntro,
|
||||
@@ -126,7 +127,6 @@ export default defineComponent({
|
||||
CCardCarouselComp,
|
||||
CMyActivities,
|
||||
CMyProfileTutorial,
|
||||
CSendRISTo,
|
||||
CTitleBanner,
|
||||
CShareSocial,
|
||||
CCheckAppRunning,
|
||||
|
||||
@@ -134,6 +134,19 @@
|
||||
</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
|
||||
v-else-if="myel.type === shared_consts.ELEMTYPE.IMGPOSTER"
|
||||
class="myElemBase"
|
||||
@@ -607,18 +620,6 @@
|
||||
</div>
|
||||
<CMovements :showbuttolastmov="true"></CMovements>
|
||||
</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
|
||||
v-else-if="myel.type === shared_consts.ELEMTYPE.GRID_ORIZ"
|
||||
class="myElemBase"
|
||||
|
||||
@@ -1,17 +1,357 @@
|
||||
|
||||
.button_download{
|
||||
display: inline-block !important;
|
||||
padding: 10px 20px !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 500;
|
||||
color: #ffffff !important;
|
||||
background-color: #027be3 !important;
|
||||
/* Colore primario di Quasar */
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
.qrcode-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// QR READER SECTION
|
||||
// ============================================
|
||||
.qr-reader-section {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
import {
|
||||
computed,
|
||||
provide, defineComponent, onBeforeMount, onBeforeUnmount, onMounted, ref, toRef, toRefs, watch, reactive
|
||||
} from 'vue'
|
||||
defineComponent,
|
||||
onMounted,
|
||||
ref,
|
||||
toRefs,
|
||||
reactive,
|
||||
nextTick,
|
||||
} from 'vue';
|
||||
|
||||
import { tools } from '@tools'
|
||||
import { costanti } from '@costanti'
|
||||
import { useGlobalStore } from '@store/globalStore'
|
||||
import { useUserStore } from '@store/UserStore'
|
||||
import { tools } from '@tools';
|
||||
import { costanti } from '@costanti';
|
||||
import { useGlobalStore } from '@store/globalStore';
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
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 lettura QR
|
||||
import { QrStream, QrCapture, QrDropzone } from 'vue3-qr-reader';
|
||||
|
||||
// Import per generazione QR
|
||||
import QRCodeVue3 from 'qrcode-vue3';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CQRCode',
|
||||
emits: [''],
|
||||
|
||||
components: {
|
||||
QrStream,
|
||||
QrCapture,
|
||||
QrDropzone,
|
||||
QRCodeVue3,
|
||||
},
|
||||
|
||||
props: {
|
||||
link: {
|
||||
type: String,
|
||||
@@ -41,51 +54,361 @@ export default defineComponent({
|
||||
required: 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({
|
||||
data: null
|
||||
})
|
||||
data: null as string | null,
|
||||
isDownloading: false,
|
||||
uploadedFile: null as File | null,
|
||||
});
|
||||
|
||||
function onDecode(data: any) {
|
||||
if (data)
|
||||
state.data = data
|
||||
// Computed
|
||||
const qrSize = computed(() => {
|
||||
// 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);
|
||||
}
|
||||
|
||||
const text = ref('');
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'QR Code rilevato!',
|
||||
position: 'top',
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
$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 {
|
||||
// Stores
|
||||
globalStore,
|
||||
userStore,
|
||||
|
||||
// Utils
|
||||
t,
|
||||
tools,
|
||||
costanti,
|
||||
toolsext,
|
||||
text,
|
||||
userStore,
|
||||
|
||||
// State
|
||||
...toRefs(state),
|
||||
|
||||
// Computed
|
||||
qrSize,
|
||||
logoImage,
|
||||
dotsOptions,
|
||||
canShare,
|
||||
downloadFilename,
|
||||
primaryColor: props.primaryColor,
|
||||
|
||||
// Methods
|
||||
onDecode,
|
||||
handleFileUpload,
|
||||
qrDisplayRef,
|
||||
downloadQR,
|
||||
shareQR,
|
||||
copyToClipboard,
|
||||
isValidUrl,
|
||||
truncateUrl,
|
||||
naviga,
|
||||
globalStore,
|
||||
}
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,71 +1,215 @@
|
||||
<template>
|
||||
<div v-if="globalStore.finishLoading">
|
||||
<div v-if="read">
|
||||
<div class="stream">
|
||||
<qr-stream @decode="onDecode" class="mb">
|
||||
<div style="color: red" class="frame"></div>
|
||||
</qr-stream>
|
||||
|
||||
<br />
|
||||
<qr-capture @decode="onDecode" class="mb"></qr-capture>
|
||||
</div>
|
||||
<div class="row justify-center q-ma-sm">
|
||||
<q-btn
|
||||
v-if="data && data.startsWith('http')"
|
||||
class="q-ma-sm"
|
||||
dense
|
||||
color="positive"
|
||||
@click="tools.openUrl(data)"
|
||||
label="APRI PAGINA"
|
||||
<div
|
||||
v-if="globalStore.finishLoading"
|
||||
class="qrcode-container"
|
||||
>
|
||||
<!-- Modalità Lettura QR -->
|
||||
<div
|
||||
v-if="read"
|
||||
class="qr-reader-section"
|
||||
>
|
||||
<div class="stream-container">
|
||||
<qr-stream
|
||||
@decode="onDecode"
|
||||
class="qr-stream"
|
||||
>
|
||||
<div class="scan-frame">
|
||||
<div class="corner top-left"></div>
|
||||
<div class="corner top-right"></div>
|
||||
<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>
|
||||
<br />
|
||||
<div v-if="data && data.startsWith('http')" class="result">
|
||||
Link: {{ data }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="q-ma-sm">
|
||||
{{ textlink }}<br />
|
||||
{{ link }}<br />
|
||||
Logo: {{imglogo}}<br />
|
||||
</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"
|
||||
|
||||
<!-- QR Code Display -->
|
||||
<div class="qr-display" ref="qrDisplayRef">
|
||||
<QRCodeVue3
|
||||
:value="link"
|
||||
downloadButton="button_download"
|
||||
:downloadOptions="{
|
||||
name: 'qrcode-riso-' + userStore.my.username,
|
||||
extension: 'png',
|
||||
: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>
|
||||
</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>
|
||||
|
||||
<script lang="ts" src="./CQRCode.ts">
|
||||
</script>
|
||||
<script lang="ts" src="./CQRCode.ts"></script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CQRCode.scss';
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ $r-md: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: $r-md;
|
||||
padding: $s-md;
|
||||
padding: $s-sm;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ $r-md: 10px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $s-md;
|
||||
margin-bottom: $s-lg;
|
||||
|
||||
.balance-label {
|
||||
font-size: 0.85rem;
|
||||
@@ -56,7 +56,7 @@ $r-md: 10px;
|
||||
// Container progressione
|
||||
.progress-container {
|
||||
position: relative;
|
||||
margin-bottom: $s-lg;
|
||||
margin-bottom: $s-xs;
|
||||
}
|
||||
|
||||
// Track di sfondo
|
||||
@@ -160,6 +160,7 @@ $r-md: 10px;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +170,7 @@ $r-md: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $s-sm;
|
||||
padding-top: $s-md;
|
||||
padding-top: $s-sm;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
// Layout inline quando !small
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CRISBalanceBar',
|
||||
@@ -24,14 +25,24 @@ export default defineComponent({
|
||||
// Label opzionale
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Range disponibile',
|
||||
default: '',
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
info: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
|
||||
const { t } = useI18n();
|
||||
// Range totale
|
||||
const totalRange = computed(() => {
|
||||
return Math.abs(props.minLimit) + props.maxLimit;
|
||||
@@ -86,6 +97,7 @@ export default defineComponent({
|
||||
canGive,
|
||||
canReceive,
|
||||
zeroPosition,
|
||||
t,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<template>
|
||||
<div class="ris-balance-bar">
|
||||
<div
|
||||
class="ris-balance-bar"
|
||||
:style="color ? { color } : {}"
|
||||
>
|
||||
<!-- Label e valore corrente -->
|
||||
<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
|
||||
@@ -15,7 +22,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Barra di progressione -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-container q-pt-xs">
|
||||
<!-- Linea di sfondo -->
|
||||
<div class="progress-track">
|
||||
<!-- Zona negativa (rossa) -->
|
||||
@@ -47,28 +54,31 @@
|
||||
:style="{ '--zero-position': zeroPosition + '%' }"
|
||||
>
|
||||
<div class="marker min-marker">
|
||||
<span class="marker-value">{{ minLimit.toFixed(2) }}</span>
|
||||
<span class="marker-label">Fido</span>
|
||||
<span class="marker-value">{{ minLimit }}</span>
|
||||
<span class="marker-label">FIDO</span>
|
||||
</div>
|
||||
<div class="marker zero-marker-label">
|
||||
<span class="marker-value">0</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info disponibilità -->
|
||||
<div :class="['availability-info', { 'inline-layout': !small }]">
|
||||
<div
|
||||
v-if="info"
|
||||
:class="['availability-info', { 'inline-layout': !small }]"
|
||||
>
|
||||
<div class="availability-item">
|
||||
<q-icon
|
||||
name="arrow_downward"
|
||||
size="xs"
|
||||
color="negative"
|
||||
/>
|
||||
<span class="availability-text">
|
||||
<span class="availability-text text-white">
|
||||
Puoi dare ancora: <strong>{{ canGive.toFixed(2) }} RIS</strong>
|
||||
</span>
|
||||
</div>
|
||||
@@ -78,7 +88,7 @@
|
||||
size="xs"
|
||||
color="positive"
|
||||
/>
|
||||
<span class="availability-text">
|
||||
<span class="availability-text text-white">
|
||||
Puoi ricevere: <strong>{{ canReceive.toFixed(2) }} RIS</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Variables
|
||||
$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%);
|
||||
$ris-color: #ff5500;
|
||||
$border-radius-lg: 16px;
|
||||
@@ -8,36 +9,15 @@ $border-radius-sm: 8px;
|
||||
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
$shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
// Main Dialog
|
||||
.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 Gradients
|
||||
.header-gradient {
|
||||
background: $primary-gradient;
|
||||
padding: 12px 16px 14px;
|
||||
position: relative;
|
||||
|
||||
&.receive-gradient {
|
||||
background: $receive-gradient;
|
||||
}
|
||||
}
|
||||
|
||||
.header-top-bar {
|
||||
@@ -62,6 +42,33 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
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 {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -145,17 +152,203 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// Content
|
||||
.dialog-content {
|
||||
padding: 14px 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
// ═══════════════════════════════════════════
|
||||
// Receive Tabs
|
||||
// ═══════════════════════════════════════════
|
||||
.receive-tabs {
|
||||
margin-top: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
.receive-tabs-inner {
|
||||
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;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
:deep(.q-tab__indicator) {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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
|
||||
@@ -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) {
|
||||
border-color: #667eea;
|
||||
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
|
||||
.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));
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
background: #f3f4f6;
|
||||
@@ -403,6 +574,15 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
opacity: 0.5;
|
||||
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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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
|
||||
// ═══════════════════════════════════════════
|
||||
.body--dark {
|
||||
.send-coins-dialog {
|
||||
background: #1f2937;
|
||||
@@ -526,10 +750,6 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
.fixed-bottom-actions {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
@@ -547,9 +767,34 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
.keyboard-title {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.generated-link-section {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
// Responsive - Ultra compatto per mobile piccoli
|
||||
.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
|
||||
// ═══════════════════════════════════════════
|
||||
@media (max-width: 360px) {
|
||||
.header-gradient {
|
||||
padding: 10px 12px 12px;
|
||||
@@ -572,4 +817,8 @@ $shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
font-size: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,24 @@ import { CNumericKeyboard } from '@/components/CNumericKeyboard';
|
||||
import { CMyUserOnlyView } from '@/components/CMyUserOnlyView';
|
||||
import { CMyGroupOnlyView } from '@/components/CMyGroupOnlyView';
|
||||
import { CCheckCircuitsEnabled } from '@/components/CCheckCircuitsEnabled';
|
||||
import { CQRCode } from '@/components/CQRCode';
|
||||
import { CCopyBtnSmall } from '@/components/CCopyBtnSmall';
|
||||
import { costanti } from '@costanti';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { shared_consts } from '@/common/shared_vuejs';
|
||||
|
||||
export type DialogMode = 'send' | 'receive';
|
||||
export type ReceiveTabType = 'link' | 'qrcode' | 'showonlist';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CSendCoins',
|
||||
emits: ['close', 'showed'],
|
||||
props: {
|
||||
// Modalità: 'send' per inviare, 'receive' per ricevere
|
||||
mode: {
|
||||
type: String as PropType<DialogMode>,
|
||||
default: 'send',
|
||||
},
|
||||
loadprofile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -75,6 +85,8 @@ export default defineComponent({
|
||||
CMyGroupOnlyView,
|
||||
CCheckCircuitsEnabled,
|
||||
CNumericKeyboard,
|
||||
CQRCode,
|
||||
CCopyBtnSmall,
|
||||
},
|
||||
|
||||
setup(props, { emit }) {
|
||||
@@ -85,23 +97,24 @@ export default defineComponent({
|
||||
const circuitStore = useCircuitStore();
|
||||
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_groupname = ref('');
|
||||
const from_contocom = ref('');
|
||||
const circuitsel = ref('');
|
||||
const qty = ref(<string | number>'');
|
||||
const causal = ref('');
|
||||
const loading = ref(false);
|
||||
const visubanner = ref(true);
|
||||
const bothcircuits = ref(<any>[]);
|
||||
|
||||
const showProvinceToSelect = ref(false);
|
||||
const showKeyboard = ref(false);
|
||||
|
||||
const groupSel = ref(<IMyGroup | null | undefined>null);
|
||||
|
||||
const datasaved = ref(<any>null);
|
||||
const step = ref(0);
|
||||
const sendCoinDialog = ref(null);
|
||||
@@ -116,19 +129,61 @@ export default defineComponent({
|
||||
const numstep = ref(0);
|
||||
const arrTypesAccounts = ref(<any>[]);
|
||||
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(() =>
|
||||
circuitloaded.value ? `${qty.value} ` + circuitloaded.value.symbol : ''
|
||||
);
|
||||
const arrayMarkerLabel = ref(<any>[]);
|
||||
|
||||
const qtyRef = ref(<any>null);
|
||||
const causalRef = ref(<any>null);
|
||||
// Circuiti disponibili (diversi per SEND e RECEIVE)
|
||||
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[]>[]);
|
||||
|
||||
const arrGroupsList = ref(<any[]>[]);
|
||||
// Link generato per la ricezione
|
||||
const generatedLink = computed(() => {
|
||||
return userStore.getLinkProfileAndRIS(
|
||||
circuitsel.value,
|
||||
'',
|
||||
receiveQty.value,
|
||||
receiveCausal.value
|
||||
);
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// WATCHERS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
watch(
|
||||
() => circuitsel.value,
|
||||
(newval, oldval) => {
|
||||
@@ -144,9 +199,7 @@ export default defineComponent({
|
||||
if (arrGroupsList.value.length >= 1)
|
||||
from_groupname.value = arrGroupsList.value[0].value;
|
||||
}
|
||||
|
||||
tools.setCookie(tools.COOK_TIPOCONTO, tipoConto.value.toString());
|
||||
|
||||
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) {
|
||||
if (load) {
|
||||
inizio_caricamento();
|
||||
@@ -219,7 +283,6 @@ export default defineComponent({
|
||||
groupSel.value = userStore.my.profile.manage_mygroups.find(
|
||||
(group: IMyGroup) => from_groupname.value === group.groupname
|
||||
);
|
||||
|
||||
accountloaded.value = groupSel.value ? groupSel.value.account : null;
|
||||
} else if (tipoConto.value === shared_consts.AccountType.COMMUNITY_ACCOUNT) {
|
||||
from_contocom.value = circuitloaded.value.path;
|
||||
@@ -237,10 +300,8 @@ export default defineComponent({
|
||||
if (groupsListAdmin.value) {
|
||||
for (const group of groupsListAdmin.value) {
|
||||
let aggiungi = true;
|
||||
|
||||
if (props.to_group && props.to_group.groupname === group.groupname)
|
||||
aggiungi = false;
|
||||
|
||||
if (aggiungi)
|
||||
arrGroupsList.value.push({
|
||||
label: group.groupname,
|
||||
@@ -324,6 +385,19 @@ export default defineComponent({
|
||||
async function mounted() {
|
||||
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;
|
||||
loading.value = true;
|
||||
|
||||
@@ -360,7 +434,7 @@ export default defineComponent({
|
||||
}
|
||||
if (
|
||||
bothcircuits.value &&
|
||||
bothcircuits.value.find((name: any) => name !== circuitsel.value)
|
||||
!bothcircuits.value.some((name: any) => name === circuitsel.value)
|
||||
) {
|
||||
circuitsel.value = bothcircuits.value[0];
|
||||
}
|
||||
@@ -414,9 +488,39 @@ export default defineComponent({
|
||||
|
||||
showpage.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
emit('showed');
|
||||
async function initReceiveMode() {
|
||||
// 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() {
|
||||
@@ -424,6 +528,9 @@ export default defineComponent({
|
||||
showpage.value = false;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// METHODS - Send Mode
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function sendCoin() {
|
||||
const ok =
|
||||
(to_user_real.value && to_user_real.value.username) ||
|
||||
@@ -501,7 +608,6 @@ export default defineComponent({
|
||||
|
||||
function getQty(): number {
|
||||
let myqty: number | null = null;
|
||||
|
||||
try {
|
||||
if (qty.value) {
|
||||
myqty = parseFloat(String(qty.value));
|
||||
@@ -509,48 +615,106 @@ export default defineComponent({
|
||||
} catch (e) {
|
||||
return 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) {
|
||||
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);
|
||||
|
||||
return {
|
||||
// Common
|
||||
t,
|
||||
tools,
|
||||
showpage,
|
||||
bothcircuits,
|
||||
from_username,
|
||||
circuitsel,
|
||||
circuitloaded,
|
||||
accountloaded,
|
||||
accountdest,
|
||||
qty,
|
||||
hide,
|
||||
loading,
|
||||
circuitStore,
|
||||
costanti,
|
||||
userStore,
|
||||
step,
|
||||
sendCoinDialog,
|
||||
showProvinceToSelect,
|
||||
shared_consts,
|
||||
availableCircuits,
|
||||
isReceiveMode,
|
||||
onDialogShow,
|
||||
|
||||
// Send Mode
|
||||
bothcircuits,
|
||||
from_username,
|
||||
qty,
|
||||
sendCoin,
|
||||
causal,
|
||||
priceLabel,
|
||||
@@ -559,27 +723,31 @@ export default defineComponent({
|
||||
qtyRef,
|
||||
causalRef,
|
||||
maxsendable,
|
||||
circuitStore,
|
||||
numstep,
|
||||
costanti,
|
||||
userStore,
|
||||
tipoConto,
|
||||
arrGroupsList,
|
||||
from_groupname,
|
||||
from_contocom,
|
||||
arrTypesAccounts,
|
||||
loading,
|
||||
showProvinceToSelect,
|
||||
shared_consts,
|
||||
step,
|
||||
ifNextCheck,
|
||||
getTitle,
|
||||
getIcon,
|
||||
sendCoinDialog,
|
||||
visubanner,
|
||||
showKeyboard,
|
||||
setQty,
|
||||
to_user_real,
|
||||
|
||||
// Receive Mode
|
||||
receiveType,
|
||||
receiveQty,
|
||||
receiveCausal,
|
||||
riscallrec,
|
||||
quickAmounts,
|
||||
generatedLink,
|
||||
limitReceiveQuantity,
|
||||
setQuickAmount,
|
||||
truncateLink,
|
||||
shareLink,
|
||||
shareWhatsApp,
|
||||
configureShowcase,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,30 +4,40 @@
|
||||
ref="sendCoinDialog"
|
||||
:maximized="$q.screen.lt.sm"
|
||||
@hide="hide"
|
||||
@show="qtyRef ? qtyRef.focus() : ''"
|
||||
@show="onDialogShow"
|
||||
transition-show="slide-up"
|
||||
transition-hide="slide-down"
|
||||
>
|
||||
<q-card
|
||||
class="send-coins-dialog"
|
||||
class="send-coins-dialog column no-wrap"
|
||||
:class="{ 'mobile-fullheight': $q.screen.lt.sm }"
|
||||
>
|
||||
<!-- Header con gradiente -->
|
||||
<q-card-section class="dialog-header q-pa-none">
|
||||
<div class="header-gradient">
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- HEADER -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<q-card-section class="dialog-header q-pa-none col-shrink">
|
||||
<div class="header-gradient" :class="{ 'receive-gradient': isReceiveMode }">
|
||||
<!-- Top bar -->
|
||||
<div class="header-top-bar">
|
||||
<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
|
||||
v-if="circuitloaded.symbol === 'RIS'"
|
||||
v-else
|
||||
src="/images/1ris_rosso_100.png"
|
||||
alt="RIS"
|
||||
class="ris-logo"
|
||||
/>
|
||||
<span v-else class="coin-symbol">{{ circuitloaded.symbol }}</span>
|
||||
</div>
|
||||
<span class="header-title">Invia {{ circuitloaded.symbol || 'Crediti' }}</span>
|
||||
<span class="header-title">
|
||||
{{ isReceiveMode ? 'Ricevi' : 'Invia' }}
|
||||
{{ circuitloaded.symbol || 'Crediti' }}
|
||||
</span>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
@@ -40,41 +50,61 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Balance Card compatto -->
|
||||
<div class="balance-card">
|
||||
<!-- Balance Card (solo per SEND mode) -->
|
||||
<div v-if="!isReceiveMode && accountloaded" class="balance-card">
|
||||
<div class="balance-info">
|
||||
<div class="balance-main">
|
||||
<span class="balance-label">Saldo disponibile</span>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Content -->
|
||||
<q-card-section
|
||||
class="dialog-content scroll"
|
||||
:style="$q.screen.lt.sm ? 'padding-bottom: 80px;' : ''"
|
||||
>
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- CONTENT -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<q-card-section class="dialog-content col scroll">
|
||||
<!-- Circuit Check -->
|
||||
<CCheckCircuitsEnabled
|
||||
v-if="!isReceiveMode"
|
||||
:to_user="to_user_real"
|
||||
: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
|
||||
v-model="circuitsel"
|
||||
:options="bothcircuits"
|
||||
:options="availableCircuits"
|
||||
:behavior="$q.platform.is.ios === true ? 'dialog' : 'menu'"
|
||||
outlined
|
||||
dense
|
||||
@@ -85,6 +115,16 @@
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="account_balance_wallet" color="primary" />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -100,8 +140,12 @@
|
||||
{{ $t('circuit.insertprovince_text') }}
|
||||
</q-banner>
|
||||
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- SEND MODE CONTENT -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<template v-if="!isReceiveMode && circuitsel">
|
||||
<!-- Sender Section -->
|
||||
<div v-if="circuitsel" class="section-block">
|
||||
<div class="section-block">
|
||||
<q-select
|
||||
v-if="arrTypesAccounts.length > 0"
|
||||
v-model="tipoConto"
|
||||
@@ -153,11 +197,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Recipient Section -->
|
||||
<div v-if="circuitsel" class="section-block">
|
||||
<div 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"
|
||||
@@ -165,8 +208,6 @@
|
||||
@setCmd="tools.setCmd"
|
||||
class="recipient-view"
|
||||
/>
|
||||
|
||||
<!-- Group Recipient -->
|
||||
<CMyGroupOnlyView
|
||||
v-if="to_group"
|
||||
:mygrp="to_group"
|
||||
@@ -174,8 +215,6 @@
|
||||
:circuitname="circuitloaded.name"
|
||||
class="recipient-view"
|
||||
/>
|
||||
|
||||
<!-- Community Account Recipient -->
|
||||
<CMyGroupOnlyView
|
||||
v-if="circuitloaded && !!circuitloaded._id && to_contocom"
|
||||
:mygrp="{ groupname: to_contocom }"
|
||||
@@ -187,11 +226,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount Section - Compatto -->
|
||||
<div v-if="circuitsel" class="section-block">
|
||||
<!-- 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()">
|
||||
<div
|
||||
class="amount-input-row"
|
||||
@click="$q.screen.lt.sm ? (showKeyboard = true) : qtyRef?.focus()"
|
||||
>
|
||||
<q-input
|
||||
ref="qtyRef"
|
||||
v-model="qty"
|
||||
@@ -202,7 +243,12 @@
|
||||
: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) <= 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
|
||||
@@ -213,7 +259,10 @@
|
||||
<span class="currency-symbol">€</span>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<div class="coin-badge" :style="`background: ${circuitloaded.color || '#ff5500'}`">
|
||||
<div
|
||||
class="coin-badge"
|
||||
:style="`background: ${circuitloaded.color || '#ff5500'}`"
|
||||
>
|
||||
{{ circuitloaded.symbol }}
|
||||
</div>
|
||||
<q-btn
|
||||
@@ -243,8 +292,8 @@
|
||||
{{ $t('circuit.transactionsEnabled_text') }}
|
||||
</q-banner>
|
||||
|
||||
<!-- Note Section - Compatto -->
|
||||
<div v-if="circuitsel" class="section-block">
|
||||
<!-- Note Section -->
|
||||
<div class="section-block">
|
||||
<q-input
|
||||
ref="causalRef"
|
||||
v-model="causal"
|
||||
@@ -276,20 +325,198 @@
|
||||
</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>
|
||||
|
||||
<!-- Fixed Bottom Actions -->
|
||||
<q-card-actions
|
||||
class="dialog-actions"
|
||||
:class="{ 'fixed-bottom-actions': $q.screen.lt.sm }"
|
||||
>
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<!-- ACTIONS -->
|
||||
<!-- ═══════════════════════════════════════════ -->
|
||||
<q-card-actions class="dialog-actions col-shrink">
|
||||
<q-btn
|
||||
flat
|
||||
:label="t('dialog.cancel')"
|
||||
class="cancel-btn"
|
||||
v-close-popup
|
||||
/>
|
||||
|
||||
<!-- Send Button (solo SEND mode) -->
|
||||
<q-btn
|
||||
v-if="!isReceiveMode"
|
||||
:disable="!ifNextCheck(step)"
|
||||
class="send-btn"
|
||||
:class="{ 'btn-disabled': !ifNextCheck(step) }"
|
||||
@@ -304,12 +531,23 @@
|
||||
/>
|
||||
<q-icon v-else name="send" class="q-ml-sm" />
|
||||
</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>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Numeric Keyboard Dialog -->
|
||||
<!-- Numeric Keyboard Dialog (solo SEND mode) -->
|
||||
<q-dialog
|
||||
v-if="!isReceiveMode"
|
||||
v-model="showKeyboard"
|
||||
position="bottom"
|
||||
seamless
|
||||
@@ -319,19 +557,14 @@
|
||||
<q-card-section class="keyboard-header">
|
||||
<div class="keyboard-header-content">
|
||||
<span class="keyboard-title">Inserisci importo</span>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
label="Fatto"
|
||||
color="primary"
|
||||
class="done-btn"
|
||||
v-close-popup
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-display">
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +577,14 @@
|
||||
@update:model-value="setQty"
|
||||
/>
|
||||
</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-dialog>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
@@ -1 +0,0 @@
|
||||
export {default as CSendRISTo} from './CSendRISTo.vue'
|
||||
156
src/components/Dashboard/ActivityFeed/ActivityFeed.vue
Normal 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>
|
||||
171
src/components/Dashboard/QuickActions/QuickActions.vue
Normal 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>
|
||||
307
src/components/Dashboard/RecentPosters/RecentPosters.vue
Normal 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>
|
||||
207
src/components/Dashboard/StatsCards/StatsCards.vue
Normal 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>
|
||||
181
src/components/Dashboard/TemplateShowcase/TemplateShowcase.vue
Normal 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>
|
||||
128
src/components/EventPosterGenerator/EventPosterGenerator.scss
Executable 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); }
|
||||
218
src/components/EventPosterGenerator/EventPosterGenerator.ts
Executable 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,
|
||||
};
|
||||
}
|
||||
221
src/components/EventPosterGenerator/EventPosterGenerator.vue
Executable 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>
|
||||
1
src/components/EventPosterGenerator/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { default as EventPosterGenerator } from './EventPosterGenerator.vue'
|
||||
271
src/components/PosterGenerator/PosterGenerator.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
391
src/components/PosterGenerator/PosterGenerator.ts
Normal 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
|
||||
};
|
||||
}
|
||||
207
src/components/PosterGenerator/PosterGenerator.vue
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
261
src/components/TemplateBuilder/TemplateBuilder.scss
Normal file
@@ -0,0 +1,261 @@
|
||||
.template-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Header
|
||||
.builder-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
z-index: 100;
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.template-name-input {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
min-width: 200px;
|
||||
|
||||
:deep(.q-field__control) {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
:deep(input) {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main Content
|
||||
.builder-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Sidebars
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.sidebar-right {
|
||||
border-right: none;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.q-tab-panel {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-panel {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
|
||||
p {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas Area
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem;
|
||||
|
||||
.zoom-value {
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
|
||||
// Custom scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #bbb;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
gap: 1rem;
|
||||
|
||||
.format-info {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
// Safe Area Grid
|
||||
.safe-area-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 1200px) {
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
|
||||
&.sidebar-right {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.builder-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
&.sidebar-right {
|
||||
border-left: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-center {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
.body--dark {
|
||||
.template-builder {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.builder-header,
|
||||
.sidebar,
|
||||
.canvas-toolbar,
|
||||
.canvas-footer {
|
||||
background: #2d2d2d;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
background: #404040;
|
||||
}
|
||||
}
|
||||
601
src/components/TemplateBuilder/TemplateBuilder.ts
Normal file
@@ -0,0 +1,601 @@
|
||||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
import { Api } from 'src/store/Api';
|
||||
import type {
|
||||
Template,
|
||||
Layer,
|
||||
LayerType,
|
||||
Position,
|
||||
LayerStyle,
|
||||
Palette,
|
||||
Format
|
||||
} from './types/template.types';
|
||||
import {
|
||||
FORMAT_PRESETS,
|
||||
LAYER_TYPE_INFO,
|
||||
FONT_OPTIONS
|
||||
} from './types/template.types';
|
||||
|
||||
// Default template structure
|
||||
const createDefaultTemplate = (): Template => ({
|
||||
name: 'Nuovo Template',
|
||||
templateType: 'custom',
|
||||
description: '',
|
||||
format: {
|
||||
preset: 'instagram-story',
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
unit: 'px',
|
||||
dpi: 72
|
||||
},
|
||||
safeArea: {
|
||||
top: 0.03,
|
||||
right: 0.04,
|
||||
bottom: 0.03,
|
||||
left: 0.04
|
||||
},
|
||||
backgroundColor: '#1a1a2e',
|
||||
layers: [
|
||||
{
|
||||
id: 'layer_bg',
|
||||
type: 'backgroundImage',
|
||||
zIndex: 0,
|
||||
position: { x: 0, y: 0, w: 1, h: 1 },
|
||||
anchor: 'top-left',
|
||||
required: false,
|
||||
visible: true,
|
||||
locked: false,
|
||||
style: {
|
||||
opacity: 1,
|
||||
objectFit: 'cover'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'layer_title',
|
||||
type: 'title',
|
||||
zIndex: 10,
|
||||
position: { x: 0.5, y: 0.5, w: 0.9, h: 0.1 },
|
||||
anchor: 'center',
|
||||
required: true,
|
||||
visible: true,
|
||||
locked: false,
|
||||
style: {
|
||||
fontFamily: 'Montserrat',
|
||||
fontWeight: 700,
|
||||
fontSize: 64,
|
||||
color: '#ffffff',
|
||||
textAlign: 'center',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 2
|
||||
}
|
||||
}
|
||||
],
|
||||
logoSlots: {
|
||||
enabled: true,
|
||||
maxCount: 3,
|
||||
collapseIfEmpty: true,
|
||||
slots: []
|
||||
},
|
||||
palette: {
|
||||
primary: '#667eea',
|
||||
secondary: '#764ba2',
|
||||
accent: '#ffd700',
|
||||
background: '#1a1a2e',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
textMuted: '#888888'
|
||||
},
|
||||
typography: {
|
||||
titleFont: 'Montserrat',
|
||||
headingFont: 'Bebas Neue',
|
||||
bodyFont: 'Open Sans',
|
||||
accentFont: 'Playfair Display'
|
||||
},
|
||||
defaultAiPromptHints: {
|
||||
backgroundImage: '',
|
||||
mainImage: ''
|
||||
},
|
||||
metadata: {
|
||||
author: 'User',
|
||||
version: '1.0.0',
|
||||
tags: [],
|
||||
isPublic: false
|
||||
}
|
||||
});
|
||||
|
||||
export function useTemplateBuilder() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const $q = useQuasar();
|
||||
|
||||
// Refs
|
||||
const canvasWrapperRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// State
|
||||
const template = reactive<Template>(createDefaultTemplate());
|
||||
const originalTemplate = ref<string>('');
|
||||
const selectedLayerId = ref<string | null>(null);
|
||||
const viewMode = ref<'edit' | 'preview'>('edit');
|
||||
const rightPanelTab = ref<'layer' | 'style' | 'template'>('layer');
|
||||
const zoom = ref(0.5);
|
||||
const showGuides = ref(true);
|
||||
const showSafeArea = ref(true);
|
||||
const showPreviewDialog = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const isPublishing = ref(false);
|
||||
|
||||
// History for undo/redo
|
||||
const history = ref<string[]>([]);
|
||||
const historyIndex = ref(-1);
|
||||
const maxHistoryLength = 50;
|
||||
|
||||
// Preview content
|
||||
const previewContent = reactive({
|
||||
title: 'TITOLO EVENTO',
|
||||
subtitle: 'Sottotitolo descrittivo',
|
||||
eventDate: '15 Ottobre 2025',
|
||||
eventTime: '18:00',
|
||||
location: 'Location Fantastica, Città',
|
||||
contacts: 'info@evento.it | +39 123 456 7890'
|
||||
});
|
||||
|
||||
// Computed
|
||||
const selectedLayer = computed(() => {
|
||||
if (!selectedLayerId.value) return null;
|
||||
return template.layers.find(l => l.id === selectedLayerId.value) || null;
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
return JSON.stringify(template) !== originalTemplate.value;
|
||||
});
|
||||
|
||||
const canUndo = computed(() => historyIndex.value > 0);
|
||||
const canRedo = computed(() => historyIndex.value < history.value.length - 1);
|
||||
|
||||
const fontOptions = computed(() => FONT_OPTIONS);
|
||||
|
||||
// Methods
|
||||
const pushToHistory = () => {
|
||||
const state = JSON.stringify(template);
|
||||
|
||||
// Se siamo nel mezzo della history, rimuovi tutto dopo
|
||||
if (historyIndex.value < history.value.length - 1) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1);
|
||||
}
|
||||
|
||||
history.value.push(state);
|
||||
|
||||
// Limita la lunghezza
|
||||
if (history.value.length > maxHistoryLength) {
|
||||
history.value.shift();
|
||||
} else {
|
||||
historyIndex.value++;
|
||||
}
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (!canUndo.value) return;
|
||||
historyIndex.value--;
|
||||
const state = JSON.parse(history.value[historyIndex.value]);
|
||||
Object.assign(template, state);
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
if (!canRedo.value) return;
|
||||
historyIndex.value++;
|
||||
const state = JSON.parse(history.value[historyIndex.value]);
|
||||
Object.assign(template, state);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (hasUnsavedChanges.value) {
|
||||
$q.dialog({
|
||||
title: 'Modifiche non salvate',
|
||||
message: 'Hai modifiche non salvate. Vuoi uscire senza salvare?',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(() => {
|
||||
router.back();
|
||||
});
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const selectLayer = (layerId: string | null) => {
|
||||
selectedLayerId.value = layerId;
|
||||
if (layerId) {
|
||||
rightPanelTab.value = 'layer';
|
||||
}
|
||||
};
|
||||
|
||||
const generateLayerId = (type: LayerType): string => {
|
||||
const count = template.layers.filter(l => l.type === type).length;
|
||||
return `layer_${type}_${count + 1}_${Date.now().toString(36)}`;
|
||||
};
|
||||
|
||||
const addLayer = (type: LayerType) => {
|
||||
const info = LAYER_TYPE_INFO[type];
|
||||
const isTextLayer = info.category === 'text';
|
||||
const isImageLayer = info.category === 'image';
|
||||
|
||||
const newLayer: Layer = {
|
||||
id: generateLayerId(type),
|
||||
type,
|
||||
zIndex: Math.max(...template.layers.map(l => l.zIndex), 0) + 1,
|
||||
position: { x: 0.5, y: 0.5, w: isTextLayer ? 0.8 : 0.4, h: isTextLayer ? 0.08 : 0.25 },
|
||||
anchor: 'center',
|
||||
required: false,
|
||||
visible: true,
|
||||
locked: false,
|
||||
style: isTextLayer ? {
|
||||
fontFamily: template.typography.bodyFont,
|
||||
fontWeight: 400,
|
||||
fontSize: 32,
|
||||
color: template.palette.text,
|
||||
textAlign: 'center'
|
||||
} : {
|
||||
opacity: 1,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 0
|
||||
}
|
||||
};
|
||||
|
||||
template.layers.push(newLayer);
|
||||
selectedLayerId.value = newLayer.id;
|
||||
pushToHistory();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: `Layer "${info.label}" aggiunto`,
|
||||
position: 'bottom'
|
||||
});
|
||||
};
|
||||
|
||||
const deleteLayer = (layerId: string) => {
|
||||
const index = template.layers.findIndex(l => l.id === layerId);
|
||||
if (index === -1) return;
|
||||
|
||||
const layer = template.layers[index];
|
||||
|
||||
$q.dialog({
|
||||
title: 'Elimina Layer',
|
||||
message: `Vuoi eliminare il layer "${LAYER_TYPE_INFO[layer.type].label}"?`,
|
||||
cancel: true
|
||||
}).onOk(() => {
|
||||
template.layers.splice(index, 1);
|
||||
if (selectedLayerId.value === layerId) {
|
||||
selectedLayerId.value = null;
|
||||
}
|
||||
pushToHistory();
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateLayer = (layerId: string) => {
|
||||
const layer = template.layers.find(l => l.id === layerId);
|
||||
if (!layer) return;
|
||||
|
||||
const newLayer: Layer = {
|
||||
...JSON.parse(JSON.stringify(layer)),
|
||||
id: generateLayerId(layer.type),
|
||||
zIndex: layer.zIndex + 1,
|
||||
position: {
|
||||
...layer.position,
|
||||
x: Math.min(layer.position.x + 0.05, 0.95),
|
||||
y: Math.min(layer.position.y + 0.05, 0.95)
|
||||
}
|
||||
};
|
||||
|
||||
const index = template.layers.findIndex(l => l.id === layerId);
|
||||
template.layers.splice(index + 1, 0, newLayer);
|
||||
selectedLayerId.value = newLayer.id;
|
||||
pushToHistory();
|
||||
|
||||
$q.notify({
|
||||
type: 'info',
|
||||
message: 'Layer duplicato',
|
||||
position: 'bottom'
|
||||
});
|
||||
};
|
||||
|
||||
const reorderLayers = (fromIndex: number, toIndex: number) => {
|
||||
const [layer] = template.layers.splice(fromIndex, 1);
|
||||
template.layers.splice(toIndex, 0, layer);
|
||||
|
||||
// Ricalcola zIndex
|
||||
template.layers.forEach((l, i) => {
|
||||
l.zIndex = i;
|
||||
});
|
||||
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
const toggleLayerVisibility = (layerId: string) => {
|
||||
const layer = template.layers.find(l => l.id === layerId);
|
||||
if (layer) {
|
||||
layer.visible = !layer.visible;
|
||||
pushToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLayerLock = (layerId: string) => {
|
||||
const layer = template.layers.find(l => l.id === layerId);
|
||||
if (layer) {
|
||||
layer.locked = !layer.locked;
|
||||
pushToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const updateLayerPosition = (layerId: string, position: Partial<Position>) => {
|
||||
const layer = template.layers.find(l => l.id === layerId);
|
||||
if (layer && !layer.locked) {
|
||||
Object.assign(layer.position, position);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLayerSize = (layerId: string, size: { w: number; h: number }) => {
|
||||
const layer = template.layers.find(l => l.id === layerId);
|
||||
if (layer && !layer.locked) {
|
||||
layer.position.w = size.w;
|
||||
layer.position.h = size.h;
|
||||
pushToHistory();
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedLayer = (updates: Partial<Layer>) => {
|
||||
if (!selectedLayer.value) return;
|
||||
Object.assign(selectedLayer.value, updates);
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
const updateSelectedLayerStyle = (style: Partial<LayerStyle>) => {
|
||||
if (!selectedLayer.value) return;
|
||||
Object.assign(selectedLayer.value.style, style);
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
const updateFormat = (format: Format) => {
|
||||
Object.assign(template.format, format);
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
const updatePalette = (palette: Partial<Palette>) => {
|
||||
Object.assign(template.palette, palette);
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
const updateBackgroundColor = (color: string) => {
|
||||
template.backgroundColor = color;
|
||||
pushToHistory();
|
||||
};
|
||||
|
||||
// Zoom
|
||||
const zoomIn = () => {
|
||||
zoom.value = Math.min(zoom.value + 0.1, 2);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
zoom.value = Math.max(zoom.value - 0.1, 0.1);
|
||||
};
|
||||
|
||||
const zoomFit = () => {
|
||||
if (!canvasWrapperRef.value) return;
|
||||
|
||||
const wrapper = canvasWrapperRef.value;
|
||||
const wrapperRect = wrapper.getBoundingClientRect();
|
||||
const padding = 40;
|
||||
|
||||
const scaleX = (wrapperRect.width - padding * 2) / template.format.width;
|
||||
const scaleY = (wrapperRect.height - padding * 2) / template.format.height;
|
||||
|
||||
zoom.value = Math.min(scaleX, scaleY, 1);
|
||||
};
|
||||
|
||||
// Save & Publish
|
||||
const saveTemplate = async () => {
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const method = template._id ? 'PUT' : 'POST';
|
||||
const url = template._id
|
||||
? `/api/templates/${template._id}`
|
||||
: '/api/templates';
|
||||
|
||||
const res = await Api.SendReq(url, method, template);
|
||||
|
||||
if (res?.data?.success) {
|
||||
if (!template._id) {
|
||||
template._id = res.data.data._id;
|
||||
}
|
||||
originalTemplate.value = JSON.stringify(template);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Template salvato con successo!',
|
||||
icon: 'check_circle'
|
||||
});
|
||||
} else {
|
||||
throw new Error(res?.data?.error || 'Errore salvataggio');
|
||||
}
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante il salvataggio',
|
||||
icon: 'error'
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const publishTemplate = async () => {
|
||||
isPublishing.value = true;
|
||||
|
||||
try {
|
||||
// Prima salva
|
||||
await saveTemplate();
|
||||
|
||||
// Poi pubblica (rendi pubblico)
|
||||
template.metadata.isPublic = true;
|
||||
await saveTemplate();
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Template pubblicato!',
|
||||
icon: 'public'
|
||||
});
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante la pubblicazione'
|
||||
});
|
||||
} finally {
|
||||
isPublishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load template if editing
|
||||
const loadTemplate = async (id: string) => {
|
||||
try {
|
||||
const res = await Api.SendReq(`/api/templates/${id}`, 'GET');
|
||||
|
||||
if (res?.data?.success) {
|
||||
Object.assign(template, res.data.data);
|
||||
originalTemplate.value = JSON.stringify(template);
|
||||
pushToHistory();
|
||||
}
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'Errore caricamento template'
|
||||
});
|
||||
router.push('/templates');
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
// Ctrl/Cmd + S = Save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveTemplate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Z = Undo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + Shift + Z = Redo
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete/Backspace = Delete selected layer
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedLayerId.value) {
|
||||
// Solo se non siamo in un input
|
||||
if ((e.target as HTMLElement).tagName !== 'INPUT' &&
|
||||
(e.target as HTMLElement).tagName !== 'TEXTAREA') {
|
||||
e.preventDefault();
|
||||
deleteLayer(selectedLayerId.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape = Deselect
|
||||
if (e.key === 'Escape') {
|
||||
selectedLayerId.value = null;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + D = Duplicate
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'd' && selectedLayerId.value) {
|
||||
e.preventDefault();
|
||||
duplicateLayer(selectedLayerId.value);
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Load template if ID in route
|
||||
const templateId = route.params.id as string;
|
||||
if (templateId && templateId !== 'new') {
|
||||
loadTemplate(templateId);
|
||||
} else {
|
||||
originalTemplate.value = JSON.stringify(template);
|
||||
pushToHistory();
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
|
||||
// Fit to screen after mount
|
||||
setTimeout(zoomFit, 100);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
// Watch for history on template changes (debounced)
|
||||
let historyTimeout: ReturnType<typeof setTimeout>;
|
||||
watch(
|
||||
() => JSON.stringify(template),
|
||||
() => {
|
||||
clearTimeout(historyTimeout);
|
||||
historyTimeout = setTimeout(() => {
|
||||
// Non aggiungere alla history durante undo/redo
|
||||
}, 500);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
template,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
viewMode,
|
||||
rightPanelTab,
|
||||
zoom,
|
||||
showGuides,
|
||||
showSafeArea,
|
||||
showPreviewDialog,
|
||||
previewContent,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
isPublishing,
|
||||
canUndo,
|
||||
canRedo,
|
||||
fontOptions,
|
||||
canvasWrapperRef,
|
||||
|
||||
// Methods
|
||||
handleBack,
|
||||
selectLayer,
|
||||
addLayer,
|
||||
deleteLayer,
|
||||
duplicateLayer,
|
||||
reorderLayers,
|
||||
toggleLayerVisibility,
|
||||
toggleLayerLock,
|
||||
updateLayerPosition,
|
||||
updateLayerSize,
|
||||
updateSelectedLayer,
|
||||
updateSelectedLayerStyle,
|
||||
updateFormat,
|
||||
updatePalette,
|
||||
updateBackgroundColor,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomFit,
|
||||
undo,
|
||||
redo,
|
||||
saveTemplate,
|
||||
publishTemplate
|
||||
};
|
||||
}
|
||||
523
src/components/TemplateBuilder/TemplateBuilder.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="template-builder">
|
||||
<!-- Header -->
|
||||
<header class="builder-header">
|
||||
<div class="header-left">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
@click="handleBack"
|
||||
/>
|
||||
<q-input
|
||||
v-model="template.name"
|
||||
dense
|
||||
borderless
|
||||
class="template-name-input"
|
||||
placeholder="Nome Template"
|
||||
/>
|
||||
<q-chip
|
||||
:color="hasUnsavedChanges ? 'orange' : 'green'"
|
||||
text-color="white"
|
||||
size="sm"
|
||||
>
|
||||
{{ hasUnsavedChanges ? 'Non salvato' : 'Salvato' }}
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<q-btn-toggle
|
||||
v-model="viewMode"
|
||||
flat
|
||||
toggle-color="primary"
|
||||
:options="[
|
||||
{ value: 'edit', slot: 'edit' },
|
||||
{ value: 'preview', slot: 'preview' }
|
||||
]"
|
||||
>
|
||||
<template #edit>
|
||||
<q-icon name="edit" class="q-mr-xs" /> Editor
|
||||
</template>
|
||||
<template #preview>
|
||||
<q-icon name="visibility" class="q-mr-xs" /> Anteprima
|
||||
</template>
|
||||
</q-btn-toggle>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<q-btn
|
||||
flat
|
||||
icon="undo"
|
||||
:disable="!canUndo"
|
||||
@click="undo"
|
||||
>
|
||||
<q-tooltip>Annulla</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
icon="redo"
|
||||
:disable="!canRedo"
|
||||
@click="redo"
|
||||
>
|
||||
<q-tooltip>Ripeti</q-tooltip>
|
||||
</q-btn>
|
||||
<q-separator vertical class="q-mx-sm" />
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
icon="save"
|
||||
label="Salva"
|
||||
:loading="isSaving"
|
||||
@click="saveTemplate"
|
||||
/>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="publish"
|
||||
label="Pubblica"
|
||||
:loading="isPublishing"
|
||||
@click="publishTemplate"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="builder-content">
|
||||
<!-- Left Sidebar: Layers -->
|
||||
<aside class="sidebar sidebar-left">
|
||||
<LayerPanel
|
||||
:layers="template.layers"
|
||||
:selected-layer-id="selectedLayerId"
|
||||
:logo-slots="template.logoSlots"
|
||||
@select="selectLayer"
|
||||
@add="addLayer"
|
||||
@delete="deleteLayer"
|
||||
@duplicate="duplicateLayer"
|
||||
@reorder="reorderLayers"
|
||||
@toggle-visibility="toggleLayerVisibility"
|
||||
@toggle-lock="toggleLayerLock"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Center: Canvas -->
|
||||
<main class="canvas-area">
|
||||
<div class="canvas-toolbar">
|
||||
<FormatSelector
|
||||
:format="template.format"
|
||||
@update="updateFormat"
|
||||
/>
|
||||
<q-space />
|
||||
<div class="zoom-controls">
|
||||
<q-btn flat dense icon="remove" @click="zoomOut" />
|
||||
<span class="zoom-value">{{ Math.round(zoom * 100) }}%</span>
|
||||
<q-btn flat dense icon="add" @click="zoomIn" />
|
||||
<q-btn flat dense icon="fit_screen" @click="zoomFit">
|
||||
<q-tooltip>Adatta alla finestra</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-wrapper" ref="canvasWrapperRef">
|
||||
<CanvasPreview
|
||||
:template="template"
|
||||
:selected-layer-id="selectedLayerId"
|
||||
:zoom="zoom"
|
||||
:show-guides="showGuides"
|
||||
:show-safe-area="showSafeArea"
|
||||
:preview-content="previewContent"
|
||||
:view-mode="viewMode"
|
||||
@select-layer="selectLayer"
|
||||
@update-layer-position="updateLayerPosition"
|
||||
@update-layer-size="updateLayerSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="canvas-footer">
|
||||
<q-toggle
|
||||
v-model="showGuides"
|
||||
label="Guide"
|
||||
dense
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="showSafeArea"
|
||||
label="Area sicura"
|
||||
dense
|
||||
/>
|
||||
<q-space />
|
||||
<span class="format-info">
|
||||
{{ template.format.width }} × {{ template.format.height }} px
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Right Sidebar: Properties -->
|
||||
<aside class="sidebar sidebar-right">
|
||||
<q-tabs
|
||||
v-model="rightPanelTab"
|
||||
dense
|
||||
align="justify"
|
||||
class="panel-tabs"
|
||||
>
|
||||
<q-tab name="layer" icon="layers" label="Layer" />
|
||||
<q-tab name="style" icon="palette" label="Stile" />
|
||||
<q-tab name="template" icon="tune" label="Template" />
|
||||
</q-tabs>
|
||||
|
||||
<q-tab-panels v-model="rightPanelTab" animated class="panel-content">
|
||||
<!-- Layer Properties -->
|
||||
<q-tab-panel name="layer" class="q-pa-none">
|
||||
<LayerEditor
|
||||
v-if="selectedLayer"
|
||||
:layer="selectedLayer"
|
||||
:palette="template.palette"
|
||||
:typography="template.typography"
|
||||
@update="updateSelectedLayer"
|
||||
/>
|
||||
<div v-else class="empty-panel">
|
||||
<q-icon name="touch_app" size="48px" color="grey-5" />
|
||||
<p>Seleziona un layer per modificarlo</p>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Style Properties -->
|
||||
<q-tab-panel name="style" class="q-pa-none">
|
||||
<StyleEditor
|
||||
v-if="selectedLayer"
|
||||
:style="selectedLayer.style"
|
||||
:layer-type="selectedLayer.type"
|
||||
:palette="template.palette"
|
||||
:typography="template.typography"
|
||||
@update="updateSelectedLayerStyle"
|
||||
/>
|
||||
<div v-else class="empty-panel">
|
||||
<q-icon name="format_paint" size="48px" color="grey-5" />
|
||||
<p>Seleziona un layer per modificarne lo stile</p>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Template Properties -->
|
||||
<q-tab-panel name="template" class="q-pa-none">
|
||||
<q-scroll-area class="scroll-panel">
|
||||
<!-- Template Info -->
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="info"
|
||||
label="Informazioni"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<div class="panel-section">
|
||||
<q-input
|
||||
v-model="template.name"
|
||||
filled
|
||||
dense
|
||||
label="Nome Template"
|
||||
/>
|
||||
<q-input
|
||||
v-model="template.templateType"
|
||||
filled
|
||||
dense
|
||||
label="Tipo/Categoria"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-model="template.description"
|
||||
filled
|
||||
dense
|
||||
type="textarea"
|
||||
rows="2"
|
||||
label="Descrizione"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-select
|
||||
v-model="template.metadata.tags"
|
||||
filled
|
||||
dense
|
||||
multiple
|
||||
use-chips
|
||||
use-input
|
||||
new-value-mode="add-unique"
|
||||
label="Tag"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Palette -->
|
||||
<q-expansion-item
|
||||
icon="palette"
|
||||
label="Palette Colori"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<PaletteEditor
|
||||
:palette="template.palette"
|
||||
:background-color="template.backgroundColor"
|
||||
@update:palette="updatePalette"
|
||||
@update:background-color="updateBackgroundColor"
|
||||
/>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Typography -->
|
||||
<q-expansion-item
|
||||
icon="text_fields"
|
||||
label="Tipografia"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<div class="panel-section">
|
||||
<q-select
|
||||
v-model="template.typography.titleFont"
|
||||
:options="fontOptions"
|
||||
filled
|
||||
dense
|
||||
label="Font Titoli"
|
||||
/>
|
||||
<q-select
|
||||
v-model="template.typography.headingFont"
|
||||
:options="fontOptions"
|
||||
filled
|
||||
dense
|
||||
label="Font Heading"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-select
|
||||
v-model="template.typography.bodyFont"
|
||||
:options="fontOptions"
|
||||
filled
|
||||
dense
|
||||
label="Font Corpo"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-select
|
||||
v-model="template.typography.accentFont"
|
||||
:options="fontOptions"
|
||||
filled
|
||||
dense
|
||||
label="Font Accento"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Safe Area -->
|
||||
<q-expansion-item
|
||||
icon="crop_free"
|
||||
label="Area Sicura"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<div class="panel-section">
|
||||
<div class="safe-area-grid">
|
||||
<q-input
|
||||
v-model.number="template.safeArea.top"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Top"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="0.5"
|
||||
/>
|
||||
<q-input
|
||||
v-model.number="template.safeArea.right"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Destra"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="0.5"
|
||||
/>
|
||||
<q-input
|
||||
v-model.number="template.safeArea.bottom"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Bottom"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="0.5"
|
||||
/>
|
||||
<q-input
|
||||
v-model.number="template.safeArea.left"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Sinistra"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- AI Prompt Hints -->
|
||||
<q-expansion-item
|
||||
icon="auto_awesome"
|
||||
label="Suggerimenti AI"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<div class="panel-section">
|
||||
<q-input
|
||||
v-model="template.defaultAiPromptHints.backgroundImage"
|
||||
filled
|
||||
dense
|
||||
type="textarea"
|
||||
rows="3"
|
||||
label="Prompt Sfondo"
|
||||
hint="Suggerimento per generare lo sfondo con AI"
|
||||
/>
|
||||
<q-input
|
||||
v-model="template.defaultAiPromptHints.mainImage"
|
||||
filled
|
||||
dense
|
||||
type="textarea"
|
||||
rows="3"
|
||||
label="Prompt Immagine Principale"
|
||||
hint="Suggerimento per generare l'immagine principale"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Logo Slots -->
|
||||
<q-expansion-item
|
||||
icon="branding_watermark"
|
||||
label="Slot Logo"
|
||||
header-class="panel-section-header"
|
||||
>
|
||||
<div class="panel-section">
|
||||
<q-toggle
|
||||
v-model="template.logoSlots.enabled"
|
||||
label="Abilita slot logo"
|
||||
/>
|
||||
<q-input
|
||||
v-if="template.logoSlots.enabled"
|
||||
v-model.number="template.logoSlots.maxCount"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Numero massimo loghi"
|
||||
min="1"
|
||||
max="10"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-toggle
|
||||
v-if="template.logoSlots.enabled"
|
||||
v-model="template.logoSlots.collapseIfEmpty"
|
||||
label="Nascondi se vuoti"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-scroll-area>
|
||||
</q-tab-panel>
|
||||
</q-tab-panels>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Preview Content Dialog -->
|
||||
<q-dialog v-model="showPreviewDialog">
|
||||
<q-card style="min-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Contenuto Anteprima</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
v-model="previewContent.title"
|
||||
filled
|
||||
dense
|
||||
label="Titolo"
|
||||
/>
|
||||
<q-input
|
||||
v-model="previewContent.subtitle"
|
||||
filled
|
||||
dense
|
||||
label="Sottotitolo"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-model="previewContent.eventDate"
|
||||
filled
|
||||
dense
|
||||
label="Data"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-model="previewContent.location"
|
||||
filled
|
||||
dense
|
||||
label="Luogo"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-model="previewContent.contacts"
|
||||
filled
|
||||
dense
|
||||
label="Contatti"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Chiudi" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateBuilder } from './TemplateBuilder';
|
||||
import LayerPanel from './components/LayerPanel/LayerPanel.vue';
|
||||
import CanvasPreview from './components/CanvasPreview/CanvasPreview.vue';
|
||||
import LayerEditor from './components/LayerEditor/LayerEditor.vue';
|
||||
import StyleEditor from './components/StyleEditor/StyleEditor.vue';
|
||||
import PaletteEditor from './components/PaletteEditor/PaletteEditor.vue';
|
||||
import FormatSelector from './components/FormatSelector/FormatSelector.vue';
|
||||
|
||||
const {
|
||||
// State
|
||||
template,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
viewMode,
|
||||
rightPanelTab,
|
||||
zoom,
|
||||
showGuides,
|
||||
showSafeArea,
|
||||
showPreviewDialog,
|
||||
previewContent,
|
||||
hasUnsavedChanges,
|
||||
isSaving,
|
||||
isPublishing,
|
||||
canUndo,
|
||||
canRedo,
|
||||
fontOptions,
|
||||
canvasWrapperRef,
|
||||
|
||||
// Methods
|
||||
handleBack,
|
||||
selectLayer,
|
||||
addLayer,
|
||||
deleteLayer,
|
||||
duplicateLayer,
|
||||
reorderLayers,
|
||||
toggleLayerVisibility,
|
||||
toggleLayerLock,
|
||||
updateLayerPosition,
|
||||
updateLayerSize,
|
||||
updateSelectedLayer,
|
||||
updateSelectedLayerStyle,
|
||||
updateFormat,
|
||||
updatePalette,
|
||||
updateBackgroundColor,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomFit,
|
||||
undo,
|
||||
redo,
|
||||
saveTemplate,
|
||||
publishTemplate
|
||||
} = useTemplateBuilder();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './TemplateBuilder.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
.canvas-preview {
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
// Grid Guides
|
||||
.grid-guides {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
|
||||
.guide {
|
||||
position: absolute;
|
||||
background: rgba(0, 150, 255, 0.3);
|
||||
|
||||
&.guide-h {
|
||||
height: 1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
&.guide-center {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
&.guide-third-1 {
|
||||
top: 33.33%;
|
||||
}
|
||||
|
||||
&.guide-third-2 {
|
||||
top: 66.66%;
|
||||
}
|
||||
}
|
||||
|
||||
&.guide-v {
|
||||
width: 1px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
&.guide-center {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
&.guide-third-1 {
|
||||
left: 33.33%;
|
||||
}
|
||||
|
||||
&.guide-third-2 {
|
||||
left: 66.66%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer Wrapper
|
||||
.layer-wrapper {
|
||||
transition: box-shadow 0.15s ease;
|
||||
|
||||
&.is-selected {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
&.is-locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:hover:not(.is-locked) {
|
||||
box-shadow: 0 0 0 1px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Selection Border & Handles
|
||||
.selection-border {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border: 2px solid #667eea;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 2px;
|
||||
z-index: 20;
|
||||
|
||||
&.handle-nw {
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
&.handle-n {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
&.handle-ne {
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
&.handle-e {
|
||||
top: 50%;
|
||||
right: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
&.handle-se {
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
&.handle-s {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
&.handle-sw {
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
&.handle-w {
|
||||
top: 50%;
|
||||
left: -5px;
|
||||
transform: translateY(-50%);
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #667eea;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer Info Tooltip
|
||||
.layer-info-tooltip {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import type { Layer, LayerType, Position } from '../../types/template.types';
|
||||
import { LAYER_TYPE_INFO } from '../../types/template.types';
|
||||
|
||||
export function useCanvasPreview(props: any, emit: any) {
|
||||
// Drag state
|
||||
const isDragging = ref(false);
|
||||
const isResizing = ref(false);
|
||||
const dragStartPos = ref({ x: 0, y: 0 });
|
||||
const dragStartLayerPos = ref<Position | null>(null);
|
||||
const resizeHandle = ref<string | null>(null);
|
||||
|
||||
// Computed
|
||||
const sortedVisibleLayers = computed(() => {
|
||||
return [...props.template.layers]
|
||||
.filter(l => l.visible)
|
||||
.sort((a, b) => a.zIndex - b.zIndex);
|
||||
});
|
||||
|
||||
const canvasContainerStyle = computed(() => ({
|
||||
width: `${props.template.format.width * props.zoom}px`,
|
||||
height: `${props.template.format.height * props.zoom}px`,
|
||||
transform: `scale(1)`,
|
||||
position: 'relative' as const,
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.3)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}));
|
||||
|
||||
const safeAreaStyle = computed(() => {
|
||||
const sa = props.template.safeArea;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
top: `${sa.top * 100}%`,
|
||||
right: `${sa.right * 100}%`,
|
||||
bottom: `${sa.bottom * 100}%`,
|
||||
left: `${sa.left * 100}%`,
|
||||
border: '1px dashed rgba(255, 0, 0, 0.4)',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 1000
|
||||
};
|
||||
});
|
||||
|
||||
const selectedLayer = computed(() => {
|
||||
if (!props.selectedLayerId) return null;
|
||||
return props.template.layers.find((l: Layer) => l.id === props.selectedLayerId);
|
||||
});
|
||||
|
||||
const selectedLayerInfo = computed(() => {
|
||||
if (!selectedLayer.value) return '';
|
||||
const layer = selectedLayer.value;
|
||||
const info = LAYER_TYPE_INFO[layer.type as LayerType];
|
||||
const pos = layer.position;
|
||||
return `${info.label} | X: ${Math.round(pos.x * 100)}% Y: ${Math.round(pos.y * 100)}% | ${Math.round(pos.w * 100)}% × ${Math.round(pos.h * 100)}%`;
|
||||
});
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (!selectedLayer.value) return { display: 'none' };
|
||||
const pos = selectedLayer.value.position;
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${pos.x * 100}%`,
|
||||
top: `${(pos.y - pos.h / 2) * 100 - 2}%`,
|
||||
transform: 'translateX(-50%) translateY(-100%)'
|
||||
};
|
||||
});
|
||||
|
||||
const resizeHandles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
|
||||
|
||||
// Methods
|
||||
const isTextLayer = (type: LayerType) => {
|
||||
return LAYER_TYPE_INFO[type]?.category === 'text';
|
||||
};
|
||||
|
||||
const isImageLayer = (type: LayerType) => {
|
||||
return LAYER_TYPE_INFO[type]?.category === 'image';
|
||||
};
|
||||
|
||||
const getLayerStyle = (layer: Layer) => {
|
||||
const pos = layer.position;
|
||||
const anchor = layer.anchor || 'center';
|
||||
|
||||
let transform = '';
|
||||
|
||||
// Calcola transform basato su anchor
|
||||
switch (anchor) {
|
||||
case 'top-left':
|
||||
break;
|
||||
case 'top-center':
|
||||
transform = 'translateX(-50%)';
|
||||
break;
|
||||
case 'top-right':
|
||||
transform = 'translateX(-100%)';
|
||||
break;
|
||||
case 'center-left':
|
||||
transform = 'translateY(-50%)';
|
||||
break;
|
||||
case 'center':
|
||||
transform = 'translate(-50%, -50%)';
|
||||
break;
|
||||
case 'center-right':
|
||||
transform = 'translate(-100%, -50%)';
|
||||
break;
|
||||
case 'bottom-left':
|
||||
transform = 'translateY(-100%)';
|
||||
break;
|
||||
case 'bottom-center':
|
||||
transform = 'translate(-50%, -100%)';
|
||||
break;
|
||||
case 'bottom-right':
|
||||
transform = 'translate(-100%, -100%)';
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: `${pos.x * 100}%`,
|
||||
top: `${pos.y * 100}%`,
|
||||
width: `${pos.w * 100}%`,
|
||||
height: `${pos.h * 100}%`,
|
||||
transform,
|
||||
zIndex: layer.zIndex,
|
||||
opacity: layer.style?.opacity ?? 1,
|
||||
cursor: layer.locked ? 'not-allowed' : (props.viewMode === 'edit' ? 'move' : 'default')
|
||||
};
|
||||
};
|
||||
|
||||
const getLayerContent = (layer: Layer) => {
|
||||
const content = props.previewContent;
|
||||
|
||||
switch (layer.type) {
|
||||
case 'title':
|
||||
return content.title || 'Titolo';
|
||||
case 'subtitle':
|
||||
return content.subtitle || 'Sottotitolo';
|
||||
case 'eventDate':
|
||||
return content.eventTime
|
||||
? `${content.eventDate} • ${content.eventTime}`
|
||||
: content.eventDate || 'Data Evento';
|
||||
case 'eventTime':
|
||||
return content.eventTime || '00:00';
|
||||
case 'location':
|
||||
return content.location || 'Luogo Evento';
|
||||
case 'contacts':
|
||||
return content.contacts || 'Contatti';
|
||||
case 'extraText':
|
||||
return 'Testo aggiuntivo';
|
||||
case 'customText':
|
||||
return layer.defaultValue || 'Testo personalizzato';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getLayerComponent = (type: LayerType) => {
|
||||
const category = LAYER_TYPE_INFO[type]?.category;
|
||||
|
||||
switch (category) {
|
||||
case 'text':
|
||||
return 'TextLayerRenderer';
|
||||
case 'image':
|
||||
return 'ImageLayerRenderer';
|
||||
case 'decoration':
|
||||
if (type === 'divider') return 'DividerLayerRenderer';
|
||||
return 'ShapeLayerRenderer';
|
||||
default:
|
||||
return 'div';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: MouseEvent) => {
|
||||
// Click sul canvas vuoto = deseleziona
|
||||
emit('selectLayer', null);
|
||||
};
|
||||
|
||||
const handleLayerMouseDown = (e: MouseEvent, layer: Layer) => {
|
||||
if (props.viewMode !== 'edit' || layer.locked) return;
|
||||
|
||||
emit('selectLayer', layer.id);
|
||||
|
||||
isDragging.value = true;
|
||||
dragStartPos.value = { x: e.clientX, y: e.clientY };
|
||||
dragStartLayerPos.value = { ...layer.position };
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleResizeMouseDown = (e: MouseEvent, handle: string) => {
|
||||
if (!selectedLayer.value || selectedLayer.value.locked) return;
|
||||
|
||||
isResizing.value = true;
|
||||
resizeHandle.value = handle;
|
||||
dragStartPos.value = { x: e.clientX, y: e.clientY };
|
||||
dragStartLayerPos.value = { ...selectedLayer.value.position };
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value && !isResizing.value) return;
|
||||
if (!dragStartLayerPos.value) return;
|
||||
|
||||
const canvasWidth = props.template.format.width * props.zoom;
|
||||
const canvasHeight = props.template.format.height * props.zoom;
|
||||
|
||||
const deltaX = (e.clientX - dragStartPos.value.x) / canvasWidth;
|
||||
const deltaY = (e.clientY - dragStartPos.value.y) / canvasHeight;
|
||||
|
||||
if (isDragging.value && props.selectedLayerId) {
|
||||
const newX = Math.max(0, Math.min(1, dragStartLayerPos.value.x + deltaX));
|
||||
const newY = Math.max(0, Math.min(1, dragStartLayerPos.value.y + deltaY));
|
||||
|
||||
emit('updateLayerPosition', props.selectedLayerId, { x: newX, y: newY });
|
||||
}
|
||||
|
||||
if (isResizing.value && props.selectedLayerId && resizeHandle.value) {
|
||||
const handle = resizeHandle.value;
|
||||
let newW = dragStartLayerPos.value.w;
|
||||
let newH = dragStartLayerPos.value.h;
|
||||
let newX = dragStartLayerPos.value.x;
|
||||
let newY = dragStartLayerPos.value.y;
|
||||
|
||||
// Handle resize based on direction
|
||||
if (handle.includes('e')) {
|
||||
newW = Math.max(0.05, dragStartLayerPos.value.w + deltaX);
|
||||
}
|
||||
if (handle.includes('w')) {
|
||||
newW = Math.max(0.05, dragStartLayerPos.value.w - deltaX);
|
||||
newX = dragStartLayerPos.value.x + deltaX;
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
newH = Math.max(0.02, dragStartLayerPos.value.h + deltaY);
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
newH = Math.max(0.02, dragStartLayerPos.value.h - deltaY);
|
||||
newY = dragStartLayerPos.value.y + deltaY;
|
||||
}
|
||||
|
||||
emit('updateLayerPosition', props.selectedLayerId, { x: newX, y: newY });
|
||||
emit('updateLayerSize', props.selectedLayerId, { w: newW, h: newH });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false;
|
||||
isResizing.value = false;
|
||||
resizeHandle.value = null;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
return {
|
||||
sortedVisibleLayers,
|
||||
canvasContainerStyle,
|
||||
safeAreaStyle,
|
||||
tooltipStyle,
|
||||
selectedLayerInfo,
|
||||
resizeHandles,
|
||||
getLayerStyle,
|
||||
getLayerContent,
|
||||
getLayerComponent,
|
||||
isTextLayer,
|
||||
isImageLayer,
|
||||
handleCanvasMouseDown,
|
||||
handleLayerMouseDown,
|
||||
handleResizeMouseDown
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div
|
||||
class="canvas-preview"
|
||||
:style="canvasContainerStyle"
|
||||
@mousedown="handleCanvasMouseDown"
|
||||
>
|
||||
<!-- Canvas Background -->
|
||||
<div
|
||||
class="canvas-background"
|
||||
:style="{ background: template.backgroundColor }"
|
||||
/>
|
||||
|
||||
<!-- Safe Area Overlay -->
|
||||
<div
|
||||
v-if="showSafeArea"
|
||||
class="safe-area-overlay"
|
||||
:style="safeAreaStyle"
|
||||
/>
|
||||
|
||||
<!-- Grid Guides -->
|
||||
<div v-if="showGuides" class="grid-guides">
|
||||
<div class="guide guide-h guide-center" />
|
||||
<div class="guide guide-v guide-center" />
|
||||
<div class="guide guide-h guide-third-1" />
|
||||
<div class="guide guide-h guide-third-2" />
|
||||
<div class="guide guide-v guide-third-1" />
|
||||
<div class="guide guide-v guide-third-2" />
|
||||
</div>
|
||||
|
||||
<!-- Layers -->
|
||||
<div
|
||||
v-for="layer in sortedVisibleLayers"
|
||||
:key="layer.id"
|
||||
class="layer-wrapper"
|
||||
:class="{
|
||||
'is-selected': layer.id === selectedLayerId,
|
||||
'is-locked': layer.locked,
|
||||
'is-text': isTextLayer(layer.type),
|
||||
'is-image': isImageLayer(layer.type)
|
||||
}"
|
||||
:style="getLayerStyle(layer)"
|
||||
@mousedown.stop="handleLayerMouseDown($event, layer)"
|
||||
>
|
||||
<!-- Layer Content -->
|
||||
<component
|
||||
:is="getLayerComponent(layer.type)"
|
||||
:layer="layer"
|
||||
:content="getLayerContent(layer)"
|
||||
:palette="template.palette"
|
||||
:view-mode="viewMode"
|
||||
/>
|
||||
|
||||
<!-- Selection Handles -->
|
||||
<template v-if="layer.id === selectedLayerId && !layer.locked && viewMode === 'edit'">
|
||||
<div class="selection-border" />
|
||||
<div
|
||||
v-for="handle in resizeHandles"
|
||||
:key="handle"
|
||||
class="resize-handle"
|
||||
:class="`handle-${handle}`"
|
||||
@mousedown.stop="handleResizeMouseDown($event, handle)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Layer Info Tooltip -->
|
||||
<div
|
||||
v-if="selectedLayerId && viewMode === 'edit'"
|
||||
class="layer-info-tooltip"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
{{ selectedLayerInfo }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCanvasPreview } from './CanvasPreview';
|
||||
import TextLayerRenderer from './renderers/TextLayerRenderer.vue';
|
||||
import ImageLayerRenderer from './renderers/ImageLayerRenderer.vue';
|
||||
import ShapeLayerRenderer from './renderers/ShapeLayerRenderer.vue';
|
||||
import DividerLayerRenderer from './renderers/DividerLayerRenderer.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
template: any;
|
||||
selectedLayerId: string | null;
|
||||
zoom: number;
|
||||
showGuides: boolean;
|
||||
showSafeArea: boolean;
|
||||
previewContent: any;
|
||||
viewMode: 'edit' | 'preview';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectLayer', layerId: string | null): void;
|
||||
(e: 'updateLayerPosition', layerId: string, position: { x?: number; y?: number }): void;
|
||||
(e: 'updateLayerSize', layerId: string, size: { w: number; h: number }): void;
|
||||
}>();
|
||||
|
||||
const {
|
||||
sortedVisibleLayers,
|
||||
canvasContainerStyle,
|
||||
safeAreaStyle,
|
||||
tooltipStyle,
|
||||
selectedLayerInfo,
|
||||
resizeHandles,
|
||||
getLayerStyle,
|
||||
getLayerContent,
|
||||
getLayerComponent,
|
||||
isTextLayer,
|
||||
isImageLayer,
|
||||
handleCanvasMouseDown,
|
||||
handleLayerMouseDown,
|
||||
handleResizeMouseDown
|
||||
} = useCanvasPreview(props, emit);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './CanvasPreview.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div
|
||||
class="image-layer-renderer"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
v-if="!hasImage"
|
||||
class="image-placeholder"
|
||||
>
|
||||
<q-icon :name="placeholderIcon" size="32px" />
|
||||
<span>{{ placeholderText }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
:src="imageSrc"
|
||||
:style="imageStyle"
|
||||
alt=""
|
||||
/>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
v-if="layer.style?.overlay?.enabled"
|
||||
class="image-overlay"
|
||||
:style="overlayStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYER_TYPE_INFO } from '../../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
layer: any;
|
||||
content: string;
|
||||
palette: any;
|
||||
viewMode: 'edit' | 'preview';
|
||||
}>();
|
||||
|
||||
const hasImage = computed(() => false);
|
||||
const imageSrc = computed(() => '');
|
||||
|
||||
const placeholderIcon = computed(() => {
|
||||
return LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.icon || 'image';
|
||||
});
|
||||
|
||||
const placeholderText = computed(() => {
|
||||
return LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.label || 'Immagine';
|
||||
});
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
const style = props.layer.style || {};
|
||||
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: `${style.borderRadius || 0}px`,
|
||||
overflow: 'hidden',
|
||||
position: 'relative' as const,
|
||||
background: props.layer.type === 'backgroundImage'
|
||||
? props.palette?.background || '#333'
|
||||
: 'rgba(128,128,128,0.2)'
|
||||
};
|
||||
});
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
const style = props.layer.style || {};
|
||||
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: style.objectFit || 'cover',
|
||||
filter: style.blur ? `blur(${style.blur}px)` : 'none'
|
||||
};
|
||||
});
|
||||
|
||||
const overlayStyle = computed(() => {
|
||||
const overlay = props.layer.style?.overlay;
|
||||
if (!overlay?.enabled) return {};
|
||||
|
||||
if (overlay.type === 'solid') {
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
background: overlay.color || 'rgba(0,0,0,0.5)'
|
||||
};
|
||||
}
|
||||
|
||||
// Gradient
|
||||
const direction = overlay.direction || 'to-bottom';
|
||||
const dirMap: Record<string, string> = {
|
||||
'to-bottom': '180deg',
|
||||
'to-top': '0deg',
|
||||
'to-right': '90deg',
|
||||
'to-left': '270deg',
|
||||
'to-bottom-right': '135deg'
|
||||
};
|
||||
|
||||
let gradientStops = 'rgba(0,0,0,0), rgba(0,0,0,0.7)';
|
||||
if (overlay.stops?.length) {
|
||||
gradientStops = overlay.stops
|
||||
.map((s: any) => `${s.color} ${s.position * 100}%`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
background: `linear-gradient(${dirMap[direction] || '180deg'}, ${gradientStops})`
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-layer-renderer {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div
|
||||
class="shape-layer-renderer"
|
||||
:style="shapeStyle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
layer: any;
|
||||
content: string;
|
||||
palette: any;
|
||||
viewMode: 'edit' | 'preview';
|
||||
}>();
|
||||
|
||||
const shapeStyle = computed(() => {
|
||||
const style = props.layer.style || {};
|
||||
|
||||
const css: Record<string, any> = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: style.fill || props.palette?.primary || 'rgba(255,255,255,0.2)',
|
||||
opacity: style.opacity ?? 1,
|
||||
borderRadius: style.shape === 'circle' ? '50%' : `${style.borderRadius || 0}px`
|
||||
};
|
||||
|
||||
if (style.border?.enabled) {
|
||||
css.border = `${style.border.width || 2}px ${style.border.style || 'solid'} ${style.border.color || '#fff'}`;
|
||||
}
|
||||
|
||||
if (style.shadow?.enabled) {
|
||||
css.boxShadow = `${style.shadow.offsetX || 0}px ${style.shadow.offsetY || 0}px ${style.shadow.blur || 10}px ${style.shadow.color || 'rgba(0,0,0,0.3)'}`;
|
||||
}
|
||||
|
||||
return css;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shape-layer-renderer {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-layer-renderer"
|
||||
:style="textStyle"
|
||||
>
|
||||
{{ content }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
layer: any;
|
||||
content: string;
|
||||
palette: any;
|
||||
viewMode: 'edit' | 'preview';
|
||||
}>();
|
||||
|
||||
const textStyle = computed(() => {
|
||||
const style = props.layer.style || {};
|
||||
|
||||
const css: Record<string, any> = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontFamily: `"${style.fontFamily || 'Open Sans'}", sans-serif`,
|
||||
fontWeight: style.fontWeight || 400,
|
||||
fontSize: `${style.fontSize || 32}px`,
|
||||
fontStyle: style.fontStyle || 'normal',
|
||||
color: style.color || props.palette?.text || '#ffffff',
|
||||
textAlign: style.textAlign || 'center',
|
||||
textTransform: style.textTransform || 'none',
|
||||
letterSpacing: `${style.letterSpacing || 0}px`,
|
||||
lineHeight: style.lineHeight || 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 8px',
|
||||
boxSizing: 'border-box'
|
||||
};
|
||||
|
||||
// Justify content based on text align
|
||||
if (style.textAlign === 'left') {
|
||||
css.justifyContent = 'flex-start';
|
||||
} else if (style.textAlign === 'right') {
|
||||
css.justifyContent = 'flex-end';
|
||||
} else {
|
||||
css.justifyContent = 'center';
|
||||
}
|
||||
|
||||
// Text shadow
|
||||
if (style.shadow?.enabled) {
|
||||
css.textShadow = `${style.shadow.offsetX || 0}px ${style.shadow.offsetY || 0}px ${style.shadow.blur || 0}px ${style.shadow.color || 'rgba(0,0,0,0.5)'}`;
|
||||
}
|
||||
|
||||
// Text stroke (webkit)
|
||||
if (style.stroke?.enabled) {
|
||||
css.webkitTextStroke = `${style.stroke.width || 1}px ${style.stroke.color || '#000'}`;
|
||||
}
|
||||
|
||||
return css;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-layer-renderer {
|
||||
pointer-events: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="format-selector">
|
||||
<q-select
|
||||
:model-value="format.preset"
|
||||
@update:model-value="applyPreset($event)"
|
||||
:options="presetOptions"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
filled
|
||||
style="min-width: 180px"
|
||||
label="Formato"
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="aspect_ratio" size="20px" />
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="tune"
|
||||
@click="showCustomDialog = true"
|
||||
>
|
||||
<q-tooltip>Dimensioni personalizzate</q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
<!-- Custom Size Dialog -->
|
||||
<q-dialog v-model="showCustomDialog">
|
||||
<q-card style="min-width: 300px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Dimensioni Personalizzate</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="two-col">
|
||||
<q-input
|
||||
v-model.number="customWidth"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Larghezza (px)"
|
||||
min="100"
|
||||
/>
|
||||
<q-input
|
||||
v-model.number="customHeight"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Altezza (px)"
|
||||
min="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-select
|
||||
v-model.number="customDpi"
|
||||
:options="dpiOptions"
|
||||
filled
|
||||
dense
|
||||
label="DPI"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<div class="preset-buttons q-mt-md">
|
||||
<q-btn
|
||||
v-for="preset in quickPresets"
|
||||
:key="preset.value"
|
||||
:label="preset.label"
|
||||
flat
|
||||
dense
|
||||
size="sm"
|
||||
@click="applyQuickPreset(preset)"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Annulla" v-close-popup />
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="Applica"
|
||||
@click="applyCustom"
|
||||
v-close-popup
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { FORMAT_PRESETS, Format } from '../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
format: Format;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', format: Format): void;
|
||||
}>();
|
||||
|
||||
const showCustomDialog = ref(false);
|
||||
const customWidth = ref(props.format.width);
|
||||
const customHeight = ref(props.format.height);
|
||||
const customDpi = ref(props.format.dpi);
|
||||
|
||||
watch(() => props.format, (newFormat) => {
|
||||
customWidth.value = newFormat.width;
|
||||
customHeight.value = newFormat.height;
|
||||
customDpi.value = newFormat.dpi;
|
||||
}, { deep: true });
|
||||
|
||||
const presetOptions = [
|
||||
{ label: 'Instagram Story (1080×1920)', value: 'instagram-story' },
|
||||
{ label: 'Instagram Post (1080×1080)', value: 'instagram-post' },
|
||||
{ label: 'Instagram Portrait (1080×1350)', value: 'instagram-portrait' },
|
||||
{ label: 'Facebook Event (1920×1080)', value: 'facebook-event' },
|
||||
{ label: 'Twitter Post (1200×675)', value: 'twitter-post' },
|
||||
{ label: 'A4 Verticale', value: 'A4' },
|
||||
{ label: 'A4 Orizzontale', value: 'A4-landscape' },
|
||||
{ label: 'A3 Verticale', value: 'A3' },
|
||||
{ label: 'Personalizzato', value: 'custom' }
|
||||
];
|
||||
|
||||
const dpiOptions = [72, 150, 300];
|
||||
|
||||
const quickPresets = [
|
||||
{ label: 'HD', value: 'hd', width: 1920, height: 1080 },
|
||||
{ label: '4K', value: '4k', width: 3840, height: 2160 },
|
||||
{ label: 'Square', value: 'square', width: 1080, height: 1080 }
|
||||
];
|
||||
|
||||
const applyPreset = (presetKey: string) => {
|
||||
if (presetKey === 'custom') {
|
||||
showCustomDialog.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = FORMAT_PRESETS[presetKey];
|
||||
if (preset) {
|
||||
emit('update', {
|
||||
preset: presetKey,
|
||||
...preset
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applyQuickPreset = (preset: any) => {
|
||||
customWidth.value = preset.width;
|
||||
customHeight.value = preset.height;
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
emit('update', {
|
||||
preset: 'custom',
|
||||
width: customWidth.value,
|
||||
height: customHeight.value,
|
||||
unit: 'px',
|
||||
dpi: customDpi.value
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.format-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
.layer-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layer-type-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1));
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
.layer-type-name {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.layer-id {
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.position-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
.body--dark {
|
||||
.layer-type-header {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2));
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
border-color: #555;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div class="layer-editor">
|
||||
<q-scroll-area class="editor-scroll">
|
||||
<!-- Layer Type Info -->
|
||||
<div class="layer-type-header">
|
||||
<q-icon :name="layerIcon" size="24px" color="primary" />
|
||||
<div>
|
||||
<div class="layer-type-name">{{ layerLabel }}</div>
|
||||
<div class="layer-id">{{ layer.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position Section -->
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="open_with"
|
||||
label="Posizione"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<div class="position-grid">
|
||||
<q-input
|
||||
:model-value="Math.round(layer.position.x * 100)"
|
||||
@update:model-value="updatePosition('x', $event)"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="X %"
|
||||
step="1"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="Math.round(layer.position.y * 100)"
|
||||
@update:model-value="updatePosition('y', $event)"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Y %"
|
||||
step="1"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="Math.round(layer.position.w * 100)"
|
||||
@update:model-value="updatePosition('w', $event)"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Largh. %"
|
||||
step="1"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="Math.round(layer.position.h * 100)"
|
||||
@update:model-value="updatePosition('h', $event)"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Alt. %"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-select
|
||||
:model-value="layer.anchor"
|
||||
@update:model-value="$emit('update', { anchor: $event })"
|
||||
:options="anchorOptions"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="Ancoraggio"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Layer Properties -->
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="tune"
|
||||
label="Proprietà"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-input
|
||||
:model-value="layer.zIndex"
|
||||
@update:model-value="$emit('update', { zIndex: parseInt($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Z-Index (ordine)"
|
||||
/>
|
||||
|
||||
<q-toggle
|
||||
:model-value="layer.required"
|
||||
@update:model-value="$emit('update', { required: $event })"
|
||||
label="Campo obbligatorio"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<q-toggle
|
||||
:model-value="layer.visible"
|
||||
@update:model-value="$emit('update', { visible: $event })"
|
||||
label="Visibile"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
|
||||
<q-toggle
|
||||
:model-value="layer.locked"
|
||||
@update:model-value="$emit('update', { locked: $event })"
|
||||
label="Bloccato"
|
||||
class="q-mt-xs"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Text-specific properties -->
|
||||
<template v-if="isTextLayer">
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="format_size"
|
||||
label="Testo"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-input
|
||||
v-if="layer.type === 'customText'"
|
||||
:model-value="layer.defaultValue || ''"
|
||||
@update:model-value="$emit('update', { defaultValue: $event })"
|
||||
filled
|
||||
dense
|
||||
label="Testo predefinito"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="layer.maxLines || ''"
|
||||
@update:model-value="$emit('update', { maxLines: parseInt($event) || null })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Max righe"
|
||||
min="1"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Image-specific properties -->
|
||||
<template v-if="isImageLayer">
|
||||
<q-expansion-item
|
||||
icon="image"
|
||||
label="Immagine"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-select
|
||||
:model-value="layer.style?.objectFit || 'cover'"
|
||||
@update:model-value="updateStyle({ objectFit: $event })"
|
||||
:options="objectFitOptions"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="Adattamento"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="layer.style?.blur || 0"
|
||||
@update:model-value="updateStyle({ blur: parseFloat($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Sfocatura (px)"
|
||||
min="0"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="layer.style?.borderRadius || 0"
|
||||
@update:model-value="updateStyle({ borderRadius: parseInt($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Bordi arrotondati (px)"
|
||||
min="0"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Overlay -->
|
||||
<q-expansion-item
|
||||
icon="gradient"
|
||||
label="Overlay"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-toggle
|
||||
:model-value="layer.style?.overlay?.enabled || false"
|
||||
@update:model-value="updateOverlay({ enabled: $event })"
|
||||
label="Abilita overlay"
|
||||
/>
|
||||
|
||||
<template v-if="layer.style?.overlay?.enabled">
|
||||
<q-select
|
||||
:model-value="layer.style?.overlay?.type || 'gradient'"
|
||||
@update:model-value="updateOverlay({ type: $event })"
|
||||
:options="['solid', 'gradient']"
|
||||
filled
|
||||
dense
|
||||
label="Tipo"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<q-select
|
||||
v-if="layer.style?.overlay?.type === 'gradient'"
|
||||
:model-value="layer.style?.overlay?.direction || 'to-bottom'"
|
||||
@update:model-value="updateOverlay({ direction: $event })"
|
||||
:options="gradientDirections"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="Direzione"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="layer.style?.overlay?.type === 'solid'"
|
||||
class="q-mt-sm"
|
||||
>
|
||||
<label class="input-label">Colore</label>
|
||||
<q-input
|
||||
:model-value="layer.style?.overlay?.color || 'rgba(0,0,0,0.5)'"
|
||||
@update:model-value="updateOverlay({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
>
|
||||
<template #prepend>
|
||||
<q-icon name="palette" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Icon (for location, etc.) -->
|
||||
<template v-if="canHaveIcon">
|
||||
<q-expansion-item
|
||||
icon="emoji_symbols"
|
||||
label="Icona"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-toggle
|
||||
:model-value="layer.icon?.enabled || false"
|
||||
@update:model-value="updateIcon({ enabled: $event })"
|
||||
label="Mostra icona"
|
||||
/>
|
||||
|
||||
<template v-if="layer.icon?.enabled">
|
||||
<q-input
|
||||
:model-value="layer.icon?.name || 'place'"
|
||||
@update:model-value="updateIcon({ name: $event })"
|
||||
filled
|
||||
dense
|
||||
label="Nome icona (Material)"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
:model-value="layer.icon?.size || 24"
|
||||
@update:model-value="updateIcon({ size: parseInt($event) || 24 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Dimensione"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<div class="color-input q-mt-sm">
|
||||
<label class="input-label">Colore icona</label>
|
||||
<div class="color-row">
|
||||
<div
|
||||
class="color-preview"
|
||||
:style="{ background: layer.icon?.color || palette.accent }"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="layer.icon?.color || palette.accent"
|
||||
@update:model-value="updateIcon({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Fallback (for images) -->
|
||||
<template v-if="isImageLayer && layer.type !== 'logo'">
|
||||
<q-expansion-item
|
||||
icon="image_not_supported"
|
||||
label="Fallback"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-select
|
||||
:model-value="layer.fallback?.type || 'solid'"
|
||||
@update:model-value="updateFallback({ type: $event })"
|
||||
:options="['solid', 'gradient']"
|
||||
filled
|
||||
dense
|
||||
label="Tipo fallback"
|
||||
/>
|
||||
|
||||
<div v-if="layer.fallback?.type === 'solid'" class="q-mt-sm">
|
||||
<label class="input-label">Colore</label>
|
||||
<q-input
|
||||
:model-value="layer.fallback?.color || '#333333'"
|
||||
@update:model-value="updateFallback({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="layer.fallback?.type === 'gradient'" class="q-mt-sm">
|
||||
<label class="input-label">Colori (separati da virgola)</label>
|
||||
<q-input
|
||||
:model-value="(layer.fallback?.colors || []).join(', ')"
|
||||
@update:model-value="updateFallback({ colors: $event.split(',').map((c: string) => c.trim()) })"
|
||||
filled
|
||||
dense
|
||||
placeholder="#333, #666, #999"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYER_TYPE_INFO } from '../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
layer: any;
|
||||
palette: any;
|
||||
typography: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', updates: any): void;
|
||||
}>();
|
||||
|
||||
// Computed
|
||||
const layerIcon = computed(() => LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.icon || 'layers');
|
||||
const layerLabel = computed(() => LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.label || props.layer.type);
|
||||
const isTextLayer = computed(() => LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.category === 'text');
|
||||
const isImageLayer = computed(() => LAYER_TYPE_INFO[props.layer.type as keyof typeof LAYER_TYPE_INFO]?.category === 'image');
|
||||
const canHaveIcon = computed(() => ['location', 'contacts', 'eventDate', 'eventTime'].includes(props.layer.type));
|
||||
|
||||
// Options
|
||||
const anchorOptions = [
|
||||
{ label: 'Alto Sinistra', value: 'top-left' },
|
||||
{ label: 'Alto Centro', value: 'top-center' },
|
||||
{ label: 'Alto Destra', value: 'top-right' },
|
||||
{ label: 'Centro Sinistra', value: 'center-left' },
|
||||
{ label: 'Centro', value: 'center' },
|
||||
{ label: 'Centro Destra', value: 'center-right' },
|
||||
{ label: 'Basso Sinistra', value: 'bottom-left' },
|
||||
{ label: 'Basso Centro', value: 'bottom-center' },
|
||||
{ label: 'Basso Destra', value: 'bottom-right' }
|
||||
];
|
||||
|
||||
const objectFitOptions = [
|
||||
{ label: 'Riempi (cover)', value: 'cover' },
|
||||
{ label: 'Contieni', value: 'contain' },
|
||||
{ label: 'Allunga', value: 'fill' },
|
||||
{ label: 'Nessuno', value: 'none' }
|
||||
];
|
||||
|
||||
const gradientDirections = [
|
||||
{ label: 'Verso il basso', value: 'to-bottom' },
|
||||
{ label: 'Verso l\'alto', value: 'to-top' },
|
||||
{ label: 'Verso destra', value: 'to-right' },
|
||||
{ label: 'Verso sinistra', value: 'to-left' },
|
||||
{ label: 'Diagonale', value: 'to-bottom-right' }
|
||||
];
|
||||
|
||||
// Methods
|
||||
const updatePosition = (key: string, value: any) => {
|
||||
const numValue = parseFloat(value) / 100;
|
||||
emit('update', {
|
||||
position: {
|
||||
...props.layer.position,
|
||||
[key]: Math.max(0, Math.min(1, numValue))
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateStyle = (updates: any) => {
|
||||
emit('update', {
|
||||
style: { ...props.layer.style, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
const updateOverlay = (updates: any) => {
|
||||
emit('update', {
|
||||
style: {
|
||||
...props.layer.style,
|
||||
overlay: { ...props.layer.style?.overlay, ...updates }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateIcon = (updates: any) => {
|
||||
emit('update', {
|
||||
icon: { ...props.layer.icon, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
const updateFallback = (updates: any) => {
|
||||
emit('update', {
|
||||
fallback: { ...props.layer.fallback, ...updates }
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './LayerEditor.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
.layer-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-list-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-left: 3px solid #667eea;
|
||||
padding-left: calc(0.75rem - 3px);
|
||||
}
|
||||
|
||||
&.is-hidden {
|
||||
opacity: 0.5;
|
||||
|
||||
.layer-name {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-locked {
|
||||
.layer-name {
|
||||
color: #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.layer-type {
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
color: #e74c3c;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
.layer-item:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.layer-item.is-selected & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ghost {
|
||||
opacity: 0.5;
|
||||
background: #667eea !important;
|
||||
|
||||
* {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
|
||||
p {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-slots-section {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-slots-header {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.logo-slots-list {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.logo-slot-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-slots {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
.body--dark {
|
||||
.panel-header {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
border-color: #333;
|
||||
|
||||
&:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.logo-slots-section {
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.logo-slots-header {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="layer-panel">
|
||||
<!-- Header -->
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Layer</span>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="add"
|
||||
size="sm"
|
||||
@click="showAddMenu = true"
|
||||
>
|
||||
<q-menu v-model="showAddMenu" anchor="bottom right" self="top right">
|
||||
<q-list dense style="min-width: 200px">
|
||||
<q-item-label header>Aggiungi Layer</q-item-label>
|
||||
|
||||
<q-item-label header class="text-caption q-mt-sm">Testo</q-item-label>
|
||||
<q-item
|
||||
v-for="type in textLayerTypes"
|
||||
:key="type"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="$emit('add', type)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="getLayerIcon(type)" size="20px" />
|
||||
</q-item-section>
|
||||
<q-item-section>{{ getLayerLabel(type) }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item-label header class="text-caption q-mt-sm">Immagini</q-item-label>
|
||||
<q-item
|
||||
v-for="type in imageLayerTypes"
|
||||
:key="type"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="$emit('add', type)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="getLayerIcon(type)" size="20px" />
|
||||
</q-item-section>
|
||||
<q-item-section>{{ getLayerLabel(type) }}</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item-label header class="text-caption q-mt-sm">Decorazioni</q-item-label>
|
||||
<q-item
|
||||
v-for="type in decorationLayerTypes"
|
||||
:key="type"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="$emit('add', type)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="getLayerIcon(type)" size="20px" />
|
||||
</q-item-section>
|
||||
<q-item-section>{{ getLayerLabel(type) }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
|
||||
<!-- Layer List -->
|
||||
<q-scroll-area class="layer-list-scroll">
|
||||
<draggable
|
||||
v-model="localLayers"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
ghost-class="layer-ghost"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element: layer, index }">
|
||||
<div
|
||||
class="layer-item"
|
||||
:class="{
|
||||
'is-selected': layer.id === selectedLayerId,
|
||||
'is-hidden': !layer.visible,
|
||||
'is-locked': layer.locked
|
||||
}"
|
||||
@click="$emit('select', layer.id)"
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="drag-handle">
|
||||
<q-icon name="drag_indicator" size="18px" color="grey-6" />
|
||||
</div>
|
||||
|
||||
<!-- Layer Icon -->
|
||||
<div class="layer-icon">
|
||||
<q-icon
|
||||
:name="getLayerIcon(layer.type)"
|
||||
size="20px"
|
||||
:color="layer.visible ? 'grey-8' : 'grey-5'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Layer Name -->
|
||||
<div class="layer-name">
|
||||
<span class="layer-type">{{ getLayerLabel(layer.type) }}</span>
|
||||
<span v-if="layer.required" class="required-badge">*</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="layer-actions">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="xs"
|
||||
:icon="layer.visible ? 'visibility' : 'visibility_off'"
|
||||
:color="layer.visible ? 'grey-7' : 'grey-5'"
|
||||
@click.stop="$emit('toggle-visibility', layer.id)"
|
||||
>
|
||||
<q-tooltip>{{ layer.visible ? 'Nascondi' : 'Mostra' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="xs"
|
||||
:icon="layer.locked ? 'lock' : 'lock_open'"
|
||||
:color="layer.locked ? 'orange-7' : 'grey-5'"
|
||||
@click.stop="$emit('toggle-lock', layer.id)"
|
||||
>
|
||||
<q-tooltip>{{ layer.locked ? 'Sblocca' : 'Blocca' }}</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="xs"
|
||||
icon="more_vert"
|
||||
color="grey-7"
|
||||
@click.stop
|
||||
>
|
||||
<q-menu>
|
||||
<q-list dense style="min-width: 150px">
|
||||
<q-item clickable v-close-popup @click="$emit('duplicate', layer.id)">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="content_copy" size="18px" />
|
||||
</q-item-section>
|
||||
<q-item-section>Duplica</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="$emit('delete', layer.id)"
|
||||
class="text-negative"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon name="delete" size="18px" />
|
||||
</q-item-section>
|
||||
<q-item-section>Elimina</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="layers.length === 0" class="empty-layers">
|
||||
<q-icon name="layers" size="48px" color="grey-5" />
|
||||
<p>Nessun layer</p>
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="add"
|
||||
label="Aggiungi layer"
|
||||
@click="showAddMenu = true"
|
||||
/>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
|
||||
<!-- Logo Slots Section -->
|
||||
<div v-if="logoSlots?.enabled" class="logo-slots-section">
|
||||
<q-expansion-item
|
||||
dense
|
||||
icon="branding_watermark"
|
||||
label="Slot Logo"
|
||||
header-class="logo-slots-header"
|
||||
>
|
||||
<div class="logo-slots-list">
|
||||
<div
|
||||
v-for="(slot, index) in logoSlots.slots"
|
||||
:key="slot.id"
|
||||
class="logo-slot-item"
|
||||
>
|
||||
<q-icon name="image" size="18px" color="grey-6" />
|
||||
<span>Logo {{ index + 1 }}</span>
|
||||
</div>
|
||||
<div v-if="logoSlots.slots.length === 0" class="no-slots">
|
||||
Nessuno slot configurato
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import { LAYER_TYPE_INFO, LayerType } from '../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
layers: any[];
|
||||
selectedLayerId: string | null;
|
||||
logoSlots?: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', layerId: string): void;
|
||||
(e: 'add', type: LayerType): void;
|
||||
(e: 'delete', layerId: string): void;
|
||||
(e: 'duplicate', layerId: string): void;
|
||||
(e: 'reorder', fromIndex: number, toIndex: number): void;
|
||||
(e: 'toggle-visibility', layerId: string): void;
|
||||
(e: 'toggle-lock', layerId: string): void;
|
||||
}>();
|
||||
|
||||
const showAddMenu = ref(false);
|
||||
|
||||
// Local copy for drag & drop
|
||||
const localLayers = ref([...props.layers].reverse()); // Reverse for visual order (top = front)
|
||||
|
||||
watch(() => props.layers, (newLayers) => {
|
||||
localLayers.value = [...newLayers].reverse();
|
||||
}, { deep: true });
|
||||
|
||||
// Layer types by category
|
||||
const textLayerTypes: LayerType[] = ['title', 'subtitle', 'eventDate', 'eventTime', 'location', 'contacts', 'extraText', 'customText'];
|
||||
const imageLayerTypes: LayerType[] = ['backgroundImage', 'mainImage', 'logo', 'customImage'];
|
||||
const decorationLayerTypes: LayerType[] = ['shape', 'divider'];
|
||||
|
||||
const getLayerIcon = (type: LayerType) => LAYER_TYPE_INFO[type]?.icon || 'layers';
|
||||
const getLayerLabel = (type: LayerType) => LAYER_TYPE_INFO[type]?.label || type;
|
||||
|
||||
const onDragEnd = (evt: any) => {
|
||||
if (evt.oldIndex !== evt.newIndex) {
|
||||
// Convert from reversed visual order to actual order
|
||||
const actualOldIndex = props.layers.length - 1 - evt.oldIndex;
|
||||
const actualNewIndex = props.layers.length - 1 - evt.newIndex;
|
||||
emit('reorder', actualOldIndex, actualNewIndex);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './LayerPanel.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="palette-editor">
|
||||
<div class="section-content">
|
||||
<!-- Background Color -->
|
||||
<div class="color-field">
|
||||
<label>Sfondo Canvas</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="backgroundColor"
|
||||
@input="$emit('update:background-color', $event.target.value)"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="backgroundColor"
|
||||
@update:model-value="$emit('update:background-color', $event)"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md" />
|
||||
|
||||
<!-- Palette Colors -->
|
||||
<div
|
||||
v-for="(color, key) in palette"
|
||||
:key="key"
|
||||
class="color-field"
|
||||
>
|
||||
<label>{{ colorLabels[key] || key }}</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="color"
|
||||
@input="updatePaletteColor(key, $event.target.value)"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="color"
|
||||
@update:model-value="updatePaletteColor(key, $event)"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md" />
|
||||
|
||||
<!-- Preset Palettes -->
|
||||
<label class="input-label">Palette predefinite</label>
|
||||
<div class="preset-palettes">
|
||||
<div
|
||||
v-for="(preset, index) in presetPalettes"
|
||||
:key="index"
|
||||
class="preset-palette"
|
||||
:title="preset.name"
|
||||
@click="applyPreset(preset)"
|
||||
>
|
||||
<div
|
||||
v-for="color in preset.colors"
|
||||
:key="color"
|
||||
class="preset-color"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Palette } from '../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
palette: Palette;
|
||||
backgroundColor: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:palette', palette: Partial<Palette>): void;
|
||||
(e: 'update:background-color', color: string): void;
|
||||
}>();
|
||||
|
||||
const colorLabels: Record<string, string> = {
|
||||
primary: 'Primario',
|
||||
secondary: 'Secondario',
|
||||
accent: 'Accento',
|
||||
background: 'Sfondo Palette',
|
||||
text: 'Testo',
|
||||
textSecondary: 'Testo Secondario',
|
||||
textMuted: 'Testo Attenuato'
|
||||
};
|
||||
|
||||
const presetPalettes = [
|
||||
{
|
||||
name: 'Techno Dark',
|
||||
colors: ['#667eea', '#764ba2', '#ffd700', '#1a1a2e'],
|
||||
palette: {
|
||||
primary: '#667eea',
|
||||
secondary: '#764ba2',
|
||||
accent: '#ffd700',
|
||||
background: '#1a1a2e',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#cccccc',
|
||||
textMuted: '#888888'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Nature Green',
|
||||
colors: ['#2e7d32', '#5d4037', '#ffd700', '#1a1a1a'],
|
||||
palette: {
|
||||
primary: '#2e7d32',
|
||||
secondary: '#5d4037',
|
||||
accent: '#ffd700',
|
||||
background: '#1a1a1a',
|
||||
text: '#f5f5f5',
|
||||
textSecondary: '#c8e6c9',
|
||||
textMuted: '#81c784'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Sunset Warm',
|
||||
colors: ['#ff6b35', '#f7c59f', '#2ec4b6', '#1a1a2e'],
|
||||
palette: {
|
||||
primary: '#ff6b35',
|
||||
secondary: '#f7c59f',
|
||||
accent: '#2ec4b6',
|
||||
background: '#1a1a2e',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#f7c59f',
|
||||
textMuted: '#aaaaaa'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Elegant Gold',
|
||||
colors: ['#d4af37', '#1a1a1a', '#ffffff', '#0a0a0a'],
|
||||
palette: {
|
||||
primary: '#d4af37',
|
||||
secondary: '#1a1a1a',
|
||||
accent: '#ffd700',
|
||||
background: '#0a0a0a',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#d4af37',
|
||||
textMuted: '#888888'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Ocean Blue',
|
||||
colors: ['#0077b6', '#00b4d8', '#90e0ef', '#03045e'],
|
||||
palette: {
|
||||
primary: '#0077b6',
|
||||
secondary: '#00b4d8',
|
||||
accent: '#90e0ef',
|
||||
background: '#03045e',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#caf0f8',
|
||||
textMuted: '#90e0ef'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Berry Purple',
|
||||
colors: ['#9b5de5', '#f15bb5', '#fee440', '#1a0a2e'],
|
||||
palette: {
|
||||
primary: '#9b5de5',
|
||||
secondary: '#f15bb5',
|
||||
accent: '#fee440',
|
||||
background: '#1a0a2e',
|
||||
text: '#ffffff',
|
||||
textSecondary: '#e0aaff',
|
||||
textMuted: '#9b5de5'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const updatePaletteColor = (key: string, value: string) => {
|
||||
emit('update:palette', { [key]: value });
|
||||
};
|
||||
|
||||
const applyPreset = (preset: any) => {
|
||||
emit('update:palette', preset.palette);
|
||||
emit('update:background-color', preset.palette.background);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.palette-editor {
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.color-field {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preset-palettes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-palette {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.preset-color {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
.body--dark {
|
||||
.color-field label,
|
||||
.input-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
border-color: #555;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
.style-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
.color-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-swatches {
|
||||
.swatches-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Dark mode
|
||||
.body--dark {
|
||||
.section-header {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
border-color: #555;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="style-editor">
|
||||
<q-scroll-area class="editor-scroll">
|
||||
<!-- Opacity -->
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="opacity"
|
||||
label="Opacità"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-slider
|
||||
:model-value="(style.opacity ?? 1) * 100"
|
||||
@update:model-value="updateStyle({ opacity: $event / 100 })"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
label
|
||||
:label-value="`${Math.round((style.opacity ?? 1) * 100)}%`"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Typography (for text layers) -->
|
||||
<template v-if="isTextLayer">
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="text_format"
|
||||
label="Tipografia"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-select
|
||||
:model-value="style.fontFamily || typography.bodyFont"
|
||||
@update:model-value="updateStyle({ fontFamily: $event })"
|
||||
:options="fontOptions"
|
||||
filled
|
||||
dense
|
||||
label="Font"
|
||||
/>
|
||||
|
||||
<div class="two-col q-mt-sm">
|
||||
<q-select
|
||||
:model-value="style.fontWeight || 400"
|
||||
@update:model-value="updateStyle({ fontWeight: $event })"
|
||||
:options="fontWeightOptions"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="Peso"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.fontSize || 32"
|
||||
@update:model-value="updateStyle({ fontSize: parseInt($event) || 32 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Dimensione"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="two-col q-mt-sm">
|
||||
<q-select
|
||||
:model-value="style.fontStyle || 'normal'"
|
||||
@update:model-value="updateStyle({ fontStyle: $event })"
|
||||
:options="['normal', 'italic']"
|
||||
filled
|
||||
dense
|
||||
label="Stile"
|
||||
/>
|
||||
<q-select
|
||||
:model-value="style.textTransform || 'none'"
|
||||
@update:model-value="updateStyle({ textTransform: $event })"
|
||||
:options="textTransformOptions"
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
label="Trasformazione"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-btn-toggle
|
||||
:model-value="style.textAlign || 'center'"
|
||||
@update:model-value="updateStyle({ textAlign: $event })"
|
||||
:options="textAlignOptions"
|
||||
class="q-mt-md full-width"
|
||||
spread
|
||||
no-caps
|
||||
toggle-color="primary"
|
||||
/>
|
||||
|
||||
<div class="two-col q-mt-sm">
|
||||
<q-input
|
||||
:model-value="style.letterSpacing || 0"
|
||||
@update:model-value="updateStyle({ letterSpacing: parseFloat($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Spaziatura lettere"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.lineHeight || 1.2"
|
||||
@update:model-value="updateStyle({ lineHeight: parseFloat($event) || 1.2 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Interlinea"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Color -->
|
||||
<q-expansion-item
|
||||
default-opened
|
||||
icon="palette"
|
||||
label="Colore"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<div class="color-input">
|
||||
<label class="input-label">{{ isTextLayer ? 'Colore testo' : 'Colore riempimento' }}</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="style.color || style.fill || palette.text"
|
||||
@input="updateStyle(isTextLayer ? { color: $event.target.value } : { fill: $event.target.value })"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.color || style.fill || palette.text"
|
||||
@update:model-value="updateStyle(isTextLayer ? { color: $event } : { fill: $event })"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick palette colors -->
|
||||
<div class="palette-swatches q-mt-sm">
|
||||
<label class="input-label">Palette rapida</label>
|
||||
<div class="swatches-row">
|
||||
<div
|
||||
v-for="(color, key) in paletteColors"
|
||||
:key="key"
|
||||
class="swatch"
|
||||
:style="{ background: color }"
|
||||
:title="key"
|
||||
@click="updateStyle(isTextLayer ? { color } : { fill: color })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Shadow -->
|
||||
<q-expansion-item
|
||||
icon="blur_on"
|
||||
label="Ombra"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-toggle
|
||||
:model-value="style.shadow?.enabled || false"
|
||||
@update:model-value="updateShadow({ enabled: $event })"
|
||||
label="Abilita ombra"
|
||||
/>
|
||||
|
||||
<template v-if="style.shadow?.enabled">
|
||||
<div class="two-col q-mt-sm">
|
||||
<q-input
|
||||
:model-value="style.shadow?.offsetX || 0"
|
||||
@update:model-value="updateShadow({ offsetX: parseFloat($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Offset X"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.shadow?.offsetY || 0"
|
||||
@update:model-value="updateShadow({ offsetY: parseFloat($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Offset Y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
:model-value="style.shadow?.blur || 10"
|
||||
@update:model-value="updateShadow({ blur: parseFloat($event) || 0 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Sfocatura"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<div class="color-input q-mt-sm">
|
||||
<label class="input-label">Colore ombra</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="shadowColorHex"
|
||||
@input="updateShadow({ color: $event.target.value })"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.shadow?.color || 'rgba(0,0,0,0.5)'"
|
||||
@update:model-value="updateShadow({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Stroke (for text) -->
|
||||
<template v-if="isTextLayer">
|
||||
<q-expansion-item
|
||||
icon="format_paint"
|
||||
label="Contorno"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-toggle
|
||||
:model-value="style.stroke?.enabled || false"
|
||||
@update:model-value="updateStroke({ enabled: $event })"
|
||||
label="Abilita contorno"
|
||||
/>
|
||||
|
||||
<template v-if="style.stroke?.enabled">
|
||||
<q-input
|
||||
:model-value="style.stroke?.width || 2"
|
||||
@update:model-value="updateStroke({ width: parseFloat($event) || 1 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Spessore"
|
||||
class="q-mt-sm"
|
||||
/>
|
||||
|
||||
<div class="color-input q-mt-sm">
|
||||
<label class="input-label">Colore contorno</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="style.stroke?.color || '#000000'"
|
||||
@input="updateStroke({ color: $event.target.value })"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.stroke?.color || '#000000'"
|
||||
@update:model-value="updateStroke({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
|
||||
<!-- Border (for images/shapes) -->
|
||||
<template v-if="!isTextLayer">
|
||||
<q-expansion-item
|
||||
icon="border_style"
|
||||
label="Bordo"
|
||||
header-class="section-header"
|
||||
>
|
||||
<div class="section-content">
|
||||
<q-toggle
|
||||
:model-value="style.border?.enabled || false"
|
||||
@update:model-value="updateBorder({ enabled: $event })"
|
||||
label="Abilita bordo"
|
||||
/>
|
||||
|
||||
<template v-if="style.border?.enabled">
|
||||
<div class="two-col q-mt-sm">
|
||||
<q-input
|
||||
:model-value="style.border?.width || 2"
|
||||
@update:model-value="updateBorder({ width: parseFloat($event) || 1 })"
|
||||
type="number"
|
||||
filled
|
||||
dense
|
||||
label="Spessore"
|
||||
/>
|
||||
<q-select
|
||||
:model-value="style.border?.style || 'solid'"
|
||||
@update:model-value="updateBorder({ style: $event })"
|
||||
:options="['solid', 'dashed', 'dotted']"
|
||||
filled
|
||||
dense
|
||||
label="Stile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="color-input q-mt-sm">
|
||||
<label class="input-label">Colore bordo</label>
|
||||
<div class="color-row">
|
||||
<input
|
||||
type="color"
|
||||
:value="style.border?.color || '#ffffff'"
|
||||
@input="updateBorder({ color: $event.target.value })"
|
||||
class="color-picker"
|
||||
/>
|
||||
<q-input
|
||||
:model-value="style.border?.color || '#ffffff'"
|
||||
@update:model-value="updateBorder({ color: $event })"
|
||||
filled
|
||||
dense
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</template>
|
||||
</q-scroll-area>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYER_TYPE_INFO, FONT_OPTIONS, FONT_WEIGHT_OPTIONS } from '../../types/template.types';
|
||||
|
||||
const props = defineProps<{
|
||||
style: any;
|
||||
layerType: string;
|
||||
palette: any;
|
||||
typography: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', style: any): void;
|
||||
}>();
|
||||
|
||||
// Computed
|
||||
const isTextLayer = computed(() => LAYER_TYPE_INFO[props.layerType as keyof typeof LAYER_TYPE_INFO]?.category === 'text');
|
||||
|
||||
const paletteColors = computed(() => ({
|
||||
primary: props.palette.primary,
|
||||
secondary: props.palette.secondary,
|
||||
accent: props.palette.accent,
|
||||
text: props.palette.text,
|
||||
textSecondary: props.palette.textSecondary,
|
||||
textMuted: props.palette.textMuted
|
||||
}));
|
||||
|
||||
const shadowColorHex = computed(() => {
|
||||
const color = props.style.shadow?.color || 'rgba(0,0,0,0.5)';
|
||||
if (color.startsWith('#')) return color;
|
||||
// Try to extract hex from rgba - simplified
|
||||
return '#000000';
|
||||
});
|
||||
|
||||
// Options
|
||||
const fontOptions = FONT_OPTIONS;
|
||||
const fontWeightOptions = FONT_WEIGHT_OPTIONS;
|
||||
|
||||
const textTransformOptions = [
|
||||
{ label: 'Nessuna', value: 'none' },
|
||||
{ label: 'MAIUSCOLO', value: 'uppercase' },
|
||||
{ label: 'minuscolo', value: 'lowercase' },
|
||||
{ label: 'Capitalizzato', value: 'capitalize' }
|
||||
];
|
||||
|
||||
const textAlignOptions = [
|
||||
{ value: 'left', icon: 'format_align_left' },
|
||||
{ value: 'center', icon: 'format_align_center' },
|
||||
{ value: 'right', icon: 'format_align_right' }
|
||||
];
|
||||
|
||||
// Methods
|
||||
const updateStyle = (updates: any) => {
|
||||
emit('update', updates);
|
||||
};
|
||||
|
||||
const updateShadow = (updates: any) => {
|
||||
emit('update', {
|
||||
shadow: { ...props.style.shadow, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
const updateStroke = (updates: any) => {
|
||||
emit('update', {
|
||||
stroke: { ...props.style.stroke, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
const updateBorder = (updates: any) => {
|
||||
emit('update', {
|
||||
border: { ...props.style.border, ...updates }
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './StyleEditor.scss';
|
||||
</style>
|
||||
273
src/components/TemplateBuilder/types/template.types.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Shadow {
|
||||
enabled: boolean;
|
||||
blur: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Stroke {
|
||||
enabled: boolean;
|
||||
width: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Border {
|
||||
enabled: boolean;
|
||||
width: number;
|
||||
color: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
}
|
||||
|
||||
export interface GradientStop {
|
||||
position: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Overlay {
|
||||
enabled: boolean;
|
||||
type: 'solid' | 'gradient';
|
||||
color?: string;
|
||||
direction?: string;
|
||||
stops?: GradientStop[];
|
||||
}
|
||||
|
||||
export interface LayerStyle {
|
||||
// Comuni
|
||||
opacity?: number;
|
||||
|
||||
// Immagini
|
||||
objectFit?: 'cover' | 'contain' | 'fill' | 'none';
|
||||
blur?: number;
|
||||
borderRadius?: number;
|
||||
overlay?: Overlay;
|
||||
border?: Border;
|
||||
|
||||
// Testi
|
||||
fontFamily?: string;
|
||||
fontWeight?: number;
|
||||
fontSize?: number;
|
||||
fontSizeMin?: number;
|
||||
fontSizeMax?: number;
|
||||
autoFit?: boolean;
|
||||
fontStyle?: 'normal' | 'italic';
|
||||
color?: string;
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
|
||||
// Effetti
|
||||
shadow?: Shadow;
|
||||
stroke?: Stroke;
|
||||
|
||||
// Shape
|
||||
shape?: 'rectangle' | 'circle' | 'ellipse';
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export interface Icon {
|
||||
enabled: boolean;
|
||||
name: string;
|
||||
size: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Fallback {
|
||||
type: 'solid' | 'gradient';
|
||||
color?: string;
|
||||
direction?: string;
|
||||
colors?: string[];
|
||||
}
|
||||
|
||||
export type LayerType =
|
||||
| 'backgroundImage'
|
||||
| 'mainImage'
|
||||
| 'logo'
|
||||
| 'title'
|
||||
| 'subtitle'
|
||||
| 'eventDate'
|
||||
| 'eventTime'
|
||||
| 'location'
|
||||
| 'contacts'
|
||||
| 'extraText'
|
||||
| 'customText'
|
||||
| 'customImage'
|
||||
| 'shape'
|
||||
| 'divider';
|
||||
|
||||
export type AnchorType =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'center-left'
|
||||
| 'center'
|
||||
| 'center-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right';
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
type: LayerType;
|
||||
zIndex: number;
|
||||
position: Position;
|
||||
anchor: AnchorType;
|
||||
required: boolean;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
maxLines?: number;
|
||||
fallback?: Fallback;
|
||||
icon?: Icon;
|
||||
style: LayerStyle;
|
||||
// Per customText
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface LogoSlot {
|
||||
id: string;
|
||||
position: Position;
|
||||
anchor: AnchorType;
|
||||
style: LayerStyle;
|
||||
}
|
||||
|
||||
export interface LogoSlotsConfig {
|
||||
enabled: boolean;
|
||||
maxCount: number;
|
||||
collapseIfEmpty: boolean;
|
||||
slots: LogoSlot[];
|
||||
}
|
||||
|
||||
export interface Format {
|
||||
preset: string;
|
||||
width: number;
|
||||
height: number;
|
||||
unit: 'px' | 'mm' | 'in';
|
||||
dpi: number;
|
||||
}
|
||||
|
||||
export interface SafeArea {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface Palette {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
textMuted: string;
|
||||
}
|
||||
|
||||
export interface Typography {
|
||||
titleFont: string;
|
||||
headingFont: string;
|
||||
bodyFont: string;
|
||||
accentFont: string;
|
||||
}
|
||||
|
||||
export interface AiPromptHints {
|
||||
backgroundImage?: string;
|
||||
mainImage?: string;
|
||||
}
|
||||
|
||||
export interface TemplateMetadata {
|
||||
author: string;
|
||||
version: string;
|
||||
tags: string[];
|
||||
isPublic: boolean;
|
||||
usageCount?: number;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
_id?: string;
|
||||
name: string;
|
||||
templateType: string;
|
||||
description?: string;
|
||||
format: Format;
|
||||
safeArea: SafeArea;
|
||||
backgroundColor: string;
|
||||
layers: Layer[];
|
||||
logoSlots: LogoSlotsConfig;
|
||||
palette: Palette;
|
||||
typography: Typography;
|
||||
defaultAiPromptHints: AiPromptHints;
|
||||
previewUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
metadata: TemplateMetadata;
|
||||
isActive?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
isResizing: boolean;
|
||||
layerId: string | null;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startPosition: Position | null;
|
||||
resizeHandle: string | null;
|
||||
}
|
||||
|
||||
export const FORMAT_PRESETS: Record<string, Omit<Format, 'preset'>> = {
|
||||
'A4': { width: 2480, height: 3508, unit: 'px', dpi: 300 },
|
||||
'A4-landscape': { width: 3508, height: 2480, unit: 'px', dpi: 300 },
|
||||
'A3': { width: 3508, height: 4961, unit: 'px', dpi: 300 },
|
||||
'instagram-story': { width: 1080, height: 1920, unit: 'px', dpi: 72 },
|
||||
'instagram-post': { width: 1080, height: 1080, unit: 'px', dpi: 72 },
|
||||
'instagram-portrait': { width: 1080, height: 1350, unit: 'px', dpi: 72 },
|
||||
'facebook-event': { width: 1920, height: 1080, unit: 'px', dpi: 72 },
|
||||
'twitter-post': { width: 1200, height: 675, unit: 'px', dpi: 72 }
|
||||
};
|
||||
|
||||
export const LAYER_TYPE_INFO: Record<LayerType, { label: string; icon: string; category: string }> = {
|
||||
backgroundImage: { label: 'Sfondo', icon: 'wallpaper', category: 'image' },
|
||||
mainImage: { label: 'Immagine Principale', icon: 'image', category: 'image' },
|
||||
logo: { label: 'Logo', icon: 'branding_watermark', category: 'image' },
|
||||
title: { label: 'Titolo', icon: 'title', category: 'text' },
|
||||
subtitle: { label: 'Sottotitolo', icon: 'short_text', category: 'text' },
|
||||
eventDate: { label: 'Data Evento', icon: 'event', category: 'text' },
|
||||
eventTime: { label: 'Ora Evento', icon: 'schedule', category: 'text' },
|
||||
location: { label: 'Luogo', icon: 'place', category: 'text' },
|
||||
contacts: { label: 'Contatti', icon: 'contact_phone', category: 'text' },
|
||||
extraText: { label: 'Testo Extra', icon: 'notes', category: 'text' },
|
||||
customText: { label: 'Testo Custom', icon: 'text_fields', category: 'text' },
|
||||
customImage: { label: 'Immagine Custom', icon: 'add_photo_alternate', category: 'image' },
|
||||
shape: { label: 'Forma', icon: 'shape_line', category: 'decoration' },
|
||||
divider: { label: 'Divisore', icon: 'horizontal_rule', category: 'decoration' }
|
||||
};
|
||||
|
||||
export const FONT_OPTIONS = [
|
||||
'Montserrat',
|
||||
'Open Sans',
|
||||
'Bebas Neue',
|
||||
'Playfair Display',
|
||||
'Roboto',
|
||||
'Lato',
|
||||
'Poppins',
|
||||
'Raleway',
|
||||
'Oswald',
|
||||
'Merriweather'
|
||||
];
|
||||
|
||||
export const FONT_WEIGHT_OPTIONS = [
|
||||
{ label: 'Light', value: 300 },
|
||||
{ label: 'Regular', value: 400 },
|
||||
{ label: 'Medium', value: 500 },
|
||||
{ label: 'Semi Bold', value: 600 },
|
||||
{ label: 'Bold', value: 700 },
|
||||
{ label: 'Extra Bold', value: 800 },
|
||||
{ label: 'Black', value: 900 }
|
||||
];
|
||||
124
src/components/TemplateSelector/TemplateSelector.scss
Normal file
@@ -0,0 +1,124 @@
|
||||
.template-selector {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filter-select {
|
||||
min-width: 180px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--q-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--q-primary-rgb), 0.2);
|
||||
|
||||
.template-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.template-preview {
|
||||
height: 160px;
|
||||
position: relative;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.template-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.2s;
|
||||
}
|
||||
|
||||
.template-info {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.template-type {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: capitalize;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.template-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
|
||||
.q-chip {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #888;
|
||||
|
||||
p {
|
||||
margin-top: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
.body--dark {
|
||||
.template-card {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.template-type {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||