chore: update changes
This commit is contained in:
parent
8610b0f0d6
commit
4a90b72576
477 changed files with 34538 additions and 14867 deletions
768
.cargo/2026-05-26_cargo_toml_source_sync_done.md
Normal file
768
.cargo/2026-05-26_cargo_toml_source_sync_done.md
Normal file
|
|
@ -0,0 +1,768 @@
|
||||||
|
el tema con cargo xtask
|
||||||
|
tal y como lo tenemos @.coder/2026-05-26-deps-sync.info.md
|
||||||
|
es ¿ cómo actualizamos los crated a nuevas versiones sin romper el sync de dependencias ?
|
||||||
|
⎿ Read .coder/2026-05-26-deps-sync.info.md (232 lines)
|
||||||
|
|
||||||
|
⏺ Voy a verificar el estado actual del sistema xtask y la registry antes de responder.
|
||||||
|
|
||||||
|
Searched for 1 pattern, listed 1 directory (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- El sistema invierte el flujo habitual de Cargo: en vez de cargo update actualizando Cargo.lock, aquí los bumps de versión son
|
||||||
|
cambios source-controlled sobre registry/dependencies.toml, y el xtask los propaga determinísticamente a cada Cargo.toml.
|
||||||
|
- El overlay (rustelo-deps-overlay.toml) rechaza por diseño los overrides de versión — esto convierte cada bump en una decisión
|
||||||
|
global y atómica, no en una negociación per-impl.
|
||||||
|
- El --check reporta drift por conteo de líneas, no diff semántico: es un gate de "estado convergido", no un explicador del cambio.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
El flujo para actualizar versiones
|
||||||
|
|
||||||
|
El sistema no impide actualizar — al contrario, lo canaliza por un único punto. La pregunta correcta no es "cómo actualizo sin
|
||||||
|
romper el sync", sino "el sync convierte el bump en una decisión global; ¿estoy listo a propagar?".
|
||||||
|
|
||||||
|
Workflow concreto
|
||||||
|
|
||||||
|
1. Editar registry/dependencies.toml ← única fuente de verdad
|
||||||
|
[deps.NAME]
|
||||||
|
version = "X.Y.Z"
|
||||||
|
|
||||||
|
2. cargo xtask sync-deps ← propaga a rustelo/Cargo.toml
|
||||||
|
3. cargo xtask sync-deps --target ../jpl-website
|
||||||
|
(y a cada impl conocida)
|
||||||
|
|
||||||
|
4. cargo build / cargo test ← validar en CADA workspace
|
||||||
|
(rustelo + cada impl)
|
||||||
|
|
||||||
|
5. Si rompe en impl:
|
||||||
|
a) FORWARD → adaptar código del impl al nuevo API
|
||||||
|
b) REGRESS → bajar la versión en registry/dependencies.toml
|
||||||
|
y volver a sincronizar
|
||||||
|
|
||||||
|
Lo que no se puede hacer (y es intencional)
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Intento │ Por qué falla │
|
||||||
|
├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [overlay.sqlx] version = "..." en impl │ El validador rechaza override de versión (línea 90-93 del │
|
||||||
|
│ │ .info) │
|
||||||
|
├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Editar a mano dentro del bloque # >>> │ El siguiente --check lo detecta como drift y sync-deps lo │
|
||||||
|
│ rustelo-sync:workspace-deps │ sobreescribe │
|
||||||
|
├─────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Per-impl version pin │ "Per-impl version overrides are intentionally NOT supported" │
|
||||||
|
│ │ (línea 189) │
|
||||||
|
└─────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Esto es el corazón del invariante de unificación cross-workspace: si dos workspaces pueden tener versiones distintas, no hay
|
||||||
|
garantía de unificación; el sistema entero pierde su razón de existir.
|
||||||
|
|
||||||
|
Qué hacer con bumps que rompen impl
|
||||||
|
|
||||||
|
El caso ya documentado: sqlx 0.8 → 0.9 en jpl-website tras un sync.
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- La "convergencia forzada" no es un bug sino el contrato. El xtask te empuja a tomar la decisión arquitectónica: el registry
|
||||||
|
refleja lo que el framework dice que es canónico, y los impls deben alinearse o el registry retrocede.
|
||||||
|
- La opción "Regress registry" es legítima pero costosa: significa que el framework no puede avanzar hasta que el impl bloqueante
|
||||||
|
se adapte. Útil como freno cuando hay deadline en impl.
|
||||||
|
- No existe punto medio. Si necesitas el punto medio, el sistema te está diciendo que el registry está mal-versionado:
|
||||||
|
probablemente la nueva versión no debió subir al registry todavía.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Patrón recomendado para bumps frágiles (leptos, axum, sqlx)
|
||||||
|
|
||||||
|
1. Crear branch en rustelo: bump/leptos-0.X
|
||||||
|
2. Editar registry/dependencies.toml
|
||||||
|
3. cargo xtask sync-deps # rustelo
|
||||||
|
4. cargo build --workspace # ¿compila rustelo?
|
||||||
|
5. cargo xtask sync-deps --target ../jpl-website
|
||||||
|
6. cd ../jpl-website && cargo build # ¿compila impl?
|
||||||
|
7. Si 4 o 6 falla:
|
||||||
|
- Fix forward → commit a rustelo (si es framework code)
|
||||||
|
→ commit a impl (si es código de impl)
|
||||||
|
- O regress registry y volver al paso 2
|
||||||
|
8. Commit registry + Cargo.toml sincronizados JUNTOS (atomicidad de bump)
|
||||||
|
|
||||||
|
El paso 8 es importante: el pre-commit hook (rustelo-deps-sync con --check) bloquea cualquier commit donde
|
||||||
|
registry/dependencies.toml esté desincronizado de Cargo.toml. Esto fuerza que el bump y su propagación sean un solo commit, no dos.
|
||||||
|
|
||||||
|
Gap actual del sistema (línea 217 del .info)
|
||||||
|
|
||||||
|
▎ Auto-discovery of impls via rustelo.manifest.toml [sync] section (currently impls are reached via explicit --target).
|
||||||
|
|
||||||
|
Hoy debes recordar a mano qué impls existen y pasarles --target uno a uno. Esto es un riesgo operacional real: si añades un impl
|
||||||
|
nuevo y olvidas sincronizarlo, drifta silenciosamente hasta que alguien corra el hook en ese repo. Si quieres, esto sería el
|
||||||
|
siguiente xtask útil de implementar (sync-deps --all leyendo de un manifiesto).
|
||||||
|
|
||||||
|
¿Quieres que profundice en alguno de los flujos — por ejemplo el patrón forward vs regress para un bump concreto que tengas
|
||||||
|
pendiente — o que mire el código del xtask para confirmar el comportamiento de algún caso edge?
|
||||||
|
|
||||||
|
✻ Churned for 55s
|
||||||
|
|
||||||
|
❯ zed nos muestra versiones actualizables sobre Cargo.toml pero es sobreescrito por xtask
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- Zed (vía rust-analyzer + crates lsp) detecta updates leyendo tablas [dependencies] o [workspace.dependencies] en archivos
|
||||||
|
Cargo.toml. No reconoce registry/dependencies.toml porque no es un manifiesto Cargo válido — es un schema ad-hoc del xtask.
|
||||||
|
- El conflicto es estructural: el sistema xtask deliberadamente coloca la fuente de verdad fuera del sitio donde las herramientas
|
||||||
|
Cargo-native esperan encontrarla. Esto es bueno para el invariante cross-workspace pero rompe el flujo editor-driven que ofrecen
|
||||||
|
Zed/rust-analyzer/crates-tooling.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Esto es una fricción real, no un mal uso. Tienes tres caminos. Te los presento con los tradeoffs.
|
||||||
|
|
||||||
|
Opción A — Reverse-sync (ingest)
|
||||||
|
|
||||||
|
Añadir un modo al xtask que lee Cargo.toml y actualiza registry/dependencies.toml (dirección inversa):
|
||||||
|
|
||||||
|
1. En Zed, click en el upgrade inline sobre Cargo.toml
|
||||||
|
2. cargo xtask sync-deps --ingest ← lee Cargo.toml → escribe registry
|
||||||
|
3. cargo xtask sync-deps --target ../jpl-website
|
||||||
|
4. Commit los tres archivos juntos
|
||||||
|
|
||||||
|
Pros: mínimo cambio estructural, conserva el schema actual del registry (con rebase, etc).
|
||||||
|
Contras: dos comandos por bump, fácil olvidar el --ingest y commitear Cargo.toml drifteado → el pre-commit hook lo detecta pero
|
||||||
|
solo si está activo.
|
||||||
|
|
||||||
|
Opción B — Convertir el registry en Cargo.toml ficticio (recomendada)
|
||||||
|
|
||||||
|
Reemplazar registry/dependencies.toml por registry/Cargo.toml:
|
||||||
|
|
||||||
|
# registry/Cargo.toml
|
||||||
|
[workspace]
|
||||||
|
members = []
|
||||||
|
exclude = []
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
leptos = { version = "0.8" }
|
||||||
|
axum = { version = "0.8" }
|
||||||
|
sqlx = { version = "0.9", features = ["postgres"], default-features = false }
|
||||||
|
rustelo_utils = { path = "../crates/foundation/crates/rustelo_utils" }
|
||||||
|
# ...
|
||||||
|
|
||||||
|
Y en rustelo/Cargo.toml:
|
||||||
|
exclude = ["xtask", "registry"]
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Abrir registry/Cargo.toml en Zed
|
||||||
|
2. Click inline-upgrade — Zed actualiza directamente la fuente de verdad
|
||||||
|
3. cargo xtask sync-deps ← propaga a rustelo/Cargo.toml
|
||||||
|
4. cargo xtask sync-deps --target ../jpl-website
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- Truco clave: registry/Cargo.toml con members = [] es un workspace válido pero vacío — Cargo no compila nada ahí, pero
|
||||||
|
rust-analyzer sí parsea sus deps y Zed muestra los upgrades disponibles.
|
||||||
|
- Las paths necesitan re-prefix con ../ (porque la base cambia), pero el xtask ya tiene la lógica de pathdiff para rebase, así que
|
||||||
|
solo invierte el cálculo en una dirección.
|
||||||
|
- La metadata rebase = false no cabe en el formato Cargo nativo. Solución: moverla a un sidecar registry/sync.toml o inferirla
|
||||||
|
(cualquier path que sale del workspace = no-rebase por defecto).
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Pros: Zed funciona nativamente, sin pasos extra. Bump y sync siguen siendo dos operaciones explícitas pero la primera ya no
|
||||||
|
requiere herramienta especial.
|
||||||
|
Contras: migración del schema, hay que decidir dónde vive rebase (sidecar o convención). Cargo carga un workspace extra en cada
|
||||||
|
operación (impacto mínimo).
|
||||||
|
|
||||||
|
Opción C — Aceptar la fricción
|
||||||
|
|
||||||
|
Documentar que Zed no debe usarse para upgrades en Cargo.toml, solo para descubrir versiones disponibles. El bump real se hace a
|
||||||
|
mano en registry/dependencies.toml. Cero código nuevo, máxima fricción humana.
|
||||||
|
|
||||||
|
Recomendación
|
||||||
|
|
||||||
|
Opción B. Es la única que elimina la fricción en lugar de añadirle un workaround. El xtask sigue siendo necesario (para el splice
|
||||||
|
cross-workspace), pero deja de ser el único punto de entrada — Zed se convierte en una interfaz válida porque la fuente de verdad
|
||||||
|
ahora habla el dialecto que Zed entiende.
|
||||||
|
|
||||||
|
La migración es mecánica:
|
||||||
|
1. Renombrar registry/dependencies.toml → registry/Cargo.toml con schema [workspace.dependencies]
|
||||||
|
2. Re-prefix de paths con ../
|
||||||
|
3. Mover rebase a registry/sync.toml (o inferirlo)
|
||||||
|
4. Adaptar xtask/src/registry.rs para leer el nuevo formato
|
||||||
|
5. Añadir exclude = ["registry"] a rustelo/Cargo.toml
|
||||||
|
6. Validar cargo xtask sync-deps --check con la nueva fuente
|
||||||
|
|
||||||
|
¿Quieres que lo implemente, o prefieres ver primero el código actual de xtask/src/registry.rs para evaluar el alcance del cambio?
|
||||||
|
|
||||||
|
✻ Baked for 1m 1s
|
||||||
|
|
||||||
|
※ recap: Goal: resolve friction where Zed shows Cargo.toml upgrades but xtask overwrites them. Current task: choose between
|
||||||
|
reverse-sync (ingest), converting registry to a fake Cargo.toml (recommended), or accepting the friction. Next: decide which option
|
||||||
|
to implement. (disable recaps in /config)
|
||||||
|
por qué implementar la opción B es más recomentable que A ?
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- La pregunta no es trivial: A es menos invasiva (no migra schema, no toca paths) y resuelve el síntoma. B requiere migración. Si
|
||||||
|
solo miráramos esfuerzo, A gana. La elección depende de qué propiedad valoramos más: minimizar el cambio (A) o minimizar la
|
||||||
|
complejidad sistémica resultante (B).
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Te lo desgloso en ejes concretos. Voy del más decisivo al menos.
|
||||||
|
|
||||||
|
1. Direccionalidad del flujo
|
||||||
|
|
||||||
|
A introduce bidirección. El sync deja de ser una operación push (registry → Cargo.toml) y pasa a tener dos modos: push y pull.
|
||||||
|
Bidirectional syncs son históricamente fuente de bugs sutiles — son el problema clásico de réplica master-master.
|
||||||
|
|
||||||
|
B preserva la unidireccionalidad. Siempre registry/Cargo.toml → todos los demás. El xtask no necesita resolver conflictos porque no
|
||||||
|
puede haberlos: solo hay un origen.
|
||||||
|
|
||||||
|
2. Idempotencia y orden de operaciones
|
||||||
|
|
||||||
|
A pierde idempotencia parcial. El resultado de cargo xtask sync-deps depende del estado relativo de dos archivos: si Cargo.toml
|
||||||
|
tiene un upgrade que registry no, el sync lo borra. Esto significa que el orden importa: primero --ingest, después sync-deps.
|
||||||
|
Olvidar el orden = trabajo perdido silenciosamente.
|
||||||
|
|
||||||
|
B es idempotente. Corre sync-deps N veces sobre el mismo registry: resultado idéntico. El estado en disco de Cargo.toml es
|
||||||
|
irrelevante para el resultado, porque siempre es destino, nunca origen.
|
||||||
|
|
||||||
|
3. Coherencia con el modelo de "fuente de verdad"
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- El diseño actual ya tiene "DO NOT EDIT" en los marcadores del bloque managed. Es una declaración explícita: este texto es
|
||||||
|
derivado, no lo toques. La opción A contradice esa declaración: pide al usuario que edite el bloque managed (vía Zed) como si fuera
|
||||||
|
fuente, y luego ingiera el cambio. Confunde el contrato.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A rompe el invariante semántico del marker # >>> rustelo-sync:workspace-deps (managed by xtask — DO NOT EDIT). Con --ingest, el
|
||||||
|
bloque managed pasa a ser editable bajo ciertas condiciones. El marker miente.
|
||||||
|
|
||||||
|
B preserva el invariante. El bloque managed sigue siendo solo-escritura-por-xtask. La edición humana ocurre en otro archivo,
|
||||||
|
claramente identificado como fuente.
|
||||||
|
|
||||||
|
4. Composabilidad con ecosistema Cargo
|
||||||
|
|
||||||
|
Esto es el argumento más fuerte a medio plazo.
|
||||||
|
|
||||||
|
A solo resuelve Zed. ¿Y cargo upgrade de cargo-edit? ¿Y Dependabot, que mira Cargo.toml? ¿Y cargo outdated? Cada herramienta que en
|
||||||
|
el futuro quieras integrar requerirá su propio adaptador --ingest-from-X, o un paso manual.
|
||||||
|
|
||||||
|
B resuelve toda la familia de una sola vez. Cualquier herramienta que sepa leer [workspace.dependencies] (es decir, todas las
|
||||||
|
herramientas Cargo-native) opera sobre la fuente sin saber que existe el sync.
|
||||||
|
|
||||||
|
```text
|
||||||
|
A B
|
||||||
|
│ │
|
||||||
|
┌────────┼────────┐ ┌────────┼────────┐
|
||||||
|
│ │ │ │ │ │
|
||||||
|
Zed cargo-up Depbot Zed cargo-up Depbot
|
||||||
|
│ │ │ │ │ │
|
||||||
|
▼ ▼ ▼ ▼ ▼ ▼
|
||||||
|
Cargo (custom) (custom) registry/Cargo.toml
|
||||||
|
│ │ │ │
|
||||||
|
└───────┼────────┘ ▼
|
||||||
|
▼ xtask sync
|
||||||
|
xtask --ingest
|
||||||
|
▼
|
||||||
|
registry
|
||||||
|
▼
|
||||||
|
xtask sync
|
||||||
|
```
|
||||||
|
|
||||||
|
En A cada herramienta nueva requiere un adaptador o un paso manual. En B la herramienta apunta directamente a la fuente.
|
||||||
|
|
||||||
|
5. Superficie de código en el xtask
|
||||||
|
|
||||||
|
A añade código: parser inverso (Cargo.toml → dep map), detección de conflicto (¿qué pasa si registry y Cargo.toml difieren en N
|
||||||
|
deps a la vez?), validación de overlay contra ingest, lógica de "qué dirección priorizar".
|
||||||
|
|
||||||
|
B reduce código: desaparece el parser custom de dependencies.toml, se reemplaza por el parser de Cargo.toml que el xtask ya tiene
|
||||||
|
(o toml_edit directo). El schema custom se va.
|
||||||
|
|
||||||
|
6. Modo de fallo y recuperación
|
||||||
|
|
||||||
|
┌───────────────────────────┬───────────────────────────────────────────────────────┬──────────────────────────────────────────┐
|
||||||
|
│ Escenario │ A │ B │
|
||||||
|
├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||||
|
│ Editor con upgrade inline │ flujo natural pero peligroso (olvido de --ingest) │ flujo natural y seguro │
|
||||||
|
├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||||
|
│ Edit manual en archivo │ difícil distinguir error de uso correcto (Cargo.toml │ error claro: editaste un bloque marcado │
|
||||||
|
│ equivocado │ es editable a veces) │ "DO NOT EDIT" │
|
||||||
|
├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||||
|
│ Dos personas suben bumps │ merge complejo: ambos editaron Cargo.toml + ambos │ merge normal en registry/Cargo.toml, │
|
||||||
|
│ distintos en paralelo │ --ingest-aron, registry tiene un orden ganador │ conflicto de Git visible │
|
||||||
|
│ │ silencioso │ │
|
||||||
|
├───────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────────────────────────┤
|
||||||
|
│ Pre-commit detecta drift │ sí, pero el mensaje confunde (¿debía hacer --ingest?) │ sí, mensaje claro: "registry y │
|
||||||
|
│ │ │ Cargo.toml difieren, corre sync-deps" │
|
||||||
|
└───────────────────────────┴───────────────────────────────────────────────────────┴──────────────────────────────────────────┘
|
||||||
|
|
||||||
|
7. Curva de aprendizaje para nuevos contribuidores
|
||||||
|
|
||||||
|
A: dos archivos parecidos (Cargo.toml y dependencies.toml con schema custom), una operación que depende de la dirección. Necesita
|
||||||
|
documentación y disciplina humana.
|
||||||
|
|
||||||
|
B: un archivo (registry/Cargo.toml) que cualquier rustáceo entiende a primera vista, un comando para propagar (sync-deps). La
|
||||||
|
documentación se reduce a "el registry es un workspace Cargo vacío cuya única función es declarar dependencies".
|
||||||
|
|
||||||
|
El caso donde A gana
|
||||||
|
|
||||||
|
Hay uno: si los rebase/features/metadata custom del schema actual son suficientemente ricos para que migrarlos a formato Cargo
|
||||||
|
nativo pierda información, A preserva la flexibilidad. Mirando el schema actual:
|
||||||
|
|
||||||
|
[deps.NAME]
|
||||||
|
version = "..."
|
||||||
|
path = "..."
|
||||||
|
features = ["..."]
|
||||||
|
default_features = false
|
||||||
|
rebase = false
|
||||||
|
|
||||||
|
Solo rebase no tiene correspondencia directa en formato Cargo. El resto (version, path, features, default-features) es exactamente
|
||||||
|
la sintaxis de Cargo workspace.dependencies. Así que la "pérdida" se limita a un campo, que se resuelve con un sidecar
|
||||||
|
registry/sync.toml muy pequeño:
|
||||||
|
|
||||||
|
[paths]
|
||||||
|
"platform-nats" = { rebase = false }
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- En el fondo, A trata el síntoma ("Zed no ve el registry"); B trata la causa ("la fuente de verdad usa un dialecto que el
|
||||||
|
ecosistema no entiende"). La cura siempre es preferible al alivio cuando el coste de la cura es proporcional al problema — y aquí
|
||||||
|
lo es: una migración mecánica de un archivo a otro formato.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Resumen ejecutivo
|
||||||
|
|
||||||
|
A resuelve Zed con un comando nuevo, manteniendo un schema custom y añadiendo bidireccionalidad. B resuelve Zed (y cualquier futura
|
||||||
|
herramienta Cargo-native) eliminando el schema custom y manteniendo la unidireccionalidad del sync. El coste es una migración
|
||||||
|
única; el beneficio es un sistema más simple y más componible para siempre.
|
||||||
|
|
||||||
|
A es la respuesta correcta si la migración es prohibitiva o si hay restricciones que aún no me has contado (p.ej. impls que ya usan
|
||||||
|
el formato dependencies.toml directamente). B es la respuesta correcta en cualquier otro escenario.
|
||||||
|
|
||||||
|
¿Hay alguna restricción del lado de jpl-website u otros impls que me esté perdiendo y que pudiera hacer la migración más costosa de
|
||||||
|
lo que asumo?
|
||||||
|
|
||||||
|
✻ Brewed for 1m 12s
|
||||||
|
|
||||||
|
❯ implementa la opción B
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- El sync reproduce bit-a-bit la región managed previa — confirma que el cambio de schema preserva la semántica. Para platform-nats
|
||||||
|
(no-rebase) el path se canonicaliza a ../stratumiops/crates/platform-nats igual que antes, pero ahora viene de
|
||||||
|
../../stratumiops/... en registry/Cargo.toml (registry-relative).
|
||||||
|
- pathdiff opera textualmente sobre componentes, así que canonicalize antes del diff evita que .. segmentos contaminen el output.
|
||||||
|
Sin canonicalize, el path emitido sería registry/../crates/... — válido pero feo. Con canonicalize, es limpio.
|
||||||
|
- [workspace.dependencies] con members = [] es un workspace Cargo legítimo: cargo metadata retorna packages: [] sin error. Cargo
|
||||||
|
solo valida sintaxis del manifest, no intenta resolver paths (no hay miembros que las usen). Esto es justo lo que necesitamos:
|
||||||
|
Zed/rust-analyzer escanean la tabla, Cargo no compila nada.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GUIDELINE: rust.md (no unwrap, idiomatic, ?, expect, clippy -D warnings clean)
|
||||||
|
IMPLEMENTING: opción B — registry como workspace Cargo vacío editable nativamente por Zed
|
||||||
|
EXCLUDED: documentación nueva, commits, actualización de .coder/2026-05-26-deps-sync.info.md
|
||||||
|
(queda stale; pendiente de tu confirmación para refrescarlo)
|
||||||
|
|
||||||
|
Cambios aplicados
|
||||||
|
|
||||||
|
┌────────────────────────────┬───────────────┬─────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Archivo │ Acción │ Por qué │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ registry/Cargo.toml │ nuevo │ Workspace Cargo vacío con [workspace.dependencies] — fuente de verdad │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ registry/sync.toml │ nuevo │ Sidecar: no_rebase = ["platform-nats"] (única metadata sin equivalente Cargo) │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ registry/dependencies.toml │ borrado │ Schema custom reemplazado │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/registry.rs │ reescrito │ Parsea Cargo.toml (untagged enum string-or-table) + lee sidecar; Dep con │
|
||||||
|
│ │ │ Deserialize manual │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/render.rs │ reescrito │ Rebase con origen en registry/, canonicalize antes de diff_paths, propaga Result │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/sync.rs │ edición │ Propaga ? desde render_region; fix clippy pendiente │
|
||||||
|
│ │ mínima │ (manual_pattern_char_comparison) │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/overlay.rs │ edición │ Mensaje de error apunta a registry/Cargo.toml │
|
||||||
|
│ │ mínima │ │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/main.rs │ edición │ Comentarios docs actualizados │
|
||||||
|
│ │ mínima │ │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Cargo.toml (raíz) │ edición │ exclude = ["xtask", "registry"] para que la sub-workspace no contamine │
|
||||||
|
├────────────────────────────┼──────────────┼──────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ .pre-commit-config.yaml │ edición │ Regex ^(Cargo\.toml|registry/(Cargo|sync)\.toml)$ │
|
||||||
|
└────────────────────────────┴──────────────┴──────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Verificación ejecutada
|
||||||
|
|
||||||
|
$ cargo build --manifest-path xtask/Cargo.toml → OK
|
||||||
|
$ cargo clippy --manifest-path xtask/Cargo.toml -D warnings → OK
|
||||||
|
$ cargo xtask sync-deps --check → "up to date" (exit 0)
|
||||||
|
$ cargo metadata --no-deps --manifest-path Cargo.toml → OK
|
||||||
|
$ cargo metadata --no-deps --manifest-path registry/Cargo.toml → OK (packages=[])
|
||||||
|
|
||||||
|
La región managed en rustelo/Cargo.toml quedó idéntica al estado previo a la migración — convergencia bit-a-bit valida que la
|
||||||
|
traducción del schema preserva semántica.
|
||||||
|
|
||||||
|
Flujo nuevo para Zed
|
||||||
|
|
||||||
|
1. Abrir registry/Cargo.toml en Zed → rust-analyzer detecta [workspace.dependencies] y muestra upgrades inline.
|
||||||
|
2. Click en el upgrade → Zed escribe directamente en la fuente de verdad.
|
||||||
|
3. cargo xtask sync-deps → propaga a rustelo/Cargo.toml.
|
||||||
|
4. cargo xtask sync-deps --target ../jpl-website → propaga a cada impl.
|
||||||
|
5. Pre-commit hook bloquea commits desincronizados (fires en cambios a Cargo.toml, registry/Cargo.toml o registry/sync.toml).
|
||||||
|
|
||||||
|
Pendientes (no implementados, por si los quieres)
|
||||||
|
|
||||||
|
- Actualizar .coder/2026-05-26-deps-sync.info.md: el doc describe el formato viejo (dependencies.toml, schema custom). Su modelo
|
||||||
|
conceptual sigue siendo correcto pero los detalles concretos están stale.
|
||||||
|
- Sincronizar jpl-website u otros impls: no he tocado nada fuera de rustelo. Cuando ejecutes cargo xtask sync-deps --target
|
||||||
|
../jpl-website la primera vez producirá output equivalente al anterior (la región managed allí seguirá la misma forma).
|
||||||
|
- Auto-discovery de impls: sigue pendiente del .info original; no es regresión sino limitación heredada.
|
||||||
|
|
||||||
|
STATUS: Complete
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- En rustelo las recetas viven en el top-level (justfiles/build.just importado con import), así que se invocan planas (just
|
||||||
|
deps-sync). En jpl-website el justfile usa mod build "just/build.just", así que se invocan con namespace (just build deps-sync).
|
||||||
|
Misma funcionalidad, dos convenciones distintas según el patrón del proyecto.
|
||||||
|
- Para el impl, la receta encapsula tres detalles operacionales molestos: la ruta al xtask (../rustelo/xtask/Cargo.toml), las flags
|
||||||
|
--rustelo-root ../rustelo --target ., y el override de CARGO_TARGET_DIR para no contaminar el target volume-pinned del framework.
|
||||||
|
El humano nunca tiene que recordarlos.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Resumen de cambios
|
||||||
|
|
||||||
|
Documentación
|
||||||
|
- .coder/2026-05-26-deps-sync.info.md — actualizado: schema nuevo (registry/Cargo.toml + sync.toml), workflow editor-driven con
|
||||||
|
Zed, rationale de la migración, verificación de bit-identical reproduction.
|
||||||
|
- rustelo/reflection/qa.ncl — deps-sync-workflow reescrito: bump editor-driven como flujo recomendado, schema Cargo-native, sidecar
|
||||||
|
no_rebase, sección "why a fake Cargo.toml".
|
||||||
|
- jpl-website/reflection/qa.ncl — deps-sync-impl-workflow actualizado: referencia a ../rustelo/registry/Cargo.toml, mención de Zed
|
||||||
|
inline upgrades para descubrir versiones disponibles.
|
||||||
|
|
||||||
|
Recetas just
|
||||||
|
│ rustelo │ just deps-sync │ propagar registry → rustelo/Cargo.toml │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ rustelo │ just deps-sync-check │ verificar drift sin escribir │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ rustelo │ just deps-sync-impl ../jpl-website │ propagar a impl arbitrario │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ rustelo │ just deps-sync-check-impl ../jpl-website │ verificar drift en impl │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ rustelo │ just deps-sync-all │ rustelo + impls conocidos en un solo comando │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ jpl-website │ just build deps-sync │ pull desde ../rustelo/registry/Cargo.toml │
|
||||||
|
├─────────────┼──────────────────────────────────────────┼──────────────────────────────────────────────┤
|
||||||
|
│ jpl-website │ just build deps-sync-check │ verificar drift contra rustelo │
|
||||||
|
└─────────────┴──────────────────────────────────────────┴──────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
Verificado
|
||||||
|
$ just deps-sync-check → ./Cargo.toml is up to date (exit 0)
|
||||||
|
$ just --list (rustelo) → 5 recipes deps-sync* visibles
|
||||||
|
$ just --list build (jpl) → deps-sync, deps-sync-check visibles bajo build::
|
||||||
|
|
||||||
|
STATUS: Complete
|
||||||
|
|
||||||
|
escribe un doc en .coder
|
||||||
|
para pedir en una sesión claude code
|
||||||
|
que se implemente este mismo mecanismo tal cual (opción B e incluir las recetas just) de sync de crates en un proyecto nuevo
|
||||||
|
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- El doc está estructurado como prompt para una sesión Claude Code, no como referencia general. Cada task usa el formato
|
||||||
|
obligatorio del proyecto (COMPLEXITY/DEPENDENCIES/RISKS/SUCCESS_CRITERIA) sin estimaciones temporales, lo que evita que la sesión
|
||||||
|
receptora "improvise" pasos.
|
||||||
|
- Las tasks marcan dependencias explícitas (1→3→4→5→6→[7,8,9]→10), así que si la sesión receptora se interrumpe a la mitad, puede
|
||||||
|
retomar desde un estado parcial seguro sin reconstruir contexto.
|
||||||
|
- El doc separa lo que se debe copiar tal cual (xtask source, markers # >>> rustelo-sync) de lo que hay que adaptar (paths
|
||||||
|
registry-relativos, lista de deps, hook id). Esa separación es lo que distingue una replicación correcta de una "reescritura
|
||||||
|
libre".
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Resumen del doc
|
||||||
|
|
||||||
|
Ruta: .coder/2026-05-26-deps-sync-replication.plan.md
|
||||||
|
|
||||||
|
Es un brief auto-contenido para entregar a otra sesión Claude Code. Estructura:
|
||||||
|
|
||||||
|
1. Why / Reference implementation — contexto mínimo + apuntadores a la doc canónica de rustelo
|
||||||
|
2. Preconditions — qué debe tener el proyecto destino
|
||||||
|
3. Open questions — 5 preguntas que la sesión receptora debe hacer al usuario antes de empezar (project root, project name,
|
||||||
|
framework-vs-impl, no-rebase deps, downstream impls)
|
||||||
|
4. 10 tasks ordenadas con COMPLEXITY/DEPENDENCIES/RISKS/SUCCESS_CRITERIA cada una:
|
||||||
|
- 1: crear registry/Cargo.toml
|
||||||
|
- 2: crear registry/sync.toml
|
||||||
|
- 3: copiar xtask
|
||||||
|
- 4: editar root Cargo.toml (exclude)
|
||||||
|
- 5: añadir alias .cargo/config.toml
|
||||||
|
- 6: primer sync + commit
|
||||||
|
- 7: pre-commit hook
|
||||||
|
- 8: recetas just (framework)
|
||||||
|
- 9: recetas just (impl) — opcional
|
||||||
|
- 10: validación end-to-end
|
||||||
|
5. Adaptations checklist — qué cambiar vs rustelo (paths, nombre, lista de deps, etc.)
|
||||||
|
6. Things to leave untouched — marker text, standalone-workspace de xtask, deserialización dual de Dep
|
||||||
|
7. Order of independence — qué subconjuntos de tasks son estados estables si se quiere parar a mitad
|
||||||
|
8. See also — los tres docs/QA de referencia en el repo rustelo
|
||||||
|
|
||||||
|
El doc asume que rustelo está accesible para cp -r ../rustelo/xtask .; si no lo está, la sesión receptora debe preguntar dónde vive
|
||||||
|
(mencionado explícitamente en la Open question #1 y Task 3 NOTES).
|
||||||
|
|
||||||
|
STATUS: Complete
|
||||||
|
|
||||||
|
exlica lo de Auto-discovery de impls: sigue pendiente del .info original; no es regresión sino limitación heredada.
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
- "Limitación heredada" significa que existía en el diseño original con dependencies.toml y la migración a opción B no la introdujo
|
||||||
|
ni la resolvió. Decir "no es regresión" es importante: si el usuario migrara con la expectativa de que B arreglase TODO lo del
|
||||||
|
.info original, este punto le decepcionaría sin esta aclaración.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Qué significa "auto-discovery de impls"
|
||||||
|
|
||||||
|
Hoy, cuando ejecutas cargo xtask sync-deps, el xtask sincroniza un solo target — el que recibe por CLI. Hay tres modos posibles:
|
||||||
|
|
||||||
|
cargo xtask sync-deps → rustelo/Cargo.toml
|
||||||
|
cargo xtask sync-deps --target ../jpl-website → jpl-website/Cargo.toml
|
||||||
|
cargo xtask sync-deps --target ../otra-impl → otra-impl/Cargo.toml
|
||||||
|
|
||||||
|
El xtask no sabe qué impls existen en el ecosistema. Si añades ../proyecto-z mañana como consumidor del registry, el xtask no se
|
||||||
|
entera hasta que alguien le pase --target ../proyecto-z explícitamente.
|
||||||
|
|
||||||
|
Auto-discovery sería el mecanismo opuesto: el xtask conoce la lista completa de impls y puede iterar sobre ellos sin que un humano
|
||||||
|
se la recuerde:
|
||||||
|
|
||||||
|
cargo xtask sync-deps --all
|
||||||
|
→ sincroniza rustelo + cada impl declarada en algún lugar
|
||||||
|
|
||||||
|
Por qué importa operacionalmente
|
||||||
|
|
||||||
|
Hoy hay tres puntos de fricción:
|
||||||
|
|
||||||
|
1. Memoria humana: para mantener el ecosistema en sync, alguien debe recordar la lista de impls. En mi receta deps-sync-all añadí
|
||||||
|
../jpl-website hardcoded — si mañana aparece ../jpl-blog, hay que editar la receta. Y nadie lo recordará hasta que rompa algo.
|
||||||
|
2. Drift silencioso: si una impl nueva no se añade a la lista, los pre-commit hooks de cada repo seguirán fallando
|
||||||
|
independientemente (eso protege la integridad de cada repo aisladamente), pero no hay un sitio donde rustelo te diga "atención, hay
|
||||||
|
3 impls drifteadas". Cada repo es una isla hasta que alguien hace commit allí.
|
||||||
|
3. Onboarding: un colaborador nuevo en rustelo no sabe automáticamente qué impls hay que sincronizar tras un bump de versión. Tiene
|
||||||
|
que preguntar o leer el README/justfile.
|
||||||
|
|
||||||
|
Por qué no es regresión de la migración a opción B
|
||||||
|
|
||||||
|
El diseño original (registry/dependencies.toml schema custom) tampoco tenía auto-discovery. El .info original lo lista en su
|
||||||
|
sección Open items:
|
||||||
|
|
||||||
|
- Auto-discovery of impls via `rustelo.manifest.toml` `[sync]` section
|
||||||
|
(currently impls are reached via explicit `--target`).
|
||||||
|
|
||||||
|
La migración a opción B preservó todo el comportamiento del CLI: --target sigue funcionando idéntico, los markers son los mismos,
|
||||||
|
el splice es el mismo. Solo cambió el formato de la fuente de verdad, no la topología del sync. Por eso uso "limitación heredada" —
|
||||||
|
viaja con el sistema desde el diseño original.
|
||||||
|
|
||||||
|
★ Insight ─────────────────────────────────────
|
||||||
|
- En realidad la migración a opción B hace este open item más fácil de resolver, no más difícil. Antes había que extender el schema
|
||||||
|
custom de dependencies.toml ([meta], [deps.X], y ahora añadir [sync.impls]). Con opción B, ese tipo de metadata no-Cargo ya tiene
|
||||||
|
un sitio natural: registry/sync.toml. Solo hay que añadir un campo y una iteración en sync.rs.
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Cómo se resolvería (boceto, no implementado)
|
||||||
|
|
||||||
|
Extender registry/sync.toml:
|
||||||
|
|
||||||
|
no_rebase = ["platform-nats"]
|
||||||
|
|
||||||
|
[impls]
|
||||||
|
known = [
|
||||||
|
"../jpl-website",
|
||||||
|
"../jpl-blog", # ejemplo futuro
|
||||||
|
]
|
||||||
|
|
||||||
|
En el xtask, añadir un flag --all:
|
||||||
|
|
||||||
|
// sync.rs (boceto)
|
||||||
|
if args.all {
|
||||||
|
let sidecar = registry::load_sidecar(&args.rustelo_root)?;
|
||||||
|
// sync rustelo
|
||||||
|
sync_target(&args.rustelo_root, /*target=*/ &args.rustelo_root, …)?;
|
||||||
|
for impl_path in sidecar.impls.known {
|
||||||
|
let target = args.rustelo_root.join(&impl_path);
|
||||||
|
sync_target(&args.rustelo_root, &target, …)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Y la receta just simplificaría:
|
||||||
|
|
||||||
|
# antes
|
||||||
|
deps-sync-all:
|
||||||
|
@just deps-sync
|
||||||
|
@just deps-sync-impl ../jpl-website # hardcoded
|
||||||
|
|
||||||
|
# después
|
||||||
|
deps-sync-all:
|
||||||
|
cargo xtask sync-deps --all # lista vive en sync.toml
|
||||||
|
|
||||||
|
Por qué sigue pendiente
|
||||||
|
|
||||||
|
Es un trade-off consciente, no un olvido:
|
||||||
|
|
||||||
|
┌───────────────────────────────────────┬──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Argumento a favor de implementarlo │ Argumento a favor de aplazarlo │
|
||||||
|
├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Elimina memoria humana del flujo │ Hoy solo hay 1 impl (jpl-website); sobre-ingeniería para N=1 │
|
||||||
|
├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Drift detection centralizado │ Cada impl ya tiene su pre-commit hook independiente │
|
||||||
|
├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Onboarding más simple │ El README/justfile ya documenta el comando manual │
|
||||||
|
├───────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
|
||||||
|
│ sync.toml ya tiene sitio para meterlo │ Es código nuevo en xtask, validación, tests │
|
||||||
|
└───────────────────────────────────────┴──────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
El veredicto del .info original fue "no urgente, pero está identificado". Coincido. Si alguna vez aparece la segunda impl, vale la
|
||||||
|
pena cerrarlo; con una sola impl, el --target manual es perfectamente operativo.
|
||||||
|
|
||||||
|
Resumen en una línea
|
||||||
|
|
||||||
|
▎ Auto-discovery = el xtask conoce la lista de impls que consumen el registry y puede sincronizarlas en bloque sin que un humano se
|
||||||
|
▎ las recuerde. Sigue pendiente porque con una sola impl no compensa el código adicional, pero la opción B (sidecar sync.toml)
|
||||||
|
▎ deja el camino más despejado para resolverlo el día que haga falta.
|
||||||
|
|
||||||
|
✻ Churned for 59s
|
||||||
|
|
||||||
|
❯ implementalo, en cuanto verifiquemos jpl-website vamos a crear más sites
|
||||||
|
|
||||||
|
Plan en orden de dependencias:
|
||||||
|
|
||||||
|
TASK 1: Extender sidecar con [impls.known] en registry.rs
|
||||||
|
COMPLEXITY: simple
|
||||||
|
DEPENDENCIES: none
|
||||||
|
RISKS: typos en sync.toml apuntan a paths que no existen → mitigación: validar existencia al cargar
|
||||||
|
SUCCESS_CRITERIA: load_sidecar() retorna no_rebase + impls
|
||||||
|
|
||||||
|
TASK 2: Refactorizar sync.rs para extraer sync_one() y añadir driver --all
|
||||||
|
COMPLEXITY: simple
|
||||||
|
DEPENDENCIES: TASK 1
|
||||||
|
RISKS: en --check --all, error en target N no debe abortar el chequeo de N+1
|
||||||
|
SUCCESS_CRITERIA: sync_one por target, drift de cualquier target → exit 1 al final
|
||||||
|
|
||||||
|
TASK 3: Añadir flag --all en CLI (conflicts_with --target y --overlay)
|
||||||
|
COMPLEXITY: trivial
|
||||||
|
DEPENDENCIES: TASK 2
|
||||||
|
SUCCESS_CRITERIA: --all sin --target ni --overlay funciona; combinarlos error
|
||||||
|
|
||||||
|
TASK 4: Declarar ../jpl-website en registry/sync.toml [impls.known]
|
||||||
|
COMPLEXITY: trivial
|
||||||
|
DEPENDENCIES: TASK 3
|
||||||
|
SUCCESS_CRITERIA: cargo xtask sync-deps --all itera 2 targets
|
||||||
|
|
||||||
|
TASK 5: Sustituir deps-sync-all en justfile + añadir deps-sync-check-all
|
||||||
|
COMPLEXITY: trivial
|
||||||
|
DEPENDENCIES: TASK 4
|
||||||
|
SUCCESS_CRITERIA: just deps-sync-check-all exit 0
|
||||||
|
|
||||||
|
TASK 6: Documentación (info, QA, replication plan)
|
||||||
|
COMPLEXITY: simple
|
||||||
|
DEPENDENCIES: TASK 5
|
||||||
|
SUCCESS_CRITERIA: auto-discovery sale de Open Items y aparece en Architecture
|
||||||
|
|
||||||
|
Empiezo.
|
||||||
|
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
|
||||||
|
- El refactor importante fue normalizar paths registry-relative →
|
||||||
|
rustelo-root-relative en el loader, en lugar de cambiar la lógica del render.
|
||||||
|
Eso preservó el contrato de Rebase::translate_path y evitó romper el
|
||||||
|
rustelo-root-relativos, no registry-relativos). Una capa de traducción
|
||||||
|
centralizada en un solo punto = un solo sitio que mantener.
|
||||||
|
- sync_one retorna un enum SyncOutcome en vez de un ExitCode. Eso permite al
|
||||||
|
driver de --all agregar drift de N targets sin perder información: cada target
|
||||||
|
imprime su outcome, y solo al final el run agregado decide el código de
|
||||||
|
salida. Si hubiera usado ExitCode, habrías tenido que return-early en el
|
||||||
|
primer drift.
|
||||||
|
- Adding una impl ahora es exactamente una línea en registry/sync.toml. No
|
||||||
|
xtask, no recetas. Esa es la propiedad clave para tu plan de crear más sites:
|
||||||
|
la fricción operacional al escalar de N=1 a N=K se mantiene constante (O(1)
|
||||||
|
por impl).
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
GUIDELINE: rust.md (no unwrap, idiomatic, ?, expect, clippy -D warnings clean)
|
||||||
|
IMPLEMENTING: auto-discovery de impls vía [impls.known] en sync.toml + --all
|
||||||
|
en xtask
|
||||||
|
EXCLUDED: ondaod evaluation (sin solicitud de adr?), commits
|
||||||
|
|
||||||
|
Cambios
|
||||||
|
|
||||||
|
┌───────────────────────┬──────────────────────────────────────────────────┐
|
||||||
|
│ Archivo │ Acción │
|
||||||
|
├───────────────────────┼──────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/registry.rs │ Sidecar struct, load_sidecar() público, │
|
||||||
|
│ │ normalize_registry_path() (strip ..) │
|
||||||
|
├───────────────────────┼──────────────────────────────────────────────────┤
|
||||||
|
│ xtask/src/sync.rs │ refactor en sync_one() + driver de --all; agrega │
|
||||||
|
│ xtask/src/sync.rs │ refactor en sync_one() + driver de │
|
||||||
|
│ │ --all; agrega drift sin fail-fast │
|
||||||
|
├─────────────────────────────────────┼────────────────────────────────────┤
|
||||||
|
│ │ revert al contrato original │
|
||||||
|
│ xtask/src/render.rs │ (Dep.path rustelo-root-relative, │
|
||||||
|
│ │ sin canonicalize) │
|
||||||
|
├─────────────────────────────────────┼────────────────────────────────────┤
|
||||||
|
│ xtask/src/main.rs │ flag --all, --target ahora │
|
||||||
|
│ │ Option<PathBuf> │
|
||||||
|
├─────────────────────────────────────┼────────────────────────────────────┤
|
||||||
|
│ registry/sync.toml │ añadido [impls] known = │
|
||||||
|
│ │ ["../jpl-website"] │
|
||||||
|
├─────────────────────────────────────┼────────────────────────────────────┤
|
||||||
|
────────────────────────────────────────
|
||||||
|
Archivo: .coder/2026-05-26-deps-sync.info.md
|
||||||
|
Acción: [impls] documentado, --all documentado, auto-discovery movida de Open
|
||||||
|
→
|
||||||
|
Closed
|
||||||
|
────────────────────────────────────────
|
||||||
|
Archivo: reflection/qa.ncl (rustelo)
|
||||||
|
Acción: sección PROPAGATE TO IMPLEMENTATIONS actualizada con --all
|
||||||
|
────────────────────────────────────────
|
||||||
|
Archivo: .coder/2026-05-26-deps-sync-replication.plan.md
|
||||||
|
Acción: Task 2 y Task 8 actualizadas con [impls.known] y --all
|
||||||
|
|
||||||
|
Verificación
|
||||||
|
|
||||||
|
$ cargo xtask sync-deps --check → exit 0
|
||||||
|
$ cargo xtask sync-deps --all --check → 2 targets, exit 0
|
||||||
|
$ cargo xtask sync-deps --all --target X → rejected (exit 2)
|
||||||
|
$ cargo xtask sync-deps --all --overlay Y → rejected (exit 2)
|
||||||
|
$ cargo clippy --manifest-path xtask/Cargo.toml -- -D warnings → exit 0
|
||||||
|
$ just deps-sync-check-all → 2 targets, exit 0
|
||||||
|
|
||||||
|
Para los próximos sites
|
||||||
|
|
||||||
|
Cuando crees ../site-nueva, el workflow es:
|
||||||
|
|
||||||
|
1. Crear el workspace ../site-nueva con [workspace.dependencies] (vacío o
|
||||||
|
pobladio).
|
||||||
|
|
||||||
|
Para los próximos sites
|
||||||
|
|
||||||
|
Cuando crees ../site-nueva, el workflow es:
|
||||||
|
|
||||||
|
1. Crear el workspace ../site-nueva con [workspace.dependencies] (vacío o pobladio).
|
||||||
|
2. Opcional: crear ../site-nueva/rustelo-deps-overlay.toml con [overlay.*] y [extra.*].
|
||||||
|
3. Editar registry/sync.toml: añadir "../site-nueva" al array [impls.known].
|
||||||
|
4. cargo xtask sync-deps --all — itera rustelo + jpl-website + site-nueva.
|
||||||
|
5. (Opcional) Replicar el hook pre-commit en ../site-nueva/.pre-commit-config.yaml apuntando al xtask de rustelo.
|
||||||
|
|
||||||
|
No hay cambios al xtask ni a las recetas just. La operación es O(1) por impl añadida.
|
||||||
|
|
||||||
|
STATUS: Complete
|
||||||
|
|
||||||
|
|
||||||
|
⏺ Confirmado. El doc ahora refleja el modelo [impls.known] + --all en cuatro puntos:
|
||||||
|
|
||||||
|
┌──────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Sección │ Contenido │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Open questions (línea 67) │ Pregunta sobre impls indica que se encodean en registry/sync.toml [impls.known], no │
|
||||||
|
│ │ en la receta │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Task 2 RISKS (línea 104) │ Añade riesgo de impl path sin Cargo.toml │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Task 2 SUCCESS_CRITERIA (líneas │ Schema de sync.toml incluye [impls] known = [...] │
|
||||||
|
│ 113–114) │ │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Task 2 NOTES (línea 121) │ Documenta que [impls.known] habilita --all y que añadir impls es one-line edit │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Task 8 recipes (líneas 269–278) │ deps-sync-all y deps-sync-check-all usan cargo xtask sync-deps --all │
|
||||||
|
├──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Adaptations checklist (línea 354) │ Señala que la lista de impls va en sync.toml, no en la receta │
|
||||||
|
└──────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
STATUS: Complete
|
||||||
292
Cargo.toml
292
Cargo.toml
|
|
@ -13,173 +13,159 @@ members = [
|
||||||
"crates/foundation/crates/rustelo_core_types",
|
"crates/foundation/crates/rustelo_core_types",
|
||||||
"crates/foundation/crates/rustelo_language",
|
"crates/foundation/crates/rustelo_language",
|
||||||
"crates/foundation/crates/rustelo_routing",
|
"crates/foundation/crates/rustelo_routing",
|
||||||
"crates/foundation/crates/rustelo_components",
|
"crates/foundation/crates/rustelo_components_leptos",
|
||||||
"crates/foundation/crates/rustelo_pages",
|
"crates/foundation/crates/rustelo_components_htmx",
|
||||||
|
"crates/foundation/crates/rustelo_pages_htmx",
|
||||||
|
"crates/foundation/crates/rustelo_pages_leptos",
|
||||||
|
"crates/foundation/crates/rustelo_seo",
|
||||||
"crates/foundation/crates/rustelo_tools",
|
"crates/foundation/crates/rustelo_tools",
|
||||||
"crates/foundation/crates/rustelo_utils",
|
"crates/foundation/crates/rustelo_utils",
|
||||||
"crates/foundation/crates/rustelo_macros",
|
"crates/foundation/crates/rustelo_macros",
|
||||||
|
"crates/foundation/crates/rustelo_config",
|
||||||
|
"crates/foundation/crates/rustelo_content_graph_ssr",
|
||||||
|
"examples/plugin-example-theme",
|
||||||
]
|
]
|
||||||
|
exclude = ["xtask", "registry", "features/content-graph"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Core dependencies
|
# >>> rustelo-sync:workspace-deps (managed by `cargo xtask sync-deps` — DO NOT EDIT)
|
||||||
|
aes-gcm = "0.10"
|
||||||
# Rustelo foundation crates
|
ammonia = "4.1"
|
||||||
rustelo_utils = { path = "crates/foundation/crates/rustelo_utils" }
|
any_spawner = "0.3"
|
||||||
rustelo_core_types = { path = "crates/foundation/crates/rustelo_core_types" }
|
anyhow = "1.0.102"
|
||||||
rustelo_language = { path = "crates/foundation/crates/rustelo_language" }
|
argon2 = "0.5"
|
||||||
rustelo_routing = { path = "crates/foundation/crates/rustelo_routing" }
|
async-compression = { version = "0.4", features = ["gzip", "tokio"] }
|
||||||
rustelo_core_lib = { path = "crates/foundation/crates/rustelo_core_lib" }
|
async-nats = "0.49"
|
||||||
rustelo_components = { path = "crates/foundation/crates/rustelo_components" }
|
async-trait = "0.1.89"
|
||||||
rustelo_pages = { path = "crates/foundation/crates/rustelo_pages" }
|
axum = "0.8.9"
|
||||||
rustelo_client = { path = "crates/foundation/crates/rustelo_client" }
|
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||||
rustelo_server = { path = "crates/foundation/crates/rustelo_server" }
|
axum-test = "20.0"
|
||||||
rustelo_tools = { path = "crates/foundation/crates/rustelo_tools" }
|
base32 = "0.5"
|
||||||
rustelo_macros = { path = "crates/foundation/crates/rustelo_macros" }
|
base64 = "0.22"
|
||||||
|
bytes = "1.11"
|
||||||
# Rustelo framework crates
|
cfg-if = "1.0"
|
||||||
rustelo_core = { path = "crates/framework/crates/rustelo_core" }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rustelo_web = { path = "crates/framework/crates/rustelo_web" }
|
clap = { version = "4.6", features = ["derive", "env"] }
|
||||||
rustelo_auth = { path = "crates/framework/crates/rustelo_auth" }
|
comrak = { version = "0.52", features = ["syntect"] }
|
||||||
rustelo_content = { path = "crates/framework/crates/rustelo_content" }
|
console = "0.16"
|
||||||
rustelo_cli = { path = "crates/framework/crates/rustelo_cli" }
|
|
||||||
|
|
||||||
|
|
||||||
# Leptos ecosystem
|
|
||||||
leptos = { version = "0.8.15", features = ["hydrate", "ssr"] }
|
|
||||||
leptos_router = { version = "0.8.11", features = ["ssr"] }
|
|
||||||
leptos_axum = { version = "0.8.7" }
|
|
||||||
leptos_config = { version = "0.8.8" }
|
|
||||||
leptos_meta = { version = "0.8.5" }
|
|
||||||
leptos_integration_utils = { version = "0.8.7" }
|
|
||||||
|
|
||||||
# Other dependencies
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
axum = "0.8.8"
|
|
||||||
serde_json = "1.0"
|
|
||||||
thiserror = "2.0.18"
|
|
||||||
anyhow = "1.0.101"
|
|
||||||
rand = "0.9"
|
|
||||||
|
|
||||||
rand_core = { version = "0.10" }
|
|
||||||
#rand_core = { version = "0.6", features = ["getrandom"] }
|
|
||||||
getrandom = { version = "0.4", features = ["std", "wasm_js"] }
|
|
||||||
gloo-timers = { version = "0.3", features = ["futures"] }
|
|
||||||
gloo-net = { version = "0.6.0" }
|
|
||||||
glob = "0.3.3"
|
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
http = "1"
|
|
||||||
log = "0.4.29"
|
|
||||||
env_logger = "0.11"
|
|
||||||
wasm-bindgen-futures = "0.4.58"
|
|
||||||
wasm-bindgen = "0.2.108"
|
|
||||||
serde-wasm-bindgen = "0.6.5"
|
|
||||||
console_log = "1"
|
console_log = "1"
|
||||||
reqwest = { version = "0.13.2", features = ["json"] } # reqwest with JSON parsing support
|
crossterm = "0.29"
|
||||||
reqwasm = "0.5.0"
|
dashmap = "6"
|
||||||
js-sys = "0.3.85"
|
dialoguer = "0.12"
|
||||||
web-sys = { version = "0.3.85" , features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] }
|
dotenv = "0.15.0"
|
||||||
regex = "1.12.3"
|
env_logger = "0.11"
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = "0.3"
|
|
||||||
toml = "0.9"
|
|
||||||
fluent = "0.17"
|
fluent = "0.17"
|
||||||
fluent-bundle = "0.16"
|
fluent-bundle = "0.16"
|
||||||
fluent-syntax = "0.12"
|
fluent-syntax = "0.12"
|
||||||
unic-langid = { version = "0.9", features = ["unic-langid-macros"] }
|
fluent-templates = { version = "0.14.0", features = ["tera"] }
|
||||||
|
futures = "0.3.32"
|
||||||
tokio = { version = "1.49", features = ["rt-multi-thread"]}
|
getrandom = { version = "0.4", features = ["std", "wasm_js"] }
|
||||||
tower = "0.5.3"
|
glob = "0.3.3"
|
||||||
tower-http = { version = "0.6.8", features = ["fs"]}
|
gloo-net = "0.7.0"
|
||||||
|
gloo-timers = { version = "0.4", features = ["futures"] }
|
||||||
hex = "0.4.3"
|
|
||||||
dotenv = "0.15.0"
|
|
||||||
async-trait = "0.1.89"
|
|
||||||
|
|
||||||
once_cell = "1.21.3"
|
|
||||||
fluent-templates = { version = "0.13.2", features = ["tera"]}
|
|
||||||
|
|
||||||
rhai = { version = "1.24", features = ["serde", "only_i64", "no_float"] }
|
|
||||||
|
|
||||||
# Email support
|
|
||||||
lettre = { version = "0.11", features = ["tokio1-native-tls", "smtp-transport", "pool", "hostname", "builder"] }
|
|
||||||
handlebars = { version = "6.4" }
|
|
||||||
urlencoding = { version = "2.1" }
|
|
||||||
|
|
||||||
# TLS Support (optional)
|
|
||||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
|
||||||
axum-test = "18.7"
|
|
||||||
rustls = { version = "0.23" }
|
|
||||||
rustls-pemfile = { version = "2.2" }
|
|
||||||
|
|
||||||
# Authentication & Authorization (optional)
|
|
||||||
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
|
|
||||||
argon2 = { version = "0.5" }
|
|
||||||
uuid = { version = "1.20", features = ["v4", "serde", "js"] }
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
oauth2 = { version = "5.0" }
|
|
||||||
tower-sessions = { version = "0.15" }
|
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "sqlite", "chrono", "uuid", "migrate"] }
|
|
||||||
tower-cookies = { version = "0.11" }
|
|
||||||
time = { version = "0.3", features = ["serde"] }
|
|
||||||
|
|
||||||
# 2FA Support (optional)
|
|
||||||
totp-rs = { version = "5.7.0" }
|
|
||||||
qrcode = { version = "0.14", features = ["svg"] }
|
|
||||||
base32 = { version = "0.5" }
|
|
||||||
sha2 = { version = "0.10" }
|
|
||||||
base64 = { version = "0.22" }
|
|
||||||
|
|
||||||
# Cryptography dependencies
|
|
||||||
aes-gcm = { version = "0.10" }
|
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
|
||||||
|
|
||||||
# Metrics dependencies
|
|
||||||
prometheus = { version = "0.14" }
|
|
||||||
|
|
||||||
# Content Management & Rendering (optional)
|
|
||||||
pulldown-cmark = { version = "0.13.0", features = ["simd"] }
|
|
||||||
serde_yaml = { version = "0.9" }
|
|
||||||
tempfile = { version = "3.24" }
|
|
||||||
tera = { version = "1.20" }
|
|
||||||
unicode-normalization = { version = "0.1" }
|
|
||||||
|
|
||||||
paste = "1.0.15"
|
|
||||||
typed-builder = "0.23"
|
|
||||||
|
|
||||||
notify = { version = "8.2.0", default-features = false, features = ["macos_fsevent"] }
|
|
||||||
lru = "0.16"
|
|
||||||
ammonia = "4.1"
|
|
||||||
scraper = "0.25"
|
|
||||||
futures = "0.3.31"
|
|
||||||
async-compression = { version = "0.4", features = ["gzip", "tokio"] }
|
|
||||||
|
|
||||||
ratatui = "0.30"
|
|
||||||
inquire = "0.9"
|
|
||||||
crossterm = "0.29"
|
|
||||||
syntect = "5.3"
|
|
||||||
similar = "2.7"
|
|
||||||
reactive_graph = "0.2.12"
|
|
||||||
|
|
||||||
syn = { version = "2.0", features = ["full"] }
|
|
||||||
comrak = { version = "0.50", features = ["syntect"] }
|
|
||||||
|
|
||||||
walkdir = "2.5"
|
|
||||||
quote = "1.0"
|
|
||||||
proc-macro2 = "1.0"
|
|
||||||
gray_matter = "0.3"
|
gray_matter = "0.3"
|
||||||
ignore = "0.4"
|
handlebars = "6.4"
|
||||||
mockall = "0.14"
|
hex = "0.4.3"
|
||||||
wiremock = "0.6"
|
|
||||||
cfg-if = "1.0"
|
|
||||||
html-escape = "0.2"
|
html-escape = "0.2"
|
||||||
|
http = "1"
|
||||||
shellexpand = "3.1"
|
ignore = "0.4"
|
||||||
semver = "1.0"
|
|
||||||
pathdiff = "0.2"
|
|
||||||
|
|
||||||
dialoguer = "0.12"
|
|
||||||
console = "0.16"
|
|
||||||
indicatif = "0.18"
|
indicatif = "0.18"
|
||||||
|
inquire = "0.9"
|
||||||
|
js-sys = "=0.3.97"
|
||||||
|
jsonwebtoken = { version = "10.4", features = ["rust_crypto"] }
|
||||||
|
leptos = "=0.8.15"
|
||||||
|
leptos_axum = "=0.8.7"
|
||||||
|
leptos_config = "=0.8.8"
|
||||||
|
leptos_integration_utils = "=0.8.7"
|
||||||
|
leptos_meta = "=0.8.5"
|
||||||
|
leptos_router = "=0.8.11"
|
||||||
|
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "pool", "hostname", "builder"] }
|
||||||
|
log = "0.4.30"
|
||||||
|
lru = "0.18"
|
||||||
|
minijinja = { version = "2", features = ["loader", "builtins", "json"] }
|
||||||
|
mockall = "0.14"
|
||||||
|
nkeys = "0.4"
|
||||||
|
notify = { version = "8.2.0", default-features = false }
|
||||||
|
oauth2 = "5.0"
|
||||||
|
once_cell = "1.21.4"
|
||||||
|
paste = "1.0.15"
|
||||||
|
pathdiff = "0.2"
|
||||||
|
platform-nats = { path = "../../stratumiops/code/crates/platform-nats" }
|
||||||
|
proc-macro2 = "1.0"
|
||||||
|
prometheus = "0.14"
|
||||||
|
pulldown-cmark = { version = "0.13", features = ["simd"] }
|
||||||
|
qrcode = { version = "0.14", features = ["svg"] }
|
||||||
|
quote = "1.0"
|
||||||
|
rand = "0.10"
|
||||||
|
rand_core = "0.10"
|
||||||
|
ratatui = "0.30"
|
||||||
|
reactive_graph = "0.2.14"
|
||||||
|
regex = "1.12.3"
|
||||||
|
reqwasm = "0.5.0"
|
||||||
|
reqwest = { version = "0.13.4", features = ["json"] }
|
||||||
|
rhai = { version = "1.25", features = ["serde", "only_i64", "no_float"] }
|
||||||
|
rustelo_auth = { path = "crates/framework/crates/rustelo_auth" }
|
||||||
|
rustelo_cli = { path = "crates/framework/crates/rustelo_cli" }
|
||||||
|
rustelo_client = { path = "crates/foundation/crates/rustelo_client" }
|
||||||
|
rustelo_components_htmx = { path = "crates/foundation/crates/rustelo_components_htmx" }
|
||||||
|
rustelo_components_leptos = { path = "crates/foundation/crates/rustelo_components_leptos" }
|
||||||
|
rustelo_config = { path = "crates/foundation/crates/rustelo_config" }
|
||||||
|
rustelo_content = { path = "crates/framework/crates/rustelo_content" }
|
||||||
|
rustelo_core = { path = "crates/framework/crates/rustelo_core" }
|
||||||
|
rustelo_core_lib = { path = "crates/foundation/crates/rustelo_core_lib" }
|
||||||
|
rustelo_core_types = { path = "crates/foundation/crates/rustelo_core_types" }
|
||||||
|
rustelo_language = { path = "crates/foundation/crates/rustelo_language" }
|
||||||
|
rustelo_macros = { path = "crates/foundation/crates/rustelo_macros" }
|
||||||
|
rustelo_pages_htmx = { path = "crates/foundation/crates/rustelo_pages_htmx" }
|
||||||
|
rustelo_pages_leptos = { path = "crates/foundation/crates/rustelo_pages_leptos" }
|
||||||
|
rustelo_routing = { path = "crates/foundation/crates/rustelo_routing" }
|
||||||
|
rustelo_seo = { path = "crates/foundation/crates/rustelo_seo" }
|
||||||
|
rustelo_server = { path = "crates/foundation/crates/rustelo_server", default-features = false }
|
||||||
|
rustelo_tools = { path = "crates/foundation/crates/rustelo_tools" }
|
||||||
|
rustelo_utils = { path = "crates/foundation/crates/rustelo_utils" }
|
||||||
|
rustelo_web = { path = "crates/framework/crates/rustelo_web" }
|
||||||
|
rustls = "0.23"
|
||||||
|
rustls-pemfile = "2.2"
|
||||||
|
scraper = "0.27"
|
||||||
|
semver = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde-wasm-bindgen = "0.6.5"
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
sha2 = "0.11"
|
||||||
|
shellexpand = "3.1"
|
||||||
|
similar = "3.1"
|
||||||
|
sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls-ring", "postgres", "sqlite", "chrono", "uuid", "migrate"] }
|
||||||
|
syn = { version = "2.0", features = ["full"] }
|
||||||
|
syntect = "5.3"
|
||||||
|
tempfile = "3.27"
|
||||||
|
tera = "1.20"
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
time = { version = "0.3", features = ["serde"] }
|
||||||
|
tokio = { version = "1.52", features = ["rt-multi-thread"] }
|
||||||
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
|
tokio-test = "0.4"
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
toml = "1.1.2"
|
||||||
|
totp-rs = "5.7"
|
||||||
|
tower = "0.5.3"
|
||||||
|
tower-cookies = "0.11"
|
||||||
|
tower-http = { version = "0.6.11", features = ["fs"] }
|
||||||
|
tower-sessions = "0.15"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
typed-builder = "0.23"
|
||||||
|
unic-langid = { version = "0.9", features = ["unic-langid-macros"] }
|
||||||
|
unicode-normalization = "0.1"
|
||||||
|
urlencoding = "2.1"
|
||||||
|
uuid = { version = "1.23", features = ["v4", "serde", "js"] }
|
||||||
|
walkdir = "2.5"
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
web-sys = { version = "=0.3.97", features = ["Clipboard", "Window", "Navigator", "Permissions", "MouseEvent", "KeyboardEvent", "Event", "Storage", "console", "File", "SvgElement", "SvgsvgElement", "SvgPathElement", "MediaQueryList"] }
|
||||||
|
wiremock = "0.6"
|
||||||
|
# <<< rustelo-sync:workspace-deps
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
|
||||||
|
✻
|
||||||
|
|
|
||||||
|
▟█▙ Claude Code v2.1.50
|
||||||
|
▐▛███▜▌ Sonnet 4.6 · Claude Max
|
||||||
|
▝▜█████▛▘ /Users/Akasha/Development/evol-rustelo/rustelo/crates/foundation
|
||||||
|
▘▘ ▝▝
|
||||||
|
|
||||||
|
✻ Conversation compacted (ctrl+o for history)
|
||||||
|
|
||||||
|
⎿ Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu
|
||||||
|
g/build/website-client-857cf649d398b40d/out/client_routes.rs (135 lines)
|
||||||
|
⎿ Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu
|
||||||
|
g/build/website-client-857cf649d398b40d/out/page_provider_impl.rs (92
|
||||||
|
lines)
|
||||||
|
⎿ Read ../../../website/website-impl/target/front/wasm32-unknown-unknown/debu
|
||||||
|
g/build/website-client-857cf649d398b40d/out/menu_registry_fn.rs (140 lines)
|
||||||
|
⎿ Read crates/rustelo_components/src/navigation/language_selector/ssr.rs (59
|
||||||
|
lines)
|
||||||
|
⎿ Read crates/rustelo_components/src/navigation/language_selector/client.rs
|
||||||
|
(74 lines)
|
||||||
|
|
||||||
|
⏺ Let me read the current state of the files before modifying them.
|
||||||
|
|
||||||
|
⏺ Read 2 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Now let me read shell.rs:
|
||||||
|
|
||||||
|
⏺ Read 2 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Let me check what render_page_content_ssr does in the server routing:
|
||||||
|
|
||||||
|
⏺ Searched for 2 patterns, read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Let me check the PageProvider trait signature:
|
||||||
|
|
||||||
|
⏺ Searched for 9 patterns, read 3 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
Three layered bugs to fix:
|
||||||
|
1. Routing: render_page_content_with_language is a stub → generated
|
||||||
|
WebsitePageProvider exists but is disconnected from routing
|
||||||
|
2. DynChild mismatch: SSR renders static (no markers), WASM uses move || {}
|
||||||
|
(expects markers) → hydration cursor misalignment → "callback removed before
|
||||||
|
attaching"
|
||||||
|
3. Missing reverse lookup: client_routes.rs has path→const but no
|
||||||
|
path→component reverse lookup
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Now implementing all three fixes:
|
||||||
|
|
||||||
|
Step 1: build.rs — add get_component_for_path generation
|
||||||
|
|
||||||
|
⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie
|
||||||
|
nt/build.rs)
|
||||||
|
⎿ Added 19 lines
|
||||||
|
382 let routes_dir = rustelo_utils::routes_path();
|
||||||
|
383
|
||||||
|
384 let mut all_routes = HashMap::new();
|
||||||
|
385 + let mut path_to_component: HashMap<String, String> = HashM
|
||||||
|
+ap::new();
|
||||||
|
386
|
||||||
|
387 // Create default route structure (used as fallback)
|
||||||
|
388 let default_routes = {
|
||||||
|
...
|
||||||
|
396 if !routes_dir.exists() {
|
||||||
|
397 println!("cargo:warning=No routes configuration found,
|
||||||
|
generating defaults");
|
||||||
|
398 all_routes = default_routes;
|
||||||
|
399 + path_to_component.insert("/".to_string(), "Home".to_st
|
||||||
|
+ring());
|
||||||
|
400 } else {
|
||||||
|
401 // Use typed route loading (NCL-first, TOML-fallback)
|
||||||
|
402 match build_config::route_config::load_all_routes(&rou
|
||||||
|
tes_dir) {
|
||||||
|
...
|
||||||
|
417
|
||||||
|
418 // Use component name as route name fo
|
||||||
|
r consistency
|
||||||
|
419 lang_routes.insert(route.component.to_
|
||||||
|
lowercase(), route.path.clone());
|
||||||
|
420 +
|
||||||
|
421 + // Reverse lookup: path → component (f
|
||||||
|
+irst language wins per path)
|
||||||
|
422 + path_to_component
|
||||||
|
423 + .entry(route.path.clone())
|
||||||
|
424 + .or_insert_with(|| route.component
|
||||||
|
+.clone());
|
||||||
|
425 }
|
||||||
|
426
|
||||||
|
427 all_routes.insert(lang, lang_routes);
|
||||||
|
...
|
||||||
|
430 Err(e) => {
|
||||||
|
431 eprintln!("Warning: Failed to load routes: {}.
|
||||||
|
Using defaults.", e);
|
||||||
|
432 all_routes = default_routes;
|
||||||
|
433 + path_to_component.insert("/".to_string(), "Hom
|
||||||
|
+e".to_string());
|
||||||
|
434 }
|
||||||
|
435 }
|
||||||
|
436 }
|
||||||
|
...
|
||||||
|
465 _ => None,
|
||||||
|
466 }
|
||||||
|
467 }
|
||||||
|
468 +
|
||||||
|
469 + /// Get component name for a given path (reverse route loo
|
||||||
|
+kup)
|
||||||
|
470 + pub fn get_component_for_path(path: &str) -> Option<&'stat
|
||||||
|
+ic str> {
|
||||||
|
471 + match path {
|
||||||
|
472 + {% for route_path, component_name in path_to_compo
|
||||||
|
+nent %}
|
||||||
|
473 + "{{ route_path }}" => Some("{{ component_name }}")
|
||||||
|
+,
|
||||||
|
474 + {% endfor %}
|
||||||
|
475 + _ => None,
|
||||||
|
476 + }
|
||||||
|
477 + }
|
||||||
|
478 }
|
||||||
|
479 "#;
|
||||||
|
480
|
||||||
|
...
|
||||||
|
482
|
||||||
|
483 let mut context = Context::new();
|
||||||
|
484 context.insert("languages", &all_routes);
|
||||||
|
485 + context.insert("path_to_component", &path_to_component);
|
||||||
|
486
|
||||||
|
487 let generated_code = tera.render("client_routes", &context
|
||||||
|
)?;
|
||||||
|
488
|
||||||
|
|
||||||
|
⏺ Step 2: app.rs — replace stub with generated WebsitePageProvider
|
||||||
|
|
||||||
|
⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie
|
||||||
|
nt/src/app.rs)
|
||||||
|
⎿ Added 1 line
|
||||||
|
13 i18n::{provide_unified_i18n, UnifiedI18n},
|
||||||
|
14 routing::utils::detect_language_from_path,
|
||||||
|
15 state::LanguageProvider,
|
||||||
|
16 + PageProvider,
|
||||||
|
17 };
|
||||||
|
18 #[cfg(target_arch = "wasm32")]
|
||||||
|
19 use std::time::Duration;
|
||||||
|
⎿ Found 25 new diagnostic issues in 5 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie
|
||||||
|
nt/src/app.rs)
|
||||||
|
⎿ Added 12 lines, removed 1 line
|
||||||
|
165 #[cfg(target_arch = "wasm32")]
|
||||||
|
166 web_sys::console::log_1(&format!("
|
||||||
|
🎬 App main content render: path='{}', language='{}' (content
|
||||||
|
re-rendering)", current_path, current_lang).into());
|
||||||
|
167
|
||||||
|
168 - rustelo_client::routing::render_pa
|
||||||
|
-ge_content_with_language(¤t_path, ¤t_lang)
|
||||||
|
168 + {
|
||||||
|
169 + let component = crate::routes:
|
||||||
|
+:get_component_for_path(¤t_path)
|
||||||
|
170 + .unwrap_or("NotFound")
|
||||||
|
171 + .to_string();
|
||||||
|
172 + crate::WebsitePageProvider
|
||||||
|
173 + .render_component(
|
||||||
|
174 + &component,
|
||||||
|
175 + std::collections::Hash
|
||||||
|
+Map::new(),
|
||||||
|
176 + ¤t_lang,
|
||||||
|
177 + )
|
||||||
|
178 + .unwrap_or_else(|_| view!
|
||||||
|
+{ <div>"Page not found"</div> }.into_any())
|
||||||
|
179 + }
|
||||||
|
180 }
|
||||||
|
181 }
|
||||||
|
182 </main>
|
||||||
|
⎿ Found 19 new diagnostic issues in 3 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Step 3: shell.rs — add move || {} wrappers for DynChild markers
|
||||||
|
|
||||||
|
⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/serv
|
||||||
|
er/src/shell.rs)
|
||||||
|
⎿ Added 12 lines, removed 3 lines
|
||||||
|
96 let language_clone = language.clone();
|
||||||
|
97 let current_path_clone = current_path.clone();
|
||||||
|
98
|
||||||
|
99 + // Pre-clone before closures to avoid move conflicts
|
||||||
|
100 + let footer_lang = language_clone.clone();
|
||||||
|
101 +
|
||||||
|
102 // Create the app component that properly establishes Owne
|
||||||
|
r context for SSR
|
||||||
|
103 let app = move || {
|
||||||
|
104 // Establish proper Owner context for SSR rendering
|
||||||
|
...
|
||||||
|
113 />
|
||||||
|
114 <main class="max-w-7xl mx-auto py-2 sm:ds-
|
||||||
|
container flex-grow page-content fade-out">
|
||||||
|
115 {
|
||||||
|
113 - // Use the foundation routing syst
|
||||||
|
-em directly (PAP-compliant!)
|
||||||
|
114 - rustelo_server::routing::render_pa
|
||||||
|
-ge_content_ssr(¤t_path_clone)
|
||||||
|
116 + // DynChild wrapper: emits <!--#n-
|
||||||
|
+->...<!--/-n--> markers that match
|
||||||
|
117 + // the client's move || {} reactiv
|
||||||
|
+e boundary in app.rs
|
||||||
|
118 + move || rustelo_server::routing::r
|
||||||
|
+ender_page_content_ssr(¤t_path_clone)
|
||||||
|
119 }
|
||||||
|
120 </main>
|
||||||
|
117 - <rustelo_components::navigation::footer::s
|
||||||
|
-sr::FooterSSR language=language_clone.clone() />
|
||||||
|
121 + {
|
||||||
|
122 + // DynChild wrapper: emits markers mat
|
||||||
|
+ching client's move || {} footer closure
|
||||||
|
123 + move || view! {
|
||||||
|
124 + <rustelo_components::navigation::f
|
||||||
|
+ooter::ssr::FooterSSR language=footer_lang.clone() />
|
||||||
|
125 + }
|
||||||
|
126 + }
|
||||||
|
127 </div>
|
||||||
|
128 </rustelo_components::theme::ThemeProvider>
|
||||||
|
129 }
|
||||||
|
⎿ Found 13 new diagnostic issues in 2 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Let me verify the complete state of the changed files:
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
⎿ Found 13 new diagnostic issues in 2 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ Let me verify the shell.rs changes:
|
||||||
|
|
||||||
|
⏺ Read 1 file (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ There's a potential type inference issue with HashMap::new() - let me make it
|
||||||
|
explicit:
|
||||||
|
|
||||||
|
⏺ Update(/Users/Akasha/Development/evol-rustelo/website/website-impl/crates/clie
|
||||||
|
nt/src/app.rs)
|
||||||
|
⎿ Added 3 lines, removed 5 lines
|
||||||
|
169 let component = crate::routes:
|
||||||
|
:get_component_for_path(¤t_path)
|
||||||
|
170 .unwrap_or("NotFound")
|
||||||
|
171 .to_string();
|
||||||
|
172 + let props: std::collections::H
|
||||||
|
+ashMap<String, String> =
|
||||||
|
173 + std::collections::HashMap:
|
||||||
|
+:new();
|
||||||
|
174 crate::WebsitePageProvider
|
||||||
|
173 - .render_component(
|
||||||
|
174 - &component,
|
||||||
|
175 - std::collections::Hash
|
||||||
|
-Map::new(),
|
||||||
|
176 - ¤t_lang,
|
||||||
|
177 - )
|
||||||
|
175 + .render_component(&compone
|
||||||
|
+nt, props, ¤t_lang)
|
||||||
|
176 .unwrap_or_else(|_| view!
|
||||||
|
{ <div>"Page not found"</div> }.into_any())
|
||||||
|
177 }
|
||||||
|
178 }
|
||||||
|
|
||||||
|
⏺ Now let me do a final review of all three changed files:
|
||||||
|
|
||||||
|
⏺ Read 2 files (ctrl+o to expand)
|
||||||
|
⎿ Found 13 new diagnostic issues in 2 files (ctrl+o to expand)
|
||||||
|
|
||||||
|
⏺ ★ Insight ─────────────────────────────────────
|
||||||
|
Three changes work together as a coherent fix:
|
||||||
|
- build.rs: or_insert_with in the HashMap ensures the first language to map a
|
||||||
|
path wins — so /about → About even if multiple languages declare that path
|
||||||
|
- app.rs: The inner {} block inside move || {} is idiomatic Rust for grouping
|
||||||
|
statements with a final expression value — needed because the closure already
|
||||||
|
owns the outer move's captures
|
||||||
|
- shell.rs: SSR DynChild closures (move || expr) evaluate immediately during
|
||||||
|
SSR but emit <!--#n--> markers — no performance penalty, pure structural
|
||||||
|
alignment with the WASM tree
|
||||||
|
─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
All three changes are in place. Summary of what was done:
|
||||||
|
|
||||||
|
crates/client/build.rs — generate_client_routes():
|
||||||
|
- Added path_to_component: HashMap<String, String> populated alongside
|
||||||
|
all_routes
|
||||||
|
- Extended the Tera template with pub fn get_component_for_path(path: &str) ->
|
||||||
|
Option<&'static str> reverse lookup
|
||||||
|
- Added path_to_component to the template context
|
||||||
|
|
||||||
|
crates/client/src/app.rs:
|
||||||
|
- Added PageProvider to the rustelo_core_lib imports (needed to call trait
|
||||||
|
method)
|
||||||
|
- Replaced the stub
|
||||||
|
rustelo_client::routing::render_page_content_with_language(...) with
|
||||||
|
crate::routes::get_component_for_path(path) →
|
||||||
|
crate::WebsitePageProvider.render_component(component, props, lang)
|
||||||
|
|
||||||
|
crates/server/src/shell.rs:
|
||||||
|
- Pre-cloned language_clone into footer_lang before the app closure
|
||||||
|
- Wrapped page content in move ||
|
||||||
|
rustelo_server::routing::render_page_content_ssr(...) to emit DynChild markers
|
||||||
|
- Wrapped footer in move || view! { <FooterSSR ... /> } to emit matching
|
||||||
|
DynChild markers
|
||||||
|
|
||||||
|
Next build triggers re-generation of client_routes.rs with the new
|
||||||
|
get_component_for_path function. After that, pages should route correctly and
|
||||||
|
the hydration panic should be resolved.
|
||||||
|
|
||||||
|
✻ Churned for 51m 20s
|
||||||
|
|
@ -44,11 +44,11 @@ fluent = { workspace = true }
|
||||||
fluent-bundle = { workspace = true }
|
fluent-bundle = { workspace = true }
|
||||||
unic-langid = { workspace = true }
|
unic-langid = { workspace = true }
|
||||||
|
|
||||||
rustelo_core_lib = { workspace = true }
|
rustelo_core_lib = { workspace = true, features = ["leptos-hydration"] }
|
||||||
rustelo_core_types = { workspace = true }
|
rustelo_core_types = { workspace = true }
|
||||||
rustelo_components = { workspace = true }
|
rustelo_components_leptos = { workspace = true }
|
||||||
|
|
||||||
rustelo_pages = { workspace = true }
|
rustelo_pages_leptos = { workspace = true }
|
||||||
gloo-timers = { workspace = true }
|
gloo-timers = { workspace = true }
|
||||||
wasm-bindgen-futures = { workspace = true }
|
wasm-bindgen-futures = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|
@ -60,6 +60,9 @@ paste = { workspace = true }
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
hydrate = ["leptos/hydrate"]
|
hydrate = ["leptos/hydrate"]
|
||||||
|
# Gate the wasm-bindgen hydrate() entry point — disable when used as a library
|
||||||
|
# dependency by another crate that provides its own hydrate() entry point.
|
||||||
|
hydrate-entrypoint = []
|
||||||
content-static = []
|
content-static = []
|
||||||
content-watcher = ["content-static"]
|
content-watcher = ["content-static"]
|
||||||
tls = []
|
tls = []
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
//! Demonstrates the 10% extension pattern - when TOML configuration isn't
|
//! Demonstrates the 10% extension pattern - when TOML configuration isn't
|
||||||
//! sufficient and you need custom client-side behavior beyond what
|
//! sufficient and you need custom client-side behavior beyond what
|
||||||
//! configuration can express.
|
//! configuration can express.
|
||||||
|
#![allow(dead_code, unused_variables, unexpected_cfgs)]
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use rustelo_client::{AppComponent, ClientBuilder, HydrationOptions};
|
use leptos::task::spawn_local;
|
||||||
|
use serde_json::json;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use web_sys::{console, window};
|
use web_sys::{console, window};
|
||||||
|
|
||||||
|
|
@ -19,7 +21,7 @@ pub fn hydrate_with_custom_extensions() {
|
||||||
console::log_1(&"============================".into());
|
console::log_1(&"============================".into());
|
||||||
|
|
||||||
// Foundation provides 90% through TOML configuration
|
// Foundation provides 90% through TOML configuration
|
||||||
let base_config = rustelo_client::ClientConfig::from_generated_routes();
|
let base_config = ClientConfig::from_generated_routes();
|
||||||
|
|
||||||
// Add 10% custom extensions when configuration isn't sufficient
|
// Add 10% custom extensions when configuration isn't sufficient
|
||||||
let extended_config = base_config
|
let extended_config = base_config
|
||||||
|
|
@ -29,7 +31,7 @@ pub fn hydrate_with_custom_extensions() {
|
||||||
.with_service_worker(setup_offline_capabilities());
|
.with_service_worker(setup_offline_capabilities());
|
||||||
|
|
||||||
// Hydrate with both generated + custom functionality
|
// Hydrate with both generated + custom functionality
|
||||||
rustelo_client::hydrate_with_config(extended_config);
|
hydrate_with_config(extended_config);
|
||||||
|
|
||||||
console::log_1(&"✨ Extended hydration complete with custom features!".into());
|
console::log_1(&"✨ Extended hydration complete with custom features!".into());
|
||||||
}
|
}
|
||||||
|
|
@ -117,15 +119,17 @@ fn setup_client_analytics() -> AnalyticsConfig {
|
||||||
.with_error_tracking(true)
|
.with_error_tracking(true)
|
||||||
.with_custom_events(vec![
|
.with_custom_events(vec![
|
||||||
// Track custom business events
|
// Track custom business events
|
||||||
CustomEvent::new("dashboard_interaction").with_properties(|interaction| {
|
CustomEvent::new("dashboard_interaction").with_properties(
|
||||||
json!({
|
|interaction: &Interaction| {
|
||||||
"component": interaction.component_name,
|
json!({
|
||||||
"action": interaction.action_type,
|
"component": interaction.component_name,
|
||||||
"timestamp": js_sys::Date::now(),
|
"action": interaction.action_type,
|
||||||
"user_session": get_session_id()
|
"timestamp": js_sys::Date::now(),
|
||||||
})
|
"user_session": get_session_id()
|
||||||
}),
|
})
|
||||||
CustomEvent::new("feature_usage").with_properties(|usage| {
|
},
|
||||||
|
),
|
||||||
|
CustomEvent::new("feature_usage").with_properties(|usage: &FeatureUsage| {
|
||||||
json!({
|
json!({
|
||||||
"feature_name": usage.feature,
|
"feature_name": usage.feature,
|
||||||
"usage_duration": usage.duration_ms,
|
"usage_duration": usage.duration_ms,
|
||||||
|
|
@ -182,24 +186,26 @@ pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
console::log_1(&"🎨 Rendering Enhanced Dashboard with custom extensions".into());
|
console::log_1(&"🎨 Rendering Enhanced Dashboard with custom extensions".into());
|
||||||
|
|
||||||
// Get the base component generated from TOML configuration (90%)
|
// Get the base component generated from TOML configuration (90%)
|
||||||
let base_dashboard = rustelo_client::get_generated_component("Dashboard");
|
let base_dashboard = get_generated_component("Dashboard");
|
||||||
|
|
||||||
// Add custom client-side state for extensions (10%)
|
// Add custom client-side state for extensions (10%)
|
||||||
let (realtime_metrics, set_realtime_metrics) = create_signal(Vec::new());
|
// RwSignal uses SyncStorage — Send + Sync — required by IntoClass for class:
|
||||||
let (notification_count, set_notification_count) = create_signal(0);
|
// conditionals.
|
||||||
let (is_offline, set_is_offline) = create_signal(false);
|
let realtime_metrics: RwSignal<Vec<_>> = RwSignal::new(Vec::new());
|
||||||
|
let notification_count = RwSignal::new(0i32);
|
||||||
|
let is_offline = RwSignal::new(false);
|
||||||
|
|
||||||
// Custom WebSocket effect for real-time data
|
// Custom WebSocket effect for real-time data
|
||||||
create_effect(move |_| {
|
Effect::new(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match establish_websocket_connection().await {
|
match establish_websocket_connection().await {
|
||||||
Ok(ws_stream) => {
|
Ok(mut ws_stream) => {
|
||||||
console::log_1(&"📡 Real-time connection established".into());
|
console::log_1(&"📡 Real-time connection established".into());
|
||||||
|
|
||||||
// Listen for real-time updates
|
// Listen for real-time updates
|
||||||
while let Some(message) = ws_stream.next().await {
|
while let Some(message) = ws_stream.next().await {
|
||||||
if let Ok(metrics) = parse_metrics_message(&message) {
|
if let Ok(metrics) = parse_metrics_message(&message) {
|
||||||
set_realtime_metrics.set(metrics);
|
realtime_metrics.set(metrics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,20 +217,23 @@ pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom offline detection
|
// Custom offline detection
|
||||||
create_effect(move |_| {
|
Effect::new(move |_| {
|
||||||
let navigator = window().unwrap().navigator();
|
if let Some(win) = window() {
|
||||||
let is_online = navigator.on_line();
|
let navigator = win.navigator();
|
||||||
set_is_offline.set(!is_online);
|
let is_online = navigator.on_line();
|
||||||
|
is_offline.set(!is_online);
|
||||||
if !is_online {
|
if !is_online {
|
||||||
console::log_1(&"🔌 Application is now offline - enabling offline features".into());
|
console::log_1(&"🔌 Application is now offline - enabling offline features".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts for power users
|
// Keyboard shortcuts for power users
|
||||||
create_effect(move |_| {
|
Effect::new(move |_| {
|
||||||
let window = window().unwrap();
|
let Some(win) = window() else { return };
|
||||||
let document = window.document().unwrap();
|
let Some(document) = win.document() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
|
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
|
||||||
if event.ctrl_key() {
|
if event.ctrl_key() {
|
||||||
|
|
@ -232,12 +241,10 @@ pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
"d" => {
|
"d" => {
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
console::log_1(&"⌨️ Keyboard shortcut: Focus dashboard".into());
|
console::log_1(&"⌨️ Keyboard shortcut: Focus dashboard".into());
|
||||||
// Custom dashboard focus logic
|
|
||||||
}
|
}
|
||||||
"n" => {
|
"n" => {
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
console::log_1(&"⌨️ Keyboard shortcut: New item".into());
|
console::log_1(&"⌨️ Keyboard shortcut: New item".into());
|
||||||
// Custom new item creation logic
|
|
||||||
}
|
}
|
||||||
"?" => {
|
"?" => {
|
||||||
event.prevent_default();
|
event.prevent_default();
|
||||||
|
|
@ -248,26 +255,25 @@ pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
}
|
}
|
||||||
}) as Box<dyn FnMut(_)>);
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
document
|
let _ =
|
||||||
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
|
document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
closure.forget(); // Keep the closure alive
|
closure.forget();
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="enhanced-dashboard">
|
<div class="enhanced-dashboard">
|
||||||
// Status indicators for custom features
|
// Status indicators for custom features
|
||||||
<div class="extension-status-bar">
|
<div class="extension-status-bar">
|
||||||
<div class="realtime-indicator" class:connected=move || !realtime_metrics.get().is_empty()>
|
<div class="realtime-indicator" class:connected={move || !realtime_metrics.get().is_empty()}>
|
||||||
"📡 " {move || if realtime_metrics.get().is_empty() { "Connecting..." } else { "Live" }}
|
"📡 " {move || if realtime_metrics.get().is_empty() { "Connecting..." } else { "Live" }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="notification-badge" class:has-notifications=move || notification_count.get() > 0>
|
<div class="notification-badge" class:has-notifications={move || notification_count.get() > 0}>
|
||||||
"🔔 " {notification_count}
|
"🔔 " {move || notification_count.get().to_string()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="offline-indicator" class:offline=is_offline>
|
<div class="offline-indicator" class:offline={is_offline}>
|
||||||
{move || if is_offline.get() { "🔌 Offline Mode" } else { "🌐 Online" }}
|
{move || if is_offline.get() { "🔌 Offline Mode" } else { "🌐 Online" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -294,19 +300,20 @@ pub fn EnhancedDashboard() -> impl IntoView {
|
||||||
///
|
///
|
||||||
/// Custom component that displays live data from WebSocket connections
|
/// Custom component that displays live data from WebSocket connections
|
||||||
#[component]
|
#[component]
|
||||||
fn RealtimeMetricsPanel(metrics: ReadSignal<Vec<Metric>>) -> impl IntoView {
|
fn RealtimeMetricsPanel(metrics: RwSignal<Vec<Metric>>) -> impl IntoView {
|
||||||
view! {
|
view! {
|
||||||
<div class="realtime-metrics-panel">
|
<div class="realtime-metrics-panel">
|
||||||
<h3>"📊 Live Metrics"</h3>
|
<h3>"📊 Live Metrics"</h3>
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
{move || {
|
{move || {
|
||||||
metrics.get().iter().map(|metric| {
|
metrics.get().into_iter().map(|metric| {
|
||||||
|
let trend = metric.trend;
|
||||||
view! {
|
view! {
|
||||||
<div class="metric-card" data-metric-type=&metric.metric_type>
|
<div class="metric-card" data-metric-type=metric.metric_type>
|
||||||
<div class="metric-value">{&metric.value}</div>
|
<div class="metric-value">{metric.value}</div>
|
||||||
<div class="metric-label">{&metric.label}</div>
|
<div class="metric-label">{metric.label}</div>
|
||||||
<div class="metric-trend" class:positive=metric.trend > 0.0 class:negative=metric.trend < 0.0>
|
<div class="metric-trend" class:positive={trend > 0.0} class:negative={trend < 0.0}>
|
||||||
{format!("{:+.1}%", metric.trend)}
|
{format!("{:+.1}%", trend)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -322,14 +329,14 @@ fn RealtimeMetricsPanel(metrics: ReadSignal<Vec<Metric>>) -> impl IntoView {
|
||||||
/// Custom charting with advanced interactivity
|
/// Custom charting with advanced interactivity
|
||||||
#[component]
|
#[component]
|
||||||
fn AdvancedCharting() -> impl IntoView {
|
fn AdvancedCharting() -> impl IntoView {
|
||||||
let (chart_data, set_chart_data) = create_signal(Vec::new());
|
let chart_data = RwSignal::new(Vec::<serde_json::Value>::new());
|
||||||
let (chart_type, set_chart_type) = create_signal(ChartType::Line);
|
let chart_type = RwSignal::new(ChartType::Line);
|
||||||
|
|
||||||
// Custom chart initialization
|
// Custom chart initialization
|
||||||
create_effect(move |_| {
|
Effect::new(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match load_advanced_chart_data().await {
|
match load_advanced_chart_data().await {
|
||||||
Ok(data) => set_chart_data.set(data),
|
Ok(data) => chart_data.set(data),
|
||||||
Err(e) => console::log_1(&format!("Chart loading error: {}", e).into()),
|
Err(e) => console::log_1(&format!("Chart loading error: {}", e).into()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -340,7 +347,7 @@ fn AdvancedCharting() -> impl IntoView {
|
||||||
<div class="chart-controls">
|
<div class="chart-controls">
|
||||||
<select on:change=move |ev| {
|
<select on:change=move |ev| {
|
||||||
let value = event_target_value(&ev);
|
let value = event_target_value(&ev);
|
||||||
set_chart_type.set(ChartType::from_str(&value));
|
chart_type.set(ChartType::from_str(&value));
|
||||||
}>
|
}>
|
||||||
<option value="line">"Line Chart"</option>
|
<option value="line">"Line Chart"</option>
|
||||||
<option value="bar">"Bar Chart"</option>
|
<option value="bar">"Bar Chart"</option>
|
||||||
|
|
@ -572,14 +579,14 @@ fn main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supporting types and functions for the example
|
// Supporting types and functions for the example
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
struct RealtimeData {
|
struct RealtimeData {
|
||||||
metrics: Vec<Metric>,
|
metrics: Vec<Metric>,
|
||||||
notifications: u32,
|
notifications: u32,
|
||||||
users_online: u32,
|
users_online: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
struct Metric {
|
struct Metric {
|
||||||
metric_type: String,
|
metric_type: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
|
@ -639,8 +646,16 @@ fn get_vapid_public_key() -> String {
|
||||||
"vapid_key".to_string()
|
"vapid_key".to_string()
|
||||||
}
|
}
|
||||||
fn show_system_notification(_title: &str, _body: &str) {}
|
fn show_system_notification(_title: &str, _body: &str) {}
|
||||||
async fn establish_websocket_connection() -> Result<(), String> {
|
async fn establish_websocket_connection() -> Result<MockWsStream, String> {
|
||||||
Ok(())
|
Ok(MockWsStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockWsStream;
|
||||||
|
|
||||||
|
impl MockWsStream {
|
||||||
|
async fn next(&mut self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn parse_metrics_message(_message: &str) -> Result<Vec<Metric>, String> {
|
fn parse_metrics_message(_message: &str) -> Result<Vec<Metric>, String> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
|
|
@ -816,14 +831,20 @@ impl CustomEvent {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn with_properties<F>(self, _f: F) -> Self
|
fn with_properties<T, F>(self, _f: F) -> Self
|
||||||
where
|
where
|
||||||
F: Fn(&Interaction) -> serde_json::Value,
|
F: Fn(&T) -> serde_json::Value,
|
||||||
{
|
{
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct FeatureUsage {
|
||||||
|
feature: String,
|
||||||
|
duration_ms: u64,
|
||||||
|
completed_successfully: bool,
|
||||||
|
}
|
||||||
|
|
||||||
struct HeatmapConfig {
|
struct HeatmapConfig {
|
||||||
track_clicks: bool,
|
track_clicks: bool,
|
||||||
track_scrolling: bool,
|
track_scrolling: bool,
|
||||||
|
|
@ -862,3 +883,61 @@ struct Notification {
|
||||||
title: String,
|
title: String,
|
||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Example API stubs
|
||||||
|
// ────────────────────────────────────────────────────────── These represent
|
||||||
|
// the future 90/10 extension API surface for rustelo_client. Defined here
|
||||||
|
// locally so the example compiles without modifications to the crate.
|
||||||
|
|
||||||
|
struct ClientConfig;
|
||||||
|
|
||||||
|
impl ClientConfig {
|
||||||
|
fn from_generated_routes() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_custom_hydration_hooks(self, _opts: HydrationOptions) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_realtime_features(self, _cfg: RealtimeConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_advanced_analytics(self, _cfg: AnalyticsConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_service_worker(self, _cfg: ServiceWorkerConfig) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hydrate_with_config(_config: ClientConfig) {}
|
||||||
|
|
||||||
|
fn get_generated_component(_name: &str) -> impl IntoView {
|
||||||
|
view! { <div class="generated-component-placeholder"></div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Missing component stubs
|
||||||
|
// ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn CustomNotificationCenter() -> impl IntoView {
|
||||||
|
view! { <div class="notification-center"></div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn OfflineQueueStatus() -> impl IntoView {
|
||||||
|
view! { <div class="offline-queue-status"></div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn KeyboardShortcutsModal() -> impl IntoView {
|
||||||
|
view! { <div class="keyboard-shortcuts-modal"></div> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn OfflineDataSyncIndicator() -> impl IntoView {
|
||||||
|
view! { <div class="offline-sync-indicator"></div> }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos_meta::provide_meta_context;
|
use leptos_meta::provide_meta_context;
|
||||||
use rustelo_components::{
|
use rustelo_components_leptos::{
|
||||||
navigation::{footer::unified::Footer, navmenu::unified::NavMenu},
|
navigation::{footer::unified::Footer, navmenu::unified::NavMenu},
|
||||||
theme::ThemeProvider,
|
theme::ThemeProvider,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
//! ### Form Components
|
//! ### Form Components
|
||||||
//!
|
//!
|
||||||
//! ```rust,ignore
|
//! ```rust,ignore
|
||||||
//! use rustelo_client::rustelo_components::{ContactForm, SupportForm};
|
//! use rustelo_client::rustelo_components_leptos::{ContactForm, SupportForm};
|
||||||
//! use leptos::prelude::*;
|
//! use leptos::prelude::*;
|
||||||
//!
|
//!
|
||||||
//! view! {
|
//! view! {
|
||||||
|
|
@ -179,8 +179,6 @@ pub mod routing;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
// Re-export console logging macros from shared for backward compatibility
|
|
||||||
use leptos::prelude::*;
|
|
||||||
// Re-export console control functions for easy access
|
// Re-export console control functions for easy access
|
||||||
pub use rustelo_core_lib::utils::console_control;
|
pub use rustelo_core_lib::utils::console_control;
|
||||||
use rustelo_core_lib::PageTranslator;
|
use rustelo_core_lib::PageTranslator;
|
||||||
|
|
@ -201,6 +199,7 @@ impl PageTranslator for crate::i18n::UseI18n {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate-entrypoint")]
|
||||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
pub fn hydrate() {
|
pub fn hydrate() {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_not_found(&self, _path: &str, language: &str) -> RoutingResult<Self::View> {
|
fn render_not_found(&self, _path: &str, language: &str) -> RoutingResult<Self::View> {
|
||||||
use rustelo_pages::NotFoundPage;
|
use rustelo_pages_leptos::NotFoundPage;
|
||||||
let lang = language.to_string();
|
let lang = language.to_string();
|
||||||
let view = view! { <NotFoundPage _language=lang /> }.into_any();
|
let view = view! { <NotFoundPage _language=lang /> }.into_any();
|
||||||
Ok(view)
|
Ok(view)
|
||||||
|
|
@ -379,7 +379,7 @@ where
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Fallback to not found - create directly since render_not_found may not be
|
// Fallback to not found - create directly since render_not_found may not be
|
||||||
// available
|
// available
|
||||||
use rustelo_pages::NotFoundPage;
|
use rustelo_pages_leptos::NotFoundPage;
|
||||||
let lang = language.to_string();
|
let lang = language.to_string();
|
||||||
view! { <NotFoundPage _language=lang /> }.into_any()
|
view! { <NotFoundPage _language=lang /> }.into_any()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
//! # Basic Layout Example
|
|
||||||
//!
|
|
||||||
//! Demonstrates the simplest way to use foundation components to create
|
|
||||||
//! a basic application layout with header, content, and footer.
|
|
||||||
|
|
||||||
use rustelo_components::{
|
|
||||||
navigation::{BrandHeader, Footer, NavMenu},
|
|
||||||
content::UnifiedContentCard,
|
|
||||||
ui::SpaLink,
|
|
||||||
};
|
|
||||||
use leptos::*;
|
|
||||||
|
|
||||||
/// Basic layout using foundation components directly
|
|
||||||
#[component]
|
|
||||||
pub fn BasicLayout() -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="min-h-screen flex flex-col">
|
|
||||||
// Header with navigation
|
|
||||||
<BrandHeader
|
|
||||||
brand_name="My Rustelo App"
|
|
||||||
logo_url="/logo.svg"
|
|
||||||
class="shadow-sm"
|
|
||||||
>
|
|
||||||
<NavMenu orientation="horizontal">
|
|
||||||
<SpaLink href="/" active_class="text-blue-600">
|
|
||||||
"Home"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/about" active_class="text-blue-600">
|
|
||||||
"About"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/contact" active_class="text-blue-600">
|
|
||||||
"Contact"
|
|
||||||
</SpaLink>
|
|
||||||
</NavMenu>
|
|
||||||
</BrandHeader>
|
|
||||||
|
|
||||||
// Main content area
|
|
||||||
<main class="flex-1 container mx-auto px-4 py-8">
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Welcome to Rustelo"
|
|
||||||
subtitle="A modern web framework for Rust"
|
|
||||||
>
|
|
||||||
<p class="mb-4">
|
|
||||||
"This is a basic layout example using foundation components. "
|
|
||||||
"The header, navigation, content card, and footer are all "
|
|
||||||
"provided by the components foundation library."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<SpaLink href="/docs" class="btn btn-primary">
|
|
||||||
"Documentation"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/examples" class="btn btn-secondary">
|
|
||||||
"More Examples"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Additional content sections
|
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Fast Development"
|
|
||||||
image_url="/features/speed.svg"
|
|
||||||
>
|
|
||||||
<p>"Build web applications quickly with pre-built components."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Type Safety"
|
|
||||||
image_url="/features/safety.svg"
|
|
||||||
>
|
|
||||||
<p>"Leverage Rust's type system for reliable web applications."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Modern Stack"
|
|
||||||
image_url="/features/modern.svg"
|
|
||||||
>
|
|
||||||
<p>"Built with Leptos, WebAssembly, and modern web standards."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
<Footer copyright="© 2024 My Company">
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<SpaLink href="/privacy" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Privacy Policy"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/terms" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Terms of Service"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/support" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Support"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</Footer>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example of using the layout in a full app
|
|
||||||
#[component]
|
|
||||||
pub fn App() -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<BasicLayout />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
To use this example:
|
|
||||||
|
|
||||||
1. Add to your Cargo.toml:
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
components = { path = "path/to/components" }
|
|
||||||
leptos = { version = "0.8", features = ["csr"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
2. In your main.rs:
|
|
||||||
```rust
|
|
||||||
use basic_layout::App;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
mount_to_body(|| view! { <App /> });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This provides:
|
|
||||||
- ✅ Responsive header with navigation
|
|
||||||
- ✅ Flexible main content area
|
|
||||||
- ✅ Reusable content cards
|
|
||||||
- ✅ SPA-aware navigation links
|
|
||||||
- ✅ Professional footer
|
|
||||||
|
|
||||||
The layout is:
|
|
||||||
- 📱 Mobile-responsive by default
|
|
||||||
- ♿ Accessible with proper ARIA attributes
|
|
||||||
- 🎨 Styled with utility classes (works with Tailwind CSS)
|
|
||||||
- 🔗 Uses SPA routing for fast navigation
|
|
||||||
- 🧩 Composable - easy to modify and extend
|
|
||||||
*/
|
|
||||||
|
|
@ -1,476 +0,0 @@
|
||||||
//! # Content Showcase Example
|
|
||||||
//!
|
|
||||||
//! Comprehensive demonstration of content components including
|
|
||||||
//! cards, grids, content management, and various layout patterns.
|
|
||||||
|
|
||||||
use rustelo_components::{
|
|
||||||
content::{
|
|
||||||
UnifiedContentCard,
|
|
||||||
ContentManager,
|
|
||||||
HtmlContent,
|
|
||||||
SimpleContentGrid
|
|
||||||
},
|
|
||||||
navigation::{BrandHeader, Footer},
|
|
||||||
ui::SpaLink,
|
|
||||||
filter::{UnifiedCategoryFilter, FilterItem},
|
|
||||||
};
|
|
||||||
use leptos::*;
|
|
||||||
|
|
||||||
/// Content showcase demonstrating various content display patterns
|
|
||||||
#[component]
|
|
||||||
pub fn ContentShowcase() -> impl IntoView {
|
|
||||||
// Content filter state
|
|
||||||
let (active_filter, set_active_filter) = create_signal("all".to_string());
|
|
||||||
|
|
||||||
// Sample content data
|
|
||||||
let content_items = create_rw_signal(vec![
|
|
||||||
ContentItem::new("1", "blog", "Getting Started with Rustelo", "Learn how to build modern web applications with Rust and Leptos.", "/blog/getting-started", Some("/images/blog1.jpg")),
|
|
||||||
ContentItem::new("2", "tutorial", "Advanced Component Patterns", "Explore advanced techniques for building reusable components.", "/tutorials/advanced-patterns", Some("/images/tutorial1.jpg")),
|
|
||||||
ContentItem::new("3", "news", "Rustelo 2.0 Released", "Major update brings new features and performance improvements.", "/news/v2-release", Some("/images/news1.jpg")),
|
|
||||||
ContentItem::new("4", "blog", "WebAssembly Performance Tips", "Optimize your WASM applications for better performance.", "/blog/wasm-performance", Some("/images/blog2.jpg")),
|
|
||||||
ContentItem::new("5", "tutorial", "Building a Blog with Rustelo", "Step-by-step guide to creating a full-featured blog.", "/tutorials/blog-tutorial", Some("/images/tutorial2.jpg")),
|
|
||||||
ContentItem::new("6", "showcase", "Community Project Spotlight", "Amazing projects built by the Rustelo community.", "/showcase/community", Some("/images/showcase1.jpg")),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Filter items for the category filter
|
|
||||||
let filter_items = vec![
|
|
||||||
FilterItem::new("all", "All Content").with_count(6),
|
|
||||||
FilterItem::new("blog", "Blog Posts").with_count(2),
|
|
||||||
FilterItem::new("tutorial", "Tutorials").with_count(2),
|
|
||||||
FilterItem::new("news", "News").with_count(1),
|
|
||||||
FilterItem::new("showcase", "Showcase").with_count(1),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filtered content based on active filter
|
|
||||||
let filtered_content = create_memo(move |_| {
|
|
||||||
let filter = active_filter.get();
|
|
||||||
let items = content_items.get();
|
|
||||||
|
|
||||||
if filter == "all" {
|
|
||||||
items
|
|
||||||
} else {
|
|
||||||
items.into_iter().filter(|item| item.category == filter).collect()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<BrandHeader brand_name="Content Showcase" />
|
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8">
|
|
||||||
// Introduction section
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Content Components Showcase"
|
|
||||||
subtitle="Explore various ways to display and organize content"
|
|
||||||
class="mb-8"
|
|
||||||
>
|
|
||||||
<p class="mb-4">
|
|
||||||
"This showcase demonstrates the content components available in the Rustelo foundation library. "
|
|
||||||
"You'll see cards, grids, content management, HTML rendering, and filtering in action."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
<span class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
|
||||||
"📋 Content Cards"
|
|
||||||
</span>
|
|
||||||
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
|
|
||||||
"🔍 Category Filtering"
|
|
||||||
</span>
|
|
||||||
<span class="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm">
|
|
||||||
"🎨 Grid Layouts"
|
|
||||||
</span>
|
|
||||||
<span class="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm">
|
|
||||||
"⚡ Dynamic Content"
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Content filtering section
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Filterable Content Grid"</h2>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
"Use the category filter below to see content filtering in action. "
|
|
||||||
"The grid updates dynamically based on your selection."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
// Category filter
|
|
||||||
<UnifiedCategoryFilter
|
|
||||||
items=filter_items
|
|
||||||
on_filter=move |category| {
|
|
||||||
set_active_filter.set(category);
|
|
||||||
}
|
|
||||||
class="mb-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Dynamic content grid
|
|
||||||
<SimpleContentGrid
|
|
||||||
columns=3
|
|
||||||
gap="1.5rem"
|
|
||||||
responsive=true
|
|
||||||
class="mb-8"
|
|
||||||
>
|
|
||||||
<For
|
|
||||||
each=move || filtered_content.get()
|
|
||||||
key=|item| item.id.clone()
|
|
||||||
children=move |item| {
|
|
||||||
view! {
|
|
||||||
<UnifiedContentCard
|
|
||||||
title=item.title.clone()
|
|
||||||
subtitle=item.excerpt.clone()
|
|
||||||
image_url=item.image_url.clone()
|
|
||||||
class="h-full"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between items-center mt-4">
|
|
||||||
<span class=format!(
|
|
||||||
"px-2 py-1 text-xs rounded-full {}",
|
|
||||||
match item.category.as_str() {
|
|
||||||
"blog" => "bg-blue-100 text-blue-800",
|
|
||||||
"tutorial" => "bg-green-100 text-green-800",
|
|
||||||
"news" => "bg-red-100 text-red-800",
|
|
||||||
"showcase" => "bg-purple-100 text-purple-800",
|
|
||||||
_ => "bg-gray-100 text-gray-800",
|
|
||||||
}
|
|
||||||
)>
|
|
||||||
{item.category.clone()}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href=item.url.clone()
|
|
||||||
class="text-blue-600 hover:text-blue-800 font-medium text-sm"
|
|
||||||
>
|
|
||||||
"Read More →"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SimpleContentGrid>
|
|
||||||
|
|
||||||
// Results counter
|
|
||||||
<div class="text-center text-gray-600">
|
|
||||||
{move || {
|
|
||||||
let count = filtered_content.get().len();
|
|
||||||
let filter = active_filter.get();
|
|
||||||
let filter_text = if filter == "all" { "all content" } else { &filter };
|
|
||||||
format!("Showing {} {} items", count, filter_text)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// HTML Content rendering demo
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">"HTML Content Rendering"</h2>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
"The HtmlContent component safely renders HTML with built-in sanitization."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-2 gap-6">
|
|
||||||
<UnifiedContentCard title="Safe HTML Rendering" class="h-full">
|
|
||||||
<HtmlContent
|
|
||||||
html=r#"
|
|
||||||
<h3>Rich Text Content</h3>
|
|
||||||
<p>This HTML content is <strong>safely rendered</strong> with sanitization enabled.</p>
|
|
||||||
<ul>
|
|
||||||
<li>✅ Safe HTML tags are allowed</li>
|
|
||||||
<li>🛡️ Dangerous scripts are removed</li>
|
|
||||||
<li>🎨 Styling is preserved</li>
|
|
||||||
</ul>
|
|
||||||
<blockquote>
|
|
||||||
<em>"Security and functionality working together."</em>
|
|
||||||
</blockquote>
|
|
||||||
"#
|
|
||||||
sanitize=true
|
|
||||||
class="prose prose-sm"
|
|
||||||
/>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="Markdown-style Content" class="h-full">
|
|
||||||
<HtmlContent
|
|
||||||
html=r#"
|
|
||||||
<div class="markdown-content">
|
|
||||||
<h3>📝 Code Example</h3>
|
|
||||||
<pre><code>use rustelo_components::content::HtmlContent;
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<HtmlContent
|
|
||||||
html="<p>Safe HTML</p>"
|
|
||||||
sanitize=true
|
|
||||||
/>
|
|
||||||
}</code></pre>
|
|
||||||
<p>The component handles HTML rendering with configurable sanitization rules.</p>
|
|
||||||
</div>
|
|
||||||
"#
|
|
||||||
sanitize=true
|
|
||||||
class="prose prose-sm"
|
|
||||||
/>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Content Manager demo
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Dynamic Content Management"</h2>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
"The ContentManager component handles dynamic content loading and display with various layout options."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
// List layout
|
|
||||||
<UnifiedContentCard title="List Layout">
|
|
||||||
<ContentManager
|
|
||||||
content_type="featured"
|
|
||||||
layout="list"
|
|
||||||
limit=3
|
|
||||||
class="space-y-4"
|
|
||||||
/>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Card layout
|
|
||||||
<UnifiedContentCard title="Card Layout">
|
|
||||||
<ContentManager
|
|
||||||
content_type="recent"
|
|
||||||
layout="cards"
|
|
||||||
limit=4
|
|
||||||
/>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Layout variations
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Grid Layout Variations"</h2>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
"Different grid configurations for various content display needs."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="space-y-8">
|
|
||||||
// 2-column grid
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4">"Two Column Grid"</h3>
|
|
||||||
<SimpleContentGrid columns=2 gap="2rem" responsive=true>
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Performance Optimization"
|
|
||||||
image_url="/images/performance.jpg"
|
|
||||||
>
|
|
||||||
<p>"Learn techniques to optimize your Rustelo applications for better performance and user experience."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Testing Strategies"
|
|
||||||
image_url="/images/testing.jpg"
|
|
||||||
>
|
|
||||||
<p>"Comprehensive testing approaches for Rust web applications using modern testing frameworks."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</SimpleContentGrid>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// 4-column grid
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4">"Four Column Grid"</h3>
|
|
||||||
<SimpleContentGrid columns=4 gap="1rem" responsive=true>
|
|
||||||
<UnifiedContentCard title="🚀 Fast" class="text-center">
|
|
||||||
<p class="text-sm">"Lightning-fast development with Rust's performance."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="🛡️ Safe" class="text-center">
|
|
||||||
<p class="text-sm">"Memory safety without garbage collection."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="🔧 Productive" class="text-center">
|
|
||||||
<p class="text-sm">"Rich type system and ownership model."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="🌐 Modern" class="text-center">
|
|
||||||
<p class="text-sm">"WebAssembly and modern web standards."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</SimpleContentGrid>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Mixed content sizes
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-4">"Mixed Content Sizes"</h3>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Featured Article"
|
|
||||||
subtitle="In-depth tutorial on advanced Rustelo patterns"
|
|
||||||
image_url="/images/featured.jpg"
|
|
||||||
class="h-full"
|
|
||||||
>
|
|
||||||
<p>"This comprehensive guide covers advanced component patterns, state management, and performance optimization techniques for building scalable Rustelo applications."</p>
|
|
||||||
|
|
||||||
<div class="mt-4 flex gap-2">
|
|
||||||
<SpaLink href="/featured" class="btn btn-primary">"Read Full Article"</SpaLink>
|
|
||||||
<SpaLink href="/tutorials" class="btn btn-secondary">"More Tutorials"</SpaLink>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="Quick Tips" class="h-full">
|
|
||||||
<ul class="text-sm space-y-2">
|
|
||||||
<li>"💡 Use memos for expensive computations"</li>
|
|
||||||
<li>"🔄 Batch state updates when possible"</li>
|
|
||||||
<li>"📦 Code-split large components"</li>
|
|
||||||
<li>"🎨 Use CSS-in-Rust for theming"</li>
|
|
||||||
</ul>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
<UnifiedContentCard title="Resources" class="h-full">
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<SpaLink href="/docs" class="block text-blue-600 hover:text-blue-800">
|
|
||||||
"📚 Documentation"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/examples" class="block text-blue-600 hover:text-blue-800">
|
|
||||||
"💻 Code Examples"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/community" class="block text-blue-600 hover:text-blue-800">
|
|
||||||
"👥 Community"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/github" class="block text-blue-600 hover:text-blue-800">
|
|
||||||
"🐙 GitHub Repo"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Loading states demo
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">"Loading States & Error Handling"</h2>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
"Content components with loading states and error boundaries for robust user experiences."
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<SimpleContentGrid columns=3 gap="1.5rem" responsive=true>
|
|
||||||
// Loading state demo
|
|
||||||
<UnifiedContentCard title="Loading State">
|
|
||||||
<div class="animate-pulse space-y-4">
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-center text-gray-500 text-sm">
|
|
||||||
"Loading content..."
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Empty state demo
|
|
||||||
<UnifiedContentCard title="Empty State">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<div class="text-6xl mb-4">"📭"</div>
|
|
||||||
<p class="text-gray-500 mb-4">"No content available"</p>
|
|
||||||
<SpaLink href="/create" class="btn btn-primary btn-sm">
|
|
||||||
"Create Content"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Error state demo
|
|
||||||
<UnifiedContentCard title="Error State">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<div class="text-6xl mb-4">"⚠️"</div>
|
|
||||||
<p class="text-red-600 mb-4">"Failed to load content"</p>
|
|
||||||
<button class="btn btn-secondary btn-sm">
|
|
||||||
"Try Again"
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
</SimpleContentGrid>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<Footer copyright="© 2024 Content Showcase Demo" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper struct for content items
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct ContentItem {
|
|
||||||
id: String,
|
|
||||||
category: String,
|
|
||||||
title: String,
|
|
||||||
excerpt: String,
|
|
||||||
url: String,
|
|
||||||
image_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContentItem {
|
|
||||||
fn new(id: &str, category: &str, title: &str, excerpt: &str, url: &str, image_url: Option<&str>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: id.to_string(),
|
|
||||||
category: category.to_string(),
|
|
||||||
title: title.to_string(),
|
|
||||||
excerpt: excerpt.to_string(),
|
|
||||||
url: url.to_string(),
|
|
||||||
image_url: image_url.map(|s| s.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
This comprehensive showcase demonstrates:
|
|
||||||
|
|
||||||
✅ **Content Display Patterns**
|
|
||||||
- Various card layouts and configurations
|
|
||||||
- Grid systems with responsive behavior
|
|
||||||
- Mixed content sizes and layouts
|
|
||||||
|
|
||||||
✅ **Dynamic Content Management**
|
|
||||||
- Content filtering and categorization
|
|
||||||
- Dynamic content loading with ContentManager
|
|
||||||
- Real-time content updates
|
|
||||||
|
|
||||||
✅ **HTML Rendering**
|
|
||||||
- Safe HTML content rendering with sanitization
|
|
||||||
- Support for rich text and markdown-style content
|
|
||||||
- Configurable sanitization rules
|
|
||||||
|
|
||||||
✅ **Layout Flexibility**
|
|
||||||
- Multiple grid column configurations
|
|
||||||
- Responsive grid behavior
|
|
||||||
- Mixed content sizing patterns
|
|
||||||
|
|
||||||
✅ **User Experience**
|
|
||||||
- Loading states and skeletons
|
|
||||||
- Empty state handling
|
|
||||||
- Error state management
|
|
||||||
- Interactive filtering
|
|
||||||
|
|
||||||
✅ **Content Organization**
|
|
||||||
- Category-based filtering
|
|
||||||
- Search and discovery patterns
|
|
||||||
- Content relationship management
|
|
||||||
|
|
||||||
To use these patterns in your application:
|
|
||||||
|
|
||||||
1. **Import required components:**
|
|
||||||
```rust
|
|
||||||
use rustelo_components::{
|
|
||||||
content::*,
|
|
||||||
filter::UnifiedCategoryFilter,
|
|
||||||
ui::SpaLink,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Customize content structures:**
|
|
||||||
- Replace ContentItem with your data models
|
|
||||||
- Adapt filtering logic to your categorization
|
|
||||||
- Customize card layouts for your content types
|
|
||||||
|
|
||||||
3. **Integrate with your data:**
|
|
||||||
- Connect ContentManager to your API
|
|
||||||
- Replace mock data with real content
|
|
||||||
- Add pagination and infinite scroll
|
|
||||||
|
|
||||||
4. **Style for your brand:**
|
|
||||||
- Customize CSS classes and colors
|
|
||||||
- Apply your design system
|
|
||||||
- Add animations and transitions
|
|
||||||
|
|
||||||
The showcase is fully responsive and accessible,
|
|
||||||
providing a solid foundation for content-rich applications.
|
|
||||||
*/
|
|
||||||
|
|
@ -1,366 +0,0 @@
|
||||||
//! # Navigation Demo Example
|
|
||||||
//!
|
|
||||||
//! Comprehensive demonstration of navigation components including
|
|
||||||
//! responsive menus, language switching, and active state management.
|
|
||||||
|
|
||||||
use rustelo_components::{
|
|
||||||
navigation::{BrandHeader, Footer, NavMenu, LanguageSelector},
|
|
||||||
ui::{SpaLink, MobileMenu, MobileMenuToggle},
|
|
||||||
content::UnifiedContentCard,
|
|
||||||
};
|
|
||||||
use leptos::*;
|
|
||||||
|
|
||||||
/// Advanced navigation demo with responsive mobile menu and language switching
|
|
||||||
#[component]
|
|
||||||
pub fn NavigationDemo() -> impl IntoView {
|
|
||||||
// Mobile menu state
|
|
||||||
let (mobile_menu_open, set_mobile_menu_open) = create_signal(false);
|
|
||||||
|
|
||||||
// Language state
|
|
||||||
let (current_language, set_current_language) = create_signal("en".to_string());
|
|
||||||
let available_languages = vec!["en".to_string(), "es".to_string(), "fr".to_string()];
|
|
||||||
|
|
||||||
// Current page for active state demo
|
|
||||||
let (current_path, set_current_path) = create_signal("/".to_string());
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
// Advanced header with responsive navigation
|
|
||||||
<BrandHeader
|
|
||||||
brand_name="Rustelo Navigation Demo"
|
|
||||||
logo_url="/logo.svg"
|
|
||||||
class="bg-white shadow-md"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between w-full">
|
|
||||||
// Desktop navigation menu
|
|
||||||
<div class="hidden lg:block">
|
|
||||||
<NavMenu
|
|
||||||
orientation="horizontal"
|
|
||||||
active_path=current_path
|
|
||||||
class="space-x-1"
|
|
||||||
>
|
|
||||||
<SpaLink
|
|
||||||
href="/"
|
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| set_current_path.set("/".to_string())
|
|
||||||
>
|
|
||||||
"Home"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/products"
|
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| set_current_path.set("/products".to_string())
|
|
||||||
>
|
|
||||||
"Products"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/services"
|
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| set_current_path.set("/services".to_string())
|
|
||||||
>
|
|
||||||
"Services"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/blog"
|
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| set_current_path.set("/blog".to_string())
|
|
||||||
>
|
|
||||||
"Blog"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/contact"
|
|
||||||
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| set_current_path.set("/contact".to_string())
|
|
||||||
>
|
|
||||||
"Contact"
|
|
||||||
</SpaLink>
|
|
||||||
</NavMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Header utilities (language selector + mobile menu toggle)
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
// Language selector
|
|
||||||
<LanguageSelector
|
|
||||||
current_lang=current_language
|
|
||||||
available_langs=available_languages
|
|
||||||
on_change=move |new_lang| {
|
|
||||||
set_current_language.set(new_lang.clone());
|
|
||||||
logging::log!("Language changed to: {}", new_lang);
|
|
||||||
}
|
|
||||||
class="text-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
// Mobile menu toggle (hidden on desktop)
|
|
||||||
<div class="lg:hidden">
|
|
||||||
<MobileMenuToggle
|
|
||||||
is_open=mobile_menu_open
|
|
||||||
on_toggle=move |_| {
|
|
||||||
set_mobile_menu_open.update(|open| *open = !*open);
|
|
||||||
}
|
|
||||||
class="text-gray-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</BrandHeader>
|
|
||||||
|
|
||||||
// Mobile slide-out menu
|
|
||||||
<MobileMenu
|
|
||||||
is_open=mobile_menu_open
|
|
||||||
on_close=move |_| set_mobile_menu_open.set(false)
|
|
||||||
position="right"
|
|
||||||
class="lg:hidden"
|
|
||||||
>
|
|
||||||
<div class="px-4 py-6 space-y-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">"Navigation"</h3>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<SpaLink
|
|
||||||
href="/"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| {
|
|
||||||
set_current_path.set("/".to_string());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"🏠 Home"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/products"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| {
|
|
||||||
set_current_path.set("/products".to_string());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"📦 Products"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/services"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| {
|
|
||||||
set_current_path.set("/services".to_string());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"⚙️ Services"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/blog"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| {
|
|
||||||
set_current_path.set("/blog".to_string());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"📝 Blog"
|
|
||||||
</SpaLink>
|
|
||||||
|
|
||||||
<SpaLink
|
|
||||||
href="/contact"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
|
||||||
active_class="bg-blue-100 text-blue-700"
|
|
||||||
on:click=move |_| {
|
|
||||||
set_current_path.set("/contact".to_string());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
>
|
|
||||||
"📞 Contact"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Mobile language selector
|
|
||||||
<div class="pt-4 mt-4 border-t border-gray-200">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-2">"Language"</h4>
|
|
||||||
<LanguageSelector
|
|
||||||
current_lang=current_language
|
|
||||||
available_langs=available_languages.clone()
|
|
||||||
on_change=move |new_lang| {
|
|
||||||
set_current_language.set(new_lang.clone());
|
|
||||||
set_mobile_menu_open.set(false);
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MobileMenu>
|
|
||||||
|
|
||||||
// Main content showcasing current navigation state
|
|
||||||
<main class="container mx-auto px-4 py-8">
|
|
||||||
<UnifiedContentCard
|
|
||||||
title="Navigation Demo"
|
|
||||||
subtitle="Interactive navigation component showcase"
|
|
||||||
>
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-2">"Current State"</h3>
|
|
||||||
<div class="bg-gray-100 p-4 rounded-lg">
|
|
||||||
<p><strong>"Active Page:"</strong> {move || current_path.get()}</p>
|
|
||||||
<p><strong>"Current Language:"</strong> {move || current_language.get()}</p>
|
|
||||||
<p><strong>"Mobile Menu:"</strong> {move || if mobile_menu_open.get() { "Open" } else { "Closed" }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-2">"Features Demonstrated"</h3>
|
|
||||||
<ul class="list-disc list-inside space-y-2 text-gray-700">
|
|
||||||
<li>"Responsive navigation (desktop horizontal, mobile slide-out)"</li>
|
|
||||||
<li>"Active state management with visual indicators"</li>
|
|
||||||
<li>"Language switching with state persistence"</li>
|
|
||||||
<li>"Mobile menu toggle with overlay"</li>
|
|
||||||
<li>"SPA-aware navigation links"</li>
|
|
||||||
<li>"Keyboard navigation support"</li>
|
|
||||||
<li>"Accessible ARIA attributes"</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-2">"Try It Out"</h3>
|
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="font-medium">"Desktop Navigation:"</p>
|
|
||||||
<ul class="text-sm text-gray-600 space-y-1">
|
|
||||||
<li>"• Click navigation items to see active states"</li>
|
|
||||||
<li>"• Switch languages to see state changes"</li>
|
|
||||||
<li>"• Resize window to see responsive behavior"</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="font-medium">"Mobile Navigation:"</p>
|
|
||||||
<ul class="text-sm text-gray-600 space-y-1">
|
|
||||||
<li>"• Use hamburger menu on small screens"</li>
|
|
||||||
<li>"• Test slide-out navigation"</li>
|
|
||||||
<li>"• Try mobile language switching"</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
|
|
||||||
// Demonstration pages content
|
|
||||||
<div class="mt-8 grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{move || {
|
|
||||||
match current_path.get().as_str() {
|
|
||||||
"/" => view! {
|
|
||||||
<UnifiedContentCard title="Home Page">
|
|
||||||
<p>"Welcome to the navigation demo home page. This content changes based on the active navigation item."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
|
|
||||||
"/products" => view! {
|
|
||||||
<UnifiedContentCard title="Products">
|
|
||||||
<p>"Product catalog and listings would appear here."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
|
|
||||||
"/services" => view! {
|
|
||||||
<UnifiedContentCard title="Services">
|
|
||||||
<p>"Service offerings and descriptions would be shown here."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
|
|
||||||
"/blog" => view! {
|
|
||||||
<UnifiedContentCard title="Blog">
|
|
||||||
<p>"Blog posts and articles would be listed here."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
|
|
||||||
"/contact" => view! {
|
|
||||||
<UnifiedContentCard title="Contact">
|
|
||||||
<p>"Contact information and forms would be available here."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
|
|
||||||
_ => view! {
|
|
||||||
<UnifiedContentCard title="Page Not Found">
|
|
||||||
<p>"This page doesn't exist in the demo."</p>
|
|
||||||
</UnifiedContentCard>
|
|
||||||
}.into_view(),
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
// Footer with additional navigation
|
|
||||||
<Footer
|
|
||||||
copyright="© 2024 Rustelo Navigation Demo"
|
|
||||||
class="bg-white border-t"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap justify-center space-x-6 text-sm">
|
|
||||||
<SpaLink href="/privacy" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Privacy Policy"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/terms" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Terms of Service"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/accessibility" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Accessibility"
|
|
||||||
</SpaLink>
|
|
||||||
<SpaLink href="/sitemap" class="text-gray-600 hover:text-gray-800">
|
|
||||||
"Sitemap"
|
|
||||||
</SpaLink>
|
|
||||||
</div>
|
|
||||||
</Footer>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
This example demonstrates:
|
|
||||||
|
|
||||||
✅ **Responsive Navigation**
|
|
||||||
- Desktop horizontal navigation menu
|
|
||||||
- Mobile slide-out menu with overlay
|
|
||||||
- Automatic responsive behavior
|
|
||||||
|
|
||||||
✅ **Active State Management**
|
|
||||||
- Visual indicators for current page
|
|
||||||
- Consistent styling across components
|
|
||||||
- State synchronization
|
|
||||||
|
|
||||||
✅ **Language Switching**
|
|
||||||
- Multi-language support
|
|
||||||
- State persistence across navigation
|
|
||||||
- Integration with i18n systems
|
|
||||||
|
|
||||||
✅ **Mobile UX**
|
|
||||||
- Touch-friendly mobile menu toggle
|
|
||||||
- Slide-out navigation with proper animations
|
|
||||||
- Mobile-optimized layout and spacing
|
|
||||||
|
|
||||||
✅ **Accessibility Features**
|
|
||||||
- ARIA attributes for screen readers
|
|
||||||
- Keyboard navigation support
|
|
||||||
- Focus management and indicators
|
|
||||||
|
|
||||||
✅ **SPA Navigation**
|
|
||||||
- Client-side routing with SpaLink
|
|
||||||
- No page reloads for navigation
|
|
||||||
- Fast navigation between pages
|
|
||||||
|
|
||||||
To use in your application:
|
|
||||||
1. Import the NavigationDemo component
|
|
||||||
2. Customize the navigation items for your needs
|
|
||||||
3. Replace demo content with real pages
|
|
||||||
4. Add your brand colors and styling
|
|
||||||
5. Integrate with your routing system
|
|
||||||
|
|
||||||
The component is fully self-contained and can be used as-is
|
|
||||||
or customized for your specific navigation requirements.
|
|
||||||
*/
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
//! Enhanced Simple Content Grid Component
|
|
||||||
//!
|
|
||||||
//! Pure functional content grid with content-kinds.toml configuration support.
|
|
||||||
//! Includes essential features while avoiding complex reactive patterns and
|
|
||||||
//! hydration issues.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use leptos::prelude::*;
|
|
||||||
use rustelo_core_lib::{
|
|
||||||
content::UnifiedContentItem, create_content_kind_registry, fluent::load_content_index,
|
|
||||||
i18n::create_content_provider,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::content::card::UnifiedContentCard;
|
|
||||||
use crate::content::pagination::PaginationControls;
|
|
||||||
|
|
||||||
/// Enhanced Simple Content Grid with content-kinds.toml configuration support
|
|
||||||
#[component]
|
|
||||||
pub fn SimpleContentGrid(
|
|
||||||
content_type: String,
|
|
||||||
language: String,
|
|
||||||
#[prop(default = HashMap::new())] lang_content: HashMap<String, String>,
|
|
||||||
#[prop(default = "".to_string())] category_filter: String,
|
|
||||||
#[prop(default = 12)] limit: usize,
|
|
||||||
#[prop(default = 1)] current_page: u32,
|
|
||||||
#[prop(default = true)] enable_pagination: bool,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let content_provider = create_content_provider(Some(lang_content.clone()));
|
|
||||||
|
|
||||||
let content_config = {
|
|
||||||
let registry = create_content_kind_registry();
|
|
||||||
registry
|
|
||||||
.kinds
|
|
||||||
.get(&content_type)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
tracing::error!(
|
|
||||||
"❌ CONFIG_ERROR: Unknown content_type: '{}', available configs: {:?}, using \
|
|
||||||
default config - WASM FILESYSTEM ISSUE!",
|
|
||||||
content_type,
|
|
||||||
registry.kinds.keys().collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
rustelo_core_lib::ContentConfig::from_env()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let page = RwSignal::new(current_page);
|
|
||||||
let page_size = RwSignal::new(0u32); // Will be set from config
|
|
||||||
let set_page = page.write_only();
|
|
||||||
let set_page_size = page_size.write_only();
|
|
||||||
|
|
||||||
// Set pagination configuration from content config
|
|
||||||
let config_page_size = 12; // Default page size since ContentFeatures doesn't have this field
|
|
||||||
let config_page_size_options = vec![6, 12, 24, 48]; // Default options
|
|
||||||
let config_show_page_info = true; // Default to showing page info
|
|
||||||
|
|
||||||
// Initialize page size from config
|
|
||||||
if page_size.get_untracked() == 0 {
|
|
||||||
set_page_size.set(config_page_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone strings for use in multiple closures
|
|
||||||
let content_type_clone = content_type.clone();
|
|
||||||
let content_type_for_empty = content_type.clone();
|
|
||||||
let language_clone = language.clone();
|
|
||||||
let category_filter_clone = category_filter.clone();
|
|
||||||
|
|
||||||
// Calculate effective page size and offset for pagination
|
|
||||||
let effective_page_size = if enable_pagination {
|
|
||||||
12 // Default page size
|
|
||||||
} else {
|
|
||||||
limit
|
|
||||||
};
|
|
||||||
|
|
||||||
let page_offset = if enable_pagination {
|
|
||||||
((current_page.saturating_sub(1)) as usize) * (effective_page_size as usize)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clone config for use in memo and later in view
|
|
||||||
let config_for_memo = content_config.clone();
|
|
||||||
let _use_feature = true; // Default feature usage
|
|
||||||
let _use_emojis = true; // Default emoji usage
|
|
||||||
let style_css = Some("default".to_string()); // Default CSS style
|
|
||||||
let style_mode = "grid".to_string(); // Default style mode
|
|
||||||
|
|
||||||
// Static content loading with configuration support - no reactive signals
|
|
||||||
// during render
|
|
||||||
let (content_items, total_items) = Memo::new(move |_| {
|
|
||||||
load_content_for_type_enhanced(
|
|
||||||
&content_type_clone,
|
|
||||||
&language_clone,
|
|
||||||
&category_filter_clone,
|
|
||||||
&config_for_memo,
|
|
||||||
effective_page_size as usize,
|
|
||||||
page_offset,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.get_untracked();
|
|
||||||
|
|
||||||
let update_page_in_url = move |new_page: u32, _new_page_size: u32| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
if let Some(_set_path) = use_context::<WriteSignal<String>>() {
|
|
||||||
// For now, just update the page state without URL changes
|
|
||||||
// TODO: Implement URL query parameter support in custom navigation system
|
|
||||||
tracing::debug!("Page changed to: {}", new_page);
|
|
||||||
// Future: nav::anchor_navigate(set_path, &url_with_params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
tracing::debug!("Page changed to: {} (SSR mode)", new_page);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate CSS classes from content configuration
|
|
||||||
let css_classes = format!(
|
|
||||||
"enhanced-content-grid {} content-type-{} layout-{}-layout",
|
|
||||||
style_css.unwrap_or("default".to_string()),
|
|
||||||
&content_type,
|
|
||||||
style_mode
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine grid layout based on style_mode
|
|
||||||
let grid_classes = match style_mode.as_str() {
|
|
||||||
"grid" => match content_items.len() {
|
|
||||||
0 => "grid grid-cols-1 gap-6",
|
|
||||||
1 => "grid grid-cols-1 gap-8 max-w-2xl mx-auto",
|
|
||||||
2 => "grid md:grid-cols-2 gap-8",
|
|
||||||
_ => "grid md:grid-cols-2 lg:grid-cols-3 gap-6",
|
|
||||||
},
|
|
||||||
_ => "flex flex-col gap-6",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if content is empty before consuming the vector
|
|
||||||
let is_empty = content_items.is_empty();
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<div class={css_classes}>
|
|
||||||
<div class={grid_classes}>
|
|
||||||
{content_items.into_iter().map(|item| {
|
|
||||||
view! {
|
|
||||||
<UnifiedContentCard
|
|
||||||
content_item=item.clone()
|
|
||||||
content_type=content_type.to_string()
|
|
||||||
language=language.clone()
|
|
||||||
lang_content=lang_content.clone()
|
|
||||||
_content_config=content_config.clone()
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}).collect_view()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Pagination controls (if enabled)
|
|
||||||
{if enable_pagination && total_items > effective_page_size {
|
|
||||||
let total_pages = (total_items + effective_page_size - 1) / effective_page_size;
|
|
||||||
|
|
||||||
// Create callbacks outside the view
|
|
||||||
let page_change_callback = {
|
|
||||||
Callback::new(move |new_page: u32| {
|
|
||||||
set_page.set(new_page);
|
|
||||||
update_page_in_url(new_page, page_size.get_untracked());
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
let page_size_change_callback = {
|
|
||||||
Callback::new(move |new_page_size: u32| {
|
|
||||||
set_page_size.set(new_page_size);
|
|
||||||
set_page.set(1); // Reset to first page when changing page size
|
|
||||||
update_page_in_url(1, new_page_size);
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<PaginationControls
|
|
||||||
current_page=Signal::from(page)
|
|
||||||
total_pages=Signal::from(total_pages as u32)
|
|
||||||
total_items=total_items as u32
|
|
||||||
items_per_page=Signal::from(page_size)
|
|
||||||
content_type=content_type.to_string()
|
|
||||||
lang_content=std::collections::HashMap::new()
|
|
||||||
on_page_change=page_change_callback
|
|
||||||
on_page_size_change=page_size_change_callback
|
|
||||||
page_size_options=config_page_size_options
|
|
||||||
show_page_info=config_show_page_info
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! { <></> }.into_any()
|
|
||||||
}}
|
|
||||||
|
|
||||||
// Empty state when no content
|
|
||||||
{if is_empty {
|
|
||||||
view! {
|
|
||||||
<div class="empty-state text-center py-12">
|
|
||||||
<div class="max-w-md mx-auto">
|
|
||||||
<div class="text-4xl mb-4 opacity-50">{"📄"}</div>
|
|
||||||
<h3 class="text-lg font-medium ds-text mb-2">
|
|
||||||
{content_provider.t_with_prefixes("no-content", &[&content_type_for_empty, "content"], Some("No content available"))}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm ds-text-secondary">
|
|
||||||
{if !category_filter.is_empty() {
|
|
||||||
format!("No {} content found for category: {}", content_type_for_empty, category_filter)
|
|
||||||
} else {
|
|
||||||
format!("No {} content is currently available", content_type_for_empty)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! { <></> }.into_any()
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Using UnifiedContentItem directly instead of custom ContentItem struct
|
|
||||||
|
|
||||||
// Enhanced content loading function with configuration support
|
|
||||||
fn load_content_for_type_enhanced(
|
|
||||||
content_type: &str,
|
|
||||||
language: &str,
|
|
||||||
category_filter: &str,
|
|
||||||
_content_config: &rustelo_core_lib::ContentConfig,
|
|
||||||
page_size: usize,
|
|
||||||
page_offset: usize,
|
|
||||||
) -> (Vec<UnifiedContentItem>, usize) {
|
|
||||||
// Load content index
|
|
||||||
let content_index = match load_content_index(content_type, language) {
|
|
||||||
Ok(index) => index,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
"Failed to load content index for {}/{}: {}",
|
|
||||||
content_type,
|
|
||||||
language,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
return (vec![], 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter content based on configuration and parameters
|
|
||||||
let mut all_items: Vec<UnifiedContentItem> = content_index
|
|
||||||
.items
|
|
||||||
.into_iter()
|
|
||||||
.filter(|item| item.published) // Only published items
|
|
||||||
.filter_map(|item| {
|
|
||||||
// Category filtering
|
|
||||||
if !category_filter.is_empty()
|
|
||||||
&& category_filter != "all"
|
|
||||||
&& !item.categories.contains(&category_filter.to_string())
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(UnifiedContentItem {
|
|
||||||
id: item.slug.clone(),
|
|
||||||
title: item.title,
|
|
||||||
slug: item.slug,
|
|
||||||
language: language.to_string(),
|
|
||||||
content_type: content_type.to_string(),
|
|
||||||
content: String::new(), // Not loaded in grid view
|
|
||||||
excerpt: item.excerpt,
|
|
||||||
subtitle: None,
|
|
||||||
categories: item.categories,
|
|
||||||
tags: item.tags,
|
|
||||||
emoji: None,
|
|
||||||
featured: item.featured,
|
|
||||||
published: true, // Already filtered
|
|
||||||
draft: Some(false),
|
|
||||||
author: None,
|
|
||||||
read_time: item.read_time,
|
|
||||||
created_at: item.created_at,
|
|
||||||
updated_at: Some(String::new()),
|
|
||||||
translations: vec![],
|
|
||||||
localized_slug: None,
|
|
||||||
source_file: String::new(),
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
difficulty: None,
|
|
||||||
prep_time: None,
|
|
||||||
duration: None,
|
|
||||||
prerequisites: None,
|
|
||||||
view_count: None,
|
|
||||||
image_url: None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total_count = all_items.len();
|
|
||||||
|
|
||||||
// Sort content based on configuration
|
|
||||||
all_items.sort_by(|a, b| {
|
|
||||||
// Featured items first if use_feature is enabled
|
|
||||||
if true {
|
|
||||||
// Default feature usage
|
|
||||||
match (a.featured, b.featured) {
|
|
||||||
(true, false) => return std::cmp::Ordering::Less,
|
|
||||||
(false, true) => return std::cmp::Ordering::Greater,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then sort by date (newest first)
|
|
||||||
b.created_at.cmp(&a.created_at)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply pagination
|
|
||||||
let items = all_items
|
|
||||||
.into_iter()
|
|
||||||
.skip(page_offset)
|
|
||||||
.take(page_size)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
(items, total_count)
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
//! Server-side rendering (SSR) implementations for filter components
|
|
||||||
//!
|
|
||||||
//! These components render filter UI during server-side rendering,
|
|
||||||
//! providing initial HTML structure before client-side hydration.
|
|
||||||
|
|
||||||
// use crate::{get_active_button_classes, get_inactive_button_classes};
|
|
||||||
|
|
||||||
use super::unified::UnifiedCategoryFilter;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
/// SSR-only Content Category Filter Component
|
|
||||||
///
|
|
||||||
/// Renders the initial HTML structure for category filtering during SSR.
|
|
||||||
/// Client-side functionality is handled by the client-side component after hydration.
|
|
||||||
#[component]
|
|
||||||
pub fn CategoryFilterSSR(
|
|
||||||
content_type: String,
|
|
||||||
language: String,
|
|
||||||
#[prop(optional)] all_emoji: Option<String>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
view! {
|
|
||||||
<div class="mb-8">
|
|
||||||
<UnifiedCategoryFilter
|
|
||||||
content_type=content_type.clone()
|
|
||||||
language=language.clone()
|
|
||||||
_all_emoji=all_emoji.unwrap_or_else(|| "".to_string())
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSR-compatible helper functions that also work in WASM for hydration consistency
|
|
||||||
|
|
||||||
/// Generic content filtering by category - SSR compatible version
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn filter_content_by_category(content_type: &str, category: &str) {
|
|
||||||
// Navigate to category URL when not "All"
|
|
||||||
if category != "All" {
|
|
||||||
navigate_to_category_url(content_type, category);
|
|
||||||
return; // Let the URL change handle the content filtering
|
|
||||||
}
|
|
||||||
|
|
||||||
// For "All" category, navigate back to main content page
|
|
||||||
navigate_to_main_content_url(content_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to the appropriate category URL based on content type and language
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn navigate_to_category_url(content_type: &str, category: &str) {
|
|
||||||
// Use the current language context to determine the language
|
|
||||||
let language = rustelo_core_lib::state::language::use_current_language().get();
|
|
||||||
|
|
||||||
// Build the correct category URL using the routing system dynamically
|
|
||||||
let base_path = format!("/{}", content_type);
|
|
||||||
// First get the localized base path, then append the category
|
|
||||||
let localized_base = rustelo_core_lib::routing::rustelo_utils::get_localized_route(&base_path, &language);
|
|
||||||
let category_url = format!("{}/{}", localized_base, category);
|
|
||||||
|
|
||||||
tracing::debug!(
|
|
||||||
"SPA Navigation: Navigating to category URL: {}",
|
|
||||||
category_url
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the proper navigation system instead of location.set_href() to maintain SPA behavior
|
|
||||||
if let Some(navigate) = try_get_navigate() {
|
|
||||||
let success = navigate(&category_url);
|
|
||||||
if !success {
|
|
||||||
tracing::warn!(
|
|
||||||
"SPA navigation failed for: {}, falling back to page reload",
|
|
||||||
category_url
|
|
||||||
);
|
|
||||||
// Fallback to page reload if SPA navigation fails
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&category_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"Navigation context not available, using page reload for: {}",
|
|
||||||
category_url
|
|
||||||
);
|
|
||||||
// Fallback to page reload if navigation context is not available
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&category_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to main content page (when "All" is selected)
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn navigate_to_main_content_url(content_type: &str) {
|
|
||||||
// Use the current language context to determine the language
|
|
||||||
let language = rustelo_core_lib::state::language::use_current_language().get();
|
|
||||||
|
|
||||||
// Build the correct main URL using the routing system dynamically
|
|
||||||
let base_path = format!("/{}", content_type);
|
|
||||||
let main_url = rustelo_core_lib::routing::rustelo_utils::get_localized_route(&base_path, &language);
|
|
||||||
|
|
||||||
tracing::debug!(
|
|
||||||
"SPA Navigation: Navigating to main content URL: {}",
|
|
||||||
main_url
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the proper navigation system instead of location.set_href() to maintain SPA behavior
|
|
||||||
if let Some(navigate) = try_get_navigate() {
|
|
||||||
let success = navigate(&main_url);
|
|
||||||
if !success {
|
|
||||||
tracing::warn!(
|
|
||||||
"SPA navigation failed for: {}, falling back to page reload",
|
|
||||||
main_url
|
|
||||||
);
|
|
||||||
// Fallback to page reload if SPA navigation fails
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&main_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(
|
|
||||||
"Navigation context not available, using page reload for: {}",
|
|
||||||
main_url
|
|
||||||
);
|
|
||||||
// Fallback to page reload if navigation context is not available
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&main_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update content filter button visual states
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn update_content_filter_button_states(content_type: &str, selected_category: &str) {
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use web_sys::{window, HtmlElement};
|
|
||||||
|
|
||||||
let window = match window() {
|
|
||||||
Some(w) => w,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let document = match window.document() {
|
|
||||||
Some(d) => d,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update all content filter buttons
|
|
||||||
let buttons = document.get_elements_by_class_name("content-category-filter-btn");
|
|
||||||
|
|
||||||
for i in 0..buttons.length() {
|
|
||||||
if let Some(element) = buttons.item(i) {
|
|
||||||
if let Ok(button) = element.dyn_into::<HtmlElement>() {
|
|
||||||
let button_content_type = button
|
|
||||||
.get_attribute("data-content-type")
|
|
||||||
.unwrap_or_default();
|
|
||||||
let category = button.get_attribute("data-category").unwrap_or_default();
|
|
||||||
|
|
||||||
// Only update buttons for the matching content type
|
|
||||||
if button_content_type == content_type {
|
|
||||||
if category == selected_category {
|
|
||||||
// Active button style
|
|
||||||
let _ = button.set_class_name(crate::get_active_button_classes());
|
|
||||||
} else {
|
|
||||||
// Inactive button style
|
|
||||||
let _ = button.set_class_name(crate::get_inactive_button_classes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status text
|
|
||||||
let status_id = format!("{}-filter-status", content_type);
|
|
||||||
if let Some(status_element) = document.get_element_by_id(&status_id) {
|
|
||||||
if let Ok(status) = status_element.dyn_into::<HtmlElement>() {
|
|
||||||
let status_text = if selected_category == "All" {
|
|
||||||
format!("Showing all {} content", content_type)
|
|
||||||
} else {
|
|
||||||
format!("Showing {}: {}", content_type, selected_category)
|
|
||||||
};
|
|
||||||
status.set_inner_text(&status_text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to get the navigation function, handling cases where the context is not available
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
fn try_get_navigate() -> Option<impl Fn(&str) -> bool> {
|
|
||||||
use std::panic;
|
|
||||||
|
|
||||||
// Try to get the navigation context safely
|
|
||||||
let result = panic::catch_unwind(|| rustelo_core_lib::state::navigation::use_navigate());
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(navigate_fn) => Some(navigate_fn),
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!("Navigation context not available, will use page reload fallback");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
//! Unified brand header component
|
|
||||||
//!
|
|
||||||
//! Provides consistent branding (logo or title) with identical behavior
|
|
||||||
//! across navigation menu and footer components.
|
|
||||||
|
|
||||||
use ::rustelo_core_lib::{get_theme, load_reactive_footer_for_language};
|
|
||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
/// Extract logo configuration from registered theme
|
|
||||||
///
|
|
||||||
/// This function reads from the registered theme resources (loaded at server
|
|
||||||
/// startup from config/themes/default.toml) to get configuration-driven logo
|
|
||||||
/// paths. PAP-compliant: Uses registered resources, no hardcoding.
|
|
||||||
fn get_logo_config() -> (String, String, String) {
|
|
||||||
// Get theme - available in both SSR and client contexts
|
|
||||||
// On WASM: registry will be populated from embedded constants
|
|
||||||
// On server: registry is populated at startup from ResourceContributor
|
|
||||||
let theme_result = get_theme("default");
|
|
||||||
|
|
||||||
if let Some(theme_toml) = theme_result {
|
|
||||||
let logo_light = extract_toml_value(&theme_toml, "logo_light");
|
|
||||||
let logo_dark = extract_toml_value(&theme_toml, "logo_dark");
|
|
||||||
let mut logo_alt = extract_toml_value(&theme_toml, "logo_alt");
|
|
||||||
|
|
||||||
if !logo_light.is_empty() {
|
|
||||||
// Use extracted alt text, or default to "Logo"
|
|
||||||
if logo_alt.is_empty() {
|
|
||||||
logo_alt = "Logo".to_string();
|
|
||||||
}
|
|
||||||
return (logo_light, logo_dark, logo_alt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback when theme unavailable
|
|
||||||
(String::new(), String::new(), "Logo".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to extract TOML values from theme content using simple string parsing
|
|
||||||
/// This avoids adding toml dependency to rustelo_components
|
|
||||||
fn extract_toml_value(content: &str, key: &str) -> String {
|
|
||||||
// Look for key = "value" pattern in [assets] section
|
|
||||||
let pattern = format!("{} = \"", key);
|
|
||||||
if let Some(start) = content.find(&pattern) {
|
|
||||||
let start_pos = start + pattern.len();
|
|
||||||
if let Some(end) = content[start_pos..].find('"') {
|
|
||||||
return content[start_pos..start_pos + end].to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unified brand header that works consistently in nav and footer
|
|
||||||
#[component]
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
pub fn BrandHeader(
|
|
||||||
/// Language for loading configuration
|
|
||||||
#[prop(optional)]
|
|
||||||
language: Option<String>,
|
|
||||||
/// CSS classes for the brand element
|
|
||||||
#[prop(default = "text-xl font-bold".to_string())]
|
|
||||||
class: String,
|
|
||||||
/// Force show/hide logo (overrides config)
|
|
||||||
#[prop(optional)]
|
|
||||||
force_logo: Option<bool>,
|
|
||||||
/// Use footer logo settings instead of nav settings
|
|
||||||
#[prop(default = false)]
|
|
||||||
is_footer: bool,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Get logo configuration
|
|
||||||
let (logo_light, logo_dark, logo_alt) = get_logo_config();
|
|
||||||
|
|
||||||
// Get site title from footer config or environment
|
|
||||||
let site_title = {
|
|
||||||
// Try to load from footer config first
|
|
||||||
let lang = language.as_deref().unwrap_or("en");
|
|
||||||
let config_title = load_reactive_footer_for_language(lang)
|
|
||||||
.ok()
|
|
||||||
.map(|footer| footer.title)
|
|
||||||
.filter(|title| !title.is_empty());
|
|
||||||
|
|
||||||
// Fallback to environment variables
|
|
||||||
config_title.unwrap_or_else(|| {
|
|
||||||
std::env::var("SITE_TITLE")
|
|
||||||
.or_else(|_| std::env::var("SITE_LOGO_ALT"))
|
|
||||||
.unwrap_or_else(|_| "Site".to_string())
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine if we should show logo (from config or override)
|
|
||||||
let show_logo = force_logo.unwrap_or(true);
|
|
||||||
let render_logo = show_logo && !logo_light.is_empty();
|
|
||||||
let has_dark_logo = !logo_dark.is_empty();
|
|
||||||
view! {
|
|
||||||
<a href="/" class=format!("{} transition-colors flex items-center gap-2", class)>
|
|
||||||
{
|
|
||||||
if render_logo {
|
|
||||||
// Render logo images if available
|
|
||||||
if has_dark_logo {
|
|
||||||
view! {
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src=logo_light.clone()
|
|
||||||
alt=logo_alt.clone()
|
|
||||||
class="h-10 object-contain dark:hidden"
|
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src=logo_dark.clone()
|
|
||||||
alt=logo_alt.clone()
|
|
||||||
class="h-10 object-contain hidden dark:block"
|
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
<img
|
|
||||||
src=logo_light.clone()
|
|
||||||
alt=logo_alt.clone()
|
|
||||||
class="h-10 object-contain"
|
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Render text fallback
|
|
||||||
view! {
|
|
||||||
<span class="ds-text">{site_title.clone()}</span>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
//! Client-side navigation menu wrapper with context-based menu loading
|
|
||||||
//!
|
|
||||||
//! This component loads menu items from the MenuRegistry context
|
|
||||||
//! (SSR-pre-loaded) and provides them to the unified menu component for instant
|
|
||||||
//! language switching.
|
|
||||||
|
|
||||||
use ::rustelo_core_lib::state::use_language;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
use crate::navigation::MenuRegistry;
|
|
||||||
|
|
||||||
/// Client-side navigation menu wrapper with context-based loading
|
|
||||||
#[component]
|
|
||||||
pub fn NavMenuClient(set_path: WriteSignal<String>, path: ReadSignal<String>) -> impl IntoView {
|
|
||||||
// Get reactive language context
|
|
||||||
let language_context = use_language();
|
|
||||||
|
|
||||||
// Get menu registry from context (SSR-pre-loaded)
|
|
||||||
let menu_registry = use_context::<MenuRegistry>().unwrap_or_else(|| {
|
|
||||||
tracing::warn!("🔧 NavMenuClient: MenuRegistry context not found, using empty registry");
|
|
||||||
MenuRegistry::new()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render function that reactively responds to language changes
|
|
||||||
move || {
|
|
||||||
let current_lang = language_context.current.with(|lang| lang.clone());
|
|
||||||
|
|
||||||
// Get menu items for current language from registry
|
|
||||||
let menu_items = menu_registry.get_cloned(¤t_lang).unwrap_or_default();
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"🔧 NavMenuClient: Rendering menu for language '{}' ({} items)",
|
|
||||||
current_lang,
|
|
||||||
menu_items.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Note: i18n content is now loaded automatically by UnifiedNavMenu from FTL
|
|
||||||
// registry if not provided as a prop, so we don't need to extract it
|
|
||||||
// here
|
|
||||||
view! {
|
|
||||||
<crate::navigation::navmenu::unified::UnifiedNavMenu
|
|
||||||
navigation_signals=(path, set_path)
|
|
||||||
language=current_lang
|
|
||||||
menu_items=menu_items
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export as NavMenu for compatibility
|
|
||||||
pub use NavMenuClient as NavMenu;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_menu_registry_empty() {
|
|
||||||
let registry = MenuRegistry::new();
|
|
||||||
assert_eq!(registry.language_count(), 0);
|
|
||||||
assert!(registry.available_languages().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_menu_registry_insert_and_get() {
|
|
||||||
let mut registry = MenuRegistry::new();
|
|
||||||
let menu_items = vec![];
|
|
||||||
|
|
||||||
registry.insert("en", menu_items.clone());
|
|
||||||
|
|
||||||
assert_eq!(registry.language_count(), 1);
|
|
||||||
assert!(registry.contains_language("en"));
|
|
||||||
assert_eq!(registry.get("en"), Some(&menu_items));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_menu_registry_multiple_languages() {
|
|
||||||
let mut registry = MenuRegistry::new();
|
|
||||||
registry.insert("en", vec![]);
|
|
||||||
registry.insert("es", vec![]);
|
|
||||||
registry.insert("fr", vec![]);
|
|
||||||
|
|
||||||
assert_eq!(registry.language_count(), 3);
|
|
||||||
|
|
||||||
let languages = registry.available_languages();
|
|
||||||
assert!(languages.contains(&"en".to_string()));
|
|
||||||
assert!(languages.contains(&"es".to_string()));
|
|
||||||
assert!(languages.contains(&"fr".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_menu_registry_get_cloned() {
|
|
||||||
let mut registry = MenuRegistry::new();
|
|
||||||
registry.insert("en", vec![]);
|
|
||||||
|
|
||||||
let cloned = registry.get_cloned("en");
|
|
||||||
assert!(cloned.is_some());
|
|
||||||
assert_eq!(cloned.unwrap(), vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_menu_registry_get_missing_language() {
|
|
||||||
let registry = MenuRegistry::new();
|
|
||||||
assert!(registry.get("en").is_none());
|
|
||||||
assert!(registry.get_cloned("en").is_none());
|
|
||||||
assert!(!registry.contains_language("en"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,403 +0,0 @@
|
||||||
//! Unified navigation menu with actual implementation
|
|
||||||
//!
|
|
||||||
//! This module contains the main navigation menu implementation that works
|
|
||||||
//! in both client-side and server-side contexts.
|
|
||||||
|
|
||||||
use ::rustelo_core_lib::state::use_current_language;
|
|
||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
use crate::navigation::brand_header::BrandHeader;
|
|
||||||
use crate::theme::DarkModeToggle;
|
|
||||||
|
|
||||||
/// Main NavMenu component that delegates to appropriate wrapper
|
|
||||||
#[component]
|
|
||||||
pub fn NavMenu(
|
|
||||||
/// Path setter for SPA navigation (client-side only)
|
|
||||||
#[prop(optional)]
|
|
||||||
_set_path: Option<WriteSignal<String>>,
|
|
||||||
/// Current path signal (client-side only)
|
|
||||||
#[prop(optional)]
|
|
||||||
_path: Option<ReadSignal<String>>,
|
|
||||||
/// Language for SSR context
|
|
||||||
#[prop(default = rustelo_core_lib::config::get_default_language().to_string())]
|
|
||||||
language: String,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Use target_arch to determine execution context
|
|
||||||
// Server-side (not WASM): delegate to SSR implementation
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
tracing::info!("🔧 NavMenu: Server-side context, using SSR branch");
|
|
||||||
view! { <crate::navigation::navmenu::ssr::NavMenuSSR language=language /> }.into_any()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side (WASM): delegate to client implementation if signals provided
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&format!(
|
|
||||||
"🔧 NavMenu: WASM context - signals available: {}",
|
|
||||||
_set_path.is_some() && _path.is_some()
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Client context: use client wrapper
|
|
||||||
if let (Some(set_path), Some(path)) = (_set_path, _path) {
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&"🔧 NavMenu: Delegating to NavMenuClient with navigation signals".into(),
|
|
||||||
);
|
|
||||||
view! { <crate::navigation::navmenu::client::NavMenuClient set_path=set_path path=path /> }.into_any()
|
|
||||||
} else {
|
|
||||||
// Fallback if navigation signals not provided - render unified without signals
|
|
||||||
web_sys::console::log_1(
|
|
||||||
&"⚠️ NavMenu: Navigation signals not provided, showing empty menu for hydration"
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
view! { <UnifiedNavMenu language=language /> }.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unified navigation menu component with the actual implementation
|
|
||||||
#[component]
|
|
||||||
pub fn UnifiedNavMenu(
|
|
||||||
/// Navigation signals for client-side routing (optional)
|
|
||||||
#[prop(optional)]
|
|
||||||
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
|
||||||
/// Language for menu items
|
|
||||||
#[prop(default = use_current_language().with(|lang| lang.clone()))]
|
|
||||||
language: String,
|
|
||||||
/// Menu items (if pre-loaded)
|
|
||||||
#[prop(optional)]
|
|
||||||
menu_items: Option<Vec<rustelo_core_lib::MenuItem>>,
|
|
||||||
/// i18n content map
|
|
||||||
#[prop(optional)]
|
|
||||||
content: Option<std::collections::HashMap<String, String>>,
|
|
||||||
) -> impl IntoView {
|
|
||||||
// Get current language - use static language prop only
|
|
||||||
let current_language = language.clone();
|
|
||||||
|
|
||||||
// Get menu items - use prop if provided (from server SSR)
|
|
||||||
// During hydration, context is NOT available, so we use empty fallback
|
|
||||||
// After hydration, effects can populate menus from context
|
|
||||||
let items = menu_items.unwrap_or_default();
|
|
||||||
|
|
||||||
// Setup i18n content from provided prop or load from FTL registry
|
|
||||||
let nav_content = content.unwrap_or_else(|| {
|
|
||||||
let mut content = std::collections::HashMap::new();
|
|
||||||
|
|
||||||
// Try to load from registered FTL resources
|
|
||||||
// FTL files are registered with language prefix: {language}_{filename}
|
|
||||||
let ftl_key = format!("{}_{}", current_language, "navigation");
|
|
||||||
if let Some(ftl_content) = ::rustelo_core_lib::registration::get_ftl(&ftl_key) {
|
|
||||||
// Extract navigation keys from FTL content
|
|
||||||
// FTL format: key = value
|
|
||||||
for line in ftl_content.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
// Skip comments and empty lines
|
|
||||||
if line.is_empty() || line.starts_with('#') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Parse key = value format
|
|
||||||
if let Some(eq_pos) = line.find('=') {
|
|
||||||
let key = line[..eq_pos].trim();
|
|
||||||
let value = line[eq_pos + 1..].trim();
|
|
||||||
|
|
||||||
// Include navigation and language selector keys
|
|
||||||
if key.starts_with("nav-") || key.starts_with("lang-") {
|
|
||||||
content.insert(key.to_string(), value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback defaults if FTL not found or keys missing
|
|
||||||
content
|
|
||||||
.entry("nav-menu-aria-controls".to_string())
|
|
||||||
.or_insert_with(|| "navbar-collapse".to_string());
|
|
||||||
content
|
|
||||||
.entry("nav-menu-aria-label".to_string())
|
|
||||||
.or_insert_with(|| "Open navigation menu".to_string());
|
|
||||||
content
|
|
||||||
.entry("nav-mobile-controls".to_string())
|
|
||||||
.or_insert_with(|| "Controls".to_string());
|
|
||||||
content
|
|
||||||
.entry("lang-selector-button".to_string())
|
|
||||||
.or_insert_with(|| "Language Selector".to_string());
|
|
||||||
content
|
|
||||||
.entry("lang-en-display".to_string())
|
|
||||||
.or_insert_with(|| "English".to_string());
|
|
||||||
content
|
|
||||||
.entry("lang-es-display".to_string())
|
|
||||||
.or_insert_with(|| "Español".to_string());
|
|
||||||
|
|
||||||
content
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simple content lookup function
|
|
||||||
let get_content = |key: &str| -> String {
|
|
||||||
nav_content.get(key).cloned().unwrap_or_else(|| {
|
|
||||||
tracing::warn!("i18n key not found: '{}'", key);
|
|
||||||
String::new()
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mobile menu state - create signal with platform-specific initialization
|
|
||||||
// On WASM/client: create reactive signal for interactivity
|
|
||||||
// On SSR/server: create dummy signals that won't be used
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let (mobile_open, set_mobile_open) = {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
signal(false)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
// For SSR, create a dummy signal that will be optimized out
|
|
||||||
// The signal is created but the view code won't actually use it
|
|
||||||
// This ensures consistent compilation across both targets
|
|
||||||
signal(false)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clone current_language for use in menu rendering closures
|
|
||||||
let current_language_clone = current_language.clone();
|
|
||||||
|
|
||||||
// These are only used on WASM/client, but we need to get them for consistency
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let nav_menu_aria_controls = get_content("nav-menu-aria-controls");
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let nav_menu_aria_label = get_content("nav-menu-aria-label");
|
|
||||||
#[allow(unused_variables)]
|
|
||||||
let nav_mobile_controls = get_content("nav-mobile-controls");
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<nav class="border-b border-gray-200 shadow-sm">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex justify-between h-16">
|
|
||||||
<div class="flex items-center">
|
|
||||||
// Brand header - consistent with footer
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<BrandHeader
|
|
||||||
language={current_language.clone()}
|
|
||||||
class="h-8 w-auto text-xl font-bold".to_string()
|
|
||||||
is_footer=false
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Desktop menu
|
|
||||||
<div class="hidden lg:block md:ml-6 md:flex md:space-x-8">
|
|
||||||
{items.clone().into_iter().map(|item| {
|
|
||||||
let href = item.route.clone();
|
|
||||||
let is_external = item.is_external.unwrap_or(false);
|
|
||||||
let label_text = item
|
|
||||||
.label
|
|
||||||
.get_for_language(¤t_language_clone)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "Menu Item".to_string());
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<a
|
|
||||||
href={href.clone()}
|
|
||||||
class="ds-text hover:ds-text-secondary px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline"
|
|
||||||
target={if is_external { "_blank" } else { "_self" }}
|
|
||||||
rel={if is_external { "noopener noreferrer" } else { "" }}
|
|
||||||
on:click={
|
|
||||||
let href = href.clone();
|
|
||||||
move |ev| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🔧 NavMenu: Link clicked! href='{}', is_external={}, has_nav_signals={}", href, is_external, navigation_signals.is_some()).into());
|
|
||||||
|
|
||||||
// Only use SPA routing for internal links
|
|
||||||
if !is_external {
|
|
||||||
// Only handle click if navigation_signals are available (client-side)
|
|
||||||
if let Some((_, set_path)) = navigation_signals {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🔧 NavMenu: Click intercepted for href: '{}', preventing default and using SPA navigation", href).into());
|
|
||||||
|
|
||||||
ev.prevent_default();
|
|
||||||
|
|
||||||
// Use anchor_navigate to properly update URL and apply transitions
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &href);
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
set_path.set(href.clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("⚠️ NavMenu: No navigation signals available for href: '{}', allowing default browser navigation", href).into());
|
|
||||||
}
|
|
||||||
// Otherwise, let the normal link behavior happen (SSR)
|
|
||||||
} else {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🌐 NavMenu: External link detected for href: '{}', allowing default browser navigation", href).into());
|
|
||||||
}
|
|
||||||
// External links: let the browser handle navigation normally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label_text}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Right side: Controls and Mobile menu button
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
// Desktop controls (hidden on mobile)
|
|
||||||
<div class="hidden md:flex md:items-center md:space-x-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
|
||||||
{
|
|
||||||
if let Some(nav_signals) = navigation_signals {
|
|
||||||
view! {
|
|
||||||
<crate::navigation::language_selector::unified::LanguageSelector
|
|
||||||
current_language={Some(current_language.clone())}
|
|
||||||
content=nav_content.clone()
|
|
||||||
navigation_signals=nav_signals
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
<crate::navigation::language_selector::unified::LanguageSelector
|
|
||||||
current_language={Some(current_language.clone())}
|
|
||||||
content=nav_content.clone()
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Mobile menu button - rendered on both SSR and client for hydration consistency
|
|
||||||
// On SSR, this is just static HTML that won't be interactive
|
|
||||||
// On client (WASM), this becomes interactive with proper click handlers
|
|
||||||
<div class="md:hidden flex items-center">
|
|
||||||
<button
|
|
||||||
aria-controls={nav_menu_aria_controls.clone()}
|
|
||||||
class="ds-mobile-menu-btn"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-label={nav_menu_aria_label.clone()}
|
|
||||||
on:click=move |_| {
|
|
||||||
set_mobile_open.set(!mobile_open.get_untracked())
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Mobile menu - rendered on both SSR and client for hydration consistency
|
|
||||||
// On SSR: just renders as static HTML, hidden via CSS
|
|
||||||
// On client (WASM): becomes reactive with style:display controlled by signal
|
|
||||||
<div
|
|
||||||
class="md:hidden border-t border-gray-200 bg-white"
|
|
||||||
id="navbar-collapse"
|
|
||||||
// style:display=move || {
|
|
||||||
// if mobile_open.get() { "block" } else { "none" }
|
|
||||||
// }
|
|
||||||
>
|
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
|
||||||
// Mobile menu items
|
|
||||||
<div class="text-gray-500 text-sm p-3">
|
|
||||||
{items.clone().into_iter().map(|item| {
|
|
||||||
let href = item.route.clone();
|
|
||||||
let is_external = item.is_external.unwrap_or(false);
|
|
||||||
let label_text = item
|
|
||||||
.label
|
|
||||||
.get_for_language(¤t_language_clone)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "Menu Item".to_string());
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<a
|
|
||||||
href={href.clone()}
|
|
||||||
class="ds-text hover:ds-text-secondary px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline"
|
|
||||||
target={if is_external { "_blank" } else { "_self" }}
|
|
||||||
rel={if is_external { "noopener noreferrer" } else { "" }}
|
|
||||||
on:click={
|
|
||||||
let href = href.clone();
|
|
||||||
move |ev| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🔧 NavMenu: Link clicked! href='{}', is_external={}, has_nav_signals={}", href, is_external, navigation_signals.is_some()).into());
|
|
||||||
|
|
||||||
// Only use SPA routing for internal links
|
|
||||||
if !is_external {
|
|
||||||
// Only handle click if navigation_signals are available (client-side)
|
|
||||||
if let Some((_, set_path)) = navigation_signals {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🔧 NavMenu: Click intercepted for href: '{}', preventing default and using SPA navigation", href).into());
|
|
||||||
|
|
||||||
ev.prevent_default();
|
|
||||||
|
|
||||||
// Use anchor_navigate to properly update URL and apply transitions
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
rustelo_core_lib::utils::nav::anchor_navigate(set_path, &href);
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
set_path.set(href.clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("⚠️ NavMenu: No navigation signals available for href: '{}', allowing default browser navigation", href).into());
|
|
||||||
}
|
|
||||||
// Otherwise, let the normal link behavior happen (SSR)
|
|
||||||
} else {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🌐 NavMenu: External link detected for href: '{}', allowing default browser navigation", href).into());
|
|
||||||
}
|
|
||||||
// External links: let the browser handle navigation normally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{label_text}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
}).collect::<Vec<_>>()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Mobile controls
|
|
||||||
<div class="px-3 py-2 border-t border-gray-200">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm text-gray-500">{nav_mobile_controls}</span>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<DarkModeToggle class="btn btn-ghost btn-sm".to_string() />
|
|
||||||
{
|
|
||||||
if let Some(nav_signals) = navigation_signals {
|
|
||||||
view! {
|
|
||||||
<crate::navigation::language_selector::unified::LanguageSelector
|
|
||||||
current_language={Some(current_language.clone())}
|
|
||||||
content=nav_content.clone()
|
|
||||||
navigation_signals=nav_signals
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
} else {
|
|
||||||
view! {
|
|
||||||
<crate::navigation::language_selector::unified::LanguageSelector
|
|
||||||
current_language={Some(current_language)}
|
|
||||||
content=nav_content
|
|
||||||
/>
|
|
||||||
}.into_any()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
38
crates/foundation/crates/rustelo_components_htmx/Cargo.toml
Normal file
38
crates/foundation/crates/rustelo_components_htmx/Cargo.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
[package]
|
||||||
|
name = "rustelo_components_htmx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Rustelo Contributors"]
|
||||||
|
license = "MIT"
|
||||||
|
description = "Rustelo UI components for the HTMX-SSR rendering profile (ADR-006)"
|
||||||
|
documentation = "https://docs.rs/rustelo_components_htmx"
|
||||||
|
repository = "https://github.com/yourusername/rustelo"
|
||||||
|
homepage = "https://jesusperez.pro"
|
||||||
|
keywords = ["rust", "web", "htmx", "components", "ssr"]
|
||||||
|
categories = ["web-programming", "template-engine"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["lib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
html-escape = { workspace = true }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
minijinja = { workspace = true }
|
||||||
|
rustelo_core_lib = { workspace = true }
|
||||||
|
rustelo_core_types = { workspace = true }
|
||||||
|
rustelo_pages_htmx = { workspace = true }
|
||||||
|
rustelo_seo = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["htmx-runtime"]
|
||||||
|
# Enables the htmx-ssr runtime: every component emits hx-* attributes targeting
|
||||||
|
# the /api/htmx/* endpoint catalogue. Disabling this feature makes the crate
|
||||||
|
# inert — useful for downstream crates that want a pure-SSR build without HTMX.
|
||||||
|
htmx-runtime = []
|
||||||
|
|
||||||
|
[package.metadata.docs.rs]
|
||||||
|
all-features = true
|
||||||
|
rustdoc-args = ["--cfg", "docsrs"]
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
//! Nav login trigger. Fetches the login modal into `#modal-slot`.
|
||||||
|
|
||||||
|
/// Render the "Sign in" button (defaults to English).
|
||||||
|
pub fn render_login_button(label: &str) -> String {
|
||||||
|
render_login_button_for_lang(label, "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the "Sign in" button with a language hint for the modal.
|
||||||
|
pub fn render_login_button_for_lang(label: &str, lang: &str) -> String {
|
||||||
|
let ctx = minijinja::context! {
|
||||||
|
label => label,
|
||||||
|
lang => lang,
|
||||||
|
};
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/nav/login_button.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let endpoint = format!("/api/htmx/auth/login-modal?lang={}", html_escape::encode_double_quoted_attribute(lang));
|
||||||
|
format!(
|
||||||
|
r##"<button type="button" class="ds-btn-ghost-sm" hx-get="{ep}" hx-target="#modal-slot" hx-swap="outerHTML">{label}</button>"##,
|
||||||
|
ep = html_escape::encode_double_quoted_attribute(&endpoint),
|
||||||
|
label = html_escape::encode_text(label),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn triggers_modal_fetch() {
|
||||||
|
let html = render_login_button("Sign in");
|
||||||
|
assert!(html.contains("/api/htmx/auth/login-modal"));
|
||||||
|
assert!(html.contains(r##"hx-target="#modal-slot""##));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passes_lang_query_param() {
|
||||||
|
let html = render_login_button_for_lang("Entrar", "es");
|
||||||
|
assert!(html.contains("lang=es"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_label() {
|
||||||
|
let html = render_login_button("<x>");
|
||||||
|
assert!(html.contains("<x>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
//! Login form. Posts to `/api/htmx/auth/login`. Implemented in F-06.
|
||||||
|
|
||||||
|
/// Placeholder until F-06 lands the full form with validation hooks.
|
||||||
|
pub fn render_login_form() -> String {
|
||||||
|
String::from(
|
||||||
|
r#"<form data-stub="login-form" hx-post="/api/htmx/auth/login" hx-target="this" hx-swap="outerHTML"></form>"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stub_points_at_login_endpoint() {
|
||||||
|
assert!(render_login_form().contains(r#"hx-post="/api/htmx/auth/login""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
//! Login modal — fetched on demand by `hx-get` into `#modal-slot`. F-06.
|
||||||
|
|
||||||
|
/// Placeholder until F-06.
|
||||||
|
pub fn render_login_modal() -> String {
|
||||||
|
String::from(
|
||||||
|
r#"<div data-stub="login-modal" id="modal-slot" hx-get="/api/htmx/auth/login-modal" hx-trigger="login-requested from:body"></div>"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn carries_modal_slot_id() {
|
||||||
|
assert!(render_login_modal().contains(r#"id="modal-slot""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
//! Logout button. Posts to `/api/htmx/auth/logout`.
|
||||||
|
|
||||||
|
/// Render the logout button with a language-appropriate label.
|
||||||
|
pub fn render_logout_button() -> String {
|
||||||
|
render_logout_button_for_lang("Sign out", "en")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the logout button with a custom label.
|
||||||
|
pub fn render_logout_button_for_lang(label: &str, lang: &str) -> String {
|
||||||
|
let _ = lang;
|
||||||
|
let ctx = minijinja::context! { label => label };
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/nav/logout_button.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
r##"<button type="button" class="ds-btn-ghost-sm" hx-post="/api/htmx/auth/logout" hx-target="body" hx-swap="outerHTML">{}</button>"##,
|
||||||
|
html_escape::encode_text(label)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stub_posts_to_logout_endpoint() {
|
||||||
|
assert!(render_logout_button().contains(r#"hx-post="/api/htmx/auth/logout""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! Authentication surfaces.
|
||||||
|
//!
|
||||||
|
//! The actual login form/modal HTML is produced server-side by the framework
|
||||||
|
//! handler at `/api/htmx/auth/login-modal` so credentials never get reflected
|
||||||
|
//! through the client. The components here are the **triggers** that fetch
|
||||||
|
//! those fragments and the slot they land in.
|
||||||
|
|
||||||
|
mod login_button;
|
||||||
|
mod login_form;
|
||||||
|
mod login_modal;
|
||||||
|
mod logout_button;
|
||||||
|
|
||||||
|
pub use login_button::{render_login_button, render_login_button_for_lang};
|
||||||
|
pub use login_form::render_login_form;
|
||||||
|
pub use login_modal::render_login_modal;
|
||||||
|
pub use logout_button::render_logout_button;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
//! Single content card.
|
||||||
|
|
||||||
|
/// Render a content card.
|
||||||
|
pub fn render_content_card(id: &str, title: &str) -> String {
|
||||||
|
let ctx = minijinja::context! { id => id, title => title };
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/content/card.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
r#"<article data-stub="card" id="card-{id}" class="ds-card"><h3>{title}</h3></article>"#,
|
||||||
|
id = html_escape::encode_double_quoted_attribute(id),
|
||||||
|
title = html_escape::encode_text(title),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn carries_unique_id() {
|
||||||
|
let html = render_content_card("post-1", "Hello");
|
||||||
|
assert!(html.contains("post-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_title() {
|
||||||
|
let html = render_content_card("p", "<bad>");
|
||||||
|
assert!(html.contains("<bad>") || html.contains("<bad>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
//! Content grid trigger surface.
|
||||||
|
|
||||||
|
use crate::primitives::htmx_path;
|
||||||
|
|
||||||
|
/// Render the content grid surface: search/tag-filter controls + initial grid
|
||||||
|
/// container.
|
||||||
|
pub fn render_content_grid(content_kind: &str, initial_grid_html: &str) -> String {
|
||||||
|
let endpoint = htmx_path(&format!("content/{content_kind}"));
|
||||||
|
let ctx = minijinja::context! {
|
||||||
|
kind => content_kind,
|
||||||
|
endpoint => endpoint,
|
||||||
|
initial_grid_html => initial_grid_html,
|
||||||
|
};
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/content/grid.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let kind_attr = html_escape::encode_double_quoted_attribute(content_kind);
|
||||||
|
let ep_attr = html_escape::encode_double_quoted_attribute(&endpoint);
|
||||||
|
format!(
|
||||||
|
r##"<section data-grid-host="{kind_attr}" data-loading-states><form hx-get="{ep_attr}" hx-target="closest [data-grid-host] > [data-content-grid]" hx-swap="morph" hx-trigger="keyup changed delay:300ms from:input[name=q], change from:select[name=tag], submit" hx-push-url="true" hx-include="this" hx-sync="this:replace"><input type="search" name="q" placeholder="Search…" autocomplete="off"><select name="tag"><option value="">All tags</option></select></form>{initial_grid_html}</section>"##,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn carries_morph_swap() {
|
||||||
|
let html = render_content_grid("blog", "");
|
||||||
|
assert!(html.contains(r#"hx-swap="morph""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounces_search_typing() {
|
||||||
|
let html = render_content_grid("blog", "");
|
||||||
|
assert!(html.contains("keyup changed delay:300ms"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn includes_form_state_via_hx_include() {
|
||||||
|
let html = render_content_grid("blog", "");
|
||||||
|
assert!(html.contains(r#"hx-include="this""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replaces_in_flight_requests() {
|
||||||
|
let html = render_content_grid("blog", "");
|
||||||
|
assert!(html.contains(r#"hx-sync="this:replace""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_path_matches_kind() {
|
||||||
|
let html = render_content_grid("recipes", "");
|
||||||
|
assert!(html.contains("/api/htmx/content/recipes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embeds_initial_html() {
|
||||||
|
let html = render_content_grid("blog", "<ul>INITIAL</ul>");
|
||||||
|
assert!(html.contains("INITIAL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Content grids, cards and paginators. Implemented in F-08.
|
||||||
|
|
||||||
|
mod card;
|
||||||
|
mod grid;
|
||||||
|
|
||||||
|
pub use card::render_content_card;
|
||||||
|
pub use grid::render_content_grid;
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
//! Generic form field with inline validation feedback. F-11.
|
||||||
|
|
||||||
|
use html_escape::encode_double_quoted_attribute;
|
||||||
|
|
||||||
|
use crate::primitives::htmx_path;
|
||||||
|
|
||||||
|
/// Input kind. Drives the `<input>` `type` attribute.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum FieldKind {
|
||||||
|
Text,
|
||||||
|
Email,
|
||||||
|
Password,
|
||||||
|
Tel,
|
||||||
|
Url,
|
||||||
|
TextArea,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldKind {
|
||||||
|
pub fn input_type(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FieldKind::Text => "text",
|
||||||
|
FieldKind::Email => "email",
|
||||||
|
FieldKind::Password => "password",
|
||||||
|
FieldKind::Tel => "tel",
|
||||||
|
FieldKind::Url => "url",
|
||||||
|
FieldKind::TextArea => "textarea",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a single form field with `hx-trigger="blur"` validation pointing at
|
||||||
|
/// `/api/htmx/validate/{form}/{name}`.
|
||||||
|
pub fn render_field(form: &str, name: &str, label: &str, kind: FieldKind) -> String {
|
||||||
|
let endpoint = htmx_path(&format!("validate/{form}/{name}"));
|
||||||
|
let id = format!("field-{form}-{name}");
|
||||||
|
let body = if matches!(kind, FieldKind::TextArea) {
|
||||||
|
format!(
|
||||||
|
r#"<textarea id="{id}" name="{name}" hx-post="{endpoint}" hx-trigger="blur" hx-target="closest .ds-field" hx-swap="outerHTML"></textarea>"#,
|
||||||
|
id = encode_double_quoted_attribute(&id),
|
||||||
|
name = encode_double_quoted_attribute(name),
|
||||||
|
endpoint = encode_double_quoted_attribute(&endpoint),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
r#"<input type="{ty}" id="{id}" name="{name}" hx-post="{endpoint}" hx-trigger="blur" hx-target="closest .ds-field" hx-swap="outerHTML">"#,
|
||||||
|
ty = kind.input_type(),
|
||||||
|
id = encode_double_quoted_attribute(&id),
|
||||||
|
name = encode_double_quoted_attribute(name),
|
||||||
|
endpoint = encode_double_quoted_attribute(&endpoint),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"<div class="ds-field"><label for="{id}">{label}</label>{body}<span class="ds-field-error" data-error-for="{name}"></span></div>"#,
|
||||||
|
id = encode_double_quoted_attribute(&id),
|
||||||
|
label = html_escape::encode_text(label),
|
||||||
|
name = encode_double_quoted_attribute(name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_per_field_validate_endpoint() {
|
||||||
|
let html = render_field("contact", "email", "Email", FieldKind::Email);
|
||||||
|
assert!(html.contains(r#"hx-post="/api/htmx/validate/contact/email""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn triggers_on_blur() {
|
||||||
|
let html = render_field("contact", "email", "Email", FieldKind::Email);
|
||||||
|
assert!(html.contains(r#"hx-trigger="blur""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_textarea_for_textarea_kind() {
|
||||||
|
let html = render_field("contact", "msg", "Message", FieldKind::TextArea);
|
||||||
|
assert!(html.contains("<textarea"));
|
||||||
|
assert!(!html.contains("<input"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn carries_error_target_span() {
|
||||||
|
let html = render_field("contact", "email", "Email", FieldKind::Email);
|
||||||
|
assert!(html.contains(r#"data-error-for="email""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
//! Form primitives with `hx-trigger=blur` per-field validation. F-11.
|
||||||
|
|
||||||
|
mod field;
|
||||||
|
|
||||||
|
pub use field::{render_field, FieldKind};
|
||||||
52
crates/foundation/crates/rustelo_components_htmx/src/lib.rs
Normal file
52
crates/foundation/crates/rustelo_components_htmx/src/lib.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
//! Rustelo UI components for the HTMX-SSR rendering profile.
|
||||||
|
//!
|
||||||
|
//! This crate is the HTMX peer of [`rustelo_components_leptos`]. Both render
|
||||||
|
//! the same visual UI but target different runtimes:
|
||||||
|
//!
|
||||||
|
//! - `rustelo_components_leptos` emits Leptos components with `on:click`
|
||||||
|
//! handlers that only become interactive after WASM hydrates the SSR markup.
|
||||||
|
//! - `rustelo_components_htmx` emits server-rendered HTML with `hx-*`
|
||||||
|
//! attributes that drive every interaction through `/api/htmx/*` endpoints.
|
||||||
|
//! No WASM, no signals — interactivity lives on the server.
|
||||||
|
//!
|
||||||
|
//! ## Why two crates
|
||||||
|
//!
|
||||||
|
//! The two trees are physically separated to prevent drift and to let the
|
||||||
|
//! workspace build either one without dragging the other's dependencies (or
|
||||||
|
//! its WASM target) into the binary. The active profile is selected per route
|
||||||
|
//! in `site/config/rendering.ncl` (ADR-006).
|
||||||
|
//!
|
||||||
|
//! ## Conventions
|
||||||
|
//!
|
||||||
|
//! - Components are **functions** returning `impl IntoView`, not
|
||||||
|
//! `#[component]`. This keeps the API explicit about its statelessness.
|
||||||
|
//! - No `on:click`, no `signal()`, no `Resource::new`. If interactivity is
|
||||||
|
//! needed, the function emits `hx-*` attributes pointing at an endpoint under
|
||||||
|
//! `/api/htmx/`.
|
||||||
|
//! - Translations and content are pre-resolved by the caller and passed in as
|
||||||
|
//! strings or slices. The components themselves do not read from any context
|
||||||
|
//! — making them trivially testable.
|
||||||
|
//!
|
||||||
|
//! ## Module layout
|
||||||
|
//!
|
||||||
|
//! Mirrors `rustelo_components_leptos` so finding the HTMX peer for a Leptos
|
||||||
|
//! component is a one-to-one path translation:
|
||||||
|
//!
|
||||||
|
//! - [`navigation`] — nav menu, footer, theme toggle, language selector
|
||||||
|
//! - [`auth`] — login form/modal, logout button
|
||||||
|
//! - [`content`] — grids, cards, paginators
|
||||||
|
//! - [`forms`] — generic field, validation feedback, common forms
|
||||||
|
//! - [`overlays`] — modals, toasts, cookie banner
|
||||||
|
//! - [`primitives`] — internal helpers for building hx-* attribute strings
|
||||||
|
|
||||||
|
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod content;
|
||||||
|
pub mod forms;
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod overlays;
|
||||||
|
pub mod primitives;
|
||||||
|
|
||||||
|
/// Endpoint root for all HTMX server endpoints exposed by this component set.
|
||||||
|
pub const HTMX_API_ROOT: &str = "/api/htmx";
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
//! Footer component for the HTMX-SSR rendering profile.
|
||||||
|
|
||||||
|
use html_escape::{encode_double_quoted_attribute, encode_text};
|
||||||
|
|
||||||
|
/// Site-level footer metadata passed from the project's shell.
|
||||||
|
pub struct FooterConfig<'a> {
|
||||||
|
pub title: &'a str,
|
||||||
|
pub description: &'a str,
|
||||||
|
pub copyright: &'a str,
|
||||||
|
/// Each entry: `(display_name, url, icon_class)`.
|
||||||
|
pub social_links: &'a [(&'a str, &'a str, &'a str)],
|
||||||
|
/// Footer nav links: `(label, href)`.
|
||||||
|
pub nav_links: &'a [(&'a str, &'a str)],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the site footer with site-specific config.
|
||||||
|
pub fn render_footer_with_config(config: &FooterConfig<'_>, _lang: &str) -> String {
|
||||||
|
let social_html = config
|
||||||
|
.social_links
|
||||||
|
.iter()
|
||||||
|
.map(|(name, url, icon)| {
|
||||||
|
format!(
|
||||||
|
r##"<a href="{url}" target="_blank" rel="noopener noreferrer" aria-label="{name}" class="ds-text-muted hover:ds-text transition-colors"><i class="{icon}"></i></a>"##,
|
||||||
|
url = encode_double_quoted_attribute(url),
|
||||||
|
name = encode_double_quoted_attribute(name),
|
||||||
|
icon = encode_double_quoted_attribute(icon),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
let nav_html = config
|
||||||
|
.nav_links
|
||||||
|
.iter()
|
||||||
|
.map(|(label, href)| {
|
||||||
|
format!(
|
||||||
|
r##"<a href="{href}" class="ds-text-muted hover:ds-text text-xs transition-colors no-underline">{label}</a>"##,
|
||||||
|
href = encode_double_quoted_attribute(href),
|
||||||
|
label = encode_text(label),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r##"<footer class="border-t border-gray-200 dark:border-gray-700 mt-auto py-8"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex flex-col sm:flex-row justify-between items-center gap-4"><div class="text-center sm:text-left"><p class="font-semibold ds-text text-sm">{title}</p><p class="text-xs ds-text-muted">{desc}</p></div><div class="flex items-center gap-4">{social}</div></div><div class="mt-6 pt-4 border-t border-gray-100 dark:border-gray-800 flex flex-col sm:flex-row justify-between items-center gap-2"><p class="text-xs ds-text-muted">{copy}</p><div class="flex items-center gap-4">{nav}</div></div></div></footer>"##,
|
||||||
|
title = encode_text(config.title),
|
||||||
|
desc = encode_text(config.description),
|
||||||
|
social = social_html,
|
||||||
|
copy = encode_text(config.copyright),
|
||||||
|
nav = nav_html,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal footer fallback used when no `FooterConfig` is available.
|
||||||
|
pub fn render_footer(lang: &str) -> String {
|
||||||
|
let _ = lang;
|
||||||
|
r#"<footer class="border-t border-gray-200 dark:border-gray-700 mt-auto py-6"><div class="max-w-7xl mx-auto px-4 text-xs ds-text-muted text-center">© Jesús Pérez</div></footer>"#.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_social_links() {
|
||||||
|
let cfg = FooterConfig {
|
||||||
|
title: "Test",
|
||||||
|
description: "Desc",
|
||||||
|
copyright: "© 2024",
|
||||||
|
social_links: &[("GitHub", "https://github.com/x", "fab fa-github")],
|
||||||
|
nav_links: &[],
|
||||||
|
};
|
||||||
|
let html = render_footer_with_config(&cfg, "en");
|
||||||
|
assert!(html.contains("https://github.com/x"));
|
||||||
|
assert!(html.contains("fab fa-github"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_nav_links() {
|
||||||
|
let cfg = FooterConfig {
|
||||||
|
title: "T",
|
||||||
|
description: "D",
|
||||||
|
copyright: "C",
|
||||||
|
social_links: &[],
|
||||||
|
nav_links: &[("Privacy", "/privacy")],
|
||||||
|
};
|
||||||
|
let html = render_footer_with_config(&cfg, "en");
|
||||||
|
assert!(html.contains("/privacy"));
|
||||||
|
assert!(html.contains("Privacy"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
//! Language selector — dropdown using native `<details>` so it opens/closes
|
||||||
|
//! without JavaScript. Mirrors the Leptos `LanguageSelector` look (globe icon
|
||||||
|
//! summary, panel of options) but the options are HTMX `hx-post` triggers
|
||||||
|
//! that go to `/api/htmx/lang/{code}`. The server responds with the new
|
||||||
|
//! selector fragment + `HX-Redirect` to the localised URL.
|
||||||
|
|
||||||
|
use crate::primitives::htmx_path;
|
||||||
|
|
||||||
|
/// Render the language selector for the currently active language.
|
||||||
|
pub fn render_lang_selector(current_lang: &str) -> String {
|
||||||
|
let languages: [(&str, &str); 2] = [("en", "English"), ("es", "Español")];
|
||||||
|
let langs: Vec<minijinja::Value> = languages
|
||||||
|
.iter()
|
||||||
|
.map(|(code, label)| {
|
||||||
|
let endpoint = htmx_path(&format!("lang/{code}"));
|
||||||
|
minijinja::context! {
|
||||||
|
code => code,
|
||||||
|
label => label,
|
||||||
|
endpoint => endpoint,
|
||||||
|
is_active => *code == current_lang,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let ctx = minijinja::context! { languages => langs };
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/nav/lang_selector.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| fallback_lang_selector(current_lang, &languages))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_lang_selector(current_lang: &str, languages: &[(&str, &str)]) -> String {
|
||||||
|
let mut options = String::new();
|
||||||
|
for (code, label) in languages {
|
||||||
|
let endpoint = htmx_path(&format!("lang/{code}"));
|
||||||
|
let active = if *code == current_lang {
|
||||||
|
r#" aria-current="true" disabled"#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
options.push_str(&format!(
|
||||||
|
r##"<button type="button" hx-post="{ep}" hx-target="#lang-selector" hx-swap="outerHTML"{active} data-lang="{code}">{label}</button>"##,
|
||||||
|
ep = html_escape::encode_double_quoted_attribute(&endpoint),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
r##"<details id="lang-selector" class="language-selector relative"><summary aria-label="Language">🌐</summary><div class="absolute right-0 mt-1 w-48 ds-bg border rounded-md shadow-lg z-10"><div class="py-1">{options}</div></div></details>"##
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_two_buttons() {
|
||||||
|
let html = render_lang_selector("en");
|
||||||
|
assert_eq!(html.matches("<button").count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_active_language() {
|
||||||
|
let html = render_lang_selector("es");
|
||||||
|
assert!(html.contains(r#"aria-current="true" disabled"#));
|
||||||
|
assert!(html.contains(r#"data-lang="es""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoints_target_correct_paths() {
|
||||||
|
let html = render_lang_selector("en");
|
||||||
|
assert!(html.contains(r#"hx-post="/api/htmx/lang/en""#));
|
||||||
|
assert!(html.contains(r#"hx-post="/api/htmx/lang/es""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_native_details_dropdown() {
|
||||||
|
let html = render_lang_selector("en");
|
||||||
|
assert!(html.contains("<details"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_matches_wrapper_id() {
|
||||||
|
let html = render_lang_selector("en");
|
||||||
|
assert!(html.contains(r##"id="lang-selector""##));
|
||||||
|
assert!(html.contains(r##"hx-target="#lang-selector""##));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
//! Navigation surfaces: nav menu, footer, theme toggle, language selector.
|
||||||
|
//!
|
||||||
|
//! Every component here is stateless — caller passes in the resolved menu
|
||||||
|
//! items, current language, and current theme. State changes are submitted to
|
||||||
|
//! `/api/htmx/{theme,lang}/*` endpoints which return the new fragment(s) +
|
||||||
|
//! any OOB swaps needed (e.g. `<html class>` update).
|
||||||
|
|
||||||
|
mod footer;
|
||||||
|
mod lang_selector;
|
||||||
|
mod nav_menu;
|
||||||
|
mod theme_toggle;
|
||||||
|
|
||||||
|
pub use footer::{render_footer, render_footer_with_config, FooterConfig};
|
||||||
|
pub use lang_selector::render_lang_selector;
|
||||||
|
pub use nav_menu::{render_nav_menu, NavItem};
|
||||||
|
pub use theme_toggle::{render_theme_toggle, Theme};
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
//! Top navigation menu emitted with `hx-boost`-friendly anchors.
|
||||||
|
//!
|
||||||
|
//! Anchors carry plain `href` attributes — boost on `<body>` upgrades them to
|
||||||
|
//! HTMX-driven navigations. The current route is computed server-side so the
|
||||||
|
//! active-item highlight is part of the SSR output (no client JS needed).
|
||||||
|
|
||||||
|
use html_escape::encode_double_quoted_attribute;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One row in the navigation menu.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NavItem {
|
||||||
|
/// Localised label already resolved against the active language.
|
||||||
|
pub label: String,
|
||||||
|
/// Destination URL. Internal routes get `hx-boost` treatment via the
|
||||||
|
/// surrounding `<body>`; external links get `target="_blank"`.
|
||||||
|
pub route: String,
|
||||||
|
/// `true` if the link points outside the site.
|
||||||
|
pub is_external: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the navigation menu as a `<nav>` element.
|
||||||
|
///
|
||||||
|
/// `active_path` is the current request path used to mark the active anchor
|
||||||
|
/// with `aria-current="page"`. `items` are pre-localised by the caller.
|
||||||
|
pub fn render_nav_menu(items: &[NavItem], active_path: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
out.push_str(r#"<nav class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm" aria-label="Primary">"#);
|
||||||
|
out.push_str(r#"<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="hidden lg:flex lg:space-x-8 items-center">"#);
|
||||||
|
for item in items {
|
||||||
|
let is_active = !item.is_external && paths_match(active_path, &item.route);
|
||||||
|
let target_attr = if item.is_external {
|
||||||
|
r#" target="_blank" rel="noopener noreferrer""#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let aria_current = if is_active {
|
||||||
|
r#" aria-current="page""#
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let active_class = if is_active {
|
||||||
|
" text-gray-900 dark:text-white font-semibold"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
out.push_str(&format!(
|
||||||
|
r#"<a href="{href}" class="text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white px-3 py-2 rounded-md text-sm font-medium transition-colors no-underline{active_class}"{aria_current}{target_attr}>{label}</a>"#,
|
||||||
|
href = encode_double_quoted_attribute(&item.route),
|
||||||
|
label = html_escape::encode_text(&item.label),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str("</div></div></div></nav>");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paths_match(current: &str, route: &str) -> bool {
|
||||||
|
if route == "/" {
|
||||||
|
current == "/" || current.is_empty()
|
||||||
|
} else {
|
||||||
|
current == route || current.starts_with(&format!("{route}/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_items() -> Vec<NavItem> {
|
||||||
|
vec![
|
||||||
|
NavItem {
|
||||||
|
label: "Home".into(),
|
||||||
|
route: "/".into(),
|
||||||
|
is_external: false,
|
||||||
|
},
|
||||||
|
NavItem {
|
||||||
|
label: "Blog".into(),
|
||||||
|
route: "/blog".into(),
|
||||||
|
is_external: false,
|
||||||
|
},
|
||||||
|
NavItem {
|
||||||
|
label: "Rustelo".into(),
|
||||||
|
route: "https://rustelo.dev".into(),
|
||||||
|
is_external: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_all_items() {
|
||||||
|
let html = render_nav_menu(&sample_items(), "/");
|
||||||
|
assert!(html.contains(r#"href="/""#));
|
||||||
|
assert!(html.contains(r#"href="/blog""#));
|
||||||
|
assert!(html.contains(r#"href="https://rustelo.dev""#));
|
||||||
|
assert_eq!(html.matches("<a ").count(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_active_item_on_root() {
|
||||||
|
let html = render_nav_menu(&sample_items(), "/");
|
||||||
|
let home_anchor = html.split("<a ").nth(1).expect("first anchor present");
|
||||||
|
assert!(home_anchor.contains(r#"aria-current="page""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marks_active_item_on_subpath() {
|
||||||
|
let html = render_nav_menu(&sample_items(), "/blog/some-post");
|
||||||
|
let blog_anchor = html
|
||||||
|
.split(r#"href="/blog""#)
|
||||||
|
.nth(1)
|
||||||
|
.expect("blog anchor present");
|
||||||
|
let prefix = &blog_anchor[..blog_anchor.find('>').expect("> found")];
|
||||||
|
assert!(prefix.contains(r#"aria-current="page""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_links_get_target_blank() {
|
||||||
|
let html = render_nav_menu(&sample_items(), "/");
|
||||||
|
assert!(html.contains(r#"target="_blank" rel="noopener noreferrer""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_label_text() {
|
||||||
|
let items = vec![NavItem {
|
||||||
|
label: "<bad>".into(),
|
||||||
|
route: "/x".into(),
|
||||||
|
is_external: false,
|
||||||
|
}];
|
||||||
|
let html = render_nav_menu(&items, "/");
|
||||||
|
assert!(html.contains("<bad>"));
|
||||||
|
assert!(!html.contains("<bad>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
//! Theme toggle button. Posts to `/api/htmx/theme/toggle`.
|
||||||
|
|
||||||
|
/// Theme value, kept lowercase to match `<html class>` conventions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Theme {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "light",
|
||||||
|
Theme::Dark => "dark",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the theme toggle button. `current` controls which icon shows.
|
||||||
|
pub fn render_theme_toggle(current: Theme) -> String {
|
||||||
|
let label = match current {
|
||||||
|
Theme::Light => "Switch to dark theme",
|
||||||
|
Theme::Dark => "Switch to light theme",
|
||||||
|
};
|
||||||
|
let ctx = minijinja::context! {
|
||||||
|
theme => current.as_str(),
|
||||||
|
label => label,
|
||||||
|
};
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/nav/theme_toggle.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| fallback_theme_button(current, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_theme_button(current: Theme, label: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r##"<button type="button" class="ds-btn-ghost-sm" hx-post="/api/htmx/theme/toggle" hx-swap="outerHTML" hx-target="this" hx-ext="multi-swap" data-theme="{theme}" aria-label="{label}">{icon}</button>"##,
|
||||||
|
theme = current.as_str(),
|
||||||
|
label = html_escape::encode_double_quoted_attribute(label),
|
||||||
|
icon = theme_icon(current),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme_icon(theme: Theme) -> &'static str {
|
||||||
|
match theme {
|
||||||
|
Theme::Light => {
|
||||||
|
r#"<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path></svg>"#
|
||||||
|
}
|
||||||
|
Theme::Dark => {
|
||||||
|
r#"<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emits_post_to_toggle_endpoint() {
|
||||||
|
let html = render_theme_toggle(Theme::Light);
|
||||||
|
assert!(html.contains(r#"hx-post="/api/htmx/theme/toggle""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn carries_current_theme_data_attribute() {
|
||||||
|
let html = render_theme_toggle(Theme::Dark);
|
||||||
|
assert!(html.contains(r#"data-theme="dark""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_multi_swap_extension() {
|
||||||
|
let html = render_theme_toggle(Theme::Light);
|
||||||
|
assert!(html.contains(r#"hx-ext="multi-swap""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! Cookie banner — server emits only when the consent cookie is missing.
|
||||||
|
|
||||||
|
use crate::primitives::htmx_path;
|
||||||
|
|
||||||
|
/// Localized strings + resolved policy href for the cookie banner.
|
||||||
|
///
|
||||||
|
/// The component is language-agnostic: the consumer's shell resolves every
|
||||||
|
/// string (via the FTL registry) and the per-language privacy route, so this
|
||||||
|
/// renderer stays a pure HTML emitter with no i18n or routing knowledge.
|
||||||
|
pub struct CookieBannerLabels {
|
||||||
|
/// Lead sentence — FTL `cookies-banner-text`.
|
||||||
|
pub message: String,
|
||||||
|
/// Language-resolved privacy/cookie policy URL (e.g. `/privacy`,
|
||||||
|
/// `/privacidad`).
|
||||||
|
pub policy_href: String,
|
||||||
|
/// Inline policy link text — FTL `cookies-policy-link`.
|
||||||
|
pub policy_link: String,
|
||||||
|
/// Sentence tail after the link — FTL `cookies-policy-suffix`.
|
||||||
|
pub policy_suffix: String,
|
||||||
|
/// "Manage" action label — FTL `cookies-manage`.
|
||||||
|
pub manage_label: String,
|
||||||
|
/// Reject-non-essential label — FTL `cookies-reject`.
|
||||||
|
pub reject_label: String,
|
||||||
|
/// Accept-all label — FTL `cookies-accept-all`.
|
||||||
|
pub accept_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the cookie banner when `show` is true.
|
||||||
|
pub fn render_cookie_banner(show: bool, labels: &CookieBannerLabels) -> String {
|
||||||
|
if !show {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let endpoint = htmx_path("cookies/accept");
|
||||||
|
let ctx = minijinja::context! {
|
||||||
|
endpoint => endpoint,
|
||||||
|
message => labels.message,
|
||||||
|
policy_href => labels.policy_href,
|
||||||
|
policy_link => labels.policy_link,
|
||||||
|
policy_suffix => labels.policy_suffix,
|
||||||
|
manage_label => labels.manage_label,
|
||||||
|
reject_label => labels.reject_label,
|
||||||
|
accept_label => labels.accept_label,
|
||||||
|
};
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/overlays/cookie_banner.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let ep = html_escape::encode_double_quoted_attribute(&endpoint);
|
||||||
|
let href = html_escape::encode_double_quoted_attribute(&labels.policy_href);
|
||||||
|
format!(
|
||||||
|
r##"<div id="cookie-consent-wrapper"><div class="fixed inset-0 z-50 bg-black opacity-75"></div><div id="cookie-banner" role="dialog" aria-label="Cookie consent" class="fixed bottom-0 left-0 right-0 z-[60] bg-base-100 border-t px-6 py-5 flex gap-4"><p class="text-sm flex-1 ds-text">{msg} <a href="{href}" class="underline ds-text">{plink}</a> {psuffix}</p><div class="flex items-center gap-2 shrink-0"><a href="{href}" class="btn btn-ghost btn-sm rounded-full ds-text no-underline">{manage}</a><button type="button" hx-post="{ep}" hx-vals='{{"level":"essential"}}' hx-target="#cookie-consent-wrapper" hx-swap="outerHTML" class="btn btn-ghost btn-sm rounded-full ds-text">{reject}</button><button type="button" hx-post="{ep}" hx-vals='{{"level":"all"}}' hx-target="#cookie-consent-wrapper" hx-swap="outerHTML" class="btn btn-neutral btn-sm rounded-full">{accept}</button></div></div></div>"##,
|
||||||
|
msg = html_escape::encode_text(&labels.message),
|
||||||
|
plink = html_escape::encode_text(&labels.policy_link),
|
||||||
|
psuffix = html_escape::encode_text(&labels.policy_suffix),
|
||||||
|
manage = html_escape::encode_text(&labels.manage_label),
|
||||||
|
reject = html_escape::encode_text(&labels.reject_label),
|
||||||
|
accept = html_escape::encode_text(&labels.accept_label),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample() -> CookieBannerLabels {
|
||||||
|
CookieBannerLabels {
|
||||||
|
message: "We use cookies".to_string(),
|
||||||
|
policy_href: "/privacidad".to_string(),
|
||||||
|
policy_link: "Cookie Policy".to_string(),
|
||||||
|
policy_suffix: "for more info.".to_string(),
|
||||||
|
manage_label: "Manage".to_string(),
|
||||||
|
reject_label: "Reject".to_string(),
|
||||||
|
accept_label: "Accept".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_when_show_true() {
|
||||||
|
let html = render_cookie_banner(true, &sample());
|
||||||
|
assert!(html.contains(r#"id="cookie-banner""#));
|
||||||
|
assert!(html.contains("We use cookies"));
|
||||||
|
assert!(html.contains(r#"hx-post="/api/htmx/cookies/accept""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_when_show_false() {
|
||||||
|
assert_eq!(render_cookie_banner(false, &sample()), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_localized_policy_link() {
|
||||||
|
let html = render_cookie_banner(true, &sample());
|
||||||
|
assert!(html.contains(r#"href="/privacidad""#));
|
||||||
|
assert!(html.contains("Cookie Policy"));
|
||||||
|
assert!(html.contains("for more info."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_reject_and_accept_levels() {
|
||||||
|
let html = render_cookie_banner(true, &sample());
|
||||||
|
assert!(html.contains(r#"hx-vals='{"level":"essential"}'"#));
|
||||||
|
assert!(html.contains(r#"hx-vals='{"level":"all"}'"#));
|
||||||
|
assert!(html.contains("Reject"));
|
||||||
|
assert!(html.contains("Accept"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrapper_contains_overlay_and_banner() {
|
||||||
|
let html = render_cookie_banner(true, &sample());
|
||||||
|
assert!(html.contains(r##"id="cookie-consent-wrapper""##));
|
||||||
|
assert!(html.contains(r##"id="cookie-banner""##));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
//! Overlays: modal slot, toast container (SSE-driven), cookie banner.
|
||||||
|
//! F-09/F-10.
|
||||||
|
|
||||||
|
mod cookie_banner;
|
||||||
|
mod modal_slot;
|
||||||
|
mod toast_container;
|
||||||
|
|
||||||
|
pub use cookie_banner::{render_cookie_banner, CookieBannerLabels};
|
||||||
|
pub use modal_slot::render_modal_slot;
|
||||||
|
pub use toast_container::render_toast_container;
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
//! Empty `#modal-slot` container.
|
||||||
|
|
||||||
|
/// Render the modal slot.
|
||||||
|
pub fn render_modal_slot() -> String {
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/overlays/modal_slot.j2")
|
||||||
|
.and_then(|t| t.render(minijinja::Value::UNDEFINED))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| String::from(r#"<div id="modal-slot" aria-live="polite"></div>"#))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slot_has_stable_id() {
|
||||||
|
assert!(render_modal_slot().contains(r#"id="modal-slot""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
//! Toast container — connects to SSE for live notifications.
|
||||||
|
|
||||||
|
use crate::primitives::htmx_path;
|
||||||
|
|
||||||
|
/// Render the toast container with SSE connection.
|
||||||
|
pub fn render_toast_container() -> String {
|
||||||
|
let sse_url = htmx_path("notifications/sse");
|
||||||
|
let ctx = minijinja::context! { sse_url => sse_url };
|
||||||
|
rustelo_pages_htmx::fragment_env()
|
||||||
|
.and_then(|env| {
|
||||||
|
env.get_template("partials/overlays/toast_container.j2")
|
||||||
|
.and_then(|t| t.render(ctx))
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
r##"<div id="toast-stack" class="fixed bottom-4 right-4 flex flex-col gap-2 z-50" aria-live="polite" hx-ext="sse" sse-connect="{url}" sse-swap="toast" hx-swap="beforeend"></div>"##,
|
||||||
|
url = html_escape::encode_double_quoted_attribute(&sse_url),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opens_sse_to_notifications_endpoint() {
|
||||||
|
let html = render_toast_container();
|
||||||
|
assert!(html.contains(r#"hx-ext="sse""#));
|
||||||
|
assert!(html.contains(r#"sse-connect="/api/htmx/notifications/sse""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_via_beforeend() {
|
||||||
|
assert!(render_toast_container().contains(r#"hx-swap="beforeend""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
//! URL builders for HTMX endpoints under `/api/htmx/*`.
|
||||||
|
|
||||||
|
use crate::HTMX_API_ROOT;
|
||||||
|
|
||||||
|
/// Build a path under the HTMX API root.
|
||||||
|
///
|
||||||
|
/// `subpath` is appended with exactly one `/` separator regardless of leading
|
||||||
|
/// slashes on the input.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rustelo_components_htmx::primitives::htmx_path;
|
||||||
|
/// assert_eq!(htmx_path("theme/toggle"), "/api/htmx/theme/toggle");
|
||||||
|
/// assert_eq!(htmx_path("/auth/login"), "/api/htmx/auth/login");
|
||||||
|
/// ```
|
||||||
|
pub fn htmx_path(subpath: &str) -> String {
|
||||||
|
let trimmed = subpath.trim_start_matches('/');
|
||||||
|
format!("{HTMX_API_ROOT}/{trimmed}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a path under the generic `/api/*` root (non-HTMX server functions).
|
||||||
|
///
|
||||||
|
/// Used when a component points at a server function or REST endpoint that
|
||||||
|
/// returns JSON rather than an HTML fragment.
|
||||||
|
pub fn api_path(subpath: &str) -> String {
|
||||||
|
let trimmed = subpath.trim_start_matches('/');
|
||||||
|
format!("/api/{trimmed}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn htmx_path_strips_leading_slash() {
|
||||||
|
assert_eq!(htmx_path("/x/y"), "/api/htmx/x/y");
|
||||||
|
assert_eq!(htmx_path("x/y"), "/api/htmx/x/y");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_path_strips_leading_slash() {
|
||||||
|
assert_eq!(api_path("/health"), "/api/health");
|
||||||
|
assert_eq!(api_path("health"), "/api/health");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
//! Typed builders for the most common `hx-*` attribute values.
|
||||||
|
//!
|
||||||
|
//! These are not wrappers around HTML; they produce plain `&str`/`String`
|
||||||
|
//! values ready to be embedded into Leptos `view!{}` attribute slots.
|
||||||
|
//! Keeping these typed prevents typos in trigger/swap strings that would
|
||||||
|
//! silently fall back to htmx defaults.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// `hx-target` value.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HxTarget {
|
||||||
|
/// Replace `this` element (the one carrying the trigger).
|
||||||
|
This,
|
||||||
|
/// Replace the element matched by the CSS selector.
|
||||||
|
Selector(String),
|
||||||
|
/// Replace the closest ancestor matching the selector.
|
||||||
|
Closest(String),
|
||||||
|
/// Replace the next sibling matching the selector.
|
||||||
|
Next(String),
|
||||||
|
/// Replace the previous sibling matching the selector.
|
||||||
|
Previous(String),
|
||||||
|
/// Replace the body. Equivalent to `body`.
|
||||||
|
Body,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for HxTarget {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
HxTarget::This => f.write_str("this"),
|
||||||
|
HxTarget::Selector(s) => f.write_str(s),
|
||||||
|
HxTarget::Closest(s) => write!(f, "closest {s}"),
|
||||||
|
HxTarget::Next(s) => write!(f, "next {s}"),
|
||||||
|
HxTarget::Previous(s) => write!(f, "previous {s}"),
|
||||||
|
HxTarget::Body => f.write_str("body"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hx-swap` value.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HxSwap {
|
||||||
|
InnerHtml,
|
||||||
|
OuterHtml,
|
||||||
|
BeforeBegin,
|
||||||
|
AfterBegin,
|
||||||
|
BeforeEnd,
|
||||||
|
AfterEnd,
|
||||||
|
Delete,
|
||||||
|
None,
|
||||||
|
/// Requires the `idiomorph` extension; preserves form/input state.
|
||||||
|
Morph,
|
||||||
|
/// Requires the `idiomorph` extension; morphs only innerHTML.
|
||||||
|
MorphInner,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for HxSwap {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
HxSwap::InnerHtml => "innerHTML",
|
||||||
|
HxSwap::OuterHtml => "outerHTML",
|
||||||
|
HxSwap::BeforeBegin => "beforebegin",
|
||||||
|
HxSwap::AfterBegin => "afterbegin",
|
||||||
|
HxSwap::BeforeEnd => "beforeend",
|
||||||
|
HxSwap::AfterEnd => "afterend",
|
||||||
|
HxSwap::Delete => "delete",
|
||||||
|
HxSwap::None => "none",
|
||||||
|
HxSwap::Morph => "morph",
|
||||||
|
HxSwap::MorphInner => "morph:innerHTML",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `hx-trigger` value. Composable: multiple triggers join with `, `.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HxTrigger {
|
||||||
|
Click,
|
||||||
|
Submit,
|
||||||
|
/// `change` event — typical for `<select>` and checkboxes.
|
||||||
|
Change,
|
||||||
|
/// `blur` event — used for per-field form validation.
|
||||||
|
Blur,
|
||||||
|
/// `keyup changed delayed:NNms` — instant-search style with debounce.
|
||||||
|
KeyupDebounced {
|
||||||
|
delay_ms: u32,
|
||||||
|
},
|
||||||
|
/// `every Ns` — polling.
|
||||||
|
Every {
|
||||||
|
seconds: u32,
|
||||||
|
},
|
||||||
|
/// `load` event — fires once on element load.
|
||||||
|
Load,
|
||||||
|
/// Raw trigger string when none of the above fits.
|
||||||
|
Raw(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for HxTrigger {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
HxTrigger::Click => f.write_str("click"),
|
||||||
|
HxTrigger::Submit => f.write_str("submit"),
|
||||||
|
HxTrigger::Change => f.write_str("change"),
|
||||||
|
HxTrigger::Blur => f.write_str("blur"),
|
||||||
|
HxTrigger::KeyupDebounced { delay_ms } => {
|
||||||
|
write!(f, "keyup changed delay:{delay_ms}ms")
|
||||||
|
}
|
||||||
|
HxTrigger::Every { seconds } => write!(f, "every {seconds}s"),
|
||||||
|
HxTrigger::Load => f.write_str("load"),
|
||||||
|
HxTrigger::Raw(s) => f.write_str(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_renders_each_variant() {
|
||||||
|
assert_eq!(HxTarget::This.to_string(), "this");
|
||||||
|
assert_eq!(HxTarget::Body.to_string(), "body");
|
||||||
|
assert_eq!(HxTarget::Selector("#x".into()).to_string(), "#x");
|
||||||
|
assert_eq!(
|
||||||
|
HxTarget::Closest(".field".into()).to_string(),
|
||||||
|
"closest .field"
|
||||||
|
);
|
||||||
|
assert_eq!(HxTarget::Next("section".into()).to_string(), "next section");
|
||||||
|
assert_eq!(HxTarget::Previous("p".into()).to_string(), "previous p");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swap_renders_each_variant() {
|
||||||
|
assert_eq!(HxSwap::InnerHtml.to_string(), "innerHTML");
|
||||||
|
assert_eq!(HxSwap::OuterHtml.to_string(), "outerHTML");
|
||||||
|
assert_eq!(HxSwap::Delete.to_string(), "delete");
|
||||||
|
assert_eq!(HxSwap::Morph.to_string(), "morph");
|
||||||
|
assert_eq!(HxSwap::MorphInner.to_string(), "morph:innerHTML");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_keyup_includes_debounce() {
|
||||||
|
let t = HxTrigger::KeyupDebounced { delay_ms: 300 };
|
||||||
|
assert_eq!(t.to_string(), "keyup changed delay:300ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trigger_every_renders_seconds() {
|
||||||
|
assert_eq!(HxTrigger::Every { seconds: 5 }.to_string(), "every 5s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
//! Internal helpers for building htmx attribute payloads.
|
||||||
|
//!
|
||||||
|
//! Every component in this crate emits `hx-*` attributes that point at a
|
||||||
|
//! server endpoint or describe a swap. These helpers centralise the
|
||||||
|
//! construction so the same conventions (URL escaping, endpoint root, target
|
||||||
|
//! resolution) apply everywhere.
|
||||||
|
|
||||||
|
mod endpoints;
|
||||||
|
mod hx_attrs;
|
||||||
|
|
||||||
|
pub use endpoints::{api_path, htmx_path};
|
||||||
|
pub use hx_attrs::{HxSwap, HxTarget, HxTrigger};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "rustelo_components"
|
name = "rustelo_components_leptos"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Rustelo Contributors"]
|
authors = ["Rustelo Contributors"]
|
||||||
|
|
@ -35,8 +35,10 @@ paste = { workspace = true }
|
||||||
once_cell = { workspace = true }
|
once_cell = { workspace = true }
|
||||||
web-sys = { workspace = true }
|
web-sys = { workspace = true }
|
||||||
|
|
||||||
rustelo_core_lib = { workspace = true }
|
rustelo_core_lib = { workspace = true, features = ["leptos-hydration"] }
|
||||||
rustelo_core_types = { workspace = true }
|
rustelo_core_types = { workspace = true }
|
||||||
|
leptos_axum = { workspace = true, optional = true }
|
||||||
|
axum = { workspace = true, optional = true }
|
||||||
# rustelo_tools = { workspace = true } # Tools crate needs extensive fixes
|
# rustelo_tools = { workspace = true } # Tools crate needs extensive fixes
|
||||||
|
|
||||||
# WASM-specific dependencies (client-side only)
|
# WASM-specific dependencies (client-side only)
|
||||||
|
|
@ -52,7 +54,15 @@ console_error_panic_hook = { workspace = true }
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
hydrate = ["leptos/hydrate"]
|
hydrate = ["leptos/hydrate"]
|
||||||
ssr = []
|
csr = ["leptos/csr"]
|
||||||
|
ssr = ["leptos/ssr", "dep:leptos_axum", "dep:axum"]
|
||||||
|
# Marker feature for the htmx-ssr rendering profile (ADR-006). Currently empty
|
||||||
|
# because the `#[cfg(any(feature = "hydrate", feature = "csr"))]` gates on
|
||||||
|
# `client.rs` modules already exclude WASM-only code when neither `hydrate`
|
||||||
|
# nor `csr` is enabled. The feature exists so build scripts and downstream
|
||||||
|
# crates can pattern-match on it without inferring profile from the absence
|
||||||
|
# of `hydrate`.
|
||||||
|
htmx-ssr = []
|
||||||
cache = []
|
cache = []
|
||||||
# Console logging control
|
# Console logging control
|
||||||
console-log = [] # Enable console logging (for development)
|
console-log = [] # Enable console logging (for development)
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Component Override Pattern
|
||||||
|
|
||||||
|
`rustelo_components` provides the full set of generic UI components used by any Rustelo implementation. Components are organized by domain:
|
||||||
|
|
||||||
|
- `auth` — LoginModal, OTP flow
|
||||||
|
- `cookie` — CookieBanner, CookieManageModal
|
||||||
|
- `navigation` — NavMenu, Footer, LanguageSelector, PreNavMenu, PreFooter
|
||||||
|
- `content` — ContentManager, HtmlContent, ContentCard, ContentGrid
|
||||||
|
- `theme` — ThemeProvider, ThemeContext
|
||||||
|
- `ui` — SpaLink, MobileMenu, PageTransition
|
||||||
|
|
||||||
|
## Overriding a component in your implementation
|
||||||
|
|
||||||
|
When a generic component does not fit your site's requirements, create a local replacement in your implementation crate and register it instead. The override lives entirely in your codebase — `rustelo_components` is not modified.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
your-impl/crates/pages/src/components/nav_menu.rs ← your override
|
||||||
|
your-impl/crates/client/src/app.rs ← uses your version
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// your override
|
||||||
|
#[component]
|
||||||
|
pub fn NavMenu() -> impl IntoView {
|
||||||
|
// site-specific implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// app.rs — use your version instead of the generic one
|
||||||
|
use crate::components::nav_menu::NavMenu;
|
||||||
|
```
|
||||||
|
|
||||||
|
No indirection crate needed. If you use the generic component as-is, import directly from `rustelo_components`.
|
||||||
|
|
||||||
|
## When to create an override crate
|
||||||
|
|
||||||
|
Only if multiple crates in your workspace share the same overridden component. In that case, a local `components` crate with `pub use rustelo_components::X` for unmodified components and your custom types for the rest is the right structure.
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
//! `ActivitiesCTAView` — per-activity CTA section rendered below markdown.
|
||||||
|
//!
|
||||||
|
//! Reads activity-specific metadata from `UnifiedContentItem.metadata` (JSONB),
|
||||||
|
//! determines the download-gate state via `server_check_activity_access`, and
|
||||||
|
//! renders: Slidev iframe, download gate, internal questionnaire, repository
|
||||||
|
//! link, and image gallery.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::content::UnifiedContentItem;
|
||||||
|
|
||||||
|
use super::questionnaire::QuestionnaireForm;
|
||||||
|
use super::server_fns::{
|
||||||
|
server_check_activity_access, server_get_questionnaire_definition, ActivityAccessStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Metadata helpers
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn meta_str(item: &UnifiedContentItem, key: &str) -> Option<String> {
|
||||||
|
item.metadata
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_bool(item: &UnifiedContentItem, key: &str) -> bool {
|
||||||
|
item.metadata
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// CTA section rendered below the activity markdown content.
|
||||||
|
///
|
||||||
|
/// Handles slides embed, gated PDF download, external / internal questionnaire
|
||||||
|
/// flow, repository link, and image gallery. All data comes from
|
||||||
|
/// `item.metadata` so no schema change to `UnifiedContentItem` is needed.
|
||||||
|
#[component]
|
||||||
|
pub fn ActivitiesCTAView(
|
||||||
|
item: UnifiedContentItem,
|
||||||
|
#[prop(default = "en".to_string())] language: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let slides_id = meta_str(&item, "slides_id");
|
||||||
|
let pdf_filename = meta_str(&item, "pdf_filename");
|
||||||
|
let repository_url = meta_str(&item, "repository_url");
|
||||||
|
let questionnaire_type =
|
||||||
|
meta_str(&item, "questionnaire_type").unwrap_or_else(|| "external".to_string());
|
||||||
|
let questionnaire_url = meta_str(&item, "questionnaire_url");
|
||||||
|
let download_requires_auth = meta_bool(&item, "download_requires_auth");
|
||||||
|
let download_requires_questionnaire = meta_bool(&item, "download_requires_questionnaire");
|
||||||
|
let questionnaire_config = meta_str(&item, "questionnaire_config");
|
||||||
|
|
||||||
|
// Gallery is stored as a JSON array of relative image paths.
|
||||||
|
let gallery: Vec<String> = item
|
||||||
|
.metadata
|
||||||
|
.get("gallery")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let activity_id = StoredValue::new(item.id.clone());
|
||||||
|
let lang = StoredValue::new(language);
|
||||||
|
let q_config = StoredValue::new(questionnaire_config);
|
||||||
|
|
||||||
|
// Server-function resource — resolves once after hydration.
|
||||||
|
let access = Resource::new(
|
||||||
|
|| (),
|
||||||
|
move |_| server_check_activity_access(activity_id.get_value()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// For the internal questionnaire state: whether to show the form.
|
||||||
|
let (show_questionnaire, set_show_questionnaire) = signal(false);
|
||||||
|
let (download_unlocked, set_download_unlocked) = signal(false);
|
||||||
|
|
||||||
|
// Lazy-load the questionnaire definition the first time the form is shown.
|
||||||
|
// The resource fires once `show_questionnaire` flips to `true`; subsequent
|
||||||
|
// `false → true` transitions reuse the cached result.
|
||||||
|
let questionnaire_def = Resource::new(
|
||||||
|
move || show_questionnaire.get(),
|
||||||
|
move |show| async move {
|
||||||
|
if !show {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match q_config.get_value() {
|
||||||
|
Some(name) => server_get_questionnaire_definition(name).await.ok(),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="space-y-8 mt-8">
|
||||||
|
|
||||||
|
// ── Slidev embed ───────────────────────────────────────────────
|
||||||
|
{slides_id.map(|sid| {
|
||||||
|
let src = format!("/slides/{}/", sid);
|
||||||
|
view! {
|
||||||
|
<section class="w-full">
|
||||||
|
<iframe
|
||||||
|
src=src
|
||||||
|
class="w-full rounded-lg border border-base-300 shadow-lg"
|
||||||
|
style="height: 60vh; min-height: 400px;"
|
||||||
|
allowfullscreen
|
||||||
|
title="Slidev presentation"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Download section ──────────────────────────────────────────
|
||||||
|
{if pdf_filename.is_some() && download_requires_auth {
|
||||||
|
let pdf_fn = pdf_filename.clone().unwrap_or_default();
|
||||||
|
let act_id = activity_id.get_value();
|
||||||
|
let q_type = questionnaire_type.clone();
|
||||||
|
let q_url = questionnaire_url.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<section class="p-6 rounded-lg border border-base-300 bg-base-200">
|
||||||
|
<Suspense fallback=move || view! { <div class="animate-pulse h-12 bg-base-300 rounded" /> }>
|
||||||
|
{move || {
|
||||||
|
let pdf = pdf_fn.clone();
|
||||||
|
let aid = act_id.clone();
|
||||||
|
let qt = q_type.clone();
|
||||||
|
let qu = q_url.clone();
|
||||||
|
let _lang_val = lang.get_value();
|
||||||
|
access.get().map(|result| {
|
||||||
|
let status: ActivityAccessStatus = result.unwrap_or(ActivityAccessStatus {
|
||||||
|
authenticated: false,
|
||||||
|
questionnaire_completed: false,
|
||||||
|
can_download: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let can_dl = status.can_download || download_unlocked.get();
|
||||||
|
|
||||||
|
if can_dl {
|
||||||
|
// Download available
|
||||||
|
let download_url = format!("/api/activities/download/{}?pdf={}", aid, pdf);
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<p class="text-success font-semibold">
|
||||||
|
"Your download is ready."
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href=download_url
|
||||||
|
class="ds-btn-primary"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
"Download PDF"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else if !status.authenticated {
|
||||||
|
// Not logged in
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
"Sign in to download the PDF for this activity."
|
||||||
|
</p>
|
||||||
|
<a href="/login" class="ds-btn-secondary">
|
||||||
|
"Sign in"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else if download_requires_questionnaire && !status.questionnaire_completed {
|
||||||
|
// Logged in, questionnaire pending
|
||||||
|
if qt == "internal" {
|
||||||
|
// Show inline form trigger
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
"Complete the evaluation to unlock the PDF download."
|
||||||
|
</p>
|
||||||
|
{move || {
|
||||||
|
if show_questionnaire.get() {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="ds-btn-primary"
|
||||||
|
on:click=move |_| set_show_questionnaire.set(true)
|
||||||
|
>
|
||||||
|
"Complete evaluation"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
// External questionnaire link
|
||||||
|
let ext_url = qu.unwrap_or_default();
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<p class="ds-text-secondary">
|
||||||
|
"Complete the evaluation to unlock the PDF download."
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href=ext_url
|
||||||
|
class="ds-btn-primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
"Complete evaluation"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Logged in, no questionnaire required
|
||||||
|
let download_url = format!("/api/activities/download/{}?pdf={}", aid, pdf);
|
||||||
|
view! {
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<a href=download_url class="ds-btn-primary" download>
|
||||||
|
"Download PDF"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
// Inline internal questionnaire
|
||||||
|
{move || {
|
||||||
|
if show_questionnaire.get() {
|
||||||
|
let aid2 = activity_id.get_value();
|
||||||
|
view! {
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-semibold ds-text mb-4">
|
||||||
|
"Activity Evaluation"
|
||||||
|
</h3>
|
||||||
|
<Suspense fallback=move || view! {
|
||||||
|
<div class="animate-pulse h-8 bg-base-300 rounded" />
|
||||||
|
}>
|
||||||
|
{move || questionnaire_def.get().flatten().map(|def| {
|
||||||
|
let aid3 = aid2.clone();
|
||||||
|
view! {
|
||||||
|
<QuestionnaireForm
|
||||||
|
definition=def
|
||||||
|
activity_id=aid3
|
||||||
|
on_submit_complete=Callback::new(move |_: ()| {
|
||||||
|
set_show_questionnaire.set(false);
|
||||||
|
set_download_unlocked.set(true);
|
||||||
|
})
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
} else if let Some(ref pdf) = pdf_filename {
|
||||||
|
// No auth required — direct download
|
||||||
|
let download_url = format!("/api/activities/download/{}?pdf={}", activity_id.get_value(), pdf);
|
||||||
|
view! {
|
||||||
|
<section class="flex justify-center">
|
||||||
|
<a href=download_url class="ds-btn-primary" download>
|
||||||
|
"Download PDF"
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// ── Repository link ────────────────────────────────────────────
|
||||||
|
{repository_url.map(|url| {
|
||||||
|
view! {
|
||||||
|
<section class="flex items-center gap-2">
|
||||||
|
<span class="font-semibold ds-text">"Source code:"</span>
|
||||||
|
<a href=url class="link link-primary" target="_blank" rel="noopener">
|
||||||
|
"View on GitHub"
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
// ── Gallery ────────────────────────────────────────────────────
|
||||||
|
{if !gallery.is_empty() {
|
||||||
|
view! {
|
||||||
|
<section>
|
||||||
|
<h3 class="text-lg font-semibold ds-text mb-4">"Gallery"</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{gallery.into_iter().enumerate().map(|(i, path)| {
|
||||||
|
view! {
|
||||||
|
<img
|
||||||
|
src=path
|
||||||
|
class="rounded-lg w-full h-48 object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
alt=format!("Activity photo {}", i + 1)
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span /> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
//! Activities feature — charlas, talleres, and Slidev presentations.
|
||||||
|
//!
|
||||||
|
//! This module provides:
|
||||||
|
//! - `ActivityAccessStatus` — download gating data transfer type
|
||||||
|
//! - `QuestionnaireDefinition` + `QuestionnaireForm` — internal questionnaire
|
||||||
|
//! types and Leptos renderer
|
||||||
|
//! - Server functions: `server_check_activity_access`,
|
||||||
|
//! `server_record_activity_completion`,
|
||||||
|
//! `server_submit_internal_questionnaire`,
|
||||||
|
//! `server_get_questionnaire_definition`
|
||||||
|
|
||||||
|
pub mod cta_view;
|
||||||
|
pub mod questionnaire;
|
||||||
|
pub mod server_fns;
|
||||||
|
|
||||||
|
pub use cta_view::ActivitiesCTAView;
|
||||||
|
pub use questionnaire::{
|
||||||
|
QuestionnaireDefinition, QuestionnaireElement, QuestionnaireForm, SelectOption,
|
||||||
|
};
|
||||||
|
pub use server_fns::{
|
||||||
|
server_check_activity_access, server_get_questionnaire_definition,
|
||||||
|
server_record_activity_completion, server_submit_internal_questionnaire, ActivityAccessStatus,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
//! Internal questionnaire types and Leptos form renderer.
|
||||||
|
//!
|
||||||
|
//! Types are designed to be wire-compatible with TypeDialog's `FormDefinition`
|
||||||
|
//! so a future integration can swap the parsing layer without changing the UI
|
||||||
|
//! component.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ── Data model ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A single field element in the questionnaire form.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum QuestionnaireElement {
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
#[serde(default)]
|
||||||
|
i18n: bool,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
name: String,
|
||||||
|
prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
i18n: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
required: bool,
|
||||||
|
},
|
||||||
|
Select {
|
||||||
|
name: String,
|
||||||
|
prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
i18n: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
required: bool,
|
||||||
|
options: Vec<SelectOption>,
|
||||||
|
},
|
||||||
|
Confirm {
|
||||||
|
name: String,
|
||||||
|
prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
i18n: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
required: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An option within a select element.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SelectOption {
|
||||||
|
pub value: String,
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete questionnaire form definition.
|
||||||
|
///
|
||||||
|
/// Wire-compatible with TypeDialog's `FormDefinition` for future integration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionnaireDefinition {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub i18n_prefix: String,
|
||||||
|
pub elements: Vec<QuestionnaireElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leptos renderer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Inline questionnaire form rendered in the activity CTA section.
|
||||||
|
///
|
||||||
|
/// Collects user answers and calls `on_submit` with `Vec<(key, value)>` pairs.
|
||||||
|
#[component]
|
||||||
|
pub fn QuestionnaireForm(
|
||||||
|
definition: QuestionnaireDefinition,
|
||||||
|
activity_id: String,
|
||||||
|
#[prop(into)] on_submit_complete: Callback<()>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let (answers, set_answers) = signal(std::collections::HashMap::<String, String>::new());
|
||||||
|
let (submitting, set_submitting) = signal(false);
|
||||||
|
let (error, set_error) = signal(Option::<String>::None);
|
||||||
|
|
||||||
|
let definition = StoredValue::new(definition);
|
||||||
|
let activity_id = StoredValue::new(activity_id);
|
||||||
|
|
||||||
|
let handle_submit = move |ev: web_sys::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
let current = answers.get();
|
||||||
|
let def = definition.get_value();
|
||||||
|
|
||||||
|
// Validate required fields.
|
||||||
|
let missing: Vec<String> = def
|
||||||
|
.elements
|
||||||
|
.iter()
|
||||||
|
.filter_map(|el| match el {
|
||||||
|
QuestionnaireElement::Text {
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| QuestionnaireElement::Select {
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| QuestionnaireElement::Confirm {
|
||||||
|
name,
|
||||||
|
required: true,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let filled = current.get(name).map_or(false, |v| !v.is_empty());
|
||||||
|
if filled {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !missing.is_empty() {
|
||||||
|
set_error.set(Some(format!(
|
||||||
|
"Please answer all required questions: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_error.set(None);
|
||||||
|
set_submitting.set(true);
|
||||||
|
|
||||||
|
let responses: Vec<(String, String)> = current.into_iter().collect();
|
||||||
|
let aid = activity_id.get_value();
|
||||||
|
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
match super::server_fns::server_submit_internal_questionnaire(aid, responses).await {
|
||||||
|
Ok(()) => {
|
||||||
|
set_submitting.set(false);
|
||||||
|
on_submit_complete.run(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
set_submitting.set(false);
|
||||||
|
set_error.set(Some(e.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form
|
||||||
|
class="space-y-4"
|
||||||
|
on:submit=handle_submit
|
||||||
|
>
|
||||||
|
{move || definition.get_value().elements.into_iter().map(|el| {
|
||||||
|
render_element(el, answers, set_answers)
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
|
||||||
|
{move || error.get().map(|msg| view! {
|
||||||
|
<p class="text-error text-sm">{msg}</p>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="ds-btn-primary w-full"
|
||||||
|
disabled=move || submitting.get()
|
||||||
|
>
|
||||||
|
{move || if submitting.get() { "Submitting…" } else { "Submit Evaluation" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_element(
|
||||||
|
el: QuestionnaireElement,
|
||||||
|
answers: ReadSignal<std::collections::HashMap<String, String>>,
|
||||||
|
set_answers: WriteSignal<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
match el {
|
||||||
|
QuestionnaireElement::Header { content, .. } => view! {
|
||||||
|
<h4 class="text-lg font-semibold ds-text mt-4">{content}</h4>
|
||||||
|
}
|
||||||
|
.into_any(),
|
||||||
|
|
||||||
|
QuestionnaireElement::Text { name, prompt, .. } => {
|
||||||
|
let name_clone = name.clone();
|
||||||
|
let name_attr = name.clone();
|
||||||
|
let name_id = format!("q-{name_attr}");
|
||||||
|
view! {
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text ds-text">{prompt}</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id=name_id
|
||||||
|
name=name_attr
|
||||||
|
class="textarea textarea-bordered w-full"
|
||||||
|
rows="3"
|
||||||
|
on:input=move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_answers.update(|m| { m.insert(name_clone.clone(), val); });
|
||||||
|
}
|
||||||
|
prop:value=move || {
|
||||||
|
answers.with(|m| m.get(&name).cloned().unwrap_or_default())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
QuestionnaireElement::Select {
|
||||||
|
name,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let name_clone = name.clone();
|
||||||
|
let name_attr = name.clone();
|
||||||
|
let name_id = format!("q-{name_attr}");
|
||||||
|
view! {
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text ds-text">{prompt}</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id=name_id
|
||||||
|
name=name_attr
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let val = event_target_value(&ev);
|
||||||
|
set_answers.update(|m| { m.insert(name_clone.clone(), val); });
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="" disabled selected>"— select —"</option>
|
||||||
|
{options.into_iter().map(|opt| {
|
||||||
|
let opt_val = opt.value.clone();
|
||||||
|
view! {
|
||||||
|
<option value={opt.value}>{opt_val} " - " {opt.label}</option>
|
||||||
|
}
|
||||||
|
}).collect::<Vec<_>>()}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
QuestionnaireElement::Confirm { name, prompt, .. } => {
|
||||||
|
let name_clone = name.clone();
|
||||||
|
let name_attr = name.clone();
|
||||||
|
let name_id = format!("q-{name_attr}");
|
||||||
|
view! {
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id=name_id
|
||||||
|
name=name_attr
|
||||||
|
class="checkbox"
|
||||||
|
on:change=move |ev| {
|
||||||
|
let checked = event_target_checked(&ev);
|
||||||
|
let val = if checked { "true" } else { "false" };
|
||||||
|
set_answers.update(|m| { m.insert(name_clone.clone(), val.to_string()); });
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span class="label-text ds-text">{prompt}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
//! Leptos server functions for the activities feature.
|
||||||
|
//!
|
||||||
|
//! All functions use the `OtpServiceExtension` pattern so they remain
|
||||||
|
//! decoupled from `rustelo_server` — the same bridge used by auth, bookmarks,
|
||||||
|
//! and user-dashboard features.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Access status for a gated activity download.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ActivityAccessStatus {
|
||||||
|
/// Is the request coming from an authenticated session?
|
||||||
|
pub authenticated: bool,
|
||||||
|
/// Did the user already complete the required questionnaire?
|
||||||
|
pub questionnaire_completed: bool,
|
||||||
|
/// Combined: `authenticated && questionnaire_completed` (or download is
|
||||||
|
/// not gated at all — callers should check `require_auth` from content
|
||||||
|
/// metadata).
|
||||||
|
pub can_download: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the current session can download the PDF for `activity_id`.
|
||||||
|
///
|
||||||
|
/// Returns `ActivityAccessStatus` instead of a hard error so WASM can render
|
||||||
|
/// the right UI state without a round-trip failure.
|
||||||
|
#[server(CheckActivityAccess, "/api")]
|
||||||
|
pub async fn server_check_activity_access(
|
||||||
|
activity_id: String,
|
||||||
|
) -> Result<ActivityAccessStatus, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers: axum::http::HeaderMap = extract()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("Could not extract headers"))?;
|
||||||
|
|
||||||
|
let session_token = headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies
|
||||||
|
.split(';')
|
||||||
|
.find_map(|c| c.trim().strip_prefix("session_token=").map(str::to_string))
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(token) = session_token else {
|
||||||
|
return Ok(ActivityAccessStatus {
|
||||||
|
authenticated: false,
|
||||||
|
questionnaire_completed: false,
|
||||||
|
can_download: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let completed = svc
|
||||||
|
.check_activity_completion(&token, activity_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
return Ok(ActivityAccessStatus {
|
||||||
|
authenticated: true,
|
||||||
|
questionnaire_completed: completed,
|
||||||
|
can_download: completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Ok(ActivityAccessStatus {
|
||||||
|
authenticated: false,
|
||||||
|
questionnaire_completed: false,
|
||||||
|
can_download: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record completion of an external questionnaire callback.
|
||||||
|
///
|
||||||
|
/// Called after the HMAC callback has been verified server-side and the
|
||||||
|
/// completion recorded in the DB. The WASM side calls this to keep the
|
||||||
|
/// reactive state in sync.
|
||||||
|
#[server(RecordActivityCompletion, "/api")]
|
||||||
|
pub async fn server_record_activity_completion(activity_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers: axum::http::HeaderMap = extract()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("Could not extract headers"))?;
|
||||||
|
|
||||||
|
let token = headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies
|
||||||
|
.split(';')
|
||||||
|
.find_map(|c| c.trim().strip_prefix("session_token=").map(str::to_string))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.record_activity_completion(&token, activity_id, "external".to_string())
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the questionnaire definition for an internal questionnaire.
|
||||||
|
///
|
||||||
|
/// Reads `{SITE_CONTENT_PATH}/config/questionnaires/{config_name}.json` from
|
||||||
|
/// disk and deserialises it into a `QuestionnaireDefinition`. The config name
|
||||||
|
/// is validated (alphanumeric + `-` + `_` only) to prevent path traversal.
|
||||||
|
#[server(GetQuestionnaireDefinition, "/api")]
|
||||||
|
pub async fn server_get_questionnaire_definition(
|
||||||
|
config_name: String,
|
||||||
|
) -> Result<super::questionnaire::QuestionnaireDefinition, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
if config_name.is_empty()
|
||||||
|
|| !config_name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
return Err(ServerFnError::new(
|
||||||
|
"Invalid questionnaire config name: only alphanumeric, '-', '_' allowed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_path =
|
||||||
|
std::env::var("SITE_CONTENT_PATH").unwrap_or_else(|_| "site".to_string());
|
||||||
|
|
||||||
|
let path = std::path::PathBuf::from(&content_path)
|
||||||
|
.join("config")
|
||||||
|
.join("questionnaires")
|
||||||
|
.join(format!("{config_name}.json"));
|
||||||
|
|
||||||
|
// Canonical-path check: prevent any `..` escape after resolution.
|
||||||
|
let canonical_base = std::path::PathBuf::from(&content_path)
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|_| ServerFnError::new("Invalid SITE_CONTENT_PATH"))?;
|
||||||
|
|
||||||
|
let canonical_path = path
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|_| ServerFnError::new("Questionnaire config not found"))?;
|
||||||
|
|
||||||
|
if !canonical_path.starts_with(&canonical_base) {
|
||||||
|
return Err(ServerFnError::new("Path traversal detected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&canonical_path)
|
||||||
|
.map_err(|_| ServerFnError::new("Questionnaire config not found"))?;
|
||||||
|
|
||||||
|
return serde_json::from_str(&json)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Invalid questionnaire JSON: {e}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR only"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit an internal questionnaire.
|
||||||
|
///
|
||||||
|
/// Validates that responses is non-empty and that all keys are alphanumeric,
|
||||||
|
/// then delegates to the OTP service which records the completion + responses.
|
||||||
|
#[server(SubmitInternalQuestionnaire, "/api")]
|
||||||
|
pub async fn server_submit_internal_questionnaire(
|
||||||
|
activity_id: String,
|
||||||
|
responses: Vec<(String, String)>,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
if responses.is_empty() {
|
||||||
|
return Err(ServerFnError::new(
|
||||||
|
"Questionnaire must have at least one response",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate question keys to prevent injection.
|
||||||
|
for (key, _) in &responses {
|
||||||
|
if !key
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
||||||
|
{
|
||||||
|
return Err(ServerFnError::new(format!(
|
||||||
|
"Invalid question key '{key}': only alphanumeric, '_', '-' allowed"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers: axum::http::HeaderMap = extract()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("Could not extract headers"))?;
|
||||||
|
|
||||||
|
let token = headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies
|
||||||
|
.split(';')
|
||||||
|
.find_map(|c| c.trim().strip_prefix("session_token=").map(str::to_string))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.submit_internal_questionnaire(&token, activity_id, responses)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use rustelo_core_lib::{i18n::UnifiedI18n, state::use_current_language};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
otp_service::OtpVerifyResult,
|
||||||
|
server_fns::{server_request_otp, server_verify_otp},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESEND_COOLDOWN_SECS: u32 = 60;
|
||||||
|
|
||||||
|
/// Async sleep for WASM using gloo-timers.
|
||||||
|
async fn sleep_secs(secs: u32) {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
gloo_timers::future::sleep(std::time::Duration::from_secs(u64::from(secs))).await;
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let _ = secs; // no-op in SSR context (this code path is never reached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Second step of the OTP login flow: verify the 6-digit code.
|
||||||
|
#[component]
|
||||||
|
pub fn CodeStep(
|
||||||
|
email: String,
|
||||||
|
on_success: Callback<OtpVerifyResult>,
|
||||||
|
on_back: Callback<()>,
|
||||||
|
error_msg: RwSignal<Option<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = use_current_language();
|
||||||
|
let code = RwSignal::new(String::new());
|
||||||
|
let loading = RwSignal::new(false);
|
||||||
|
let countdown = RwSignal::new(RESEND_COOLDOWN_SECS);
|
||||||
|
let resend_available = RwSignal::new(false);
|
||||||
|
|
||||||
|
let title = Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-check-inbox"));
|
||||||
|
let placeholder =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-code-placeholder"));
|
||||||
|
let continue_label =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-continue"));
|
||||||
|
let back_label =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-back-to-email"));
|
||||||
|
|
||||||
|
let email_for_subtitle = email.clone();
|
||||||
|
let subtitle = Memo::new(move |_| {
|
||||||
|
UnifiedI18n::new(&lang.get(), "/")
|
||||||
|
.t("auth-otp-sent-to")
|
||||||
|
.replace("{ $email }", &email_for_subtitle)
|
||||||
|
.replace("$email", &email_for_subtitle)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the resend countdown immediately on mount.
|
||||||
|
let countdown_effect_email = email.clone();
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let _ = countdown_effect_email.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
let mut remaining = RESEND_COOLDOWN_SECS;
|
||||||
|
while remaining > 0 {
|
||||||
|
sleep_secs(1).await;
|
||||||
|
remaining = remaining.saturating_sub(1);
|
||||||
|
countdown.set(remaining);
|
||||||
|
}
|
||||||
|
resend_available.set(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let resend_email = email.clone();
|
||||||
|
|
||||||
|
let on_resend = move |_| {
|
||||||
|
if !resend_available.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let e = resend_email.clone();
|
||||||
|
let resend_lang = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
lang.get()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
lang.get_untracked()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
resend_available.set(false);
|
||||||
|
countdown.set(RESEND_COOLDOWN_SECS);
|
||||||
|
error_msg.set(None);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
if let Err(err) = server_request_otp(e, resend_lang).await {
|
||||||
|
error_msg.set(Some(err.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart countdown.
|
||||||
|
let mut remaining = RESEND_COOLDOWN_SECS;
|
||||||
|
while remaining > 0 {
|
||||||
|
sleep_secs(1).await;
|
||||||
|
remaining = remaining.saturating_sub(1);
|
||||||
|
countdown.set(remaining);
|
||||||
|
}
|
||||||
|
resend_available.set(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let captured_email = email.clone();
|
||||||
|
|
||||||
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
let e = captured_email.clone();
|
||||||
|
let c = code.get();
|
||||||
|
loading.set(true);
|
||||||
|
error_msg.set(None);
|
||||||
|
spawn_local(async move {
|
||||||
|
match server_verify_otp(e, c).await {
|
||||||
|
Ok(result) => on_success.run(result),
|
||||||
|
Err(err) => error_msg.set(Some(err.to_string())),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit class="flex flex-col gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">{title}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{subtitle}</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="auth-otp-code"
|
||||||
|
name="otp"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
aria-label="Verification code"
|
||||||
|
placeholder=placeholder
|
||||||
|
maxlength="6"
|
||||||
|
class="input input-bordered w-full text-center tracking-widest text-xl"
|
||||||
|
prop:value=code
|
||||||
|
on:input=move |ev| code.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
prop:disabled=loading
|
||||||
|
>
|
||||||
|
{move || if loading.get() { "…".to_string() } else { continue_label.get() }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Resend button with countdown
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm w-full border border-base-content/20 text-base-content/60 bg-transparent hover:bg-base-content/5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
prop:disabled=move || !resend_available.get()
|
||||||
|
on:click=on_resend
|
||||||
|
>
|
||||||
|
{move || {
|
||||||
|
let lang_val = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{ lang.get() }
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ lang.get_untracked() }
|
||||||
|
};
|
||||||
|
let i18n = UnifiedI18n::new(&lang_val, "/");
|
||||||
|
if resend_available.get() {
|
||||||
|
i18n.t("auth-otp-resend-now")
|
||||||
|
} else {
|
||||||
|
i18n.t("auth-otp-resend-countdown")
|
||||||
|
.replace("{ $seconds }", &countdown.get().to_string())
|
||||||
|
.replace("$seconds", &countdown.get().to_string())
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Back link
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs dark:text-gray-400 text-base-content/50 mt-1"
|
||||||
|
on:click=move |_| on_back.run(())
|
||||||
|
>
|
||||||
|
{back_label}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{move || error_msg.get().map(|e| view! {
|
||||||
|
<p class="text-error text-sm">{e}</p>
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use rustelo_core_lib::{i18n::UnifiedI18n, state::use_current_language};
|
||||||
|
|
||||||
|
use super::server_fns::server_request_otp;
|
||||||
|
|
||||||
|
/// First step of the OTP login flow: collect the user's email address.
|
||||||
|
#[component]
|
||||||
|
pub fn EmailStep(
|
||||||
|
on_success: Callback<String>,
|
||||||
|
error_msg: RwSignal<Option<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = use_current_language();
|
||||||
|
let email = RwSignal::new(String::new());
|
||||||
|
let loading = RwSignal::new(false);
|
||||||
|
|
||||||
|
let title = Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-modal-title"));
|
||||||
|
let subtitle =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-modal-subtitle"));
|
||||||
|
let continue_label =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-otp-continue"));
|
||||||
|
|
||||||
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
let current_email = email.get();
|
||||||
|
let current_lang = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
lang.get()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
lang.get_untracked()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loading.set(true);
|
||||||
|
error_msg.set(None);
|
||||||
|
spawn_local(async move {
|
||||||
|
match server_request_otp(current_email.clone(), current_lang).await {
|
||||||
|
Ok(()) => on_success.run(current_email),
|
||||||
|
Err(e) => error_msg.set(Some(e.to_string())),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit novalidate class="flex flex-col gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">{title}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{subtitle}</p>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="auth-email"
|
||||||
|
name="email"
|
||||||
|
autocomplete="email"
|
||||||
|
aria-label="Email address"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
prop:value=email
|
||||||
|
on:input=move |ev| email.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
prop:disabled=loading
|
||||||
|
>
|
||||||
|
{move || if loading.get() { "…".to_string() } else { continue_label.get() }}
|
||||||
|
</button>
|
||||||
|
{move || error_msg.get().map(|e| view! {
|
||||||
|
<p class="text-error text-sm">{e}</p>
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
use rustelo_core_lib::{
|
||||||
|
i18n::UnifiedI18n,
|
||||||
|
state::{use_current_language, UserInfo},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::server_fns::server_accept_gdpr;
|
||||||
|
|
||||||
|
/// Third step of the OTP login flow for new accounts: GDPR consent collection.
|
||||||
|
#[component]
|
||||||
|
pub fn GdprStep(
|
||||||
|
pending_token: String,
|
||||||
|
on_success: Callback<UserInfo>,
|
||||||
|
error_msg: RwSignal<Option<String>>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let lang = use_current_language();
|
||||||
|
let loading = RwSignal::new(false);
|
||||||
|
let accept_required = RwSignal::new(false);
|
||||||
|
let accept_marketing = RwSignal::new(false);
|
||||||
|
|
||||||
|
let title = Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-gdpr-title"));
|
||||||
|
let description =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-gdpr-description"));
|
||||||
|
let required_label =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-gdpr-accept-required"));
|
||||||
|
let marketing_label =
|
||||||
|
Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-gdpr-accept-marketing"));
|
||||||
|
let submit_label = Memo::new(move |_| UnifiedI18n::new(&lang.get(), "/").t("auth-gdpr-submit"));
|
||||||
|
|
||||||
|
let token = pending_token.clone();
|
||||||
|
|
||||||
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
|
||||||
|
if !accept_required.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = token.clone();
|
||||||
|
loading.set(true);
|
||||||
|
error_msg.set(None);
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
match server_accept_gdpr(t).await {
|
||||||
|
Ok(user_info) => on_success.run(user_info),
|
||||||
|
Err(err) => error_msg.set(Some(err.to_string())),
|
||||||
|
}
|
||||||
|
loading.set(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<form on:submit=submit class="flex flex-col gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-base-content">{title}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">{description}</p>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="gdpr-accept-required"
|
||||||
|
name="gdpr_required"
|
||||||
|
class="w-4 h-4 mt-1 shrink-0 cursor-pointer rounded"
|
||||||
|
style="accent-color: hsl(var(--p));"
|
||||||
|
prop:checked=accept_required
|
||||||
|
on:change=move |ev| accept_required.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-base-content" inner_html=required_label />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="gdpr-accept-marketing"
|
||||||
|
name="gdpr_marketing"
|
||||||
|
class="w-4 h-4 mt-1 shrink-0 cursor-pointer rounded"
|
||||||
|
style="accent-color: hsl(var(--p));"
|
||||||
|
prop:checked=accept_marketing
|
||||||
|
on:change=move |ev| accept_marketing.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-base-content/70">{marketing_label}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
prop:disabled=move || loading.get() || !accept_required.get()
|
||||||
|
>
|
||||||
|
{move || if loading.get() { "…".to_string() } else { submit_label.get() }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{move || error_msg.get().map(|e| view! {
|
||||||
|
<p class="text-error text-sm">{e}</p>
|
||||||
|
})}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use rustelo_core_lib::state::UserInfo;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use super::otp_service::OtpVerifyResult;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use super::{code_step::CodeStep, email_step::EmailStep, gdpr_step::GdprStep};
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
enum LoginStep {
|
||||||
|
Email,
|
||||||
|
Code { email: String },
|
||||||
|
Gdpr { pending_token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modal overlay for passwordless OTP login.
|
||||||
|
/// Renders nothing in SSR — only active in WASM.
|
||||||
|
#[component]
|
||||||
|
pub fn LoginModal(show: ReadSignal<bool>, set_show: WriteSignal<bool>) -> impl IntoView {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let lang = rustelo_core_lib::state::use_current_language();
|
||||||
|
let close_label = Memo::new(move |_| {
|
||||||
|
rustelo_core_lib::i18n::UnifiedI18n::new(&lang.get(), "/").t("auth-modal-close")
|
||||||
|
});
|
||||||
|
|
||||||
|
let step = RwSignal::new(LoginStep::Email);
|
||||||
|
let error_msg: RwSignal<Option<String>> = RwSignal::new(None);
|
||||||
|
let auth_state = use_context::<rustelo_core_lib::state::AuthState>();
|
||||||
|
|
||||||
|
// Lock body scroll while login modal is open
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(body) = web_sys::window()
|
||||||
|
.and_then(|w| w.document())
|
||||||
|
.and_then(|d| d.body())
|
||||||
|
{
|
||||||
|
let _ = if show.get() {
|
||||||
|
body.style().set_property("overflow", "hidden")
|
||||||
|
} else {
|
||||||
|
body.style().remove_property("overflow").map(|_| ())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_email_success = Callback::new(move |email: String| {
|
||||||
|
step.set(LoginStep::Code { email });
|
||||||
|
error_msg.set(None);
|
||||||
|
});
|
||||||
|
|
||||||
|
let set_path = use_context::<WriteSignal<String>>();
|
||||||
|
|
||||||
|
let finalize_login = {
|
||||||
|
let auth_state = auth_state.clone();
|
||||||
|
let set_path = set_path;
|
||||||
|
move |user: UserInfo| {
|
||||||
|
if let Some(auth) = auth_state.clone() {
|
||||||
|
auth.set_user(user);
|
||||||
|
}
|
||||||
|
set_show.set(false);
|
||||||
|
step.set(LoginStep::Email);
|
||||||
|
|
||||||
|
let return_url = web_sys::window()
|
||||||
|
.and_then(|w| w.location().search().ok())
|
||||||
|
.and_then(|q| {
|
||||||
|
q.trim_start_matches('?')
|
||||||
|
.split('&')
|
||||||
|
.find_map(|pair| {
|
||||||
|
let (k, v) = pair.split_once('=')?;
|
||||||
|
if k == "return" {
|
||||||
|
js_sys::decode_uri_component(v).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|s| s.as_string())
|
||||||
|
.flatten()
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(url) = return_url {
|
||||||
|
if let Some(sp) = set_path {
|
||||||
|
sp.set(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_code_success = {
|
||||||
|
let finalize = finalize_login.clone();
|
||||||
|
Callback::new(move |result: OtpVerifyResult| match result {
|
||||||
|
OtpVerifyResult::Authenticated { user, .. } => finalize(user),
|
||||||
|
OtpVerifyResult::NeedsConsent { pending_token } => {
|
||||||
|
step.set(LoginStep::Gdpr { pending_token });
|
||||||
|
error_msg.set(None);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_gdpr_success = Callback::new(move |user: UserInfo| {
|
||||||
|
finalize_login(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
let on_back = Callback::new(move |_: ()| {
|
||||||
|
step.set(LoginStep::Email);
|
||||||
|
error_msg.set(None);
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || show.get()>
|
||||||
|
// Blocking backdrop — covers full viewport, click outside closes modal
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[80] bg-black opacity-75 backdrop-blur-sm"
|
||||||
|
on:click=move |_| set_show.set(false)
|
||||||
|
/>
|
||||||
|
// Modal panel — above the backdrop, below cookie banner (z-[90]/z-[100])
|
||||||
|
<div class="fixed inset-0 z-[85] flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-md bg-base-100 rounded-2xl shadow-xl p-8"
|
||||||
|
on:click=|ev| ev.stop_propagation()
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 btn btn-ghost btn-sm btn-circle text-base-content"
|
||||||
|
aria-label=move || close_label.get()
|
||||||
|
title=move || close_label.get()
|
||||||
|
on:click=move |_| {
|
||||||
|
set_show.set(false);
|
||||||
|
step.set(LoginStep::Email);
|
||||||
|
}
|
||||||
|
>"✕"</button>
|
||||||
|
{move || match step.get() {
|
||||||
|
LoginStep::Email => view! {
|
||||||
|
<EmailStep
|
||||||
|
on_success=on_email_success
|
||||||
|
error_msg=error_msg
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
LoginStep::Code { email } => view! {
|
||||||
|
<CodeStep
|
||||||
|
email=email
|
||||||
|
on_success=on_code_success
|
||||||
|
on_back=on_back
|
||||||
|
error_msg=error_msg
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
LoginStep::Gdpr { pending_token } => view! {
|
||||||
|
<GdprStep
|
||||||
|
pending_token=pending_token
|
||||||
|
on_success=on_gdpr_success
|
||||||
|
error_msg=error_msg
|
||||||
|
/>
|
||||||
|
}.into_any(),
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR: emit one DynChild `<!--></-->` pair to match the `<Show>` DynChild
|
||||||
|
// markers that WASM emits on initial render (show=false → empty content).
|
||||||
|
// Without this, the hydration cursor desynchronises at this position.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let _ = (show, set_show);
|
||||||
|
view! { {move || ()} }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
pub mod code_step;
|
||||||
|
pub mod email_step;
|
||||||
|
pub mod gdpr_step;
|
||||||
|
pub mod login_modal;
|
||||||
|
pub mod otp_service;
|
||||||
|
pub mod server_fns;
|
||||||
|
|
||||||
|
pub use gdpr_step::GdprStep;
|
||||||
|
pub use login_modal::LoginModal;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use otp_service::server::{OtpServiceExtension, OtpServiceFacade};
|
||||||
|
pub use otp_service::OtpVerifyResult;
|
||||||
|
pub use server_fns::{
|
||||||
|
server_accept_gdpr, server_add_bookmark, server_create_note, server_create_service_token,
|
||||||
|
server_delete_account, server_delete_message, server_delete_note, server_get_bookmarks,
|
||||||
|
server_get_messages, server_get_notes, server_get_resources, server_get_session_user,
|
||||||
|
server_is_bookmarked, server_list_service_tokens, server_logout, server_logout_all,
|
||||||
|
server_mark_message_read, server_refresh_token, server_remove_bookmark, server_request_otp,
|
||||||
|
server_revoke_service_token, server_update_display_name, server_update_note, server_verify_otp,
|
||||||
|
BookmarkInfo, CreatedServiceToken, ServiceTokenInfo, UserMessage, UserNote, UserResource,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
/// Serializable result of a successful OTP verification — available on both
|
||||||
|
/// SSR and WASM so Leptos server functions can return it directly.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum OtpVerifyResult {
|
||||||
|
/// The email belongs to an existing account; session is ready.
|
||||||
|
Authenticated {
|
||||||
|
user: rustelo_core_lib::state::UserInfo,
|
||||||
|
session_token: String,
|
||||||
|
},
|
||||||
|
/// The email is new; GDPR consent must be obtained before account creation.
|
||||||
|
NeedsConsent { pending_token: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OTP service facade for use from Leptos server functions.
|
||||||
|
///
|
||||||
|
/// Defined in `rustelo_components_leptos` so the UI auth components can call
|
||||||
|
/// OTP operations without a direct dependency on `rustelo_server`. The
|
||||||
|
/// concrete implementation lives in `rustelo_server` and is registered as an
|
||||||
|
/// Axum `Extension` at startup.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod server {
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use super::OtpVerifyResult;
|
||||||
|
|
||||||
|
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||||
|
|
||||||
|
use crate::auth::server_fns::{
|
||||||
|
BookmarkInfo, CreatedServiceToken, ServiceTokenInfo, UserMessage, UserNote, UserResource,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Abstraction over the OTP backend, injected by the consumer at startup.
|
||||||
|
pub trait OtpServiceFacade: Send + Sync {
|
||||||
|
fn request_otp(&self, email: &str, lang: &str) -> BoxFuture<Result<(), String>>;
|
||||||
|
fn verify_otp(&self, email: &str, code: &str)
|
||||||
|
-> BoxFuture<Result<OtpVerifyResult, String>>;
|
||||||
|
fn accept_gdpr(&self, pending_token: &str) -> BoxFuture<Result<OtpVerifyResult, String>>;
|
||||||
|
fn logout_session(&self, session_token: &str) -> BoxFuture<Result<(), String>>;
|
||||||
|
fn get_bookmarks(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
) -> BoxFuture<Result<Vec<BookmarkInfo>, String>>;
|
||||||
|
fn add_bookmark(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
content_type: String,
|
||||||
|
content_id: String,
|
||||||
|
content_title: Option<String>,
|
||||||
|
content_url: Option<String>,
|
||||||
|
) -> BoxFuture<Result<String, String>>;
|
||||||
|
fn remove_bookmark(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
bookmark_id: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
fn is_bookmarked(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
content_id: String,
|
||||||
|
content_type: String,
|
||||||
|
) -> BoxFuture<Result<bool, String>>;
|
||||||
|
fn delete_account(&self, session_token: &str) -> BoxFuture<Result<(), String>>;
|
||||||
|
fn update_display_name(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
name: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
/// Rotate the session: verify the existing access token, issue a fresh
|
||||||
|
/// one, and return the new raw JWT string so the caller can set
|
||||||
|
/// a new cookie.
|
||||||
|
fn refresh_session(&self, session_token: &str) -> BoxFuture<Result<String, String>>;
|
||||||
|
|
||||||
|
/// Invalidate all active sessions for the user identified by
|
||||||
|
/// `session_token`.
|
||||||
|
fn logout_all_sessions(&self, session_token: &str) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
/// Resolve a session token to the owning user UUID without a DB
|
||||||
|
/// round-trip.
|
||||||
|
fn get_user_id_from_token(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
) -> BoxFuture<Result<uuid::Uuid, String>>;
|
||||||
|
|
||||||
|
/// Resolve a valid session token to the full `UserInfo` record.
|
||||||
|
///
|
||||||
|
/// Validates the JWT and performs one DB lookup to fetch display name
|
||||||
|
/// and avatar. Returns `Err` if the token is invalid or the user no
|
||||||
|
/// longer exists. Used by the WASM session-restoration path that runs
|
||||||
|
/// on every new tab open.
|
||||||
|
fn get_session_user(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
) -> BoxFuture<Result<rustelo_core_lib::state::UserInfo, String>>;
|
||||||
|
|
||||||
|
fn get_messages(&self, session_token: &str) -> BoxFuture<Result<Vec<UserMessage>, String>>;
|
||||||
|
|
||||||
|
fn mark_message_read(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
message_id: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
fn get_notes(&self, session_token: &str) -> BoxFuture<Result<Vec<UserNote>, String>>;
|
||||||
|
|
||||||
|
fn create_note(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> BoxFuture<Result<String, String>>;
|
||||||
|
|
||||||
|
fn update_note(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
note_id: String,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
fn delete_note(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
note_id: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
fn get_resources(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
) -> BoxFuture<Result<Vec<UserResource>, String>>;
|
||||||
|
|
||||||
|
// ── Service token management (admin only) ─────────────────────────────
|
||||||
|
|
||||||
|
fn delete_message(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
message_id: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
fn list_service_tokens(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
) -> BoxFuture<Result<Vec<ServiceTokenInfo>, String>>;
|
||||||
|
|
||||||
|
fn create_service_token(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
name: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
expires_days: Option<u32>,
|
||||||
|
) -> BoxFuture<Result<CreatedServiceToken, String>>;
|
||||||
|
|
||||||
|
fn revoke_service_token(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
token_id: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
// ── Activity completions ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Return `true` if the authenticated user already completed the
|
||||||
|
/// questionnaire for `activity_id`.
|
||||||
|
fn check_activity_completion(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
activity_id: String,
|
||||||
|
) -> BoxFuture<Result<bool, String>>;
|
||||||
|
|
||||||
|
/// Record that the authenticated user completed the questionnaire.
|
||||||
|
fn record_activity_completion(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
activity_id: String,
|
||||||
|
questionnaire_type: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
/// Submit responses for an internal questionnaire and record
|
||||||
|
/// completion.
|
||||||
|
fn submit_internal_questionnaire(
|
||||||
|
&self,
|
||||||
|
session_token: &str,
|
||||||
|
activity_id: String,
|
||||||
|
responses: Vec<(String, String)>,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
|
||||||
|
/// Record completion using a raw user UUID string (from HMAC callback).
|
||||||
|
///
|
||||||
|
/// Used by the Axum callback endpoint where the identity is proven by
|
||||||
|
/// a signed token rather than a live session cookie.
|
||||||
|
fn record_activity_completion_by_user_id(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
activity_id: String,
|
||||||
|
questionnaire_type: &str,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum `Extension` wrapper so
|
||||||
|
/// `extract::<Extension<OtpServiceExtension>>()` works inside
|
||||||
|
/// `#[server]` functions.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct OtpServiceExtension(pub Arc<dyn OtpServiceFacade>);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,923 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
use super::otp_service::OtpVerifyResult;
|
||||||
|
|
||||||
|
/// Request an OTP code to be sent to the given email address.
|
||||||
|
///
|
||||||
|
/// `lang` is the UI language code (`"en"`, `"es"`, …) used to select the
|
||||||
|
/// email template language. Defaults to `"en"` on the server side if empty.
|
||||||
|
#[server(RequestOtp, "/api")]
|
||||||
|
pub async fn server_request_otp(email: String, lang: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
ServerFnError::new(
|
||||||
|
"OTP service not configured — register OtpServiceExtension at startup",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let lang_str = if lang.is_empty() { "en" } else { &lang };
|
||||||
|
svc.request_otp(&email, lang_str)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an OTP code.
|
||||||
|
///
|
||||||
|
/// Returns `OtpVerifyResult::Authenticated` (sets session cookie) for existing
|
||||||
|
/// accounts, or `OtpVerifyResult::NeedsConsent` for new emails.
|
||||||
|
#[server(VerifyOtp, "/api")]
|
||||||
|
pub async fn server_verify_otp(
|
||||||
|
email: String,
|
||||||
|
code: String,
|
||||||
|
) -> Result<OtpVerifyResult, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
ServerFnError::new(
|
||||||
|
"OTP service not configured — register OtpServiceExtension at startup",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = svc
|
||||||
|
.verify_otp(&email, &code)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
// Only set the session cookie for authenticated users.
|
||||||
|
if let OtpVerifyResult::Authenticated {
|
||||||
|
ref session_token, ..
|
||||||
|
} = result
|
||||||
|
{
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
let cookie_value = format!(
|
||||||
|
"session_token={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400",
|
||||||
|
session_token
|
||||||
|
);
|
||||||
|
if let Ok(hv) = header::HeaderValue::from_str(&cookie_value) {
|
||||||
|
response.insert_header(header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept GDPR consent for a new account.
|
||||||
|
///
|
||||||
|
/// Validates the `pending_token`, creates the user, sets session cookie.
|
||||||
|
#[server(AcceptGdpr, "/api")]
|
||||||
|
pub async fn server_accept_gdpr(
|
||||||
|
pending_token: String,
|
||||||
|
) -> Result<rustelo_core_lib::state::UserInfo, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let result = svc
|
||||||
|
.accept_gdpr(&pending_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
OtpVerifyResult::Authenticated {
|
||||||
|
ref user,
|
||||||
|
ref session_token,
|
||||||
|
} => {
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
let cookie_value = format!(
|
||||||
|
"session_token={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400",
|
||||||
|
session_token
|
||||||
|
);
|
||||||
|
if let Ok(hv) = header::HeaderValue::from_str(&cookie_value) {
|
||||||
|
response.insert_header(header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(user.clone());
|
||||||
|
}
|
||||||
|
OtpVerifyResult::NeedsConsent { .. } => {
|
||||||
|
return Err(ServerFnError::new(
|
||||||
|
"Unexpected NeedsConsent after GDPR accept",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-side logout: invalidate session cookie and remove from store.
|
||||||
|
#[server(Logout, "/api")]
|
||||||
|
pub async fn server_logout() -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
// Extract the session_token cookie from the incoming request.
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
|
||||||
|
let session_token = headers
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies.split(';').find_map(|part| {
|
||||||
|
let part = part.trim();
|
||||||
|
part.strip_prefix("session_token=").map(str::to_string)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(token) = session_token {
|
||||||
|
// Best-effort: do not abort if the session is already gone.
|
||||||
|
let _ = svc.logout_session(&token).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expire the cookie on the client.
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
if let Ok(hv) = header::HeaderValue::from_str(
|
||||||
|
"session_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0",
|
||||||
|
) {
|
||||||
|
response.insert_header(header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the current user's bookmarks.
|
||||||
|
#[server(GetBookmarks, "/api")]
|
||||||
|
pub async fn server_get_bookmarks() -> Result<Vec<BookmarkInfo>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let bookmarks = svc
|
||||||
|
.get_bookmarks(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new bookmark for the current user.
|
||||||
|
#[server(AddBookmark, "/api")]
|
||||||
|
pub async fn server_add_bookmark(
|
||||||
|
content_type: String,
|
||||||
|
content_id: String,
|
||||||
|
content_title: Option<String>,
|
||||||
|
content_url: Option<String>,
|
||||||
|
) -> Result<String, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let id = svc
|
||||||
|
.add_bookmark(
|
||||||
|
&session_token,
|
||||||
|
content_type,
|
||||||
|
content_id,
|
||||||
|
content_title,
|
||||||
|
content_url,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a bookmark by ID.
|
||||||
|
#[server(RemoveBookmark, "/api")]
|
||||||
|
pub async fn server_remove_bookmark(bookmark_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.remove_bookmark(&session_token, bookmark_id)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the current user has bookmarked a specific content item.
|
||||||
|
#[server(IsBookmarked, "/api")]
|
||||||
|
pub async fn server_is_bookmarked(
|
||||||
|
content_id: String,
|
||||||
|
content_type: String,
|
||||||
|
) -> Result<bool, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let bookmarked = svc
|
||||||
|
.is_bookmarked(&session_token, content_id, content_type)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(bookmarked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the current user's account.
|
||||||
|
#[server(DeleteAccount, "/api")]
|
||||||
|
pub async fn server_delete_account() -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.delete_account(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
// Expire the session cookie.
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
if let Ok(hv) = axum::http::HeaderValue::from_str(
|
||||||
|
"session_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0",
|
||||||
|
) {
|
||||||
|
response.insert_header(axum::http::header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the display name for the current authenticated user.
|
||||||
|
#[server(UpdateDisplayName, "/api")]
|
||||||
|
pub async fn server_update_display_name(name: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.update_display_name(&session_token, name)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate the current session: issues a fresh JWT and sets it as a new cookie.
|
||||||
|
///
|
||||||
|
/// Called by WASM when the WS channel sends `TokenRefreshRequested` (~120 s
|
||||||
|
/// before the current token expires). Returns `Ok(())` — the new token is
|
||||||
|
/// delivered via the `Set-Cookie` response header, not the JSON body.
|
||||||
|
#[server(RefreshToken, "/api")]
|
||||||
|
pub async fn server_refresh_token() -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let new_token = svc
|
||||||
|
.refresh_session(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
let cookie_value = format!(
|
||||||
|
"session_token={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400",
|
||||||
|
new_token
|
||||||
|
);
|
||||||
|
if let Ok(hv) = header::HeaderValue::from_str(&cookie_value) {
|
||||||
|
response.insert_header(header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate all active sessions for the current user and expire this
|
||||||
|
/// session's cookie.
|
||||||
|
///
|
||||||
|
/// The adapter emits a `SessionRevoked` WS notification to all other active
|
||||||
|
/// connections of the same user so they redirect to the login page immediately.
|
||||||
|
#[server(LogoutAll, "/api")]
|
||||||
|
pub async fn server_logout_all() -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::{http::header, Extension};
|
||||||
|
use leptos_axum::{extract, ResponseOptions};
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
// Invalidates all DB sessions and emits SessionRevoked WS notification.
|
||||||
|
svc.logout_all_sessions(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
let response = use_context::<ResponseOptions>()
|
||||||
|
.ok_or_else(|| ServerFnError::new("No response options in context"))?;
|
||||||
|
|
||||||
|
if let Ok(hv) = header::HeaderValue::from_str(
|
||||||
|
"session_token=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0",
|
||||||
|
) {
|
||||||
|
response.insert_header(header::SET_COOKIE, hv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the current session cookie and return the authenticated user.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when no cookie is present or the JWT is invalid/expired.
|
||||||
|
/// Returns `Ok(Some(user))` for a valid session.
|
||||||
|
///
|
||||||
|
/// Called by the WASM client on every new tab open to restore `AuthState`
|
||||||
|
/// from an existing `session_token` cookie without requiring re-login.
|
||||||
|
/// Accessible to anonymous requests so the RBAC middleware does not block the
|
||||||
|
/// initial hydration call made before auth state is known.
|
||||||
|
#[server(GetSessionUser, "/api")]
|
||||||
|
pub async fn server_get_session_user(
|
||||||
|
) -> Result<Option<rustelo_core_lib::state::UserInfo>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
|
||||||
|
let Some(token) = extract_session_token(&headers) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
match svc.get_session_user(&token).await {
|
||||||
|
Ok(user) => return Ok(Some(user)),
|
||||||
|
// Invalid / expired token — not an error from the client's perspective.
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookmark data returned to clients.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BookmarkInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub content_type: String,
|
||||||
|
pub content_id: String,
|
||||||
|
pub content_title: Option<String>,
|
||||||
|
pub content_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message data returned to clients.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UserMessage {
|
||||||
|
pub id: String,
|
||||||
|
pub sender_email: String,
|
||||||
|
pub subject: String,
|
||||||
|
pub body: String,
|
||||||
|
pub is_read: bool,
|
||||||
|
/// One of "sent" | "read" | "deleted".
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub received_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note data returned to clients.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UserNote {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service token metadata returned to admins (no token hash exposed).
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ServiceTokenInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returned once on token creation; contains the raw token (shown only once).
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct CreatedServiceToken {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub raw_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource data returned to clients.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct UserResource {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub role_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the current user's messages.
|
||||||
|
#[server(GetMessages, "/api")]
|
||||||
|
pub async fn server_get_messages() -> Result<Vec<UserMessage>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let messages = svc
|
||||||
|
.get_messages(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a message as read for the current user.
|
||||||
|
#[server(MarkMessageRead, "/api")]
|
||||||
|
pub async fn server_mark_message_read(message_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.mark_message_read(&session_token, message_id)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a message for the current user (sets status = 'deleted').
|
||||||
|
#[server(DeleteMessage, "/api")]
|
||||||
|
pub async fn server_delete_message(message_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.delete_message(&session_token, message_id)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the current user's notes.
|
||||||
|
#[server(GetNotes, "/api")]
|
||||||
|
pub async fn server_get_notes() -> Result<Vec<UserNote>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let notes = svc
|
||||||
|
.get_notes(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new note for the current user. Returns the new note's UUID.
|
||||||
|
#[server(CreateNote, "/api")]
|
||||||
|
pub async fn server_create_note(title: String, content: String) -> Result<String, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let id = svc
|
||||||
|
.create_note(&session_token, title, content)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update an existing note for the current user.
|
||||||
|
#[server(UpdateNote, "/api")]
|
||||||
|
pub async fn server_update_note(
|
||||||
|
note_id: String,
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.update_note(&session_token, note_id, title, content)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a note for the current user.
|
||||||
|
#[server(DeleteNote, "/api")]
|
||||||
|
pub async fn server_delete_note(note_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
svc.delete_note(&session_token, note_id)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve resources available to the current user based on their roles.
|
||||||
|
#[server(GetResources, "/api")]
|
||||||
|
pub async fn server_get_resources() -> Result<Vec<UserResource>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
let resources = svc
|
||||||
|
.get_resources(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
|
||||||
|
return Ok(resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List service tokens created by the current admin user.
|
||||||
|
#[server(ListServiceTokens, "/api")]
|
||||||
|
pub async fn server_list_service_tokens() -> Result<Vec<ServiceTokenInfo>, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
return svc
|
||||||
|
.list_service_tokens(&session_token)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new service token (admin only). Returns the raw token once.
|
||||||
|
#[server(CreateServiceToken, "/api")]
|
||||||
|
pub async fn server_create_service_token(
|
||||||
|
name: String,
|
||||||
|
scopes: Vec<String>,
|
||||||
|
expires_days: Option<u32>,
|
||||||
|
) -> Result<CreatedServiceToken, ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
return svc
|
||||||
|
.create_service_token(&session_token, name, scopes, expires_days)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a service token by id (admin only, scoped to creator).
|
||||||
|
#[server(RevokeServiceToken, "/api")]
|
||||||
|
pub async fn server_revoke_service_token(token_id: String) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::auth::otp_service::server::OtpServiceExtension;
|
||||||
|
|
||||||
|
let Extension(OtpServiceExtension(svc)) = extract::<Extension<OtpServiceExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| ServerFnError::new("OTP service not configured"))?;
|
||||||
|
|
||||||
|
let headers = extract::<axum::http::HeaderMap>().await.unwrap_or_default();
|
||||||
|
let session_token = extract_session_token(&headers)
|
||||||
|
.ok_or_else(|| ServerFnError::new("Not authenticated"))?;
|
||||||
|
|
||||||
|
return svc
|
||||||
|
.revoke_service_token(&session_token, token_id)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
Err(ServerFnError::new("SSR not enabled"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn extract_session_token(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get(axum::http::header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|cookies| {
|
||||||
|
cookies.split(';').find_map(|part| {
|
||||||
|
let part = part.trim();
|
||||||
|
part.strip_prefix("session_token=").map(str::to_string)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
|
|
||||||
|
use crate::auth::server_fns::{server_add_bookmark, server_get_bookmarks, server_remove_bookmark};
|
||||||
|
|
||||||
|
/// Interactive bookmark toggle button for a content item.
|
||||||
|
///
|
||||||
|
/// Renders a star icon that reflects whether the current authenticated user
|
||||||
|
/// has bookmarked the given content. On SSR and before the async state load
|
||||||
|
/// completes, the button is rendered disabled (neutral "loading" state) so
|
||||||
|
/// both SSR and WASM produce identical initial HTML — no hydration mismatch.
|
||||||
|
///
|
||||||
|
/// - **Loading / unauthenticated** (`None`): disabled button; click redirects
|
||||||
|
/// to login.
|
||||||
|
/// - **Not bookmarked** (`Some(false)`): outline star; click adds bookmark.
|
||||||
|
/// - **Bookmarked** (`Some(true)`): filled star; click removes bookmark.
|
||||||
|
#[component]
|
||||||
|
pub fn BookmarkButton(
|
||||||
|
content_id: String,
|
||||||
|
content_type: String,
|
||||||
|
content_title: String,
|
||||||
|
content_url: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// None = loading / unauthenticated; Some(bool) = resolved bookmarked state.
|
||||||
|
// Both SSR and WASM start with None → identical initial render.
|
||||||
|
let bookmarked: RwSignal<Option<bool>> = RwSignal::new(None);
|
||||||
|
|
||||||
|
// Load state on WASM only — SSR renders the disabled "loading" state.
|
||||||
|
// All WASM-specific bindings (server_is_bookmarked, set_path) are scoped
|
||||||
|
// here to avoid unused-import/variable warnings on non-WASM builds.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let set_path = use_context::<WriteSignal<String>>();
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use crate::auth::server_fns::server_is_bookmarked;
|
||||||
|
|
||||||
|
let cid = content_id.clone();
|
||||||
|
let ctype = content_type.clone();
|
||||||
|
let bm = bookmarked;
|
||||||
|
spawn_local(async move {
|
||||||
|
// Treat any error (including unauthenticated) as "not bookmarked".
|
||||||
|
let state = server_is_bookmarked(cid, ctype).await.unwrap_or(false);
|
||||||
|
bm.set(Some(state));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture props for the click handler. Strings are moved into the closure;
|
||||||
|
// inner async blocks clone them as needed so the closure remains FnMut.
|
||||||
|
let cid2 = content_id.clone();
|
||||||
|
let ctype2 = content_type.clone();
|
||||||
|
let ctitle2 = content_title.clone();
|
||||||
|
let curl2 = content_url.clone();
|
||||||
|
|
||||||
|
let on_click = move |_| {
|
||||||
|
let state = bookmarked.get_untracked();
|
||||||
|
let bm = bookmarked;
|
||||||
|
|
||||||
|
match state {
|
||||||
|
None => {
|
||||||
|
// State not yet loaded → send to login so user can authenticate.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(setter) = set_path {
|
||||||
|
let current = web_sys::window()
|
||||||
|
.and_then(|w| w.location().pathname().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
setter.set(format!("/login?return={}", current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(false) => {
|
||||||
|
let ct = ctype2.clone();
|
||||||
|
let cid = cid2.clone();
|
||||||
|
let title = ctitle2.clone();
|
||||||
|
let url = curl2.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
match server_add_bookmark(ct, cid, Some(title), Some(url)).await {
|
||||||
|
Ok(_) => bm.set(Some(true)),
|
||||||
|
Err(_) => {
|
||||||
|
// Auth failure or transient error — redirect to login.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let Some(setter) = set_path {
|
||||||
|
let current = web_sys::window()
|
||||||
|
.and_then(|w| w.location().pathname().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
setter.set(format!("/login?return={}", current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Some(true) => {
|
||||||
|
let ct = ctype2.clone();
|
||||||
|
let cid = cid2.clone();
|
||||||
|
spawn_local(async move {
|
||||||
|
// Fetch bookmark list to retrieve the UUID needed for removal.
|
||||||
|
if let Ok(bookmarks) = server_get_bookmarks().await {
|
||||||
|
if let Some(entry) = bookmarks
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.content_id == cid && b.content_type == ct)
|
||||||
|
{
|
||||||
|
let uuid = entry.id.clone();
|
||||||
|
if server_remove_bookmark(uuid).await.is_ok() {
|
||||||
|
bm.set(Some(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=move || {
|
||||||
|
let base = "btn btn-ghost btn-sm";
|
||||||
|
match bookmarked.get() {
|
||||||
|
Some(true) => format!("{} text-warning", base),
|
||||||
|
_ => base.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled=move || bookmarked.get().is_none()
|
||||||
|
on:click=on_click
|
||||||
|
title=move || match bookmarked.get() {
|
||||||
|
Some(true) => "Remove bookmark",
|
||||||
|
Some(false) => "Add bookmark",
|
||||||
|
None => "Loading…",
|
||||||
|
}
|
||||||
|
aria-label=move || match bookmarked.get() {
|
||||||
|
Some(true) => "Remove bookmark",
|
||||||
|
Some(false) => "Add bookmark",
|
||||||
|
None => "Loading…",
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || match bookmarked.get() {
|
||||||
|
Some(true) => "★",
|
||||||
|
_ => "☆",
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod button;
|
||||||
|
|
||||||
|
pub use button::BookmarkButton;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/// Facade trait for sending contact-form emails from Leptos server functions.
|
||||||
|
///
|
||||||
|
/// Defined here in `rustelo_components_leptos` so UI components can trigger
|
||||||
|
/// email sends without a direct dependency on `rustelo_server`. The concrete
|
||||||
|
/// adapter lives in `rustelo_server` and is injected at startup as
|
||||||
|
/// `axum::Extension<ContactFormExtension>`.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod server {
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||||
|
|
||||||
|
/// Minimal email-send abstraction required by contact-form server
|
||||||
|
/// functions.
|
||||||
|
pub trait ContactFormFacade: Send + Sync {
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
subject: String,
|
||||||
|
message: String,
|
||||||
|
) -> BoxFuture<Result<(), String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum `Extension` wrapper enabling
|
||||||
|
/// `extract::<Extension<ContactFormExtension>>()` inside `#[server]`
|
||||||
|
/// functions.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ContactFormExtension(pub Arc<dyn ContactFormFacade>);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod facade;
|
||||||
|
pub mod server_fns;
|
||||||
|
|
||||||
|
pub use server_fns::server_send_contact_form;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Send a contact/work-request form via the registered email service.
|
||||||
|
///
|
||||||
|
/// `subject` and `message` are pre-composed by the caller. The server-side
|
||||||
|
/// adapter validates, rate-limits, and dispatches via the configured provider
|
||||||
|
/// (SMTP, SendGrid, or Console in dev).
|
||||||
|
#[server(SendContactForm, "/api")]
|
||||||
|
pub async fn server_send_contact_form(
|
||||||
|
name: String,
|
||||||
|
email: String,
|
||||||
|
subject: String,
|
||||||
|
message: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
{
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
|
||||||
|
use crate::contact::facade::server::ContactFormExtension;
|
||||||
|
|
||||||
|
let Extension(ContactFormExtension(svc)) = extract::<Extension<ContactFormExtension>>()
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
ServerFnError::new(
|
||||||
|
"Contact form service not configured — register ContactFormExtension at \
|
||||||
|
startup",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
svc.send(name, email, subject, message)
|
||||||
|
.await
|
||||||
|
.map_err(ServerFnError::new)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -42,20 +42,59 @@ pub fn UnifiedContentCard(
|
||||||
let tags = content_item.tags();
|
let tags = content_item.tags();
|
||||||
let featured = content_item.featured();
|
let featured = content_item.featured();
|
||||||
|
|
||||||
// Generate URL using built-in method
|
// Generate URL using built-in method (respects page_route when set)
|
||||||
let url = content_item.url_path();
|
let url = content_item.url_path();
|
||||||
let title_url = content_item.url_path();
|
let title_url = content_item.url_path();
|
||||||
|
let thumbnail = content_item.thumbnail.clone();
|
||||||
|
let thumbnail_dark = content_item.thumbnail_dark.clone();
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<article class={
|
<article class={
|
||||||
format!("ds-card ds-shadow-xl h-full {}",
|
format!("ds-card ds-shadow-xl h-full flex flex-col {}",
|
||||||
if featured {
|
if featured {
|
||||||
"ds-bg-base-200/50 ds-ring-2 ds-ring-primary/30 border border-base-300/50"
|
"ds-bg-base-200/50 ds-ring-2 ds-ring-primary/30 border border-base-300/50"
|
||||||
} else {
|
} else {
|
||||||
"ds-bg-base-100"
|
"ds-bg-base-100"
|
||||||
})
|
})
|
||||||
}>
|
}>
|
||||||
<div class="ds-card-body p-6 flex flex-col justify-between h-full">
|
{thumbnail.map(|src| {
|
||||||
|
let thumb_url = url.clone();
|
||||||
|
let has_dark = thumbnail_dark.is_some();
|
||||||
|
let dark_src = thumbnail_dark.unwrap_or_default();
|
||||||
|
let light_class = if has_dark {
|
||||||
|
"w-full grid-post-img grid-post-img-light hover:scale-105 transition-transform duration-300"
|
||||||
|
} else {
|
||||||
|
"w-full grid-post-img hover:scale-105 transition-transform duration-300"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<figure class="overflow-hidden rounded-t-2xl grid-post-figure">
|
||||||
|
<a href={thumb_url.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
_ev.prevent_default();
|
||||||
|
nav::anchor_navigate(set_path, &thumb_url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img src={src} alt={title.clone()}
|
||||||
|
class={light_class} />
|
||||||
|
{if has_dark {
|
||||||
|
view! {
|
||||||
|
<img src={dark_src} alt={title.clone()}
|
||||||
|
class="w-full grid-post-img grid-post-img-dark hover:scale-105 transition-transform duration-300" />
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</figure>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
<div class="ds-card-body p-6 flex flex-col justify-between flex-1">
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<h3 class="ds-card-title mb-3 flex flex-col">
|
<h3 class="ds-card-title mb-3 flex flex-col">
|
||||||
<a href={title_url.clone()} class="ds-link-hover no-underline hover:text-primary transition-colors"
|
<a href={title_url.clone()} class="ds-link-hover no-underline hover:text-primary transition-colors"
|
||||||
|
|
@ -65,6 +104,7 @@ pub fn UnifiedContentCard(
|
||||||
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
_ev.prevent_default();
|
_ev.prevent_default();
|
||||||
nav::anchor_navigate(set_path, &title_url);
|
nav::anchor_navigate(set_path, &title_url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,23 +152,6 @@ pub fn UnifiedContentCard(
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug logging for both server and client
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
|
||||||
"🐛 CARD URL DEBUG: content_type='{}', language='{}', base_path='{}', category='{}'",
|
|
||||||
content_type, language, resolved_base_path, main_category
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
web_sys::console::log_1(&format!(
|
|
||||||
"🐛 CARD URL DEBUG: content_type='{}', language='{}', base_path='{}', category='{}'",
|
|
||||||
content_type, language, resolved_base_path, main_category
|
|
||||||
).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let main_category_url = format!("{}/{}", resolved_base_path, main_category);
|
let main_category_url = format!("{}/{}", resolved_base_path, main_category);
|
||||||
let main_category_display = {
|
let main_category_display = {
|
||||||
let category_emoji = get_category_emoji(&content_type, &language, &main_category);
|
let category_emoji = get_category_emoji(&content_type, &language, &main_category);
|
||||||
|
|
@ -147,6 +170,7 @@ pub fn UnifiedContentCard(
|
||||||
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
_ev.prevent_default();
|
_ev.prevent_default();
|
||||||
nav::anchor_navigate(set_path, &main_category_url);
|
nav::anchor_navigate(set_path, &main_category_url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +202,7 @@ pub fn UnifiedContentCard(
|
||||||
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
_ev.prevent_default();
|
_ev.prevent_default();
|
||||||
nav::anchor_navigate(set_path, &tag_url);
|
nav::anchor_navigate(set_path, &tag_url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,6 +241,7 @@ pub fn UnifiedContentCard(
|
||||||
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
_ev.prevent_default();
|
_ev.prevent_default();
|
||||||
nav::anchor_navigate(set_path, &tag_url);
|
nav::anchor_navigate(set_path, &tag_url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +285,7 @@ pub fn UnifiedContentCard(
|
||||||
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
_ev.prevent_default();
|
_ev.prevent_default();
|
||||||
nav::anchor_navigate(set_path, &url);
|
nav::anchor_navigate(set_path, &url);
|
||||||
|
nav::doc_scroll_to_top(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,6 +145,8 @@ fn PaginationControlsClient(
|
||||||
{items_per_page_text.clone()}
|
{items_per_page_text.clone()}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
|
id="pagination-page-size"
|
||||||
|
name="page-size"
|
||||||
class="ds-select ds-select-bordered ds-select-sm"
|
class="ds-select ds-select-bordered ds-select-sm"
|
||||||
on:change=move |ev| {
|
on:change=move |ev| {
|
||||||
let value = event_target_value(&ev).parse::<u32>().unwrap_or(items_per_page.get());
|
let value = event_target_value(&ev).parse::<u32>().unwrap_or(items_per_page.get());
|
||||||
|
|
@ -297,6 +299,8 @@ fn PaginationControlsSSR(
|
||||||
{content.t_with_prefixes("items-per-page", &[&content_type, "content"], Some("Items per page:"))}
|
{content.t_with_prefixes("items-per-page", &[&content_type, "content"], Some("Items per page:"))}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
|
id="pagination-page-size-static"
|
||||||
|
name="page-size"
|
||||||
class="ds-select ds-select-bordered ds-select-sm"
|
class="ds-select ds-select-bordered ds-select-sm"
|
||||||
disabled=true
|
disabled=true
|
||||||
>
|
>
|
||||||
|
|
@ -0,0 +1,438 @@
|
||||||
|
//! Enhanced Simple Content Grid Component
|
||||||
|
//!
|
||||||
|
//! Pure functional content grid with content-kinds.toml configuration support.
|
||||||
|
//! Uses direct async HTTP fetch (same pattern as UnifiedCategoryFilter) to
|
||||||
|
//! avoid race conditions from polling and reactive-owner loss.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::{
|
||||||
|
content::UnifiedContentItem, create_content_kind_registry, fluent::load_content_index,
|
||||||
|
i18n::create_content_provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::content::card::UnifiedContentCard;
|
||||||
|
use crate::content::pagination::PaginationControls;
|
||||||
|
|
||||||
|
/// Async HTTP fetch for content index JSON — WASM only, mirrors
|
||||||
|
/// fetch_filter_data pattern. Parses the API JSON format via
|
||||||
|
/// UnifiedContentItem::from_json() which handles field mapping differences
|
||||||
|
/// (category vs categories, date vs created_at, etc.).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
async fn fetch_content_items_async(
|
||||||
|
content_type: &str,
|
||||||
|
language: &str,
|
||||||
|
category_filter: &str,
|
||||||
|
page_size: usize,
|
||||||
|
page_offset: usize,
|
||||||
|
) -> Result<(Vec<UnifiedContentItem>, usize), String> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
use web_sys::{Request, RequestInit, RequestMode, Response};
|
||||||
|
|
||||||
|
let url = format!("/r/{}/{}/index.json", content_type, language);
|
||||||
|
|
||||||
|
let opts = RequestInit::new();
|
||||||
|
opts.set_method("GET");
|
||||||
|
opts.set_mode(RequestMode::SameOrigin);
|
||||||
|
|
||||||
|
let request = Request::new_with_str_and_init(&url, &opts)
|
||||||
|
.map_err(|e| format!("request init failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("no window")?;
|
||||||
|
let resp_value = JsFuture::from(window.fetch_with_request(&request))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("fetch failed: {:?}", e))?;
|
||||||
|
let resp: Response = resp_value
|
||||||
|
.dyn_into()
|
||||||
|
.map_err(|_| "cast to Response failed")?;
|
||||||
|
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(format!("HTTP {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_val = JsFuture::from(resp.json().map_err(|e| format!("json() failed: {:?}", e))?)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("json future failed: {:?}", e))?;
|
||||||
|
|
||||||
|
let text = js_sys::JSON::stringify(&json_val)
|
||||||
|
.map_err(|e| format!("stringify failed: {:?}", e))?
|
||||||
|
.as_string()
|
||||||
|
.ok_or("stringify returned non-string")?;
|
||||||
|
|
||||||
|
// Parse as generic JSON value — the API format uses different field names
|
||||||
|
// (e.g. "posts" array, "category" singular, "date" instead of "created_at")
|
||||||
|
// that don't match UnifiedContentItem's serde fields. Use from_json() which
|
||||||
|
// handles all these mappings explicitly.
|
||||||
|
let raw: serde_json::Value =
|
||||||
|
serde_json::from_str(&text).map_err(|e| format!("json parse failed: {}", e))?;
|
||||||
|
|
||||||
|
let posts_arr = raw
|
||||||
|
.get("posts")
|
||||||
|
.or_else(|| raw.get("items"))
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.ok_or("response has no posts/items array")?;
|
||||||
|
|
||||||
|
let items: Vec<UnifiedContentItem> = posts_arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| UnifiedContentItem::from_json(v, content_type, language))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(process_content_index_items(
|
||||||
|
items,
|
||||||
|
category_filter,
|
||||||
|
page_size,
|
||||||
|
page_offset,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter, sort, and paginate content items.
|
||||||
|
fn process_content_index_items(
|
||||||
|
items: Vec<UnifiedContentItem>,
|
||||||
|
category_filter: &str,
|
||||||
|
page_size: usize,
|
||||||
|
page_offset: usize,
|
||||||
|
) -> (Vec<UnifiedContentItem>, usize) {
|
||||||
|
let mut items: Vec<UnifiedContentItem> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.published)
|
||||||
|
.filter(|item| {
|
||||||
|
category_filter.is_empty()
|
||||||
|
|| category_filter == "all"
|
||||||
|
|| item.categories.contains(&category_filter.to_string())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total = items.len();
|
||||||
|
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
// Featured items first
|
||||||
|
match (a.featured, b.featured) {
|
||||||
|
(true, false) => return std::cmp::Ordering::Less,
|
||||||
|
(false, true) => return std::cmp::Ordering::Greater,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// Then by sort_order (lower first), treating 0 as "no preference" (last)
|
||||||
|
match (a.sort_order, b.sort_order) {
|
||||||
|
(0, 0) => {}
|
||||||
|
(0, _) => return std::cmp::Ordering::Greater,
|
||||||
|
(_, 0) => return std::cmp::Ordering::Less,
|
||||||
|
(a_ord, b_ord) => {
|
||||||
|
let cmp = a_ord.cmp(&b_ord);
|
||||||
|
if cmp != std::cmp::Ordering::Equal {
|
||||||
|
return cmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Finally by date descending
|
||||||
|
b.created_at.cmp(&a.created_at)
|
||||||
|
});
|
||||||
|
|
||||||
|
let paged = items
|
||||||
|
.into_iter()
|
||||||
|
.skip(page_offset)
|
||||||
|
.take(page_size)
|
||||||
|
.collect();
|
||||||
|
(paged, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enhanced Simple Content Grid with content-kinds.toml configuration support
|
||||||
|
#[component]
|
||||||
|
pub fn SimpleContentGrid(
|
||||||
|
content_type: String,
|
||||||
|
language: String,
|
||||||
|
#[prop(default = HashMap::new())] lang_content: HashMap<String, String>,
|
||||||
|
#[prop(default = "".to_string())] category_filter: String,
|
||||||
|
#[prop(default = 12)] limit: usize,
|
||||||
|
#[prop(default = 1)] current_page: u32,
|
||||||
|
#[prop(default = true)] enable_pagination: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
tracing::info!(
|
||||||
|
"🔍 SimpleContentGrid: content_type='{}', language='{}', category_filter='{}'",
|
||||||
|
content_type,
|
||||||
|
language,
|
||||||
|
category_filter
|
||||||
|
);
|
||||||
|
|
||||||
|
let content_provider = create_content_provider(Some(lang_content.clone()));
|
||||||
|
|
||||||
|
let content_type_for_cards = content_type.clone();
|
||||||
|
let content_type_for_pagination = content_type.clone();
|
||||||
|
let content_type_for_empty = content_type.clone();
|
||||||
|
|
||||||
|
// Resolve the base path once so UnifiedContentCard generates correct category
|
||||||
|
// URLs. Falls back to "/{content_type}" only when no route config is
|
||||||
|
// available.
|
||||||
|
let card_base_path =
|
||||||
|
rustelo_core_lib::routing::utils::get_content_type_base_path(&content_type, &language)
|
||||||
|
.unwrap_or_else(|| format!("/{}", content_type));
|
||||||
|
|
||||||
|
let content_config = {
|
||||||
|
let registry = create_content_kind_registry();
|
||||||
|
registry
|
||||||
|
.kinds
|
||||||
|
.get(&content_type)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
tracing::error!(
|
||||||
|
"❌ CONFIG_ERROR: Unknown content_type: '{}', using default",
|
||||||
|
content_type
|
||||||
|
);
|
||||||
|
rustelo_core_lib::ContentConfig::from_env()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let effective_page_size = if enable_pagination { 12 } else { limit };
|
||||||
|
let page_offset = if enable_pagination {
|
||||||
|
((current_page.saturating_sub(1)) as usize) * effective_page_size
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = RwSignal::new(current_page);
|
||||||
|
let page_size = RwSignal::new(effective_page_size as u32);
|
||||||
|
|
||||||
|
// Signal that holds the loaded items. Starts empty; updated after async fetch.
|
||||||
|
let content_signal = RwSignal::new((Vec::<UnifiedContentItem>::new(), 0usize));
|
||||||
|
|
||||||
|
// Synchronous initial load — works on SSR and on WASM cache hit.
|
||||||
|
let initial = load_content_for_type_sync(
|
||||||
|
&content_type,
|
||||||
|
&language,
|
||||||
|
&category_filter,
|
||||||
|
&content_config,
|
||||||
|
effective_page_size,
|
||||||
|
page_offset,
|
||||||
|
);
|
||||||
|
// SSR: store initial data for non-reactive reads in DynChild closures.
|
||||||
|
// StoredValue::get_value() is non-reactive — eliminates the
|
||||||
|
// "outside reactive tracking context" warnings from signal reads in SSR
|
||||||
|
// DynChild.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let ssr_data = StoredValue::new((initial.0.clone(), initial.1));
|
||||||
|
content_signal.set(initial);
|
||||||
|
|
||||||
|
// WASM: if sync load returned empty, fetch via HTTP directly (same pattern as
|
||||||
|
// UnifiedCategoryFilter) — single await, no polling, no race with reactive
|
||||||
|
// owner.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let ct = content_type.clone();
|
||||||
|
let lang = language.clone();
|
||||||
|
let cat = category_filter.clone();
|
||||||
|
let write = content_signal.write_only();
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🗂️ SimpleContentGrid WASM init: ct='{}', lang='{}', initial_empty={}",
|
||||||
|
ct,
|
||||||
|
lang,
|
||||||
|
content_signal.get_untracked().0.is_empty()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !content_signal.get_untracked().0.is_empty() {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🗂️ SimpleContentGrid Effect: data already loaded ({} items), skipping \
|
||||||
|
fetch",
|
||||||
|
content_signal.get_untracked().0.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ct = ct.clone();
|
||||||
|
let lang = lang.clone();
|
||||||
|
let cat = cat.clone();
|
||||||
|
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🗂️ SimpleContentGrid Effect: spawning fetch for {}/{}",
|
||||||
|
ct, lang
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"🗂️ SimpleContentGrid fetch_content_items_async: starting for {}/{}",
|
||||||
|
ct, lang
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
match fetch_content_items_async(&ct, &lang, &cat, effective_page_size, page_offset)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => {
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!(
|
||||||
|
"✅ SimpleContentGrid: loaded {} items for {}/{}",
|
||||||
|
result.0.len(),
|
||||||
|
ct,
|
||||||
|
lang
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
write.set(result);
|
||||||
|
web_sys::console::log_1(&"✅ SimpleContentGrid: write.set() called".into());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
web_sys::console::warn_1(
|
||||||
|
&format!(
|
||||||
|
"❌ SimpleContentGrid: fetch failed for {}/{}: {}",
|
||||||
|
ct, lang, e
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_page_size_options = vec![6u32, 12, 24, 48];
|
||||||
|
let config_show_page_info = true;
|
||||||
|
|
||||||
|
let update_page_in_url = move |new_page: u32, _new_page_size: u32| {
|
||||||
|
tracing::debug!("Page changed to: {}", new_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only needed in WASM where content loads async and closures must re-run.
|
||||||
|
// SSR reads from ssr_data.get_value() directly — non-reactive, no tracking
|
||||||
|
// warning.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let pagination_total = Memo::new(move |_| content_signal.get().1);
|
||||||
|
|
||||||
|
let css_classes = format!(
|
||||||
|
"enhanced-content-grid default content-type-{} layout-grid-layout",
|
||||||
|
&content_type
|
||||||
|
);
|
||||||
|
let grid_classes = "grid md:grid-cols-2 lg:grid-cols-3 gap-6";
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class={css_classes}>
|
||||||
|
<div class={grid_classes}>
|
||||||
|
{move || {
|
||||||
|
// SSR: get_value() is non-reactive — no "outside reactive tracking context" warning.
|
||||||
|
// WASM: get() subscribes so the grid re-renders when async fetch completes.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let items = ssr_data.get_value().0;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let items = content_signal.get().0;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(
|
||||||
|
&format!("🗂️ SimpleContentGrid DynChild: rendering {} items", items.len()).into()
|
||||||
|
);
|
||||||
|
items.into_iter().map(|item| {
|
||||||
|
view! {
|
||||||
|
<UnifiedContentCard
|
||||||
|
content_item=item.clone()
|
||||||
|
content_type=content_type_for_cards.to_string()
|
||||||
|
language=language.clone()
|
||||||
|
lang_content=lang_content.clone()
|
||||||
|
_content_config=content_config.clone()
|
||||||
|
base_path=card_base_path.clone()
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
let options_clone = config_page_size_options.clone();
|
||||||
|
let show_info_clone = config_show_page_info;
|
||||||
|
move || {
|
||||||
|
// SSR: get_value() is non-reactive — no "outside reactive tracking context" warning.
|
||||||
|
// WASM: Memo::get() subscribes so the closure re-runs when content loads async.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let total = ssr_data.get_value().1;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let total = pagination_total.get();
|
||||||
|
if enable_pagination && total > effective_page_size {
|
||||||
|
let total_pages = (total + effective_page_size - 1) / effective_page_size;
|
||||||
|
let page_change_cb = Callback::new(move |new_page: u32| {
|
||||||
|
page.set(new_page);
|
||||||
|
update_page_in_url(new_page, page_size.get_untracked());
|
||||||
|
});
|
||||||
|
let page_size_change_cb = Callback::new(move |new_size: u32| {
|
||||||
|
page_size.set(new_size);
|
||||||
|
page.set(1);
|
||||||
|
update_page_in_url(1, new_size);
|
||||||
|
});
|
||||||
|
view! {
|
||||||
|
<PaginationControls
|
||||||
|
current_page=Signal::from(page)
|
||||||
|
total_pages=Signal::from(total_pages as u32)
|
||||||
|
total_items=total as u32
|
||||||
|
items_per_page=Signal::from(page_size)
|
||||||
|
content_type=content_type_for_pagination.to_string()
|
||||||
|
lang_content=std::collections::HashMap::new()
|
||||||
|
on_page_change=page_change_cb
|
||||||
|
on_page_size_change=page_size_change_cb
|
||||||
|
page_size_options=options_clone.clone()
|
||||||
|
show_page_info=show_info_clone
|
||||||
|
/>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{move || {
|
||||||
|
// SSR: get_value() is non-reactive — no "outside reactive tracking context" warning.
|
||||||
|
// WASM: get() subscribes so the empty-state hides reactively once content loads.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let (items, _) = ssr_data.get_value();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let (items, _) = content_signal.get();
|
||||||
|
if items.is_empty() {
|
||||||
|
view! {
|
||||||
|
<div class="empty-state text-center py-12">
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="text-4xl mb-4 opacity-50">{"📄"}</div>
|
||||||
|
<h3 class="text-lg font-medium ds-text mb-2">
|
||||||
|
{content_provider.t_with_prefixes("no-content", &[&content_type_for_empty, "content"], Some("No content available"))}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm ds-text-secondary">
|
||||||
|
{if !category_filter.is_empty() {
|
||||||
|
format!("No {} content found for category: {}", content_type_for_empty, category_filter)
|
||||||
|
} else {
|
||||||
|
format!("No {} content is currently available", content_type_for_empty)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronous content load — uses embedded resources or cache, never HTTP.
|
||||||
|
fn load_content_for_type_sync(
|
||||||
|
content_type: &str,
|
||||||
|
language: &str,
|
||||||
|
category_filter: &str,
|
||||||
|
_content_config: &rustelo_core_lib::ContentConfig,
|
||||||
|
page_size: usize,
|
||||||
|
page_offset: usize,
|
||||||
|
) -> (Vec<UnifiedContentItem>, usize) {
|
||||||
|
match load_content_index(content_type, language) {
|
||||||
|
Ok(index) => {
|
||||||
|
process_content_index_items(index.items, category_filter, page_size, page_offset)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("sync load failed for {}/{}: {}", content_type, language, e);
|
||||||
|
(vec![], 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use rustelo_core_lib::i18n::use_unified_i18n;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
use super::manage_modal::CookieManageModal;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const CONSENT_KEY: &str = "cookie_consent";
|
||||||
|
|
||||||
|
/// Cookie consent banner. Renders nothing in SSR — only active in WASM.
|
||||||
|
#[component]
|
||||||
|
pub fn CookieBanner(
|
||||||
|
#[prop(default = "/privacy".to_string())] policy_page: String,
|
||||||
|
/// "modal" shows the category picker inline; anything else navigates to
|
||||||
|
/// policy_page.
|
||||||
|
#[prop(default = "page".to_string())]
|
||||||
|
detail_mode: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let banner_text = i18n.t("cookies-banner-text");
|
||||||
|
let policy_link = i18n.t("cookies-policy-link");
|
||||||
|
let policy_suffix = i18n.t("cookies-policy-suffix");
|
||||||
|
let manage_label = i18n.t("cookies-manage");
|
||||||
|
let reject_label = i18n.t("cookies-reject");
|
||||||
|
let accept_label = i18n.t("cookies-accept-all");
|
||||||
|
|
||||||
|
let show = RwSignal::new(false);
|
||||||
|
let show_manage = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Read localStorage on mount to decide whether to show the banner
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(storage) = local_storage() {
|
||||||
|
let already_set = storage.get_item(CONSENT_KEY).ok().flatten().is_some();
|
||||||
|
show.set(!already_set);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lock body scroll while banner (or manage modal) is visible
|
||||||
|
Effect::new(move |_| {
|
||||||
|
let visible = show.get() || show_manage.get();
|
||||||
|
if let Some(body) = web_sys::window()
|
||||||
|
.and_then(|w| w.document())
|
||||||
|
.and_then(|d| d.body())
|
||||||
|
{
|
||||||
|
let _ = if visible {
|
||||||
|
body.style().set_property("overflow", "hidden")
|
||||||
|
} else {
|
||||||
|
body.style().remove_property("overflow").map(|_| ())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap as Callback (Copy via StoredValue) so they can be captured by Fn
|
||||||
|
// children closures
|
||||||
|
let write_consent_essential = Callback::<leptos::ev::MouseEvent>::new(move |_| {
|
||||||
|
if let Some(storage) = local_storage() {
|
||||||
|
let _ = storage.set_item(CONSENT_KEY, "essential");
|
||||||
|
}
|
||||||
|
show.set(false);
|
||||||
|
});
|
||||||
|
let write_consent_all = Callback::<leptos::ev::MouseEvent>::new(move |_| {
|
||||||
|
if let Some(storage) = local_storage() {
|
||||||
|
let _ = storage.set_item(CONSENT_KEY, "all");
|
||||||
|
}
|
||||||
|
show.set(false);
|
||||||
|
});
|
||||||
|
let on_manage = Callback::<leptos::ev::MouseEvent>::new({
|
||||||
|
let policy = policy_page.clone();
|
||||||
|
let mode = detail_mode.clone();
|
||||||
|
move |_| {
|
||||||
|
if mode == "modal" {
|
||||||
|
show_manage.set(true);
|
||||||
|
} else if let Some(set_path) = use_context::<WriteSignal<String>>() {
|
||||||
|
set_path.set(policy.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let policy_href = policy_page.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Show when=move || show.get()>
|
||||||
|
// Blocking overlay — prevents any interaction with page content
|
||||||
|
// until the user makes a cookie choice. Not dismissible by click.
|
||||||
|
<div class="fixed inset-0 z-100 bg-black opacity-75" />
|
||||||
|
// Banner panel — fully opaque, above the overlay
|
||||||
|
<div class="fixed bottom-0 left-0 right-0 z-120 bg-base-100 border-t-2 border-base-300 shadow-2xl
|
||||||
|
px-6 py-5 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<p class="text-sm flex-1 dark:color-white">
|
||||||
|
{banner_text.clone()}
|
||||||
|
" "
|
||||||
|
<a class="underline" href=policy_href.clone()>{policy_link.clone()}</a>
|
||||||
|
" "
|
||||||
|
{policy_suffix.clone()}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 dark:color-white">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm rounded-full"
|
||||||
|
on:click=move |e| on_manage.run(e)
|
||||||
|
>
|
||||||
|
{manage_label.clone()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm rounded-full dark:color-white"
|
||||||
|
on:click=move |e| write_consent_essential.run(e)
|
||||||
|
>
|
||||||
|
{reject_label.clone()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm rounded-full dark:color-white"
|
||||||
|
on:click=move |e| write_consent_all.run(e)
|
||||||
|
>
|
||||||
|
{accept_label.clone()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when=move || show_manage.get()>
|
||||||
|
<CookieManageModal
|
||||||
|
on_save=Callback::new(move |prefs: String| {
|
||||||
|
if let Some(storage) = local_storage() {
|
||||||
|
let _ = storage.set_item(CONSENT_KEY, &prefs);
|
||||||
|
}
|
||||||
|
show.set(false);
|
||||||
|
show_manage.set(false);
|
||||||
|
})
|
||||||
|
on_close=Callback::new(move |_: ()| show_manage.set(false))
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR: emit TWO DynChild pairs to match WASM's two <Show> components
|
||||||
|
// (banner Show + manage modal Show). Each Show with when=false emits one
|
||||||
|
// reactive-node marker; the sequential hydration counter must match exactly.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let _ = (policy_page, detail_mode);
|
||||||
|
view! { {move || ()} {move || ()} }.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
fn local_storage() -> Option<web_sys::Storage> {
|
||||||
|
web_sys::window()?.local_storage().ok()?
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use rustelo_core_lib::i18n::use_unified_i18n;
|
||||||
|
|
||||||
|
/// Cookie category management modal — lets users pick which cookies to accept.
|
||||||
|
#[component]
|
||||||
|
pub fn CookieManageModal(on_save: Callback<String>, on_close: Callback<()>) -> impl IntoView {
|
||||||
|
let i18n = use_unified_i18n();
|
||||||
|
let manage_label = i18n.t("cookies-manage");
|
||||||
|
let essential_label = i18n.t("cookies-essential");
|
||||||
|
let analytics_label = i18n.t("cookies-analytics");
|
||||||
|
let marketing_label = i18n.t("cookies-marketing");
|
||||||
|
let save_label = i18n.t("cookies-save-prefs");
|
||||||
|
let cancel_label = i18n.t("cookies-cancel");
|
||||||
|
|
||||||
|
let analytics = RwSignal::new(false);
|
||||||
|
let marketing = RwSignal::new(false);
|
||||||
|
|
||||||
|
let save = move |_| {
|
||||||
|
let prefs = serde_json::json!({
|
||||||
|
"essential": true,
|
||||||
|
"analytics": analytics.get(),
|
||||||
|
"marketing": marketing.get(),
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
on_save.run(prefs);
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
// z-[110] — above the cookie banner overlay (z-[90]) and banner panel (z-[100])
|
||||||
|
<div class="fixed inset-0 z-[110] bg-black opacity-75 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-base-100 rounded-xl p-6 w-full max-w-sm shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{manage_label}</h3>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-base-200">
|
||||||
|
<span>{essential_label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="cookie-essential"
|
||||||
|
name="cookie_essential"
|
||||||
|
class="toggle toggle-sm"
|
||||||
|
checked=true
|
||||||
|
disabled=true
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-base-200">
|
||||||
|
<span>{analytics_label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="cookie-analytics"
|
||||||
|
name="cookie_analytics"
|
||||||
|
class="toggle toggle-sm"
|
||||||
|
prop:checked=analytics
|
||||||
|
on:change=move |ev| analytics.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<span>{marketing_label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="cookie-marketing"
|
||||||
|
name="cookie_marketing"
|
||||||
|
class="toggle toggle-sm"
|
||||||
|
prop:checked=marketing
|
||||||
|
on:change=move |ev| marketing.set(event_target_checked(&ev))
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost flex-1"
|
||||||
|
on:click=move |_| on_close.run(())
|
||||||
|
>
|
||||||
|
{cancel_label}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary flex-1" on:click=save>
|
||||||
|
{save_label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod banner;
|
||||||
|
pub mod manage_modal;
|
||||||
|
|
||||||
|
pub use banner::CookieBanner;
|
||||||
|
pub use manage_modal::CookieManageModal;
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
//! Import the unified components for seamless SSR/client compatibility:
|
//! Import the unified components for seamless SSR/client compatibility:
|
||||||
//!
|
//!
|
||||||
//! ```rust
|
//! ```rust
|
||||||
//! use rustelo_components::filter::ContentCategoryFilter};
|
//! use rustelo_components_leptos::filter::ContentCategoryFilter};
|
||||||
//! use leptos::prelude::*;
|
//! use leptos::prelude::*;
|
||||||
//!
|
//!
|
||||||
//! fn example_usage() -> impl IntoView {
|
//! fn example_usage() -> impl IntoView {
|
||||||
|
|
@ -245,87 +245,119 @@ pub fn UnifiedCategoryFilter(
|
||||||
|
|
||||||
move || {
|
move || {
|
||||||
let data = filter_memo.get();
|
let data = filter_memo.get();
|
||||||
|
let filter_content = {
|
||||||
|
let translator = rustelo_core_lib::SsrTranslator::new(language.clone());
|
||||||
|
rustelo_core_lib::build_page_content_patterns(
|
||||||
|
&translator,
|
||||||
|
&[content_type_for_categories.as_str(), "content"],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let lbl_categories = filter_content
|
||||||
|
.get("content-filter-categories")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Categories".to_string());
|
||||||
|
let lbl_tags = filter_content
|
||||||
|
.get("content-filter-tags")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Tags".to_string());
|
||||||
|
let lbl_all = filter_content
|
||||||
|
.get("content-filter-all")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "All".to_string());
|
||||||
|
let lbl_show_all = filter_content
|
||||||
|
.get("content-filter-show-all")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Show All".to_string());
|
||||||
view! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
// Categories section - always show "All", then additional categories if they exist
|
// Categories section - only shown when categories exist
|
||||||
<div class="category-filters ">
|
{if !data.categories.is_empty() {
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">"Categories"</h3>
|
view! {
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="category-filters ">
|
||||||
// Always show "All" category
|
<h3 class="text-sm font-medium text-gray-700 mb-2">{lbl_categories.clone()}</h3>
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
type="button"
|
// "All" category button
|
||||||
class={if current_filter_for_categories == "All" {
|
|
||||||
get_active_button_classes()
|
|
||||||
} else {
|
|
||||||
get_inactive_button_classes()
|
|
||||||
}}
|
|
||||||
data-filter-type="category"
|
|
||||||
data-filter-value="All"
|
|
||||||
data-content-type={content_type_for_categories.clone()}
|
|
||||||
on:click={
|
|
||||||
let content_type_for_click = content_type_for_categories.clone();
|
|
||||||
let language_for_click = language.clone();
|
|
||||||
move |_ev| {
|
|
||||||
_ev.prevent_default();
|
|
||||||
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&filter_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="filter-button-text ">{"🏷️ All "}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
// Additional categories from data
|
|
||||||
{
|
|
||||||
data.categories.iter().map(|(category, category_data)| {
|
|
||||||
let category_for_click = category.clone();
|
|
||||||
let content_type_for_click = content_type_for_categories.clone();
|
|
||||||
let language_for_click = language.clone();
|
|
||||||
let is_active = current_filter_for_categories == *category;
|
|
||||||
let cat_class = if is_active {
|
|
||||||
get_active_button_classes()
|
|
||||||
} else {
|
|
||||||
get_inactive_button_classes()
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={cat_class}
|
class={if current_filter_for_categories == "All" {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
}}
|
||||||
data-filter-type="category"
|
data-filter-type="category"
|
||||||
data-filter-value={category_for_click.clone()}
|
data-filter-value="All"
|
||||||
data-content-type={content_type_for_click.clone()}
|
data-content-type={content_type_for_categories.clone()}
|
||||||
on:click=move |_ev| {
|
on:click={
|
||||||
_ev.prevent_default();
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
// Get the proper base path using configuration-driven lookup
|
let language_for_click = language.clone();
|
||||||
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
move |_ev| {
|
||||||
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
_ev.prevent_default();
|
||||||
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
if let Some(window) = web_sys::window() {
|
if let Some(window) = web_sys::window() {
|
||||||
let location = window.location();
|
let location = window.location();
|
||||||
let _ = location.set_href(&filter_url);
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="filter-button-text ">
|
<span class="filter-button-text ">{format!("🏷️ {} ", lbl_all)}</span>
|
||||||
{format!("{} {}", category_data.emoji, category_for_click)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
}).collect_view()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Tags section - always render container
|
// Additional categories from data
|
||||||
|
{
|
||||||
|
data.categories.iter().map(|(category, category_data)| {
|
||||||
|
let cat_display = filter_content
|
||||||
|
.get(&format!("{}-cat-{}-title", content_type_for_categories, category))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| category.to_string());
|
||||||
|
let category_for_click = category.clone();
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
let is_active = current_filter_for_categories == *category;
|
||||||
|
let cat_class = if is_active {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cat_class}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value={category_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
||||||
|
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", category_data.emoji, cat_display)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Tags section - only shown when tags exist
|
||||||
<div class="tag-filters ">
|
<div class="tag-filters ">
|
||||||
{if !data.tags.is_empty() {
|
{if !data.tags.is_empty() {
|
||||||
view! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">"Tags"</h3>
|
<h3 class="text-sm font-medium text-gray-700 mb-2">{lbl_tags.clone()}</h3>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
// Show All button for tags
|
// Show All button for tags
|
||||||
<button
|
<button
|
||||||
|
|
@ -345,7 +377,7 @@ pub fn UnifiedCategoryFilter(
|
||||||
let _ = js_sys::eval(&js_code);
|
let _ = js_sys::eval(&js_code);
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="filter-button-text ">{"🔍 Show All "}</span>
|
<span class="filter-button-text ">{format!("🔍 {} ", lbl_show_all)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{data.tags.iter().map(|(tag, tag_data)| {
|
{data.tags.iter().map(|(tag, tag_data)| {
|
||||||
|
|
@ -421,89 +453,94 @@ pub fn UnifiedCategoryFilter(
|
||||||
);
|
);
|
||||||
view! {
|
view! {
|
||||||
<div>
|
<div>
|
||||||
// Categories section - always show "All", then additional categories if they exist
|
// Categories section - only shown when categories exist
|
||||||
<div class="category-filters ">
|
{if !empty_data.categories.is_empty() {
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">"Categories"</h3>
|
view! {
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="category-filters ">
|
||||||
// Always show "All" category
|
<h3 class="text-sm font-medium text-gray-700 mb-2">"Categories"</h3>
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
type="button"
|
// "All" category button
|
||||||
class={if current_filter_for_categories == "All" {
|
|
||||||
get_active_button_classes()
|
|
||||||
} else {
|
|
||||||
get_inactive_button_classes()
|
|
||||||
}}
|
|
||||||
data-filter-type="category"
|
|
||||||
data-filter-value="All"
|
|
||||||
data-content-type={content_type_for_categories.clone()}
|
|
||||||
on:click={
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
let content_type_for_click = content_type_for_categories.clone();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
let language_for_click = language.clone();
|
|
||||||
move |_ev| {
|
|
||||||
_ev.prevent_default();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
|
||||||
if let Some(window) = web_sys::window() {
|
|
||||||
let location = window.location();
|
|
||||||
let _ = location.set_href(&filter_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="filter-button-text ">{"🏷️ All "}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
// Additional categories from data (empty for SSR, will be hydrated)
|
|
||||||
{
|
|
||||||
empty_data.categories.iter().map(|(category, category_data)| {
|
|
||||||
let category_for_click = category.clone();
|
|
||||||
let content_type_for_click = content_type_for_categories.clone();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
let language_for_click = language.clone();
|
|
||||||
let is_active = current_filter_for_categories == *category;
|
|
||||||
let cat_class = if is_active {
|
|
||||||
get_active_button_classes()
|
|
||||||
} else {
|
|
||||||
get_inactive_button_classes()
|
|
||||||
};
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={cat_class}
|
class={if current_filter_for_categories == "All" {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
}}
|
||||||
data-filter-type="category"
|
data-filter-type="category"
|
||||||
data-filter-value={category_for_click.clone()}
|
data-filter-value="All"
|
||||||
data-content-type={content_type_for_click.clone()}
|
data-content-type={content_type_for_categories.clone()}
|
||||||
on:click=move |_ev| {
|
on:click={
|
||||||
_ev.prevent_default();
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
// Get the proper base path using configuration-driven lookup
|
#[cfg(target_arch = "wasm32")]
|
||||||
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
let language_for_click = language.clone();
|
||||||
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
move |_ev| {
|
||||||
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
_ev.prevent_default();
|
||||||
if let Some(window) = web_sys::window() {
|
#[cfg(target_arch = "wasm32")]
|
||||||
let location = window.location();
|
{
|
||||||
let _ = location.set_href(&filter_url);
|
let filter_url = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="filter-button-text ">
|
<span class="filter-button-text ">{"🏷️ All "}</span>
|
||||||
{format!("{} {}", category_data.emoji, category_for_click)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
}).collect_view()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
// Tags section - always render container (empty for SSR, will be hydrated)
|
// Additional categories from data
|
||||||
|
{
|
||||||
|
empty_data.categories.iter().map(|(category, category_data)| {
|
||||||
|
let category_for_click = category.clone();
|
||||||
|
let content_type_for_click = content_type_for_categories.clone();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let language_for_click = language.clone();
|
||||||
|
let is_active = current_filter_for_categories == *category;
|
||||||
|
let cat_class = if is_active {
|
||||||
|
get_active_button_classes()
|
||||||
|
} else {
|
||||||
|
get_inactive_button_classes()
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={cat_class}
|
||||||
|
data-filter-type="category"
|
||||||
|
data-filter-value={category_for_click.clone()}
|
||||||
|
data-content-type={content_type_for_click.clone()}
|
||||||
|
on:click=move |_ev| {
|
||||||
|
_ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let localized_base_path = get_content_type_base_path(&content_type_for_click, &language_for_click).unwrap_or_else(|| "/".to_string());
|
||||||
|
let filter_url = rustelo_core_lib::routing::utils::get_category_url(&localized_base_path, &category_for_click, &language_for_click);
|
||||||
|
tracing::debug!("Category filter navigation: {} -> {} -> {}", content_type_for_click, localized_base_path, filter_url);
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let location = window.location();
|
||||||
|
let _ = location.set_href(&filter_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="filter-button-text ">
|
||||||
|
{format!("{} {}", category_data.emoji, category_for_click)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <></> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Tags section - only shown when tags exist
|
||||||
<div class="tag-filters ">
|
<div class="tag-filters ">
|
||||||
{if !empty_data.tags.is_empty() {
|
{if !empty_data.tags.is_empty() {
|
||||||
view! {
|
view! {
|
||||||
|
|
@ -22,12 +22,19 @@
|
||||||
|
|
||||||
// Fix Leptos macro conflicts by shadowing our 'core' crate with std::core
|
// Fix Leptos macro conflicts by shadowing our 'core' crate with std::core
|
||||||
|
|
||||||
|
pub mod activities;
|
||||||
|
pub use activities::{ActivitiesCTAView, ActivityAccessStatus};
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod bookmarks;
|
||||||
|
pub mod contact;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
pub mod cookie;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
pub mod logo;
|
pub mod logo;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod toast;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
// Re-enable filter exports (partially - components with macro errors disabled)
|
// Re-enable filter exports (partially - components with macro errors disabled)
|
||||||
|
|
@ -35,11 +42,26 @@ pub mod ui;
|
||||||
pub use admin::admin_layout::{
|
pub use admin::admin_layout::{
|
||||||
AdminBreadcrumb, AdminCard, AdminEmptyState, AdminHeader, AdminLayout, AdminSection,
|
AdminBreadcrumb, AdminCard, AdminEmptyState, AdminHeader, AdminLayout, AdminSection,
|
||||||
};
|
};
|
||||||
|
pub use auth::server_fns::{
|
||||||
|
server_get_bookmarks, server_get_session_user, server_is_bookmarked, server_logout_all,
|
||||||
|
server_refresh_token, server_remove_bookmark, BookmarkInfo,
|
||||||
|
};
|
||||||
|
// Re-export auth components
|
||||||
|
pub use auth::{server_request_otp, server_verify_otp, LoginModal};
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use auth::{OtpServiceExtension, OtpServiceFacade, OtpVerifyResult};
|
||||||
|
// Re-export bookmark components
|
||||||
|
pub use bookmarks::BookmarkButton;
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub use contact::facade::server::{ContactFormExtension, ContactFormFacade};
|
||||||
|
pub use contact::server_send_contact_form;
|
||||||
// pub use ui::mobile_menu::{MobileMenu, MobileMenuToggle};
|
// pub use ui::mobile_menu::{MobileMenu, MobileMenuToggle};
|
||||||
// pub use ui::page_transition::{PageTransition, SimplePageTransition, TransitionStyle};
|
// pub use ui::page_transition::{PageTransition, SimplePageTransition, TransitionStyle};
|
||||||
|
|
||||||
// Re-export content components
|
// Re-export content components
|
||||||
pub use content::{ContentManager, HtmlContent, SimpleContentGrid, UnifiedContentCard};
|
pub use content::{ContentManager, HtmlContent, SimpleContentGrid, UnifiedContentCard};
|
||||||
|
// Re-export cookie components
|
||||||
|
pub use cookie::{CookieBanner, CookieManageModal};
|
||||||
pub use filter::{
|
pub use filter::{
|
||||||
clear_filter_cache,
|
clear_filter_cache,
|
||||||
get_active_button_classes,
|
get_active_button_classes,
|
||||||
|
|
@ -49,9 +71,8 @@ pub use filter::{
|
||||||
// Re-enabled after fixing spawn_local compilation errors
|
// Re-enabled after fixing spawn_local compilation errors
|
||||||
UnifiedCategoryFilter,
|
UnifiedCategoryFilter,
|
||||||
};
|
};
|
||||||
// Re-export client-specific theme types when available
|
pub use navigation::command_palette::CommandPaletteExtraItems;
|
||||||
#[cfg(target_arch = "wasm32")]
|
pub use navigation::command_palette::CommandPaletteOpen;
|
||||||
pub use theme::{use_theme, ThemeContext};
|
|
||||||
// Re-export navigation components (commented out until fixed)
|
// Re-export navigation components (commented out until fixed)
|
||||||
// pub use navigation::{BrandHeader, Footer, LanguageSelector, NavMenu};
|
// pub use navigation::{BrandHeader, Footer, LanguageSelector, NavMenu};
|
||||||
|
|
||||||
|
|
@ -61,5 +82,15 @@ pub use theme::{use_theme, ThemeContext};
|
||||||
// Re-export theme components (commented out until fixed)
|
// Re-export theme components (commented out until fixed)
|
||||||
// pub use theme::{DarkModeToggle, EffectiveTheme, ThemeMode, ThemeProvider, ThemeUtils};
|
// pub use theme::{DarkModeToggle, EffectiveTheme, ThemeMode, ThemeProvider, ThemeUtils};
|
||||||
|
|
||||||
|
// Re-export navigation overlays
|
||||||
|
pub use navigation::CommandPalette;
|
||||||
|
pub use navigation::FloatingPill;
|
||||||
|
// Re-export client-specific theme types when available
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use theme::{use_theme, ThemeContext};
|
||||||
|
pub use toast::{
|
||||||
|
provide_toast_context, push_toast, push_toast_to, ToastContainer, ToastKind, ToastNotification,
|
||||||
|
};
|
||||||
// Re-export UI components
|
// Re-export UI components
|
||||||
|
pub use ui::image_modal::ImageModal;
|
||||||
pub use ui::spa_link::SpaLink;
|
pub use ui::spa_link::SpaLink;
|
||||||
|
|
@ -12,7 +12,7 @@ pub fn LogoClient(
|
||||||
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
#[prop(default = false)] as_link: bool,
|
#[prop(default = false)] as_link: bool,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
let logo_config = config.unwrap_or_else(|| use_context::<LogoConfig>().unwrap_or_default());
|
||||||
// Get reactive theme/language if needed
|
// Get reactive theme/language if needed
|
||||||
let _current_lang = move || {
|
let _current_lang = move || {
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
|
@ -43,7 +43,7 @@ pub fn LogoLinkClient(
|
||||||
#[prop(optional)] config: Option<LogoConfig>,
|
#[prop(optional)] config: Option<LogoConfig>,
|
||||||
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
let logo_config = config.unwrap_or_else(|| use_context::<LogoConfig>().unwrap_or_default());
|
||||||
view! {
|
view! {
|
||||||
<crate::logo::unified::LogoLink config=logo_config class=class />
|
<crate::logo::unified::LogoLink config=logo_config class=class />
|
||||||
}
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ pub fn LogoSSR(
|
||||||
#[prop(default = false)] as_link: bool,
|
#[prop(default = false)] as_link: bool,
|
||||||
#[prop(default = "en".to_string())] _lang: String,
|
#[prop(default = "en".to_string())] _lang: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
let logo_config = config.unwrap_or_else(|| use_context::<LogoConfig>().unwrap_or_default());
|
||||||
// Delegate to unified implementation
|
// Delegate to unified implementation
|
||||||
view! {
|
view! {
|
||||||
<crate::logo::unified::Logo
|
<crate::logo::unified::Logo
|
||||||
|
|
@ -31,7 +31,7 @@ pub fn LogoLinkSSR(
|
||||||
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
#[prop(default = "h-8 w-auto".to_string())] class: String,
|
||||||
#[prop(default = "en".to_string())] _lang: String,
|
#[prop(default = "en".to_string())] _lang: String,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let logo_config = config.unwrap_or_else(|| LogoConfig::from_env());
|
let logo_config = config.unwrap_or_else(|| use_context::<LogoConfig>().unwrap_or_default());
|
||||||
view! {
|
view! {
|
||||||
<crate::logo::unified::LogoLink config=logo_config class=class />
|
<crate::logo::unified::LogoLink config=logo_config class=class />
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
//! Unified brand header component
|
||||||
|
//!
|
||||||
|
//! Provides consistent branding (logo or title) with identical behavior
|
||||||
|
//! across navigation menu and footer components.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Extract logo configuration from registered theme
|
||||||
|
///
|
||||||
|
/// This function reads from the registered theme resources (loaded at server
|
||||||
|
/// startup from config/themes/default.toml) to get configuration-driven logo
|
||||||
|
/// paths. PAP-compliant: Uses registered resources, no hardcoding.
|
||||||
|
fn get_logo_config() -> (String, String, String) {
|
||||||
|
// Always use fallback for consistency between SSR and client
|
||||||
|
// Theme loading happens at startup/server-side only
|
||||||
|
// For hydration safety, we render without dynamic theme lookups
|
||||||
|
(String::new(), String::new(), "Logo".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unified brand header that works consistently in nav and footer
|
||||||
|
#[component]
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub fn BrandHeader(
|
||||||
|
/// Language for loading configuration
|
||||||
|
#[prop(optional)]
|
||||||
|
language: Option<String>,
|
||||||
|
/// CSS classes for the brand element
|
||||||
|
#[prop(default = "text-xl font-bold".to_string())]
|
||||||
|
class: String,
|
||||||
|
/// Force show/hide logo (overrides config)
|
||||||
|
#[prop(optional)]
|
||||||
|
force_logo: Option<bool>,
|
||||||
|
/// Use footer logo settings instead of nav settings
|
||||||
|
#[prop(default = false)]
|
||||||
|
is_footer: bool,
|
||||||
|
) -> impl IntoView {
|
||||||
|
// Get logo configuration
|
||||||
|
let (logo_light, logo_dark, logo_alt) = get_logo_config();
|
||||||
|
|
||||||
|
// Always render text for hydration safety - use static classes to avoid string
|
||||||
|
// issues
|
||||||
|
view! {
|
||||||
|
<a href="/" class="text-xl font-bold transition-colors flex items-center gap-2">
|
||||||
|
<span class="ds-text">"Rustelo"</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
pub mod unified;
|
||||||
|
pub use unified::CommandPalette;
|
||||||
|
|
||||||
|
/// Context type for sharing the command palette open/close signal
|
||||||
|
/// between sibling components (e.g. FloatingPill toggle button).
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CommandPaletteOpen(pub leptos::prelude::RwSignal<bool>);
|
||||||
|
|
||||||
|
/// Context type for injecting build-time application pages into the command
|
||||||
|
/// palette. Provided by the website client from the generated
|
||||||
|
/// `get_palette_app_pages()` function. Each entry is `(display_label,
|
||||||
|
/// route_path)`.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CommandPaletteExtraItems(pub &'static [(&'static str, &'static str)]);
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
//! Command Palette — keyboard-driven navigation overlay (Cmd+K / Ctrl+K).
|
||||||
|
//!
|
||||||
|
//! WASM-only: SSR renders an empty fragment so hydration sees zero DOM nodes
|
||||||
|
//! from this component on both sides.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Global command palette triggered by Cmd+K / Ctrl+K.
|
||||||
|
///
|
||||||
|
/// Renders nothing in SSR. On the client, registers a document-level `keydown`
|
||||||
|
/// listener that toggles the overlay. Menu items are loaded from the menu
|
||||||
|
/// registry for the current language and filtered as the user types.
|
||||||
|
#[component]
|
||||||
|
pub fn CommandPalette() -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
view! { <></> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use leptos::html::Input;
|
||||||
|
use leptos::portal::Portal;
|
||||||
|
use rustelo_core_lib::state::use_current_language;
|
||||||
|
use rustelo_core_lib::utils::nav;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
||||||
|
let show = use_context::<crate::navigation::command_palette::CommandPaletteOpen>()
|
||||||
|
.map(|c| c.0)
|
||||||
|
.unwrap_or_else(|| RwSignal::new(false));
|
||||||
|
|
||||||
|
let query = RwSignal::new(String::new());
|
||||||
|
let selected_idx = RwSignal::new(0usize);
|
||||||
|
let lang_signal = use_current_language();
|
||||||
|
|
||||||
|
let extra_items_ctx =
|
||||||
|
use_context::<crate::navigation::command_palette::CommandPaletteExtraItems>();
|
||||||
|
|
||||||
|
// Both signals are Copy — captured by move into items_memo for reactive
|
||||||
|
// filtering.
|
||||||
|
let deny_paths_signal = use_context::<RwSignal<Vec<String>>>();
|
||||||
|
let auth_user_signal = use_context::<rustelo_core_lib::state::AuthState>().map(|a| a.user);
|
||||||
|
|
||||||
|
let items_memo = Memo::new(move |_| {
|
||||||
|
let lang = lang_signal.get();
|
||||||
|
|
||||||
|
// Nav menu items (non-external)
|
||||||
|
let mut items: Vec<(String, String)> =
|
||||||
|
rustelo_core_lib::load_reactive_menu_for_language(&lang)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| !item.is_external.unwrap_or(false))
|
||||||
|
.map(|item| {
|
||||||
|
let label = item
|
||||||
|
.label
|
||||||
|
.get_for_language(&lang)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| item.route.clone());
|
||||||
|
let route = item.get_route_for_language(&lang).to_string();
|
||||||
|
(label, route)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Footer menu items (non-external, non-auth)
|
||||||
|
if let Ok(footer) = rustelo_core_lib::load_reactive_footer_for_language(&lang) {
|
||||||
|
for item in footer.menu {
|
||||||
|
if !item.is_external.unwrap_or(false) && !item.auth_required {
|
||||||
|
let label = item
|
||||||
|
.label
|
||||||
|
.get_for_language(&lang)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| item.route.clone());
|
||||||
|
items.push((label, item.route));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static application pages injected by the website client at build time
|
||||||
|
if let Some(extra) = extra_items_ctx {
|
||||||
|
for &(label, route) in extra.0 {
|
||||||
|
items.push((label.to_string(), route.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup by route — preserve first occurrence order
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
items.retain(|(_, route)| seen.insert(route.clone()));
|
||||||
|
|
||||||
|
// Filter auth-required routes for unauthenticated users.
|
||||||
|
// deny_paths comes from the RBAC WebSocket — updates reactively on policy
|
||||||
|
// reload.
|
||||||
|
let is_authed = auth_user_signal.map(|s| s.get().is_some()).unwrap_or(false);
|
||||||
|
if !is_authed {
|
||||||
|
if let Some(deny_sig) = deny_paths_signal {
|
||||||
|
let deny = deny_sig.get();
|
||||||
|
items.retain(|(_, route)| {
|
||||||
|
!deny.iter().any(|pattern| {
|
||||||
|
if let Some(prefix) = pattern.strip_suffix("/*") {
|
||||||
|
route == prefix || route.starts_with(&format!("{prefix}/"))
|
||||||
|
} else {
|
||||||
|
route.as_str() == pattern.as_str()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered = Memo::new(move |_| {
|
||||||
|
let q = query.get().to_lowercase();
|
||||||
|
let all = items_memo.get();
|
||||||
|
if q.is_empty() {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|(label, route)| {
|
||||||
|
label.to_lowercase().contains(&q) || route.to_lowercase().contains(&q)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset selection to first item whenever the query changes.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
query.get();
|
||||||
|
selected_idx.set(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
let set_path_ctx = use_context::<WriteSignal<String>>();
|
||||||
|
|
||||||
|
// NodeRef for focusing the search input when the palette opens.
|
||||||
|
let input_ref = NodeRef::<Input>::new();
|
||||||
|
|
||||||
|
// Reset state and focus input on every open, covering all open paths
|
||||||
|
// (Cmd+K and FloatingPill button) without relying on `autofocus`.
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if show.get() {
|
||||||
|
query.set(String::new());
|
||||||
|
selected_idx.set(0);
|
||||||
|
if let Some(input) = input_ref.get() {
|
||||||
|
let _ = input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document-level keydown: Cmd+K toggle, Esc close, arrows navigate, Enter
|
||||||
|
// select.
|
||||||
|
{
|
||||||
|
let closure = Closure::wrap(Box::new(move |e: web_sys::KeyboardEvent| {
|
||||||
|
let key = e.key();
|
||||||
|
|
||||||
|
if !show.get_untracked() {
|
||||||
|
if (key == "k" || key == "K") && (e.meta_key() || e.ctrl_key()) {
|
||||||
|
e.prevent_default();
|
||||||
|
show.set(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.as_str() {
|
||||||
|
"Escape" => show.set(false),
|
||||||
|
"k" if e.meta_key() || e.ctrl_key() => {
|
||||||
|
e.prevent_default();
|
||||||
|
show.set(false);
|
||||||
|
}
|
||||||
|
"ArrowDown" => {
|
||||||
|
e.prevent_default();
|
||||||
|
let max = filtered.get_untracked().len().saturating_sub(1);
|
||||||
|
selected_idx.update(|v| *v = (*v + 1).min(max));
|
||||||
|
}
|
||||||
|
"ArrowUp" => {
|
||||||
|
e.prevent_default();
|
||||||
|
selected_idx.update(|v| *v = v.saturating_sub(1));
|
||||||
|
}
|
||||||
|
"Enter" => {
|
||||||
|
e.prevent_default();
|
||||||
|
let items = filtered.get_untracked();
|
||||||
|
let idx = selected_idx.get_untracked();
|
||||||
|
if let Some((_, route)) = items.get(idx) {
|
||||||
|
if let Some(sp) = set_path_ctx {
|
||||||
|
nav::anchor_navigate(sp, route);
|
||||||
|
}
|
||||||
|
show.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}) as Box<dyn FnMut(_)>);
|
||||||
|
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
let _ = document.add_event_listener_with_callback(
|
||||||
|
"keydown",
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal mounts into document.body outside the SSR hydration stream.
|
||||||
|
// display:none guarantees zero pointer-events when closed, eliminating
|
||||||
|
// any z-index race with FloatingPill (z-9990) regardless of async timing.
|
||||||
|
view! {
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
style=move || if show.get() {
|
||||||
|
"position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);"
|
||||||
|
} else {
|
||||||
|
"display:none;"
|
||||||
|
}
|
||||||
|
on:click=move |_| show.set(false)
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="rounded bg-gray-800 text-white dark:bg-gray-300 dark:text-gray-900"
|
||||||
|
style=move || if show.get() {
|
||||||
|
"position:fixed;z-index:9999;top:20vh;left:50%;transform:translateX(-50%);width:min(calc(100vw - 2rem),32rem);pointer-events:none;"
|
||||||
|
} else {
|
||||||
|
"display:none;"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="pointer-events:auto;background:var(--b1,#ffffff);border:1px solid var(--b3,#d1d5db);border-radius:1rem;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);overflow:hidden;color:var(--bc,#1f2937);">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;padding:0.75rem 1rem;border-bottom:1px solid var(--b3,#d1d5db);">
|
||||||
|
<svg style="width:1rem;height:1rem;flex-shrink:0;opacity:0.4;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="command-palette-search"
|
||||||
|
name="command-palette-search"
|
||||||
|
node_ref=input_ref
|
||||||
|
style="flex:1;background:transparent;outline:none;font-size:0.875rem;color:inherit;border:0;min-width:0;"
|
||||||
|
placeholder="Search..."
|
||||||
|
prop:value=move || query.get()
|
||||||
|
on:input=move |ev| query.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<kbd style="font-size:0.75rem;opacity:0.4;border:1px solid var(--b3,#d1d5db);border-radius:0.25rem;padding:0.125rem 0.375rem;font-family:monospace;flex-shrink:0;">{"esc"}</kbd>
|
||||||
|
</div>
|
||||||
|
<ul style="max-height:18rem;overflow-y:auto;padding:0.25rem 0;margin:0;list-style:none;">
|
||||||
|
{move || {
|
||||||
|
let items = filtered.get();
|
||||||
|
let sel = selected_idx.get();
|
||||||
|
if items.is_empty() {
|
||||||
|
view! {
|
||||||
|
<li style="padding:1.5rem 1rem;font-size:0.875rem;opacity:0.5;text-align:center;">
|
||||||
|
{"No results"}
|
||||||
|
</li>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, (label, route))| {
|
||||||
|
let route_nav = route.clone();
|
||||||
|
let sp = set_path_ctx;
|
||||||
|
let is_sel = idx == sel;
|
||||||
|
let btn_style = if is_sel {
|
||||||
|
"display:flex;align-items:center;gap:0.75rem;width:100%;padding:0.625rem 1rem;font-size:0.875rem;text-align:left;border:0;border-radius:0.5rem;cursor:pointer;color:inherit;background:rgba(99,102,241,0.18);"
|
||||||
|
} else {
|
||||||
|
"display:flex;align-items:center;gap:0.75rem;width:100%;padding:0.625rem 1rem;font-size:0.875rem;text-align:left;border:0;border-radius:0.5rem;cursor:pointer;color:inherit;background:transparent;"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<li style="margin:2px 4px;">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style=btn_style
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(sp) = sp {
|
||||||
|
nav::anchor_navigate(sp, &route_nav);
|
||||||
|
}
|
||||||
|
show.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg style="width:0.875rem;height:0.875rem;flex-shrink:0;opacity:0.4;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</ul>
|
||||||
|
<div style="padding:0.5rem 1rem;border-top:1px solid var(--b3,#d1d5db);display:flex;gap:1rem;font-size:0.75rem;opacity:0.5;">
|
||||||
|
<span>
|
||||||
|
<kbd style="border:1px solid var(--b3,#d1d5db);border-radius:0.25rem;padding:0 0.25rem;font-family:monospace;">{"↑↓"}</kbd>
|
||||||
|
{" navigate"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd style="border:1px solid var(--b3,#d1d5db);border-radius:0.25rem;padding:0 0.25rem;font-family:monospace;">{"↵"}</kbd>
|
||||||
|
{" select"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd style="border:1px solid var(--b3,#d1d5db);border-radius:0.25rem;padding:0 0.25rem;font-family:monospace;">{"esc"}</kbd>
|
||||||
|
{" close"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod unified;
|
||||||
|
pub use unified::FloatingPill;
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
//! Floating Pill Navigation — compact centered nav that appears on scroll-up.
|
||||||
|
//!
|
||||||
|
//! WASM-only: SSR renders nothing. The pill appears when the user scrolls down
|
||||||
|
//! past a threshold, and hides when they scroll back toward the top or have
|
||||||
|
//! been idle at the top. Navigation uses the SPA set_path context.
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
/// Scroll-aware floating pill navigation bar.
|
||||||
|
///
|
||||||
|
/// Appears after scrolling 80px down, hides on scroll-up past the threshold.
|
||||||
|
/// Renders nothing in SSR. Items come from the same menu registry as the
|
||||||
|
/// main nav, filtered to internal (non-external) links only.
|
||||||
|
#[component]
|
||||||
|
pub fn FloatingPill() -> impl IntoView {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
view! { <></> }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use leptos::portal::Portal;
|
||||||
|
use rustelo_core_lib::state::use_current_language;
|
||||||
|
use rustelo_core_lib::utils::nav;
|
||||||
|
use wasm_bindgen::closure::Closure;
|
||||||
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
||||||
|
// Visible once scrolled past SHOW_THRESHOLD px.
|
||||||
|
let visible = RwSignal::new(false);
|
||||||
|
let lang_signal = use_current_language();
|
||||||
|
let set_path_ctx = use_context::<WriteSignal<String>>();
|
||||||
|
// Palette open signal — used to render the search button inside the pill.
|
||||||
|
let palette_open =
|
||||||
|
use_context::<crate::navigation::command_palette::CommandPaletteOpen>().map(|c| c.0);
|
||||||
|
|
||||||
|
// Attach scroll listener — show/hide pill based on Y position.
|
||||||
|
// Hidden when near the bottom to avoid overlapping the footer.
|
||||||
|
{
|
||||||
|
const SHOW_THRESHOLD: f64 = 80.0;
|
||||||
|
const NEAR_BOTTOM_THRESHOLD: f64 = 120.0;
|
||||||
|
let closure = Closure::wrap(Box::new(move || {
|
||||||
|
if let Some(w) = web_sys::window() {
|
||||||
|
let scroll_y = w.scroll_y().unwrap_or(0.0);
|
||||||
|
let near_bottom = w
|
||||||
|
.document()
|
||||||
|
.and_then(|d| d.document_element())
|
||||||
|
.and_then(|root| {
|
||||||
|
w.inner_height().ok().and_then(|vh| {
|
||||||
|
let doc_h = root.scroll_height() as f64;
|
||||||
|
let vp_h = vh.as_f64().unwrap_or(0.0);
|
||||||
|
Some(doc_h - scroll_y - vp_h < NEAR_BOTTOM_THRESHOLD)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
visible.set(scroll_y > SHOW_THRESHOLD && !near_bottom);
|
||||||
|
}
|
||||||
|
}) as Box<dyn Fn()>);
|
||||||
|
|
||||||
|
if let Some(w) = web_sys::window() {
|
||||||
|
let _ =
|
||||||
|
w.add_event_listener_with_callback("scroll", closure.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
|
closure.forget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the nav items are expanded.
|
||||||
|
let nav_open = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Reactive tooltip label — re-computed when language or open state changes.
|
||||||
|
let toggle_label = Memo::new(move |_| {
|
||||||
|
let lang = lang_signal.get();
|
||||||
|
let key = if nav_open.get() {
|
||||||
|
"nav-pill-hide"
|
||||||
|
} else {
|
||||||
|
"nav-pill-show"
|
||||||
|
};
|
||||||
|
::rustelo_core_lib::i18n::UnifiedI18n::new(&lang, "/").t(key)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu items — only internal, non-auth-required links.
|
||||||
|
let items_memo = Memo::new(move |_| {
|
||||||
|
let lang = lang_signal.get();
|
||||||
|
let all = rustelo_core_lib::load_reactive_menu_for_language(&lang).unwrap_or_default();
|
||||||
|
all.into_iter()
|
||||||
|
.filter(|item| !item.is_external.unwrap_or(false) && !item.auth_required)
|
||||||
|
.map(|item| {
|
||||||
|
let label = item
|
||||||
|
.label
|
||||||
|
.get_for_language(&lang)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| item.route.clone());
|
||||||
|
let route = item.get_route_for_language(&lang).to_string();
|
||||||
|
(label, route)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Portal mounts the pill into document.body outside the SSR hydration stream.
|
||||||
|
// display:none when not visible eliminates any z-index interaction with
|
||||||
|
// CommandPalette (z-9998/9999) while the pill is hidden.
|
||||||
|
view! {
|
||||||
|
<Portal>
|
||||||
|
<nav
|
||||||
|
class="floating-pill"
|
||||||
|
style=move || if !visible.get() { "display:none;" } else { "" }
|
||||||
|
aria-label="Quick navigation"
|
||||||
|
>
|
||||||
|
// Toggle button — always visible
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-icon-btn"
|
||||||
|
on:click=move |_| nav_open.update(|v| *v = !*v)
|
||||||
|
aria-label=move || toggle_label.get()
|
||||||
|
aria-expanded=move || nav_open.get().to_string()
|
||||||
|
title=move || toggle_label.get()
|
||||||
|
>
|
||||||
|
{move || if nav_open.get() {
|
||||||
|
// X close — filled crosses
|
||||||
|
view! {
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
|
||||||
|
</svg>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
// Hamburger — three solid bars
|
||||||
|
view! {
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<rect x="2" y="4" width="16" height="2.5" rx="1.25"/>
|
||||||
|
<rect x="2" y="9" width="16" height="2.5" rx="1.25"/>
|
||||||
|
<rect x="2" y="14" width="16" height="2.5" rx="1.25"/>
|
||||||
|
</svg>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Command palette button — always visible
|
||||||
|
{palette_open.map(|sig| view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-icon-btn"
|
||||||
|
on:click=move |_| sig.update(|v| *v = !*v)
|
||||||
|
title="Cmd+K"
|
||||||
|
aria-label="Open command palette"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
|
||||||
|
// Nav items — scrollable container so items never push toggle off-screen
|
||||||
|
<Show when=move || nav_open.get()>
|
||||||
|
<div class="pill-items">
|
||||||
|
<span class="pill-divider"></span>
|
||||||
|
{move || {
|
||||||
|
items_memo
|
||||||
|
.get()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(label, route)| {
|
||||||
|
let route_nav = route.clone();
|
||||||
|
let sp = set_path_ctx;
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill-nav-btn"
|
||||||
|
on:click=move |_| {
|
||||||
|
if let Some(sp) = sp {
|
||||||
|
nav::anchor_navigate(sp, &route_nav);
|
||||||
|
}
|
||||||
|
nav_open.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect_view()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</nav>
|
||||||
|
</Portal>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,15 +28,21 @@ pub fn FooterSSR(
|
||||||
let ssr_i18n = SsrTranslator::new(language.clone());
|
let ssr_i18n = SsrTranslator::new(language.clone());
|
||||||
let patterns = ["footer-", "lang-"];
|
let patterns = ["footer-", "lang-"];
|
||||||
let lang_content = build_page_content_patterns(&ssr_i18n, &patterns);
|
let lang_content = build_page_content_patterns(&ssr_i18n, &patterns);
|
||||||
|
let class_str = class.unwrap_or_default();
|
||||||
|
|
||||||
view! {
|
// Wrap in a DynChild closure to match FooterClient's `move || { view! {
|
||||||
<UnifiedFooter
|
// <UnifiedFooter /> } }` structure — both SSR and WASM must emit identical
|
||||||
language=language
|
// reactive-node (DynChild marker) counts.
|
||||||
lang_content=lang_content
|
move || {
|
||||||
class=class.clone().unwrap_or_default()
|
view! {
|
||||||
show_social=show_social
|
<UnifiedFooter
|
||||||
show_scroll_to_top=show_scroll_to_top
|
language=language.clone()
|
||||||
/>
|
lang_content=lang_content.clone()
|
||||||
|
class=class_str.clone()
|
||||||
|
show_social=show_social
|
||||||
|
show_scroll_to_top=show_scroll_to_top
|
||||||
|
/>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
use ::rustelo_core_lib::load_reactive_footer_for_language;
|
use ::rustelo_core_lib::load_reactive_footer_for_language;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
use crate::navigation::brand_header::BrandHeader;
|
use crate::logo::unified::Logo;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::navigation::footer::client::FooterClient;
|
use crate::navigation::footer::client::FooterClient;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
@ -135,6 +135,11 @@ pub fn UnifiedFooter(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Logo config provided by the app root via provide_context — no env vars, no
|
||||||
|
// filesystem reads
|
||||||
|
let logo_config = use_context::<::rustelo_core_lib::defs::LogoConfig>().unwrap_or_default();
|
||||||
|
let home_url = ::rustelo_core_lib::localize_url("/", ¤t_lang);
|
||||||
|
|
||||||
let base_class = "ds-bg-surface border-t border-gray-200 mt-auto";
|
let base_class = "ds-bg-surface border-t border-gray-200 mt-auto";
|
||||||
let footer_class = format!("{} {}", base_class, class.unwrap_or_default());
|
let footer_class = format!("{} {}", base_class, class.unwrap_or_default());
|
||||||
|
|
||||||
|
|
@ -186,15 +191,29 @@ pub fn UnifiedFooter(
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
// Company info from footer config - consistent with navigation
|
// Company info from footer config - consistent with navigation
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<div class="flex items-center mb-1">
|
// Logo — SPA-navigates to home page on click
|
||||||
<div class="flex-shrink-0">
|
{logo_config.show_in_footer.then(move || {
|
||||||
<BrandHeader
|
let nav_sigs = navigation_signals;
|
||||||
language={current_lang.clone()}
|
let dest = home_url.clone();
|
||||||
class="h-8 w-auto text-xl font-bold".to_string()
|
let logo_footer_config = logo_config.clone();
|
||||||
is_footer=true
|
view! {
|
||||||
/>
|
<a
|
||||||
</div>
|
href={dest.clone()}
|
||||||
</div>
|
class="inline-block mb-4"
|
||||||
|
on:click=move |ev| {
|
||||||
|
if let Some((_, set_path)) = nav_sigs {
|
||||||
|
ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
::rustelo_core_lib::utils::nav::anchor_navigate(set_path, &dest);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
set_path.set(dest.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Logo config=logo_footer_config class="h-8 w-auto".to_string() />
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
})}
|
||||||
<p class="ds-text-secondary text-sm mb-4">
|
<p class="ds-text-secondary text-sm mb-4">
|
||||||
{footer_config.company_desc}
|
{footer_config.company_desc}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -226,7 +245,7 @@ pub fn UnifiedFooter(
|
||||||
let name = social.name.clone();
|
let name = social.name.clone();
|
||||||
// Extract icon class for proper rendering
|
// Extract icon class for proper rendering
|
||||||
let icon_html = match social.icon_class.as_str() {
|
let icon_html = match social.icon_class.as_str() {
|
||||||
icon if icon.contains("github") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>"#.to_string(),
|
icon if icon.contains("github") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315 .225 .69 .825 .57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>"#.to_string(),
|
||||||
icon if icon.contains("linkedin") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>"#.to_string(),
|
icon if icon.contains("linkedin") => r#"<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>"#.to_string(),
|
||||||
_ => format!(r#"<i class="{}" aria-hidden="true"></i>"#, social.icon_class),
|
_ => format!(r#"<i class="{}" aria-hidden="true"></i>"#, social.icon_class),
|
||||||
};
|
};
|
||||||
|
|
@ -245,13 +264,46 @@ pub fn UnifiedFooter(
|
||||||
</div>
|
</div>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// "Built with Rustelo" attribution bar
|
||||||
|
{
|
||||||
|
let built_label = get_content("footer-built-with");
|
||||||
|
let rustelo_route = "/rustelo".to_string();
|
||||||
|
let rustelo_route_click = rustelo_route.clone();
|
||||||
|
let nav_sigs = navigation_signals;
|
||||||
|
view! {
|
||||||
|
<div class="border-t border-base-300 mt-8 pt-5 flex justify-center items-center gap-2 text-sm ds-text-secondary">
|
||||||
|
<span>{built_label}</span>
|
||||||
|
<a
|
||||||
|
href={rustelo_route}
|
||||||
|
class="inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity no-underline"
|
||||||
|
on:click=move |ev| {
|
||||||
|
if let Some((_, set_path)) = nav_sigs {
|
||||||
|
ev.prevent_default();
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
::rustelo_core_lib::utils::nav::anchor_navigate(set_path, &rustelo_route_click);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
set_path.set(rustelo_route_click.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/logos/rustelo-img.svg"
|
||||||
|
alt="Rustelo"
|
||||||
|
class="h-5 w-auto"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-700 dark:text-gray-500">Rustelo</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
// Scroll to top button (if enabled) - small fixed position button
|
// Scroll to top button (if enabled) - small fixed position button
|
||||||
{show_scroll_to_top.then(|| view! {
|
{show_scroll_to_top.then(|| view! {
|
||||||
<button
|
<button
|
||||||
class="fixed bottom-4 right-4 w-10 h-10 ds-bg-primary text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center z-50"
|
class="fixed bottom-4 right-4 w-10 h-10 bg-gray-800 dark:bg-gray-600 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-gray-700 dark:hover:bg-gray-500 transition-all duration-300 flex items-center justify-center z-50"
|
||||||
on:click=scroll_to_top
|
on:click=scroll_to_top
|
||||||
aria-label="Scroll to top"
|
aria-label="Scroll to top"
|
||||||
title="Scroll to top"
|
title="Scroll to top"
|
||||||
|
|
@ -67,6 +67,7 @@ pub fn LanguageSelectorClient(
|
||||||
class=class.unwrap_or_default()
|
class=class.unwrap_or_default()
|
||||||
current_language=Some(current_lang)
|
current_language=Some(current_lang)
|
||||||
content=lang_content
|
content=lang_content
|
||||||
|
navigation_signals=None
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ pub fn LanguageSelectorSSR(
|
||||||
current_language=Some(lang)
|
current_language=Some(lang)
|
||||||
_current_path=current_path.unwrap_or_default()
|
_current_path=current_path.unwrap_or_default()
|
||||||
content=lang_content
|
content=lang_content
|
||||||
|
navigation_signals=None
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +52,7 @@ pub fn LanguageToggleSSR(
|
||||||
current_language=Some(lang)
|
current_language=Some(lang)
|
||||||
_current_path=current_path.unwrap_or_default()
|
_current_path=current_path.unwrap_or_default()
|
||||||
content=lang_content
|
content=lang_content
|
||||||
|
navigation_signals=None
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,10 +18,12 @@ pub fn LanguageSelector(
|
||||||
current_language: Option<String>,
|
current_language: Option<String>,
|
||||||
#[prop(optional)] _current_path: Option<String>,
|
#[prop(optional)] _current_path: Option<String>,
|
||||||
#[prop(optional)] content: Option<std::collections::HashMap<String, String>>,
|
#[prop(optional)] content: Option<std::collections::HashMap<String, String>>,
|
||||||
/// Navigation signals for client-side routing (optional)
|
/// Navigation signals for client-side routing (None on SSR, Some on WASM)
|
||||||
#[prop(optional)]
|
|
||||||
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
navigation_signals: Option<(ReadSignal<String>, WriteSignal<String>)>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
|
// current_language prop kept for call-site API compatibility; active state is
|
||||||
|
// read reactively from the language context instead.
|
||||||
|
let _ = current_language;
|
||||||
let selector_class = class.unwrap_or_default();
|
let selector_class = class.unwrap_or_default();
|
||||||
|
|
||||||
// Get language registry for auto-detection
|
// Get language registry for auto-detection
|
||||||
|
|
@ -94,45 +96,65 @@ pub fn LanguageSelector(
|
||||||
let set_path = navigation_signals.map(|(_, set_path)| set_path);
|
let set_path = navigation_signals.map(|(_, set_path)| set_path);
|
||||||
|
|
||||||
// Generate dropdown items reactively from available languages
|
// Generate dropdown items reactively from available languages
|
||||||
let language_options = move || {
|
// Build one button per language. All dynamic state (class, disabled, check
|
||||||
available_languages
|
// mark) uses reactive closures reading language_context.current directly —
|
||||||
.iter()
|
// this guarantees updates via fine-grained reactivity without relying on
|
||||||
.map(|lang_code| {
|
// tachys Vec reconciliation, which patches DOM attributes but never
|
||||||
// Use static current_language prop to ensure SSR/client consistency
|
// replaces on:click handlers.
|
||||||
// This avoids hydration mismatches by using the same value on both sides
|
let language_options: Vec<_> = available_languages
|
||||||
let is_current = lang_code == current_language.as_ref().unwrap_or(&"en".to_string());
|
.iter()
|
||||||
|
.map(|lang_code| {
|
||||||
|
let display_name = registry.get_display_name(lang_code, false);
|
||||||
|
|
||||||
let button_class = if is_current {
|
// Per-button signal reads — each needs its own clone of the context handle
|
||||||
"w-full text-left px-3 py-2 text-sm text-gray-400 cursor-not-allowed flex items-center"
|
// and language code so the reactive closures can capture them by move.
|
||||||
} else {
|
let lc_class = lang_code.clone();
|
||||||
"w-full text-left px-3 py-2 text-sm ds-text hover:ds-bg-hover flex items-center"
|
let ctx_class = language_context.clone();
|
||||||
};
|
let lc_disabled = lang_code.clone();
|
||||||
|
let ctx_disabled = language_context.clone();
|
||||||
|
let lc_check = lang_code.clone();
|
||||||
|
let ctx_check = language_context.clone();
|
||||||
|
|
||||||
// Get language-agnostic display name using registry method
|
// Create the language option button
|
||||||
let display_check = if is_current {
|
view! {
|
||||||
"✓"
|
<button
|
||||||
} else {
|
class=move || {
|
||||||
""
|
let is_active = {
|
||||||
};
|
#[cfg(target_arch = "wasm32")]
|
||||||
let display_name = registry.get_display_name(lang_code, false);
|
{ ctx_class.current.get() == lc_class }
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ ctx_class.current.get_untracked() == lc_class }
|
||||||
|
};
|
||||||
|
if is_active {
|
||||||
|
"w-full text-left px-3 py-2 text-sm text-gray-400 cursor-not-allowed flex items-center"
|
||||||
|
} else {
|
||||||
|
"w-full text-left px-3 py-2 text-sm ds-text hover:ds-bg-hover flex items-center"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disabled=move || {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{ ctx_disabled.current.get() == lc_disabled }
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ ctx_disabled.current.get_untracked() == lc_disabled }
|
||||||
|
}
|
||||||
|
data-language={lang_code.clone()}
|
||||||
|
hx-post={format!("/api/htmx/lang/{}", lang_code)}
|
||||||
|
hx-target="#lang-selector"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
on:click={
|
||||||
|
let _btn_lang_code = lang_code.clone();
|
||||||
|
let lang_code_clone = lang_code.clone();
|
||||||
|
let _set_path_signal = set_path;
|
||||||
|
let _current_path_clone = _current_path.clone();
|
||||||
|
let _lang_ctx = language_context.clone();
|
||||||
|
move |e: MouseEvent| {
|
||||||
|
// Read signal at click time — on:click is never replaced by
|
||||||
|
// tachys reconciliation, so we cannot rely on captured values.
|
||||||
|
let is_active_now = _lang_ctx.current.get_untracked() == _btn_lang_code;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
web_sys::console::log_1(&format!("🌍 Language Selector: Language button clicked - {}, is_active_now: {}", lang_code_clone, is_active_now).into());
|
||||||
|
|
||||||
// Create the language option button
|
if !is_active_now {
|
||||||
view! {
|
|
||||||
<button
|
|
||||||
class={button_class}
|
|
||||||
disabled={is_current}
|
|
||||||
data-language={lang_code.clone()}
|
|
||||||
on:click={
|
|
||||||
let _btn_lang_code = lang_code.clone();
|
|
||||||
let lang_code_clone = lang_code.clone();
|
|
||||||
let _set_path_signal = set_path;
|
|
||||||
let _current_path_clone = _current_path.clone();
|
|
||||||
let _lang_ctx = language_context.clone();
|
|
||||||
move |e: MouseEvent| {
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
web_sys::console::log_1(&format!("🌍 Language Selector: Language button clicked - {}, is_current: {}", lang_code_clone, is_current).into());
|
|
||||||
|
|
||||||
if !is_current {
|
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
set_is_open.set(false); // Close dropdown after selection
|
set_is_open.set(false); // Close dropdown after selection
|
||||||
|
|
||||||
|
|
@ -198,13 +220,25 @@ pub fn LanguageSelector(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span class="mx-2 text-xs">{display_check} </span>
|
<span class="mx-2 text-xs">
|
||||||
|
{move || {
|
||||||
|
let is_active = {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{ ctx_check.current.get() == lc_check }
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{ ctx_check.current.get_untracked() == lc_check }
|
||||||
|
};
|
||||||
|
if is_active { "✓ " } else { "" }
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
<span>{display_name}</span>
|
<span>{display_name}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect();
|
||||||
};
|
|
||||||
|
// Vec<View> is not Clone in Leptos 0.8 — render it directly.
|
||||||
|
// All reactivity lives inside each button via reactive closures.
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let aria_expanded = move || _is_open.get();
|
let aria_expanded = move || _is_open.get();
|
||||||
|
|
@ -213,10 +247,10 @@ pub fn LanguageSelector(
|
||||||
|
|
||||||
// Interactive dropdown structure
|
// Interactive dropdown structure
|
||||||
view! {
|
view! {
|
||||||
<div class={format!("language-selector relative {}", selector_class)}>
|
<div id="lang-selector" class={format!("language-selector relative {}", selector_class)}>
|
||||||
// Button with SVG icon only
|
// Button with SVG icon only
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm ds-text hover:ds-text-secondary flex items-center justify-center w-8 h-8 text-sm"
|
class="btn btn-ghost btn-sm btn-icon ds-text hover:ds-text-secondary flex items-center justify-center text-sm"
|
||||||
aria-label={aria_label.clone()}
|
aria-label={aria_label.clone()}
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={aria_expanded}
|
aria-expanded={aria_expanded}
|
||||||
|
|
@ -249,7 +283,7 @@ pub fn LanguageSelector(
|
||||||
aria-label="Language selection"
|
aria-label="Language selection"
|
||||||
>
|
>
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
{language_options()}
|
{language_options}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue