Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
SITO
7d5dbeec60 Cuenta de externas, arreglos de seguridad, títulos arriba y despliegue
- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el
  colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta
  que la asamblea de la Enre las confirma.
- /pedir/ explica en una línea que los colectivos externos piden fecha en la
  asamblea de externas, los lunes de 19:00 a 20:00.
- Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta
  antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB);
  bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la
  asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de
  31 días como mucho; parámetros de argon2 acotados.
- Títulos pegados arriba en todas las páginas; la franja de la enredadera
  recupera ese alto para seguir llenando la pantalla.
- despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso
  encerrado, fragmento de nginx que además deja de servir .git y las notas
  internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
2026-09-23 12:15:33 +02:00
SITO
ce3127baff Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
2026-09-23 11:32:25 +02:00
SITO
c3b51be992 La enredadera serpentea entre filas y los brotes cuelgan de un tallo
El dibujo del tallo de ordenador pasa a llevar dos filas: la pasada y su
espejo. Entra y sale vertical por las dos esquinas, así que empalma consigo
mismo por los dos lados y con repeat-y cada dos filas serpentea sin junta. En
colectivos, que tiene doce hojas, se veían dos ramas sueltas: la segunda
empezaba otra vez desde el margen izquierdo, debajo de la espiral con la que
acababa la primera.

El fondo va ahora en dos capas porque la última fila remata y las de arriba
siguen: .enredadera::before pinta todas menos la última y ::after pinta la
última con la espiral, en espejo si la fila es par (lo decide un :has()). Las
reglas por hoja pasan de tandas de seis a tandas de doce: las seis del espejo
son las mismas del revés y con el giro cambiado de signo.

Los tres brotes de REDES llevan su propio tallo, encadenado del agarre a la
primera hoja y de hoja en hoja, en vez de salir flotando. Para eso lo que se
coloca es la punta del rabillo -translate(-50%,0)- y no el centro de la hoja,
que va en vw y no dejaba que el ángulo fuera constante.

En ordenador el brote baja a la izquierda y ya no se sale por abajo de la
pantalla (se salía en seis de las siete medidas, hasta 41px a 1920x1080). En
móvil sale por la izquierda y trepa por el margen en vez de salir a la
derecha, donde asoman Eventos y Campañas: de 48 solapes en las nueve
pantallas a cero.

Banco: 0 fallos y 0 avisos en 150 mediciones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRjJnc4j5P7f5gCjkVmNY9
2026-09-14 20:38:40 +02:00
sito
8bcaf1644c Rediseño de la enredadera y páginas nuevas
Portada
- La bajada del título pasa de un trazo de grosor constante a dos cintas
  trenzadas que se afilan, con la punta rematada en zarcillo. El viewBox es
  1000x72 y la caja mide el 7.2% del ancho de la rejilla, así que la escala
  sale uniforme y el dibujo no se deforma en ninguna pantalla.
- El giro de la esquina es una Bézier cúbica de salida vertical y la franja
  entra también vertical: radio mínimo 26 unidades en vez de 15.
- La franja entera pasa de trazos a cintas. La cola deja de ser un lazo y
  abre un bucle afilado que remata una espiral logarítmica verde, con centro
  y sentido derivados de la tangente del tallo.
- El rabillo de la hoja pasa de 24 a 42 unidades (recuadro 100x132) para que
  el limbo no cruce la raíz. Con él, el origen del giro (1.52%) y la etiqueta
  (62%), que se miden sobre ese alto.
- Letra de las etiquetas un 10% mayor. "La Enre" a 164 grados y tamaño 1.16;
  "Calendario", a 1.00.

Móvil
- El tallo pasa de una recta a una onda de coseno, media vuelta por celda, en
  un recuadro cuadrado para que la escala sea uniforme. Tres dibujos: la onda,
  su espejo para las celdas pares y el remate en espiral de la última.
- Las seis hojas entran sin deslizar de 320x568 en adelante: la celda reparte
  el alto disponible en vez de medir lo que mide la hoja, --ancho-hoja se mide
  contra las dos dimensiones y la cabecera baja de 104 a 68px.
- El abanico de REDES se recoloca (centro -0.25r, apertura 0.50r) para caber
  en el hueco nuevo.

Páginas
- colectivos.html, calendario.html, eventos.html, tatus.html, panel.html y
  404.html.
- El calendario semanal esconde la semana entera vacía que dibujaba de más.

Comprobado con qa.py: 0 fallos y 0 avisos en 150 mediciones (10 páginas por
15 pantallas), con contraste, foco, brotes y HTML roto incluidos.
2026-09-09 00:34:53 +02:00
51 changed files with 8472 additions and 1377 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# copias de trabajo
*.bak
index.css.puntuacion0
# el binario compilado para el servidor
despliegue/enrecal

90
404.html Normal file
View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Esta hoja no existe · La Enredadera de Tetuán</title>
<meta name="description" content="La página que buscabas no está. Vuelve a la enredadera y sigue otra hoja.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Esta hoja no existe · La Enredadera de Tetuán">
<meta property="og:description" content="La página que buscabas no está. Vuelve a la enredadera y sigue otra hoja.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<div class="container">
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html" aria-current="page">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>Esta hoja no existe</h1>
<p class="sub">La rama que seguías se ha secado</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<article class="texto">
<h2>No está aquí</h2>
<p>La página que buscabas no existe. Puede que el enlace esté viejo o
que la hayamos movido de sitio.</p>
<p>Vuelve a <a href="/">la enredadera</a> y sigue otra hoja: ahí están
los <a href="/colectivos.html">colectivos</a>, el
<a href="/calendario.html">calendario</a> de cada semana y los
<a href="/eventos.html">eventos</a> que vienen.</p>
<p>Si has llegado desde un enlace de otra web y crees que deberíamos
saberlo, escríbenos a
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a>.</p>
</article>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>

BIN
INFO/cartel_tatus.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

484
README.md
View file

@ -0,0 +1,484 @@
# Web del CSO La Enredadera de Tetuán
Sitio estático: HTML y CSS. **No hay build, ni framework, ni dependencias.**
Se despliega copiando la carpeta y sirviéndola con cualquier servidor web.
Todo el JavaScript que hay son los dos calendarios, que venían de antes y
funcionan. La navegación, el menú, las fichas de colectivo y el brote de REDES
son CSS puro: funcionan con JavaScript desactivado. **Comprobado**, no
supuesto: se desactiva el JS por CDP y se mira. Los dos calendarios, que sí lo
necesitan, avisan de ello con un `<noscript>` y dicen dónde mirar.
---
## Ver la web en local
```sh
python3 -m http.server 8099 --bind 127.0.0.1
```
y abrir <http://127.0.0.1:8099/>. Tiene que servirse por HTTP: si abres el
fichero con `file://` fallan las rutas absolutas y el calendario de convoca.la.
---
## Los ficheros
| Fichero | Qué es |
|---|---|
| `index.html` | Portada: la enredadera con seis hojas |
| `colectivos.html` | Las doce hojas de colectivos: once con ficha y Tattoo Circus, que lleva a su micrositio |
| `calendario.html` | Horarios semanales de cada colectivo |
| `eventos.html` | Agenda de convoca.la |
| `subdir/campa.html` | Campañas |
| `subdir/sobre_nosotrxs.html` | Quiénes somos y la historia del espacio |
| `subdir/contacto.html` | Dónde estamos y cómo proponer algo |
| `tatus.html`, `panel.html` | Tattoo Circus: micrositio aparte, con su propio CSS |
| `404.html` | Página de error, con el mismo armazón que el resto |
| `index.css` | **Todo el estilo del sitio**, menos los dos del Tattoo Circus |
| `INFO/` | Imágenes. **12 de las 18 no se usan** (3,2 MB): ver pendientes |
| `favicon.svg`, `favicon.ico` | Icono: una hoja verde con los nervios en rosa |
| `Code462WPink-Regular.ttf` | La fuente de los títulos, servida desde aquí |
---
## Añadir una hoja a la enredadera
Copia un `<li>` entero y cambia el enlace y el texto. **Nada más.** La
posición, el ángulo y el tamaño salen solos de `:nth-child`, así que la hoja
nueva se coloca en el punto que le toca sin tocar el CSS.
```html
<li>
<a class="hoja" href="/donde-va.html">
<svg class="hoja-svg" viewBox="0 0 100 114" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Como se llama</span>
</a>
</li>
```
Variantes:
- `class="hoja brota"` — hoja más tenue, para un colectivo que existe pero
todavía no ha escrito su ficha.
- Para que abra una ficha en vez de ir a otra página, el enlace apunta a
`#ficha-loquesea` y añades el `<article class="ficha" id="ficha-loquesea">`
al final del documento, copiando uno de los que ya hay.
**Aviso:** los ciclos son de 6. Si añades una séptima hoja a la portada, la
raíz seguirá pasando por su sitio, pero repasa a ojo que no se pise con la
vecina — y pasa el banco de pruebas (más abajo).
---
## Los colores
Están todos en `:root`, al principio de `index.css`. **Para recolorear el
sitio entero solo hay que tocar ahí.**
| Variable | Para qué |
|---|---|
| `--verde`, `--verde-hondo`, `--verde-humo` | fondos |
| `--rosa` | contorno de las hojas y acentos. **Nunca para texto normal:** da 3.1:1 |
| `--rosa-legible` | el rosa para textos y enlaces: 6.3:1 |
| `--verde-vivo` | nervios y zarcillos. Es adorno: 1.7:1, no vale para texto |
| `--verde-hoja` | el verde legible, para subtítulos |
| `--tinta`, `--tinta-suave` | textos |
| `--marron`, `--negro` | las dos hebras de la raíz |
Los contrastes están medidos contra WCAG AA. Si cambias un color, mídelo: hay
un cálculo hecho en `../BITACORA.md` que puedes reutilizar.
---
## Las tres medidas de la enredadera
Están en `.enredadera`, y **son tres cosas distintas a propósito**. Antes eran
una sola variable y se estorbaban entre sí.
| Variable | Qué manda |
|---|---|
| `--fila` | la **separación**: alto de la franja. Más alta = hojas más apartadas y menos hueco abajo |
| `--hoja-base` | el **tamaño** de la hoja |
| `--col` | el ancho **real** de columna, que es el tope de la hoja |
Por qué separadas: estirar la franja para que llene la pantalla agrandaba
también las hojas y volvía a juntarlas. Ese fue el callejón de «`--fila` a
63vh», anotado en `../BITACORA.md`.
Y `--col` no es `15vw`: coinciden solo por debajo de 1680px. A partir de ahí
la rejilla deja de crecer (`max-width:1680px`) pero `15vw` sigue subiendo, así
que a 1920 las hojas se salían de su columna y se pisaban.
En móvil mandan otras dos, en `.enredadera-wrap`:
- **`--ancho-hoja`**, de la que salen a la vez el ancho de la hoja y el hueco
bajo la cabecera, porque las hojas de móvil giran hacia arriba y sobresalen
~0.96 de su ancho por encima de donde se agarran. Si se separan esas dos
medidas, la primera hoja se come el subtítulo. Va contra las **dos**
dimensiones —`min(28vw, 13.6vh)`—: sólo con `vw`, en una pantalla baja las
seis hojas no entran sin deslizar.
- **`--celda`**, el alto de cada `<li>`. Es **más corto que la hoja a
propósito**: las hojas alternan lado, así que se entrelazan como en una
enredadera de verdad. Reparte entre las seis lo que queda tras la cabecera,
el hueco de entrada y lo que la última asoma por abajo, así que la portada
entra sin deslizar de 320x568 para arriba.
El tallo de móvil son **tres** dibujos, no uno: la onda que empieza a la
derecha, su espejo y el remate con espiral de la última celda. La onda es
`x = 50 ± 28·cos(pi·y/100)` en un recuadro de 100x100 que se pinta cuadrado
—`background-size:var(--celda) 100%`—, así que la escala sale uniforme. En los
dos bordes la tangente es vertical: por eso dos celdas seguidas empalman sin
junta, y por eso los extremos caen justo donde se agarra la hoja. Si tocas la
amplitud, toca también el `--centrar` de las hojas, que vale `AM/100`.
---
## Cuidado con `--tam` dentro de una hoja
Las reglas por hoja son de la forma:
```css
.enredadera > li:nth-child(12n+4) .hoja{ --giro:154deg; --tam:0.92; }
```
Ese `.hoja` es un **descendiente**, así que alcanza también a las hojas que
haya dentro del `<li>` —los brotes de REDES— y con especificidad (0,3,1), que
le gana a cualquier `.una-clase`. Poner `--tam` en `.hoja-brote` no hacía
nada: los brotes salían con el `--tam` de REDES, al doble de tamaño, y se
comían a las hojas vecinas.
**Regla:** dentro de un `<li>` de la enredadera, no confíes en `--tam`. Si
necesitas un tamaño propio, escríbelo en la medida, sin `var(--tam)`.
---
## La raíz (los dibujos de la enredadera)
Los dibujos —la bajada desde el título, la franja de ordenador, su remate y el
tallo de móvil— **no están escritos a mano**: los genera un script, porque el
trenzado hay que calcularlo (la perpendicular a lo largo del recorrido).
```sh
cd ../prueba && python3 raiz2.py # regenera raiz2.json
```
Después hay que **reinyectar los SVG en el CSS**. Y lo más importante:
> Los puntos por donde pasa la raíz y las alturas de las que cuelgan las hojas
> (`--y` en las reglas `nth-child(12n+N)`) **tienen que coincidir**. Si mueves
> uno sin mover el otro, las hojas quedan flotando sin tocar la raíz.
Para eso está `../coherencia.py`: compara el CSS contra el JSON y avisa.
**Pásalo siempre después de tocar la raíz.**
### La franja de ordenador: dos filas, no una
La franja es la única parte que se **repite**, porque una página puede tener
más hojas de las que caben en una fila: colectivos tiene doce, o sea dos.
El dibujo lleva **dos filas**: la pasada de izquierda a derecha y, debajo, esa
misma pasada en **espejo** —un `<use>` del mismo grupo, así que no pesa el
doble—. Entra y sale **vertical** por las dos esquinas, de modo que empalma
con su espejo por la derecha y con la copia siguiente por la izquierda. Puesto
con `repeat-y` y `background-size:100% calc(var(--fila) * 2)`, la enredadera
serpentea por las filas que haga falta **sin una sola junta**. Antes el dibujo
era de una fila y remataba en espiral: en colectivos se veían dos ramas
sueltas, la segunda empezando otra vez desde el margen izquierdo.
De ahí salen tres cosas encadenadas:
- La tanda de las reglas por hoja es de **doce** (`nth-child(12n+N)`), no de
seis: las seis de la pasada y las seis del espejo, que son **las mismas
leídas del revés y con el giro cambiado de signo**. `../coherencia.py` lo
comprueba.
- El tallo va en **dos capas**, no en el fondo del `<ul>`: `.enredadera::before`
pinta todas las filas menos la última (`top:0; bottom:var(--fila)`) y
`.enredadera::after` pinta la última con el remate en espiral. Las dos con
`z-index:-1`, que es donde estaba el fondo al que sustituyen.
- Si la última fila es **par**, la pasada va de derecha a izquierda y el remate
se pinta en espejo. Lo decide un `:has()` sobre dónde cae el último `<li>`
dentro de la tanda de doce, así que añadir una hoja sigue sin tocar nada.
### La bajada del título
Es el dibujo más delicado de los tres, porque empalma con dos cosas a la vez:
el título por arriba y la franja por la esquina de abajo a la izquierda.
- Su viewBox es **1000x72** y la caja que lo pinta mide `--bajada`, o sea el
**7.2% de `--rejilla`**. Los dos números van juntos: si cambias uno, cambia
el otro, o el dibujo se deforma —`preserveAspectRatio` es `none`—.
- Las hebras no son `stroke` sino **cintas rellenas** de grosor variable
(`../prueba/cinta.py`), afiladas en la punta y del grosor de la franja —9 y
5.5— en el empalme. Si tocas el grosor de la franja, toca también ése.
- El recorrido llega **vertical** al margen izquierdo: es lo que evita el pico
contra la franja. No lo hagas terminar en diagonal.
- `--fila` descuenta `--bajada` por su nombre. No vuelvas a meterla en una
constante fija: la bajada mide en proporción al ancho, no en `vh`.
---
## El brote de REDES
Al pulsar REDES salen tres hojas pequeñas, una por red. **Cuelgan de un tallo
de verdad**, no flotan: cada `<li>` del brote lleva un `::before` que es su
trozo de tallo, y los tres se encadenan —el primero va hasta el agarre en la
enredadera, el segundo hasta el primero y el tercero hasta el segundo—, así que
lo que se dibuja es **un solo brote con tres hojas**, no tres hojas sueltas.
Dos cifras por hoja bastan, y son las mismas que la colocan leídas del revés:
```css
.brotes > li:nth-child(2){ --largo:0.605; --ang:262.4deg; }
```
- `--largo` va en unidades de `--r` y `--ang` es **absoluto**, porque el
`<li>` no gira (gira la hoja que lleva dentro).
- Por eso lo que se coloca es la **punta del rabillo** —`translate(-50%,0)`,
no `(-50%,-50%)`—: si se colocara el centro de la hoja, el punto donde
empieza el tallo dependería de lo que mida la hoja, que va en `vw`, y el
ángulo no podría ser una constante.
- Si mueves un nudo, recalcula las dos: `--largo` es la distancia al nudo
anterior y `--ang` = `atan2(dx, -dy)`.
En **ordenador** el brote sale hacia abajo y a la izquierda, al hueco que
dejan Eventos y Campañas. En **móvil** no cabe un abanico: el tallo es
vertical y las hojas ocupan los ~130px del centro, así que lo único libre son
las franjas de los lados —62px a 390, 86 a 360, 110 a 320—. Ahí el brote sale
por la **izquierda**, pasa por debajo de la hoja de REDES y **trepa** por el
margen. Antes salía a la derecha y se comía Eventos y Campañas.
---
## El banco de pruebas
```sh
cd ..
python3 coherencia.py # raíz y hojas cuadran
python3 qa.py --breve # las 10 páginas en 15 pantallas
python3 qa.py index.html # una sola, con detalle
```
Puntuación **0 = perfecto**. Mide, en cada página y cada tamaño de pantalla:
- desborde horizontal
- si la portada entra sin deslizar en ordenador
- hojas cortadas por los bordes
- hojas que se pisan entre sí
- etiquetas que se solapan
- **los brotes de REDES**: que no se salgan, no pisen a las hojas, no se
toquen entre ellos y no partan su etiqueta en dos líneas
- **contraste** de todos los textos contra su fondo real, con el umbral de
WCAG AA (4.5, o 3.0 si la letra es grande). Ojo: las fuentes usan `clamp()`
con `vw`, así que un titular puede ser «grande» en ordenador y dejar de
serlo en móvil — por eso se mide en las quince pantallas y no en una
- **contenido perdido** dentro de elementos con `overflow` oculto
- **foco fantasma**: enlaces que se tabulan pero no se ven
- espacio muerto en los bordes
- el menú desplegado: que se alcancen todos los enlaces
### El banco va por CDP, y no es un capricho
`chromium --dump-dom` y `--screenshot` **sujetan la ventana a un mínimo de
~500px**: si pides 320, maqueta a 500. La captura encima engaña, porque
reescala la imagen al ancho pedido y parece buena. Da igual `--headless`,
`--headless=new` o `--headless=old`.
Durante un tiempo el banco midió así, y **las cuatro pantallas de móvil (430,
390, 360 y 320) daban todas la medida de 500**: el 28% de cada barrido era
falso. Por eso `cdp.py` levanta chromium con `--remote-debugging-port` y fija
el viewport con `Emulation.setDeviceMetricsOverride`, que sí respeta cualquier
ancho. Hace falta `--remote-allow-origins=*` o el websocket devuelve 403.
**No vuelvas a medir con `--dump-dom`.** Si un día das con números idénticos
en varios anchos distintos, eso es la señal: el ancho no está cambiando.
Al medir cosas que se animan (los brotes, el menú) hay que **quitar la
transición antes**, o se mide a medio camino. Y `--r` y las demás variables
con `calc()` no se pueden leer con `getPropertyValue`: devuelven el texto. Se
miden creando un testigo con `width:var(--r)`.
### Recorrer el foco
```sh
python3 teclado.py # se usa desde otro script: ver el ejemplo al final
```
Pulsa Tab de verdad por CDP y anota cada parada. Mover `activeElement` con
`.focus()` no vale: no prueba el orden de tabulación ni lo que el navegador se
salta. Va aparte del banco porque cada parada es una ida y vuelta al
navegador y en las 98 mediciones sería eterno.
### Cuidado: la sonda se recorta
`SONDA_JS` no es el texto entero de la sonda: se **recorta** desde la línea
`var d=document.documentElement` hasta donde escribía su resultado. Todo lo
que se ponga fuera de ese trozo **desaparece sin avisar**. Pasó con el apagado
de animaciones: estuvo seis vueltas escrito y sin aplicarse. Al tocar la sonda,
comprobar que lo nuevo está de verdad:
```python
python3 -c "import qa; print('sinAnim' in qa.SONDA_JS)"
```
### La sonda apaga las animaciones
Lo primero que hace es meter `*{animation:none!important}`. La portada tiene
una entrada animada que acaba a los 0.8s y la sonda mide a los 1.1s: medir tan
cerca del final es pedir problemas. Como la animación termina en el estado
normal, apagarla deja justo lo que hay que medir.
### Cuidado al medir cosas que se abren
La barra lateral cierra con `transition: visibility 0s .32s`. Si un bloque de
la sonda la abre y la cierra, **todo lo que se mida después la ve todavía
abierta**. Ha mordido tres veces: al foco, a los brotes y al contraste. Por
eso el bloque del menú y el de los brotes quitan la transición mientras miden
y la reponen al salir. **Si añades una comprobación nueva, no la pongas
detrás de una que abra algo sin ese cuidado.**
Lo que el banco **no** ve, y hay que mirar a ojo:
- Las fichas de colectivo y los dos calendarios.
- **Los avisos de reserva.** El `.gancio-fallback` de `eventos.html` y el
`<noscript>` de `calendario.html` sólo se dibujan cuando el componente
externo no carga o no hay JavaScript. Cuando el banco mide, no están, así
que sus colores y tamaños **no se comprueban solos**. Ahí se coló un
contraste de 3.4:1. Para revisarlos hay que bloquear el script a mano:
```python
nav._envia("Network.enable")
nav._envia("Network.setBlockedURLs", {"urls": ["*rosa.frama.io*"]})
```
## Qué NO tocar
**El IIFE del calendario semanal** (`calendario.html`, dentro de `<script>`).
Lleva los horarios escritos a mano en `WEEKLY` y `COLORS`, y reglas especiales
—todos los viernes Hacklab salvo el segundo que es Art-Lab; Fridays el segundo
y el último viernes—. Para cambiar un horario se editan esos arrays, no el
resto del código.
Y hay un **contrato entre ese script y el CSS**: el script escribe
`style="--bg:...; --fg:..."` en cada píldora de color, y `.cal-pill` del CSS
las lee con `color-mix()`. Esas dos variables no se declaran en ninguna parte
del CSS —sólo se usan—, así que parecen sobrar y no sobran. Si se quitan las
reglas que las usan, las píldoras se quedan sin color.
**El web component de gancio** (`eventos.html`). Se carga desde
`rosa.frama.io` y trae la agenda de `mad.convoca.la`. El identificador del
lugar es `places="42"`, comprobado contra la API.
**No se puede recolorear.** Durante un tiempo el CSS le pasaba once variables
`--calendar-*` y aquí ponía que se tematizaba con ellas. Es falso: el
componente **las redefine dentro de su propio shadow DOM** —una tanda para
`.light` y otra para `.dark`— y esa definición gana a la que herede del host.
Comprobado: se le pasaba `--calendar-surface:#f5faef` y su `.calendar` seguía
calculando `#ffffff`. Tampoco expone ningún `::part`. Las once variables se
quitaron: no hacían nada.
Lo único que se puede hacer es **enmarcarlo desde fuera** (borde, radio,
sombra, fondo), que es lo que hay en la regla `gancio-calendar`, para que la
tarjeta blanca se lea como un panel puesto a propósito.
---
## Cosas pendientes de la asamblea
- **Orden de los colectivos.** Los cinco con la ficha sin escribir (`brota`)
van seguidos al final, así que la segunda fila entera sale desvaída.
Repartidos entre las dos filas se leería como "esta hoja está brotando"
y no como "la mitad de abajo está apagada". El orden lo decide la asamblea.
- **Hacklab**: el párrafo de su ficha lo escribí yo de forma provisional
porque la sección original no tenía ni una línea de texto. Conviene que lo
revise la gente del hacklab y lo ponga en sus palabras.
- **Bicilab**: el texto dice «domingos a las 17:00» y el calendario lo pinta a
las 19:00. Hay que decidir cuál es la hora buena.
- **«Asamblea ext.»** aparece en el calendario semanal pero **no tiene color
asignado en `COLORS`**, así que su píldora sale en blanco y no figura en la
leyenda. Las otras once etiquetas sí. O se le pone color, o se decide que va
neutra a propósito y entonces conviene explicarlo en la leyenda.
- En el calendario la etiqueta pone **«Fridays 🌱»** y en la leyenda
**«Fridays for Future»**. El color sí casa, pero los dos nombres deberían
ser el mismo para que la leyenda se entienda.
- **Ficha de Feminismos**: pone «el derecho a la libre **intervención** del
embarazo». La expresión habitual es «interrupción», y aquí el cambio de
palabra cambia el sentido. No lo he tocado porque el texto es suyo, pero
conviene que lo miren.
- **Ficha de Bicilab**: «Bocata de espaguetis todos los domingos a las 17:00
nos engrasamos las manos y recuperamos tu bicicleta!» son dos frases pegadas
sin puntuación, y a la exclamación le falta el «¡» de apertura. Tampoco lo
he tocado: es su voz.
- Dos botones apuntaban a PDF que no existen (`/archivos/biblioteca.pdf` y
`/archivos/asamblea.pdf`). Los quité y dejé el comentario en el HTML: cuando
subáis los ficheros, se reponen.
- Las campañas tenían seis botones que no llevaban a ninguna parte. Quitados,
con nota en el HTML de lo que prometía cada uno.
---
## La página de error
`404.html` existe, pero **el servidor tiene que saber usarla**. Con nginx:
```nginx
error_page 404 /404.html;
```
Sin esa línea, un enlace roto enseña el error por defecto del servidor y la
página no se usa nunca.
---
## Al compartir el enlace
Todas las páginas llevan `og:title`, `og:description` y `og:image` para que
Mastodon, Telegram o WhatsApp enseñen una vista previa decente. La imagen es
`INFO/cso_laenre.jpeg`, la foto de la fachada con el cartel pintado a mano.
**Una pega conocida:** la `og:image` va en **ruta relativa**, porque el
dominio propio del sitio no aparece escrito en ningún fichero —todos los
enlaces internos son relativos y las únicas URLs absolutas son de subdominios
(`hacklab.laenre.net`, `okupanel.laenre.net`)—. La mayoría de plataformas la
resuelven, pero **lo seguro es ponerla absoluta**. Cuando confirméis el
dominio, cambiad las ocho líneas `og:image` por la URL completa.
---
## Accesibilidad
Se ha cuidado y medido:
- Contrastes AA en todos los textos.
- Anillo de foco visible sobre cualquier fondo (en rosa era invisible sobre
las propias hojas).
- Nada oculto se puede tabular.
- Áreas de toque medidas en las ocho páginas: **todo lo pulsable llega a los
24px** que pide WCAG 2.5.8, y casi todo a 44. Lo que queda por debajo son
enlaces dentro de una frase, exentos por la norma.
Ojo con la píldora del calendario en móvil: se **ve** de 10px pero **mide**
24, gracias a un relleno vertical con `background-clip:content-box`. Necesita
`box-sizing:content-box`, o el `border-box` global se come la altura y la
barra desaparece.
- `:active` en todo lo pulsable: en un móvil no hay `:hover` y sin él el toque
no responde. `.tarjeta` queda fuera a propósito: es un `div` que no lleva a
ninguna parte.
- `color-scheme` declarado: `light` en el sitio, `dark` en el Tattoo Circus.
Sin eso, un navegador en tema oscuro pinta en oscuro lo que el CSS no
controla (barras de desplazamiento, controles, el lienzo inicial).
- Las imágenes de contenido llevan texto alternativo que **describe la
imagen**, no que repite el nombre del colectivo. Si un enlace es sólo una
imagen, el nombre del enlace va en un `aria-label` que dice **a dónde va**:
el `alt` describe el dibujo, y para un enlace eso no sirve.
- El logo de la barra lateral va con `alt=""`, decorativo a propósito: justo
debajo hay un `<h2>` que ya dice «La Enredadera», y con `alt` puesto, cuando
la imagen no cargaba, el texto se dibujaba **recortado** dentro de la caja de
48px.
- Se respeta `prefers-reduced-motion`, **y también el retardo**: la entrada
de la enredadera va escalonada, y anular sólo la duración dejaba las hojas
invisibles hasta 0.3s para luego aparecer de golpe.
Límite conocido: al ser CSS puro, **las fichas no se cierran con Escape** y el
contenido de detrás sigue siendo tabulable. Se cierran con la ✕, pulsando
fuera o con el botón atrás del navegador. Arreglarlo exigiría JavaScript.

275
calendario.html Normal file
View file

@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendario · La Enredadera de Tetuán</title>
<meta name="description" content="Horarios semanales de los colectivos del CSO La Enredadera de Tetuán.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Calendario · La Enredadera de Tetuán">
<meta property="og:description" content="Horarios semanales de los colectivos del CSO La Enredadera de Tetuán.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<div class="container">
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html" aria-current="page">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>CALENDARIO</h1>
<p class="sub">Qué pasa cada semana en la Enre</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<section class="pantalla" aria-label="Horarios semanales de colectivos">
<h2>Horarios semanales</h2>
<p class="pie">Las asambleas y talleres fijos de cada colectivo. Pulsa cualquier etiqueta para ver los eventos publicados en convoca.la.</p>
<!-- Sin JavaScript la rejilla se queda en un marco vacío, con dos flechas
que no hacen nada. Se esconden los tres trozos que dibuja el script y
se explica dónde está la información. No toca el IIFE. -->
<noscript>
<style>
#semanal-controls, #semanal-root, #semanal-legend-root{ display:none; }
</style>
<div class="gancio-fallback">
<p><strong>El horario se dibuja con JavaScript y lo tienes desactivado.</strong></p>
<p>Los horarios de cada colectivo están también en su ficha, en
<a href="/colectivos.html">Colectivos</a>, y los eventos concretos en
<a href="/eventos.html">Eventos</a>.</p>
</div>
</noscript>
<div class="cal-controls" id="semanal-controls" role="group" aria-label="Navegación de mes">
<button class="button semanal-prev" type="button" aria-label="Mes anterior">&larr;</button>
<div class="cal-title semanal-title" aria-live="polite"></div>
<button class="button semanal-next" type="button" aria-label="Mes siguiente">&rarr;</button>
</div>
<div class="cal" id="semanal-root"></div>
<div class="cal-legend" id="semanal-legend-root"></div>
<script>
(function () {
const monthNames = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const weekdayNames = ['L','M','X','J','V','S','D'];
const ENRE_EVENTS_URL = 'https://mad.convoca.la/place/42/CSO%20La%20Enredadera';
const COLORS = {
hacklab: { bg:'#39FF14', fg:'#000', label:'Hacklab' },
artlab: { bg:'#39FF14', fg:'#ff00aa', label:'Art-Lab' },
feminismos: { bg:'#6A0DAD', fg:'#fff', label:'Feminismos' },
vivienda: { bg:'#000000', fg:'#ff00aa', label:'Vivienda' },
bicilab: { bg:'#FF7A00', fg:'#000', label:'Bicilab' },
autodefensa: { bg:'#00BCD4', fg:'#fff', label:'Autodefensa' },
fridays: { bg:'#2E8B57', fg:'#fff', label:'Fridays for Future' },
euskera: { bg:'#FF0000', fg:'#00ff00', label:'Euskera' },
asamblea_enre: { bg:'#ff00ff', fg:'#fff', label:'Asamblea Enre' },
asamblea_externas: { bg:'#fff', fg:'#ff00ff', label:'Asamblea ext.' },
montana: { bg:'#8B4513', fg:'#fff', label:'Montaña' },
teatro: { bg:'#4169E1', fg:'#fff', label:'Teatro' }
};
const WEEKLY = [
{ group:'asamblea_externas', dow:0, start:'19:00' },
{ group:'asamblea_enre', dow:0, start:'20:00' },
{ group:'autodefensa', dow:1, start:'20:00' },
{ group:'teatro', dow:2, start:'11:00' },
{ group:'autodefensa', dow:2, start:'20:00' },
{ group:'euskera', dow:2, start:'18:15' },
{ group:'vivienda', dow:3, start:'19:00' },
{ group:'feminismos', dow:3, start:'20:00' },
{ group:'bicilab', dow:6, start:'19:00' },
{ group:'montana', dow:6, start:'10:00' }
];
function createPill(text, bg, fg) {
const pill = document.createElement('div');
pill.className = 'cal-pill';
pill.style.setProperty('--bg', bg);
pill.style.setProperty('--fg', fg);
pill.textContent = text;
pill.setAttribute('role', 'link');
pill.setAttribute('tabindex', '0');
pill.addEventListener('click', () => { window.location.href = ENRE_EVENTS_URL; });
pill.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.location.href = ENRE_EVENTS_URL; }
});
return pill;
}
function renderWeeklyPills(cell, wd) {
const todays = WEEKLY.filter(e => e.dow === wd);
if (!todays.length) return;
const wrap = document.createElement('div');
wrap.className = 'cal-pills';
todays.forEach(e => {
const c = COLORS[e.group] || { bg:'#eee', fg:'#000', label: e.group };
wrap.appendChild(createPill(c.label + (e.start ? ' ' + e.start : ''), c.bg, c.fg));
});
cell.appendChild(wrap);
}
const root = document.getElementById('semanal-root');
const legend = document.getElementById('semanal-legend-root');
const title = document.querySelector('.semanal-title');
const btnPrev = document.querySelector('.semanal-prev');
const btnNext = document.querySelector('.semanal-next');
const today = new Date();
let view = new Date();
view.setDate(1);
function buildCalendar(date) {
root.innerHTML = '';
const year = date.getFullYear();
const month = date.getMonth();
title.textContent = monthNames[month] + ' ' + year;
const startIndex = (new Date(year, month, 1).getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const fridays = [];
for (let d = 1; d <= daysInMonth; d++) {
if ((new Date(year, month, d).getDay() + 6) % 7 === 4) fridays.push(d);
}
const secondFri = fridays[1];
const lastFri = fridays[fridays.length - 1];
const head = document.createElement('div');
head.className = 'cal-row cal-head';
weekdayNames.forEach(w => {
const cell = document.createElement('div');
cell.className = 'cal-cell cal-head-cell';
cell.textContent = w;
head.appendChild(cell);
});
root.appendChild(head);
let day = 1 - startIndex;
for (let r = 0; r < 6; r++) {
const row = document.createElement('div');
row.className = 'cal-row';
for (let c = 0; c < 7; c++) {
const cell = document.createElement('div');
cell.className = 'cal-cell';
if (day >= 1 && day <= daysInMonth) {
if (year === today.getFullYear() && month === today.getMonth() && day === today.getDate())
cell.classList.add('cal-today');
const num = document.createElement('span');
num.className = 'cal-daynum';
num.textContent = day;
cell.appendChild(num);
const wd = (new Date(year, month, day).getDay() + 6) % 7;
if (wd === 4) {
const wrap = document.createElement('div');
wrap.className = 'cal-pills';
if (day === secondFri)
wrap.appendChild(createPill('Art-Lab', COLORS.artlab.bg, COLORS.artlab.fg));
else
wrap.appendChild(createPill('Hacklab', COLORS.hacklab.bg, COLORS.hacklab.fg));
if (day === secondFri || day === lastFri)
wrap.appendChild(createPill('Fridays 🌱', COLORS.fridays.bg, COLORS.fridays.fg));
cell.appendChild(wrap);
} else {
renderWeeklyPills(cell, wd);
}
} else {
cell.classList.add('cal-empty');
}
row.appendChild(cell);
day++;
}
root.appendChild(row);
}
}
function buildLegend() {
legend.innerHTML = '';
['hacklab','artlab','feminismos','vivienda','bicilab','autodefensa','fridays','euskera','asamblea_enre','montana','teatro']
.forEach(key => {
const c = COLORS[key];
if (!c) return;
const item = document.createElement('div');
item.className = 'cal-legend-item';
const dot = document.createElement('span');
dot.className = 'cal-legend-dot';
dot.style.background = c.bg;
if (c.bg === '#fff') dot.style.border = '1px solid #ff00aa';
const txt = document.createElement('span');
txt.textContent = c.label;
item.appendChild(dot);
item.appendChild(txt);
legend.appendChild(item);
});
}
btnPrev.addEventListener('click', () => { view.setMonth(view.getMonth() - 1); buildCalendar(view); });
btnNext.addEventListener('click', () => { view.setMonth(view.getMonth() + 1); buildCalendar(view); });
buildCalendar(view);
buildLegend();
})();
</script>
</section>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>

389
colectivos.html Normal file
View file

@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Colectivos · La Enredadera de Tetuán</title>
<meta name="description" content="Los colectivos que habitan el CSO La Enredadera de Tetuán: feminismos, vivienda, hacklab, bicilab, biblioteca libre y más.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Colectivos · La Enredadera de Tetuán">
<meta property="og:description" content="Los colectivos que habitan el CSO La Enredadera de Tetuán: feminismos, vivienda, hacklab, bicilab, biblioteca libre y más.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<svg class="defs" aria-hidden="true" focusable="false" width="0" height="0">
<symbol id="hoja-forma" viewBox="0 0 100 132">
<!-- Volteada: el rabillo queda arriba y la hoja cuelga. Así el punto por
el que se agarra a la raíz es la esquina superior del recuadro, y el
giro no depende de ningún porcentaje. -->
<g transform="translate(0,132) scale(1,-1)" fill="none" stroke-linejoin="round" stroke-linecap="round">
<!-- Rabillo, en marrón: es la parte leñosa que engancha con el tallo.
Mide 42 y no 24: con el corto, el limbo nacía pegado a la raíz y
la cruzaba. El recuadro crece con él (114 -> 132) para que la caja
siga midiendo lo que se dibuja; el limbo no cambia de tamaño. -->
<path stroke="var(--rabillo)" stroke-width="3.2" d="M50 88 L50 130"/>
<!-- Zarcillo verde. Sale solo en algunas hojas: el CSS enciende
--zarcillo en una de cada tres para que no salgan calcadas. -->
<path stroke="var(--nervio)" stroke-width="1.9" stroke-opacity="var(--zarcillo,0)"
d="M50 100 C38 98 32 90 36 84 C39 80 45 82 44 88 C43 94 36 96 30 92"/>
<!-- Contorno de hoja de hiedra, en rosa -->
<path stroke="var(--trazo)" stroke-width="2.7"
d="M50 5 C56 20 61 36 65 48 C76 44 89 40 96 45 C96 62 84 74 70 80 C62 84 56 83 50 88 C44 83 38 84 30 80 C16 74 4 62 4 45 C11 40 24 44 35 48 C39 36 44 20 50 5 Z"/>
<!-- Nervadura palmada, en verde: los cinco nervios salen del rabillo -->
<g stroke="var(--nervio)" stroke-width="1.5">
<path d="M50 86 L50 13"/>
<path d="M50 86 C59 72 78 56 90 48"/>
<path d="M50 86 C41 72 22 56 10 48"/>
<path d="M50 86 C57 84 65 81 71 77"/>
<path d="M50 86 C43 84 35 81 29 77"/>
</g>
</g>
</symbol>
</svg>
<div class="container">
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html" aria-current="page">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>COLECTIVOS</h1>
<p class="sub">Cada hoja, una asamblea. Pulsa para conocerla.</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<div class="enredadera-wrap">
<ul class="enredadera" id="hojas">
<li>
<a class="hoja" href="#ficha-feminismos">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Feminismos</span>
</a>
</li>
<li>
<a class="hoja" href="#ficha-vivienda">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Vivienda</span>
</a>
</li>
<li>
<a class="hoja" href="#ficha-fridays">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Fridays for Future</span>
</a>
</li>
<li>
<a class="hoja" href="#ficha-hacklab">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Hacklab</span>
</a>
</li>
<li>
<a class="hoja" href="#ficha-biblioteka">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Biblioteca Libre</span>
</a>
</li>
<li>
<a class="hoja" href="#ficha-bicilab">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Bicilab</span>
</a>
</li>
<li>
<a class="hoja" href="/tatus.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Tattoo Circus</span>
</a>
</li>
<!-- Colectivos que existen pero aún no han escrito su ficha.
Van con la clase "brota": hoja más tenue, pero visible. -->
<li>
<a class="hoja brota" href="#ficha-circo">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Circo</span>
</a>
</li>
<li>
<a class="hoja brota" href="#ficha-tienda-gratis">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Tienda Gratis</span>
</a>
</li>
<li>
<a class="hoja brota" href="#ficha-warawas">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Warawas</span>
</a>
</li>
<li>
<a class="hoja brota" href="#ficha-hongos">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Hongos</span>
</a>
</li>
<li>
<a class="hoja brota" href="#ficha-fermentos">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Fermentos</span>
</a>
</li>
</ul>
</div>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
<!-- ==============================================================
FICHAS. Cada una se abre con :target, sin JavaScript. El botón
atrás del navegador funciona y el enlace se puede compartir:
laenre.net/colectivos.html#ficha-feminismos
"Cerrar" apunta a #hojas, que deja de cumplirse el :target.
============================================================== -->
<article class="ficha" id="ficha-feminismos" aria-labelledby="t-feminismos">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-feminismos">Feminismos Tetuán</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<a href="https://www.instagram.com/feminismostetuan" target="_blank" rel="noopener" aria-label="Instagram de Feminismos Tetuán (abre en otra pestaña)">
<img src="/INFO/feminismos_tetuan.jpg" alt="Ilustración de dos manos entrelazadas sobre fondo lila, con «feminismos tetuán» escrito a mano" loading="lazy" width="1080" height="1080">
</a>
<p><strong>Feminismo Tetuán</strong> es una asamblea de barrio que nace a raíz del movimiento del <strong>8M</strong>.</p>
<p>Es una asamblea abierta no mixta transinclusiva. <strong>Nos reunimos los jueves a las 20</strong> en el CSO La Enredadera.</p>
<p>Uno de los temas que más trabajamos es el derecho a la libre intervención del embarazo, ya que en nuestro barrio se encuentra la clínica Dator, la primera clínica en realizar abortos en España.</p>
<ul class="enlaces">
<li><a href="https://linktr.ee/FeminismosTetuan" target="_blank" rel="noopener">LinkTree<span class="sr-only"> (abre en otra pestaña)</span></a></li>
<li><a href="https://www.instagram.com/feminismostetuan/" target="_blank" rel="noopener">Instagram<span class="sr-only"> (abre en otra pestaña)</span></a></li>
</ul>
</div>
</div>
</article>
<article class="ficha" id="ficha-vivienda" aria-labelledby="t-vivienda">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-vivienda">Colectivo de Vivienda</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<a href="https://www.instagram.com/organizacionviviendatetuan/" target="_blank" rel="noopener" aria-label="Instagram de la Organización de Vivienda de Tetuán (abre en otra pestaña)">
<img src="/INFO/ovt.jpg" alt="Logotipo en blanco y negro: la silueta de una casa con una llama en la chimenea y el texto «Organización de Vivienda de Tetuán»" loading="lazy" width="1080" height="1080">
</a>
<p>Asamblea abierta desde donde defendemos que la vivienda es para quien la habita y no un negocio.</p>
<p><strong>Nos reunimos los jueves a las 19</strong> en el CSO La Enredadera.</p>
<ul class="enlaces">
<li><a href="https://www.instagram.com/organizacionviviendatetuan/" target="_blank" rel="noopener">Instagram<span class="sr-only"> (abre en otra pestaña)</span></a></li>
</ul>
</div>
</div>
</article>
<article class="ficha" id="ficha-fridays" aria-labelledby="t-fridays">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-fridays">Fridays for Future</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<a href="https://linktr.ee/fridaysforfuturemadrid" target="_blank" rel="noopener" aria-label="Enlaces de Fridays for Future Madrid (abre en otra pestaña)">
<img src="/INFO/fridays_madrid.jpg" alt="Logotipo circular verde: un globo terráqueo dibujado a mano, rodeado por el texto «Fridays for Future Madrid»" loading="lazy" width="1080" height="1080">
</a>
<p>Somos parte del movimiento global juvenil por la Justicia Climática.</p>
<p><strong>Nos reunimos el segundo y el último viernes de cada mes</strong> en el CSO La Enredadera.</p>
<ul class="enlaces">
<li><a href="https://linktr.ee/fridaysforfuturemadrid" target="_blank" rel="noopener">LinkTree<span class="sr-only"> (abre en otra pestaña)</span></a></li>
<li><a href="https://www.instagram.com/fridaysformadrid/" target="_blank" rel="noopener">Instagram<span class="sr-only"> (abre en otra pestaña)</span></a></li>
</ul>
</div>
</div>
</article>
<article class="ficha" id="ficha-hacklab" aria-labelledby="t-hacklab">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-hacklab">Hacklab La Raíz</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<a href="https://hacklab.laenre.net" target="_blank" rel="noopener" aria-label="Web del Hacklab La Raíz (abre en otra pestaña)">
<img src="/INFO/hacklab.jpeg" alt="Ilustración de raíces de árbol sobre un circuito impreso verde oscuro con nodos luminosos, y el texto «Hacklab La Raíz»" loading="lazy" width="640" height="640">
</a>
<!-- PENDIENTE: escribir aquí el párrafo del Hacklab. La sección
original no tenía ni una línea de texto, solo las dos fotos. -->
<p>Tecnología libre, cacharreo y autodefensa digital en el CSO La Enredadera. <strong>Nos juntamos los viernes</strong>, salvo el segundo del mes, que es Art-Lab.</p>
<a href="https://hacklab.laenre.net/eventos.html" target="_blank" rel="noopener" aria-label="Eventos del Hacklab La Raíz (abre en otra pestaña)">
<img src="/INFO/teclado_negro_planta.jpg" alt="Primer plano de un teclado gris oscuro con brotes verdes creciendo entre las teclas" loading="lazy" width="474" height="474">
</a>
<ul class="enlaces">
<li><a href="https://hacklab.laenre.net" target="_blank" rel="noopener">hacklab.laenre.net<span class="sr-only"> (abre en otra pestaña)</span></a></li>
</ul>
</div>
</div>
</article>
<article class="ficha" id="ficha-biblioteka" aria-labelledby="t-biblioteka">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-biblioteka">Biblioteca Libre</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p>Accede a libros, fanzines y documentos sobre activismo, agroecología, tecnología libre y feminismos.</p>
<!-- El botón original apuntaba a /archivos/biblioteca.pdf, que no existe.
Cuando subáis el PDF, devolvedlo aquí como <ul class="enlaces">. -->
</div>
</div>
</article>
<article class="ficha" id="ficha-bicilab" aria-labelledby="t-bicilab">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-bicilab">Bicilab</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p>Bocata de espaguetis todos los domingos a las 17:00 nos engrasamos las manos y recuperamos tu bicicleta!</p>
<!-- OJO: el calendario semanal pone Bicilab el domingo a las 19:00 y este
texto dice 17:00. Hay que decidir cuál es la hora buena. -->
<!-- El botón original apuntaba a /archivos/asamblea.pdf, que no existe. -->
</div>
</div>
</article>
<article class="ficha" id="ficha-circo" aria-labelledby="t-circo">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-circo">Circo</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p class="aviso">Esta hoja todavía está brotando. El colectivo existe y se reúne en la Enre, pero aún no ha escrito su presentación. Si eres parte, pásate por la asamblea de los lunes y la publicamos.</p>
</div>
</div>
</article>
<article class="ficha" id="ficha-tienda-gratis" aria-labelledby="t-tienda">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-tienda">Tienda Gratis</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p class="aviso">Esta hoja todavía está brotando. El colectivo existe y se reúne en la Enre, pero aún no ha escrito su presentación. Si eres parte, pásate por la asamblea de los lunes y la publicamos.</p>
</div>
</div>
</article>
<article class="ficha" id="ficha-warawas" aria-labelledby="t-warawas">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-warawas">Warawas</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p class="aviso">Esta hoja todavía está brotando. El colectivo existe y se reúne en la Enre, pero aún no ha escrito su presentación. Si eres parte, pásate por la asamblea de los lunes y la publicamos.</p>
</div>
</div>
</article>
<article class="ficha" id="ficha-hongos" aria-labelledby="t-hongos">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-hongos">Hongos</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p class="aviso">Esta hoja todavía está brotando. El colectivo existe y se reúne en la Enre, pero aún no ha escrito su presentación. Si eres parte, pásate por la asamblea de los lunes y la publicamos.</p>
</div>
</div>
</article>
<article class="ficha" id="ficha-fermentos" aria-labelledby="t-fermentos">
<a class="ficha-fondo" href="#hojas" aria-label="Cerrar la ficha" tabindex="-1"></a>
<div class="ficha-caja">
<div class="ficha-cab">
<h2 id="t-fermentos">Fermentos</h2>
<a class="ficha-cerrar" href="#hojas" aria-label="Cerrar">×</a>
</div>
<div class="ficha-cuerpo">
<p class="aviso">Esta hoja todavía está brotando. El colectivo existe y se reúne en la Enre, pero aún no ha escrito su presentación. Si eres parte, pásate por la asamblea de los lunes y la publicamos.</p>
</div>
</div>
</article>
</body>
</html>

69
despliegue/README.md Normal file
View file

@ -0,0 +1,69 @@
# Despliegue de /pedir/
El gestor de reservas es un programa en Go que escucha en `127.0.0.1:8090`.
nginx le pasa `/pedir/` y sigue sirviendo el resto de la web como hasta ahora.
| Qué | Dónde |
|---|---|
| programa y plantillas | `/opt/enrecal/` |
| configuración | `/etc/enrecal/config.json` |
| datos (registro, solo crece) | `/var/lib/enrecal/registro.jsonl` |
| accesos, para fail2ban | `/var/log/enrecal/auth.log` |
| servicio | `enrecal.service`, usuario `enrecal` |
## Primera vez
En el servidor, en la carpeta de la web (la que ya es un clon del repo):
git fetch && git checkout pedir-evento
sudo ./despliegue/instalar.sh
Después, dos cosas a mano:
1. Añadir esta línea dentro del `server{}` de www.laenre.net y recargar:
include /etc/nginx/snippets/enrecal.conf;
sudo nginx -t && sudo systemctl reload nginx
El fragmento también deja de servir `.git`, `gestor/`, `despliegue/`,
`README.md`, `context.txt` e `info.txt`, que ahora mismo se pueden
descargar desde la web.
2. Crear las cuentas. `cuenta.sh` para el servicio, crea la cuenta y lo
vuelve a arrancar:
sudo ./despliegue/cuenta.sh -usuario asamblea -nombre "Asamblea de La Enre" -rol enre -clave 'LA-CLAVE' -definitiva cuenta
sudo ./despliegue/cuenta.sh -usuario externas -nombre "Asamblea de externas" -rol externas cuenta
sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta
Sin `-clave`, se genera una al azar y se imprime: se entrega en mano y se
cambia al entrar. Con `-clave X` sin `-definitiva`, también se pide
cambiarla al entrar. Ninguna clave va en el repositorio.
Comprobar: `https://www.laenre.net/pedir/` carga y `https://www.laenre.net/.git/HEAD`
da 403.
## Actualizar
git pull
sudo ./despliegue/instalar.sh
No toca la configuración ni los datos.
## Si el servidor no tiene Go
`instalar.sh` compila si encuentra `go`. Si no, se compila en otra máquina y se
copia el binario a `despliegue/enrecal` antes de lanzar el instalador:
cd gestor && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false -o ../despliegue/enrecal .
scp ../despliegue/enrecal servidor:/ruta/de/la/web/despliegue/
## Día a día
- Otra clave para una cuenta: `sudo ./despliegue/cuenta.sh -usuario X clave`
- Registro del servicio: `journalctl -u enrecal`
- Copia de los datos: basta con copiar `/var/lib/enrecal/registro.jsonl`,
también con el servicio en marcha, porque solo se escribe al final.
- La configuración (cupo, ventana, qué finde es tranqui o con ruido) se cambia
en `/etc/enrecal/config.json` y se aplica con `sudo systemctl restart enrecal`.

View file

@ -0,0 +1,19 @@
{
"escucha": "127.0.0.1:8090",
"base_url": "https://www.laenre.net",
"dir_datos": "/var/lib/enrecal",
"dir_plantillas": "/opt/enrecal/plantillas",
"log_auth": "/var/log/enrecal/auth.log",
"cookie_segura": true,
"local": false,
"meses_vista": 4,
"reglas": {
"meses_ventana": 3,
"cupo_dias": 3,
"dias_minimo_confirmar": 7,
"dias_maximo_pendiente": 30,
"dias_reservables": [5, 6, 7],
"ancla_finde": "2026-09-25",
"turno_ancla": "ruido"
}
}

23
despliegue/cuenta.sh Executable file
View file

@ -0,0 +1,23 @@
#!/bin/sh
# Da de alta una cuenta o le cambia la clave, con el servicio parado.
#
# El gestor lee el registro al arrancar, así que una cuenta creada con el
# servicio en marcha no se vería hasta reiniciarlo. Este script lo para, hace
# la operación como el usuario enrecal (para que el registro no cambie de dueño)
# y lo vuelve a arrancar.
#
# sudo ./despliegue/cuenta.sh -usuario hacklab -nombre "Hacklab La Raíz" cuenta
# sudo ./despliegue/cuenta.sh -usuario hacklab clave
#
# Opciones: -rol enre|interno|externas, -clave X (si no, se genera una al
# azar), -definitiva (la clave no se pide cambiar al entrar).
set -eu
if [ "$(id -u)" -ne 0 ]; then
echo "Hay que lanzarlo con sudo." >&2
exit 1
fi
systemctl stop enrecal
trap 'systemctl start enrecal' EXIT
runuser -u enrecal -- /opt/enrecal/enrecal -config /etc/enrecal/config.json "$@"

View file

@ -0,0 +1,44 @@
[Unit]
Description=Gestor de reservas de La Enredadera (/pedir/)
After=network.target
[Service]
Type=simple
User=enrecal
Group=enrecal
ExecStart=/opt/enrecal/enrecal -config /etc/enrecal/config.json servir
Restart=on-failure
RestartSec=5
# Solo puede escribir en sus datos y en su registro de accesos.
ProtectSystem=strict
ReadWritePaths=/var/lib/enrecal /var/log/enrecal
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
UMask=0027
# Tope de memoria: el gestor limita argon2 a dos cálculos a la vez (unos
# 130 MB), así que 512 MB sobran y protegen al resto de servicios de la máquina.
MemoryMax=512M
[Install]
WantedBy=multi-user.target

77
despliegue/instalar.sh Executable file
View file

@ -0,0 +1,77 @@
#!/bin/sh
# Instala o actualiza el gestor de /pedir/ en el servidor.
#
# sudo ./despliegue/instalar.sh
#
# Se lanza desde la raíz del repositorio. Se puede repetir: la segunda vez solo
# actualiza el programa y las plantillas, y no toca ni la configuración ni los
# datos.
set -eu
RAIZ=$(cd "$(dirname "$0")/.." && pwd)
PROG=/opt/enrecal
DATOS=/var/lib/enrecal
LOGS=/var/log/enrecal
CONF=/etc/enrecal
if [ "$(id -u)" -ne 0 ]; then
echo "Hay que lanzarlo con sudo." >&2
exit 1
fi
# 1. El programa: se compila aquí si hay Go, o se usa el que se haya copiado a
# despliegue/enrecal (ver README.md).
if command -v go >/dev/null 2>&1; then
echo "Compilando..."
(cd "$RAIZ/gestor" && CGO_ENABLED=0 go build -trimpath -buildvcs=false -o "$RAIZ/despliegue/enrecal" .)
elif [ ! -x "$RAIZ/despliegue/enrecal" ]; then
echo "No hay Go en esta máquina ni un despliegue/enrecal ya compilado." >&2
echo "Compílalo en otra máquina y cópialo (ver despliegue/README.md)." >&2
exit 1
fi
# 2. Usuario propio, sin shell ni carpeta personal.
if ! id enrecal >/dev/null 2>&1; then
useradd --system --no-create-home --shell /usr/sbin/nologin enrecal
fi
# 3. Carpetas. El programa y las plantillas fuera de la web pública.
install -d -m 0755 "$PROG" "$PROG/plantillas"
install -d -m 0750 -o enrecal -g enrecal "$DATOS" "$LOGS"
install -d -m 0755 "$CONF"
install -m 0755 "$RAIZ/despliegue/enrecal" "$PROG/enrecal"
rm -f "$PROG"/plantillas/*.tmpl.html
install -m 0644 "$RAIZ"/gestor/plantillas/*.tmpl.html "$PROG/plantillas/"
if [ ! -f "$CONF/config.json" ]; then
install -m 0644 "$RAIZ/despliegue/config.ejemplo.json" "$CONF/config.json"
echo "Configuración nueva en $CONF/config.json"
fi
# 4. nginx: el límite de intentos va en http{} (conf.d) y el resto en un
# fragmento que se incluye con una línea dentro del server{} de la web.
if [ -d /etc/nginx ]; then
install -d -m 0755 /etc/nginx/conf.d /etc/nginx/snippets
install -m 0644 "$RAIZ/despliegue/nginx-zona.conf" /etc/nginx/conf.d/enrecal-zona.conf
install -m 0644 "$RAIZ/despliegue/nginx-laenre.conf" /etc/nginx/snippets/enrecal.conf
fi
# 5. systemd.
install -m 0644 "$RAIZ/despliegue/enrecal.service" /etc/systemd/system/enrecal.service
systemctl daemon-reload
systemctl enable enrecal >/dev/null 2>&1
systemctl restart enrecal
sleep 1
systemctl --no-pager --lines=3 status enrecal || true
cat <<'FIN'
Listo. Si es la primera vez, faltan dos cosas:
1. En el server{} de www.laenre.net, añadir esta línea y recargar nginx:
include /etc/nginx/snippets/enrecal.conf;
sudo nginx -t && sudo systemctl reload nginx
2. Crear las cuentas con despliegue/cuenta.sh (ver despliegue/README.md).
FIN

View file

@ -0,0 +1,28 @@
# Se incluye dentro del server{} de www.laenre.net:
# include /etc/nginx/snippets/enrecal.conf;
# La web se despliega con git, así que en la carpeta pública están también
# .git, el código del gestor y las notas internas. Nada de eso se sirve.
location ~ /\.(?!well-known) { deny all; }
location ~ ^/(gestor|despliegue)/ { deny all; }
location ~ ^/(README\.md|context\.txt|info\.txt)$ { deny all; }
location /pedir/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
# Imprescindible pisar las dos: si no, cualquiera manda su propia
# X-Real-IP y se salta el bloqueo por IP del gestor.
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 16k;
}
location = /pedir/entrar {
limit_req zone=enrecal burst=5 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 16k;
}

View file

@ -0,0 +1,3 @@
# Va dentro de http{}: en Debian, /etc/nginx/conf.d/ ya se incluye ahí.
# Diez intentos de acceso por minuto y por IP en /pedir/entrar.
limit_req_zone $binary_remote_addr zone=enrecal:1m rate=10r/m;

103
eventos.html Normal file
View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eventos · La Enredadera de Tetuán</title>
<meta name="description" content="Agenda de eventos del CSO La Enredadera de Tetuán, publicada en mad.convoca.la.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Eventos · La Enredadera de Tetuán">
<meta property="og:description" content="Agenda de eventos del CSO La Enredadera de Tetuán, publicada en mad.convoca.la.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
<!-- Web component gancio-calendar (CSO La Rosa) https://framagit.org/rosa/gancio-calendar -->
<script type="module" src="https://rosa.frama.io/gancio-calendar/gancio-calendar.js"></script>
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<div class="container">
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html" aria-current="page">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>EVENTOS</h1>
<p class="sub">Lo que viene: conciertos, charlas, talleres y kafetas</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<section class="pantalla" aria-label="Calendario de eventos">
<h2>Agenda</h2>
<p class="pie">Se alimenta sola desde <a href="https://mad.convoca.la/place/CSO%20La%20Enredadera" target="_blank" rel="noopener">mad.convoca.la<span class="sr-only"> (abre en otra pestaña)</span></a>, la agenda activista de Madrid. Para publicar aquí, sigue la <a href="https://convoca.la/mad/guias/publicar_evento" target="_blank" rel="noopener">guía de convoca.la<span class="sr-only"> (abre en otra pestaña)</span></a>.</p>
<!--
places="42" es el identificador de CSO La Enredadera en mad.convoca.la.
Comprobado contra https://mad.convoca.la/api/events?places=42
-->
<gancio-calendar
baseurl="https://mad.convoca.la"
title=""
instance_name="mad.convoca.la"
instance_description="Agenda activista y cultural de Tetuán"
locale="es-ES"
places="42"
accent_color="#ff00aa"
theme="light">
<div class="gancio-fallback">
<p><strong>La agenda no ha cargado.</strong></p>
<p>Se sirve desde convoca.la, así que necesita conexión. Puedes verla directamente en
<a href="https://mad.convoca.la/place/CSO%20La%20Enredadera" target="_blank" rel="noopener">mad.convoca.la<span class="sr-only"> (abre en otra pestaña)</span></a>.</p>
</div>
</gancio-calendar>
</section>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

12
favicon.svg Normal file
View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 128" width="128" height="128">
<rect width="100" height="128" fill="#eef4e5"/>
<path d="M50 6 C 76 30, 92 66, 84 94 C 76 118, 60 126, 50 126 C 40 126, 24 118, 16 94 C 8 66, 24 30, 50 6 Z"
fill="#7fb069" stroke="#4f7f3c" stroke-width="5" stroke-linejoin="round"/>
<g fill="none" stroke="#ff00aa" stroke-width="4" stroke-linecap="round">
<path d="M50 14 L50 122"/>
<path d="M50 46 C 40 51, 32 60, 26 71"/>
<path d="M50 46 C 60 51, 68 60, 74 71"/>
<path d="M50 78 C 41 82, 34 90, 29 100"/>
<path d="M50 78 C 59 82, 66 90, 71 100"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 634 B

BIN
fuentes/anton-latin.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

238
gestor/almacen.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// Apunte es una linea del registro. El registro solo crece: cada cambio se
// escribe al final y nunca se reescribe nada. Al arrancar se relee entero para
// reconstruir el estado en memoria, asi que el fichero es a la vez la base de
// datos y el acta de la asamblea.
type Apunte struct {
Ts time.Time `json:"ts"`
Tipo string `json:"tipo"`
Por string `json:"por,omitempty"`
Cuenta *Cuenta `json:"cuenta,omitempty"`
Evento *Evento `json:"evento,omitempty"`
Excepcion *Excepcion `json:"excepcion,omitempty"`
}
const (
ApunteCuenta = "cuenta"
ApunteEvento = "evento"
ApunteExcepcion = "excepcion"
)
// Almacen aisla el resto del programa de como se guardan los datos. Hoy es un
// registro de texto; si algun dia hace falta SQL, se cambia solo esta pieza.
type Almacen interface {
Cuentas() []Cuenta
BuscarCuenta(usuario string) (Cuenta, bool)
GuardarCuenta(por string, c Cuenta) error
Eventos() []Evento
BuscarEvento(id string) (Evento, bool)
GuardarEvento(por string, e Evento) error
Excepciones() map[Dia]Turno
GuardarExcepcion(por string, x Excepcion) error
}
type Registro struct {
mu sync.RWMutex
f *os.File
ruta string
cuentas map[string]Cuenta
eventos map[string]Evento
excepciones map[Dia]Turno
}
func AbrirRegistro(dir string) (*Registro, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ruta := filepath.Join(dir, "registro.jsonl")
r := &Registro{
ruta: ruta,
cuentas: map[string]Cuenta{},
eventos: map[string]Evento{},
excepciones: map[Dia]Turno{},
}
if err := r.releer(); err != nil {
return nil, err
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
r.f = f
return r, nil
}
func (r *Registro) releer() error {
f, err := os.Open(r.ruta)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
n := 0
for sc.Scan() {
n++
linea := sc.Bytes()
if len(linea) == 0 {
continue
}
var a Apunte
if err := json.Unmarshal(linea, &a); err != nil {
return fmt.Errorf("%s linea %d: %w", r.ruta, n, err)
}
r.aplicar(a)
}
return sc.Err()
}
func (r *Registro) aplicar(a Apunte) {
switch a.Tipo {
case ApunteCuenta:
if a.Cuenta != nil {
r.cuentas[a.Cuenta.Usuario] = *a.Cuenta
}
case ApunteEvento:
if a.Evento == nil {
return
}
if _, err := a.Evento.Desde.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
if _, err := a.Evento.Hasta.Time(); err != nil {
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
return
}
r.eventos[a.Evento.ID] = *a.Evento
case ApunteExcepcion:
if a.Excepcion != nil {
if _, err := a.Excepcion.Viernes.Time(); err != nil {
log.Printf("excepcion de finde ignorada: %v", err)
return
}
if a.Excepcion.Turno == "" {
// Turno vacio significa volver a la alternancia automatica.
delete(r.excepciones, a.Excepcion.Viernes)
} else {
r.excepciones[a.Excepcion.Viernes] = a.Excepcion.Turno
}
}
}
}
// anotar escribe el apunte al final del fichero y lo sincroniza a disco antes
// de dar la operacion por buena. Con el candado ya tomado por quien llama.
func (r *Registro) anotar(a Apunte) error {
a.Ts = time.Now()
b, err := json.Marshal(a)
if err != nil {
return err
}
b = append(b, '\n')
if _, err := r.f.Write(b); err != nil {
return err
}
if err := r.f.Sync(); err != nil {
return err
}
r.aplicar(a)
return nil
}
func (r *Registro) Cerrar() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.f == nil {
return nil
}
return r.f.Close()
}
func (r *Registro) Cuentas() []Cuenta {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Cuenta, 0, len(r.cuentas))
for _, c := range r.cuentas {
out = append(out, c)
}
sort.Slice(out, func(i, j int) bool { return out[i].Usuario < out[j].Usuario })
return out
}
func (r *Registro) BuscarCuenta(usuario string) (Cuenta, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.cuentas[usuario]
return c, ok
}
func (r *Registro) GuardarCuenta(por string, c Cuenta) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteCuenta, Por: por, Cuenta: &c})
}
func (r *Registro) Eventos() []Evento {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Evento, 0, len(r.eventos))
for _, e := range r.eventos {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Desde != out[j].Desde {
return out[i].Desde < out[j].Desde
}
return out[i].ID < out[j].ID
})
return out
}
func (r *Registro) BuscarEvento(id string) (Evento, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.eventos[id]
return e, ok
}
func (r *Registro) GuardarEvento(por string, e Evento) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteEvento, Por: por, Evento: &e})
}
func (r *Registro) Excepciones() map[Dia]Turno {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[Dia]Turno, len(r.excepciones))
for k, v := range r.excepciones {
out[k] = v
}
return out
}
func (r *Registro) GuardarExcepcion(por string, x Excepcion) error {
r.mu.Lock()
defer r.mu.Unlock()
return r.anotar(Apunte{Tipo: ApunteExcepcion, Por: por, Excepcion: &x})
}

388
gestor/auth.go Normal file
View file

@ -0,0 +1,388 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"os"
"strings"
"sync"
"time"
"unicode"
"golang.org/x/crypto/argon2"
)
// Parametros de argon2id. 64 MiB por intento es suficiente para que probar
// claves a lo bruto salga caro y poco para el servidor, que atiende a treinta
// cuentas.
const (
argonMemoria = 64 * 1024
argonIteraciones = 3
argonHilos = 4
argonLargo = 32
argonSal = 16
)
// argonHuecos limita cuántos cálculos de argon2 corren a la vez. Cada uno
// reserva argonMemoria (64 MiB): sin este tope, sesenta intentos de acceso
// simultáneos llevaban el proceso a casi 4 GB y podían tumbar la máquina, que
// comparte con gitea y el resto de servicios de la Enre. Los que llegan de más
// esperan su turno en vez de reservar memoria.
var argonHuecos = make(chan struct{}, 2)
func argonID(clave, sal []byte, t, m uint32, p uint8, largo uint32) []byte {
argonHuecos <- struct{}{}
defer func() { <-argonHuecos }()
return argon2.IDKey(clave, sal, t, m, p, largo)
}
func aleatorio(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("sin fuente de aleatoriedad: " + err.Error())
}
return b
}
func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
// HashClave devuelve la clave cifrada en el formato codificado de argon2, que
// lleva dentro los parametros con los que se genero.
func HashClave(clave string) string {
sal := aleatorio(argonSal)
h := argonID([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
base64.RawStdEncoding.EncodeToString(sal),
base64.RawStdEncoding.EncodeToString(h))
}
// hashInutil se usa para gastar el mismo tiempo cuando la cuenta no existe, de
// forma que no se pueda averiguar que usuarios hay midiendo la respuesta.
var hashInutil = HashClave("cuenta que no existe")
func VerificarClave(codificado, clave string) bool {
partes := strings.Split(codificado, "$")
if len(partes) != 6 || partes[1] != "argon2id" {
return false
}
var version, memoria, iteraciones int
var hilos int
if _, err := fmt.Sscanf(partes[2], "v=%d", &version); err != nil {
return false
}
if _, err := fmt.Sscanf(partes[3], "m=%d,t=%d,p=%d", &memoria, &iteraciones, &hilos); err != nil {
return false
}
sal, err := base64.RawStdEncoding.DecodeString(partes[4])
if err != nil {
return false
}
esperado, err := base64.RawStdEncoding.DecodeString(partes[5])
if err != nil {
return false
}
// Los parámetros salen del propio hash, que escribimos nosotros; aun así se
// acotan, para que un registro manipulado no pida gigas de memoria.
if memoria <= 0 || memoria > argonMemoria || iteraciones <= 0 || iteraciones > 10 ||
hilos <= 0 || hilos > 16 || len(esperado) == 0 || len(esperado) > 64 {
return false
}
calculado := argonID([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
uint8(hilos), uint32(len(esperado)))
return subtle.ConstantTimeCompare(calculado, esperado) == 1
}
// ValidarClave es la politica de contrasenas: larga antes que retorcida, que es
// lo que de verdad aguanta un ataque por diccionario.
func ValidarClave(c string) error {
if len([]rune(c)) < 12 {
return fmt.Errorf("la contraseña tiene que tener al menos 12 caracteres")
}
if len(c) > 256 {
return fmt.Errorf("la contraseña no puede pasar de 256 caracteres")
}
distintos := map[rune]bool{}
for _, r := range c {
distintos[r] = true
}
if len(distintos) < 5 {
return fmt.Errorf("la contraseña repite demasiado los mismos caracteres")
}
return nil
}
// ClaveTemporal genera la clave de un solo uso que se entrega en mano al dar de
// alta un colectivo. Sin caracteres que se confundan al copiarla a papel.
func ClaveTemporal() string {
const alfabeto = "abcdefghjkmnpqrstuvwxyz23456789"
b := aleatorio(20)
var sb strings.Builder
for i, v := range b {
if i > 0 && i%5 == 0 {
sb.WriteByte('-')
}
sb.WriteByte(alfabeto[int(v)%len(alfabeto)])
}
return sb.String()
}
func ValidarUsuario(u string) error {
if len(u) < 3 || len(u) > 32 {
return fmt.Errorf("el usuario tiene entre 3 y 32 caracteres")
}
for _, r := range u {
if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
return fmt.Errorf("el usuario solo admite minúsculas, números, guion y guion bajo")
}
}
return nil
}
// Sesiones viven solo en memoria: se pueden revocar al instante y un reinicio
// del servicio cierra todas las sesiones abiertas, que es lo que interesa.
type Sesiones struct {
mu sync.Mutex
m map[string]*Sesion
maxInactivo time.Duration
}
func NuevasSesiones(maxInactivo time.Duration) *Sesiones {
return &Sesiones{m: map[string]*Sesion{}, maxInactivo: maxInactivo}
}
func (s *Sesiones) Crear(usuario string) *Sesion {
s.mu.Lock()
defer s.mu.Unlock()
ses := &Sesion{
ID: hex.EncodeToString(aleatorio(32)),
Usuario: usuario,
CSRF: hex.EncodeToString(aleatorio(32)),
Creada: time.Now(),
Vista: time.Now(),
}
s.m[ses.ID] = ses
return ses
}
func (s *Sesiones) Buscar(id string) (*Sesion, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return nil, false
}
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
return nil, false
}
ses.Vista = time.Now()
return ses, true
}
// PonerClave guarda la clave temporal recien generada para ensenarla una sola
// vez en la siguiente pagina, sin que pase por la URL.
func (s *Sesiones) PonerClave(id, usuario, clave string) {
s.mu.Lock()
defer s.mu.Unlock()
if ses, ok := s.m[id]; ok {
ses.ClaveDe, ses.ClaveNueva = usuario, clave
}
}
// TomarClave la devuelve y la borra: no se vuelve a mostrar.
func (s *Sesiones) TomarClave(id string) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return "", ""
}
usuario, clave := ses.ClaveDe, ses.ClaveNueva
ses.ClaveDe, ses.ClaveNueva = "", ""
return usuario, clave
}
func (s *Sesiones) PonerAviso(id, aviso, fallo string) {
s.mu.Lock()
defer s.mu.Unlock()
if ses, ok := s.m[id]; ok {
ses.Aviso, ses.Error = aviso, fallo
}
}
// TomarAviso devuelve el aviso pendiente y lo borra: se enseña una vez.
func (s *Sesiones) TomarAviso(id string) (string, string) {
s.mu.Lock()
defer s.mu.Unlock()
ses, ok := s.m[id]
if !ok {
return "", ""
}
aviso, fallo := ses.Aviso, ses.Error
ses.Aviso, ses.Error = "", ""
return aviso, fallo
}
func (s *Sesiones) Borrar(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, id)
}
// BorrarDeUsuario cierra todas las sesiones de una cuenta: al cambiarle la
// clave o al desactivarla.
func (s *Sesiones) BorrarDeUsuario(usuario string) {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if ses.Usuario == usuario {
delete(s.m, id)
}
}
}
func (s *Sesiones) Limpiar() {
s.mu.Lock()
defer s.mu.Unlock()
for id, ses := range s.m {
if time.Since(ses.Vista) > s.maxInactivo {
delete(s.m, id)
}
}
}
// Cerrojo cuenta intentos fallidos por IP y por cuenta. Es la primera barrera,
// dentro de la propia aplicacion; fail2ban es la segunda, en el cortafuegos.
type Cerrojo struct {
mu sync.Mutex
fallos map[string][]time.Time
maxIntentos int
ventana time.Duration
espera time.Duration
}
func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
return &Cerrojo{
fallos: map[string][]time.Time{},
maxIntentos: maxIntentos,
ventana: ventana,
espera: espera,
}
}
// Intentar comprueba y apunta el intento en la misma operación, antes de
// calcular la clave. Si se apuntara después, como hacía Fallo, cien intentos
// simultáneos pasaban todos la comprobación antes de que se apuntara el
// primero.
func (c *Cerrojo) Intentar(clave string) (bool, time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
recientes := c.podar(clave, ahora)
if len(recientes) >= c.maxIntentos {
if falta := c.espera - ahora.Sub(recientes[len(recientes)-1]); falta > 0 {
return false, falta
}
}
c.fallos[clave] = append(recientes, ahora)
return true, 0
}
func (c *Cerrojo) Limpiar(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
for _, k := range claves {
delete(c.fallos, k)
}
}
func (c *Cerrojo) podar(k string, ahora time.Time) []time.Time {
var out []time.Time
for _, t := range c.fallos[k] {
if ahora.Sub(t) <= c.ventana {
out = append(out, t)
}
}
if len(out) == 0 {
delete(c.fallos, k)
} else {
c.fallos[k] = out
}
return out
}
// AuthLog escribe el registro que lee fail2ban. Formato fijo y de una sola
// linea por intento.
type AuthLog struct {
mu sync.Mutex
f *os.File
}
func AbrirAuthLog(ruta string) (*AuthLog, error) {
if ruta == "" {
return &AuthLog{}, nil
}
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
return &AuthLog{f: f}, nil
}
// limpiarCampo evita que un usuario inventado meta saltos de linea o espacios
// en el log: si no, se podria falsificar una linea AUTH_FAIL con la IP de otro
// y hacer que fail2ban banee a quien no toca.
func limpiarCampo(s string) string {
if s == "" {
return "-"
}
var sb strings.Builder
for _, r := range s {
if r > unicode.MaxASCII || unicode.IsSpace(r) || !unicode.IsPrint(r) {
sb.WriteByte('.')
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
sb.WriteRune(r)
} else {
sb.WriteByte('.')
}
if sb.Len() >= 32 {
break
}
}
if sb.Len() == 0 {
return "-"
}
return sb.String()
}
func (l *AuthLog) Escribir(evento, ip, usuario, motivo string) {
linea := fmt.Sprintf("%s %s ip=%s usuario=%s motivo=%s\n",
time.Now().Format(time.RFC3339), evento,
limpiarCampo(ip), limpiarCampo(usuario), limpiarCampo(motivo))
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.WriteString(linea)
return
}
os.Stderr.WriteString(linea)
}
// iguales compara dos cadenas sin filtrar por el tiempo cuanto coinciden.
func iguales(a, b string) bool {
return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func (l *AuthLog) Cerrar() {
l.mu.Lock()
defer l.mu.Unlock()
if l.f != nil {
l.f.Close()
}
}

99
gestor/config.go Normal file
View file

@ -0,0 +1,99 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"time"
)
type Config struct {
// Red
Escucha string `json:"escucha"` // siempre local: nginx hace de puerta
BaseURL string `json:"base_url"` // https://www.laenre.net, para comprobar el Origen
// Rutas
DirDatos string `json:"dir_datos"` // registro.jsonl
DirPlantillas string `json:"dir_plantillas"` // plantillas de /pedir/
DirEstatico string `json:"dir_estatico"` // raiz de la web, solo en modo local
LogAuth string `json:"log_auth"` // el registro que lee fail2ban
// Comportamiento
CookieSegura bool `json:"cookie_segura"` // false solo para probar sin TLS
Local bool `json:"local"` // sirve tambien la web estatica
MesesVista int `json:"meses_vista"` // meses que pinta /pedir/
Reglas Reglas `json:"reglas"`
}
func ConfigPorDefecto() Config {
return Config{
Escucha: "127.0.0.1:8090",
BaseURL: "https://www.laenre.net",
DirDatos: "/var/lib/enrecal",
DirPlantillas: "/opt/enrecal/plantillas",
DirEstatico: "/var/www/laenre",
LogAuth: "/var/log/enrecal/auth.log",
CookieSegura: true,
MesesVista: 4,
Reglas: ReglasPorDefecto(),
}
}
func CargarConfig(ruta string) (Config, error) {
c := ConfigPorDefecto()
if ruta == "" {
return c, nil
}
b, err := os.ReadFile(ruta)
if err != nil {
return c, err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(&c); err != nil {
return c, fmt.Errorf("%s: %w", ruta, err)
}
return c, c.Validar()
}
func (c Config) Validar() error {
if c.Reglas.CupoDias < 1 {
return fmt.Errorf("cupo_dias tiene que ser 1 o mas")
}
if c.Reglas.MesesVentana < 1 {
return fmt.Errorf("meses_ventana tiene que ser 1 o mas")
}
if c.Reglas.DiasMinimoConfirmar < 0 {
return fmt.Errorf("dias_minimo_confirmar no puede ser negativo")
}
if c.Reglas.DiasMaximoPendiente < 1 {
return fmt.Errorf("dias_maximo_pendiente tiene que ser 1 o mas")
}
if len(c.Reglas.DiasReservables) == 0 {
return fmt.Errorf("dias_reservables no puede estar vacio")
}
if _, err := c.Reglas.AnclaFinde.Time(); err != nil {
return fmt.Errorf("ancla_finde: %w", err)
}
if c.Reglas.TurnoAncla != Tranqui && c.Reglas.TurnoAncla != Ruido {
return fmt.Errorf("turno_ancla solo puede ser %q o %q", Tranqui, Ruido)
}
if c.MesesVista < 1 {
return fmt.Errorf("meses_vista tiene que ser 1 o mas")
}
return nil
}
const (
inactividadSesion = 8 * time.Hour
maxIntentos = 5
// Por cuenta el tope es más alto que por IP: si fuera 5, cualquiera podría
// dejar a la asamblea sin entrar fallando 5 veces cada cuarto de hora. Con
// argon2 y claves de 12 caracteres, 20 intentos cada 10 minutos no bastan
// para adivinar nada.
maxIntentosCuenta = 20
ventanaIntentos = 10 * time.Minute
esperaBloqueo = 15 * time.Minute
)

7
gestor/go.mod Normal file
View file

@ -0,0 +1,7 @@
module laenre.net/enrecal
go 1.22
require golang.org/x/crypto v0.26.0
require golang.org/x/sys v0.26.0 // indirect

4
gestor/go.sum Normal file
View file

@ -0,0 +1,4 @@
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

363
gestor/main.go Normal file
View file

@ -0,0 +1,363 @@
// enrecal: reservas de fechas del CSO La Enredadera de Tetuán.
//
// Sirve la sección /pedir/ de laenre.net: el calendario de fechas pedidas y
// confirmadas, el formulario para pedirlas y el panel de la asamblea. Sin una
// línea de JavaScript en el navegador.
package main
import (
"context"
"errors"
"flag"
"fmt"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
)
type App struct {
cfg Config
alm Almacen
ses *Sesiones
cer *Cerrojo // por IP
cerU *Cerrojo // por cuenta
auth *AuthLog
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
// tienen que ser una sola operacion, o dos colectivos podrian colarse en la
// misma fecha a la vez.
mu sync.Mutex
plantillas *template.Template
}
func main() {
log.SetFlags(0)
log.SetPrefix("enrecal: ")
var (
rutaConfig = flag.String("config", "", "fichero de configuracion en JSON")
escucha = flag.String("escucha", "", "direccion donde escuchar, por ejemplo 127.0.0.1:8090")
dirDatos = flag.String("datos", "", "directorio del registro")
dirPlant = flag.String("plantillas", "", "directorio de plantillas")
dirEstat = flag.String("estatico", "", "raiz de la web, para el modo local")
logAuth = flag.String("logauth", "", "registro de accesos; \"-\" para sacarlo por stderr")
insegura = flag.Bool("insegura", false, "cookie sin marca Secure, solo para probar sin TLS")
local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx")
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas")
claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar")
definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)")
)
flag.Usage = uso
flag.Parse()
cfg, err := CargarConfig(*rutaConfig)
if err != nil {
log.Fatal(err)
}
for destino, valor := range map[*string]string{
&cfg.Escucha: *escucha, &cfg.DirDatos: *dirDatos,
&cfg.DirPlantillas: *dirPlant, &cfg.DirEstatico: *dirEstat,
} {
if valor != "" {
*destino = valor
}
}
switch *logAuth {
case "":
case "-":
cfg.LogAuth = "" // a stderr, para probar en local
default:
cfg.LogAuth = *logAuth
}
if *insegura {
cfg.CookieSegura = false
}
if *local {
cfg.Local = true
}
if err := cfg.Validar(); err != nil {
log.Fatal(err)
}
app, err := NuevaApp(cfg)
if err != nil {
log.Fatal(err)
}
defer app.Cerrar()
orden := flag.Arg(0)
if orden == "" {
orden = "servir"
}
switch orden {
case "servir":
if err := app.Servir(); err != nil {
log.Fatal(err)
}
case "cuenta":
if err := app.AltaCuenta(*usuario, *nombre, Rol(*rol), *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "clave":
if err := app.ResetClave(*usuario, *claveFija, *definitiva); err != nil {
log.Fatal(err)
}
case "caducar":
n, err := app.Caducar()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d solicitudes caducadas\n", n)
default:
uso()
os.Exit(2)
}
}
func uso() {
fmt.Fprint(os.Stderr, `uso: enrecal [opciones] <orden>
ordenes:
servir levanta la seccion /pedir/ (por defecto)
cuenta da de alta una cuenta e imprime su clave temporal (-clave para fijarla)
enrecal cuenta -usuario hacklab -nombre "Hacklab La Raiz" -rol interno
clave resetea la clave de una cuenta e imprime la nueva temporal
caducar pasa a caducadas las solicitudes que toque y sale
opciones:
`)
flag.PrintDefaults()
}
func NuevaApp(cfg Config) (*App, error) {
reg, err := AbrirRegistro(cfg.DirDatos)
if err != nil {
return nil, fmt.Errorf("abriendo el registro: %w", err)
}
al, err := AbrirAuthLog(cfg.LogAuth)
if err != nil {
return nil, fmt.Errorf("abriendo el registro de accesos: %w", err)
}
patron := filepath.Join(cfg.DirPlantillas, "*.tmpl.html")
plantillas, err := template.New("pedir").ParseGlob(patron)
if err != nil {
return nil, fmt.Errorf("plantillas (%s): %w", patron, err)
}
return &App{
cfg: cfg,
alm: reg,
ses: NuevasSesiones(inactividadSesion),
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
auth: al,
plantillas: plantillas,
}, nil
}
func (a *App) Cerrar() {
if r, ok := a.alm.(*Registro); ok {
r.Cerrar()
}
a.auth.Cerrar()
}
// nombres asocia cada usuario con el nombre visible de su colectivo.
func (a *App) nombres() map[string]string {
out := map[string]string{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Nombre
}
return out
}
func (a *App) roles() map[string]Rol {
out := map[string]Rol{}
for _, c := range a.alm.Cuentas() {
out[c.Usuario] = c.Rol
}
return out
}
func (a *App) vista() Vista {
return Vista{
roles: a.roles(),
reglas: a.cfg.Reglas,
exc: a.alm.Excepciones(),
eventos: a.alm.Eventos(),
nombres: a.nombres(),
hoy: DiaDe(time.Now()),
}
}
// claveTemporal devuelve la clave pedida o, si no se pide ninguna, una al azar.
// Sea cual sea, la cuenta queda marcada como temporal: al entrar por primera
// vez no deja hacer nada hasta cambiarla por una de 12 caracteres o más.
func claveTemporal(fija string) string {
if fija != "" {
return fija
}
return ClaveTemporal()
}
func avisoClave(clave string, definitiva bool) {
if definitiva {
fmt.Println("clave definitiva: no se pide cambiarla al entrar")
return
}
fmt.Printf("clave temporal: %s\nSe entrega en mano y hay que cambiarla al entrar por primera vez.\n", clave)
}
// claveDefinitiva comprueba que una clave que no se va a cambiar al entrar
// cumple lo mismo que se exige al cambiarla desde la web.
func claveDefinitiva(fija string) error {
if fija == "" {
return errors.New("-definitiva necesita -clave")
}
if len([]rune(fija)) < 12 {
return errors.New("una clave definitiva tiene al menos 12 caracteres")
}
return nil
}
func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiva bool) error {
if err := ValidarUsuario(usuario); err != nil {
return err
}
if nombre == "" {
return errors.New("falta -nombre: el nombre visible del colectivo")
}
if !rol.Valido() {
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c := Cuenta{
Usuario: usuario, Nombre: nombre, Rol: rol,
Hash: HashClave(clave), Temporal: !definitiva, Activa: true, Creada: time.Now(),
}
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
fmt.Printf("cuenta %q (%s) creada con rol %s\n", usuario, nombre, rol)
avisoClave(clave, definitiva)
return nil
}
func (a *App) ResetClave(usuario, fija string, definitiva bool) error {
c, ok := a.alm.BuscarCuenta(usuario)
if !ok {
return fmt.Errorf("no existe la cuenta %q", usuario)
}
if definitiva {
if err := claveDefinitiva(fija); err != nil {
return err
}
}
clave := claveTemporal(fija)
c.Hash = HashClave(clave)
c.Temporal = !definitiva
if err := a.alm.GuardarCuenta("consola", c); err != nil {
return err
}
a.ses.BorrarDeUsuario(usuario)
fmt.Printf("clave de %q reseteada\n", usuario)
avisoClave(clave, definitiva)
return nil
}
// Caducar pasa a caducadas las solicitudes que se han quedado sin margen o sin
// respuesta, liberando las fechas.
func (a *App) Caducar() (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
ahora := time.Now()
n := 0
for _, e := range a.alm.Eventos() {
motivo := a.cfg.Reglas.Caducidad(e, ahora)
if motivo == "" {
continue
}
e.Estado = Caducado
e.Resuelto = ahora
e.ResueltoPor = "automatico"
e.Motivo = motivo
if err := a.alm.GuardarEvento("automatico", e); err != nil {
return n, err
}
log.Printf("caducada %q de %s: %s", e.Titulo, e.Solicitante, motivo)
n++
}
return n, nil
}
func (a *App) Servir() error {
if _, err := a.Caducar(); err != nil {
return err
}
srv := &http.Server{
Addr: a.cfg.Escucha,
Handler: a.rutas(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 * 1024,
}
paro := make(chan os.Signal, 1)
signal.Notify(paro, syscall.SIGINT, syscall.SIGTERM)
go a.tareasPeriodicas(paro)
errores := make(chan error, 1)
go func() {
log.Printf("escuchando en %s", a.cfg.Escucha)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errores <- err
}
}()
select {
case err := <-errores:
return err
case <-paro:
log.Print("parando")
ctx, cancelar := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelar()
return srv.Shutdown(ctx)
}
}
// tareasPeriodicas repasa cada hora las solicitudes caducables y limpia las
// sesiones abandonadas.
func (a *App) tareasPeriodicas(paro <-chan os.Signal) {
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-t.C:
if _, err := a.Caducar(); err != nil {
log.Printf("repaso de caducidades: %v", err)
}
a.ses.Limpiar()
case <-paro:
return
}
}
}

183
gestor/modelo.go Normal file
View file

@ -0,0 +1,183 @@
package main
import (
"fmt"
"time"
)
// Dia es una fecha civil en formato ISO "2006-01-02". Se guarda como texto
// porque asi se ordena y se compara igual que como se lee en el registro.
type Dia string
const formatoDia = "2006-01-02"
func DiaDe(t time.Time) Dia { return Dia(t.Format(formatoDia)) }
func (d Dia) Time() (time.Time, error) {
t, err := time.ParseInLocation(formatoDia, string(d), time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("fecha %q no válida (formato AAAA-MM-DD)", string(d))
}
return t, nil
}
// MustTime solo se usa sobre fechas ya validadas al entrar en el sistema.
func (d Dia) MustTime() time.Time {
t, err := d.Time()
if err != nil {
panic(err)
}
return t
}
func (d Dia) Mas(dias int) Dia { return DiaDe(d.MustTime().AddDate(0, 0, dias)) }
// DiaISO devuelve 1 (lunes) a 7 (domingo).
func (d Dia) DiaISO() int { return int(d.MustTime().Weekday()+6)%7 + 1 }
func (d Dia) Legible() string {
t := d.MustTime()
return fmt.Sprintf("%s %d de %s", diasSemana[d.DiaISO()-1], t.Day(), mesesMin[int(t.Month())-1])
}
var (
diasSemana = []string{"lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"}
meses = []string{"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio",
"Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"}
mesesMin = []string{"enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"}
)
// Rango de dias que ocupa un evento, ambos extremos incluidos.
func Rango(desde, hasta Dia) []Dia {
if hasta < desde {
return nil
}
var out []Dia
for d := desde; d <= hasta; d = d.Mas(1) {
out = append(out, d)
if len(out) > 400 {
break
}
}
return out
}
type Rol string
const (
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
RolInterno Rol = "interno" // colectivo de la Enre: solicita y amadrina
// RolExternas es la cuenta de la asamblea de externas: apunta los eventos
// de colectivos de fuera que se hablan allí, sin cupo, y la asamblea de la
// Enre los confirma.
RolExternas Rol = "externas"
)
func (r Rol) Valido() bool { return r == RolEnre || r == RolInterno || r == RolExternas }
type Cuenta struct {
Usuario string `json:"usuario"`
Nombre string `json:"nombre"` // nombre visible del colectivo
Rol Rol `json:"rol"`
Hash string `json:"hash"` // argon2id codificado
Temporal bool `json:"temporal,omitempty"` // obliga a cambiar la clave al entrar
Colectivo string `json:"colectivo,omitempty"` // clave en datos/horarios.json, para el color
Activa bool `json:"activa"`
Creada time.Time `json:"creada"`
}
type Estado string
const (
Solicitado Estado = "solicitado"
Confirmado Estado = "confirmado"
Rechazado Estado = "rechazado"
Caducado Estado = "caducado"
Cancelado Estado = "cancelado"
)
// Ocupa indica si el estado reserva la fecha frente a otras solicitudes.
func (e Estado) Ocupa() bool { return e == Solicitado || e == Confirmado }
func (e Estado) Etiqueta() string {
switch e {
case Solicitado:
return "SOLICITADO"
case Confirmado:
return "CONFIRMADO"
case Rechazado:
return "RECHAZADO"
case Caducado:
return "CADUCADO"
case Cancelado:
return "CANCELADO"
}
return string(e)
}
type Evento struct {
ID string `json:"id"`
Titulo string `json:"titulo"`
Solicitante string `json:"solicitante"` // usuario de la cuenta
Amadrinado string `json:"amadrinado,omitempty"` // colectivo externo, vacio si es propio
Contacto string `json:"contacto,omitempty"` // contacto del externo
Desde Dia `json:"desde"`
Hasta Dia `json:"hasta"`
Hora string `json:"hora,omitempty"`
Notas string `json:"notas,omitempty"`
Estado Estado `json:"estado"`
Creado time.Time `json:"creado"`
Resuelto time.Time `json:"resuelto,omitempty"`
ResueltoPor string `json:"resuelto_por,omitempty"`
Motivo string `json:"motivo,omitempty"`
}
func (e *Evento) Dias() []Dia { return Rango(e.Desde, e.Hasta) }
func (e *Evento) NumDias() int { return len(e.Dias()) }
// Externo indica si el evento lo organiza un colectivo de fuera amadrinado.
func (e *Evento) Externo() bool { return e.Amadrinado != "" }
type Turno string
const (
Tranqui Turno = "tranqui"
Ruido Turno = "ruido"
)
func (t Turno) Etiqueta() string {
switch t {
case Tranqui:
return "FINDE TRANQUI"
case Ruido:
return "FINDE CON RUIDO"
}
return ""
}
// Excepcion fija a mano el turno de un finde concreto, identificado por su viernes.
type Excepcion struct {
Viernes Dia `json:"viernes"`
Turno Turno `json:"turno"`
}
type Sesion struct {
ID string
Usuario string
CSRF string
Creada time.Time
Vista time.Time
// Clave temporal recien generada, para ensenarla una sola vez sin que pase
// por la URL y acabe en el historial o en el log de nginx.
ClaveDe string
ClaveNueva string
// Aviso o error de la siguiente página. Van por la sesión y no por la URL:
// con «?a=» cualquiera podía fabricar un enlace de laenre.net que
// enseñara el texto que quisiera.
Aviso string
Error string
}

1074
gestor/pedir.go Normal file

File diff suppressed because it is too large Load diff

68
gestor/pedir_test.go Normal file
View file

@ -0,0 +1,68 @@
package main
import (
"testing"
"time"
)
func TestDestinoSeguro(t *testing.T) {
casos := map[string]string{
"/pedir/nueva?fecha=2026-10-09": "/pedir/nueva?fecha=2026-10-09",
"/pedir/mis-fechas": "/pedir/mis-fechas",
"": "",
"/": "",
"/index.html": "",
"//evil.example/pedir/": "",
"https://evil.example/pedir/": "",
"/\\evil.example/pedir/": "",
"/pedir/\r\nSet-Cookie: x=1": "",
"javascript:alert(1)": "",
}
for entrada, quiero := range casos {
if got := destinoSeguro(entrada); got != quiero {
t.Errorf("destinoSeguro(%q) = %q, quiero %q", entrada, got, quiero)
}
}
}
func TestDestinoSeguroSinPuntos(t *testing.T) {
for _, sig := range []string{"/pedir/../index.html", "/pedir/./nueva", "/pedir//nueva", "/pedir/x/../../evil"} {
if got := destinoSeguro(sig); got != "" {
t.Errorf("destinoSeguro(%q) = %q, quiero vacío", sig, got)
}
}
if got := destinoSeguro("/pedir/"); got != "/pedir/" {
t.Errorf("destinoSeguro(/pedir/) = %q", got)
}
}
func TestCerrojoApuntaAntesDeCalcular(t *testing.T) {
c := NuevoCerrojo(5, time.Minute, time.Minute)
pasan := 0
for i := 0; i < 50; i++ {
if ok, _ := c.Intentar("ip:1.2.3.4"); ok {
pasan++
}
}
if pasan != 5 {
t.Fatalf("pasaron %d intentos, quiero 5", pasan)
}
}
func TestArgonParametrosAcotados(t *testing.T) {
// Un hash manipulado que pide 4 GiB de memoria no se calcula.
malo := "$argon2id$v=19$m=4194304,t=3,p=4$c2FsdHNhbHRzYWx0$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaGhhc2g"
if VerificarClave(malo, "loquesea") {
t.Fatal("no deberia verificar")
}
}
func TestConfigEjemploValida(t *testing.T) {
c, err := CargarConfig("../despliegue/config.ejemplo.json")
if err != nil {
t.Fatalf("despliegue/config.ejemplo.json no carga: %v", err)
}
if !c.CookieSegura || c.Local {
t.Fatal("el ejemplo de producción tiene que llevar cookie_segura y no local")
}
}

View file

@ -0,0 +1,92 @@
{{define "arriba"}}<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Titulo}} · La Enredadera de Tetuán</title>
<meta name="description" content="Fechas pedidas y confirmadas en el CSO La Enredadera de Tetuán, y cómo pedir una.">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<div class="container">
<aside class="sidebar">
<a class="sb-pedir" href="/pedir/" aria-current="page">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>{{.Titulo}}</h1>
{{- if .Sub}}<p class="sub">{{.Sub}}</p>{{end}}
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
{{- if .Dentro}}
<nav class="pedir-menu" aria-label="Reservas">
<a href="/pedir/"{{if eq .Actual "calendario"}} aria-current="page"{{end}}>Calendario</a>
<a href="/pedir/mis-fechas"{{if eq .Actual "mis-fechas"}} aria-current="page"{{end}}>Vuestras fechas</a>
<a href="/pedir/nueva"{{if eq .Actual "nueva"}} aria-current="page"{{end}}>Pedir fecha</a>
{{- if .EsEnre}}
<a href="/pedir/todas"{{if eq .Actual "todas"}} aria-current="page"{{end}}>Todas las fechas</a>
<a href="/pedir/pendientes"{{if eq .Actual "pendientes"}} aria-current="page"{{end}}>Pendientes</a>
<a href="/pedir/findes"{{if eq .Actual "findes"}} aria-current="page"{{end}}>Findes</a>
<a href="/pedir/cuentas"{{if eq .Actual "cuentas"}} aria-current="page"{{end}}>Cuentas</a>
{{- end}}
<a href="/pedir/clave"{{if eq .Actual "clave"}} aria-current="page"{{end}}>Contraseña</a>
<form class="pedir-salir" method="post" action="/pedir/salir">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Salir · {{.Nombre}}</button>
</form>
</nav>
{{- end}}
{{- if .Aviso}}
<p class="pedir-aviso" role="status">{{.Aviso}}</p>
{{- end}}
{{- if .Error}}
<p class="pedir-error" role="alert">{{.Error}}</p>
{{- end}}
{{end}}
{{define "abajo"}}
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>
{{end}}

View file

@ -0,0 +1,42 @@
{{define "mes"}}
<h3 class="cal-mes">{{.Nombre}} {{.Anio}}</h3>
<div class="cal cal-fechas">
<div class="cal-row cal-head">
<div class="cal-cell cal-head-cell"><abbr title="lunes">L</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="martes">M</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="miércoles">X</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="jueves">J</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="viernes">V</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="sábado">S</abbr></div>
<div class="cal-cell cal-head-cell"><abbr title="domingo">D</abbr></div>
</div>
{{- range .Semanas}}
<div class="cal-row">
{{- range .Dias}}
{{- if .Vacia}}
<div class="cal-cell cal-empty"></div>
{{- else}}
<div class="cal-cell{{if .Hoy}} cal-today{{end}}{{if .Pasado}} cal-pasado{{end}}{{if .Turno}} cal-{{.Turno}}{{end}}{{if .Libre}} cal-libre{{end}}">
<span class="cal-daynum">{{.Numero}}</span>
{{- if .EtiquetaTurno}}<span class="cal-turno">{{.EtiquetaTurno}}</span>{{end}}
{{- if .Libre}}
<a class="cal-pedir" href="/pedir/nueva?fecha={{.Dia}}" aria-label="Pedir el {{.Dia.Legible}}">Pedir</a>
{{- end}}
{{- if .Eventos}}
<div class="cal-pills">
{{- range .Eventos}}
<div class="cal-ev cal-ev-{{.Estado}}{{if .Externas}} cal-ev-externas{{end}}">
<span class="cal-ev-cab">{{if .Hora}}<b class="cal-ev-hora">{{.Hora}}</b>{{end}}<span class="cal-ev-estado">{{.Etiqueta}}</span></span>
<span class="cal-ev-nom">{{.Texto}}{{if .Sigue}} (sigue){{end}}</span>
<span class="cal-ev-quien">{{.Quien}}</span>
</div>
{{- end}}
</div>
{{- end}}
</div>
{{- end}}
{{- end}}
</div>
{{- end}}
</div>
{{end}}

View file

@ -0,0 +1,31 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Cambiar la contraseña">
<h2>Cambiar la contraseña</h2>
{{- if .Temporal}}
<p class="pedir-aviso">
Estáis usando la contraseña temporal que os dieron en la asamblea. Cambiadla ahora:
hasta que no lo hagáis no se puede pedir ninguna fecha.
</p>
{{- end}}
<form class="pedir-form" method="post" action="/pedir/clave" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
<p>
<label for="actual">Contraseña actual</label>
<input id="actual" name="actual" type="password" required maxlength="256">
</p>
<p>
<label for="nueva">Contraseña nueva</label>
<input id="nueva" name="nueva" type="password" required minlength="12" maxlength="256">
<span class="pista">Mínimo 12 caracteres. Una frase larga aguanta más que una palabra retorcida.</span>
</p>
<p>
<label for="repetida">Repetid la nueva</label>
<input id="repetida" name="repetida" type="password" required minlength="12" maxlength="256">
</p>
<p><button type="submit">Cambiar la contraseña</button></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,89 @@
{{template "arriba" .}}
{{- if .Clave}}
<section class="pantalla" aria-label="Clave temporal">
<h2>Clave temporal de «{{.DeQuien}}»</h2>
<p class="clave-nueva">{{.Clave}}</p>
<p class="pie">
Apuntadla y entregadla en mano. No se vuelve a mostrar: de la contraseña solo se
guarda su huella, no la contraseña. Quien entre con ella tendrá que cambiarla antes
de poder hacer nada.
</p>
</section>
{{- end}}
<section class="pantalla" aria-label="Cuentas">
<h2>Cuentas</h2>
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Usuario</th>
<th scope="col">Colectivo</th>
<th scope="col">Rol</th>
<th scope="col">Estado</th>
<th scope="col">Acciones</th>
</tr>
</thead>
<tbody>
{{- range .Cuentas}}
<tr>
<th scope="row">{{.Usuario}}</th>
<td>{{.Nombre}}</td>
<td>{{.Rol}}</td>
<td>
{{- if .Activa}}activa{{else}}desactivada{{end}}
{{- if .Temporal}} <span class="pista">clave temporal sin cambiar</span>{{end}}
</td>
<td>
<form class="en-linea" method="post" action="/pedir/cuentas">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="usuario" value="{{.Usuario}}">
<button type="submit" name="accion" value="reset" class="pedir-boton-flojo">Resetear clave</button>
{{- if .Activa}}
<button type="submit" name="accion" value="desactivar" class="pedir-boton-flojo">Desactivar</button>
{{- else}}
<button type="submit" name="accion" value="activar" class="pedir-boton-flojo">Activar</button>
{{- end}}
</form>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
</section>
<section class="pantalla" aria-label="Dar de alta un colectivo">
<h2>Dar de alta un colectivo de la Enre</h2>
<form class="pedir-form" method="post" action="/pedir/cuentas" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="accion" value="crear">
<p>
<label for="nuevo-usuario">Usuario</label>
<input id="nuevo-usuario" name="usuario" type="text" required maxlength="32"
autocapitalize="none" spellcheck="false">
<span class="pista">Minúsculas, números, guion y guion bajo.</span>
</p>
<p>
<label for="nuevo-nombre">Nombre del colectivo</label>
<input id="nuevo-nombre" name="nombre" type="text" required maxlength="60">
</p>
<p>
<label for="nuevo-rol">Rol</label>
<select id="nuevo-rol" name="rol">
<option value="interno">interno: pide fechas y amadrina</option>
<option value="externas">externas: la asamblea de externas, pide sin cupo</option>
<option value="enre">enre: además confirma y rechaza</option>
</select>
</p>
<p><button type="submit">Crear la cuenta</button></p>
</form>
<p class="pie">
Los colectivos de fuera no tienen cuenta: sus eventos los apunta la cuenta de externas
en la asamblea de externas, o un colectivo de la Enre que los amadrina. Cuantas menos
contraseñas haya guardadas, menos hay que perder.
</p>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,49 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Modificar evento">
<h2>Modificar evento</h2>
<p class="pie">Lo pidió <strong>{{if .Quien}}{{.Quien}}{{else}}{{.Evento.Solicitante}}{{end}}</strong>. La asamblea no tiene cupo ni ventana: solo se comprueba que no pise otro evento.</p>
<form class="pedir-form" method="post" action="/pedir/editar">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<p>
<label for="titulo">Nombre del evento</label>
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Evento.Titulo}}">
</p>
<p>
<label for="estado">Estado</label>
<select id="estado" name="estado">
<option value="solicitado"{{if eq .Evento.Estado "solicitado"}} selected{{end}}>Solicitado</option>
<option value="confirmado"{{if eq .Evento.Estado "confirmado"}} selected{{end}}>Confirmado</option>
</select>
</p>
<p>
<label for="desde">Primer día</label>
<input id="desde" name="desde" type="date" required value="{{.Evento.Desde}}">
</p>
<p>
<label for="hasta">Último día</label>
<input id="hasta" name="hasta" type="date" value="{{.Evento.Hasta}}">
</p>
<p>
<label for="hora">Hora de inicio</label>
<input id="hora" name="hora" type="time" value="{{.Evento.Hora}}">
</p>
<p>
<label for="amadrinado">Colectivo de fuera amadrinado</label>
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Evento.Amadrinado}}">
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Evento.Contacto}}">
</p>
<p>
<label for="notas">Notas</label>
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Evento.Notas}}</textarea>
</p>
<p><button type="submit">Guardar cambios</button> <a class="pedir-boton-flojo" href="/pedir/todas">Cancelar</a></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,9 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Error">
<h2>No se puede</h2>
<p class="pedir-error" role="alert">{{.Mensaje}}</p>
<p><a class="pedir-boton" href="/pedir/">Volver al calendario</a></p>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,45 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Turno de los findes">
<h2>Turno de los findes</h2>
<p class="pie">
Los findes se alternan solos, semana a semana. Aquí se cambia uno concreto cuando la
asamblea lo decida, por ejemplo para poner dos tranquilos seguidos. El turno se marca
sobre el viernes y vale para todo el finde.
</p>
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Finde</th>
<th scope="col">Turno</th>
<th scope="col">Cambiar</th>
</tr>
</thead>
<tbody>
{{- range .Filas}}
<tr>
<th scope="row">{{.Legible}}</th>
<td>
<span class="estado finde-{{.Turno}}">{{.Etiqueta}}</span>
{{- if .AMano}} <span class="pista">puesto a mano</span>{{end}}
</td>
<td>
<form class="en-linea" method="post" action="/pedir/findes">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="viernes" value="{{.Viernes}}">
<button type="submit" name="turno" value="tranqui" class="pedir-boton-flojo">Tranqui</button>
<button type="submit" name="turno" value="ruido" class="pedir-boton-flojo">Con ruido</button>
{{- if .AMano}}
<button type="submit" name="turno" value="" class="pedir-boton-flojo">Automático</button>
{{- end}}
</form>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,73 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Vuestro cupo">
{{- if .EsExternas}}
<h2>Asamblea de externas</h2>
<p class="pie">
Aquí se apuntan los eventos de colectivos de fuera que se hablan en la asamblea de
externas. No hay cupo. Quedan en naranja hasta que la asamblea de la Enre los confirme.
Se pueden pedir fechas hasta el {{.Hasta}}.
</p>
{{- else}}
<h2>Vuestro cupo</h2>
<p class="pie">
Os quedan <strong>{{.Restantes}}</strong> de {{.Reglas.CupoDias}} días dentro de los
próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}.
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
</p>
{{- end}}
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
</section>
<section class="pantalla" aria-label="Vuestras fechas">
<h2>Vuestras fechas</h2>
{{- if .Mias}}
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Evento</th>
<th scope="col">Estado</th>
<th scope="col">Caduca</th>
<th scope="col"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
{{- range .Mias}}
<tr>
<td>
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
</td>
<td>
{{.Evento.Titulo}}
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
</td>
<td><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
<td>
{{- if .Liberable}}
<form method="post" action="/pedir/liberar">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<button type="submit" class="pedir-boton-flojo">Liberar</button>
</form>
{{- end}}
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
<p class="pie">
Una solicitud sin confirmar se borra sola a los {{.Reglas.DiasMaximoPendiente}} días,
o cuando falten menos de {{.Reglas.DiasMinimoConfirmar}} para la fecha. La columna
«caduca» dice cuándo, y al caducar la fecha queda libre para el resto.
</p>
{{- else}}
<p class="pie">No tenéis ninguna fecha pedida.</p>
{{- end}}
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,61 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Pedir una fecha">
<h2>Pedir una fecha</h2>
<ul class="pedir-reglas">
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
{{- if .EsExternas}}
<li>Cada evento es de un colectivo de fuera: poned cuál y un contacto suyo.</li>
<li>La asamblea de externas no tiene cupo. La fecha queda en naranja hasta que la asamblea de la Enre la confirme.</li>
{{- else}}
<li>Como mucho <strong>{{.Reglas.CupoDias}} días por colectivo</strong> en esos {{.Reglas.MesesVentana}} meses. Os quedan {{.Restantes}}.</li>
<li>Si el evento es de un colectivo de fuera, decid cuál: vuestro colectivo lo amadrina y responde por él.</li>
{{- end}}
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
</ul>
<form class="pedir-form" method="post" action="/pedir/nueva">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p>
<label for="titulo">Nombre del evento</label>
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Sol.Titulo}}">
</p>
<p>
<label for="desde">Primer día</label>
<input id="desde" name="desde" type="date" required
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Desde}}">
</p>
<p>
<label for="hasta">Último día</label>
<input id="hasta" name="hasta" type="date"
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Hasta}}">
<span class="pista">Dejadlo vacío si es un solo día.</span>
</p>
<p>
<label for="hora">Hora de inicio</label>
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
</p>
<p>
{{- if .EsExternas}}
<label for="amadrinado">Colectivo de fuera</label>
<input id="amadrinado" name="amadrinado" type="text" required maxlength="80" value="{{.Sol.Amadrinado}}">
{{- else}}
<label for="amadrinado">Colectivo de fuera al que amadrináis</label>
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
<span class="pista">Vacío si el evento es vuestro.</span>
{{- end}}
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Sol.Contacto}}">
<span class="pista">Correo o teléfono de quien responde por ellos.</span>
</p>
<p>
<label for="notas">Notas para la asamblea</label>
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Sol.Notas}}</textarea>
</p>
<p><button type="submit">Pedir la fecha</button></p>
</form>
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,35 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Solicitudes pendientes">
<h2>Esperando a la asamblea</h2>
{{- if .Filas}}
{{- range .Filas}}
<article class="solicitud">
<h3>{{.Evento.Titulo}}</h3>
<dl class="solicitud-datos">
<dt>Fecha</dt>
<dd>{{.Legible}}{{if gt .Dias 1}} ({{.Dias}} días){{end}}{{if .Evento.Hora}}, a las {{.Evento.Hora}}{{end}}</dd>
<dt>Pide</dt><dd>{{.Quien}}</dd>
{{- if .Evento.Contacto}}<dt>Contacto</dt><dd>{{.Evento.Contacto}}</dd>{{end}}
{{- if .Turno}}<dt>Ese finde</dt><dd>{{.Turno}}</dd>{{end}}
<dt>Faltan</dt><dd>{{.Faltan}} días</dd>
{{- if .Caduca}}<dt>Caduca</dt><dd>{{.Caduca}}</dd>{{end}}
{{- if .Evento.Notas}}<dt>Notas</dt><dd>{{.Evento.Notas}}</dd>{{end}}
</dl>
<form class="solicitud-resolver" method="post" action="/pedir/resolver">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<label for="motivo-{{.Evento.ID}}">Acuerdo de la asamblea</label>
<input id="motivo-{{.Evento.ID}}" name="motivo" type="text" maxlength="200"
placeholder="opcional">
<button type="submit" name="accion" value="confirmar" class="pedir-boton">Confirmar</button>
<button type="submit" name="accion" value="rechazar" class="pedir-boton-flojo">Rechazar</button>
</form>
</article>
{{- end}}
{{- else}}
<p class="pie">No hay nada pendiente.</p>
{{- end}}
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,66 @@
{{template "arriba" .}}
{{- if not .Dentro}}
<p class="pedir-externas">Colectivos externos: las fechas se piden en persona en la <strong>asamblea de externas, lunes de 19:00 a 20:00</strong>.</p>
{{- end}}
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
<h2>Qué findes están cogidos</h2>
<p class="pie">
Los colectivos de la Enre pinchan en un día libre del finde para pedirlo.
En <strong>negro</strong>, fechas que un colectivo ha pedido y esperan a que la
asamblea las confirme; en <strong>naranja</strong>, las que ha apuntado la asamblea de
externas. En <strong>verde chillón</strong>, fechas ya confirmadas. Los findes se
turnan entre tranquilos y con ruido. Hoy se pueden pedir fechas hasta el
{{.Hasta}}, con un máximo de {{.Reglas.CupoDias}} días por colectivo.
Los horarios fijos de cada semana están en <a href="/calendario.html">Calendario</a>.
</p>
<div class="leyenda-pedir">
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
<span class="cal-ev cal-ev-solicitado cal-ev-externas leyenda-muestra">Solicitado externas</span>
<span class="cal-ev cal-ev-confirmado leyenda-muestra">Confirmado</span>
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
</div>
<input type="checkbox" id="ver-semana" class="ver-semana sr-only">
<label for="ver-semana" class="ver-semana-boton pedir-boton-flojo"><span class="no">Ver la semana entera</span><span class="si">Ver solo los findes</span></label>
{{- range .Meses}}
{{template "mes" .}}
{{- end}}
</section>
{{- if not .Dentro}}
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
<h2>Entrar</h2>
<p class="pie">
Solo para los <strong>colectivos de la Enre</strong>, que tienen credenciales, y para
la asamblea de externas. Si sois de fuera, no hay cuenta que pedir: la fecha se
pide en la <strong>asamblea de externas, los lunes de 19:00 a 20:00</strong>.
</p>
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
<p>
<label for="usuario">Colectivo</label>
<input id="usuario" name="usuario" type="text" required maxlength="32"
autocapitalize="none" spellcheck="false">
</p>
<p>
<label for="clave">Contraseña</label>
<input id="clave" name="clave" type="password" required maxlength="256">
</p>
<p><button type="submit">Entrar</button></p>
</form>
<p class="pie">
No hay recuperación de contraseña por correo, a propósito: así no hay ninguna
dirección guardada ni ningún formulario que atacar. Si la habéis perdido, se resetea
en la asamblea.
</p>
</section>
{{- end}}
{{template "abajo" .}}

View file

@ -0,0 +1,53 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Todas las fechas">
<h2>Todas las fechas que vienen</h2>
{{- if .Filas}}
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Evento</th>
<th scope="col">Estado</th>
<th scope="col"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
{{- range .Filas}}
<tr>
<td>
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
</td>
<td>
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
</td>
<td><span class="estado estado-{{.Evento.Estado}}{{if .Externas}} estado-externas{{end}}">{{.Etiqueta}}</span></td>
<td>
<div class="acciones">
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
<form method="post" action="/pedir/liberar">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<input type="hidden" name="volver" value="todas">
<button type="submit" class="pedir-boton-flojo">Quitar</button>
</form>
</div>
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
<p class="pie">
Quitar deja la fecha libre para el resto; el evento queda en el registro como
cancelado. Las solicitudes que esperan confirmación están también en
<a href="/pedir/pendientes">Pendientes</a>.
</p>
{{- else}}
<p class="pie">No hay ningún evento en las próximas fechas.</p>
{{- end}}
</section>
{{template "abajo" .}}

264
gestor/reglas.go Normal file
View file

@ -0,0 +1,264 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
)
// Reglas reune los limites que acordo la asamblea. Los valores vienen de
// /etc/enrecal/config.json para poder ajustarlos sin recompilar.
type Reglas struct {
MesesVentana int `json:"meses_ventana"` // no se puede pedir mas alla de hoy + N meses
CupoDias int `json:"cupo_dias"` // dias por colectivo dentro de la ventana
DiasMinimoConfirmar int `json:"dias_minimo_confirmar"` // por debajo de esto, una solicitud sin confirmar caduca
DiasMaximoPendiente int `json:"dias_maximo_pendiente"` // una solicitud no puede esperar mas que esto
DiasReservables []int `json:"dias_reservables"` // 1=lunes .. 7=domingo
AnclaFinde Dia `json:"ancla_finde"` // un viernes de referencia
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
}
const maxDiasEvento = 31
func ReglasPorDefecto() Reglas {
return Reglas{
MesesVentana: 3,
CupoDias: 3,
DiasMinimoConfirmar: 7,
DiasMaximoPendiente: 30,
DiasReservables: []int{5, 6, 7},
AnclaFinde: "2026-09-25",
TurnoAncla: Ruido,
}
}
// Ventana devuelve el primer y el ultimo dia que se pueden pedir hoy.
func (rg Reglas) Ventana(hoy Dia) (Dia, Dia) {
primero := hoy.Mas(rg.DiasMinimoConfirmar)
ultimo := DiaDe(hoy.MustTime().AddDate(0, rg.MesesVentana, 0))
return primero, ultimo
}
func (rg Reglas) reservable(d Dia) bool {
iso := d.DiaISO()
for _, v := range rg.DiasReservables {
if v == iso {
return true
}
}
return false
}
// Solicitud es lo que llega del formulario, ya recortado pero sin validar.
type Solicitud struct {
Solicitante string
Rol Rol
Titulo string
Amadrinado string
Contacto string
Desde Dia
Hasta Dia
Hora string
Notas string
Excluir string // ID del evento que se está modificando: no choca consigo mismo
}
// Ocupacion mapea cada dia ocupado con el titulo del evento que lo ocupa.
func Ocupacion(eventos []Evento, excluir string) map[Dia]Evento {
out := map[Dia]Evento{}
for _, e := range eventos {
if e.ID == excluir || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
out[d] = e
}
}
return out
}
// CupoUsado cuenta los dias que un colectivo ya tiene ocupados dentro de la
// ventana de reserva, sumando solicitados y confirmados.
func (rg Reglas) CupoUsado(usuario string, eventos []Evento, hoy Dia, excluir string) []Dia {
_, ultimo := rg.Ventana(hoy)
var dias []Dia
for _, e := range eventos {
if e.ID == excluir || e.Solicitante != usuario || !e.Estado.Ocupa() {
continue
}
for _, d := range e.Dias() {
if d >= hoy && d <= ultimo {
dias = append(dias, d)
}
}
}
sort.Slice(dias, func(i, j int) bool { return dias[i] < dias[j] })
return dias
}
// Validar aplica todas las reglas a una solicitud nueva. Devuelve un error con
// el motivo escrito para mostrarlo tal cual en el formulario.
func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
if strings.TrimSpace(s.Titulo) == "" {
return fmt.Errorf("falta el nombre del evento")
}
if len([]rune(s.Titulo)) > 80 {
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
}
if s.Rol == RolExternas && strings.TrimSpace(s.Amadrinado) == "" {
return fmt.Errorf("falta de qué colectivo de fuera es el evento")
}
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
}
if _, err := s.Desde.Time(); err != nil {
return err
}
if _, err := s.Hasta.Time(); err != nil {
return err
}
if s.Hasta < s.Desde {
return fmt.Errorf("la fecha de fin es anterior a la de inicio")
}
if s.Hora != "" {
if _, err := time.Parse("15:04", s.Hora); err != nil {
return fmt.Errorf("la hora %q no vale, se escribe como 20:00", s.Hora)
}
}
dias := Rango(s.Desde, s.Hasta)
// Tope para todos, también la asamblea: Rango corta en 400 días y un evento
// más largo quedaría pintado en el calendario pero sin bloquear sus fechas.
if len(dias) > maxDiasEvento {
return fmt.Errorf("un evento no puede durar más de %d días", maxDiasEvento)
}
libre := s.Rol == RolEnre // la asamblea de la Enre no tiene cupo ni ventana
if !libre {
primero, ultimo := rg.Ventana(hoy)
if s.Desde < primero {
return fmt.Errorf("hay que pedirlo con al menos %d días de antelación: "+
"la primera fecha libre es el %s. Con menos margen la solicitud caducaría sin que "+
"la asamblea llegue a verla",
rg.DiasMinimoConfirmar, primero.Legible())
}
if s.Hasta > ultimo {
return fmt.Errorf("solo se pueden pedir fechas hasta %s, que son %d meses vista",
ultimo.Legible(), rg.MesesVentana)
}
if len(dias) > rg.CupoDias {
return fmt.Errorf("un evento no puede ocupar más de %d días y estás pidiendo %d",
rg.CupoDias, len(dias))
}
for _, d := range dias {
if !rg.reservable(d) {
return fmt.Errorf("el %s no es día reservable: las fechas se piden para %s. "+
"Para otro día, habladlo en la asamblea", d.Legible(), rg.nombresReservables())
}
}
}
ocupados := Ocupacion(eventos, s.Excluir)
for _, d := range dias {
if e, ok := ocupados[d]; ok {
return fmt.Errorf("el %s ya está cogido por %q (%s)", d.Legible(), e.Titulo,
strings.ToLower(e.Estado.Etiqueta()))
}
}
// La asamblea de externas apunta eventos de muchos colectivos distintos:
// el cupo es por colectivo y no tiene sentido sumarlos todos en su cuenta.
if !libre && s.Rol != RolExternas {
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
if len(usados)+len(dias) > rg.CupoDias {
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+
"Ya tenéis %s y pedís %d día(s) más. Cancelad una de esas fechas o esperad a que pasen",
rg.CupoDias, rg.MesesVentana, listaDias(usados), len(dias))
}
}
return nil
}
func (rg Reglas) nombresReservables() string {
var out []string
for _, v := range rg.DiasReservables {
if v >= 1 && v <= 7 {
out = append(out, diasSemana[v-1])
}
}
return enumerar(out)
}
// enumerar une una lista en castellano: "viernes, sábado y domingo".
func enumerar(partes []string) string {
switch len(partes) {
case 0:
return ""
case 1:
return partes[0]
}
return strings.Join(partes[:len(partes)-1], ", ") + " y " + partes[len(partes)-1]
}
func listaDias(dias []Dia) string {
var out []string
for _, d := range dias {
out = append(out, d.Legible())
}
return enumerar(out)
}
// Caducidad devuelve el motivo por el que un evento debe caducar, o "" si
// todavia no toca.
func (rg Reglas) Caducidad(e Evento, ahora time.Time) string {
if e.Estado != Solicitado {
return ""
}
hoy := DiaDe(ahora)
if e.Desde < hoy.Mas(rg.DiasMinimoConfirmar) {
return fmt.Sprintf("sin confirmar a menos de %d días de la fecha", rg.DiasMinimoConfirmar)
}
if ahora.Sub(e.Creado) > time.Duration(rg.DiasMaximoPendiente)*24*time.Hour {
return fmt.Sprintf("más de %d días esperando confirmación", rg.DiasMaximoPendiente)
}
return ""
}
// ViernesDe devuelve el viernes del finde al que pertenece un dia, o "" si el
// dia no cae en finde.
func ViernesDe(d Dia) Dia {
iso := d.DiaISO()
if iso < 5 {
return ""
}
return d.Mas(5 - iso)
}
// TurnoDe resuelve si un finde es tranqui o con ruido: primero mira las
// excepciones puestas a mano y si no alterna semana a semana desde el ancla.
func (rg Reglas) TurnoDe(d Dia, exc map[Dia]Turno) Turno {
viernes := ViernesDe(d)
if viernes == "" {
return ""
}
if t, ok := exc[viernes]; ok {
return t
}
ancla := ViernesDe(rg.AnclaFinde)
if ancla == "" {
ancla = rg.AnclaFinde
}
dif := int(viernes.MustTime().Sub(ancla.MustTime()).Hours() / 24 / 7)
if dif < 0 {
dif = -dif
}
otro := Tranqui
if rg.TurnoAncla == Tranqui {
otro = Ruido
}
if dif%2 == 0 {
return rg.TurnoAncla
}
return otro
}

276
gestor/reglas_test.go Normal file
View file

@ -0,0 +1,276 @@
package main
import (
"strings"
"testing"
)
const hoy = Dia("2026-09-22") // martes
func solicitud(usuario string, desde, hasta Dia) Solicitud {
return Solicitud{
Solicitante: usuario,
Rol: RolInterno,
Titulo: "Concierto de prueba",
Desde: desde,
Hasta: hasta,
Hora: "20:00",
}
}
func evento(id, usuario string, desde, hasta Dia, estado Estado) Evento {
return Evento{
ID: id,
Titulo: "Evento " + id,
Solicitante: usuario,
Desde: desde,
Hasta: hasta,
Estado: estado,
Creado: hoy.MustTime(),
}
}
func TestVentanaTresMeses(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-10-02 es viernes, dentro de los tres meses.
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-02"), nil, hoy); err != nil {
t.Fatalf("una fecha dentro de la ventana deberia valer: %v", err)
}
// 2027-01-01 es viernes y cae fuera: hoy + 3 meses es 2026-12-22.
err := rg.Validar(solicitud("hacklab", "2027-01-01", "2027-01-01"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "3 meses") {
t.Fatalf("una fecha a mas de 3 meses tiene que fallar por la ventana, dio: %v", err)
}
}
func TestAntelacionMinima(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-09-25 es el viernes de esta semana: a 3 dias, caducaria al instante.
err := rg.Validar(solicitud("hacklab", "2026-09-25", "2026-09-25"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "antelación") {
t.Fatalf("pedir con menos de 7 dias tiene que fallar, dio: %v", err)
}
}
func TestCupoTresDiasEnLaVentana(t *testing.T) {
rg := ReglasPorDefecto()
// Tres dias ya cogidos por el mismo colectivo: uno confirmado, dos solicitados.
eventos := []Evento{
evento("a", "hacklab", "2026-10-02", "2026-10-02", Confirmado),
evento("b", "hacklab", "2026-10-10", "2026-10-11", Solicitado),
}
err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-16"), eventos, hoy)
if err == nil || !strings.Contains(err.Error(), "cupo") {
t.Fatalf("el cuarto dia tiene que fallar por cupo, dio: %v", err)
}
// El cupo es por colectivo: otro colectivo si puede coger ese dia.
if err := rg.Validar(solicitud("feminismos", "2026-10-16", "2026-10-16"), eventos, hoy); err != nil {
t.Fatalf("otro colectivo no deberia arrastrar el cupo del primero: %v", err)
}
// Los dias rechazados o caducados no cuentan.
eventos[1].Estado = Caducado
if err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-17"), eventos, hoy); err != nil {
t.Fatalf("un evento caducado no deberia gastar cupo: %v", err)
}
}
func TestCupoNoCuentaFechasPasadas(t *testing.T) {
rg := ReglasPorDefecto()
viejos := []Evento{evento("v", "hacklab", "2026-08-07", "2026-08-09", Confirmado)}
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-04"), viejos, hoy); err != nil {
t.Fatalf("lo que ya paso no gasta cupo: %v", err)
}
}
func TestSinSolapes(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{evento("a", "hacklab", "2026-10-03", "2026-10-03", Solicitado)}
err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy)
if err == nil || !strings.Contains(err.Error(), "ya está cogido") {
t.Fatalf("un dia solicitado por otro tiene que bloquear, dio: %v", err)
}
// Al caducar se libera la fecha.
eventos[0].Estado = Caducado
if err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy); err != nil {
t.Fatalf("una fecha caducada tiene que quedar libre: %v", err)
}
}
func TestSoloFindesSalvoLaEnre(t *testing.T) {
rg := ReglasPorDefecto()
// 2026-10-06 es martes.
err := rg.Validar(solicitud("hacklab", "2026-10-06", "2026-10-06"), nil, hoy)
if err == nil || !strings.Contains(err.Error(), "no es día reservable") {
t.Fatalf("un martes no es reservable para un colectivo, dio: %v", err)
}
s := solicitud("enre", "2026-10-06", "2026-10-06")
s.Rol = RolEnre
if err := rg.Validar(s, nil, hoy); err != nil {
t.Fatalf("la enre si puede poner cualquier dia: %v", err)
}
}
func TestLaEnreNoTieneCupoNiVentana(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "enre", "2026-10-02", "2026-10-04", Confirmado),
evento("b", "enre", "2026-10-09", "2026-10-11", Confirmado),
}
s := solicitud("enre", "2027-06-04", "2027-06-06")
s.Rol = RolEnre
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("la enre no deberia topar con cupo ni ventana: %v", err)
}
}
func TestAmadrinadoPideContacto(t *testing.T) {
rg := ReglasPorDefecto()
s := solicitud("hacklab", "2026-10-02", "2026-10-02")
s.Amadrinado = "Colectivo de fuera"
err := rg.Validar(s, nil, hoy)
if err == nil || !strings.Contains(err.Error(), "contacto") {
t.Fatalf("amadrinar sin contacto tiene que fallar, dio: %v", err)
}
s.Contacto = "fuera@riseup.net"
if err := rg.Validar(s, nil, hoy); err != nil {
t.Fatalf("con contacto deberia valer: %v", err)
}
}
func TestCaducidad(t *testing.T) {
rg := ReglasPorDefecto()
ahora := hoy.MustTime()
cerca := evento("a", "hacklab", "2026-09-27", "2026-09-27", Solicitado)
if m := rg.Caducidad(cerca, ahora); m == "" || !strings.Contains(m, "menos de 7") {
t.Fatalf("a 5 dias sin confirmar tiene que caducar, dio %q", m)
}
lejos := evento("b", "hacklab", "2026-11-06", "2026-11-06", Solicitado)
if m := rg.Caducidad(lejos, ahora); m != "" {
t.Fatalf("una solicitud reciente y lejana no caduca, dio %q", m)
}
vieja := lejos
vieja.Creado = ahora.AddDate(0, 0, -31)
if m := rg.Caducidad(vieja, ahora); m == "" || !strings.Contains(m, "esperando") {
t.Fatalf("31 dias esperando tiene que caducar, dio %q", m)
}
confirmada := cerca
confirmada.Estado = Confirmado
if m := rg.Caducidad(confirmada, ahora); m != "" {
t.Fatalf("un evento confirmado no caduca nunca, dio %q", m)
}
}
func TestTurnoDeFinde(t *testing.T) {
rg := ReglasPorDefecto() // ancla 2026-09-25, turno ruido
casos := []struct {
dia Dia
quer Turno
}{
{"2026-09-25", Ruido}, // viernes ancla
{"2026-09-26", Ruido}, // sabado del mismo finde
{"2026-09-27", Ruido}, // domingo del mismo finde
{"2026-10-02", Tranqui}, // finde siguiente
{"2026-10-09", Ruido},
{"2026-10-16", Tranqui},
{"2026-09-18", Tranqui}, // hacia atras tambien alterna
}
for _, c := range casos {
if got := rg.TurnoDe(c.dia, nil); got != c.quer {
t.Errorf("%s: turno %q, esperaba %q", c.dia, got, c.quer)
}
}
// Un dia de entre semana no tiene turno.
if got := rg.TurnoDe("2026-10-06", nil); got != "" {
t.Errorf("un martes no tiene turno de finde, dio %q", got)
}
// La excepcion escrita a mano manda sobre la alternancia.
exc := map[Dia]Turno{"2026-10-02": Ruido}
if got := rg.TurnoDe("2026-10-03", exc); got != Ruido {
t.Errorf("la excepcion del viernes tiene que valer para todo el finde, dio %q", got)
}
}
func TestRangoYDiaISO(t *testing.T) {
dias := Rango("2026-10-02", "2026-10-04")
if len(dias) != 3 || dias[0] != "2026-10-02" || dias[2] != "2026-10-04" {
t.Fatalf("rango mal calculado: %v", dias)
}
if Rango("2026-10-04", "2026-10-02") != nil {
t.Fatal("un rango invertido tiene que dar nada")
}
if got := Dia("2026-10-02").DiaISO(); got != 5 {
t.Fatalf("2026-10-02 es viernes, iso 5, dio %d", got)
}
if got := ViernesDe("2026-10-04"); got != "2026-10-02" {
t.Fatalf("el viernes del domingo 4 es el 2, dio %q", got)
}
}
func TestVentanaDevuelveTresMeses(t *testing.T) {
rg := ReglasPorDefecto()
primero, ultimo := rg.Ventana(hoy)
if primero != "2026-09-29" {
t.Errorf("primera fecha pedible %q, esperaba 2026-09-29", primero)
}
if ultimo != "2026-12-22" {
t.Errorf("ultima fecha pedible %q, esperaba 2026-12-22", ultimo)
}
}
func TestModificarNoChocaConsigoMismo(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "hacklab", "2026-10-02", "2026-10-03", Confirmado),
evento("b", "vivienda", "2026-10-09", "2026-10-09", Solicitado),
}
// La asamblea mueve la hora del evento «a» sin cambiarle los días.
s := solicitud("hacklab", "2026-10-02", "2026-10-03")
s.Rol, s.Excluir = RolEnre, "a"
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("modificar un evento en sus propios días deberia valer: %v", err)
}
// Pero no puede llevarlo encima del evento «b».
s.Desde, s.Hasta = "2026-10-09", "2026-10-09"
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "cogido") {
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
}
}
func TestExternasSinCupoPeroConColectivo(t *testing.T) {
rg := ReglasPorDefecto()
eventos := []Evento{
evento("a", "externas", "2026-10-02", "2026-10-04", Solicitado),
evento("b", "externas", "2026-10-09", "2026-10-11", Solicitado),
}
s := solicitud("externas", "2026-10-16", "2026-10-16")
s.Rol = RolExternas
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "colectivo de fuera") {
t.Fatalf("sin decir de qué colectivo es deberia fallar, dio %v", err)
}
// Con seis días ya apuntados, un séptimo vale: externas no tiene cupo.
s.Amadrinado, s.Contacto = "Colectivo Sin Techo", "sintecho@example.org"
if err := rg.Validar(s, eventos, hoy); err != nil {
t.Fatalf("externas no deberia tener cupo: %v", err)
}
// Pero sigue sin poder pedir entre semana.
s.Desde, s.Hasta = "2026-10-14", "2026-10-14"
if err := rg.Validar(s, eventos, hoy); err == nil {
t.Fatal("externas no deberia poder pedir un miércoles")
}
}

168
gestor/vista.go Normal file
View file

@ -0,0 +1,168 @@
package main
import (
"fmt"
"time"
)
// FilaEvento es un evento ya resuelto para pintarlo en una celda del mes.
type FilaEvento struct {
Hora string
Texto string
Quien string
Estado Estado
Externas bool // la pidió la asamblea de externas
Etiqueta string
Sigue bool // dia intermedio o final de un evento de varios dias
}
type Celda struct {
Dia Dia
Numero int
Vacia bool
Hoy bool
Pasado bool
Libre bool // se puede pedir: dia reservable, dentro de la ventana y sin ocupar
Turno Turno
EtiquetaTurno string // solo en el viernes, para no repetirla tres veces
Eventos []FilaEvento
}
type Semana struct {
Dias []Celda
}
type Mes struct {
Nombre string
Anio int
Semanas []Semana
}
// Vista arma los meses que se pintan, a partir del estado del registro.
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
// con su propio guion y no se toca.
type Vista struct {
roles map[string]Rol
reglas Reglas
exc map[Dia]Turno
eventos []Evento
nombres map[string]string
hoy Dia
}
func (v Vista) eventosDe(d Dia) []FilaEvento {
var out []FilaEvento
for _, e := range v.eventos {
if !e.Estado.Ocupa() || d < e.Desde || d > e.Hasta {
continue
}
quien := v.nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
externas := v.roles[e.Solicitante] == RolExternas
switch {
case externas:
quien = e.Amadrinado + " · asamblea de externas"
case e.Externo():
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
}
out = append(out, FilaEvento{
Hora: e.Hora,
Texto: e.Titulo,
Quien: quien,
Estado: e.Estado,
Externas: externas,
Etiqueta: etiquetaEstado(e.Estado, externas),
Sigue: d != e.Desde,
})
}
return out
}
func (v Vista) celda(d Dia, numero int) Celda {
turno := v.reglas.TurnoDe(d, v.exc)
etiqueta := ""
if turno != "" && d.DiaISO() == 5 {
etiqueta = turno.Etiqueta()
}
primero, ultimo := v.reglas.Ventana(v.hoy)
eventos := v.eventosDe(d)
return Celda{
Dia: d,
Numero: numero,
Hoy: d == v.hoy,
Pasado: d < v.hoy,
Libre: len(eventos) == 0 && d >= primero && d <= ultimo && v.reglas.reservable(d),
Turno: turno,
EtiquetaTurno: etiqueta,
Eventos: eventos,
}
}
func (v Vista) Mes(anio, mes int) Mes {
primero := fechaMes(anio, mes)
total := diasDelMes(anio, mes)
hueco := (int(primero.Weekday()) + 6) % 7 // celdas vacias antes del dia 1
m := Mes{Nombre: meses[mes-1], Anio: anio}
semana := Semana{}
for i := 0; i < hueco; i++ {
semana.Dias = append(semana.Dias, Celda{Vacia: true})
}
for dia := 1; dia <= total; dia++ {
d := DiaDe(primero.AddDate(0, 0, dia-1))
semana.Dias = append(semana.Dias, v.celda(d, dia))
if len(semana.Dias) == 7 {
m.Semanas = append(m.Semanas, semana)
semana = Semana{}
}
}
if len(semana.Dias) > 0 {
for len(semana.Dias) < 7 {
semana.Dias = append(semana.Dias, Celda{Vacia: true})
}
m.Semanas = append(m.Semanas, semana)
}
return m
}
// Meses devuelve el mes que corre y los siguientes, que son los que se pueden
// pedir.
func (v Vista) Meses(cuantos int) []Mes {
t := v.hoy.MustTime()
var out []Mes
for i := 0; i < cuantos; i++ {
m := t.AddDate(0, i, 0)
out = append(out, v.Mes(m.Year(), int(m.Month())))
}
return out
}
// Proximas lista los eventos que vienen, para la tabla de debajo del mes.
func (v Vista) Proximas() []Evento {
var out []Evento
for _, e := range v.eventos {
if e.Estado.Ocupa() && e.Hasta >= v.hoy {
out = append(out, e)
}
}
return out
}
// etiquetaEstado distingue lo que ha pedido la asamblea de externas mientras
// espera: una vez confirmado es un evento más.
func etiquetaEstado(e Estado, externas bool) string {
if externas && e == Solicitado {
return "SOLICITADO EXTERNAS"
}
return e.Etiqueta()
}
func diasDelMes(anio, mes int) int {
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
}
func fechaMes(anio, mes int) time.Time {
return time.Date(anio, time.Month(mes), 1, 0, 0, 0, 0, time.Local)
}

2182
index.css

File diff suppressed because one or more lines are too long

View file

@ -4,379 +4,178 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Enredadera de Tetuán</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf">
<meta name="description" content="CSO La Enredadera de Tetuán. Centro Social Autogestionado en la Calle de la Coruña 5, Madrid. Haciendo barrio desde 2009.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="La Enredadera de Tetuán">
<meta property="og:description" content="CSO La Enredadera de Tetuán. Centro Social Autogestionado en la Calle de la Coruña 5, Madrid. Haciendo barrio desde 2009.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
<!-- Web component gancio-calendar (CSO La Rosa) https://framagit.org/rosa/gancio-calendar -->
<script type="module" src="https://rosa.frama.io/gancio-calendar/gancio-calendar.js"></script>
</head>
<body>
<!-- Menú lateral: checkbox + label, sin una línea de JavaScript -->
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir/cerrar menú">☰</label>
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<!-- ==========================================================
LA FORMA DE LA HOJA. Se define una sola vez por página y
cada hoja la reutiliza con <use>. Los colores salen de las
variables --limbo, --borde y --nervio, que pone el CSS.
========================================================== -->
<svg class="defs" aria-hidden="true" focusable="false" width="0" height="0">
<symbol id="hoja-forma" viewBox="0 0 100 132">
<!-- Volteada: el rabillo queda arriba y la hoja cuelga. Así el punto por
el que se agarra a la raíz es la esquina superior del recuadro, y el
giro no depende de ningún porcentaje. -->
<g transform="translate(0,132) scale(1,-1)" fill="none" stroke-linejoin="round" stroke-linecap="round">
<!-- Rabillo, en marrón: es la parte leñosa que engancha con el tallo.
Mide 42 y no 24: con el corto, el limbo nacía pegado a la raíz y
la cruzaba. El recuadro crece con él (114 -> 132) para que la caja
siga midiendo lo que se dibuja; el limbo no cambia de tamaño. -->
<path stroke="var(--rabillo)" stroke-width="3.2" d="M50 88 L50 130"/>
<!-- Zarcillo verde. Sale solo en algunas hojas: el CSS enciende
--zarcillo en una de cada tres para que no salgan calcadas. -->
<path stroke="var(--nervio)" stroke-width="1.9" stroke-opacity="var(--zarcillo,0)"
d="M50 100 C38 98 32 90 36 84 C39 80 45 82 44 88 C43 94 36 96 30 92"/>
<!-- Contorno de hoja de hiedra, en rosa -->
<path stroke="var(--trazo)" stroke-width="2.7"
d="M50 5 C56 20 61 36 65 48 C76 44 89 40 96 45 C96 62 84 74 70 80 C62 84 56 83 50 88 C44 83 38 84 30 80 C16 74 4 62 4 45 C11 40 24 44 35 48 C39 36 44 20 50 5 Z"/>
<!-- Nervadura palmada, en verde: los cinco nervios salen del rabillo -->
<g stroke="var(--nervio)" stroke-width="1.5">
<path d="M50 86 L50 13"/>
<path d="M50 86 C59 72 78 56 90 48"/>
<path d="M50 86 C41 72 22 56 10 48"/>
<path d="M50 86 C57 84 65 81 71 77"/>
<path d="M50 86 C43 84 35 81 29 77"/>
</g>
</g>
</symbol>
</svg>
<div class="container">
<div class="vertical-marquee left-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
NI DIOS NI AMO, NI PATRIA NI ESTADO -0->
</marquee>
</div>
<div class="vertical-marquee right-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
OKUPA RESISTE 1312 <3
</marquee>
</div>
<aside id="sidebar" class="sidebar">
<img src="/INFO/logo_enre_negro.png" alt="Logo de La Enredadera" class="logo">
<h2>La Enredadera</h2>
<p>Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav">
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/subdir/sobre_nosotrxs.html">Sobre Nosotrxs</a></li>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="/convocala.html">Eventos</a></li>
<li><a href="comunicados.html">Comunicados</a></li>
<li><a href="mailto:enredadera@riseup.net">Contacto</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main-content">
<header>
<h1>CSO LA ENREDADERA DE TETUÁN</h1>
<nav class="top-nav">
<ul>
<li><a href="/index.html#feminismos">feminismos tetuan</a></li>
<li><a href="/subdir/campa.html">campañas</a></li>
<li><a href="/index.html#fridays">fridays for future</a></li>
<li><a href="/index.html#ovt">organizacion vivienda tetuan</a></li>
<li><a href="/index.html#circo">circo</a></li>
<li><a href="https://hacklab.laenre.net">hacklab la raiz</a></li>
<li><a href="/index.html#tienda-gratis">tienda gratis</a></li>
<li><a href="/index.html#biblioteka">biblioteka</a></li>
<li><a href="/index.html#warawas">warawas</a></li>
<li><a href="/index.html#hongos">hongos</a></li>
<li><a href="/index.html#fermentos">fermentos</a></li>
<li><a href="/index.html#bicilab">bicilab</a></li>
</ul>
</nav>
<main class="main">
<header class="cabecera">
<h1>La Enredadera</h1>
<!-- El espacio antes de cada «·» es duro: sin esto, al partir en dos
líneas la segunda empezaba por el separador. -->
<p class="sub">Centro social autogestionado&nbsp;· Tetuán, Madrid&nbsp;· desde 2009</p>
</header>
<!-- ======================================================
LA ENREDADERA.
Para añadir una hoja: copia un <li> entero y cambia el
href y el texto. La posición y el ángulo salen solos.
====================================================== -->
<div class="enredadera-wrap">
<ul class="enredadera">
<!-- CALENDARIO: gancio-calendar embebido desde mad.convoca.la -->
<section id="agenda" class="panel" aria-label="Calendario de eventos">
<li>
<a class="hoja" href="/colectivos.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Colectivos</span>
</a>
</li>
<!--
places="286" = ID de CSO La Enredadera en mad.convoca.la
https://mad.convoca.la/place/CSO%20La%20Enredadera
-->
<gancio-calendar
baseurl="https://mad.convoca.la"
title="Agenda de La Enredadera de Tetuán"
instance_name="mad.convoca.la"
instance_description="Agenda activista y cultural de Tetuán"
locale="es-ES"
places="42"
accent_color="#ff00aa"
theme="light">
</gancio-calendar>
<li>
<a class="hoja" href="/calendario.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Calendario</span>
</a>
</li>
</section>
<li>
<a class="hoja" href="/eventos.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Eventos</span>
</a>
</li>
<!-- REDES: al pulsarla brotan tres hojas con las redes. Todo con un
checkbox y CSS, ni una línea de JavaScript. -->
<li class="li-redes">
<input type="checkbox" id="redes-toggle" class="sr-only">
<label class="hoja" for="redes-toggle">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Redes</span>
</label>
<ul class="brotes">
<li>
<a class="hoja hoja-brote" href="https://masto.es/@csolaenre" rel="me">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Mastodon</span>
</a>
</li>
<li>
<a class="hoja hoja-brote" href="https://t.me/laenredadera">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Telegram</span>
</a>
</li>
<li>
<a class="hoja hoja-brote" href="https://www.instagram.com/la_enredadera_de_tetuan/">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Instagram</span>
</a>
</li>
</ul>
</li>
<li>
<a class="hoja" href="/subdir/campa.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">Campañas</span>
</a>
</li>
<li>
<a class="hoja" href="/subdir/sobre_nosotrxs.html">
<svg class="hoja-svg" viewBox="0 0 100 132" aria-hidden="true"><use href="#hoja-forma"></use></svg>
<span class="hoja-txt">La Enre</span>
</a>
</li>
</ul>
</div>
<!-- CALENDARIO SEMANAL DE COLECTIVOS -->
<section id="agenda-semanal" aria-label="Horarios semanales de colectivos">
<h2>Horarios Semanales</h2>
<div class="cal-controls" id="semanal-controls" role="group" aria-label="Navegación de mes">
<button class="button semanal-prev" type="button" aria-label="Mes anterior">&larr;</button>
<div class="cal-title semanal-title" aria-live="polite"></div>
<button class="button semanal-next" type="button" aria-label="Mes siguiente">&rarr;</button>
</div>
<div class="cal" id="semanal-root"></div>
<div class="cal-legend" id="semanal-legend-root"></div>
<script>
(function () {
const monthNames = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const weekdayNames = ['L','M','X','J','V','S','D'];
const ENRE_EVENTS_URL = 'https://mad.convoca.la/place/42/CSO%20La%20Enredadera';
const COLORS = {
hacklab: { bg:'#39FF14', fg:'#000', label:'Hacklab' },
artlab: { bg:'#39FF14', fg:'#ff00aa', label:'Art-Lab' },
feminismos: { bg:'#6A0DAD', fg:'#fff', label:'Feminismos' },
vivienda: { bg:'#000000', fg:'#ff00aa', label:'Vivienda' },
bicilab: { bg:'#FF7A00', fg:'#000', label:'Bicilab' },
autodefensa: { bg:'#00BCD4', fg:'#fff', label:'Autodefensa' },
fridays: { bg:'#2E8B57', fg:'#fff', label:'Fridays for Future' },
euskera: { bg:'#FF0000', fg:'#00ff00', label:'Euskera' },
asamblea_enre: { bg:'#ff00ff', fg:'#fff', label:'Asamblea Enre' },
asamblea_externas: { bg:'#fff', fg:'#ff00ff', label:'Asamblea ext.' },
montana: { bg:'#8B4513', fg:'#fff', label:'Montaña' },
teatro: { bg:'#4169E1', fg:'#fff', label:'Teatro' }
};
const WEEKLY = [
{ group:'asamblea_externas', dow:0, start:'19:00' },
{ group:'asamblea_enre', dow:0, start:'20:00' },
{ group:'autodefensa', dow:1, start:'20:00' },
{ group:'teatro', dow:2, start:'11:00' },
{ group:'autodefensa', dow:2, start:'20:00' },
{ group:'euskera', dow:2, start:'18:15' },
{ group:'vivienda', dow:3, start:'19:00' },
{ group:'feminismos', dow:3, start:'20:00' },
{ group:'bicilab', dow:6, start:'19:00' },
{ group:'montana', dow:6, start:'10:00' }
];
function createPill(text, bg, fg) {
const pill = document.createElement('div');
pill.className = 'cal-pill';
pill.style.setProperty('--bg', bg);
pill.style.setProperty('--fg', fg);
pill.textContent = text;
pill.setAttribute('role', 'link');
pill.setAttribute('tabindex', '0');
pill.addEventListener('click', () => { window.location.href = ENRE_EVENTS_URL; });
pill.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); window.location.href = ENRE_EVENTS_URL; }
});
return pill;
}
function renderWeeklyPills(cell, wd) {
const todays = WEEKLY.filter(e => e.dow === wd);
if (!todays.length) return;
const wrap = document.createElement('div');
wrap.className = 'cal-pills';
todays.forEach(e => {
const c = COLORS[e.group] || { bg:'#eee', fg:'#000', label: e.group };
wrap.appendChild(createPill(c.label + (e.start ? ' ' + e.start : ''), c.bg, c.fg));
});
cell.appendChild(wrap);
}
const root = document.getElementById('semanal-root');
const legend = document.getElementById('semanal-legend-root');
const title = document.querySelector('.semanal-title');
const btnPrev = document.querySelector('.semanal-prev');
const btnNext = document.querySelector('.semanal-next');
const today = new Date();
let view = new Date();
view.setDate(1);
function buildCalendar(date) {
root.innerHTML = '';
const year = date.getFullYear();
const month = date.getMonth();
title.textContent = monthNames[month] + ' ' + year;
const startIndex = (new Date(year, month, 1).getDay() + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const fridays = [];
for (let d = 1; d <= daysInMonth; d++) {
if ((new Date(year, month, d).getDay() + 6) % 7 === 4) fridays.push(d);
}
const secondFri = fridays[1];
const lastFri = fridays[fridays.length - 1];
const head = document.createElement('div');
head.className = 'cal-row cal-head';
weekdayNames.forEach(w => {
const cell = document.createElement('div');
cell.className = 'cal-cell cal-head-cell';
cell.textContent = w;
head.appendChild(cell);
});
root.appendChild(head);
let day = 1 - startIndex;
for (let r = 0; r < 6; r++) {
const row = document.createElement('div');
row.className = 'cal-row';
for (let c = 0; c < 7; c++) {
const cell = document.createElement('div');
cell.className = 'cal-cell';
if (day >= 1 && day <= daysInMonth) {
if (year === today.getFullYear() && month === today.getMonth() && day === today.getDate())
cell.classList.add('cal-today');
const num = document.createElement('span');
num.className = 'cal-daynum';
num.textContent = day;
cell.appendChild(num);
const wd = (new Date(year, month, day).getDay() + 6) % 7;
if (wd === 4) {
const wrap = document.createElement('div');
wrap.className = 'cal-pills';
if (day === secondFri)
wrap.appendChild(createPill('Art-Lab', COLORS.artlab.bg, COLORS.artlab.fg));
else
wrap.appendChild(createPill('Hacklab', COLORS.hacklab.bg, COLORS.hacklab.fg));
if (day === secondFri || day === lastFri)
wrap.appendChild(createPill('Fridays 🌱', COLORS.fridays.bg, COLORS.fridays.fg));
cell.appendChild(wrap);
} else {
renderWeeklyPills(cell, wd);
}
} else {
cell.classList.add('cal-empty');
}
row.appendChild(cell);
day++;
}
root.appendChild(row);
}
}
function buildLegend() {
legend.innerHTML = '';
['hacklab','artlab','feminismos','vivienda','bicilab','autodefensa','fridays','euskera','asamblea_enre','montana','teatro']
.forEach(key => {
const c = COLORS[key];
if (!c) return;
const item = document.createElement('div');
item.className = 'cal-legend-item';
const dot = document.createElement('span');
dot.className = 'cal-legend-dot';
dot.style.background = c.bg;
if (c.bg === '#fff') dot.style.border = '1px solid #ff00aa';
const txt = document.createElement('span');
txt.textContent = c.label;
item.appendChild(dot);
item.appendChild(txt);
legend.appendChild(item);
});
}
btnPrev.addEventListener('click', () => { view.setMonth(view.getMonth() - 1); buildCalendar(view); });
btnNext.addEventListener('click', () => { view.setMonth(view.getMonth() + 1); buildCalendar(view); });
buildCalendar(view);
buildLegend();
})();
</script>
</section>
<section id="feminismos">
<h2>Feminismos</h2>
<p><strong>Feminismo Tetuán</strong> es una asamblea de barrio que nace a raíz del movimiento del <strong>8M</strong>.</p>
<a class="colectivo-img-link" href="https://www.instagram.com/feminismostetuan" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/feminismos_tetuan.jpg" alt="Feminismos Tetuán">
</a>
<a class="colectivo-btn" href="https://linktr.ee/FeminismosTetuan?utm_source=ig&utm_medium=social&utm_content=link_in_bio&fbclid=PAZXh0bgNhZW0CMTEAc3J0YwZhcHBfaWQMMjU2MjgxMDQwNTU4AAGnRoxnOqEyhqJxFoRTPlDSH8zvge2GNvrAQDn4Ou98RwCysuYT0AABDgbbFWw_aem_tbCIw3s5HlsIgRoH4D4p1A" target="_blank" rel="noopener">LinkTree</a>
<a class="colectivo-btn" href="https://www.instagram.com/feminismostetuan/" target="_blank" rel="noopener">Instagram</a>
<p>Es una asamblea abierta no mixta transinclusiva. <strong>Nos reunimos los jueves a las 20</strong> en el CSO La Enredadera.</p>
<p>Uno de los temas que más trabajamos es el derecho a la libre intervención del embarazo, ya que en nuestro barrio se encuentra la clínica Dator, la primera clínica en realizar abortos en España.</p>
</section>
<section id="fridays">
<h2>Fridays for Future</h2>
<p>Somos parte del movimiento global juvenil por la Justicia Climática.</p>
<a class="colectivo-img-link" href="https://linktr.ee/fridaysforfuturemadrid" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/photo_5913560549843653631_y.jpg" alt="Fridays for Future Madrid">
</a>
<a class="colectivo-img-link" href="https://linktr.ee/fridaysforfuturemadrid" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/fridays_madrid.jpg" alt="Fridays for Future Madrid">
</a>
<a class="colectivo-btn" href="https://linktr.ee/fridaysforfuturemadrid" target="_blank" rel="noopener">LinkTree</a>
<a class="colectivo-btn" href="https://www.instagram.com/fridaysformadrid/" target="_blank" rel="noopener">Instagram</a>
</section>
<section id="ovt">
<h2>Colectivo de Vivienda</h2>
<p>Asamblea abierta desde donde defendemos que la vivienda es para quien la habita y no un negocio.</p>
<a class="colectivo-img-link" href="https://www.instagram.com/organizacionviviendatetuan/" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/ovt.jpg" alt="Organización Vivienda Tetuán">
</a>
<a class="colectivo-btn" href="https://www.instagram.com/organizacionviviendatetuan/" target="_blank" rel="noopener">Instagram</a>
</section>
<section id="circo">
<h2>Circo</h2>
<p>Información próximamente.</p>
</section>
<section id="hacklab">
<h2>Hacklab</h2>
<a class="colectivo-img-link" href="https://hacklab.laenre.net" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/hacklab.jpeg" alt="Hacklab La Raíz">
</a>
<a class="colectivo-img-link" href="https://hacklab.laenre.net/eventos.html" target="_blank" rel="noopener">
<img class="colectivo-img" src="/INFO/teclado_negro_planta.jpg" alt="">
</a>
<a class="colectivo-btn" href="https://hacklab.laenre.net" target="_blank" rel="noopener">hacklab.laenre.net</a>
</section>
<section id="tienda-gratis">
<h2>Tienda Gratis</h2>
<p>Información próximamente.</p>
</section>
<section id="biblioteka">
<h2>Biblioteca Libre</h2>
<p>Accede a libros, fanzines y documentos sobre activismo, agroecología, tecnología libre y feminismos.</p>
<a class="colectivo-btn" href="/archivos/biblioteca.pdf">Mas información</a>
</section>
<section id="warawas">
<h2>Warawas</h2>
<p>Información próximamente.</p>
</section>
<section id="hongos">
<h2>Hongos</h2>
<p>Información próximamente.</p>
</section>
<section id="fermentos">
<h2>Fermentos</h2>
<p>Información próximamente.</p>
</section>
<section id="bicilab">
<h2>BICILAB</h2>
<p>Bocata de espaguetis todos los domingos a las 17:00 nos engrasamos las manos y recuperamos tu bicicleta!</p>
<a class="colectivo-btn" href="/archivos/asamblea.pdf">Descargar Actas</a>
</section>
<footer>
<p>📩 Contacto: <a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></p>
<p>📌 Calle de la Coruña nº5, Madrid</p>
<p>⚡ Mastodon: <a href="https://masto.es/@csolaenre">@csolaenre</a></p>
</footer>
</main>
</div>
</body>
</html>

624
panel.html Normal file
View file

@ -0,0 +1,624 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Cartelera del Tattoo Circus para proyectar en el local.">
<!-- Vista previa al compartir el enlace. -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Tattoo Circus · Panel">
<meta property="og:description" content="El horario del fin de semana de jornadas, para proyectar.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
<title>Tattoo Circus · Panel</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<style>
/* Este micrositio es oscuro por diseño. */
:root{ color-scheme: dark; }
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
@font-face {
font-family: 'Anton';
src: url('/fuentes/anton-latin.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Special Elite';
src: url('/fuentes/specialelite-latin.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Code462WPink';
src: url('/Code462WPink-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root{
--neon: #E84A1F;
--cream: #f4ecd8;
--black: #0a0a0a;
--divider: rgba(232,74,31,.35);
}
*{ box-sizing: border-box; margin: 0; padding: 0; }
html, body{
height: 100vh;
width: 100vw;
overflow: hidden;
background: var(--black);
font-family: 'Special Elite', 'Courier New', monospace;
color: var(--neon);
}
body{
display: flex;
flex-direction: column;
}
/* ============================================================
HEADER FIJO — banner crema con el título principal
Altura controlada: ~12-14% del viewport
============================================================ */
header.title{
background: var(--cream);
color: #000;
text-align: center;
padding: clamp(8px, 1.6vh, 26px) 2vw;
flex-shrink: 0;
border-bottom: 3px solid #000;
}
header.title h1{
font-family: 'Code462WPink', cursive;
font-size: clamp(2.2rem, 5.5vw, 7rem);
letter-spacing: clamp(6px, 1.2vw, 24px);
text-transform: uppercase;
line-height: 1;
color: #000;
}
header.title .sub{
margin-top: clamp(4px, .6vh, 12px);
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(.75rem, 1.1vw, 1.6rem);
letter-spacing: clamp(3px, .6vw, 12px);
text-transform: uppercase;
color: #000;
opacity: .6;
}
/* ============================================================
DAY BANNER — banner negro con el día actual
============================================================ */
.day-banner{
background: var(--black);
color: var(--neon);
text-align: center;
padding: clamp(8px, 1.4vh, 24px) 2vw;
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(1.8rem, 4vw, 5rem);
letter-spacing: clamp(6px, 1.3vw, 22px);
text-transform: uppercase;
text-shadow: 0 0 12px rgba(232,74,31,.7);
border-bottom: 2px solid var(--neon);
flex-shrink: 0;
transition: opacity .4s ease;
}
/* ============================================================
SCHEDULE — contenedor de filas que rellena el espacio restante
Cada .row distribuye verticalmente con flex:1
============================================================ */
.schedule{
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
transition: opacity .4s ease;
min-height: 0;
}
.row{
flex: 1 1 0;
min-height: 0;
display: grid;
grid-template-columns: clamp(100px, 11vw, 220px) 1fr clamp(110px, 11vw, 200px);
column-gap: clamp(16px, 2.2vw, 50px);
padding: clamp(8px, 1.2vh, 20px) clamp(20px, 3vw, 60px);
align-items: center;
border-bottom: 1px solid var(--divider);
overflow: hidden;
}
.row:last-child{ border-bottom: none; }
/* alternancia de fondos */
.row:nth-child(odd){
background: var(--cream);
color: #000;
}
.row:nth-child(even){
background: var(--black);
color: var(--neon);
}
/* ============================================================
HORA (columna izquierda)
============================================================ */
.row .time{
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(1.4rem, 2.4vw, 3rem);
letter-spacing: 1.5px;
font-weight: 700;
white-space: nowrap;
line-height: 1;
}
.row:nth-child(odd) .time{ color: #000; }
.row:nth-child(even) .time{
color: var(--neon);
text-shadow: 0 0 10px rgba(232,74,31,.6);
}
/* ============================================================
ACTIVIDAD (columna derecha) — title + detail + crew
Tipografía bien jerarquizada y line-height ajustado
============================================================ */
.row .act{
font-size: clamp(.9rem, 1.3vw, 1.7rem);
line-height: 1.3;
min-width: 0;
overflow: hidden;
}
.row .act .title{
display: block;
font-family: 'Anton', Impact, sans-serif;
font-weight: 700;
font-size: 1.15em;
letter-spacing: clamp(1px, .12vw, 3px);
text-transform: uppercase;
line-height: 1.18;
word-wrap: break-word;
overflow-wrap: break-word;
}
.row .act .detail{
display: block;
margin-top: .35em;
font-size: .8em;
line-height: 1.25;
/* atenuarlo bajaba el naranja a 3.6:1 sobre el negro */
font-style: italic;
}
.row .act .crew{
display: block;
margin-top: .3em;
font-size: .72em;
line-height: 1.25;
opacity: .65;
}
/* ============================================================
HIGHLIGHT — badge para conciertos / cabaret / freakshow
============================================================ */
.row .act .highlight{
display: inline-block;
background: var(--neon);
color: #000;
padding: 3px 14px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(232,74,31,.6);
letter-spacing: clamp(2px, .25vw, 5px);
}
.row:nth-child(odd) .act .highlight{
background: #000;
color: var(--cream);
box-shadow: none;
}
/* ============================================================
PLANTA (columna derecha) — badge que indica el piso
============================================================ */
.row .planta{
justify-self: end;
text-align: center;
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(.75rem, 1vw, 1.25rem);
letter-spacing: clamp(1px, .15vw, 3px);
text-transform: uppercase;
line-height: 1.1;
padding: clamp(4px, .6vh, 9px) clamp(8px, 1vw, 16px);
border: 2px solid currentColor;
border-radius: 6px;
white-space: nowrap;
}
.row:nth-child(odd) .planta{ color: #000; }
.row:nth-child(even) .planta{
color: var(--neon);
box-shadow: 0 0 8px rgba(232,74,31,.4);
}
/* ============================================================
PLANTAS SLIDE — 4 rectángulos anchos a pantalla completa
============================================================ */
.plantas-slide{
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
overflow: hidden;
}
.planta-card{
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: clamp(6px, 1.2vh, 16px);
padding: clamp(10px, 1.8vh, 28px) clamp(24px, 4vw, 70px);
border-bottom: 1px solid var(--divider);
overflow: hidden;
}
.planta-card:last-child{ border-bottom: none; }
.planta-card:nth-child(odd){ background: var(--cream); color: #000; }
.planta-card:nth-child(even){ background: var(--black); color: var(--neon); }
.planta-head{
display: flex;
align-items: baseline;
gap: clamp(10px, 1.5vw, 26px);
flex-wrap: wrap;
}
.planta-name{
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(1.6rem, 3.2vw, 4rem);
letter-spacing: clamp(2px, .5vw, 10px);
text-transform: uppercase;
line-height: 1;
}
.planta-card:nth-child(even) .planta-name{ text-shadow: 0 0 12px rgba(232,74,31,.6); }
.planta-tag{
font-family: 'Anton', Impact, sans-serif;
font-size: clamp(.85rem, 1.4vw, 1.8rem);
letter-spacing: clamp(1px, .2vw, 4px);
text-transform: uppercase;
opacity: .65;
}
.planta-items{
display: flex;
flex-wrap: wrap;
gap: clamp(8px, 1.2vw, 20px);
}
.planta-item{
font-size: clamp(.9rem, 1.5vw, 2rem);
line-height: 1.15;
letter-spacing: .5px;
padding: clamp(4px, .7vh, 10px) clamp(10px, 1.3vw, 22px);
border: 2px solid currentColor;
border-radius: 6px;
}
/* ============================================================
BREAKPOINTS por tamaño de pantalla
============================================================ */
/* Laptops 1366x768 — más compacto, hueco para ~10 filas en 768px */
@media (max-width: 1440px) and (max-height: 900px){
header.title{ padding: clamp(5px, 1vh, 12px) 2vw; }
header.title h1{ font-size: clamp(1.7rem, 4vw, 3.2rem); letter-spacing: 5px; }
header.title .sub{ font-size: .72rem; letter-spacing: 3px; margin-top: 2px; }
.day-banner{ font-size: clamp(1.3rem, 3vw, 2.2rem); padding: 5px 2vw; letter-spacing: clamp(4px, 1vw, 14px); }
.row{
grid-template-columns: clamp(74px, 8vw, 120px) 1fr clamp(96px, 9vw, 150px);
column-gap: clamp(10px, 1.6vw, 26px);
padding: 3px clamp(14px, 2.2vw, 36px);
}
.row .time{ font-size: clamp(1rem, 1.7vw, 1.6rem); }
.row .act{ font-size: clamp(.78rem, 1vw, 1rem); line-height: 1.16; }
.row .act .title{ font-size: 1.02em; line-height: 1.1; letter-spacing: .5px; }
.row .act .detail{ font-size: .76em; margin-top: .12em; line-height: 1.12; }
.row .act .crew{ font-size: .68em; margin-top: .1em; line-height: 1.12; }
.row .act .highlight{ padding: 2px 10px; }
.row .planta{ font-size: clamp(.6rem, .8vw, .9rem); letter-spacing: 1px; padding: 3px 7px; }
}
/* Portátiles muy cortos (≤720px de alto): apretar aún más para que nada se corte */
@media (max-width: 1440px) and (max-height: 720px){
header.title h1{ font-size: clamp(1.4rem, 3.4vw, 2.4rem); letter-spacing: 4px; }
header.title .sub{ font-size: .62rem; letter-spacing: 2px; }
.day-banner{ font-size: clamp(1.1rem, 2.6vw, 1.8rem); padding: 3px 2vw; }
.row{ padding: 2px clamp(12px, 2vw, 30px); }
.row .act{ font-size: clamp(.72rem, .95vw, .92rem); line-height: 1.12; }
.row .act .detail{ font-size: .74em; }
.row .act .crew{ font-size: .66em; }
}
/* QHD (1440p) — escala intermedia agradable */
@media (min-width: 2000px) and (max-width: 2800px){
header.title h1{ font-size: clamp(4rem, 5.5vw, 8rem); }
.day-banner{ font-size: clamp(3rem, 4.2vw, 6rem); }
.row .time{ font-size: clamp(2.2rem, 2.5vw, 3.6rem); }
.row .act{ font-size: clamp(1.3rem, 1.4vw, 1.9rem); }
}
/* 4K / TV grande (≥2800px) — escala generosa */
@media (min-width: 2800px){
header.title h1{ font-size: clamp(6rem, 5.8vw, 11rem); letter-spacing: 28px; }
.day-banner{ font-size: clamp(4.5rem, 5vw, 8rem); letter-spacing: 26px; }
.row .time{ font-size: clamp(3rem, 2.8vw, 4.5rem); }
.row .act{ font-size: clamp(1.6rem, 1.5vw, 2.3rem); }
}
/* Ventana realmente pequeña (alguien abre el panel en laptop pequeña/tablet) */
/* En móvil esto deja de ser una proyección. El panel mete todo el
horario en una pantalla a la fuerza (filas de alto flexible con
overflow oculto), y a 320px la charla de título más largo perdía 23px
de texto sin avisar. Aquí las filas crecen lo que necesiten y el
horario se desliza, que es lo propio de un teléfono. */
@media (max-width: 480px){
.schedule{ overflow-y: auto; }
.row{ flex: 0 0 auto; overflow: visible; }
}
@media (max-width: 900px){
header.title h1{ font-size: clamp(1.8rem, 6vw, 3rem); letter-spacing: 3px; }
.day-banner{ font-size: clamp(1.4rem, 5vw, 2.2rem); letter-spacing: 4px; padding: 5px 2vw; }
.row{ grid-template-columns: 64px 1fr auto; column-gap: 8px; padding: 5px 12px; }
.row .time{ font-size: .95rem; }
.row .act{ font-size: .8rem; line-height: 1.2; }
.row .act .title{ font-size: 1em; line-height: 1.1; }
.row .act .detail, .row .act .crew{ font-size: .72em; margin-top: .15em; }
.row .planta{ font-size: .62rem; letter-spacing: 1px; padding: 3px 6px; border-width: 1px; }
.planta-card{ padding: 8px 14px; gap: 6px; }
.planta-name{ font-size: 1.4rem; letter-spacing: 2px; }
.planta-tag{ font-size: .8rem; }
.planta-item{ font-size: .8rem; padding: 3px 8px; border-width: 1px; }
}
/* ===== IMAGE SLIDE — PANTALLA COMPLETA ===== */
/* Cuando el body tiene .image-mode, oculta TODO menos la imagen */
body.image-mode header.title,
body.image-mode .day-banner,
body.image-mode .progress{
display: none;
}
body.image-mode .schedule{
height: 100vh;
}
.image-slide{
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: #000;
overflow: hidden;
width: 100%;
height: 100%;
}
.image-slide img{
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
/* hints para renderizar lo más nítido posible al escalar */
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: high-quality;
}
/* ===== FADE TRANSITIONS ===== */
.fading{ opacity: 0; }
/* ===== PROGRESS BAR ===== */
.progress{
height: 8px;
background: rgba(232,74,31,.12);
flex-shrink: 0;
position: relative;
border-top: 1px solid rgba(232,74,31,.3);
}
.progress-bar{
height: 100%;
background: var(--neon);
width: 0%;
box-shadow: 0 0 14px var(--neon);
}
</style>
</head>
<body>
<header class="title">
<h1>TATTOO CIRCUS</h1>
<div class="sub">★ La Enredadera de Tetuán · Fin de semana de jornadas ★</div>
</header>
<div class="day-banner" id="day-banner">VIERNES</div>
<div class="schedule" id="schedule"></div>
<div class="progress">
<div class="progress-bar" id="progress-bar"></div>
</div>
<script>
const FADE_MS = 400;
const SLIDES = [
{
type: 'day',
day: 'VIERNES',
duration: 10000,
slots: [
{ time: '16:00', title: 'Apertura', planta: 'Planta Baja' },
{ time: '16:30', title: 'Taller de carteo a presxs', detail: 'Agrietando los muros de prisión', planta: 'Planta Baja' },
{ time: '18:00', title: '¿Se puede regularizar la vida en un sistema colonial?', detail: 'Dinámicas para entender el proceso de documentación desde una perspectiva decolonial y antifascista', crew: 'Articulación Antirracista de Madrid · Sindicato de Manteros', planta: 'Planta Baja' },
{ time: '20:00', title: 'Cena', planta: 'Planta Primera' },
{ time: '20:30', title: 'Cabaret de Circo', highlight: true, planta: 'Planta Primera' },
{ time: '23:00', title: 'Cierre', planta: 'Planta Primera' }
]
},
{
type: 'day',
day: 'SÁBADO',
duration: 10000,
slots: [
{ time: '11:30', title: 'Apertura', planta: 'Planta Baja' },
{ time: '12:00', title: 'Manifestación Orgullo Loco', detail: 'Centro de salud mental Tetuán → Ni terapia, ni castigo, ni explotación', planta: 'Exterior' },
{ time: '14:00', title: 'Comida', planta: 'Planta Primera' },
{ time: '16:00', title: 'Contra el psistema productivo', detail: 'Manicomios, orfanatos, asilos y otras instituciones de control → INSANIA', planta: 'Planta Baja' },
{ time: '18:00', title: 'Salir con vida', detail: 'Autobiografía de un preso en lucha → Toni Chavero', planta: 'Planta Baja' },
{ time: '20:00', title: 'Cena', planta: 'Planta Primera' },
{ time: '20:30', title: 'Conciertos', highlight: true, planta: 'Planta Baja' },
{ time: '23:00', title: 'Cierre', planta: 'Planta Baja' }
]
},
{
type: 'day',
day: 'DOMINGO',
duration: 10000,
slots: [
{ time: '11:30', title: 'Apertura', planta: 'Planta Baja' },
{ time: '12:00', title: 'Las familias y la cárcel', crew: 'Coordinadora de Familias y Presxs · Famílies de Presos de Catalunya', planta: 'Planta Baja' },
{ time: '14:00', title: 'Comida', planta: 'Planta Primera' },
{ time: '16:00', title: 'Más fuertes que la muerte y la prisión', detail: 'Represión anti-anarquista a nivel internacional', planta: 'Planta Baja' },
{ time: '18:00', title: 'Taller de identificación policial', detail: 'Identificar policía y proteger derechos en protestas → LEGAL SOL', planta: 'Planta Baja' },
{ time: '19:30', title: 'Flamenco', detail: 'Un quejío contra los muros', planta: 'Planta Primera' },
{ time: '20:00', title: 'Cena', planta: 'Planta Primera' },
{ time: '20:30', title: 'Freakshow', highlight: true, planta: 'Planta Primera' },
{ time: '22:00', title: 'Rifa', planta: 'Planta Primera' }
]
},
{
type: 'plantas',
day: 'MAPA DE PLANTAS',
duration: 12000,
floors: [
{ name: 'PLANTA BAJA', items: ['Charlas', 'Conciertos/DJ', 'Carteo a presxs', 'Baños'] },
{ name: 'PLANTA PRIMERA', tag: 'Villapaloma', items: ['Barra', 'Distris', 'Espectáculos', 'Baños'] },
{ name: 'PLANTA DOS', items: ['Citas de Tattoo', 'Piercing', 'Masaje', 'Gemas dentales', 'Peluquería'] },
{ name: 'PLANTA TRES', items: ['Tattoo', 'Piercing', 'Gemas dentales'] }
]
}
];
const dayEl = document.getElementById('day-banner');
const scheduleEl = document.getElementById('schedule');
const progressEl = document.getElementById('progress-bar');
// Utilidad: crea un elemento con clase y texto. textContent NO interpreta HTML —
// garantiza que aunque cambien los datos en el futuro no se pueda inyectar markup.
function el(tag, className, text){
const n = document.createElement(tag);
if (className) n.className = className;
if (text != null) n.textContent = text;
return n;
}
function clearChildren(parent){
while (parent.firstChild) parent.removeChild(parent.firstChild);
}
function renderSlide(slide){
clearChildren(scheduleEl);
if (slide.type === 'image'){
document.body.classList.add('image-mode');
const wrap = el('div', 'image-slide');
const img = document.createElement('img');
img.src = slide.src;
img.alt = 'Cartel Tattoo Circus';
wrap.appendChild(img);
scheduleEl.appendChild(wrap);
return;
}
document.body.classList.remove('image-mode');
dayEl.textContent = slide.day;
if (slide.type === 'plantas'){
const wrap = el('div', 'plantas-slide');
slide.floors.forEach(f => {
const card = el('div', 'planta-card');
const head = el('div', 'planta-head');
head.appendChild(el('span', 'planta-name', f.name));
if (f.tag) head.appendChild(el('span', 'planta-tag', f.tag));
card.appendChild(head);
const list = el('div', 'planta-items');
f.items.forEach(it => list.appendChild(el('span', 'planta-item', it)));
card.appendChild(list);
wrap.appendChild(card);
});
scheduleEl.appendChild(wrap);
return;
}
slide.slots.forEach(s => {
const row = el('div', 'row');
row.appendChild(el('div', 'time', s.time));
const act = el('div', 'act');
const titleSpan = el('span', 'title');
if (s.highlight){
titleSpan.appendChild(el('span', 'highlight', s.title));
} else {
titleSpan.textContent = s.title;
}
act.appendChild(titleSpan);
if (s.detail) act.appendChild(el('span', 'detail', s.detail));
if (s.crew) act.appendChild(el('span', 'crew', s.crew));
row.appendChild(act);
if (s.planta) row.appendChild(el('div', 'planta', s.planta));
scheduleEl.appendChild(row);
});
}
function startProgress(duration){
progressEl.style.transition = 'none';
progressEl.style.width = '0%';
// forzar reflow
void progressEl.offsetWidth;
progressEl.style.transition = `width ${duration}ms linear`;
progressEl.style.width = '100%';
}
let idx = 0; // empieza con el cartel
function tick(){
scheduleEl.classList.add('fading');
dayEl.classList.add('fading');
setTimeout(() => {
idx = (idx + 1) % SLIDES.length;
const slide = SLIDES[idx];
renderSlide(slide);
scheduleEl.classList.remove('fading');
dayEl.classList.remove('fading');
startProgress(slide.duration);
setTimeout(tick, slide.duration);
}, FADE_MS);
}
// primera slide (sin fade)
const first = SLIDES[idx];
renderSlide(first);
startProgress(first.duration);
setTimeout(tick, first.duration);
</script>
</body>
</html>

View file

@ -3,118 +3,101 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Enredadera de Tetuán — Campañas</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf">
<title>Campañas · La Enredadera de Tetuán</title>
<meta name="description" content="Campañas activas e históricas del CSO La Enredadera de Tetuán.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Campañas · La Enredadera de Tetuán">
<meta property="og:description" content="Campañas activas e históricas del CSO La Enredadera de Tetuán.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div class="container">
<!-- Marquesinas laterales -->
<div class="vertical-marquee left-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
NI DIOS NI AMO, NI PATRIA NI ESTADO -0->
</marquee>
</div>
<div class="vertical-marquee right-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
OKUPA RESISTE 1312 <3
</marquee>
</div>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<!-- Sidebar -->
<aside class="sidebar">
<img src="/INFO/logo_enre_negro.png" alt="Logo de La Enredadera" class="logo">
<h2>La Enredadera</h2>
<p>Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav">
<ul>
<li><a href="/subdir/sobre_nosotrxs.html">Sobre Nosotrxs</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://mad.convoca.la/place/CSO%20La%20Enredadera">Eventos</a></li>
<li><a href="comunicados.html">Comunicados</a></li>
<li><a href="mailto:enredadera@riseup.net">Contacto</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://twitter.com/Laenredadera">Twitter</a></li>
</ul>
</nav>
</aside>
<div class="container">
<!-- Contenido principal -->
<main class="main-content">
<header>
<h1>CSO LA ENREDADERA DE TETUÁN</h1>
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html" aria-current="page">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<!-- Respeto tu último nav tal cual -->
<nav class="top-nav">
<ul>
<li><a href="/campa.html/">CAMPAÑAS</a></li>
<li><a href="/">Inicio</a></li>
<li><a href="https://hacklab.laenre.net">Hacklab</a></li>
<li><a href="/index.html#yoga">Yoga</a></li>
<li><a href="/index.html#feminismo">Feminismos</a></li>
<li><a href="/index.html#fridays">F.F.F</a></li>
<li><a href="/index.html#OVT">OVt</a></li>
</ul>
</nav>
<nav class="top-nav second">
<ul>
<li><a href="/#musica_andina/">Música Andina</a></li>
<li><a href="/#biblioteka/">Biblioteka</a></li>
<li><a href="/#Warawas/">Warawas</a></li>
<li><a href="/#felines/">Felines</a></li>
<li><a href="/#Hongos/">Hongos</a></li>
<li><a href="/#Fermentos/">Fermentos</a></li>
<li><a href="/#bicilab/">Bicilab</a></li>
</ul>
</nav>
</header>
<main class="main">
<!-- Intro campañas -->
<section id="presentacion">
<h2>📣 Campañas</h2>
<header class="cabecera">
<h1>CAMPAÑAS</h1>
<p class="sub">En lo que andamos metidas ahora mismo</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<article class="texto">
<p>
Aquí reunimos las campañas que impulsamos desde La Enredadera y con el barrio:
defensa de vivienda, espacios comunes, apoyo mutuo y cultura libre.
Súmate, difunde y participa en las asambleas abiertas.
</p>
</section>
<!-- Campañas activas -->
<section id="activas">
<h2>🔥 Activas</h2>
<h2>Activas</h2>
<h3>Stop Desahucios Tetuán</h3>
<div class="tarjeta"><h3>Stop Desahucios Tetuán</h3>
<p>
Red de apoyo vecinal para frenar desahucios, acompañar a familias y presionar por soluciones reales.
</p>
<p>
<button onclick="window.location.href='#'">🗓️ Próximas convocatorias</button>
<button onclick="window.location.href='#'">📥 Cartelería / materiales</button>
</p>
<h3>Barrios Vivos: Espacio Público para la Gente</h3>
</div><div class="tarjeta"><h3>Barrios Vivos: Espacio Público para la Gente</h3>
<p>
Recuperar plazas y calles para la vida social, el juego, la cultura y el encuentro, frente a la especulación y el coche.
</p>
<p>
<button onclick="window.location.href='#'">📝 Propuestas ciudadanas</button>
<button onclick="window.location.href='#'">📥 Dossier de campaña</button>
</p>
<h3>Red de Cuidados y Apoyo Mutuo</h3>
</div><div class="tarjeta"><h3>Red de Cuidados y Apoyo Mutuo</h3>
<p>
Red comunitaria para acompañamiento a mayores, comedores vecinales, ropero y banco de alimentos.
</p>
<p>
<button onclick="window.location.href='#'">🤝 Cómo sumarte</button>
<button onclick="window.location.href='#'">📥 Recursos y guías</button>
</p>
</section>
</div>
<!-- Campañas pasadas / histórico -->
<section id="historico">
<h2>📚 Histórico</h2>
<!-- Los botones originales de estas tarjetas no llevaban a ninguna parte
(onclick="window.location.href='#'"). Los he quitado. Prometían:
🗓️ Próximas convocatorias · 📥 Cartelería / materiales · 📝 Propuestas ciudadanas · 📥 Dossier de campaña · 🤝 Cómo sumarte · 📥 Recursos y guías
Cuando exista el destino, se ponen como <ul class="enlaces">. -->
<h2>Histórico</h2>
<ul>
<li>Defensa de Centros Sociales (2009–2024)</li>
<li>Escuela Pública y de Barrio</li>
@ -125,14 +108,19 @@
Si quieres consultar materiales antiguos, escríbenos a
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a>.
</p>
</section>
</article>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
<footer>
<p>📩 Contacto: <a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></p>
<p>📌 Plaza de la Resistencia, Local 3, Madrid</p>
<p>⚡ Mastodon: <a href="https://mastodon.social/@laenre">@laenre@mastodon.social</a></p>
</footer>
</main>
</div>
</body>
</html>

View file

@ -3,76 +3,112 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Enredadera de Tetuán</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf">
<title>Contacto · La Enredadera de Tetuán</title>
<meta name="description" content="Cómo contactar y visitar el CSO La Enredadera de Tetuán: Calle de la Coruña 5, Madrid. Asamblea abierta los lunes.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Contacto · La Enredadera de Tetuán">
<meta property="og:description" content="Cómo contactar y visitar el CSO La Enredadera de Tetuán: Calle de la Coruña 5, Madrid. Asamblea abierta los lunes.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div class="container">
<div class="vertical-marquee left-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
NI DIOS NI AMO, NI PATRIA NI ESTADO -0->
</marquee>
</div>
<div class="vertical-marquee right-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
OKUPA RESISTE 1312 <3
</marquee>
</div>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<aside class="sidebar">
<img src="/INFO/logo_enre_negro.png" alt="Logo de La Enredadera" class="logo">
<h2>La Enredadera</h2>
<p>Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav">
<ul>
<li><a href="/subdir/sobre_nosotrxs.html">Sobre Nosotrxs</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://mad.convoca.la/place/CSO%20La%20Enredadera">Eventos</a></li>
<li><a href="comunicados.html">Comunicados</a></li>
<li><a href="mailto:enredadera@riseup.net">Contacto</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<div class="container">
<main class="main-content">
<header>
<h1>CSO LA ENREDADERA DE TETUÁN</h1>
<nav class="top-nav">
<ul>
<li><a href="/subdir/campa.html">campañas</a></li>
<li><a href="/">inicio</a></li>
<li><a href="https://hacklab.laenre.net">hacklab</a></li>
<li><a href="/index.html#yoga">yoga</a></li>
<li><a href="/index.html#feminismos">feminismos tetuan</a></li>
<li><a href="/index.html#fridays">f.f.f</a></li>
<li><a href="/index.html#OVT">ovt</a></li>
</ul>
</nav>
<nav class="top-nav second">
<ul>
<li><a href="/#circo/"></a>circo</li>
<li><a href="/#circo/"></a>Tienda Gratis</li>
<li><a href="/#biblioteka/">biblioteka</a></li>
<li><a href="/#Warawas/">warawas</a></li>
<li><a href="/#Hongos/">hongos</a></li>
<li><a href="/#Fermentos/">fermentos</a></li>
<li><a href="/#bicilab/">bicilab</a></li>
</ul>
</nav>
</header>
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
<li><a href="/subdir/contacto.html" aria-current="page">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main">
<header class="cabecera">
<h1>CONTACTO</h1>
<p class="sub">Cómo llegar, cuándo venir y cómo escribirnos</p>
</header>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<article class="texto">
<h2>Dónde estamos</h2>
<p>El CSO La Enredadera está en la <strong>Calle de la Coruña nº 5</strong>, en el barrio de
Tetuán, Madrid. Metro de Estrecho y Alvarado a un paseo.</p>
<div class="tarjeta">
<h3>Asamblea de bienvenida</h3>
<p>Si quieres conocer el espacio, proponer una actividad o sumarte a un colectivo,
el sitio es la asamblea. <strong>Todos los lunes de 19:00 a 20:00</strong> atendemos a
quien viene de fuera, y a las <strong>20:00</strong> empieza la asamblea de la Enre.</p>
<p>No hace falta avisar ni traer nada preparado. Ven y cuéntanos.</p>
</div>
<h2>Escríbenos</h2>
<ul class="enlaces">
<li><a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></li>
<li><a href="https://masto.es/@csolaenre" rel="me">Mastodon</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
</ul>
<h2>Quiero organizar algo aquí</h2>
<p>La Enre acoge actividades de colectivos y eventos puntuales de gente de fuera, pero
con unos acuerdos: asistencia a la asamblea previa, turno de puerta y barra durante el
evento, limpieza del espacio después y un 10% de lo recaudado para sostener la casa.</p>
<p>La música para a las 23:00 y el espacio se desaloja a las 23:30, por respeto al
vecindario. Los conciertos y pinchadas van en la planta baja, nunca en Villa Paloma.</p>
<p><strong>Líneas rojas:</strong> no hay sitio aquí para actitudes machistas, racistas,
homófobas, tránsfobas ni excluyentes de ningún tipo. Tampoco para espacios de
masculinidad exclusiva.</p>
<h2>Qué pasa cada semana</h2>
<p>Los horarios fijos de cada colectivo están en el <a href="/calendario.html">calendario</a>,
y lo que se convoca puntualmente en <a href="/eventos.html">eventos</a>.</p>
</article>
<footer>
<p>📩 Contacto: <a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></p>
<p>📌 Calle de la coruña nº5</p>
<p>⚡ Mastodon: <a href="https://mastodon.social/@laenre">@laenre@mastodon.social</a></p>
</footer>
</main>
</div>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
</body>
</html>

View file

@ -1,87 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>La Enredadera de Tetuán</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div class="container">
<div class="vertical-marquee left-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
NI DIOS NI AMO, NI PATRIA NI ESTADO -0->
</marquee>
</div>
<div class="vertical-marquee right-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
OKUPA RESISTE 1312 <3
</marquee>
</div>
<aside class="sidebar">
<img src="/INFO/logo_enre_negro.png" alt="Logo de La Enredadera" class="logo">
<h2>La Enredadera</h2>
<p>Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav">
<ul>
<li><a href="/subdir/sobre_nosotrxs.html">Sobre Nosotrxs</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://mad.convoca.la/place/CSO%20La%20Enredadera">Eventos</a></li>
<li><a href="comunicados.html">Comunicados</a></li>
<li><a href="mailto:enredadera@riseup.net">Contacto</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<main class="main-content">
<header>
<h1>CSO LA ENREDADERA DE TETUÁN</h1>
<nav class="top-nav">
<ul>
<li><a href="/subdir/campa.html">campañas</a></li>
<li><a href="/">inicio</a></li>
<li><a href="https://hacklab.laenre.net">hacklab</a></li>
<li><a href="/index.html#yoga">yoga</a></li>
<li><a href="/index.html#feminismos">feminismos tetuan</a></li>
<li><a href="/index.html#fridays">f.f.f</a></li>
<li><a href="/index.html#OVT">ovt</a></li>
</ul>
</nav>
<nav class="top-nav second">
<ul>
<li><a href="/#circo/"></a>circo</li>
<li><a href="/#circo/"></a>Tienda Gratis</li>
<li><a href="/#biblioteka/">biblioteka</a></li>
<li><a href="/#Warawas/">warawas</a></li>
<li><a href="/#Hongos/">hongos</a></li>
<li><a href="/#Fermentos/">fermentos</a></li>
<li><a href="/#bicilab/">bicilab</a></li>
</ul>
</nav>
</header>
<section id="ovt">
<h2> Ven a conocernos!</h2>
<p>Asamblea externos los lunes a las 19:00 - 20:00 </p>
<button <a href="https://okupanel.laenre.net">ACTIVIDADES COLECTIVES</button>
<button <a href="https://www.laenre.net/subdir/sobre_nosotrxs.html">SOBRE NOSOTRES</button>
<button <a href="https://www.laenre.net/#calendario">EVENTOS</button>
<button <a href="https://www.laenre.net/valores.html">CARTA DE VALORES</button>
</section>
<footer>
<p>📩 Contacto: <a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></p>
<p>📌 Calle de la coruña nº5</p>
<p>⚡ Mastodon: <a href="https://mastodon.social/@laenre">@laenre@mastodon.social</a></p>
</footer>
</main>
</div>
</body>
</html>

View file

@ -1,81 +1,68 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>La Enredadera de Tetuán — Sobre Nosotrxs</title>
<!-- Fuentes/estilos globales del sitio -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quiénes somos · La Enredadera de Tetuán</title>
<meta name="description" content="Quiénes somos, cómo empezó todo y cómo vemos Tetuán. CSO La Enredadera.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Quiénes somos · La Enredadera de Tetuán">
<meta property="og:description" content="Quiénes somos, cómo empezó todo y cómo vemos Tetuán. CSO La Enredadera.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="stylesheet" href="/index.css" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div class="container">
<!-- Marquesinas laterales -->
<div class="vertical-marquee left-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
NI DIOS NI AMO, NI PATRIA NI ESTADO -0->
</marquee>
</div>
<div class="vertical-marquee right-marquee">
<marquee behavior="scroll" direction="up" scrollamount="2">
OKUPA RESISTE 1312 &lt;3
</marquee>
</div>
<aside class="sidebar">
<img src="/INFO/logo_enre_negro.png" alt="Logo de La Enredadera" class="logo">
<h2>La Enredadera</h2>
<p>Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav">
<ul>
<li><a href="/subdir/sobre_nosotrxs.html">Sobre Nosotrxs</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://t.me/laenredadera">Eventos</a></li>
<li><a href="comunicados.html">Comunicados</a></li>
<li><a href="mailto:enredadera@riseup.net">Contacto</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://twitter.com/Laenredadera">Twitter</a></li>
</ul>
</nav>
</aside>
<input type="checkbox" id="nav-toggle" class="sr-only">
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
<!-- Contenido principal -->
<main class="main-content">
<header>
<h1>CSO LA ENREDADERA DE TETUÁN</h1>
<div class="container">
<nav class="top-nav">
<ul>
<li><a href="/">Inicio</a></li>
<li><a href="/CAMPAÑAS/">Campañas</a></li>
<li><a href="/colectivos/">Colectivos</a></li>
<li><a href="/feminismos/">Feminismos</a></li>
<li><a href="/hacklab/">Hacklab</a></li>
<li><a href="/OVT/">O.v.t</a></li>
<li><a href="/yoga/">Yoga</a></li>
<li><a href="/fridays/">Fridays for Future</a></li>
</ul>
</nav>
<aside class="sidebar">
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
menú, y la asamblea lo quiso arriba del todo. -->
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
<!-- Es la marca del panel, no un encabezado de sección: como el panel
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
del documento fuera de nivel 2 y el <h1> viniera después. -->
<p class="sb-marca">La Enredadera</p>
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
<nav class="sidebar-nav" aria-label="Secciones">
<ul>
<li><a href="/colectivos.html">Colectivos</a></li>
<li><a href="/calendario.html">Calendario</a></li>
<li><a href="/eventos.html">Eventos</a></li>
<li><a href="/subdir/campa.html">Campañas</a></li>
<li><a href="/subdir/sobre_nosotrxs.html" aria-current="page">La Enre</a></li>
<li><a href="/subdir/contacto.html">Contacto</a></li>
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
</ul>
</nav>
</aside>
<nav class="top-nav second">
<ul>
<li><a href="/Musica_andina/">Música Andina</a></li>
<li><a href="/biblioteka/">Biblioteka</a></li>
<li><a href="/Warawas/">Warawas</a></li>
<li><a href="/felines/">Felines</a></li>
<li><a href="/Hongos/">Hongos</a></li>
<li><a href="/Fermentos/">Fermentos</a></li>
<li><a href="/Bicilab/">Bicilab</a></li>
</ul>
</nav>
</header>
<main class="main">
<!-- Página: Sobre Nosotrxs -->
<article class="content-page readable">
<h2>Sobre Nosotrxs</h2>
<header class="cabecera">
<h1>LA ENRE</h1>
<p class="sub">Quiénes somos y cómo empezó todo esto</p>
</header>
<h3>QUIÉNES SOMOS</h3>
<a class="volver" href="/index.html">← Volver a la enredadera</a>
<article class="texto">
<h2>QUIÉNES SOMOS</h2>
<p>Somos un grupo muy diverso de vecinxs de Tetuán.</p>
<p>Algunxs vivimos en el barrio desde hace muchos años, otrxs hemos llegado recientemente o simplemente venimos de visita de vez en cuando, porque Tetuán es un buen «barrio de acogida«.</p>
<p>Algunxs somos jóvenes, otrxs ya tenemos alguna edad aunque conservamos un corazón joven aún.</p>
@ -87,7 +74,7 @@
<p>Entendemos La Enredadera como un espacio para la transformación social, donde aprendemos a relacionarnos de manera diferente, eliminando las relaciones de poder y donde damos pasos para construirnos como seres más libres. Intentamos generar herramientas para cambiar verdades inamovibles de nuestra vida y de nuestra sociedad, cambiar un sistema que nos oprime, basado en desigualdades y en la explotación del planeta.</p>
<p>¿No te dan ganas de conocernos? 🙂</p>
<h3>CÓMO EMPEZÓ TODO ESTO</h3>
<h2>CÓMO EMPEZÓ TODO ESTO</h2>
<p>Esa es una buena pregunta… seguramente todas las personas que participamos en la Enredadera podríamos contar una historia diferente de cuándo pisamos la Enre por primera vez. O de quién nos contó acerca de ella, o qué estaba ocurriendo el primer día que asomamos la cabeza por la puerta. Como en casi todo, aquí cada unx tiene su historia!</p>
<p>Pero podemos contarles sobre cómo surgió la idea de okupar un Centro Social para el Barrio.</p>
<p>Durante el final de 2007 y todo el año 2008, un grupo de vecinas y vecinos de la zona de Bellas Vistas habían estado, durante meses, organizando y disfrutando actividades en plena calle. La intención era recuperar el espacio público, recordarnos que la calle es de todos y todas y que merece la pena habitarla y encontrarnos con otrxs vecinxs en ella. En esa época entre cada vez más vecinxs, organizamos varios Mercadillos de Trueque, en la plaza de la calle Tenerife esq. San Raimundo, y también juegos callejeros, dos Pasacalles por un Barrio Mestizo y Solidario y, para culminar, todo un verano de sesiones de cine nocturno en plena calle.</p>
@ -95,7 +82,7 @@
<p>Y fue así que, pensando, esas personas decidimos okupar un edificio abandonado en el barrio de Tetuán, para que el mal tiempo no fuese un problema para todo el entusiasmo y la ilusión que teníamos en nuestro proyecto. Y durante la navidad de 2008, con mucho frío, algunas personas entramos en el edificio que ahora conocemos, que entonces aún no se llamaba Enredadera, y que estaba realmente en mal estado, porque los edificios que se dejan abandonados, como quizás ya sabrán, se llenan pronto de basura, ratas, goteras, humedades, y escombros. Pero como teníamos muchas ganas de utilizar ese espacio, trabajamos durante varios meses y en Junio de 2009, oficialmente, abrió las puertas La Enredadera, con una bonita fiesta de inauguración en la que armamos un pequeño taller de radio en plena calle, samba, payasos y hasta un dragón chino que vino desde China para apoyarnos!</p>
<p>Y desde entonces… pues han pasado muchas cosas! Mucha gente nos hemos ido acercando y conociendo, y muchas actividades, muchos aprendizajes y muchas cosas que nos hemos enseñado unxs a otrxs…. y hasta un desalojo judicial, en Febrero de 2010! Y una amenaza de precinto iniciada en verano de 2016 por parte del Ayuntamiento de Madrid! Pero, como advertíamos, La Enre No Se Toca y aquí seguimos, llevando a cabo todo aquello que no hemos dejado de soñar ni por un minuto!</p>
<h3>CÓMO VEMOS NUESTRO BARRIO</h3>
<h2>CÓMO VEMOS NUESTRO BARRIO</h2>
<p>Tetuán es un barrio muy diverso, ¿no creen?</p>
<p>En sus calles se encuentran personas de muchos países diferentes, que hablan lenguas muy distintas; hay jóvenes y no tan jóvenes, hay diferentes rostros, diferentes modas, diferentes peinados y muchas formas distintas de vestir, cantar, reír, bailar…</p>
<p>Sí, ¿verdad? Nuestro barrio es muy variado! Hay calles que parecen ser de un pequeño pueblito y hay grandes avenidas que van llenas de coches… demasiados coches, es cierto, en nuestro barrio el tráfico es bien cansado, aunque ya mucha gente se decide a coger la bici porque tanto coche hace que el aire sea difícil de respirar.</p>
@ -105,14 +92,19 @@
<p>Y la misma policía también saca a veces por la fuerza a personas que okupan casas o edificios de gente rica y que llevan abandonados muchos años, para reutilizarlos y darles un uso, porque ya saben que la vivienda está muy muy cara en Tetuán, como en Madrid. Y hay gente que ya dice que «ya es suficiente» y se mete en una casa abandonada para arreglarla y hacerla su hogar, pero la policía les molesta y les echa a la calle, y eso nos parece injusto, porque es una injusticia que con la necesidad de viviendas que hay, haya casas vacías en el barrio porque a sus propietarios ricos les apetece especular con ellas.</p>
<p>Esto es más o menos un conjunto de ideas de cómo vemos el barrio. Todo esto y muchas cosas más ocurren a diario en Tetuán; lo que ocurre es que a veces no nos enteramos porque caminamos por la calle cansad@s o no prestamos mucha atención a lo que vemos en la calle, porque tenemos ganas de regresar a casa a descansar. O porque creemos que lo que vemos en la calle «no es nuestro problema», porque a veces todavía se nos olvida que lo que les ocurre a nuestrxs vecinxs, podría ocurrirnos a nosotrxs mismxs, y que si no nos cuidamos entre nosotrxs, que somos la gente del barrio, nadie nos va a cuidar jamás.</p>
<p>Nuestro barrio, como decíamos, es un barrio muy variado. Hay de todo, y eso es una suerte, porque habiendo tanta variedad, podemos tener muchos puntos de vista y muchas ideas diferentes para trabajar juntxs en un barrio mejor. Pero aun así, todavía ocurre, según observamos, algunos vivimos muy separados de otros; por eso, ya desde hace un tiempo, estamos intentando crear una red de todos los vecinos y vecinas, para que así ya no seamos más desconocidos ni extraños y que nos reconozcamos, nos saludemos y nos preocupamos los unos de las otras, las unas de los otros. Esa es la forma más bonita y real de solidaridad que conocemos. Y queremos ponerla en práctica!</p>
</article>
</article>
<footer class="pie-pagina">
<p>
Calle de la Coruña nº 5, Madrid ·
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
<a href="https://masto.es/@csolaenre">@csolaenre</a>
</p>
</footer>
</main>
</div>
<footer>
<p>📩 Contacto: <a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a></p>
<p>📌 Plaza de la Resistencia, Local 3, Madrid</p>
<p>⚡ Mastodon: <a href="https://mastodon.social/@laenre">@laenre@mastodon.social</a></p>
</footer>
</main>
</div>
</body>
</html>

587
tatus.html Normal file
View file

@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Tattoo Circus en el CSO La Enredadera de Tetuan: tres dias de tatuajes, charlas, conciertos, cabaret y freakshow. Calle de la Coruna 5, Madrid.">
<!-- Vista previa al compartir el enlace (Mastodon, Telegram, WhatsApp). -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="CSO La Enredadera de Tetuán">
<meta property="og:locale" content="es_ES">
<meta property="og:title" content="Tattoo Circus · La Enredadera">
<meta property="og:description" content="Tattoo Circus en el CSO La Enredadera de Tetuan: tres dias de tatuajes, charlas, conciertos, cabaret y freakshow. Calle de la Coruna 5, Madrid.">
<meta property="og:image" content="/INFO/cso_laenre.jpeg">
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
<meta name="twitter:card" content="summary_large_image">
<meta name="referrer" content="no-referrer-when-downgrade">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
<title>Tattoo Circus · La Enredadera</title>
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<style>
/* Este micrositio es oscuro por diseño. */
:root{ color-scheme: dark; }
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
@font-face {
font-family: 'Rock Salt';
src: url('/fuentes/rocksalt-latin.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Anton';
src: url('/fuentes/anton-latin.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Special Elite';
src: url('/fuentes/specialelite-latin.woff2') format('woff2');
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: 'Code462WPink';
src: url('/Code462WPink-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root{
--neon: #E84A1F;
--neon-soft: #F26B33;
--bg: #0a0a0a;
--cream: #f4ecd8;
}
*{ box-sizing: border-box; }
html, body{
margin: 0;
padding: 0;
background: var(--bg);
color: var(--neon);
font-family: 'Special Elite', 'Courier New', monospace;
min-height: 100vh;
}
body{
background:
radial-gradient(ellipse at top, rgba(232,74,31,.12), transparent 60%),
radial-gradient(ellipse at bottom, rgba(232,74,31,.08), transparent 60%),
var(--bg);
}
.topbar{
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 22px;
border-bottom: 2px solid var(--neon);
box-shadow: 0 0 18px rgba(232,74,31,.4);
}
.topbar a.back{
font-family: 'Anton', Impact, sans-serif;
letter-spacing: 1.5px;
font-size: 1rem;
color: #000;
background: var(--neon);
padding: 8px 14px;
border-radius: 18px;
text-decoration: none;
text-transform: uppercase;
border: 2px solid #000;
box-shadow: 0 0 10px var(--neon);
transition: .2s;
}
/* :active también: en un móvil no hay :hover y el toque no respondía. */
.topbar a.back:hover,
.topbar a.back:active{
background: #000;
color: var(--neon);
border-color: var(--neon);
}
.topbar .actions{
display: flex;
gap: 10px;
align-items: center;
}
.topbar .brand{
font-family: 'Anton', Impact, sans-serif;
color: var(--neon);
letter-spacing: 2px;
text-transform: uppercase;
font-size: .95rem;
text-shadow: 0 0 8px var(--neon);
}
header.hero{
background: var(--cream);
color: #000;
text-align: center;
padding: 40px 20px 28px;
border-bottom: 4px solid #000;
}
header.hero h1{
font-family: 'Code462WPink', 'Rock Salt', cursive;
color: #000;
font-size: clamp(2.6rem, 9vw, 6rem);
margin: 0;
letter-spacing: 10px;
text-transform: uppercase;
line-height: 1;
}
header.hero .sub{
margin-top: 14px;
font-family: 'Anton', Impact, sans-serif;
letter-spacing: 6px;
text-transform: uppercase;
color: #000;
opacity: .7;
font-size: 1rem;
}
.intro{
max-width: 820px;
margin: 10px auto 30px;
padding: 0 22px;
text-align: center;
color: var(--neon-soft);
font-size: 1.1rem;
line-height: 1.6;
text-shadow: 0 0 4px rgba(232,74,31,.3);
}
.panel{
max-width: 880px;
margin: 20px auto 50px;
padding: 0;
background: #000;
border: 2px solid var(--neon);
border-radius: 14px;
overflow: hidden;
box-shadow:
0 0 18px rgba(232,74,31,.5),
inset 0 0 30px rgba(232,74,31,.08);
}
.panel h2{
font-family: 'Anton', Impact, sans-serif;
color: #000;
background: var(--neon);
display: block;
text-align: center;
padding: 14px 18px;
margin: 0;
border-radius: 0;
letter-spacing: 8px;
text-transform: uppercase;
font-size: 1.9rem;
box-shadow: 0 0 14px var(--neon);
border-top: 3px solid #000;
border-bottom: 3px solid #000;
}
.panel h2:first-of-type{ border-top: none; }
.panel .day{
padding: 0;
margin: 0;
}
.panel .slot{
display: grid;
grid-template-columns: 110px 1fr;
gap: 18px;
padding: 14px 24px;
margin: 0;
align-items: baseline;
border-bottom: 1px solid rgba(232,74,31,.35);
}
.panel .day .slot:last-child{ border-bottom: none; }
/* === FILAS ALTERNADAS CREMA / NEGRO === */
.panel .day .slot:nth-child(odd){
background: var(--cream);
color: #000;
}
.panel .day .slot:nth-child(even){
background: #000;
color: var(--neon-soft);
}
.panel .time{
font-family: 'Anton', Impact, sans-serif;
font-size: 1.2rem;
letter-spacing: 1px;
white-space: nowrap;
}
.panel .day .slot:nth-child(odd) .time{
color: #000;
text-shadow: none;
}
.panel .day .slot:nth-child(even) .time{
color: var(--neon);
text-shadow: 0 0 6px rgba(232,74,31,.5);
}
.panel .act{
font-size: 1.02rem;
line-height: 1.5;
}
.panel .act strong{
display: block;
font-family: 'Anton', Impact, sans-serif;
font-weight: 700;
font-size: 1.15em;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.panel .day .slot:nth-child(odd) .act strong{
color: #000;
text-shadow: none;
}
.panel .day .slot:nth-child(even) .act strong{
color: var(--neon);
text-shadow: 0 0 4px rgba(232,74,31,.4);
}
.panel .act .detail{
display: block;
font-size: .92rem;
margin-top: 4px;
font-style: italic;
}
.panel .day .slot:nth-child(odd) .act .detail{ color: #3a3a3a; }
.panel .day .slot:nth-child(even) .act .detail{ color: #d4a070; }
.panel .act .crew{
display: block;
font-size: .85rem;
margin-top: 5px;
}
.panel .day .slot:nth-child(odd) .act .crew{ color: #555; }
/* Sin opacidad: el naranja sobre negro está a 5.1:1 y atenuarlo al 85%
lo bajaba a 4.1:1, por debajo del mínimo. La jerarquía ya la marcan el
tamaño y el color, que alterna gris en las filas impares. */
.panel .day .slot:nth-child(even) .act .crew{ color: var(--neon); }
.panel .highlight{
padding: 2px 10px;
border-radius: 4px;
font-weight: 700;
letter-spacing: 3px;
}
.panel .day .slot:nth-child(odd) .highlight{
background: #000;
color: var(--cream);
}
.panel .day .slot:nth-child(even) .highlight{
background: var(--neon);
color: #000;
box-shadow: 0 0 8px var(--neon);
}
footer{
text-align: center;
padding: 24px 16px 36px;
color: var(--neon);
font-family: 'Anton', Impact, sans-serif;
letter-spacing: 3px;
text-transform: uppercase;
font-size: .9rem;
text-shadow: 0 0 6px var(--neon);
}
footer a{
color: var(--neon);
text-decoration: none;
border-bottom: 1px dashed var(--neon);
}
@media (max-width: 560px){
.topbar{ padding: 10px 14px; }
.topbar .brand{ font-size: .75rem; letter-spacing: 1px; }
.topbar a.back{ font-size: .82rem; padding: 6px 10px; }
header.hero{ padding: 30px 12px 18px; }
header.hero h1{ letter-spacing: 4px; }
header.hero .sub{ font-size: .8rem; letter-spacing: 3px; }
.panel{ margin: 14px 10px 30px; padding: 0; }
.panel h2{ font-size: 1.4rem; letter-spacing: 4px; padding: 10px 12px; }
.panel .slot{ grid-template-columns: 70px 1fr; gap: 10px; padding: 12px 14px; }
.panel .time{ font-size: 1rem; }
.panel .act{ font-size: .95rem; }
.intro{ font-size: 1rem; }
}
/* === ESCRITORIO: panel centrado, fuentes equilibradas === */
@media (min-width: 1024px){
header.hero{ padding: 50px 30px 30px; }
header.hero h1{
font-size: clamp(3.5rem, 6.5vw, 5.5rem);
letter-spacing: 10px;
}
header.hero .sub{
font-size: 1.1rem;
letter-spacing: 7px;
margin-top: 16px;
}
.panel{
max-width: 1100px;
margin: 30px auto 60px;
padding: 0;
border-width: 2px;
}
.panel h2{
font-size: 2.2rem;
letter-spacing: 9px;
padding: 16px 22px;
margin: 0;
border-radius: 0;
}
.panel .day{
padding: 0;
margin: 0;
}
.panel .slot{
grid-template-columns: 150px 1fr;
gap: 28px;
padding: 18px 36px;
margin: 0;
align-items: baseline;
}
.panel .time{
font-size: 1.6rem;
letter-spacing: 2px;
}
.panel .act{
font-size: 1.2rem;
line-height: 1.5;
}
.panel .act strong{
font-size: 1.3rem;
}
.panel .act .detail{
font-size: 1rem;
margin-top: 5px;
}
.panel .act .crew{
font-size: .92rem;
margin-top: 6px;
}
.panel .highlight{
padding: 3px 12px;
font-size: 1.3rem;
}
footer{
font-size: 1rem;
letter-spacing: 5px;
padding: 32px 16px 44px;
}
.topbar{ padding: 16px 28px; }
.topbar .brand{ font-size: 1rem; letter-spacing: 2px; }
.topbar a.back{ font-size: .95rem; padding: 9px 16px; }
}
/* === PANTALLAS XL: un punto más, sin pasarse === */
@media (min-width: 1600px){
.panel{ max-width: 1280px; }
.panel .slot{ grid-template-columns: 170px 1fr; padding: 20px 44px; }
.panel .time{ font-size: 1.85rem; }
.panel .act{ font-size: 1.35rem; }
.panel .act strong{ font-size: 1.45rem; }
.panel h2{ font-size: 2.5rem; letter-spacing: 11px; }
header.hero h1{ font-size: clamp(4rem, 6vw, 6.5rem); letter-spacing: 12px; }
header.hero .sub{ font-size: 1.25rem; letter-spacing: 9px; }
}
/* Texto solo para lectores de pantalla. Esta página tiene su propio
CSS, así que la clase no se hereda de index.css. */
.sr-only{
position: absolute !important; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; border: 0;
}
</style>
</head>
<body>
<div class="topbar">
<span class="brand">★ La Enredadera de Tetuán ★</span>
<div class="actions">
<a class="back" href="/panel.html" target="_blank" rel="noopener">📺 Panel<span class="sr-only"> (abre en otra pestaña)</span></a>
<a class="back" href="/">← volver</a>
</div>
</div>
<header class="hero">
<h1>TATTOO CIRCUS</h1>
<div class="sub">Madrid · Fin de semana de jornadas</div>
</header>
<section class="panel" aria-label="Cartel del fin de semana">
<h2>Viernes</h2>
<div class="day">
<div class="slot">
<div class="time">16:00</div>
<div class="act"><strong>Apertura</strong></div>
</div>
<div class="slot">
<div class="time">16:30</div>
<div class="act">
<strong>Taller de carteo a presxs</strong>
<span class="detail">Agrietando los muros de prisión</span>
</div>
</div>
<div class="slot">
<div class="time">18:00</div>
<div class="act">
<strong>¿Se puede regularizar la vida en un sistema colonial?</strong>
<span class="detail">Dinámicas para entender el proceso de documentación desde una perspectiva decolonial y antifascista.</span>
<span class="crew">A cargo de Articulación Antirracista de Madrid y Sindicato de Manteros</span>
</div>
</div>
<div class="slot">
<div class="time">20:00</div>
<div class="act"><strong>Cena</strong></div>
</div>
<div class="slot">
<div class="time">20:30</div>
<div class="act"><strong><span class="highlight">CABARET DE CIRCO</span></strong></div>
</div>
<div class="slot">
<div class="time">23:00</div>
<div class="act"><strong>Cierre</strong></div>
</div>
</div>
<h2>Sábado</h2>
<div class="day">
<div class="slot">
<div class="time">11:30</div>
<div class="act"><strong>Apertura</strong></div>
</div>
<div class="slot">
<div class="time">12:00</div>
<div class="act">
<strong>Manifestación Orgullo Loco</strong>
<span class="detail">Centro de salud mental de Tetuán · Ni terapia ni castigos ni explotación: lokes-delinkuentes-animales por la abolición</span>
</div>
</div>
<div class="slot">
<div class="time">14:00</div>
<div class="act"><strong>Comida</strong></div>
</div>
<div class="slot">
<div class="time">16:00</div>
<div class="act">
<strong>Contra el psistema productivo</strong>
<span class="detail">Manicomios, orfanatos, asilos y otras instituciones de control, vigilancia y castigo</span>
<span class="crew">A cargo de INSANIA</span>
</div>
</div>
<div class="slot">
<div class="time">18:00</div>
<div class="act">
<strong>Salir con vida</strong>
<span class="detail">Autobiografía de un preso en lucha</span>
<span class="crew">A cargo de Toni Chavero</span>
</div>
</div>
<div class="slot">
<div class="time">20:00</div>
<div class="act"><strong>Cena</strong></div>
</div>
<div class="slot">
<div class="time">20:30</div>
<div class="act"><strong><span class="highlight">CONCIERTOS</span></strong></div>
</div>
<div class="slot">
<div class="time">23:00</div>
<div class="act"><strong>Cierre</strong></div>
</div>
</div>
<h2>Domingo</h2>
<div class="day">
<div class="slot">
<div class="time">11:30</div>
<div class="act"><strong>Apertura</strong></div>
</div>
<div class="slot">
<div class="time">12:00</div>
<div class="act">
<strong>Las familias y la cárcel</strong>
<span class="crew">A cargo de la Coordinadora de Familias y Presxs y de Famílies de Presos de Catalunya</span>
</div>
</div>
<div class="slot">
<div class="time">14:00</div>
<div class="act"><strong>Comida</strong></div>
</div>
<div class="slot">
<div class="time">16:00</div>
<div class="act">
<strong>Más fuertes que la muerte, más fuertes que la prisión</strong>
<span class="detail">Actualización de las últimas oleadas de represión anti-anarquista a nivel internacional</span>
</div>
</div>
<div class="slot">
<div class="time">18:00</div>
<div class="act">
<strong>Taller de identificación policial</strong>
<span class="detail">Herramientas para identificar a agentes policiales y proteger derechos en contextos de protesta</span>
<span class="crew">A cargo de LEGAL SOL</span>
</div>
</div>
<div class="slot">
<div class="time">19:30</div>
<div class="act">
<strong>Flamenco</strong>
<span class="detail">Un quejío contra los muros</span>
</div>
</div>
<div class="slot">
<div class="time">20:00</div>
<div class="act"><strong>Cena</strong></div>
</div>
<div class="slot">
<div class="time">20:30</div>
<div class="act"><strong><span class="highlight">FREAKSHOW</span></strong></div>
</div>
<div class="slot">
<div class="time">22:00</div>
<div class="act"><strong>Rifa</strong></div>
</div>
<div class="slot">
<div class="time">23:00</div>
<div class="act"><strong>Cierre</strong></div>
</div>
</div>
</section>
<footer>
★ Tattoo Circus · <a href="/">laenre.net</a> ★
</footer>
</body>
</html>