Compare commits
7 Commits
7dddbc4764
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301d4b06a5 | ||
|
|
c7dd1265e2 | ||
|
|
29de9db16b | ||
|
|
1783bfd3af | ||
|
|
9a1bbb29fa | ||
|
|
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: [linux, arm64] # Usaremos tu Raspberry Pi como runner auto-alojado
|
||||
|
||||
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
|
||||
port: ${{ secrets.SSH_PORT }} # Puerto SSH, que es 2222
|
||||
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."
|
||||
176
GEMINI.md
Normal file
176
GEMINI.md
Normal file
@@ -0,0 +1,176 @@
|
||||
## Gemini Added Memories
|
||||
- El flujo principal del proyecto GKACHELE™ es:
|
||||
1. **Visita a la Landing Page (`/`):** Un visitante llega.
|
||||
2. **Solicitud de Plan:** El cliente selecciona un plan, lo que abre un menú desplegable con un formulario. Lo rellena.
|
||||
3. **Redirección al Registro (`/register`):** Al enviar el formulario, es redirigido a `/register` para completar su registro.
|
||||
4. **Creación del Sitio en Borrador (`/customizer`):** El cliente diseña su página en modo borrador.
|
||||
5. **Envío de Solicitud:** El cliente envía su sitio para aprobación.
|
||||
6. **Tu Dashboard de Administrador (`/dashboard`):** La solicitud aparece en tu panel de control.
|
||||
7. **Aprobación o Rechazo Manual:** Tú revisas la solicitud y decides si la apruebas o la rechazas.
|
||||
8. **Panel de Cliente (`/admin`):** Si apruebas, el sitio se publica y se crea el panel de gestión para el cliente.
|
||||
9. **Visualización Pública:** El sitio aprobado se hace visible al público.
|
||||
- El proyecto del usuario está desplegado en una Raspberry Pi (IP local 192.168.1.134) como un servicio systemd (`gkachele-saas.service`). La actualización de código se realiza desde su PC (WSL) copiando archivos vía scp y reiniciando el servicio en la Raspberry Pi.
|
||||
- Para levantar el entorno de prueba del proyecto en el directorio 'demo/', los pasos son: 1. Navegar a `cd demo`. 2. Instalar dependencias con `pip install -r requirements.txt`. 3. Ejecutar la aplicación con `python3 app.py`. La aplicación correrá localmente, generalmente en `http://127.0.0.1:5000`.
|
||||
- El proyecto se llama GKACHELE™ y es un SaaS (Software as a Service) para crear sitios web, al estilo de un WordPress auto-alojado.
|
||||
- El backend del proyecto GKACHELE™ es una aplicación monolítica en Python/Flask, con el código principal ubicado en el directorio `demo/`.
|
||||
- El proyecto GKACHELE™ utiliza un motor de plantillas propio (`demo/utils/theme_engine.py`) que imita la funcionalidad de WordPress y actualmente usa una base de datos SQLite (`demo/database/main.db`).
|
||||
- El despliegue actual del proyecto GKACHELE™ es un proceso manual en una Raspberry Pi, utilizando scripts `scp` y gestionando el servicio con `systemd`. El control de versiones se realiza con Gitea.
|
||||
- El objetivo principal con el proyecto GKACHELE™ es modernizarlo de forma incremental. El plan es:
|
||||
1. **Contenerizar la aplicación con Docker.** (En progreso - Dockerfile creado).
|
||||
2. **Automatizar los despliegues (CI/CD) usando Gitea Actions.** (LISTO - Runner configurado y funcionando).
|
||||
3. **Configurar Secretos de Gitea.** (Pendiente por el usuario: Docker Hub y SSH keys).
|
||||
4. **Migrar la base de datos a PostgreSQL.** (Pendiente).
|
||||
5. **Mejorar el Customizer UI/UX.** (Próximo objetivo: Adaptar `saas-demo.html`).
|
||||
6. **Migración final a VPS.** (Fácil después de validar Docker en la Pi. Considerar Starlink en Argentina: posible necesidad de VPN/Cloudflare Tunnel si no hay CGNAT compatible).
|
||||
- El archivo `.gitea/workflows/deploy.yml` define el workflow de CI/CD para la app GKACHELE™. Se activa con `push` a la rama `feature/docker-setup` o manualmente. Utiliza un runner `ubuntu-latest` (configurable a `self-hosted` en la Pi).
|
||||
- El workflow de despliegue requiere configurar Gitea Secrets: `SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`, `DOCKER_USERNAME`, `DOCKER_PASSWORD`. Estos deben ser configurados por el usuario en la UI de Gitea.
|
||||
- Los pasos del workflow de Gitea Actions incluyen: checkout de código, login a Docker Hub (o Gitea registry), build y push de la imagen Docker, y despliegue en Raspberry Pi vía SSH (docker login, pull, stop/rm, run).
|
||||
- El usuario debe commitear y hacer push del archivo `deploy.yml` a Gitea, y luego configurar los secretos necesarios en la interfaz de Gitea.
|
||||
- Un paso futuro clave es la creación de un archivo `docker-compose.yml` específico para la Raspberry Pi para gestionar el despliegue de la aplicación Flask y PostgreSQL de forma más robusta que el actual `docker run` manual.
|
||||
- El usuario de Gitea del proyecto es 'admin'. El usuario ha priorizado el funcionamiento sobre la seguridad del usuario 'admin' en este momento, con la intención de revisar la seguridad más adelante.
|
||||
- La Raspberry Pi del usuario usa el hostname `komkida.duckdns.org` para acceso SSH. La aplicación y Gitea usan `gk-saas.komkida.duckdns.org`. La URL para configurar secretos en Gitea es `http://git.gk-saas.komkida.duckdns.org/user/settings/actions/secrets`.
|
||||
- La Raspberry Pi del usuario utiliza el puerto SSH 2222. Esto debe reflejarse en la configuración de los secretos de Gitea (SSH_PORT) y en el workflow de Gitea Actions.
|
||||
- Estamos bloqueados porque Gitea Actions no está habilitado globalmente en la instancia de Gitea del usuario en la Raspberry Pi. Para habilitarlo, necesitamos editar el archivo `app.ini` en la Pi. La tarea pendiente es que el usuario me proporcione el contenido de su `app.ini` para que yo pueda analizarlo proactivamente y guiarlo en la edición. El usuario me ha pedido crear un script de diagnóstico para obtener esta información de la Pi.
|
||||
- El usuario se ha ausentado. La última acción solicitada (crear el script de diagnóstico `diagnose_gitea.sh`) fue cancelada por el usuario. El problema principal sigue siendo habilitar Gitea Actions en la instancia de Gitea de la Raspberry Pi editando el archivo `app.ini`. Se está a la espera de que el usuario regrese para retomar la tarea.
|
||||
- Contenido del archivo app.ini de Gitea en Raspberry Pi:
|
||||
--- Contenido de /home/pi/gkachele-saas/gitea/gitea_data/gitea/conf/app.ini ---
|
||||
APP_NAME = Gitea: Git with a cup of tea
|
||||
RUN_MODE = prod
|
||||
RUN_USER = git
|
||||
WORK_PATH = /data/gitea
|
||||
|
||||
[repository]
|
||||
ROOT = /data/git/repositories
|
||||
|
||||
[repository.local]
|
||||
LOCAL_COPY_PATH = /data/gitea/tmp/local-repo
|
||||
|
||||
[repository.upload]
|
||||
TEMP_PATH = /data/gitea/uploads
|
||||
|
||||
[server]
|
||||
APP_DATA_PATH = /data/gitea
|
||||
DOMAIN = git.gk-saas.komkida.duckdns.org
|
||||
SSH_DOMAIN = git.gk-saas.komkida.duckdns.org
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = http://git.gk-saas.komkida.duckdns.org/
|
||||
DISABLE_SSH = false
|
||||
SSH_PORT = 22
|
||||
SSH_LISTEN_PORT = 22
|
||||
LFS_START_SERVER = true
|
||||
LFS_JWT_SECRET = e26UslhmO029LfMQbTLpU8Qn_MzP38Fks_snQRBjzF4
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[database]
|
||||
PATH = /data/gitea/gitea.db
|
||||
DB_TYPE = sqlite3
|
||||
HOST = localhost:3306
|
||||
NAME = gitea
|
||||
USER = root
|
||||
PASSWD =
|
||||
LOG_SQL = false
|
||||
SCHEMA =
|
||||
SSL_MODE = disable
|
||||
|
||||
[indexer]
|
||||
ISSUE_INDEXER_PATH = /data/gitea/indexers/issues.bleve
|
||||
|
||||
[session]
|
||||
PROVIDER_CONFIG = /data/gitea/sessions
|
||||
PROVIDER = file
|
||||
|
||||
[picture]
|
||||
AVATAR_UPLOAD_PATH = /data/gitea/avatars
|
||||
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
|
||||
|
||||
[attachment]
|
||||
PATH = /data/gitea/attachments
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = info
|
||||
ROOT_PATH = /data/gitea/log
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY =
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3Njg0OTYxMTB9.wVL_AGGU7rcilnQxINdf89bztzHr6SkFI2E3kUrR-LQ
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = false
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||
ENABLE_CAPTCHA = false
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
DEFAULT_ENABLE_TIMETRACKING = true
|
||||
NO_REPLY_ADDRESS = noreply.git.gk-saas.komkida.duckdns.org
|
||||
|
||||
[lfs]
|
||||
PATH = /data/git/lfs
|
||||
|
||||
[mailer]
|
||||
ENABLED = false
|
||||
|
||||
[openid]
|
||||
ENABLE_OPENID_SIGNIN = true
|
||||
ENABLE_OPENID_SIGNUP = true
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = true
|
||||
|
||||
[repository.pull-request]
|
||||
DEFAULT_MERGE_STYLE = merge
|
||||
|
||||
[repository.signing]
|
||||
DEFAULT_TRUST_MODEL = committer
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = yHgPirRG4OmPueCt6KQqLEFM5L7ERrsIvRvxZnRY29Q
|
||||
|
||||
[actions]
|
||||
ENABLED = true
|
||||
|
||||
- **Gitea Runner Setup (Raspberry Pi):**
|
||||
- **Status:** ACTIVO.
|
||||
- **Nombre:** `rpi-runner-1`.
|
||||
- **Etiquetas:** `linux`, `arm64`, `docker`.
|
||||
- **Acceso:** Registrado con IP interna `http://192.168.1.134:3000` para evitar bucles NAT.
|
||||
- **Configuración de Workflow:** Corregido para usar `runs-on: [linux, arm64]`.
|
||||
- **Contenedor:** `rpi-gitea-runner-1` corriendo en modo daemon con reinicio automático.
|
||||
|
||||
- **Plan de Mudanza (Argentina/Starlink):**
|
||||
- **Contexto:** Starlink usa CGNAT, lo que dificulta abrir puertos tradicionales.
|
||||
- **Solución Temporal:** Continuar desarrollo en local (Raspberry Pi/PC) y usar backups locales.
|
||||
- **Solución Definitiva:** Migrar el despliegue al VPS. El flujo de CI/CD (Gitea Actions) permitirá subir el código a la nube sin depender de la IP de casa.
|
||||
- **Backup:** Realizar hashing y copia de seguridad manual de `/home/pi/gkachele-saas` antes del traslado.
|
||||
- Los workflows de Gitea Actions del usuario se encuentran en estado 'en espera'.
|
||||
- Gitea se está ejecutando dentro de un contenedor Docker en la Raspberry Pi.
|
||||
- Estado actual de la tarea Gitea Actions:
|
||||
- **Objetivo:** Habilitar Gitea Actions que están en estado "en espera".
|
||||
- **Entorno Gitea:** Se ejecuta en un contenedor Docker en una Raspberry Pi.
|
||||
- **Versión de Gitea:** 1.25.3.
|
||||
- **Configuración app.ini:** La sección `[actions]` tiene `ENABLED = true`.
|
||||
- **Problema identificado:** Las Actions están "en espera" porque no hay ningún runner registrado o activo.
|
||||
- **Último paso intentado:** Registrar un runner usando `docker run` con la imagen `gitea/gitea-runner:latest`.
|
||||
- **Error actual:** `pull access denied` porque la imagen `gitea/gitea-runner:latest` no existe o no es la correcta.
|
||||
- **Acción pendiente:** Ejecutar el comando `docker run` corregido, utilizando la imagen correcta `gitea/act_runner:latest` para registrar el runner.
|
||||
- **Detalle del comando pendiente:**
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
--name rpi-gitea-runner-1 \
|
||||
-e GITEA_RUNNER_NAME="rpi-runner-1" \
|
||||
-e GITEA_INSTANCE_URL="http://git.gk-saas.komkida.duckdns.org/" \
|
||||
-e GITEA_RUNNER_TOKEN="c1izQkzx1FpmAnbMYkL8WRB9KKURyJ1vGqSjYDOe" \
|
||||
-e GITEA_RUNNER_LABELS="linux:arm64,docker" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /opt/gitea-runner:/data \
|
||||
gitea/act_runner:latest register
|
||||
```
|
||||
- **Próximo paso:** El usuario debe ejecutar el comando `docker run` corregido.
|
||||
- El usuario está frustrado por mis errores iniciales debidos a la falta de contexto (especialmente sobre Docker). Necesita que el flujo de CI/CD con Gitea Actions funcione para poder migrar el proyecto a un VPS. He prometido ser más preciso y proactivo ahora que tengo todo el contexto. La tarea pendiente es que el usuario ejecute el comando `docker run` con la imagen `gitea/act_runner` para registrar el runner.
|
||||
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`.
|
||||
173
README.md
173
README.md
@@ -1,98 +1,75 @@
|
||||
# WordPress con Docker Compose
|
||||
|
||||
Ejemplo básico de WordPress usando Docker Compose con MySQL y phpMyAdmin.
|
||||
|
||||
## 🚀 Inicio Rápido
|
||||
|
||||
### Requisitos
|
||||
- Docker instalado
|
||||
- Docker Compose instalado
|
||||
|
||||
### Instalación
|
||||
|
||||
1. **Clonar o descargar este proyecto**
|
||||
|
||||
2. **Iniciar los contenedores:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **Acceder a WordPress:**
|
||||
- Abre tu navegador en: http://localhost:8080
|
||||
- Sigue el asistente de instalación de WordPress
|
||||
|
||||
4. **Acceder a phpMyAdmin (opcional):**
|
||||
- Abre tu navegador en: http://localhost:8081
|
||||
- Usuario: `root`
|
||||
- Contraseña: `root_password`
|
||||
|
||||
## 📝 Configuración
|
||||
|
||||
### 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`
|
||||
|
||||
## 🛠️ Comandos Útiles
|
||||
|
||||
### Ver logs
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Detener contenedores
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Detener y eliminar volúmenes (⚠️ borra los datos)
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Reiniciar un servicio específico
|
||||
```bash
|
||||
docker-compose restart wordpress
|
||||
```
|
||||
|
||||
### Ver contenedores en ejecución
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## 📁 Estructura
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml # Configuración de servicios
|
||||
├── wp-content/ # Temas y plugins personalizados (se crea automáticamente)
|
||||
└── README.md # Este archivo
|
||||
```
|
||||
|
||||
## 🔒 Seguridad
|
||||
|
||||
⚠️ **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
|
||||
|
||||
## 🐳 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/)
|
||||
# GKACHELE™ Agent - Configuración del Asistente y Documentación del Proyecto
|
||||
|
||||
Este archivo define las reglas, el contexto y el flujo principal para el asistente de IA (y cualquier desarrollador) trabajando en el ecosistema GKACHELE™.
|
||||
|
||||
## 🎯 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.
|
||||
|
||||
## ⚠️ REGLAS CRÍTICAS (NUNCA ROMPER)
|
||||
|
||||
### 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".
|
||||
|
||||
### 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`.
|
||||
|
||||
## 🛠️ Skills & Infraestructura
|
||||
|
||||
### 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.
|
||||
|
||||
### Infraestructura (Raspberry Pi & Docker)
|
||||
- Despliegue mediante `docker-compose`.
|
||||
- Scripts de sincronización: `sync-to-raspberry.sh`, `update-code-pi.sh`.
|
||||
- Dominios gestionados via DuckDNS.
|
||||
|
||||
## 📝 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.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flujo Principal de la Aplicación GKACHELE™
|
||||
|
||||
Este es el proceso completo, desde un nuevo visitante hasta un sitio web publicado:
|
||||
|
||||
1. **Visita a la Landing Page (`/`):**
|
||||
* Un cliente potencial visita la página principal del servicio.
|
||||
|
||||
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.
|
||||
|
||||
3. **Registro de Cliente (`/register`):**
|
||||
* El cliente finaliza su registro en la plataforma.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
7. **Aprobación o Rechazo Manual:**
|
||||
* Revisas el sitio del cliente. Desde tu dashboard, decides si **apruebas** o **rechazas** la solicitud de publicación.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
**© 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.10-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,11 +53,12 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🔧 Panel Admin</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>Solicitudes Pendientes</h2>
|
||||
<table>
|
||||
<tr>
|
||||
@@ -67,10 +80,12 @@
|
||||
</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>
|
||||
|
||||
|
||||
<h2>👥 Usuarios Registrados</h2>
|
||||
<table>
|
||||
<tr>
|
||||
@@ -104,10 +119,12 @@
|
||||
</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>
|
||||
|
||||
|
||||
<h2>🌐 Todos los Sitios</h2>
|
||||
<table>
|
||||
<tr>
|
||||
@@ -131,48 +148,50 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
<style>
|
||||
.btn-danger {
|
||||
background: #d63638;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #b32d2e;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<script>
|
||||
function approve(requestId) {
|
||||
if (confirm('¿Aprobar este sitio?')) {
|
||||
fetch(`/admin/approve/${requestId}`, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ Sitio aprobado');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
fetch(`/admin/approve/${requestId}`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ Sitio aprobado');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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'})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ Usuario eliminado exitosamente');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ Error: ' + (data.error || 'Error al eliminar'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('❌ Error: ' + err);
|
||||
});
|
||||
fetch(`/admin/users/delete/${userId}`, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ Usuario eliminado exitosamente');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ Error: ' + (data.error || 'Error al eliminar'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('❌ Error: ' + err);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</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