Sección «Pedir evento» con gestor de reservas y calendario legible en móvil

- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
This commit is contained in:
SITO 2026-09-23 11:32:25 +02:00
parent c3b51be992
commit ce3127baff
38 changed files with 4029 additions and 52 deletions

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -31,6 +28,9 @@
<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

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -32,6 +29,9 @@
<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

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -61,6 +58,9 @@
<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

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -33,6 +30,9 @@
<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

BIN
fuentes/anton-latin.woff2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

238
gestor/almacen.go Normal file
View file

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

355
gestor/auth.go Normal file
View file

@ -0,0 +1,355 @@
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
)
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 := argon2.IDKey([]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
}
calculado := argon2.IDKey([]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) 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,
}
}
func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
recientes := c.podar(k, ahora)
if len(recientes) >= c.maxIntentos {
ultimo := recientes[len(recientes)-1]
if falta := c.espera - ahora.Sub(ultimo); falta > 0 {
return true, falta
}
}
}
return false, 0
}
func (c *Cerrojo) Fallo(claves ...string) {
c.mu.Lock()
defer c.mu.Unlock()
ahora := time.Now()
for _, k := range claves {
c.fallos[k] = append(c.podar(k, ahora), ahora)
}
}
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()
}
}

94
gestor/config.go Normal file
View file

@ -0,0 +1,94 @@
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: "/var/www/laenre/gestor/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
ventanaIntentos = 10 * time.Minute
esperaBloqueo = 15 * time.Minute
)

7
gestor/go.mod Normal file
View file

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

4
gestor/go.sum Normal file
View file

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

352
gestor/main.go Normal file
View file

@ -0,0 +1,352 @@
// 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
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 o interno")
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),
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) vista() Vista {
return Vista{
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 != RolEnre && rol != RolInterno {
return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno)
}
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
}
}
}

171
gestor/modelo.go Normal file
View file

@ -0,0 +1,171 @@
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 casa: solicita y amadrina
)
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
}

1042
gestor/pedir.go Normal file

File diff suppressed because it is too large Load diff

23
gestor/pedir_test.go Normal file
View file

@ -0,0 +1,23 @@
package main
import "testing"
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)
}
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,87 @@
{{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 casa</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="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 pide un colectivo de la casa
que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder.
</p>
</section>
{{template "abajo" .}}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,64 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Vuestro cupo">
<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>
<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}}">{{.Etiqueta}}</span></td>
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
<td>
{{- if .Liberable}}
<form method="post" action="/pedir/liberar">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.Evento.ID}}">
<button type="submit" class="pedir-boton-flojo">Liberar</button>
</form>
{{- end}}
</td>
</tr>
{{- end}}
</tbody>
</table>
</div>
<p class="pie">
Una solicitud sin confirmar se borra sola a los {{.Reglas.DiasMaximoPendiente}} días,
o cuando falten menos de {{.Reglas.DiasMinimoConfirmar}} para la fecha. La columna
«caduca» dice cuándo, y al caducar la fecha queda libre para el resto.
</p>
{{- else}}
<p class="pie">No tenéis ninguna fecha pedida.</p>
{{- end}}
</section>
{{template "abajo" .}}

View file

@ -0,0 +1,51 @@
{{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>
<li>Como mucho <strong>{{.Reglas.CupoDias}} días por colectivo</strong> en esos {{.Reglas.MesesVentana}} meses. Os quedan {{.Restantes}}.</li>
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
<li>Si el evento es de un colectivo de fuera, decid cuál: vuestro colectivo lo amadrina y responde por él.</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>
<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>
</p>
<p>
<label for="contacto">Contacto de ese colectivo</label>
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Sol.Contacto}}">
<span class="pista">Correo o teléfono de quien responde por ellos.</span>
</p>
<p>
<label for="notas">Notas para la asamblea</label>
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Sol.Notas}}</textarea>
</p>
<p><button type="submit">Pedir la fecha</button></p>
</form>
</section>
{{template "abajo" .}}

View file

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

View file

@ -0,0 +1,65 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
<h2>Qué findes están cogidos</h2>
<p class="pie">
Pincha 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>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-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>Cómo se pide</h2>
<p class="pie">
Si tu colectivo <strong>no es de la casa</strong>, la fecha se pide en persona: ven a
la <strong>asamblea de externas, los lunes a las 19:00</strong>, en la Enredadera,
calle de la Coruña nº 5. Allí se habla del evento y un colectivo de la casa lo
amadrina y se hace responsable.
</p>
<p class="pie">
Si tu colectivo <strong>ya tiene credenciales</strong>, entra aquí y pide la fecha.
Desde aquí se piden también las de los colectivos de fuera a los que amadrinéis.
</p>
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
<p>
<label for="usuario">Colectivo</label>
<input id="usuario" name="usuario" type="text" required maxlength="32"
autocapitalize="none" spellcheck="false">
</p>
<p>
<label for="clave">Contraseña</label>
<input id="clave" name="clave" type="password" required maxlength="256">
</p>
<p><button type="submit">Entrar</button></p>
</form>
<p class="pie">
No hay recuperación de contraseña por correo, a propósito: así no hay ninguna
dirección guardada ni ningún formulario que atacar. Si la habéis perdido, se resetea
en la asamblea.
</p>
</section>
{{- end}}
{{template "abajo" .}}

View file

@ -0,0 +1,53 @@
{{template "arriba" .}}
<section class="pantalla" aria-label="Todas las fechas">
<h2>Todas las fechas que vienen</h2>
{{- if .Filas}}
<div class="tabla-envoltorio">
<table class="pedir-tabla">
<thead>
<tr>
<th scope="col">Fecha</th>
<th scope="col">Evento</th>
<th scope="col">Estado</th>
<th scope="col"><span class="sr-only">Acciones</span></th>
</tr>
</thead>
<tbody>
{{- range .Filas}}
<tr>
<td>
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
</td>
<td>
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
</td>
<td><span class="estado estado-{{.Evento.Estado}}">{{.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" .}}

252
gestor/reglas.go Normal file
View file

@ -0,0 +1,252 @@
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
}
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.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)
libre := s.Rol == RolEnre // la asamblea de la casa 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()))
}
}
if !libre {
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
}

250
gestor/reglas_test.go Normal file
View file

@ -0,0 +1,250 @@
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)
}
}

152
gestor/vista.go Normal file
View file

@ -0,0 +1,152 @@
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
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 {
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
}
if 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,
Etiqueta: e.Estado.Etiqueta(),
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
}
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)
}

434
index.css
View file

@ -97,6 +97,22 @@
font-weight:normal; font-style:normal; font-display:swap;
}
/* Anton y Rock Salt, servidas desde aquí. Antes las traía cada página con un
<link> a fonts.googleapis.com: eso son dos conexiones más antes de pintar
nada y, sobre todo, la IP de cada persona que entra en la web de un centro
social okupado pasando por Google. Son los subconjuntos latinos, 77 KB entre
las dos, los mismos ficheros que servía Google: se ve exactamente igual. */
@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:'Rock Salt';
src:url('/fuentes/rocksalt-latin.woff2') format('woff2');
font-weight:400; font-style:normal; font-display:swap;
}
/* ============================ 2. BASE ============================ */
*,*::before,*::after{ box-sizing:border-box; }
html,body{ margin:0; padding:0; overflow-x:hidden; }
@ -1009,30 +1025,78 @@ gancio-calendar{
#redes-toggle:checked ~ .brotes > li{ visibility:visible; }
/* En pantallas muy estrechas, siete columnas dan 55px cada una y ahí no cabe
ningún texto útil: "Autodefensa 20:00" quedaba en "Autod...". Se cambia por
una barra del color del colectivo, que la leyenda de debajo explica. Sigue
siendo pulsable y el nombre completo queda en el title para lectores. */
ningún texto útil: "Autodefensa 20:00" quedaba en "Autod...".
Durante un tiempo la salida fue quitarle el texto a la píldora
—`font-size:0`— y dejar sólo una barra del color del colectivo, con la
leyenda de debajo para descifrarla. La asamblea lo rechazó: en el móvil hay
que leer la hora y el nombre, no interpretar colores.
Así que por debajo de 560px la rejilla deja de ser rejilla y se convierte en
una LISTA DE DÍAS, uno debajo de otro y a todo el ancho. Ahí el texto entra
entero. Se hace sólo con CSS: el guión del calendario sigue dibujando
exactamente el mismo DOM y no se toca ni una línea de él.
En ordenador y en tableta no cambia nada. */
@media (max-width:560px){
/* La barra se VE de 10px pero el elemento MIDE 24: el relleno vertical
agranda el área a la que llega el dedo y `background-clip:content-box`
deja el color sólo en la franja del medio. Con 10px de verdad, tocarla en
un móvil era imposible, y la página dice «pulsa cualquier etiqueta».
24px es el mínimo de WCAG 2.5.8. */
/* El marco de la rejilla sobra: ahora cada día es su propia tarjeta. */
.cal{ border:none; background:transparent; overflow:visible; border-radius:0; }
/* Los rótulos L M X J V S D sólo tienen sentido en siete columnas. */
.cal-row.cal-head{ display:none; }
/* `contents` disuelve la fila: las celdas pasan a ser hijas de .cal y caen
una debajo de otra. La regla de más arriba que esconde la semana entera
vacía tiene más especificidad, así que sigue mandando. */
.cal-row{ display:contents; }
/* La celda deja de ser columna flexible y pasa a bloque, para que el día de
la semana y el número salgan en la misma línea y las píldoras debajo. */
.cal-cell{
display:block; min-height:0;
margin:0 0 8px; padding:10px 12px 12px;
border:var(--pluma) solid var(--rosa-tenue); border-radius:var(--curva);
background:var(--verde-humo);
}
/* Los días sin nada no pintan nada en una lista, y un mes entero son treinta
tarjetas. Se quedan los que tienen actividad y el de hoy, aunque esté
vacío. Donde no haya :has() salen todos: se ve más largo, no roto. */
.cal-cell:not(:has(.cal-pills)):not(:has(.cal-turno)):not(.cal-today){ display:none; }
.cal-cell.cal-empty{ display:none; }
/* El nombre del día lo pone el CSS a partir de la posición dentro de la
fila: la columna 1 es lunes. `display:contents` no toca el árbol, así que
:nth-child sigue contando igual. */
.cal-cell::before{
font-family:var(--titular); font-size:.82rem; letter-spacing:.12em;
text-transform:uppercase; color:var(--rosa-legible); margin-right:7px;
}
.cal-row > .cal-cell:nth-child(1)::before{ content:"Lunes"; }
.cal-row > .cal-cell:nth-child(2)::before{ content:"Martes"; }
.cal-row > .cal-cell:nth-child(3)::before{ content:"Miércoles"; }
.cal-row > .cal-cell:nth-child(4)::before{ content:"Jueves"; }
.cal-row > .cal-cell:nth-child(5)::before{ content:"Viernes"; }
.cal-row > .cal-cell:nth-child(6)::before{ content:"Sábado"; }
.cal-row > .cal-cell:nth-child(7)::before{ content:"Domingo"; }
.cal-daynum{ display:inline; font-size:1.05rem; color:var(--tinta); }
.cal-pills{ gap:5px; margin-top:8px; }
/* La píldora recupera su texto y ocupa el ancho de la tarjeta. Con 1.35 de
interlínea y 8px arriba y abajo mide 35px de alto, por encima de los 24
que pide WCAG 2.5.8 para tocarla con el dedo. */
.cal-pill{
font-size:0; line-height:0; color:transparent;
/* content-box: con el border-box global el relleno se comía la altura y
la barra desaparecía. Así los 10px son los del color y el relleno los
suma por fuera hasta 24. */
box-sizing:content-box;
height:10px; padding:7px 0; border-radius:3px;
background-clip:content-box; box-shadow:none;
font-size:.92rem; line-height:1.35; color:var(--tinta);
padding:8px 14px; border-radius:999px;
white-space:normal; text-overflow:clip; overflow:visible;
background-clip:border-box;
}
@supports (background: color-mix(in srgb, red 10%, transparent)){
.cal-pill{ background:color-mix(in srgb, var(--bg) 60%, transparent);
background-clip:content-box; box-shadow:none; }
}
.cal-cell{ gap:2px; }
.cal-pills{ gap:2px; }
/* En reposo la píldora no se mueve; el desplazamiento del :hover de
escritorio se queda para el toque, que es donde se agradece. */
.cal-pill:hover, .cal-pill:active{ transform:none; }
.cal-legend{ margin-top:18px; }
.cal-legend-item{ font-size:.78rem; }
}
@ -1112,3 +1176,331 @@ gancio-calendar{
.sidebar-nav li{ margin-bottom:2px; }
.sidebar .sb-marca{ margin-bottom:22px; }
}
/* ========================== 10. PEDIR EVENTO ==========================
La sección /pedir/: el calendario de fechas pedidas y confirmadas, y los
formularios con los que se piden y se confirman. Las sirve el gestor, no son
ficheros de esta carpeta, pero comparten esta hoja y sus colores.
El sitio no tenía ni un estilo de formulario —sólo las dos flechas del
calendario—, así que todo lo de aquí es nuevo. Los contrastes van medidos
contra el mismo umbral que el resto: --rosa a secas NUNCA para texto (3.1:1),
--rosa-legible sí, tanto de tinta como de fondo con texto claro encima. */
/* ---- El botón de la barra lateral ----
Va encima del logo, que es lo primero que se ve al abrir el menú con el botón
de arriba a la izquierda. */
.sb-pedir{
display:block; margin:0 0 18px; padding:13px 12px;
background:var(--rosa-legible); color:var(--verde-humo);
font-family:var(--titular); font-size:.95rem; letter-spacing:.14em;
text-transform:uppercase; text-align:center; text-decoration:none;
border:var(--pluma) solid var(--rosa); border-radius:var(--curva);
transition:background .2s, color .2s;
}
.sb-pedir:hover, .sb-pedir:active{ background:var(--rosa); color:var(--negro); }
/* ---- El menú de la sección ---- */
.pedir-menu{
display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px;
margin:0 clamp(16px,5vw,60px) 22px; padding:8px;
border:var(--pluma) solid var(--verde-vivo); border-radius:var(--curva);
background:var(--verde-humo);
}
.pedir-menu a, .pedir-salir button{
display:flex; align-items:center; min-height:44px; padding:0 14px;
font-family:var(--titular); font-size:.8rem; letter-spacing:.1em;
text-transform:uppercase; text-decoration:none;
color:var(--rosa-legible); background:transparent;
border:none; border-radius:var(--curva); cursor:pointer;
}
.pedir-menu a:hover, .pedir-menu a:active,
.pedir-salir button:hover, .pedir-salir button:active{
background:var(--rosa-legible); color:var(--verde-humo);
}
.pedir-menu a[aria-current="page"]{
color:var(--tinta); box-shadow:inset 0 -3px 0 var(--rosa);
}
.pedir-salir{ margin-left:auto; }
.pedir-salir button{ color:var(--verde-hoja); }
/* ---- Avisos ---- */
.pedir-aviso, .pedir-error{
margin:0 clamp(16px,5vw,60px) 22px; padding:14px 18px;
border-radius:var(--curva); font-family:var(--lectura); line-height:1.6;
}
.pedir-aviso{ border:var(--pluma) solid var(--verde-hoja); background:#e7f2e9; color:var(--tinta); }
.pedir-error{ border:var(--pluma) solid var(--rosa); background:#fdeaf5; color:var(--rosa-legible); font-weight:700; }
/* ---- Textos de apoyo ---- */
.pista{ display:block; margin-top:4px; font-size:.85rem; color:var(--tinta-suave); }
.pedir-reglas{ margin:0 0 26px; padding-left:22px; line-height:1.7; max-width:70ch; }
/* ---- Formularios ---- */
.pedir-form{ max-width:34rem; }
.pedir-form p{ margin:0 0 18px; }
.pedir-form label{
display:block; margin-bottom:6px;
font-family:var(--titular); font-size:.82rem; letter-spacing:.1em;
text-transform:uppercase; color:var(--tinta);
}
.pedir-form input[type="text"], .pedir-form input[type="password"],
.pedir-form input[type="date"], .pedir-form input[type="time"],
.pedir-form select, .pedir-form textarea,
.solicitud-resolver input[type="text"]{
width:100%; box-sizing:border-box; min-height:46px; padding:10px 13px;
font-family:var(--lectura); font-size:1rem;
color:var(--tinta); background:var(--verde-humo);
border:var(--pluma) solid var(--verde-hoja); border-radius:var(--curva);
}
.pedir-form textarea{ min-height:110px; line-height:1.6; resize:vertical; }
.pedir-form input:focus-visible, .pedir-form select:focus-visible,
.pedir-form textarea:focus-visible{ outline:3px solid var(--rosa); outline-offset:2px; }
/* ---- Botones ----
El relleno deja los 44px de alto que pide el sitio para lo que se toca. */
.pedir-boton, .pedir-boton-flojo, .pedir-form button{
display:inline-flex; align-items:center; justify-content:center;
min-height:44px; padding:0 22px;
font-family:var(--titular); font-size:.9rem; letter-spacing:.12em;
text-transform:uppercase; text-decoration:none; text-align:center;
border-radius:var(--curva); cursor:pointer;
transition:background .2s, color .2s;
}
.pedir-boton, .pedir-form button{
color:var(--verde-humo); background:var(--rosa-legible);
border:var(--pluma) solid var(--rosa-legible);
}
.pedir-boton:hover, .pedir-boton:active,
.pedir-form button:hover, .pedir-form button:active{
background:var(--rosa); color:var(--negro); border-color:var(--rosa);
}
.pedir-boton-flojo{
min-height:44px; padding:0 15px; font-size:.78rem;
color:var(--tinta); background:transparent;
border:var(--pluma) solid var(--verde-hoja);
}
.pedir-boton-flojo:hover, .pedir-boton-flojo:active{
background:var(--verde-hoja); color:var(--verde-humo);
}
.en-linea{ display:flex; flex-wrap:wrap; gap:6px; }
/* ---- Tablas ----
El envoltorio deja deslizar la tabla a lo ancho en vez de desbordar la
página, que es lo que mide el banco de pruebas. */
.tabla-envoltorio{ overflow-x:auto; }
.pedir-tabla{
width:100%; border-collapse:collapse;
font-family:var(--lectura); font-size:.95rem;
}
.pedir-tabla th[scope="col"]{
font-family:var(--titular); font-size:.78rem; letter-spacing:.12em;
text-transform:uppercase; text-align:left; padding:10px 12px;
color:var(--rosa-legible);
border-bottom:var(--pluma) solid var(--rosa-tenue);
}
.pedir-tabla td, .pedir-tabla th[scope="row"]{
padding:12px; text-align:left; vertical-align:top;
border-bottom:var(--pluma) solid var(--rosa-papel);
}
.pedir-tabla th[scope="row"]{ font-family:var(--titular); letter-spacing:.04em; font-weight:400; }
/* Modificar y Quitar, uno junto al otro en la última columna de la tabla. */
.acciones{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
.acciones form{ margin:0; }
/* ---- Estados ---- */
.estado{
display:inline-block; padding:4px 11px; border-radius:999px;
font-family:var(--titular); font-size:.72rem; letter-spacing:.1em;
text-transform:uppercase; white-space:nowrap; color:var(--tinta);
}
/* Solicitado en negro y confirmado en verde radiactivo: se distinguen de un
vistazo entre sí y de los dos fondos de finde, que son verde apagado y rosa. */
.estado-solicitado{ background:var(--negro); color:#fff; }
.estado-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
.finde-tranqui{ background:#a3d1b0; box-shadow:inset 0 0 0 1.4px var(--verde-hoja); }
.finde-ruido{ background:#fbdcef; box-shadow:inset 0 0 0 1.4px var(--rosa); }
/* ---- Solicitudes pendientes ---- */
.solicitud{
border:var(--pluma) solid var(--verde-vivo);
border-left:7px solid var(--rosa);
border-radius:var(--curva);
padding:18px 20px; margin:0 0 20px; max-width:60rem;
}
.solicitud h3{
font-family:var(--titular); font-size:1.15rem; letter-spacing:.04em;
margin:0 0 12px; color:var(--tinta);
}
.solicitud-datos{ margin:0 0 16px; font-family:var(--lectura); }
.solicitud-datos dt{
font-family:var(--titular); font-size:.74rem; letter-spacing:.12em;
text-transform:uppercase; color:var(--verde-hoja); margin-top:10px;
}
.solicitud-datos dd{ margin:2px 0 0; line-height:1.6; }
.solicitud-resolver{
display:flex; flex-wrap:wrap; align-items:center; gap:10px;
padding-top:14px; border-top:var(--pluma) dashed var(--rosa-tenue);
}
.solicitud-resolver label{
font-family:var(--titular); font-size:.74rem; letter-spacing:.12em;
text-transform:uppercase; color:var(--tinta);
}
.solicitud-resolver input[type="text"]{ flex:1 1 15rem; min-height:44px; }
/* ---- La clave temporal, que sólo se enseña una vez ---- */
.clave-nueva{
font-family:ui-monospace, SFMono-Regular, Menlo, monospace;
font-size:1.5rem; letter-spacing:.12em; overflow-wrap:anywhere;
padding:16px 18px; margin:0 0 14px; color:var(--tinta);
background:var(--verde-hondo);
border:var(--pluma) dashed var(--verde-hoja); border-radius:var(--curva);
}
/* ---- Leyenda del calendario de fechas ---- */
.leyenda-pedir{ display:flex; flex-wrap:wrap; gap:10px 16px; margin:0 0 26px; }
/* Las muestras de estado miden lo mismo que las de finde, para que la
leyenda sea una sola fila pareja. Va con .leyenda-pedir delante porque
.cal-ev, más abajo, les pondría su propio relleno y su esquina. */
.leyenda-pedir .leyenda-muestra{
display:inline-flex; align-items:center; padding:6px 14px;
border-radius:999px; font-size:.85rem;
}
.leyenda-finde{
display:inline-flex; align-items:center; padding:6px 14px;
border:var(--pluma) solid var(--verde-hoja); border-radius:999px;
font-family:var(--lectura); font-size:.85rem; color:var(--tinta);
}
.leyenda-finde.cal-tranqui{ background:#a3d1b0; }
.leyenda-finde.cal-ruido{ background:#fbdff0; border-color:var(--rosa); }
/* ---- El calendario de fechas ----
Reaprovecha la rejilla del calendario semanal (.cal, .cal-row, .cal-cell), y
con ella el paso a lista de días en el móvil. Lo que cambia es lo que va
dentro de la celda: aquí un evento necesita tres líneas —hora y estado,
nombre, y quién lo pide—, así que no puede ser una .cal-pill de una línea. */
.cal-fechas .cal-cell:not(.cal-head-cell){ min-height:clamp(74px,10vw,132px); }
.cal-mes{
font-family:var(--titular); font-size:clamp(1rem,.85rem + .7vw,1.35rem);
text-transform:uppercase; letter-spacing:.08em;
color:var(--rosa-legible); margin:26px 0 10px;
}
.cal-fechas + .cal-mes, .cal + .cal-mes{ margin-top:34px; }
.cal-turno{
display:inline-block; margin-top:2px; padding:1px 7px;
font-family:var(--titular); font-size:.56rem; letter-spacing:.08em;
text-transform:uppercase; white-space:nowrap;
color:var(--tinta); background:var(--verde-humo);
border:1px solid var(--verde-hoja); border-radius:999px;
}
/* El fondo del finde. Va después de la regla del sábado y el domingo que pinta
.cal-cell:nth-child(6) y (7), y con la misma especificidad, así que manda
este por orden: en la sección de fechas el turno pesa más que «es finde». */
.cal-row:not(.cal-head) > .cal-cell.cal-tranqui{ background:#a3d1b0; }
.cal-row:not(.cal-head) > .cal-cell.cal-ruido{ background:#fbdff0; }
.cal-ev{
display:block; padding:5px 9px; border-radius:var(--curva);
font-family:var(--lectura); font-size:.72rem; line-height:1.35;
color:var(--tinta); overflow-wrap:anywhere;
}
.cal-ev-solicitado{ background:var(--negro); color:#fff; }
.cal-ev-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
.cal-ev-cab{ display:flex; flex-wrap:wrap; align-items:baseline; gap:3px 8px; }
.cal-ev-hora{ font-family:var(--titular); font-size:.78rem; letter-spacing:.04em; }
.cal-ev-estado{
font-family:var(--titular); font-size:.58rem; letter-spacing:.1em;
text-transform:uppercase; margin-left:auto;
}
.cal-ev-solicitado .cal-ev-estado{ color:var(--rosa-vivo); } /* 6.7:1 sobre el negro */
.cal-ev-confirmado .cal-ev-estado{ color:#000; }
.cal-ev-nom{ display:block; font-weight:700; }
.cal-ev-quien{ display:block; font-size:.66rem; color:var(--tinta-suave); }
.cal-ev-solicitado .cal-ev-quien{ color:#d0d4c8; }
.cal-ev-confirmado .cal-ev-quien{ color:var(--tinta); }
.cal-pasado{ opacity:.55; }
/* ---- Findes o semana entera ----
Por defecto solo se ven viernes, sábado y domingo, que es cuando se hacen
los eventos: en el móvil la lista queda en la mitad. La casilla #ver-semana
va antes de los meses, así que con ~ decide qué se pinta, sin JavaScript,
igual que el #nav-toggle del menú. Todo va con :not(:checked): marcada, se
aplican tal cual las reglas de siempre de la rejilla y de la lista. */
.ver-semana-boton{ display:inline-flex; align-items:center; margin:0 0 18px; cursor:pointer; }
.ver-semana-boton .si{ display:none; }
.ver-semana:checked + .ver-semana-boton .no{ display:none; }
.ver-semana:checked + .ver-semana-boton .si{ display:inline; }
.ver-semana:focus-visible + .ver-semana-boton{ outline:3px solid var(--rosa); outline-offset:2px; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-row{ grid-template-columns:repeat(3,minmax(0,1fr)); }
.ver-semana:not(:checked) ~ .cal-fechas .cal-row > .cal-cell:nth-child(-n+4){ display:none; }
/* La última semana de un mes que acaba en jueves no tiene finde: fuera. */
.ver-semana:not(:checked) ~ .cal-fechas .cal-row:not(.cal-head):not(:has(> .cal-cell:nth-child(n+5):not(.cal-empty))){ display:none; }
/* ---- Pinchar un día libre ----
Cada día que se puede pedir lleva un enlace a /pedir/nueva?fecha=…. El
::after lo estira sobre toda la celda, así que se pincha en cualquier parte
del día y no solo en la palabra. Sin sesión, el gestor manda a la entrada y
vuelve al formulario con la fecha puesta. */
.cal-fechas .cal-cell.cal-libre{ position:relative; }
.cal-pedir{
align-self:flex-start; margin-top:auto; padding:2px 9px;
font-family:var(--titular); font-size:.62rem; letter-spacing:.1em;
text-transform:uppercase; text-decoration:none;
color:var(--rosa-legible); border:1px solid var(--rosa-legible); border-radius:999px;
}
.cal-pedir::after{ content:""; position:absolute; inset:0; }
.cal-fechas .cal-cell.cal-libre:hover, .cal-fechas .cal-cell.cal-libre:active{
box-shadow:inset 0 0 0 2.5px var(--rosa-legible);
}
.cal-libre:hover .cal-pedir, .cal-libre:active .cal-pedir{
background:var(--rosa-legible); color:#fff;
}
.cal-pedir:focus-visible{ outline:3px solid var(--rosa); outline-offset:2px; }
/* ---- Letra grande con solo tres columnas ----
En ordenador, con solo el finde, cada columna mide unos 380px: las medidas
de la rejilla de siete (número a .78rem, eventos a .72rem) se quedaban
perdidas en la casilla. Con la semana entera vuelven las de siempre. */
@media (min-width:561px){
.ver-semana:not(:checked) ~ .cal-fechas .cal-cell:not(.cal-head-cell){
padding:12px 14px 14px; gap:8px; min-height:clamp(110px,12vw,150px);
}
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell{ padding:16px 2px; }
/* Con tres columnas caben los nombres enteros: la letra suelta se esconde
y el nombre lo pone el CSS según la columna, como en la lista del móvil. */
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell abbr{ font-size:0; text-decoration:none; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell abbr::after{ font-size:1.05rem; letter-spacing:.14em; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(5) abbr::after{ content:"Viernes"; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(6) abbr::after{ content:"Sábado"; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(7) abbr::after{ content:"Domingo"; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-daynum{ font-size:1.5rem; color:var(--tinta); }
.ver-semana:not(:checked) ~ .cal-fechas .cal-turno{ font-size:.74rem; padding:3px 12px; align-self:flex-start; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-pedir{ font-size:.9rem; padding:6px 16px; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev{ font-size:1rem; padding:10px 14px; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-hora{ font-size:1.1rem; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-estado{ font-size:.74rem; }
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-quien{ font-size:.88rem; }
}
@media (max-width:560px){
.pedir-menu{ margin:0 clamp(16px,5vw,60px) 16px; }
.pedir-menu a, .pedir-salir button{ font-size:.74rem; padding:0 10px; }
.pedir-salir{ margin-left:0; }
/* En lista, la altura la pone el contenido: un finde libre es una tarjeta de
una línea, no un hueco de 132px. */
.cal-fechas .cal-cell:not(.cal-head-cell){ min-height:0; }
/* La lista esconde los días vacíos, pero uno libre para pedir no está vacío:
tiene su botón. Misma especificidad que la regla que los esconde y va
después, así que manda esta. */
.cal-fechas .cal-row > .cal-cell.cal-libre{ display:block; }
.cal-pedir{ float:right; font-size:.74rem; padding:4px 12px; }
.cal-ev{ font-size:.92rem; padding:8px 12px; }
.cal-ev-hora{ font-size:1rem; }
.cal-ev-estado{ font-size:.66rem; }
.cal-ev-quien{ font-size:.82rem; }
.cal-turno{ font-size:.66rem; margin-left:6px; }
.clave-nueva{ font-size:1.15rem; }
}

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -67,6 +64,9 @@
<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

View file

@ -15,15 +15,24 @@
<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' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
<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; }
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
/* 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';

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -31,6 +28,9 @@
<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

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -31,6 +28,9 @@
<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

View file

@ -15,9 +15,6 @@
<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="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/index.css">
@ -31,6 +28,9 @@
<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

View file

@ -14,7 +14,7 @@
<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' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
<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">
@ -23,9 +23,22 @@
/* Este micrositio es oscuro por diseño. */
:root{ color-scheme: dark; }
@import url('https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
/* 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';