Compare commits
7
Commits
bd4282c426
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cc326e8ac | ||
|
|
6323fe8f0e | ||
|
|
adb2b4dfe4 | ||
|
|
98b19aa3a2 | ||
|
|
27166eb3f4 | ||
|
|
e08d959cf9 | ||
|
|
c06300833b |
@@ -0,0 +1,54 @@
|
|||||||
|
name: deploy-branch
|
||||||
|
|
||||||
|
# CI/CD: cada push a cualquier rama se despliega en <rama>.localesp.es
|
||||||
|
# Estrategia A — un entorno por rama. El registro DNS A es manual (precondición).
|
||||||
|
# Detalles: docs/07-cicd.md
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
delete:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: "Rama a desplegar (vacío = la actual)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: github.event_name != 'delete'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build (npm ci + vite build)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# better-sqlite3 (v12) requiere C++20; el GCC 8 del sistema (AlmaLinux 8) no lo soporta.
|
||||||
|
# gcc-toolset-12 (GCC 12) ya está instalado en /opt/rh.
|
||||||
|
source /opt/rh/gcc-toolset-12/enable 2>/dev/null || echo "WARN: gcc-toolset-12 no encontrado"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Deploy branch
|
||||||
|
env:
|
||||||
|
BRANCH: ${{ github.event.inputs.branch || github.ref_name }}
|
||||||
|
run: bash scripts/deploy.sh "$BRANCH"
|
||||||
|
|
||||||
|
# Al borrar una rama, se limpia su entorno
|
||||||
|
teardown:
|
||||||
|
if: github.event_name == 'delete'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (default branch, trae scripts/)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Teardown branch env
|
||||||
|
env:
|
||||||
|
# github.event.ref en un 'delete' es refs/heads/<rama>
|
||||||
|
BRANCH: ${{ github.event.ref }}
|
||||||
|
run: bash scripts/teardown.sh "${BRANCH#refs/heads/}"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Reportar un error
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
**Qué pasa**
|
||||||
|
Descripción breve.
|
||||||
|
|
||||||
|
**Pasos para reproducir**
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
**Qué esperabas**
|
||||||
|
|
||||||
|
**Entorno**
|
||||||
|
- Navegador:
|
||||||
|
- Sistema operativo:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: Propuesta de mejora
|
||||||
|
about: Sugerir una funcionalidad o mejora
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
**Problema o necesidad**
|
||||||
|
¿Qué te falta? ¿Quién la usaría?
|
||||||
|
|
||||||
|
**Propuesta**
|
||||||
|
Cómo lo resolverías. Mantenlo simple: si la propuesta requiere muchas piezas
|
||||||
|
nuevas, conviene discutirla antes en Discussions.
|
||||||
|
|
||||||
|
**Alternativas que has considerado**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Qué cambia
|
||||||
|
|
||||||
|
<!-- En una o dos frases. -->
|
||||||
|
|
||||||
|
## Por qué
|
||||||
|
|
||||||
|
<!-- Issue relacionado o motivación. -->
|
||||||
|
|
||||||
|
## Notas para el revisor
|
||||||
|
|
||||||
|
- [ ] La app arranca con `npm run dev`.
|
||||||
|
- [ ] No he añadido dependencias innecesarias.
|
||||||
|
- [ ] He seguido la estructura existente.
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# artefactos de despliegue manual / datos locales
|
||||||
|
*.tgz
|
||||||
|
localesp.db
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) [AÑO] [ORGANIZACIÓN]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,2 +1,39 @@
|
|||||||
# LocalESP
|
# Business Map
|
||||||
Registro colaborativo de negocios españoles
|
|
||||||
|
Registro colaborativo de negocios, en un mapa abierto.
|
||||||
|
|
||||||
|
Prototipo open source mantenido por **[ORGANIZACIÓN]**. Cualquiera puede ver
|
||||||
|
los negocios registrados y proponer nuevos; las propuestas son revisadas
|
||||||
|
antes de aparecer públicamente.
|
||||||
|
|
||||||
|
## Demo local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Abre la URL que indique Vite (normalmente `http://localhost:5173`).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- React 18 + Vite
|
||||||
|
- Leaflet + OpenStreetMap (mapa)
|
||||||
|
- CSS plano
|
||||||
|
|
||||||
|
Sin backend en el prototipo: los datos vienen de un mock en memoria. Ver
|
||||||
|
`docs/02-implementation-alternatives.md` para la decisión de backend.
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
- [`docs/01-use-cases.md`](docs/01-use-cases.md) – qué hace y qué no.
|
||||||
|
- [`docs/02-implementation-alternatives.md`](docs/02-implementation-alternatives.md) – decisiones técnicas.
|
||||||
|
- [`docs/03-decisions.md`](docs/03-decisions.md) – ADRs.
|
||||||
|
- [`docs/04-governance.md`](docs/04-governance.md) – cómo se gobierna el proyecto.
|
||||||
|
- [`docs/05-contributing.md`](docs/05-contributing.md) – cómo contribuir.
|
||||||
|
- [`docs/06-github-setup.md`](docs/06-github-setup.md) – configuración inicial del repo.
|
||||||
|
- [`docs/07-cicd.md`](docs/07-cicd.md) – CI/CD: despliegue por rama en `<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
MIT. Ver [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Casos de uso
|
||||||
|
|
||||||
|
Este documento describe los casos de uso del prototipo. El objetivo es mantener
|
||||||
|
el alcance acotado para minimizar el coste de mantenimiento.
|
||||||
|
|
||||||
|
## Actores
|
||||||
|
|
||||||
|
- **Visitante**: cualquier persona que accede a la aplicación. No requiere
|
||||||
|
autenticación. Puede consultar el mapa y contribuir con nuevas entradas.
|
||||||
|
- **Revisor**: persona designada por la organización que aprueba o rechaza las
|
||||||
|
contribuciones. Su flujo está fuera del frontend del prototipo (ver
|
||||||
|
alternativas de implementación).
|
||||||
|
|
||||||
|
## Casos de uso del prototipo
|
||||||
|
|
||||||
|
### CU-01: Explorar negocios en el mapa
|
||||||
|
- El visitante abre la aplicación.
|
||||||
|
- Ve un mapa centrado en una ubicación por defecto con marcadores de los
|
||||||
|
negocios aprobados.
|
||||||
|
- Puede hacer zoom y desplazarse libremente.
|
||||||
|
|
||||||
|
### CU-02: Ver detalles de un negocio
|
||||||
|
- El visitante selecciona un marcador en el mapa.
|
||||||
|
- La barra lateral muestra los detalles del negocio: nombre, categoría,
|
||||||
|
dirección, descripción corta y enlace/contacto si existe.
|
||||||
|
- Al seleccionar otro marcador, la barra lateral se actualiza.
|
||||||
|
|
||||||
|
### CU-03: Contribuir con un nuevo negocio
|
||||||
|
- El visitante abre el formulario de contribución desde la interfaz principal.
|
||||||
|
- Rellena los campos mínimos: nombre, categoría, dirección, descripción y
|
||||||
|
contacto opcional. La ubicación se obtiene geocodificando la dirección o
|
||||||
|
haciendo clic en el mapa.
|
||||||
|
- Envía el formulario. La entrada queda en estado "pendiente de revisión" y no
|
||||||
|
aparece en el mapa hasta ser aprobada.
|
||||||
|
- El visitante recibe confirmación visual del envío.
|
||||||
|
|
||||||
|
## Fuera de alcance del prototipo
|
||||||
|
|
||||||
|
Decisiones tomadas para mantener el proyecto simple (ver
|
||||||
|
`02-implementation-alternatives.md` para el razonamiento):
|
||||||
|
|
||||||
|
- Autenticación de usuarios.
|
||||||
|
- Panel de moderación dentro de la aplicación. La revisión se hace en el
|
||||||
|
backend (por ejemplo, en el dashboard del proveedor de datos elegido).
|
||||||
|
- Edición o eliminación de negocios por el visitante.
|
||||||
|
- Búsqueda avanzada, filtros complejos o categorías jerárquicas.
|
||||||
|
- Comentarios, valoraciones, fotos múltiples o multimedia.
|
||||||
|
- Internacionalización. El prototipo usa un único idioma.
|
||||||
|
|
||||||
|
Estos casos pueden añadirse después si la comunidad lo pide, pero no forman
|
||||||
|
parte del prototipo.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Alternativas de implementación
|
||||||
|
|
||||||
|
Este documento registra las decisiones técnicas tomadas y por qué. El criterio
|
||||||
|
guía es **mínimo coste de mantenimiento** para una organización sin ánimo de
|
||||||
|
lucro, siguiendo KISS y evitando YAGNI.
|
||||||
|
|
||||||
|
## 1. Framework frontend
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **React + Vite** (elegido) | Ecosistema enorme, fácil encontrar contribuidores, build rápido. | Requiere build step. |
|
||||||
|
| Next.js | SSR, rutas, imágenes. | Demasiado para una SPA de una página; aumenta complejidad y coste de hosting. |
|
||||||
|
| Vanilla JS | Sin dependencias. | Más código manual, menos contribuidores familiarizados. |
|
||||||
|
|
||||||
|
**Decisión**: React + Vite. Es el stack más común y los contribuidores
|
||||||
|
potenciales lo conocen. Vite mantiene la configuración mínima.
|
||||||
|
|
||||||
|
## 2. Mapa
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Leaflet + OpenStreetMap** (elegido) | Open source, sin claves API, sin coste, tiles gratuitos. | Menos pulido que Mapbox. |
|
||||||
|
| Mapbox GL | Más bonito, mejor rendimiento. | Requiere API key y tiene cuotas de pago. |
|
||||||
|
| Google Maps | Familiar. | Requiere facturación activada, cuotas estrictas, no encaja con espíritu open source. |
|
||||||
|
|
||||||
|
**Decisión**: Leaflet con tiles de OpenStreetMap. Cero coste, sin claves, y la
|
||||||
|
librería `react-leaflet` lo hace trivial de integrar.
|
||||||
|
|
||||||
|
## 3. Backend y almacenamiento de datos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backend-as-a-Service** (Supabase o similar) elegido | Sin servidor que mantener, base de datos gestionada, panel de moderación incluido en su dashboard. Plan gratuito generoso. | Dependencia de un proveedor. |
|
||||||
|
| Backend propio (Node + Postgres) | Control total. | Hay que mantenerlo, pagarlo, securizarlo. Alto coste para una ONG. |
|
||||||
|
| Archivo JSON en el repositorio | Sin backend, máxima simplicidad. | Las contribuciones tendrían que llegar como Pull Requests, fricción alta para no-técnicos. |
|
||||||
|
|
||||||
|
**Decisión**: BaaS tipo Supabase. Para el prototipo, la capa de datos se
|
||||||
|
abstrae en `src/services/businessRepository.js`, así que cambiar de proveedor
|
||||||
|
después es localizado. El prototipo entregado usa un **mock en memoria** para
|
||||||
|
no acoplar a un proveedor concreto antes de que la organización lo decida.
|
||||||
|
|
||||||
|
## 4. Moderación
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **Aprobación desde el dashboard del BaaS** (elegido) | Cero código extra. El revisor cambia un campo `status` de `pending` a `approved`. | Requiere acceso al dashboard. |
|
||||||
|
| Panel de moderación dentro de la app | Más cómodo. | Requiere autenticación, roles, vistas extra: explosión de complejidad. |
|
||||||
|
|
||||||
|
**Decisión**: La moderación vive fuera del frontend público. El modelo de
|
||||||
|
datos incluye un campo `status` (`pending` / `approved` / `rejected`). El
|
||||||
|
frontend solo lee los `approved`.
|
||||||
|
|
||||||
|
## 5. Estilos
|
||||||
|
|
||||||
|
| Opción | Pros | Contras |
|
||||||
|
|---|---|---|
|
||||||
|
| **CSS plano con variables** (elegido) | Cero dependencias, fácil de leer, los contribuidores con HTML/CSS básico pueden colaborar. | Sin utilidades. |
|
||||||
|
| Tailwind | Productivo. | Cadena de build extra, curva de aprendizaje. |
|
||||||
|
| CSS-in-JS | Componentes encapsulados. | Runtime extra, peor accesibilidad para nuevos contribuidores. |
|
||||||
|
|
||||||
|
**Decisión**: CSS plano. La app es de una página; no se justifica más.
|
||||||
|
|
||||||
|
## 6. Gestión de estado
|
||||||
|
|
||||||
|
**Decisión**: `useState` y `useEffect` de React. No hay Redux, Zustand ni
|
||||||
|
Context global. La app es pequeña; añadir una librería de estado sería YAGNI.
|
||||||
|
|
||||||
|
## 7. Geocodificación de direcciones del formulario
|
||||||
|
|
||||||
|
**Decisión para el prototipo**: el usuario hace clic en el mapa para fijar la
|
||||||
|
ubicación, o introduce lat/lng. Geocodificación automática vía Nominatim
|
||||||
|
(OpenStreetMap) queda como mejora futura con bajo coste de añadir.
|
||||||
|
|
||||||
|
## 8. Tests
|
||||||
|
|
||||||
|
**Decisión**: el prototipo no incluye suite de tests para no inflarlo, pero la
|
||||||
|
estructura (servicios separados de componentes, funciones puras donde posible)
|
||||||
|
facilita añadirlos. Cuando se incorporen, **Vitest** es la elección natural por
|
||||||
|
integrarse con Vite sin configuración.
|
||||||
|
|
||||||
|
## 9. Despliegue
|
||||||
|
|
||||||
|
**Decisión recomendada**: **GitHub Pages** o **Cloudflare Pages** (gratuitos
|
||||||
|
para open source). Una SPA estática construida con `vite build` se despliega
|
||||||
|
sin coste y sin servidor. Ver `docs/06-github-setup.md`.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Registro de decisiones
|
||||||
|
|
||||||
|
Lista corta y cronológica de decisiones que merecen ser revisadas más
|
||||||
|
adelante. Formato ADR ligero.
|
||||||
|
|
||||||
|
## D-001: SPA de una sola página
|
||||||
|
**Contexto**: el uso principal es ver un mapa y unos detalles.
|
||||||
|
**Decisión**: una sola página, sin router. El formulario es un panel/modal en
|
||||||
|
la misma vista.
|
||||||
|
**Consecuencia**: si en el futuro hay vistas adicionales (estadísticas, perfil
|
||||||
|
del negocio en URL propia), habrá que introducir React Router. Reevaluar
|
||||||
|
cuando aparezca el segundo caso de uso navegable.
|
||||||
|
|
||||||
|
## D-002: Capa de servicios
|
||||||
|
**Decisión**: todo acceso a datos pasa por `src/services/businessRepository.js`.
|
||||||
|
**Razón**: cumplir Dependency Inversion (SOLID). Los componentes no saben si
|
||||||
|
los datos vienen de un mock, de Supabase o de un JSON.
|
||||||
|
**Consecuencia**: cambiar de proveedor es modificar un único archivo.
|
||||||
|
|
||||||
|
## D-003: Modelo de datos mínimo
|
||||||
|
Campos del negocio:
|
||||||
|
- `id` (string)
|
||||||
|
- `name` (string)
|
||||||
|
- `category` (string)
|
||||||
|
- `address` (string)
|
||||||
|
- `description` (string)
|
||||||
|
- `contact` (string, opcional)
|
||||||
|
- `lat` (number)
|
||||||
|
- `lng` (number)
|
||||||
|
- `status` (`pending` | `approved` | `rejected`)
|
||||||
|
- `createdAt` (ISO string)
|
||||||
|
|
||||||
|
Sin slugs, sin fotos, sin tags. Reevaluar tras feedback de la comunidad.
|
||||||
|
|
||||||
|
## D-004: Sin autenticación en el prototipo
|
||||||
|
**Razón**: el visitante no necesita cuenta. El moderador opera en el panel del
|
||||||
|
backend.
|
||||||
|
**Reevaluar si**: aparecen casos de uso que requieran identidad (favoritos,
|
||||||
|
edición por el dueño del negocio).
|
||||||
|
|
||||||
|
## D-005: Idioma
|
||||||
|
El prototipo está en español, sin i18n. Si la organización quiere expandirse,
|
||||||
|
añadir `react-i18next` cuando exista una segunda lengua real, no antes.
|
||||||
|
|
||||||
|
## D-006: Mock de datos en el prototipo
|
||||||
|
El repositorio entregado usa un mock en memoria con varios negocios de
|
||||||
|
ejemplo. La organización debe decidir el backend antes de pasar a producción.
|
||||||
|
La interfaz del repositorio (`getApproved`, `submit`) está pensada para
|
||||||
|
mapearse 1:1 a Supabase u otro BaaS.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Gobernanza
|
||||||
|
|
||||||
|
Este proyecto es mantenido por **[NOMBRE DE LA ORGANIZACIÓN]**, una entidad
|
||||||
|
sin ánimo de lucro. Estas pautas son deliberadamente breves; queremos invertir
|
||||||
|
el esfuerzo en código y comunidad, no en burocracia.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
- **Mantenedores**: personas con permisos de merge en `main`. Son nombradas
|
||||||
|
por la organización. Responsables de revisar PRs, etiquetar issues y publicar
|
||||||
|
releases.
|
||||||
|
- **Contribuidores**: cualquiera que envíe un Pull Request, abra un issue o
|
||||||
|
participe en las discusiones.
|
||||||
|
- **Moderadores de contenido**: personas con acceso al panel del backend para
|
||||||
|
aprobar o rechazar los negocios enviados por los visitantes. No
|
||||||
|
necesariamente coinciden con los mantenedores del código.
|
||||||
|
|
||||||
|
## Toma de decisiones
|
||||||
|
|
||||||
|
- Decisiones pequeñas (bugs, mejoras menores): las toma quien revisa el PR.
|
||||||
|
- Decisiones de diseño o que afecten al modelo de datos: discusión pública en
|
||||||
|
un issue durante al menos 7 días. Cierra el debate un mantenedor buscando
|
||||||
|
consenso. Si no lo hay, decide la organización.
|
||||||
|
- Las decisiones relevantes se registran en `docs/03-decisions.md`.
|
||||||
|
|
||||||
|
## Código de conducta
|
||||||
|
|
||||||
|
Adoptamos el [Contributor Covenant](https://www.contributor-covenant.org/). En
|
||||||
|
resumen: trata a las personas con respeto. Las violaciones se reportan al
|
||||||
|
correo `[CORREO_CONTACTO]` y las gestiona la organización.
|
||||||
|
|
||||||
|
## Licencia
|
||||||
|
|
||||||
|
El proyecto se publica bajo **MIT**. Las contribuciones se entienden
|
||||||
|
licenciadas bajo los mismos términos.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Versionado semántico (semver). Los mantenedores publican versiones cuando
|
||||||
|
acumulan cambios suficientes. No hay calendario fijo.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Cómo contribuir
|
||||||
|
|
||||||
|
¡Gracias por querer ayudar! El proyecto se mantiene gracias a la comunidad.
|
||||||
|
|
||||||
|
## Antes de empezar
|
||||||
|
|
||||||
|
1. Mira los [issues abiertos](../../issues), sobre todo los marcados
|
||||||
|
`good first issue`.
|
||||||
|
2. Si tu cambio es grande, abre un issue antes de programar para discutirlo.
|
||||||
|
|
||||||
|
## Flujo
|
||||||
|
|
||||||
|
1. Haz fork del repositorio.
|
||||||
|
2. Crea una rama desde `main`: `git checkout -b mi-cambio`.
|
||||||
|
3. Escribe el código siguiendo las pautas de abajo.
|
||||||
|
4. Asegúrate de que la app arranca: `npm install && npm run dev`.
|
||||||
|
5. Abre un Pull Request hacia `main` describiendo el cambio.
|
||||||
|
|
||||||
|
## Pautas de código
|
||||||
|
|
||||||
|
- **Mantén la app simple.** Si dudas entre dos soluciones, elige la más corta
|
||||||
|
y obvia. Este proyecto evita la complejidad a propósito.
|
||||||
|
- **Sigue la estructura existente.** Componentes en `src/components`, acceso a
|
||||||
|
datos en `src/services`.
|
||||||
|
- **No añadas dependencias sin necesidad.** Cada nueva librería es coste de
|
||||||
|
mantenimiento.
|
||||||
|
- **SOLID y Clean Code aplicados con sentido común.** Nombres claros,
|
||||||
|
funciones cortas, una responsabilidad por archivo.
|
||||||
|
- **Estilo**: 2 espacios de indentación, comillas dobles en JSX y simples en JS.
|
||||||
|
|
||||||
|
## Tipos de contribución
|
||||||
|
|
||||||
|
- **Código**: bugs, mejoras, nuevas funciones acordadas en issues.
|
||||||
|
- **Documentación**: cualquier archivo de `docs/` o el README.
|
||||||
|
- **Diseño**: propuestas en issues con mockups.
|
||||||
|
- **Reportar negocios**: usa la propia aplicación; no es necesario un PR.
|
||||||
|
|
||||||
|
## Revisión
|
||||||
|
|
||||||
|
Un mantenedor revisará tu PR. Puede pedir cambios. Una vez aprobado, hará
|
||||||
|
merge. Sé paciente: somos voluntarios.
|
||||||
|
|
||||||
|
## Reconocimiento
|
||||||
|
|
||||||
|
Todas las personas que han contribuido aparecen automáticamente en la pestaña
|
||||||
|
de Contributors del repositorio.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Configuración del repositorio en GitHub
|
||||||
|
|
||||||
|
Pasos para dejar listo el repositorio. Pensado para que lo hagas una sola vez.
|
||||||
|
|
||||||
|
## 1. Crear el repositorio
|
||||||
|
|
||||||
|
1. En GitHub, crea un repositorio en la cuenta de la organización.
|
||||||
|
Recomendado: nombre corto, kebab-case, por ejemplo `business-map`.
|
||||||
|
2. Marca **Public**.
|
||||||
|
3. **No** inicialices con README, .gitignore ni licencia (ya vienen en este
|
||||||
|
prototipo).
|
||||||
|
4. Sube el código:
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Prototipo inicial"
|
||||||
|
git branch -M main
|
||||||
|
git remote add origin git@github.com:ORG/business-map.git
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Ajustes básicos (Settings → General)
|
||||||
|
|
||||||
|
- **Features**: deja activos Issues y Discussions. Desactiva Wikis y Projects
|
||||||
|
hasta que se usen.
|
||||||
|
- **Pull Requests**: marca "Allow squash merging" como única opción y "Always
|
||||||
|
suggest updating pull request branches".
|
||||||
|
- **Archive**: deja desactivado.
|
||||||
|
|
||||||
|
## 3. Protección de la rama `main` (Settings → Branches)
|
||||||
|
|
||||||
|
Añade una regla para `main`:
|
||||||
|
- Require a pull request before merging.
|
||||||
|
- Require approvals: 1.
|
||||||
|
- Dismiss stale approvals when new commits are pushed.
|
||||||
|
- Do not allow bypassing the above settings.
|
||||||
|
|
||||||
|
## 4. Plantillas
|
||||||
|
|
||||||
|
Las plantillas ya están en `.github/` del prototipo:
|
||||||
|
- `ISSUE_TEMPLATE/bug.md`
|
||||||
|
- `ISSUE_TEMPLATE/feature.md`
|
||||||
|
- `PULL_REQUEST_TEMPLATE.md`
|
||||||
|
|
||||||
|
## 5. Documentos visibles en GitHub
|
||||||
|
|
||||||
|
Para que GitHub los muestre como insignias del repositorio:
|
||||||
|
- `README.md` (raíz).
|
||||||
|
- `LICENSE` (raíz).
|
||||||
|
- `docs/05-contributing.md`: súbelo además como `CONTRIBUTING.md` en la raíz o
|
||||||
|
enlázalo desde el README.
|
||||||
|
- `CODE_OF_CONDUCT.md` recomendado en la raíz (Contributor Covenant oficial).
|
||||||
|
|
||||||
|
## 6. Despliegue gratuito
|
||||||
|
|
||||||
|
Opción recomendada: **Cloudflare Pages** o **GitHub Pages**.
|
||||||
|
|
||||||
|
Para GitHub Pages con Vite:
|
||||||
|
1. Settings → Pages → Build and deployment → Source: **GitHub Actions**.
|
||||||
|
2. Añade un workflow en `.github/workflows/deploy.yml` (no incluido en el
|
||||||
|
prototipo para mantenerlo mínimo; añadirlo cuando se decida la URL final).
|
||||||
|
3. Ajusta `base` en `vite.config.js` al path del repo.
|
||||||
|
|
||||||
|
## 7. Etiquetas iniciales sugeridas
|
||||||
|
|
||||||
|
Crea en Issues → Labels:
|
||||||
|
- `good first issue` (verde)
|
||||||
|
- `bug` (rojo)
|
||||||
|
- `enhancement` (azul)
|
||||||
|
- `documentation` (gris)
|
||||||
|
- `discussion` (morado)
|
||||||
|
|
||||||
|
## 8. Discussions
|
||||||
|
|
||||||
|
Activa Discussions con las categorías: Announcements, Ideas, Q&A. Sirven como
|
||||||
|
foro de baja fricción para la comunidad.
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# CI/CD — despliegue por rama
|
||||||
|
|
||||||
|
Cada rama se despliega automáticamente en `https://<rama>.localesp.es`.
|
||||||
|
|
||||||
|
## Cómo funciona
|
||||||
|
|
||||||
|
```
|
||||||
|
push a rama X
|
||||||
|
│ Gitea Actions (workflow: .gitea/workflows/deploy.yml)
|
||||||
|
▼
|
||||||
|
runner self-hosted (host executor, en el propio VPS)
|
||||||
|
│ npm ci → vite build → scripts/deploy.sh <rama>
|
||||||
|
▼
|
||||||
|
/opt/localesp/<slug>/ código + dist/ + node_modules (prod)
|
||||||
|
systemd localesp-<slug>.service node server/index.js (PORT=80xx)
|
||||||
|
nginx <slug>.localesp.es proxy_pass → localhost:80xx
|
||||||
|
certbot *.localesp.es Let's Encrypt (si el DNS ya apunta)
|
||||||
|
```
|
||||||
|
|
||||||
|
El **slug** se deriva del nombre de rama (`Feature/Foo` → `feature-foo`),
|
||||||
|
válido como hostname DNS. Un entorno = un subdirectorio bajo `/opt/localesp/`,
|
||||||
|
un servicio, un vhost y un puerto. La base de datos `localesp.db` de cada
|
||||||
|
entorno **se conserva** entre despliegues (no se sobreescribe).
|
||||||
|
|
||||||
|
Puertos: los históricos son `8080` (prototipo-yb01) y `8081` (master); los
|
||||||
|
nuevos se asignan desde `8082` hacia arriba. Si la rama ya tiene su
|
||||||
|
`localesp-<slug>.service`, se reutiliza su puerto (adopta el entorno existente).
|
||||||
|
|
||||||
|
## Precondición manual: el registro DNS
|
||||||
|
|
||||||
|
El registro A de `<rama>.localesp.es` **se crea a mano** (no está automatizado).
|
||||||
|
|
||||||
|
1. En el proveedor DNS de `localesp.es`, añade un registro A:
|
||||||
|
`<rama>.localesp.es A <IP-pública-del-VPS>`
|
||||||
|
2. Espera a que propague (`dig +short <rama>.localesp.es`).
|
||||||
|
3. Lanza el workflow (push a la rama, o *Actions → Run workflow* en Gitea).
|
||||||
|
|
||||||
|
Mientras el DNS no apunte al VPS, `deploy.sh` despliega la app igualmente y la
|
||||||
|
sirve por **HTTP**; emite el cert Let's Encrypt en cuanto detecta que el DNS ya
|
||||||
|
resuelve. No hay que tocar nada más.
|
||||||
|
|
||||||
|
## Añadir una rama nueva
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b mi-feature
|
||||||
|
# ... cambios ...
|
||||||
|
git push -u origin mi-feature
|
||||||
|
```
|
||||||
|
|
||||||
|
El workflow corre solo. Si quieres URL pública: crea el registro A (arriba).
|
||||||
|
Si no, el entorno existe pero no es alcanzable por hostname (útil para tests internos
|
||||||
|
si añades el host a `/etc/hosts`).
|
||||||
|
|
||||||
|
## Borrar una rama
|
||||||
|
|
||||||
|
Al borrar la rama en Gitea, el job `teardown` limpia el entorno (unit systemd,
|
||||||
|
vhost, cert y `/opt/localesp-<slug>`). También manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh localesp.jumpingcrab.com 'bash /opt/localesp-master/scripts/teardown.sh mi-feature'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entornos existentes (ya migrados al layout unificado)
|
||||||
|
|
||||||
|
Los entornos legacy ya están reubicados bajo `/opt/localesp/<rama>` con sus
|
||||||
|
systemd units renombradas al esquema `localesp-<slug>.service`, de modo que el
|
||||||
|
CI los **adopta** sin cambios (reutiliza puerto y vhost):
|
||||||
|
|
||||||
|
| Rama | Dir | Unit | Puerto | Hosts |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `master` | `/opt/localesp/master` | `localesp-master.service` | 8081 | `localesp.es`, `master.localesp.es` |
|
||||||
|
| `prototipo-yb01` | `/opt/localesp/prototipo-yb01` | `localesp-prototipo-yb01.service` | 8080 | `prototipo-yb01.localesp.es` |
|
||||||
|
|
||||||
|
El antiguo `localesp.service` (sin slug) se retiró; ahora todas las units
|
||||||
|
siguen el patrón `localesp-<slug>.service`. La db de cada entorno se preservó.
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
El runner (`gitea-runner`) se ejecuta como **root en el VPS de producción** y en
|
||||||
|
modo **host** (sin contenedor): cualquier workflow que se ejecute corresponde a
|
||||||
|
código del repo corriendo con privilegios de root en la caja. Esto es aceptable
|
||||||
|
mientras el repo sea propio / de baja colaboración. Si entran colaboradores
|
||||||
|
externos, conviene migrar a:
|
||||||
|
|
||||||
|
- runner en contenedor + despliegue por SSH (clave como secret de Gitea), o
|
||||||
|
- *ephemeral runners* (`gitea-runner register --ephemeral`).
|
||||||
|
|
||||||
|
## Evolución: estrategia B (wildcard)
|
||||||
|
|
||||||
|
Para eliminar la precondición manual del DNS y el cert por rama, más adelante:
|
||||||
|
|
||||||
|
1. Registro **wildcard `*.localesp.es`** (DNS).
|
||||||
|
2. **Wildcard cert** `*.localesp.es` vía challenge DNS-01 (una sola vez).
|
||||||
|
3. Un único vhost `*.localesp.es` que enruta por `Host` con un `map` rama→puerto.
|
||||||
|
|
||||||
|
`deploy.sh` apenas cambia: se caen los pasos de creación de vhost y certbot.
|
||||||
|
Nada de lo hecho aquí se pierde.
|
||||||
|
|
||||||
|
## Infra del runner (referencia)
|
||||||
|
|
||||||
|
Instalado en el VPS (`localesp.jumpingcrab.com`):
|
||||||
|
|
||||||
|
- Binario: `/usr/local/bin/gitea-runner` (v2.2.0), host executor.
|
||||||
|
**Cadena de suministro verificada**: el binario coincide (sha256 `d6b3f5bb…`)
|
||||||
|
con el descomprimido del release oficial `gitea-runner-2.2.0-linux-amd64.xz`, cuya suma
|
||||||
|
`13357e0d…` está publicada en el release. Gitea no publica firma GPG del runner; la suma
|
||||||
|
es la garantía máxima disponible. No hay paquete `dnf` oficial (solo binario o Docker image).
|
||||||
|
- Config: `/etc/gitea-runner/config.yaml` — etiquetas `self-hosted:host`, `linux:host`.
|
||||||
|
- Registro: `/var/lib/gitea-runner/.runner` (instance-level, `https://git.localesp.es`).
|
||||||
|
- Servicio: `gitea-runner.service` (systemd, `User=root`, `WorkingDirectory=/var/lib/gitea-runner`).
|
||||||
|
|
||||||
|
Comprobar estado: `systemctl status gitea-runner` · `journalctl -u gitea-runner -f`.
|
||||||
|
Ver runners en Gitea: *Site administration → Actions → Runners*.
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Business Map</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
crossorigin=""
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+3241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "business-map",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"dev:all": "concurrently -k -n vite,api -c cyan,green \"npm run dev\" \"npm run server\"",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"concurrently": "^10.0.0",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh — Despliega la rama actual a <slug>.localesp.es
|
||||||
|
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/env.sh
|
||||||
|
. "$SCRIPT_DIR/lib/env.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <branch>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local branch root
|
||||||
|
|
||||||
|
branch="${1:-${GITHUB_REF_NAME:-}}"
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
Logger::set_tag deploy
|
||||||
|
Env::new env "$branch" "$root"
|
||||||
|
Logger::info "$(Env::banner env)"
|
||||||
|
|
||||||
|
Env::deploy env
|
||||||
|
|
||||||
|
Logger::info "LISTO: $(Env::url env) ($(Env::summary env))"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/env.sh — Modelo OOP de entornos de previsualización por rama.
|
||||||
|
#
|
||||||
|
# Estrategia (entorno por rama):
|
||||||
|
# /opt/localesp/<slug> + systemd unit localesp-<slug> + vhost nginx + cert Let's Encrypt.
|
||||||
|
# Reutiliza el puerto de la unit existente (adopta entornos legacy) o asigna uno libre (>=8082).
|
||||||
|
#
|
||||||
|
# PRECONDICIÓN MANUAL: el registro A <slug>.localesp.es debe existir (se gestiona a mano).
|
||||||
|
# Si no resuelve aún, la app queda servida por HTTP y se emite el cert cuando el DNS apunte aquí.
|
||||||
|
#
|
||||||
|
# Representación OOP en bash:
|
||||||
|
# - Instancia = array asociativo global, referenciado por NOMBRE.
|
||||||
|
# - Constructor = Class::new <nombre> <args> (declara el array y establece invariantes).
|
||||||
|
# - Método = Class::method <nombre> <args>; accede al estado vía `local -n self="$1"`.
|
||||||
|
# - Composición = Env posee los nombres de sus colaboradores (<env>__unit, __vhost, __cert).
|
||||||
|
# - Servicios sin estado (Slug, Host, Port, Artifacts, Deps, Logger) = métodos estáticos.
|
||||||
|
#
|
||||||
|
# Requiere bash >= 4.3 (namerefs). Sin efectos al sourcear: sólo define funciones/constantes.
|
||||||
|
#
|
||||||
|
# CONTRATO (importante): el NOMBRE de instancia pasado a Env::new (p.ej. "env") es el
|
||||||
|
# nombre de un array asociativo global. NUNCA declares en el caller una `local` con ese
|
||||||
|
# mismo nombre: bash usa ámbito dinámico y la local sombrearía al array global, rompiendo
|
||||||
|
# el nameref `local -n self=env`. Pasa el nombre como literal: Env::new env ...
|
||||||
|
|
||||||
|
DOMAIN="localesp.es"
|
||||||
|
PORT_BASE=8082 # 8080/8081 son entornos legacy
|
||||||
|
LOGGER_TAG="env"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logger — servicio singleton (tag configurable según entrypoint).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Logger::set_tag() {
|
||||||
|
LOGGER_TAG="$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::info() {
|
||||||
|
printf '\033[1;34m[%s]\033[0m %s\n' "$LOGGER_TAG" "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger::error() {
|
||||||
|
printf '\033[1;31m[%s:ERROR]\033[0m %s\n' "$LOGGER_TAG" "$*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slug — value object estático: normaliza un nombre de rama a slug.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Slug::from() {
|
||||||
|
local branch="$1"
|
||||||
|
printf '%s' "$branch" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Host — servicio estático: IP pública y resolución DNS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Host::public_ip() {
|
||||||
|
curl -s4 --max-time 5 ifconfig.me || true
|
||||||
|
}
|
||||||
|
|
||||||
|
Host::resolved_ip() {
|
||||||
|
local host="$1"
|
||||||
|
getent hosts "$host" | awk '{print $1}' | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Port — servicio estático: asigna/reutiliza puerto para una unit+dest.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Port::acquire() {
|
||||||
|
local unit="$1"
|
||||||
|
local dest="$2"
|
||||||
|
local unit_file="/etc/systemd/system/$unit.service"
|
||||||
|
local used
|
||||||
|
local port
|
||||||
|
|
||||||
|
# Adopta entorno existente: reutiliza el puerto de la unit ya instalada.
|
||||||
|
if [ -f "$unit_file" ]; then
|
||||||
|
port="$(grep -oE 'PORT=[0-9]+' "$unit_file" | head -1 | cut -d= -f2 || true)"
|
||||||
|
if [ -n "$port" ]; then
|
||||||
|
echo "$port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -f "$dest/.port" ]; then
|
||||||
|
cat "$dest/.port"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
used="$( { grep -rhoE 'PORT=[0-9]+' /etc/systemd/system/localesp*.service 2>/dev/null | cut -d= -f2
|
||||||
|
grep -rhoE 'localhost:[0-9]+' /etc/nginx/conf.d/*.conf 2>/dev/null | cut -d: -f2; } | sort -un)"
|
||||||
|
port="$PORT_BASE"
|
||||||
|
while printf '%s\n' "$used" | grep -qx "$port"; do
|
||||||
|
port=$((port+1))
|
||||||
|
done
|
||||||
|
echo "$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Artifacts — servicio estático: artefactos buildados.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Artifacts::require_built() {
|
||||||
|
local root="$1"
|
||||||
|
|
||||||
|
if [ ! -d "$root/dist" ]; then
|
||||||
|
Logger::error "falta dist/ — ¿se ejecutó el build?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
Artifacts::sync() {
|
||||||
|
local root="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
mkdir -p "$dest/dist" "$dest/server"
|
||||||
|
rsync -a --delete "$root/dist/" "$dest/dist/"
|
||||||
|
rsync -a --delete "$root/server/" "$dest/server/"
|
||||||
|
cp -a "$root/package.json" "$root/package-lock.json" "$dest/"
|
||||||
|
# localesp.db se PRESERVA (datos de usuarios). No se toca aquí.
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deps — servicio estático: dependencias de producción.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Deps::install() {
|
||||||
|
local root="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
# Reutiliza los node_modules ya compilados por el workflow (better-sqlite3 v12 exige
|
||||||
|
# C++20, GCC 12). Copiar + npm prune evita recompilar en cada despliegue.
|
||||||
|
# Fallback a npm ci --omit=dev si se ejecuta a mano sin build previo.
|
||||||
|
if [ -d "$root/node_modules" ]; then
|
||||||
|
mkdir -p "$dest/node_modules"
|
||||||
|
rsync -a --delete "$root/node_modules/" "$dest/node_modules/"
|
||||||
|
( cd "$dest" && npm prune --omit=dev --no-audit --no-fund )
|
||||||
|
else
|
||||||
|
( cd "$dest" && npm ci --omit=dev --no-audit --no-fund )
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SystemdUnit — entidad: fichero .service de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SystemdUnit::new() {
|
||||||
|
local self="$1"
|
||||||
|
local name="$2"
|
||||||
|
local dest="$3"
|
||||||
|
local branch="$4"
|
||||||
|
local host="$5"
|
||||||
|
local port="$6"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n u="$self"
|
||||||
|
u[name]="$name"
|
||||||
|
u[dest]="$dest"
|
||||||
|
u[branch]="$branch"
|
||||||
|
u[host]="$host"
|
||||||
|
u[port]="$port"
|
||||||
|
u[file]="/etc/systemd/system/${name}.service"
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::render() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=LocaleSP env rama '${u[branch]}' (${u[host]})
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=${u[dest]}
|
||||||
|
ExecStart=/usr/bin/node server/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
Environment=PORT=${u[port]}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::write() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
Logger::info "escribiendo unit ${u[name]} (PORT=${u[port]})"
|
||||||
|
SystemdUnit::render "$self" > "${u[file]}"
|
||||||
|
echo "${u[port]}" > "${u[dest]}/.port"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${u[name]}" >/dev/null 2>&1 || true
|
||||||
|
systemctl restart "${u[name]}"
|
||||||
|
Logger::info "${u[name]} arrancada"
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemdUnit::remove() {
|
||||||
|
local self="$1"
|
||||||
|
local -n u="$self"
|
||||||
|
|
||||||
|
systemctl disable --now "${u[name]}" 2>/dev/null || true
|
||||||
|
rm -f "${u[file]}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NginxVhost — entidad: vhost nginx de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
NginxVhost::new() {
|
||||||
|
local self="$1"
|
||||||
|
local slug="$2"
|
||||||
|
local host="$3"
|
||||||
|
local port="$4"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n v="$self"
|
||||||
|
v[slug]="$slug"
|
||||||
|
v[host]="$host"
|
||||||
|
v[port]="$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::find_path() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local host_re
|
||||||
|
host_re="$(printf '%s' "${v[host]}" | sed 's/\./\\./g')"
|
||||||
|
grep -rlE "server_name[[:space:]]+$host_re[[:space:]]*;" /etc/nginx/conf.d/*.conf 2>/dev/null | head -1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::render() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${v[host]};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:${v[port]};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::create() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local path="/etc/nginx/conf.d/${v[slug]}.conf"
|
||||||
|
|
||||||
|
Logger::info "creando vhost HTTP $path"
|
||||||
|
NginxVhost::render "$self" > "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::repoint() {
|
||||||
|
local self="$1"
|
||||||
|
local -n v="$self"
|
||||||
|
local path="$2"
|
||||||
|
|
||||||
|
Logger::info "vhost existente: $path — forzando proxy_pass -> :${v[port]}"
|
||||||
|
sed -i -E "s|proxy_pass http://localhost:[0-9]+;|proxy_pass http://localhost:${v[port]};|g" "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::configure() {
|
||||||
|
local self="$1"
|
||||||
|
local existing
|
||||||
|
|
||||||
|
existing="$(NginxVhost::find_path "$self")"
|
||||||
|
if [ -z "$existing" ]; then
|
||||||
|
NginxVhost::create "$self"
|
||||||
|
else
|
||||||
|
NginxVhost::repoint "$self" "$existing"
|
||||||
|
fi
|
||||||
|
nginx -t 2>&1 | tail -2
|
||||||
|
systemctl reload nginx
|
||||||
|
Logger::info "nginx recargado"
|
||||||
|
}
|
||||||
|
|
||||||
|
NginxVhost::remove() {
|
||||||
|
local self="$1"
|
||||||
|
local path
|
||||||
|
|
||||||
|
path="$(NginxVhost::find_path "$self")"
|
||||||
|
if [ -n "$path" ]; then
|
||||||
|
Logger::info "borrando vhost $path"
|
||||||
|
rm -f "$path"
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TlsCert — entidad: certificado Let's Encrypt de un entorno.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TlsCert::new() {
|
||||||
|
local self="$1"
|
||||||
|
local host="$2"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n c="$self"
|
||||||
|
c[host]="$host"
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::exists() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
[ -d "/etc/letsencrypt/live/${c[host]}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::ensure() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
local host="${c[host]}"
|
||||||
|
local pub_ip resolved
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
Logger::info "cert TLS ya presente para $host"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
Logger::info "comprobando DNS de $host"
|
||||||
|
pub_ip="$(Host::public_ip)"
|
||||||
|
resolved="$(Host::resolved_ip "$host")"
|
||||||
|
|
||||||
|
if [ -n "$pub_ip" ] && [ "$resolved" = "$pub_ip" ]; then
|
||||||
|
Logger::info "emitiendo cert con certbot --nginx"
|
||||||
|
if certbot --nginx -d "$host" -n --redirect --keep-until-expiring; then
|
||||||
|
Logger::info "cert emitido ✓"
|
||||||
|
else
|
||||||
|
Logger::error "certbot falló; $host sigue en HTTP. Revisa y vuelve a lanzar el workflow."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
Logger::error "DNS de $host -> '${resolved:-<sin resolver>}', esperado $pub_ip."
|
||||||
|
Logger::error "Crea el registro A $host -> $pub_ip y, tras propagar, re-lanza el workflow"
|
||||||
|
Logger::error "(o ejecuta: certbot --nginx -d $host). La app ya vive en http://$host"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::delete() {
|
||||||
|
local self="$1"
|
||||||
|
local -n c="$self"
|
||||||
|
local host="${c[host]}"
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
Logger::info "borrando cert $host"
|
||||||
|
certbot delete --cert-name "$host" -n 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TlsCert::protocol() {
|
||||||
|
local self="$1"
|
||||||
|
|
||||||
|
if TlsCert::exists "$self"; then
|
||||||
|
echo "https"
|
||||||
|
else
|
||||||
|
echo "http"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Env — aggregate root: un entorno de rama. Compone SystemdUnit, NginxVhost, TlsCert.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
Env::new() {
|
||||||
|
local self="$1"
|
||||||
|
local branch="$2"
|
||||||
|
local root="$3"
|
||||||
|
|
||||||
|
declare -gA "$self"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
s[branch]="$branch"
|
||||||
|
s[root]="$root"
|
||||||
|
s[slug]="$(Slug::from "$branch")"
|
||||||
|
if [ -z "${s[slug]}" ]; then
|
||||||
|
Logger::error "slug vacío para la rama '$branch'"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
s[host]="${s[slug]}.$DOMAIN"
|
||||||
|
s[dest]="/opt/localesp/${s[slug]}"
|
||||||
|
s[unit_name]="localesp-${s[slug]}"
|
||||||
|
s[port]="$(Port::acquire "${s[unit_name]}" "${s[dest]}")"
|
||||||
|
|
||||||
|
# Composición: crea los colaboradores y guarda sus nombres en el agregado.
|
||||||
|
SystemdUnit::new "${self}__unit" "${s[unit_name]}" "${s[dest]}" "$branch" "${s[host]}" "${s[port]}"
|
||||||
|
NginxVhost::new "${self}__vhost" "${s[slug]}" "${s[host]}" "${s[port]}"
|
||||||
|
TlsCert::new "${self}__cert" "${s[host]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::deploy() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
Artifacts::require_built "${s[root]}"
|
||||||
|
Logger::info "sincronizando artefactos -> ${s[dest]}"
|
||||||
|
Artifacts::sync "${s[root]}" "${s[dest]}"
|
||||||
|
|
||||||
|
Logger::info "instalando dependencias (prod)"
|
||||||
|
Deps::install "${s[root]}" "${s[dest]}"
|
||||||
|
|
||||||
|
SystemdUnit::write "${self}__unit"
|
||||||
|
|
||||||
|
Logger::info "configurando nginx para ${s[host]}"
|
||||||
|
NginxVhost::configure "${self}__vhost"
|
||||||
|
|
||||||
|
TlsCert::ensure "${self}__cert"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::teardown() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
SystemdUnit::remove "${self}__unit"
|
||||||
|
TlsCert::delete "${self}__cert"
|
||||||
|
NginxVhost::remove "${self}__vhost"
|
||||||
|
rm -rf "${s[dest]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::url() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
local proto
|
||||||
|
|
||||||
|
proto="$(TlsCert::protocol "${self}__cert")"
|
||||||
|
echo "$proto://${s[host]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::banner() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "rama=${s[branch]} slug=${s[slug]} host=${s[host]} dest=${s[dest]} puerto=${s[port]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::identity() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "${s[host]} (${s[dest]}, ${s[unit_name]})"
|
||||||
|
}
|
||||||
|
|
||||||
|
Env::summary() {
|
||||||
|
local self="$1"
|
||||||
|
local -n s="$self"
|
||||||
|
|
||||||
|
echo "rama=${s[branch]} unit=${s[unit_name]} puerto=${s[port]}"
|
||||||
|
}
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# teardown.sh — Elimina el entorno de una rama (al borrar la rama o a mano).
|
||||||
|
# Toda la lógica vive en lib/env.sh (modelo OOP). Éste es sólo el entrypoint.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=lib/env.sh
|
||||||
|
. "$SCRIPT_DIR/lib/env.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <branch>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local branch root
|
||||||
|
|
||||||
|
branch="${1:-}"
|
||||||
|
if [ -z "$branch" ]; then
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
root="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
Logger::set_tag teardown
|
||||||
|
Env::new env "$branch" "$root"
|
||||||
|
Logger::info "$(Env::identity env)"
|
||||||
|
|
||||||
|
Env::teardown env
|
||||||
|
|
||||||
|
Logger::info "entorno eliminado ✓"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const db = new Database(join(__dirname, "..", "localesp.db"));
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS businesses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
contact TEXT NOT NULL DEFAULT '',
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
createdAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const empty = db.prepare("SELECT COUNT(*) AS n FROM businesses").get().n === 0;
|
||||||
|
if (empty) {
|
||||||
|
const ins = db.prepare(`
|
||||||
|
INSERT INTO businesses
|
||||||
|
(id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
|
VALUES
|
||||||
|
(@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
|
`);
|
||||||
|
for (const b of [
|
||||||
|
{ id: "1", name: "Café del Mercado", category: "Cafetería", address: "Plaza Mayor 3, Madrid", description: "Café de especialidad y bollería artesana.", contact: "hola@cafedelmercado.example", lat: 40.4155, lng: -3.7074, status: "approved", createdAt: "2025-01-10T10:00:00Z" },
|
||||||
|
{ id: "2", name: "Librería El Faro", category: "Librería", address: "Calle Luna 12, Madrid", description: "Librería independiente con sección infantil.", contact: "", lat: 40.4221, lng: -3.7059, status: "approved", createdAt: "2025-01-12T10:00:00Z" },
|
||||||
|
{ id: "3", name: "Taller Bicicletas Rueda", category: "Servicios", address: "Calle Embajadores 45, Madrid", description: "Reparación y venta de segunda mano.", contact: "+34 600 000 000", lat: 40.4078, lng: -3.7053, status: "approved", createdAt: "2025-01-15T10:00:00Z" },
|
||||||
|
]) ins.run(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default db;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { dirname, join } from "path";
|
||||||
|
import db from "./db.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(join(__dirname, "../dist")));
|
||||||
|
|
||||||
|
app.get("/api/businesses", (_req, res) => {
|
||||||
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'approved' ORDER BY createdAt DESC").all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/businesses", (req, res) => {
|
||||||
|
const { name, category, address, description, contact, lat, lng } = req.body;
|
||||||
|
const business = {
|
||||||
|
id: randomUUID(),
|
||||||
|
name, category, address, description,
|
||||||
|
contact: contact || "",
|
||||||
|
lat, lng,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO businesses (id, name, category, address, description, contact, lat, lng, status, createdAt)
|
||||||
|
VALUES (@id, @name, @category, @address, @description, @contact, @lat, @lng, @status, @createdAt)
|
||||||
|
`).run(business);
|
||||||
|
res.status(201).json(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/admin/pending", (_req, res) => {
|
||||||
|
res.json(db.prepare("SELECT * FROM businesses WHERE status = 'pending' ORDER BY createdAt ASC").all());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/api/admin/businesses/:id", (req, res) => {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!["approved", "rejected"].includes(status))
|
||||||
|
return res.status(400).json({ error: "status must be 'approved' or 'rejected'" });
|
||||||
|
const info = db.prepare("UPDATE businesses SET status = ? WHERE id = ?").run(status, req.params.id);
|
||||||
|
if (info.changes === 0) return res.status(404).json({ error: "not found" });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||||
|
res.sendFile(join(__dirname, "../dist/index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => console.log(`API listening on http://localhost:${PORT}`));
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import MapView from "./components/MapView.jsx";
|
||||||
|
import Sidebar from "./components/Sidebar.jsx";
|
||||||
|
import SubmitForm from "./components/SubmitForm.jsx";
|
||||||
|
import AdminPanel from "./components/AdminPanel.jsx";
|
||||||
|
import { getApproved, submit } from "./services/businessRepository.js";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [businesses, setBusinesses] = useState([]);
|
||||||
|
const [selectedId, setSelectedId] = useState(null);
|
||||||
|
const [formStep, setFormStep] = useState(null); // null | 'picking' | 'editing'
|
||||||
|
const [pickedLocation, setPickedLocation] = useState(null);
|
||||||
|
const [adminOpen, setAdminOpen] = useState(false);
|
||||||
|
|
||||||
|
async function loadBusinesses() {
|
||||||
|
setBusinesses(await getApproved());
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { loadBusinesses(); }, []);
|
||||||
|
|
||||||
|
const selected = businesses.find((b) => b.id === selectedId) || null;
|
||||||
|
|
||||||
|
function handlePickLocation(loc) {
|
||||||
|
setPickedLocation(loc);
|
||||||
|
if (formStep === "picking") setFormStep("editing");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(data) {
|
||||||
|
await submit(data);
|
||||||
|
setFormStep(null);
|
||||||
|
setPickedLocation(null);
|
||||||
|
alert("¡Gracias! Tu propuesta queda pendiente de revisión.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setFormStep(null);
|
||||||
|
setPickedLocation(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Sidebar
|
||||||
|
business={selected}
|
||||||
|
onContribute={() => setFormStep("picking")}
|
||||||
|
onClose={() => setSelectedId(null)}
|
||||||
|
picking={formStep === "picking"}
|
||||||
|
onCancelPicking={handleCancel}
|
||||||
|
onAdmin={() => setAdminOpen(true)}
|
||||||
|
/>
|
||||||
|
<MapView
|
||||||
|
businesses={businesses}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
pickingLocation={formStep === "picking"}
|
||||||
|
onPickLocation={handlePickLocation}
|
||||||
|
pickedLocation={pickedLocation}
|
||||||
|
/>
|
||||||
|
{formStep === "editing" && (
|
||||||
|
<SubmitForm
|
||||||
|
pickedLocation={pickedLocation}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onRepick={() => setFormStep("picking")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{adminOpen && (
|
||||||
|
<AdminPanel
|
||||||
|
onClose={() => setAdminOpen(false)}
|
||||||
|
onApproved={loadBusinesses}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getPending, setStatus } from "../services/businessRepository.js";
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose, onApproved }) {
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setPending(await getPending());
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function handle(id, status) {
|
||||||
|
await setStatus(id, status);
|
||||||
|
await load();
|
||||||
|
onApproved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal admin-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="admin-header">
|
||||||
|
<h2>Propuestas pendientes</h2>
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="admin-empty">Cargando…</p>}
|
||||||
|
|
||||||
|
{!loading && pending.length === 0 && (
|
||||||
|
<p className="admin-empty">No hay propuestas pendientes.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && pending.map((b) => (
|
||||||
|
<div key={b.id} className="admin-item">
|
||||||
|
<div className="admin-item-info">
|
||||||
|
<strong>{b.name}</strong>
|
||||||
|
<span className="badge">{b.category}</span>
|
||||||
|
<p>{b.address}</p>
|
||||||
|
<p className="admin-desc">{b.description}</p>
|
||||||
|
{b.contact && <p className="admin-contact">{b.contact}</p>}
|
||||||
|
<p className="admin-coords">{b.lat.toFixed(4)}, {b.lng.toFixed(4)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button className="approve" onClick={() => handle(b.id, "approved")}>Aprobar</button>
|
||||||
|
<button className="reject" onClick={() => handle(b.id, "rejected")}>Rechazar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
// Configuración del icono por defecto. Leaflet no encuentra sus iconos cuando
|
||||||
|
// se empaqueta con Vite; los referenciamos desde el CDN para no copiar
|
||||||
|
// archivos a public/.
|
||||||
|
const icon = new L.Icon({
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_CENTER = [40.4168, -3.7038];
|
||||||
|
const DEFAULT_ZOOM = 13;
|
||||||
|
|
||||||
|
function LocationPicker({ onPick }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onPick({ lat: e.latlng.lat, lng: e.latlng.lng });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapView({
|
||||||
|
businesses,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
pickingLocation,
|
||||||
|
onPickLocation,
|
||||||
|
pickedLocation,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="map">
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ height: "100%", width: "100%" }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{businesses.map((b) => (
|
||||||
|
<Marker
|
||||||
|
key={b.id}
|
||||||
|
position={[b.lat, b.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{ click: () => onSelect(b.id) }}
|
||||||
|
opacity={selectedId && selectedId !== b.id ? 0.6 : 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pickingLocation && <LocationPicker onPick={onPickLocation} />}
|
||||||
|
{pickedLocation && (
|
||||||
|
<Marker
|
||||||
|
position={[pickedLocation.lat, pickedLocation.lng]}
|
||||||
|
icon={icon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
{pickingLocation && (
|
||||||
|
<div className="map-hint">Haz clic en el mapa para fijar la ubicación</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export default function Sidebar({ business, onContribute, onClose, picking, onCancelPicking, onAdmin }) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<header className="sidebar-header">
|
||||||
|
<h1>Business Map</h1>
|
||||||
|
<p className="tagline">Registro colaborativo de negocios</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="sidebar-body">
|
||||||
|
{picking ? (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Haz clic en el mapa para fijar la ubicación del negocio.</p>
|
||||||
|
</div>
|
||||||
|
) : business ? (
|
||||||
|
<BusinessDetails business={business} onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<Empty />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="sidebar-footer">
|
||||||
|
{picking ? (
|
||||||
|
<button onClick={onCancelPicking}>Cancelar</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="primary" onClick={onContribute}>
|
||||||
|
+ Añadir un negocio
|
||||||
|
</button>
|
||||||
|
<button className="admin-btn" onClick={onAdmin} title="Panel de administración">
|
||||||
|
⚙ Admin
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<div className="empty">
|
||||||
|
<p>Selecciona un punto del mapa para ver sus detalles.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BusinessDetails({ business, onClose }) {
|
||||||
|
return (
|
||||||
|
<article className="details">
|
||||||
|
<button className="close" onClick={onClose} aria-label="Cerrar">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<span className="badge">{business.category}</span>
|
||||||
|
<h2>{business.name}</h2>
|
||||||
|
<p className="address">{business.address}</p>
|
||||||
|
<p className="description">{business.description}</p>
|
||||||
|
{business.contact && (
|
||||||
|
<p className="contact">
|
||||||
|
<strong>Contacto:</strong> {business.contact}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const EMPTY = {
|
||||||
|
name: "",
|
||||||
|
category: "",
|
||||||
|
address: "",
|
||||||
|
description: "",
|
||||||
|
contact: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SubmitForm({ pickedLocation, onSubmit, onCancel, onRepick }) {
|
||||||
|
const [values, setValues] = useState(EMPTY);
|
||||||
|
|
||||||
|
function update(field) {
|
||||||
|
return (e) => setValues({ ...values, [field]: e.target.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({ ...values, lat: pickedLocation.lat, lng: pickedLocation.lng });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onCancel}>
|
||||||
|
<form
|
||||||
|
className="modal"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<h2>Proponer un negocio</h2>
|
||||||
|
<p className="hint">
|
||||||
|
Los datos se revisarán antes de aparecer en el mapa.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Nombre
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
onChange={update("name")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Categoría
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
placeholder="Cafetería, librería, taller…"
|
||||||
|
value={values.category}
|
||||||
|
onChange={update("category")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Dirección
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={values.address}
|
||||||
|
onChange={update("address")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Descripción
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={values.description}
|
||||||
|
onChange={update("description")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Contacto (opcional)
|
||||||
|
<input
|
||||||
|
placeholder="Email, teléfono o web"
|
||||||
|
value={values.contact}
|
||||||
|
onChange={update("contact")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="location-status">
|
||||||
|
{`Ubicación: ${pickedLocation.lat.toFixed(4)}, ${pickedLocation.lng.toFixed(4)}`}
|
||||||
|
<button type="button" className="repick-link" onClick={onRepick}>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="primary">
|
||||||
|
Enviar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function getApproved() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando negocios");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submit(data) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/businesses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error enviando propuesta");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPending() {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/pending`);
|
||||||
|
if (!res.ok) throw new Error("Error cargando pendientes");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStatus(id, status) {
|
||||||
|
const res = await fetch(`${import.meta.env.BASE_URL}api/admin/businesses/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Error actualizando estado");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
+377
@@ -0,0 +1,377 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #fafaf7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--border: #e6e6e2;
|
||||||
|
--accent: #2f6f4f;
|
||||||
|
--accent-hover: #245a3f;
|
||||||
|
--shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
--sidebar-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--surface);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detalles */
|
||||||
|
.details {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details h2 {
|
||||||
|
margin: 12px 0 4px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mapa */
|
||||||
|
.map {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-hint {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botones */
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input,
|
||||||
|
.modal textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal input:focus,
|
||||||
|
.modal textarea:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-status {
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-color: var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repick-link:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin */
|
||||||
|
.admin-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header .close {
|
||||||
|
position: static;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-item-info p {
|
||||||
|
margin: 2px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-desc {
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact, .admin-coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .approve:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #c0392b;
|
||||||
|
border-color: #e0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions .reject:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.app {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 45%;
|
||||||
|
border-right: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: "/",
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
strictPort: true,
|
||||||
|
allowedHosts: true,
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||||
|
preflightContinue: false,
|
||||||
|
optionsSuccessStatus: 204,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user