feat(admin): add gallery multi-image upload to AdminBusinessEditor with live preview thumbnails and carousel

This commit is contained in:
2026-03-04 14:50:04 -05:00
parent d17955383a
commit 927d6549bf

View File

@ -15,6 +15,10 @@ const showMessage = ref({ text: '', type: '' });
const selectedFile = ref<File | null>(null);
const selectedFileName = ref('');
// Gallery de imágenes
const galleryFiles = ref<File[]>([]); // archivos nuevos seleccionados
const galleryPreviews = ref<string[]>([]); // previews locales URL.createObjectURL
// Business state
const businessForm = ref<Partial<Business>>({
name: '',
@ -46,6 +50,10 @@ onMounted(async () => {
if (data.image_url) {
previewImageUrl.value = data.image_url;
}
// Cargar URLs existentes de galeria como previews
if (data.gallery_images?.length) {
galleryPreviews.value = [...data.gallery_images];
}
} catch (e) {
console.error(e);
showMessage.value = { text: 'Error cargando negocio', type: 'error' };
@ -61,16 +69,38 @@ function handleImageChange(event: Event) {
const file = input.files[0];
selectedFile.value = file;
selectedFileName.value = file.name;
// Preview logic
const reader = new FileReader();
reader.onload = (e) => {
previewImageUrl.value = e.target?.result as string;
};
reader.onload = (e) => { previewImageUrl.value = e.target?.result as string; };
reader.readAsDataURL(file);
}
}
function handleGalleryFiles(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files) return;
const newFiles = Array.from(input.files);
newFiles.forEach(file => {
galleryFiles.value.push(file);
galleryPreviews.value.push(URL.createObjectURL(file));
});
// Reset input so same file can be re-picked
input.value = '';
}
function removeGalleryItem(index: number) {
const preview = galleryPreviews.value[index];
if (!preview) return;
if (preview.startsWith('blob:')) {
URL.revokeObjectURL(preview);
const existingCount = (businessForm.value.gallery_images ?? []).length;
const fileIndex = index - existingCount;
if (fileIndex >= 0) galleryFiles.value.splice(fileIndex, 1);
} else {
businessForm.value.gallery_images = (businessForm.value.gallery_images ?? []).filter((_, i) => i !== index);
}
galleryPreviews.value.splice(index, 1);
}
async function saveBusiness() {
isLoading.value = true;
showMessage.value = { text: '', type: '' };
@ -93,6 +123,18 @@ async function saveBusiness() {
formData.append('gallery_images', JSON.stringify(businessForm.value.gallery_images));
}
// Subir archivos nuevos de galeria a Supabase Storage
if (galleryFiles.value.length > 0) {
const uploadedUrls: string[] = [];
for (const gFile of galleryFiles.value) {
const url = await businessService.uploadImage(gFile);
uploadedUrls.push(url);
}
// Combinar las URL existentes ya serializadas + las nuevas subidas
const existing: string[] = businessForm.value.gallery_images ?? [];
formData.set('gallery_images', JSON.stringify([...existing, ...uploadedUrls]));
}
if (selectedFile.value) {
formData.append('image', selectedFile.value);
}
@ -229,6 +271,42 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
<input v-model="businessForm.website" type="text" placeholder="https://">
</div>
<!-- GALERIA DE IMAGENES -->
<div class="form-section-label mt-4">📸 Galería de Imágenes</div>
<div class="form-group">
<label>Fotos adicionales (menú, ambiente, experiencias)</label>
<!-- Drop zone -->
<div class="gallery-upload-zone">
<input
type="file"
id="gallery-input"
accept="image/*"
multiple
@change="handleGalleryFiles"
>
<label for="gallery-input" class="gallery-upload-label">
<span class="material-icons" style="font-size:36px">add_photo_alternate</span>
<span>Agregar fotos a la galería</span>
<span style="font-size:0.7rem;opacity:0.6">PNG, JPG hasta 5 MB cada una</span>
</label>
</div>
<!-- Thumbnails row -->
<div v-if="galleryPreviews.length > 0" class="gallery-thumbs">
<div
v-for="(src, idx) in galleryPreviews"
:key="idx"
class="gallery-thumb"
>
<img :src="src" alt="Foto galeria" />
<button class="thumb-remove" @click="removeGalleryItem(idx)" type="button">
<span class="material-icons">close</span>
</button>
</div>
</div>
<p v-else class="gallery-empty-hint">Sin fotos aún. Agrega imágenes para el carrusel del perfil.</p>
</div>
<button class="deploy-btn" :disabled="isLoading" @click="saveBusiness">
<span class="material-icons">{{ isLoading ? 'sync' : 'save' }}</span>
{{ isLoading ? 'GUARDANDO...' : 'GUARDAR NEGOCIO' }}
@ -285,6 +363,23 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
</div>
</section>
<!-- Gallery carousel preview -->
<section v-if="galleryPreviews.length > 0" class="biz-section">
<div class="section-header">
<span class="section-accent"></span>
<h2 class="section-title">📸 Galería</h2>
</div>
<div class="gallery-mini-carousel">
<img
v-for="(src, i) in galleryPreviews"
:key="i"
:src="src"
class="gallery-mini-img"
alt="Foto"
/>
</div>
</section>
<!-- Sticky CTA -->
<div class="cta-bar">
<button class="cta-map"><span class="material-icons">near_me</span> Ver en el Mapa</button>
@ -638,4 +733,102 @@ const catEmoji = computed(() => CATEGORY_EMOJI[businessForm.value.category || ''
justify-content: center;
gap: 8px;
}
/* ── GALLERY UPLOAD ── */
.gallery-upload-zone {
position: relative;
}
.gallery-upload-zone input[type="file"] {
position: absolute;
width: 0.1px;
height: 0.1px;
opacity: 0;
z-index: -1;
}
.gallery-upload-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 28px;
border: 2px dashed rgba(254, 231, 21, 0.4);
border-radius: 16px;
background: rgba(254, 231, 21, 0.05);
color: #fee715;
cursor: pointer;
font-weight: 700;
transition: all 0.25s;
text-align: center;
}
.gallery-upload-label:hover {
border-color: #fee715;
background: rgba(254, 231, 21, 0.1);
}
.gallery-thumbs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.gallery-thumb {
position: relative;
width: 90px;
height: 90px;
border-radius: 12px;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.1);
flex-shrink: 0;
}
.gallery-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-remove {
position: absolute;
top: 4px;
right: 4px;
background: rgba(239,68,68,0.9);
border: none;
border-radius: 50%;
width: 22px;
height: 22px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
backdrop-filter: blur(4px);
}
.thumb-remove .material-icons { font-size: 14px; }
.gallery-empty-hint {
margin-top: 10px;
font-size: 0.78rem;
color: #64748b;
font-style: italic;
}
/* ── GALLERY PREVIEW CAROUSEL (mini) ── */
.gallery-mini-carousel {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 4px;
}
.gallery-mini-carousel::-webkit-scrollbar { display: none; }
.gallery-mini-img {
width: 110px;
height: 80px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.1);
}
</style>