Compare commits
2 Commits
70c533e755
...
647a00d895
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647a00d895 | ||
|
|
59812e547e |
61
.gitea/workflows/deploy.yml
Normal file
61
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Deploy GKACHELE App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/docker-setup # Activa el workflow al hacer push a esta rama para pruebas iniciales
|
||||
workflow_dispatch: # Permite ejecutar el workflow manualmente desde la interfaz de Gitea
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest # Usaremos un runner gestionado por Gitea.
|
||||
# Si quieres un runner en tu Pi, necesitarás instalarlo y luego cambiar a 'self-hosted'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub (o tu registro Gitea)
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }} # Tu usuario de Docker Hub o Gitea
|
||||
password: ${{ secrets.DOCKER_PASSWORD }} # Tu token/contraseña de Docker Hub o Gitea
|
||||
# Si usas el registro de Gitea, necesitarías ajustar 'registry' en esta acción
|
||||
|
||||
- name: Build and Push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./demo # Ruta al Dockerfile de tu aplicación Flask
|
||||
push: true
|
||||
tags: ${{ secrets.DOCKER_USERNAME }}/gkachele-app:latest # Formato: tu_usuario_docker/nombre_imagen:tag
|
||||
# Si usas el registro de Gitea, el tag sería algo como: ${{ secrets.GITEA_REGISTRY_URL }}/owner/gkachele-app:latest
|
||||
|
||||
- name: Deploy to Raspberry Pi via SSH
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }} # IP o hostname de tu Raspberry Pi
|
||||
username: ${{ secrets.SSH_USERNAME }} # Usuario SSH en tu Raspberry Pi (e.g., 'pi')
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }} # Clave SSH privada (sin passphrase) para autenticación sin contraseña
|
||||
script: |
|
||||
echo "Iniciando despliegue de la app GKACHELE™ en Raspberry Pi..."
|
||||
|
||||
# Autenticarse en el registro Docker (necesario para 'docker pull' si la imagen es privada o de Docker Hub)
|
||||
# Asegúrate de que el user/pass sea el mismo que el del 'Login to Docker Hub' step
|
||||
docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}"
|
||||
|
||||
# Descargar la última imagen
|
||||
docker pull ${{ secrets.DOCKER_USERNAME }}/gkachele-app:latest # Reemplaza con tu usuario/repositorio
|
||||
|
||||
# Detener y eliminar el contenedor existente (si hay uno con el mismo nombre)
|
||||
docker stop gkachele-instance || true
|
||||
docker rm gkachele-instance || true
|
||||
|
||||
# Iniciar un nuevo contenedor
|
||||
# Nota: Esto es una ejecución manual. Posteriormente, lo haremos con 'docker-compose up -d'
|
||||
# una vez que tengamos el docker-compose.yml específico para la Pi.
|
||||
docker run -d -p 5001:5001 --name gkachele-instance ${{ secrets.DOCKER_USERNAME }}/gkachele-app:latest
|
||||
|
||||
echo "Despliegue de GKACHELE™ en Raspberry Pi completado."
|
||||
33
MEMORIA_PROYECTO.md
Normal file
33
MEMORIA_PROYECTO.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Memoria del Proyecto: GKACHELE™ SaaS
|
||||
|
||||
Este documento sirve como un resumen contextual para la IA y el desarrollador.
|
||||
|
||||
## 1. Visión General del Proyecto
|
||||
|
||||
* **Nombre:** GKACHELE™
|
||||
* **Tipo:** SaaS (Software as a Service) para la creación de sitios web.
|
||||
* **Concepto:** Un "WordPress" propio, auto-alojado y hecho a medida.
|
||||
|
||||
## 2. Arquitectura y Tecnología
|
||||
|
||||
* **Backend:** Aplicación monolítica desarrollada en **Python** con el microframework **Flask**.
|
||||
* **Código Principal:** Ubicado en el directorio `demo/`.
|
||||
* **Entrypoint:** `demo/app.py`.
|
||||
* **Base de Datos Actual:** SQLite, en el archivo `demo/database/main.db`.
|
||||
* **Motor de Plantillas:** Un sistema personalizado (`demo/utils/theme_engine.py`) que imita la lógica de temas de WordPress.
|
||||
|
||||
## 3. Despliegue y Operaciones (DevOps)
|
||||
|
||||
* **Entorno de Producción:** Una **Raspberry Pi**.
|
||||
* **Proceso de Despliegue Actual:** Manual, mediante scripts (`.sh`) que copian archivos vía `scp` y gestionan el servicio con `systemd`.
|
||||
* **Control de Versiones:** **Gitea**, autohospedado.
|
||||
|
||||
## 4. Objetivo Estratégico Actual
|
||||
|
||||
El objetivo principal es **modificar y modernizar la aplicación existente de forma incremental**, no reescribirla desde cero.
|
||||
|
||||
El plan de acción es el siguiente:
|
||||
|
||||
1. **Contenerización:** Empaquetar la aplicación Flask y sus servicios en contenedores **Docker**.
|
||||
2. **Migración de Base de Datos:** Reemplazar SQLite por **PostgreSQL**, ejecutándose en su propio contenedor Docker.
|
||||
3. **Automatización (CI/CD):** Configurar **Gitea Actions** para automatizar el proceso de construcción de imágenes Docker y el despliegue en la Raspberry Pi tras cada `git push`.
|
||||
129
README.md
129
README.md
@@ -1,98 +1,75 @@
|
||||
# WordPress con Docker Compose
|
||||
# GKACHELE™ Agent - Configuración del Asistente y Documentación del Proyecto
|
||||
|
||||
Ejemplo básico de WordPress usando Docker Compose con MySQL y phpMyAdmin.
|
||||
Este archivo define las reglas, el contexto y el flujo principal para el asistente de IA (y cualquier desarrollador) trabajando en el ecosistema GKACHELE™.
|
||||
|
||||
## 🚀 Inicio Rápido
|
||||
## 🎯 Misión del Proyecto
|
||||
GKACHELE™ es un sistema SaaS modular para la creación y gestión de sitios web, enfocado en la flexibilidad, el control de versiones mediante Gitea y el despliegue optimizado en Raspberry Pi/Linux.
|
||||
|
||||
### Requisitos
|
||||
- Docker instalado
|
||||
- Docker Compose instalado
|
||||
## ⚠️ REGLAS CRÍTICAS (NUNCA ROMPER)
|
||||
|
||||
### Instalación
|
||||
### 1. Prohibición de Referencias Externas
|
||||
- **NUNCA** mencionar "WordPress", "wordpress", "WP" o "wp-" en el código fuente.
|
||||
- Este es un sistema **propio e independiente**.
|
||||
- **Reemplazos**:
|
||||
- "WordPress" -> "GKACHELE™" o "Sistema Modular".
|
||||
- "wp-admin" -> "/dashboard".
|
||||
- "wp_options" -> "tabla settings".
|
||||
|
||||
1. **Clonar o descargar este proyecto**
|
||||
### 2. Metodología GKACHELE™
|
||||
- **Prioridad 1: Funcionalidad**. Hacer que el código funcione y sea verificado en producción (Raspberry).
|
||||
- **Prioridad 2: Limpieza**. Refactorizar y optimizar SOLO después de que la funcionalidad sea confirmada.
|
||||
- **Flujo**: `Funcionalidad -> Probar -> Funciona -> Limpieza -> Documentar`.
|
||||
|
||||
2. **Iniciar los contenedores:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
## 🛠️ Skills & Infraestructura
|
||||
|
||||
3. **Acceder a WordPress:**
|
||||
- Abre tu navegador en: http://localhost:8080
|
||||
- Sigue el asistente de instalación de WordPress
|
||||
### Gestión de Repositorios (Gitea)
|
||||
- Uso de `gitea_connector.py` para automatizar la creación de organizaciones y repositorios por cliente.
|
||||
- Workflows de auto-commit y auto-deploy tras cambios en el customizer.
|
||||
|
||||
4. **Acceder a phpMyAdmin (opcional):**
|
||||
- Abre tu navegador en: http://localhost:8081
|
||||
- Usuario: `root`
|
||||
- Contraseña: `root_password`
|
||||
### Infraestructura (Raspberry Pi & Docker)
|
||||
- Despliegue mediante `docker-compose`.
|
||||
- Scripts de sincronización: `sync-to-raspberry.sh`, `update-code-pi.sh`.
|
||||
- Dominios gestionados via DuckDNS.
|
||||
|
||||
## 📝 Configuración
|
||||
## 📝 Guías de Trabajo para el Agente
|
||||
- **Análisis antes de actuar**: Siempre revisar la carpeta `memoria/` antes de realizar cambios estructurales.
|
||||
- **Verificación de reglas**: Antes de cada commit, realizar un grep para asegurar que no se colaron referencias prohibidas.
|
||||
- **Persistencia**: Actualizar `task.md` y la memoria del proyecto tras completar hitos importantes.
|
||||
|
||||
### Cambiar puertos
|
||||
Si los puertos 8080 o 8081 están ocupados, edita `docker-compose.yml`:
|
||||
```yaml
|
||||
ports:
|
||||
- "TU_PUERTO:80" # Cambia TU_PUERTO por el que prefieras
|
||||
```
|
||||
---
|
||||
|
||||
### Cambiar credenciales
|
||||
Edita las variables de entorno en `docker-compose.yml`:
|
||||
- `MYSQL_PASSWORD`: Contraseña del usuario de WordPress
|
||||
- `MYSQL_ROOT_PASSWORD`: Contraseña del root de MySQL
|
||||
- `WORDPRESS_DB_PASSWORD`: Debe coincidir con `MYSQL_PASSWORD`
|
||||
## 🔄 Flujo Principal de la Aplicación GKACHELE™
|
||||
|
||||
## 🛠️ Comandos Útiles
|
||||
Este es el proceso completo, desde un nuevo visitante hasta un sitio web publicado:
|
||||
|
||||
### Ver logs
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
1. **Visita a la Landing Page (`/`):**
|
||||
* Un cliente potencial visita la página principal del servicio.
|
||||
|
||||
### Detener contenedores
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
2. **Solicitud de Plan y Configuración Inicial:**
|
||||
* Desde la Landing Page, el cliente selecciona un plan (Base, Pro, Premium).
|
||||
* Rellena un formulario emergente (menú desplegable) con información inicial (nombre, email, rubro, etc.).
|
||||
* Este proceso redirige al usuario a la página de registro con los datos pre-cargados.
|
||||
|
||||
### Detener y eliminar volúmenes (⚠️ borra los datos)
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
3. **Registro de Cliente (`/register`):**
|
||||
* El cliente finaliza su registro en la plataforma.
|
||||
|
||||
### Reiniciar un servicio específico
|
||||
```bash
|
||||
docker-compose restart wordpress
|
||||
```
|
||||
4. **Creación del Sitio en Borrador (`/customizer`):**
|
||||
* Una vez registrado y/o logueado, el cliente es dirigido al "Customizer". Aquí puede diseñar y personalizar su sitio web, que permanece en estado de borrador y no es público.
|
||||
|
||||
### Ver contenedores en ejecución
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
5. **Envío de Solicitud de Publicación:**
|
||||
* Cuando el cliente considera que su sitio en borrador está listo, lo envía para tu revisión y aprobación.
|
||||
|
||||
## 📁 Estructura
|
||||
6. **Revisión en el Dashboard del Administrador (`/dashboard`):**
|
||||
* Tú, como administrador del sistema, recibes esta solicitud en tu panel principal. Aquí puedes ver todos los sitios pendientes de aprobación.
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml # Configuración de servicios
|
||||
├── wp-content/ # Temas y plugins personalizados (se crea automáticamente)
|
||||
└── README.md # Este archivo
|
||||
```
|
||||
7. **Aprobación o Rechazo Manual:**
|
||||
* Revisas el sitio del cliente. Desde tu dashboard, decides si **apruebas** o **rechazas** la solicitud de publicación.
|
||||
|
||||
## 🔒 Seguridad
|
||||
8. **Panel de Cliente (`/admin`) y Publicación del Sitio:**
|
||||
* Si la solicitud es **aprobada**, el sitio web del cliente se **publica**, y se habilita un panel de administración específico para ese cliente (`/admin`), desde donde puede gestionar su sitio en línea.
|
||||
|
||||
⚠️ **IMPORTANTE**: Este es un ejemplo básico para desarrollo. Para producción:
|
||||
- Cambia todas las contraseñas por defecto
|
||||
- Usa variables de entorno seguras
|
||||
- Configura SSL/TLS
|
||||
- Implementa un firewall
|
||||
- Usa secrets de Docker o un gestor de secretos
|
||||
9. **Visualización Pública del Sitio:**
|
||||
* El sitio web aprobado y publicado es ahora accesible para cualquier usuario de internet en su dominio asignado.
|
||||
|
||||
## 🐳 Servicios Incluidos
|
||||
|
||||
- **WordPress**: Aplicación principal (puerto 8080)
|
||||
- **MySQL 8.0**: Base de datos (puerto interno)
|
||||
- **phpMyAdmin**: Administrador de base de datos (puerto 8081)
|
||||
|
||||
## 📚 Recursos
|
||||
|
||||
- [Documentación de WordPress](https://wordpress.org/support/)
|
||||
- [Documentación de Docker Compose](https://docs.docker.com/compose/)
|
||||
---
|
||||
**© 2025 GKACHELE™. Todos los derechos reservados.**
|
||||
@@ -1,21 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
# Usa una imagen oficial de Python como base
|
||||
FROM python:3.9-slim-buster
|
||||
|
||||
# Establece el directorio de trabajo dentro del contenedor
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias del sistema
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copiar requirements e instalar
|
||||
COPY demo/requirements.txt .
|
||||
# Copia el archivo de requisitos e instálalos
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copiar el resto del código
|
||||
# Copia el resto de la aplicación al directorio de trabajo
|
||||
COPY . .
|
||||
|
||||
# Exponer el puerto
|
||||
EXPOSE 5000
|
||||
# Expone el puerto en el que corre la aplicación Flask (definido en config.py)
|
||||
EXPOSE 5001
|
||||
|
||||
# Comando para arrancar
|
||||
CMD ["python", "demo/app.py"]
|
||||
# Comando para correr la aplicación
|
||||
# Asegúrate de que app.py esté en el directorio raíz de WORKDIR (/app)
|
||||
# Y que las variables de entorno si son necesarias para SECRET_KEY y PORT se pasen al docker run o compose
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
BIN
demo/__pycache__/config.cpython-314.pyc
Normal file
BIN
demo/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/__pycache__/database.cpython-314.pyc
Normal file
BIN
demo/__pycache__/database.cpython-314.pyc
Normal file
Binary file not shown.
188
demo/customizer.html
Normal file
188
demo/customizer.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GKACHELE Customizer</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: system-ui, Arial; margin:0; background:#f0f0f1; }
|
||||
.wrap { display:flex; height:100vh; }
|
||||
.sidebar { width:340px; background:#fff; border-right:1px solid #ddd; padding:12px; overflow:auto }
|
||||
.preview { flex:1; display:flex; flex-direction:column }
|
||||
.preview-header { padding:12px; background:#fff; border-bottom:1px solid #ddd }
|
||||
.preview-body { padding:20px; overflow:auto; }
|
||||
.btn { padding:8px 12px; border-radius:4px; border:0; cursor:pointer }
|
||||
.btn-primary { background:#2271b1; color:#fff }
|
||||
.btn-secondary { background:#f0f0f1 }
|
||||
.block-item { padding:10px; border:1px solid #e5e5e5; margin-bottom:8px; border-radius:4px; display:flex; justify-content:space-between }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="sidebar">
|
||||
<h3>GKACHELE™ Customizer</h3>
|
||||
<div>
|
||||
<h4>Ajustes</h4>
|
||||
<label>Nombre sitio<br><input id="siteName" placeholder="Mi Sitio"></label>
|
||||
<label>Color primario<br><input type="color" id="colorPrimary" value="#2271b1"></label>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>Añadir Bloque</h4>
|
||||
<button class="btn btn-primary" onclick="addBlock('heading')">Encabezado</button>
|
||||
<button class="btn btn-primary" onclick="addBlock('paragraph')">Párrafo</button>
|
||||
<button class="btn btn-primary" onclick="addBlock('image')">Imagen</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<h4>Bloques</h4>
|
||||
<div id="blocksList"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<div class="preview-header">
|
||||
<span id="previewTitle">Vista previa</span>
|
||||
<div style="float:right">
|
||||
<button class="btn btn-secondary" onclick="discardChanges()">Descartar</button>
|
||||
<button class="btn btn-primary" onclick="saveChanges()">Guardar y Publicar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div id="previewBlocks"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple customizer adapted to GKACHELE backend
|
||||
let state = {
|
||||
siteId: (new URLSearchParams(window.location.search)).get('site_id') || null,
|
||||
blocks: [],
|
||||
settings: { siteName:'Mi Sitio', colorPrimary:'#2271b1' },
|
||||
hasChanges: false,
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// populate from backend if editing a real site
|
||||
await loadFromBackendIfAvailable();
|
||||
// fallback: localStorage
|
||||
loadFromLocalStorage();
|
||||
renderUI();
|
||||
});
|
||||
|
||||
function loadFromLocalStorage(){
|
||||
if (!state.siteId) {
|
||||
const saved = localStorage.getItem('gkachele_customizer_demo');
|
||||
if (saved) {
|
||||
const d = JSON.parse(saved);
|
||||
state.blocks = d.blocks||state.blocks;
|
||||
state.settings = {...state.settings, ...(d.settings||{})};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromBackendIfAvailable(){
|
||||
if (!state.siteId) return;
|
||||
try{
|
||||
const r = await fetch(`/api/customizer/get-content/${state.siteId}`);
|
||||
const j = await r.json();
|
||||
if (j && j.success) {
|
||||
const c = j.content || {};
|
||||
state.blocks = c.blocks || [];
|
||||
state.settings = {...state.settings, ...(c.settings||{})};
|
||||
state.hasChanges = false;
|
||||
updateSettingsUI();
|
||||
}
|
||||
}catch(e){ console.warn('Backend load failed', e); }
|
||||
}
|
||||
|
||||
function updateSettingsUI(){
|
||||
document.getElementById('siteName').value = state.settings.siteName||'';
|
||||
document.getElementById('colorPrimary').value = state.settings.colorPrimary||'#2271b1';
|
||||
}
|
||||
|
||||
function renderUI(){
|
||||
updateSettingsUI();
|
||||
renderBlocksList();
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
function addBlock(type){
|
||||
const b = { id:'b_'+Date.now(), type:type, data: getDefault(type) };
|
||||
state.blocks.push(b);
|
||||
state.hasChanges = true;
|
||||
renderUI();
|
||||
}
|
||||
|
||||
function getDefault(type){
|
||||
if (type==='heading') return {text:'Título'};
|
||||
if (type==='paragraph') return {text:'Párrafo de ejemplo'};
|
||||
if (type==='image') return {url:'https://via.placeholder.com/600x300'};
|
||||
return {};
|
||||
}
|
||||
|
||||
function renderBlocksList(){
|
||||
const el = document.getElementById('blocksList');
|
||||
if (!state.blocks.length) { el.innerHTML='<div style="color:#888">No hay bloques</div>'; return; }
|
||||
el.innerHTML = state.blocks.map((b,i)=>`<div class="block-item"><div>${b.type}</div><div><button onclick="editBlock(${i})">✏️</button> <button onclick="deleteBlock(${i})">🗑️</button></div></div>`).join('');
|
||||
}
|
||||
|
||||
function renderPreview(){
|
||||
const el = document.getElementById('previewBlocks');
|
||||
if (!state.blocks.length) { el.innerHTML='<div style="color:#999">Añade bloques para ver la preview</div>'; return; }
|
||||
el.innerHTML = state.blocks.map(b=>renderBlockHtml(b)).join('');
|
||||
document.getElementById('previewTitle').textContent = state.settings.siteName || 'Vista previa';
|
||||
}
|
||||
|
||||
function renderBlockHtml(b){
|
||||
if (b.type==='heading') return `<h2>${escapeHtml(b.data.text)}</h2>`;
|
||||
if (b.type==='paragraph') return `<p>${escapeHtml(b.data.text)}</p>`;
|
||||
if (b.type==='image') return `<img src="${escapeHtml(b.data.url)}" style="max-width:100%;height:auto"/>`;
|
||||
return `<div>${b.type}</div>`;
|
||||
}
|
||||
|
||||
function editBlock(idx){
|
||||
const b = state.blocks[idx];
|
||||
const value = prompt('Editar contenido', b.type==='image'?b.data.url:b.data.text);
|
||||
if (value!==null){
|
||||
if (b.type==='image') b.data.url = value; else b.data.text = value;
|
||||
state.hasChanges = true; renderUI();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBlock(idx){ if (confirm('Eliminar bloque?')) { state.blocks.splice(idx,1); state.hasChanges=true; renderUI(); } }
|
||||
|
||||
function escapeHtml(s){ if (!s) return ''; return s.replaceAll('&','&').replaceAll('<','<').replaceAll('>','>'); }
|
||||
|
||||
function discardChanges(){ if (!state.hasChanges){ alert('No hay cambios'); return;} if (confirm('Descartar cambios?')){ state.blocks=[]; state.settings={siteName:'Mi Sitio',colorPrimary:'#2271b1'}; localStorage.removeItem('gkachele_customizer_demo'); renderUI(); state.hasChanges=false;} }
|
||||
|
||||
function updateSaveIndicator(status){ console.log('save:',status); }
|
||||
|
||||
function saveChanges(){
|
||||
// collect settings from UI
|
||||
state.settings.siteName = document.getElementById('siteName').value;
|
||||
state.settings.colorPrimary = document.getElementById('colorPrimary').value;
|
||||
|
||||
const content = { blocks: state.blocks, settings: state.settings };
|
||||
|
||||
// save local demo
|
||||
if (!state.siteId) localStorage.setItem('gkachele_customizer_demo', JSON.stringify(content));
|
||||
|
||||
// send to backend using API expected by server: { site_id, content }
|
||||
if (!state.siteId) { alert('Guardado local (demo). Para guardar en SaaS abre el customizer con ?site_id=ID'); state.hasChanges=false; return; }
|
||||
|
||||
updateSaveIndicator('saving');
|
||||
fetch('/api/customizer/save', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ site_id: state.siteId, content: content })
|
||||
}).then(r=>r.json()).then(j=>{
|
||||
if (j && j.success) { state.hasChanges=false; updateSaveIndicator('saved'); alert('Guardado en servidor'); }
|
||||
else { updateSaveIndicator('error'); alert('Error guardando'); }
|
||||
}).catch(e=>{ updateSaveIndicator('error'); alert('Error guardando: '+e); });
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -150,6 +150,6 @@ def init_db():
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ Base de datos GKACHELE inicializada correctamente.")
|
||||
print("Base de datos GKACHELE inicializada correctamente.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error inicializando DB: {e}")
|
||||
print(f" Error inicializando DB: {e}")
|
||||
|
||||
Binary file not shown.
@@ -1,2 +1,8 @@
|
||||
blinker==1.9.0
|
||||
click==8.3.1
|
||||
colorama==0.4.6
|
||||
Flask==2.3.3
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
Werkzeug==2.3.7
|
||||
|
||||
BIN
demo/routes/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/routes/__pycache__/admin.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/routes/__pycache__/auth.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/routes/__pycache__/customizer.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/customizer.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/routes/__pycache__/dashboard.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/dashboard.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/routes/__pycache__/public.cpython-314.pyc
Normal file
BIN
demo/routes/__pycache__/public.cpython-314.pyc
Normal file
Binary file not shown.
@@ -9,6 +9,19 @@ from utils.auth_decorators import login_required
|
||||
|
||||
customizer_bp = Blueprint('customizer', __name__)
|
||||
|
||||
|
||||
@customizer_bp.route('/customizer')
|
||||
def customizer_demo():
|
||||
"""Ruta de conveniencia: si se pasa ?site_id=ID delega a customizer_view, si no muestra demo"""
|
||||
sid = request.args.get('site_id')
|
||||
if sid:
|
||||
try:
|
||||
return customizer_view(int(sid))
|
||||
except Exception:
|
||||
pass
|
||||
# Render demo template with empty content
|
||||
return render_template('customizer.html', site_id='demo', slug='demo', theme=None, content={}, theme_template=None, theme_config={}, available_themes={}, user_plan='base')
|
||||
|
||||
@customizer_bp.route('/api/themes')
|
||||
def list_themes():
|
||||
"""Listar todos los templates disponibles filtrados por plan"""
|
||||
@@ -35,12 +48,13 @@ def customizer_view(site_id):
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT user_id, slug, theme, content_json FROM sites WHERE id = ?', (site_id,))
|
||||
site = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not site:
|
||||
conn.close()
|
||||
return "Sitio no encontrado", 404
|
||||
|
||||
if 'user_id' in session and site[0] != session['user_id']:
|
||||
conn.close()
|
||||
return "No autorizado", 403
|
||||
|
||||
content = json.loads(site[3]) if site[3] else {}
|
||||
@@ -55,9 +69,10 @@ def customizer_view(site_id):
|
||||
theme_config = get_theme_config(theme)
|
||||
|
||||
# Obtener plan del usuario para filtrar templates
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT plan, rubro FROM users WHERE id = ?', (site[0],))
|
||||
user_data = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
user_plan = user_data[0] if user_data else 'base'
|
||||
user_rubro = user_data[1] if user_data else 'restaurante'
|
||||
|
||||
@@ -91,6 +106,43 @@ def save_customizer():
|
||||
conn.close()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@customizer_bp.route('/api/customizer/get-blocks/<int:site_id>', methods=['GET'])
|
||||
def get_blocks(site_id):
|
||||
"""Retorna los bloques de un sitio"""
|
||||
conn = sqlite3.connect(MAIN_DB)
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not result or not result[0]:
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
content = json.loads(result[0])
|
||||
return jsonify(content.get('blocks', []))
|
||||
except:
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@customizer_bp.route('/api/customizer/get-content/<int:site_id>', methods=['GET'])
|
||||
def get_content(site_id):
|
||||
"""Retorna el contenido completo (blocks + settings) de un sitio"""
|
||||
conn = sqlite3.connect(MAIN_DB)
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not result or not result[0]:
|
||||
return jsonify({'success': True, 'content': {}})
|
||||
|
||||
try:
|
||||
content = json.loads(result[0])
|
||||
return jsonify({'success': True, 'content': content})
|
||||
except Exception:
|
||||
return jsonify({'success': True, 'content': {}})
|
||||
|
||||
@customizer_bp.route('/api/customizer/add-block', methods=['POST'])
|
||||
def add_block():
|
||||
data = request.get_json()
|
||||
@@ -102,6 +154,10 @@ def add_block():
|
||||
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
|
||||
result = c.fetchone()
|
||||
|
||||
if not result:
|
||||
conn.close()
|
||||
return jsonify({'success': False, 'error': 'Sitio no encontrado'}), 404
|
||||
|
||||
content = json.loads(result[0]) if result[0] else {}
|
||||
if 'blocks' not in content: content['blocks'] = []
|
||||
|
||||
|
||||
25
demo/routes/get_blocks_fix.py
Normal file
25
demo/routes/get_blocks_fix.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import json
|
||||
|
||||
customizer_bp = Blueprint('customizer_api', __name__)
|
||||
|
||||
@customizer_bp.route('/api/customizer/get-blocks/<int:site_id>', methods=['GET'])
|
||||
def get_blocks(site_id):
|
||||
"""Retorna los bloques de un sitio"""
|
||||
import sqlite3
|
||||
from config import MAIN_DB
|
||||
|
||||
conn = sqlite3.connect(MAIN_DB)
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT content_json FROM sites WHERE id = ?', (site_id,))
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not result or not result[0]:
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
content = json.loads(result[0])
|
||||
return jsonify(content.get('blocks', []))
|
||||
except:
|
||||
return jsonify([])
|
||||
13
demo/static/test.html
Normal file
13
demo/static/test.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>Test Customizer</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Abre la Consola (F12) y verás los errores del Customizer</h1>
|
||||
<iframe src="http://localhost:5001/customizer/1" width="100%" height="800px"></iframe>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,36 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Admin - Demo</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
th, td {
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 15px;
|
||||
background: #4caf50;
|
||||
@@ -41,6 +53,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🔧 Panel Admin</h1>
|
||||
@@ -67,7 +80,9 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not requests %}
|
||||
<tr><td colspan="5">No hay solicitudes pendientes</td></tr>
|
||||
<tr>
|
||||
<td colspan="5">No hay solicitudes pendientes</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
@@ -104,7 +119,9 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not users %}
|
||||
<tr><td colspan="9">No hay usuarios registrados</td></tr>
|
||||
<tr>
|
||||
<td colspan="9">No hay usuarios registrados</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
@@ -137,6 +154,7 @@
|
||||
background: #d63638;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b32d2e;
|
||||
}
|
||||
@@ -175,4 +193,5 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
1704
demo/templates/customizer.html.bak
Normal file
1704
demo/templates/customizer.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
1704
demo/templates/customizer.html.v2.bak
Normal file
1704
demo/templates/customizer.html.v2.bak
Normal file
File diff suppressed because it is too large
Load Diff
1704
demo/templates/customizer_pro_ready.html
Normal file
1704
demo/templates/customizer_pro_ready.html
Normal file
File diff suppressed because it is too large
Load Diff
597
demo/templates/demo_pro_v3.html
Normal file
597
demo/templates/demo_pro_v3.html
Normal file
@@ -0,0 +1,597 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GKACHELE Builder - Premium V3</title>
|
||||
<!-- Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--bg-sidebar: #ffffff;
|
||||
--bg-canvas: #f3f4f6;
|
||||
--border: #e5e7eb;
|
||||
--text-main: #111827;
|
||||
--text-muted: #6b7280;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-canvas);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar {
|
||||
width: 380px;
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.04);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.5px;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
-webkit-text-fill-color: #2563eb;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-sections {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.section-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.section-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-title h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.section-title p {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ACTIVE PANEL OVERLAY */
|
||||
.panel-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 380px;
|
||||
background: #fff;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border);
|
||||
box-shadow: 10px 0 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.panel-drawer.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
border: none;
|
||||
background: #f3f4f6;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4b5563;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.drawer-close:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* FORM CONTROLS */
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
transition: 0.2s;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input:focus,
|
||||
.textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* MENU CARD DESIGN */
|
||||
.dish-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.dish-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dish-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #eee;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dish-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dish-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dish-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dish-price {
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dish-desc {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
border: 1px dashed #bae6fd;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #e0f2fe;
|
||||
border-color: #7dd3fc;
|
||||
}
|
||||
|
||||
/* PREVIEW CANVAS */
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
background-color: #e5e5e5;
|
||||
background-image: radial-gradient(#d4d4d4 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
padding: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-bar {
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.device-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 50%;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.device-btn.active {
|
||||
background: var(--bg-canvas);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.device-btn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.preview-frame-wrapper {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<!-- SIDEBAR NAVIGATION -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo"><i class="fa-solid fa-cube"></i> GKACHELE™</div>
|
||||
<a href="#" class="back-link"><i class="fa-solid fa-arrow-left"></i> Salir</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-sections">
|
||||
<div class="section-item" onclick="openDrawer('identity')">
|
||||
<div class="section-info">
|
||||
<div class="section-icon"><i class="fa-solid fa-store"></i></div>
|
||||
<div class="section-title">
|
||||
<h3>Identidad del Sitio</h3>
|
||||
<p>Logo, Nombre, Slogan</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right" style="color:#d1d5db; font-size: 12px;"></i>
|
||||
</div>
|
||||
|
||||
<div class="section-item" onclick="openDrawer('menus')">
|
||||
<div class="section-info">
|
||||
<div class="section-icon" style="background:#fff7ed; color:#c2410c;"><i
|
||||
class="fa-solid fa-utensils"></i></div>
|
||||
<div class="section-title">
|
||||
<h3>Menú & Platos</h3>
|
||||
<p>Gestionar carta digital</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right" style="color:#d1d5db; font-size: 12px;"></i>
|
||||
</div>
|
||||
|
||||
<div class="section-item" onclick="openDrawer('style')">
|
||||
<div class="section-info">
|
||||
<div class="section-icon" style="background:#fdf4ff; color:#a21caf;"><i
|
||||
class="fa-solid fa-palette"></i></div>
|
||||
<div class="section-title">
|
||||
<h3>Estilo & Marca</h3>
|
||||
<p>Colores, Tipografía</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-chevron-right" style="color:#d1d5db; font-size: 12px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 24px; border-top: 1px solid var(--border);">
|
||||
<button
|
||||
style="width:100%; padding: 14px; background: var(--text-main); color: #fff; border:none; border-radius: 10px; font-weight: 600; cursor: pointer;">Publicar
|
||||
Cambios</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DRAWERS (HIDDEN PANELS) -->
|
||||
|
||||
<!-- MENU DRAWER -->
|
||||
<div id="menus" class="panel-drawer">
|
||||
<div class="drawer-header">
|
||||
<button class="drawer-close" onclick="closeDrawers()"><i class="fa-solid fa-arrow-left"></i></button>
|
||||
<span class="drawer-title">Gestionar Menú</span>
|
||||
</div>
|
||||
<div class="drawer-content">
|
||||
<button class="btn-add"><i class="fa-solid fa-plus"></i> Añadir Nuevo Plato</button>
|
||||
|
||||
<div class="dish-card">
|
||||
<img src="https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=200&h=200"
|
||||
class="dish-img">
|
||||
<div class="dish-info">
|
||||
<div class="dish-header">
|
||||
<span class="dish-name">Bowl Saludable</span>
|
||||
<span class="dish-price">$12.50</span>
|
||||
</div>
|
||||
<p class="dish-desc">Quinoa, aguacate, tomate cherry, huevo pouche y aderezo de sésamo.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dish-card">
|
||||
<img src="https://images.unsplash.com/photo-1482049016688-2d3e1b311543?auto=format&fit=crop&w=200&h=200"
|
||||
class="dish-img">
|
||||
<div class="dish-info">
|
||||
<div class="dish-header">
|
||||
<span class="dish-name">Tostada de Aguacate</span>
|
||||
<span class="dish-price">$8.00</span>
|
||||
</div>
|
||||
<p class="dish-desc">Pan de masa madre, aguacate triturado, semillas de girasol.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IDENTITY DRAWER -->
|
||||
<div id="identity" class="panel-drawer">
|
||||
<div class="drawer-header">
|
||||
<button class="drawer-close" onclick="closeDrawers()"><i class="fa-solid fa-arrow-left"></i></button>
|
||||
<span class="drawer-title">Identidad</span>
|
||||
</div>
|
||||
<div class="drawer-content">
|
||||
<div class="form-group">
|
||||
<label class="label">Nombre del Negocio</label>
|
||||
<input type="text" class="input" id="inputName" value="Green Bowl Madrid"
|
||||
oninput="updatePreviewText('.logo', this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Descripción Corta</label>
|
||||
<textarea class="textarea" rows="3" id="inputDesc"
|
||||
oninput="updatePreviewText('.hero p', this.value)">Comida saludable y fresca en el corazón de la ciudad.</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label">Título Hero</label>
|
||||
<input type="text" class="input" id="inputHero" value="Sabor Natural"
|
||||
oninput="updatePreviewText('.hero h1', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW AREA -->
|
||||
<div class="canvas-area">
|
||||
<div class="device-bar">
|
||||
<button class="device-btn active" onclick="setDevice('100%')"><i class="fa-solid fa-desktop"></i></button>
|
||||
<button class="device-btn" onclick="setDevice('768px')"><i
|
||||
class="fa-solid fa-tablet-screen-button"></i></button>
|
||||
<button class="device-btn" onclick="setDevice('390px')"><i
|
||||
class="fa-solid fa-mobile-screen-button"></i></button>
|
||||
</div>
|
||||
<div class="preview-frame-wrapper" style="max-width: 100%">
|
||||
<!-- MOCK FRAME CONTENT FOR DEMO -->
|
||||
<iframe id="previewFrame" srcdoc='
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; font-family: "Helvetica Neue", sans-serif; }
|
||||
header { padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; }
|
||||
.logo { font-weight: bold; font-size: 20px; }
|
||||
.hero { padding: 80px 40px; text-align: center; background: #f9f9f9; }
|
||||
h1 { font-size: 48px; margin: 0 0 20px 0; }
|
||||
p { font-size: 18px; color: #666; max-width: 600px; margin: 0 auto; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 30px; padding: 40px; max-width: 1200px; margin: 0 auto; }
|
||||
.card { border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
|
||||
.card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.card-body { padding: 20px; }
|
||||
.price { float: right; font-weight: bold; color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Green Bowl Madrid</div>
|
||||
<nav>
|
||||
<a href="#" style="margin-left: 20px; text-decoration: none; color: #333;">Menú</a>
|
||||
<a href="#" style="margin-left: 20px; text-decoration: none; color: #333;">Reservas</a>
|
||||
<a href="#" style="margin-left: 20px; text-decoration: none; color: #333;">Contacto</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="hero">
|
||||
<h1>Sabor Natural</h1>
|
||||
<p>Los mejores ingredientes orgánicos seleccionados para ti cada mañana.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=500&q=60">
|
||||
<div class="card-body">
|
||||
<span class="price">$12.50</span>
|
||||
<h3>Bowl Saludable</h3>
|
||||
<p style="font-size: 14px">Quinoa, aguacate, tomate cherry...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1482049016688-2d3e1b311543?auto=format&fit=crop&w=500&q=60">
|
||||
<div class="card-body">
|
||||
<span class="price">$8.00</span>
|
||||
<h3>Tostada de Aguacate</h3>
|
||||
<p style="font-size: 14px">Pan de masa madre tostado...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1512621776951-a57141f2eefd?auto=format&fit=crop&w=500&q=60">
|
||||
<div class="card-body">
|
||||
<span class="price">$10.00</span>
|
||||
<h3>Ensalada César</h3>
|
||||
<p style="font-size: 14px">Lechuga romana, crutones, parmesano...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
'></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openDrawer(id) {
|
||||
document.querySelectorAll(".panel-drawer").forEach(d => d.classList.remove("active"));
|
||||
const target = document.getElementById(id);
|
||||
if (target) target.classList.add("active");
|
||||
}
|
||||
|
||||
function closeDrawers() {
|
||||
document.querySelectorAll(".panel-drawer").forEach(d => d.classList.remove("active"));
|
||||
}
|
||||
|
||||
function setDevice(size) {
|
||||
const wrapper = document.querySelector(".preview-frame-wrapper");
|
||||
wrapper.style.maxWidth = size;
|
||||
|
||||
document.querySelectorAll(".device-btn").forEach(b => b.classList.remove("active"));
|
||||
event.currentTarget.classList.add("active");
|
||||
}
|
||||
|
||||
// REAL-TIME PREVIEW LOGIC
|
||||
function updatePreviewText(selector, value) {
|
||||
const iframe = document.getElementById('previewFrame');
|
||||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
const el = doc.querySelector(selector);
|
||||
if (el) el.innerText = value;
|
||||
}
|
||||
|
||||
// Initialize preview editability after load
|
||||
document.getElementById('previewFrame').onload = function () {
|
||||
// Optional: Add click-to-edit logic inside iframe if needed later
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
BIN
demo/utils/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
demo/utils/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/utils/__pycache__/auth_decorators.cpython-314.pyc
Normal file
BIN
demo/utils/__pycache__/auth_decorators.cpython-314.pyc
Normal file
Binary file not shown.
BIN
demo/utils/__pycache__/theme_engine.cpython-314.pyc
Normal file
BIN
demo/utils/__pycache__/theme_engine.cpython-314.pyc
Normal file
Binary file not shown.
@@ -164,6 +164,7 @@ def render_gkachele_template(theme, content, site_id=None, user_id=None):
|
||||
template_data = {
|
||||
'site_name': content.get('site_name', 'GKACHELE Site'),
|
||||
'hero_title': content.get('hero_title', 'Bienvenido'),
|
||||
'hero_description': content.get('hero_description', ''),
|
||||
'colors': content.get('colors', {}),
|
||||
'typography': content.get('typography', {}),
|
||||
'horarios': content.get('horarios', {}),
|
||||
@@ -171,6 +172,14 @@ def render_gkachele_template(theme, content, site_id=None, user_id=None):
|
||||
'blocks': content.get('blocks', []),
|
||||
'menus': menus,
|
||||
'widgets': widgets,
|
||||
'especialidad_culinaria': content.get('especialidad_culinaria', {}),
|
||||
'menu_items': content.get('menu_items', {}),
|
||||
'menu_url': content.get('menu_url', ''),
|
||||
'capacidad': content.get('capacidad', '50'),
|
||||
'direccion': content.get('direccion', ''),
|
||||
'telefono': content.get('telefono', ''),
|
||||
'email': content.get('email', ''),
|
||||
'mapa_url': content.get('mapa_url', ''),
|
||||
**content
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
# GKACHELE™ SaaS Modular - Backend Flask
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./demo/Dockerfile
|
||||
container_name: gkachele_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- SECRET_KEY=demo-secret-key-2025
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./demo/database:/app/demo/database # Persistencia de la DB SQLite
|
||||
networks:
|
||||
- gkachele_network
|
||||
|
||||
networks:
|
||||
gkachele_network:
|
||||
driver: bridge
|
||||
620
saas-demo.html
Normal file
620
saas-demo.html
Normal file
@@ -0,0 +1,620 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<!--
|
||||
FILE VERSION HISTORY
|
||||
Version 1.0 - original
|
||||
Version 2.0 - 2026-01-29: Mejoras: permitir múltiples bloques, redimensionamiento por columnas (colSpan) y fondo multimedia para plan Premium
|
||||
-->
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PageBuilder Pro - Apple Design</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f7; color: #1d1d1f; overflow: hidden; }
|
||||
|
||||
/* AUTENTICACIÓN */
|
||||
.auth-screen { display: flex; height: 100vh; background: white; }
|
||||
.auth-screen.hidden { display: none !important; }
|
||||
.auth-left { flex: 1; background: linear-gradient(135deg, #fa7921 0%, #fbb03b 100%); display: flex; flex-direction: column; justify-content: center; align-items: center; color: white; padding: 60px; text-align: center; }
|
||||
.auth-left h1 { font-size: 56px; margin-bottom: 20px; font-weight: 700; letter-spacing: -1px; }
|
||||
.auth-left p { font-size: 15px; opacity: 0.95; margin-bottom: 40px; max-width: 420px; line-height: 1.7; }
|
||||
.auth-features { list-style: none; margin-top: 30px; }
|
||||
.auth-features li { margin-bottom: 14px; font-size: 14px; opacity: 0.9; }
|
||||
.auth-right { flex: 1; display: flex; justify-content: center; align-items: center; padding: 60px; overflow-y: auto; }
|
||||
.auth-box { width: 100%; max-width: 420px; }
|
||||
.auth-box h2 { font-size: 28px; margin-bottom: 12px; color: #1d1d1f; font-weight: 700; }
|
||||
.auth-box p { color: #86868b; margin-bottom: 24px; font-size: 14px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: #1d1d1f; font-size: 13px; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 11px 14px; border: 1px solid #d2d2d7; border-radius: 10px; font-family: inherit; font-size: 14px; transition: all 0.2s; background: white; }
|
||||
.form-group input:focus, .form-group select:focus { outline: none; border-color: #fa7921; box-shadow: 0 0 0 2px rgba(250, 121, 33, 0.08); }
|
||||
.plan-options { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
||||
.plan-option { padding: 12px; border: 2px solid #d2d2d7; border-radius: 10px; cursor: pointer; text-align: center; transition: all 0.2s; background: white; }
|
||||
.plan-option:hover { border-color: #fa7921; background: #fef5ed; }
|
||||
.plan-option.selected { border-color: #fa7921; background: #fa7921; color: white; }
|
||||
.plan-option strong { display: block; font-size: 13px; }
|
||||
.plan-option small { display: block; font-size: 11px; margin-top: 4px; opacity: 0.8; }
|
||||
.btn { padding: 12px 24px; border: none; border-radius: 10px; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.btn-primary { background: #fa7921; color: white; width: 100%; }
|
||||
.btn-primary:hover { background: #f26b1f; }
|
||||
.btn-secondary { background: #f5f5f7; color: #1d1d1f; }
|
||||
.btn-secondary:hover { background: #e5e5e7; }
|
||||
.btn-sm { padding: 8px 12px; font-size: 12px; }
|
||||
.btn-icon { background: none; border: none; cursor: pointer; padding: 8px; color: #86868b; transition: all 0.2s; font-size: 16px; }
|
||||
.btn-icon:hover { color: #1d1d1f; }
|
||||
|
||||
/* LAYOUT PRINCIPAL */
|
||||
.editor-screen { display: none; height: 100vh; flex-direction: column; background: #f5f5f7; }
|
||||
.editor-screen.active { display: flex; }
|
||||
|
||||
/* TOP NAVBAR */
|
||||
.navbar { background: white; border-bottom: 1px solid #e5e5e7; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
|
||||
.navbar-brand { font-size: 16px; font-weight: 700; color: #fa7921; }
|
||||
.navbar-info { display: flex; align-items: center; gap: 20px; }
|
||||
.nav-item { font-size: 12px; color: #86868b; }
|
||||
.nav-item strong { color: #1d1d1f; }
|
||||
.nav-actions { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* EDITOR CONTAINER */
|
||||
.editor-container { display: flex; flex: 1; overflow: hidden; gap: 0; }
|
||||
|
||||
/* SIDEBAR */
|
||||
.sidebar { width: 260px; background: white; border-right: 1px solid #e5e5e7; overflow-y: auto; padding: 16px 0; }
|
||||
.sidebar-section { margin-bottom: 0; border-bottom: 1px solid #e5e5e7; }
|
||||
.sidebar-title { padding: 10px 16px; font-weight: 600; font-size: 10px; text-transform: uppercase; color: #a1a1a6; letter-spacing: 0.7px; }
|
||||
.block-item { padding: 10px 16px; cursor: grab; user-select: none; display: flex; align-items: center; gap: 10px; color: #1d1d1f; font-size: 12px; transition: all 0.2s; border-left: 3px solid transparent; }
|
||||
.block-item:hover { background: #f5f5f7; color: #fa7921; border-left-color: #fa7921; }
|
||||
.block-item i { color: #a1a1a6; font-size: 13px; width: 14px; }
|
||||
.block-counter { padding: 12px 16px; background: #fef5ed; border: 1px solid #fac9a6; border-radius: 8px; margin: 12px 12px 0 12px; font-size: 12px; color: #1d1d1f; }
|
||||
.block-counter strong { color: #fa7921; }
|
||||
|
||||
/* CANVAS AREA */
|
||||
.canvas-panel { flex: 1; display: flex; flex-direction: column; background: #f5f5f7; }
|
||||
.canvas-header { background: white; border-bottom: 1px solid #e5e5e7; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.canvas-header h3 { font-size: 12px; color: #86868b; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.canvas-tools { display: flex; gap: 6px; }
|
||||
.canvas-main { flex: 1; overflow-y: auto; padding: 24px; background: #f5f5f7; }
|
||||
.canvas-wrapper { max-width: 100%; background: white; border-radius: 16px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); padding: 40px; min-height: 600px; }
|
||||
|
||||
/* BLOQUES */
|
||||
.blocks-container { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.block-element { padding: 18px; background: #f5f5f7; border: 1px solid #e5e5e7; border-radius: 10px; position: relative; transition: all 0.2s; cursor: grab; }
|
||||
.block-element:hover { border-color: #fa7921; background: #fef5ed; box-shadow: 0 2px 8px rgba(250, 121, 33, 0.1); }
|
||||
.block-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.block-label { font-weight: 600; color: #1d1d1f; font-size: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.block-label i { color: #fa7921; font-size: 11px; }
|
||||
.block-controls { display: flex; gap: 3px; }
|
||||
.block-ctrl { background: white; border: 1px solid #e5e5e7; width: 28px; height: 28px; border-radius: 5px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #86868b; font-size: 11px; transition: all 0.2s; }
|
||||
.block-ctrl:hover { border-color: #fa7921; color: #fa7921; background: #fef5ed; }
|
||||
.resize-handle { position: absolute; width: 14px; height: 14px; background: white; border: 2px solid #fa7921; border-radius: 2px; cursor: se-resize; opacity: 0; right: -6px; bottom: -6px; transition: opacity 0.2s; z-index: 10; }
|
||||
.block-element:hover .resize-handle { opacity: 1; }
|
||||
.block-content { font-size: 13px; line-height: 1.5; color: #424245; }
|
||||
.canvas-empty { text-align: center; padding: 60px 40px; color: #a1a1a6; }
|
||||
.canvas-empty i { font-size: 48px; color: #d2d2d7; margin-bottom: 16px; }
|
||||
.canvas-empty p { font-size: 14px; }
|
||||
|
||||
/* PREVIEW MODAL */
|
||||
.preview-modal { display: none; position: fixed; z-index: 2000; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); align-items: center; justify-content: center; }
|
||||
.preview-modal.active { display: flex; }
|
||||
.preview-container { background: white; border-radius: 20px; width: 96%; max-width: 1100px; height: 92vh; box-shadow: 0 20px 60px rgba(0,0,0,0.3); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.preview-header { background: #f5f5f7; border-bottom: 1px solid #e5e5e7; padding: 14px 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.preview-header h2 { font-size: 15px; font-weight: 600; color: #1d1d1f; }
|
||||
.preview-close { background: white; border: 1px solid #d2d2d7; width: 30px; height: 30px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: #86868b; transition: all 0.2s; font-size: 14px; }
|
||||
.preview-close:hover { background: #f5f5f7; color: #1d1d1f; }
|
||||
.preview-content { flex: 1; overflow-y: auto; padding: 36px 30px; background: white; }
|
||||
.preview-page { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* EDITOR MODAL */
|
||||
.modal-overlay { display: none; position: fixed; z-index: 1000; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); align-items: center; justify-content: center; }
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-dialog { background: white; border-radius: 16px; width: 92%; max-width: 520px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); display: flex; flex-direction: column; max-height: 88vh; }
|
||||
.modal-header { background: #f5f5f7; border-bottom: 1px solid #e5e5e7; padding: 20px; display: flex; justify-content: space-between; align-items: center; border-radius: 16px 16px 0 0; }
|
||||
.modal-header h2 { font-size: 16px; color: #1d1d1f; font-weight: 700; }
|
||||
.modal-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
.modal-footer { background: #f5f5f7; border-top: 1px solid #e5e5e7; padding: 14px 20px; display: flex; gap: 10px; justify-content: flex-end; border-radius: 0 0 16px 16px; }
|
||||
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
|
||||
.field-row.full { grid-template-columns: 1fr; }
|
||||
.color-picker { width: 100%; height: 36px; border: 1px solid #d2d2d7; border-radius: 8px; cursor: pointer; }
|
||||
input, select { font-size: 13px; }
|
||||
|
||||
/* SCROLL */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d2d2d7; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a1a1a6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- AUTENTICACIÓN -->
|
||||
<div class="auth-screen" id="authScreen">
|
||||
<div class="auth-left">
|
||||
<h1> PageBuilder</h1>
|
||||
<p>Crea experiencias digitales increíbles sin código</p>
|
||||
<ul class="auth-features">
|
||||
<li> Diseño profesional & minimalista</li>
|
||||
<li> Bloques redimensionables</li>
|
||||
<li> Layouts en múltiples columnas</li>
|
||||
<li> Vista previa en tiempo real</li>
|
||||
<li> Integración de redes sociales</li>
|
||||
<li> Mapas y geolocalización</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="auth-right">
|
||||
<div class="auth-box">
|
||||
<h2>Comenzar</h2>
|
||||
<p>Elige tu rubro y plan</p>
|
||||
<form id="authForm">
|
||||
<div class="form-group">
|
||||
<label>Nombre Completo</label>
|
||||
<input type="text" id="userName" required placeholder="Tu nombre">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" id="userEmail" required placeholder="tu@email.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nombre del Negocio</label>
|
||||
<input type="text" id="businessName" required placeholder="Mi Empresa">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rubro/Industria</label>
|
||||
<select id="rubicSelect" required>
|
||||
<option value="">Selecciona tu rubro</option>
|
||||
<option value="ecommerce">E-commerce</option>
|
||||
<option value="servicios">Servicios</option>
|
||||
<option value="agencia">Agencia Digital</option>
|
||||
<option value="consulting">Consultoría</option>
|
||||
<option value="educacion">Educación</option>
|
||||
<option value="tecnologia">Tecnología</option>
|
||||
<option value="otro">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Plan</label>
|
||||
<div class="plan-options">
|
||||
<div class="plan-option" data-plan="starter">
|
||||
<strong>Starter</strong>
|
||||
<small>10 bloques</small>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="pro">
|
||||
<strong>Pro</strong>
|
||||
<small>50 bloques</small>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="premium">
|
||||
<strong>Premium</strong>
|
||||
<small>Ilimitado</small>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="planSelect" name="planSelect" value="">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Crear Mi Página</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDITOR -->
|
||||
<div class="editor-screen" id="editorScreen">
|
||||
<div class="navbar">
|
||||
<div class="navbar-brand"> PageBuilder</div>
|
||||
<div class="navbar-info">
|
||||
<div class="nav-item">Página: <strong id="topPageName"></strong></div>
|
||||
<div class="nav-item">Rubro: <strong id="topRubric"></strong></div>
|
||||
<div class="nav-item" id="userNav"></div>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<button class="btn btn-secondary btn-sm" onclick="openPreview()"> Vista Previa</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="savePage()"> Guardar</button>
|
||||
<button class="btn-icon" onclick="logout()" title="Salir"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- SIDEBAR -->
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title"> Contenido</div>
|
||||
<div class="block-item" draggable="true" data-type="heading"><i class="fas fa-heading"></i> Encabezado</div>
|
||||
<div class="block-item" draggable="true" data-type="paragraph"><i class="fas fa-align-left"></i> Párrafo</div>
|
||||
<div class="block-item" draggable="true" data-type="title"><i class="fas fa-star"></i> Título</div>
|
||||
<div class="block-item" draggable="true" data-type="list"><i class="fas fa-list"></i> Lista</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title"> Media</div>
|
||||
<div class="block-item" draggable="true" data-type="image"><i class="fas fa-image"></i> Imagen</div>
|
||||
<div class="block-item" draggable="true" data-type="gallery"><i class="fas fa-images"></i> Galería</div>
|
||||
<div class="block-item" draggable="true" data-type="video"><i class="fas fa-video"></i> Video</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title"> Interactivo</div>
|
||||
<div class="block-item" draggable="true" data-type="button"><i class="fas fa-hand-pointer"></i> Botón</div>
|
||||
<div class="block-item" draggable="true" data-type="form"><i class="fas fa-wpforms"></i> Formulario</div>
|
||||
<div class="block-item" draggable="true" data-type="map"><i class="fas fa-map"></i> Mapa</div>
|
||||
<div class="block-item" draggable="true" data-type="social"><i class="fas fa-share"></i> Sociales</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title"> Diseño</div>
|
||||
<div class="block-item" draggable="true" data-type="separator"><i class="fas fa-minus"></i> Separador</div>
|
||||
<div class="block-item" draggable="true" data-type="spacer"><i class="fas fa-square"></i> Espaciado</div>
|
||||
</div>
|
||||
|
||||
<div class="block-counter">
|
||||
<strong id="blockCount">0</strong> / <strong id="blockLimit">50</strong> bloques
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANVAS -->
|
||||
<div class="canvas-panel">
|
||||
<div class="canvas-header">
|
||||
<h3>Lienzo de Diseño</h3>
|
||||
<div class="canvas-tools">
|
||||
<button class="btn btn-secondary btn-sm"> Deshacer</button>
|
||||
<button class="btn btn-secondary btn-sm"> Rehacer</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="setBackground()"> Fondo</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-main">
|
||||
<div class="canvas-wrapper">
|
||||
<div id="canvasContent" class="blocks-container">
|
||||
<div class="canvas-empty">
|
||||
<i class="fas fa-arrow-pointer"></i>
|
||||
<p>Arrastra bloques desde el panel para empezar</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PREVIEW MODAL -->
|
||||
<div class="preview-modal" id="previewModal">
|
||||
<div class="preview-container">
|
||||
<div class="preview-header">
|
||||
<h2>Vista Previa - <strong id="previewTitle"></strong></h2>
|
||||
<button class="preview-close" onclick="closePreview()"></button>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<div class="preview-page" id="previewContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EDITOR MODAL -->
|
||||
<div class="modal-overlay" id="editorModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">Editar Bloque</h2>
|
||||
<button class="btn-icon" onclick="closeModal()"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancelar</button>
|
||||
<button class="btn btn-primary" onclick="saveBlockChanges()">Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let state = {
|
||||
user: null,
|
||||
page: null,
|
||||
blocks: [],
|
||||
editingIdx: null,
|
||||
draggedType: null,
|
||||
planLimits: { starter: 10, pro: 50, premium: 9999 }
|
||||
};
|
||||
|
||||
// AUTENTICACIÓN
|
||||
let planSelect = null;
|
||||
document.querySelectorAll('.plan-option').forEach(opt => {
|
||||
opt.addEventListener('click', function() {
|
||||
document.querySelectorAll('.plan-option').forEach(o => o.classList.remove('selected'));
|
||||
this.classList.add('selected');
|
||||
planSelect = this.dataset.plan; // store selected plan here instead of a missing DOM element
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('authForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const selectedPlan = document.querySelector('.plan-option.selected');
|
||||
if (!selectedPlan) {
|
||||
alert('Selecciona un plan');
|
||||
return;
|
||||
}
|
||||
state.user = {
|
||||
name: document.getElementById('userName').value,
|
||||
email: document.getElementById('userEmail').value,
|
||||
business: document.getElementById('businessName').value,
|
||||
rubric: document.getElementById('rubicSelect').value,
|
||||
plan: selectedPlan.dataset.plan
|
||||
};
|
||||
state.page = { name: state.user.business };
|
||||
state.blocks = [];
|
||||
showEditor();
|
||||
});
|
||||
|
||||
function showEditor() {
|
||||
document.getElementById('authScreen').classList.add('hidden');
|
||||
document.getElementById('editorScreen').classList.add('active');
|
||||
document.getElementById('topPageName').textContent = state.page.name;
|
||||
document.getElementById('topRubric').textContent = state.user.rubric;
|
||||
document.getElementById('userNav').innerHTML = `<strong>${state.user.name}</strong> ${state.user.plan.toUpperCase()}`;
|
||||
updateBlockCounter();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
document.getElementById('authScreen').classList.remove('hidden');
|
||||
document.getElementById('editorScreen').classList.remove('active');
|
||||
state = { user: null, page: null, blocks: [], editingIdx: null, draggedType: null, planLimits: { starter: 10, pro: 50, premium: 9999 } };
|
||||
document.querySelectorAll('.plan-option').forEach(o => o.classList.remove('selected'));
|
||||
document.getElementById('authForm').reset();
|
||||
}
|
||||
|
||||
// DRAG & DROP
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
if (e.target.classList.contains('block-item')) {
|
||||
state.draggedType = e.target.dataset.type;
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('canvasContent').addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.getElementById('canvasContent').addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (!state.draggedType) return;
|
||||
|
||||
const limit = state.planLimits[state.user.plan];
|
||||
if (state.blocks.length >= limit) {
|
||||
alert(`Límite: ${limit} bloques para plan ${state.user.plan}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const block = {
|
||||
id: Math.random(),
|
||||
type: state.draggedType,
|
||||
data: getDefaultData(state.draggedType),
|
||||
colSpan: 1
|
||||
};
|
||||
|
||||
state.blocks.push(block);
|
||||
renderBlocks();
|
||||
updateBlockCounter();
|
||||
state.draggedType = null;
|
||||
});
|
||||
|
||||
function getDefaultData(type) {
|
||||
const defaults = {
|
||||
heading: { text: 'Encabezado', color: '#1d1d1f' },
|
||||
paragraph: { text: 'Tu texto aquí...' },
|
||||
title: { text: 'Título Grande', color: '#fa7921' },
|
||||
image: { url: 'https://via.placeholder.com/500x300?text=Imagen', alt: 'Imagen' },
|
||||
gallery: { images: ['https://via.placeholder.com/300x300', 'https://via.placeholder.com/300x300', 'https://via.placeholder.com/300x300'], columns: 3 },
|
||||
video: { url: '', provider: 'youtube' },
|
||||
button: { text: 'Haz Clic', url: '#', bg: '#fa7921', color: 'white' },
|
||||
form: { fields: [{name: 'Nombre', type: 'text'}, {name: 'Email', type: 'email'}], submitText: 'Enviar' },
|
||||
map: { address: 'Madrid, España', lat: 40.4168, lng: -3.7038 },
|
||||
social: { facebook: '', instagram: '', linkedin: '', twitter: '', youtube: '' },
|
||||
separator: { color: '#e5e5e7', height: 1 },
|
||||
spacer: { height: 30 },
|
||||
list: { items: ['Elemento 1', 'Elemento 2', 'Elemento 3'] }
|
||||
};
|
||||
return defaults[type] || {};
|
||||
}
|
||||
|
||||
function renderBlocks() {
|
||||
const canvas = document.getElementById('canvasContent');
|
||||
|
||||
if (state.blocks.length === 0) {
|
||||
canvas.innerHTML = '<div class="canvas-empty" style="grid-column: 1/-1"><i class="fas fa-arrow-pointer"></i><p>Arrastra bloques desde el panel para empezar</p></div>';
|
||||
return;
|
||||
}
|
||||
canvas.innerHTML = state.blocks.map((b, i) => `
|
||||
<div class="block-element" data-id="${b.id}" style="grid-column: span ${b.colSpan}">
|
||||
<div class="block-head">
|
||||
<div class="block-label">${getName(b.type)}</div>
|
||||
<div class="block-controls">
|
||||
<button class="block-ctrl" onclick="editBlock(${i})" title="Editar"></button>
|
||||
<button class="block-ctrl" onclick="deleteBlock(${i})" title="Eliminar"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-content">${getPreview(b)}</div>
|
||||
<div class="resize-handle" onmousedown="startResize(event, ${i})"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getName(type) {
|
||||
const names = {
|
||||
heading: 'Encabezado', paragraph: 'Párrafo', title: 'Título', image: 'Imagen', gallery: 'Galería',
|
||||
video: 'Video', button: 'Botón', form: 'Formulario', map: 'Mapa', social: 'Sociales',
|
||||
separator: 'Separador', spacer: 'Espaciado', list: 'Lista'
|
||||
};
|
||||
return names[type] || type;
|
||||
}
|
||||
|
||||
function getPreview(b) {
|
||||
switch(b.type) {
|
||||
case 'heading': return `<h3 style="color:${b.data.color};margin:0;font-size:16px">${b.data.text}</h3>`;
|
||||
case 'paragraph': return b.data.text.substring(0, 60) + '...';
|
||||
case 'title': return `<h2 style="color:${b.data.color};margin:0;font-size:24px">${b.data.text}</h2>`;
|
||||
case 'image': return `<img src="${b.data.url}" style="width:100%;height:180px;object-fit:cover;border-radius:8px">`;
|
||||
case 'gallery': return `<div style="display:grid;grid-template-columns:repeat(${b.data.columns},1fr);gap:8px">${b.data.images.map(img=>`<img src="${img}" style="width:100%;height:120px;object-fit:cover;border-radius:6px">`).join('')}</div>`;
|
||||
case 'button': return `<button style="background:${b.data.bg};color:${b.data.color};padding:10px 20px;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:12px">${b.data.text}</button>`;
|
||||
case 'form': return `<form style="display:flex;flex-direction:column;gap:8px">${b.data.fields.map(f=>`<input type="${f.type}" placeholder="${f.name}" style="padding:8px;border:1px solid #d2d2d7;border-radius:6px;font-size:12px">`).join('')}<button style="background:#fa7921;color:white;padding:8px;border:none;border-radius:6px;cursor:pointer;font-size:12px">${b.data.submitText}</button></form>`;
|
||||
case 'map': return `<div style="background:#f5f5f7;padding:16px;border-radius:8px;text-align:center;font-size:12px"><i class="fas fa-map-pin" style="color:#fa7921"></i><p>${b.data.address}</p></div>`;
|
||||
case 'social': return `<div style="display:flex;gap:8px;justify-content:center">${Object.entries(b.data).map(([k,v])=>v?`<a href="${v}" target="_blank" style="font-size:14px;color:#fa7921"><i class="fab fa-${k}"></i></a>`:'').join('')}</div>`;
|
||||
case 'separator': return `<hr style="border:none;border-top:${b.data.height}px solid ${b.data.color};margin:0">`;
|
||||
case 'spacer': return `<div style="height:${b.data.height}px"></div>`;
|
||||
case 'list': return `<ul style="margin:0;padding-left:16px;font-size:12px">${b.data.items.map(i=>`<li>${i}</li>`).join('')}</ul>`;
|
||||
default: return `<em>${getName(b.type)}</em>`;
|
||||
}
|
||||
}
|
||||
|
||||
function editBlock(idx) {
|
||||
state.editingIdx = idx;
|
||||
const b = state.blocks[idx];
|
||||
document.getElementById('modalTitle').textContent = `Editar ${getName(b.type)}`;
|
||||
|
||||
let form = '';
|
||||
switch(b.type) {
|
||||
case 'heading':
|
||||
form = `<div class="field-row full"><label>Texto</label><input type="text" id="editText" value="${b.data.text}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div><div class="field-row full"><label>Color</label><input type="color" id="editColor" value="${b.data.color}" class="color-picker"></div>`;
|
||||
break;
|
||||
case 'button':
|
||||
form = `<div class="field-row full"><label>Texto</label><input type="text" id="editText" value="${b.data.text}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div><div class="field-row full"><label>URL</label><input type="url" id="editUrl" value="${b.data.url}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div><div class="field-row"><div><label>Color Fondo</label><input type="color" id="editBg" value="${b.data.bg}" class="color-picker"></div><div><label>Color Texto</label><input type="color" id="editTextColor" value="${b.data.color}" class="color-picker"></div></div>`;
|
||||
break;
|
||||
case 'social':
|
||||
form = `<div style="display:flex;flex-direction:column;gap:12px"><div><label>Facebook</label><input type="url" id="editFb" value="${b.data.facebook}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px;width:100%"></div><div><label>Instagram</label><input type="url" id="editIg" value="${b.data.instagram}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px;width:100%"></div><div><label>LinkedIn</label><input type="url" id="editLi" value="${b.data.linkedin}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px;width:100%"></div><div><label>Twitter</label><input type="url" id="editTw" value="${b.data.twitter}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px;width:100%"></div></div>`;
|
||||
break;
|
||||
case 'map':
|
||||
form = `<div class="field-row full"><label>Dirección</label><input type="text" id="editAddress" value="${b.data.address}" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div><div class="field-row"><div><label>Latitud</label><input type="number" id="editLat" value="${b.data.lat}" step="0.0001" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div><div><label>Longitud</label><input type="number" id="editLng" value="${b.data.lng}" step="0.0001" style="padding:10px;border:1px solid #d2d2d7;border-radius:8px"></div></div>`;
|
||||
break;
|
||||
default: form = '<p>Sin opciones</p>';
|
||||
}
|
||||
|
||||
document.getElementById('modalBody').innerHTML = form;
|
||||
document.getElementById('editorModal').classList.add('active');
|
||||
}
|
||||
|
||||
function saveBlockChanges() {
|
||||
const b = state.blocks[state.editingIdx];
|
||||
switch(b.type) {
|
||||
case 'heading':
|
||||
b.data.text = document.getElementById('editText').value;
|
||||
b.data.color = document.getElementById('editColor').value;
|
||||
break;
|
||||
case 'button':
|
||||
b.data.text = document.getElementById('editText').value;
|
||||
b.data.url = document.getElementById('editUrl').value;
|
||||
b.data.bg = document.getElementById('editBg').value;
|
||||
b.data.color = document.getElementById('editTextColor').value;
|
||||
break;
|
||||
case 'social':
|
||||
b.data.facebook = document.getElementById('editFb').value;
|
||||
b.data.instagram = document.getElementById('editIg').value;
|
||||
b.data.linkedin = document.getElementById('editLi').value;
|
||||
b.data.twitter = document.getElementById('editTw').value;
|
||||
break;
|
||||
case 'map':
|
||||
b.data.address = document.getElementById('editAddress').value;
|
||||
b.data.lat = parseFloat(document.getElementById('editLat').value);
|
||||
b.data.lng = parseFloat(document.getElementById('editLng').value);
|
||||
break;
|
||||
}
|
||||
closeModal();
|
||||
renderBlocks();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('editorModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function deleteBlock(idx) {
|
||||
state.blocks.splice(idx, 1);
|
||||
renderBlocks();
|
||||
updateBlockCounter();
|
||||
}
|
||||
|
||||
function updateBlockCounter() {
|
||||
document.getElementById('blockCount').textContent = state.blocks.length;
|
||||
const userPlan = state.user && state.user.plan ? state.user.plan : null;
|
||||
document.getElementById('blockLimit').textContent = userPlan ? state.planLimits[userPlan] : '—';
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
const preview = document.getElementById('previewContent');
|
||||
preview.innerHTML = state.blocks.map(b => `<div style="margin-bottom:24px">${getPreview(b)}</div>`).join('');
|
||||
document.getElementById('previewTitle').textContent = state.page.name;
|
||||
document.getElementById('previewModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
document.getElementById('previewModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function savePage() {
|
||||
localStorage.setItem(`page_${state.user.email}`, JSON.stringify({user:state.user, page:state.page, blocks:state.blocks}));
|
||||
alert(` Página guardada (${state.blocks.length} bloques)`);
|
||||
}
|
||||
|
||||
// Allow premium users to set an image or video background for the canvas wrapper
|
||||
function setBackground() {
|
||||
if (!state.user || state.user.plan !== 'premium') {
|
||||
alert('Fondo disponible solo para plan Premium');
|
||||
return;
|
||||
}
|
||||
const type = prompt('Tipo de fondo: escribe "image" o "video" (sin comillas)', 'image');
|
||||
if (!type) return;
|
||||
const url = prompt('Pega la URL del recurso (imagen o vídeo)');
|
||||
if (!url) return;
|
||||
const wrapper = document.querySelector('.canvas-wrapper');
|
||||
// remove existing bg media if any
|
||||
const existing = document.getElementById('bgMedia');
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (type.toLowerCase() === 'video') {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'bgMedia';
|
||||
div.style.position = 'absolute';
|
||||
div.style.inset = '0';
|
||||
div.style.zIndex = '0';
|
||||
div.style.overflow = 'hidden';
|
||||
div.innerHTML = `<video src="${url}" autoplay muted loop playsinline style="width:100%;height:100%;object-fit:cover;opacity:0.85"></video>`;
|
||||
wrapper.style.position = 'relative';
|
||||
wrapper.prepend(div);
|
||||
// ensure content sits above
|
||||
wrapper.querySelectorAll('*:not(#bgMedia)').forEach(el => { if (el !== div) el.style.position = 'relative'; });
|
||||
} else {
|
||||
wrapper.style.backgroundImage = `url('${url}')`;
|
||||
wrapper.style.backgroundSize = 'cover';
|
||||
wrapper.style.backgroundPosition = 'center';
|
||||
}
|
||||
}
|
||||
|
||||
function startResize(e, idx) {
|
||||
const block = document.querySelector(`[data-id="${state.blocks[idx].id}"]`);
|
||||
if (!block) return;
|
||||
const startX = e.clientX;
|
||||
const startSpan = state.blocks[idx].colSpan || 1;
|
||||
const minSpan = 1;
|
||||
const maxSpan = 4;
|
||||
|
||||
function onMouseMove(ev) {
|
||||
const delta = ev.clientX - startX;
|
||||
// one column step per ~260px (matches min width)
|
||||
const change = Math.round(delta / 260);
|
||||
let newSpan = Math.min(maxSpan, Math.max(minSpan, startSpan + change));
|
||||
block.style.gridColumn = `span ${newSpan}`;
|
||||
}
|
||||
|
||||
function onMouseUp(ev) {
|
||||
const delta = ev.clientX - startX;
|
||||
const change = Math.round(delta / 260);
|
||||
let finalSpan = Math.min(maxSpan, Math.max(minSpan, startSpan + change));
|
||||
state.blocks[idx].colSpan = finalSpan;
|
||||
renderBlocks(); // re-render to persist layout and controls
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user