Compare commits
4 commits
main
...
rediseno-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5dbeec60 | ||
|
|
ce3127baff | ||
|
|
c3b51be992 | ||
|
|
8bcaf1644c |
51 changed files with 8472 additions and 1377 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal 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
90
404.html
Normal 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
BIN
INFO/cartel_tatus.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
484
README.md
484
README.md
|
|
@ -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
275
calendario.html
Normal 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">←</button>
|
||||
<div class="cal-title semanal-title" aria-live="polite"></div>
|
||||
<button class="button semanal-next" type="button" aria-label="Mes siguiente">→</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
389
colectivos.html
Normal 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
69
despliegue/README.md
Normal 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`.
|
||||
19
despliegue/config.ejemplo.json
Normal file
19
despliegue/config.ejemplo.json
Normal 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
23
despliegue/cuenta.sh
Executable 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 "$@"
|
||||
44
despliegue/enrecal.service
Normal file
44
despliegue/enrecal.service
Normal 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
77
despliegue/instalar.sh
Executable 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
|
||||
28
despliegue/nginx-laenre.conf
Normal file
28
despliegue/nginx-laenre.conf
Normal 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;
|
||||
}
|
||||
3
despliegue/nginx-zona.conf
Normal file
3
despliegue/nginx-zona.conf
Normal 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
103
eventos.html
Normal 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
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
12
favicon.svg
Normal file
12
favicon.svg
Normal 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
BIN
fuentes/anton-latin.woff2
Normal file
Binary file not shown.
BIN
fuentes/rocksalt-latin.woff2
Normal file
BIN
fuentes/rocksalt-latin.woff2
Normal file
Binary file not shown.
BIN
fuentes/specialelite-latin.woff2
Normal file
BIN
fuentes/specialelite-latin.woff2
Normal file
Binary file not shown.
238
gestor/almacen.go
Normal file
238
gestor/almacen.go
Normal 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
388
gestor/auth.go
Normal 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
99
gestor/config.go
Normal 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
7
gestor/go.mod
Normal 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
4
gestor/go.sum
Normal 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
363
gestor/main.go
Normal 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
183
gestor/modelo.go
Normal 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
1074
gestor/pedir.go
Normal file
File diff suppressed because it is too large
Load diff
68
gestor/pedir_test.go
Normal file
68
gestor/pedir_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
92
gestor/plantillas/_marco.tmpl.html
Normal file
92
gestor/plantillas/_marco.tmpl.html
Normal 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}}
|
||||
42
gestor/plantillas/_mes.tmpl.html
Normal file
42
gestor/plantillas/_mes.tmpl.html
Normal 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}}
|
||||
31
gestor/plantillas/clave.tmpl.html
Normal file
31
gestor/plantillas/clave.tmpl.html
Normal 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" .}}
|
||||
89
gestor/plantillas/cuentas.tmpl.html
Normal file
89
gestor/plantillas/cuentas.tmpl.html
Normal 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" .}}
|
||||
49
gestor/plantillas/editar.tmpl.html
Normal file
49
gestor/plantillas/editar.tmpl.html
Normal 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" .}}
|
||||
9
gestor/plantillas/fallo.tmpl.html
Normal file
9
gestor/plantillas/fallo.tmpl.html
Normal 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" .}}
|
||||
45
gestor/plantillas/findes.tmpl.html
Normal file
45
gestor/plantillas/findes.tmpl.html
Normal 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" .}}
|
||||
73
gestor/plantillas/mis-fechas.tmpl.html
Normal file
73
gestor/plantillas/mis-fechas.tmpl.html
Normal 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" .}}
|
||||
61
gestor/plantillas/nueva.tmpl.html
Normal file
61
gestor/plantillas/nueva.tmpl.html
Normal 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" .}}
|
||||
35
gestor/plantillas/pendientes.tmpl.html
Normal file
35
gestor/plantillas/pendientes.tmpl.html
Normal 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" .}}
|
||||
66
gestor/plantillas/portada.tmpl.html
Normal file
66
gestor/plantillas/portada.tmpl.html
Normal 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" .}}
|
||||
53
gestor/plantillas/todas.tmpl.html
Normal file
53
gestor/plantillas/todas.tmpl.html
Normal 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
264
gestor/reglas.go
Normal 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
276
gestor/reglas_test.go
Normal 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
168
gestor/vista.go
Normal 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
2182
index.css
File diff suppressed because one or more lines are too long
501
index.html
501
index.html
|
|
@ -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 · Tetuán, Madrid · 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">←</button>
|
||||
<div class="cal-title semanal-title" aria-live="polite"></div>
|
||||
<button class="button semanal-next" type="button" aria-label="Mes siguiente">→</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
624
panel.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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 <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
587
tatus.html
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue