2 Commits

Author SHA1 Message Date
komkida91
647a00d895 feat: Add Gitea Actions deploy workflow
Some checks failed
Deploy GKACHELE App / deploy (push) Has been cancelled
2026-01-31 16:43:49 +01:00
komkida91
59812e547e feat: Add Dockerfile and initial Docker setup files 2026-01-31 16:04:55 +01:00
32 changed files with 7781 additions and 1776 deletions

View 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
View 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
View File

@@ -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.**

View File

@@ -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"]

Binary file not shown.

Binary file not shown.

188
demo/customizer.html Normal file
View 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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); }
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>

View File

@@ -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.

View File

@@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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'] = []

View 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
View 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>

View File

@@ -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;
}
@@ -145,7 +163,7 @@
<script>
function approve(requestId) {
if (confirm('¿Aprobar este sitio?')) {
fetch(`/admin/approve/${requestId}`, {method: 'POST'})
fetch(`/admin/approve/${requestId}`, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
@@ -158,7 +176,7 @@
function deleteUser(userId, email) {
if (confirm(`⚠️ ¿Eliminar usuario ${userId} (${email})?\n\nEsto eliminará TODOS sus datos:\n- Sitios\n- Menús\n- Widgets\n- Media\n- Solicitudes\n\nEsta acción NO se puede deshacer.`)) {
fetch(`/admin/users/delete/${userId}`, {method: 'POST'})
fetch(`/admin/users/delete/${userId}`, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
@@ -175,4 +193,5 @@
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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
}

View File

@@ -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
View 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>