fix: server side rendering for pattern detail

This commit is contained in:
2026-07-16 10:11:35 +02:00
parent b0c341a189
commit e504971740
4 changed files with 245 additions and 62 deletions
+159
View File
@@ -0,0 +1,159 @@
"""Renderizado del patrón a HTML plano para la vista de solo lectura.
A diferencia del editor (que construye el resultado con JS a partir del DOM
editable, ver static/js/section-types.js), esto genera HTML estático en el
servidor, sin ningún <input>/<button> ni JS de edición: así no hay nada que
"revelar" quitando una clase `hidden` desde las herramientas de desarrollo
del navegador, porque el marcado editable simplemente no existe en esta
página. Reproduce las mismas clases de Tailwind que usa cada `render` de
SECTION_TYPES para que el resultado sea visualmente idéntico.
"""
import re
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
HEX_COLOR_RE = re.compile(r'^#[0-9a-fA-F]{6}$')
# Mismas opciones que ofrecen los <select> de "Personalización de página"
# (ver pattern.html): los valores guardados llegan tal cual desde el POST a
# pattern_save, sin validar allí, así que se comprueban aquí antes de
# volcarlos en un atributo style/@page (ver sanitize_page_settings).
ALLOWED_FONTS = {
'sans-serif',
'serif',
'Georgia, serif',
"'Trebuchet MS', sans-serif",
"'Courier New', monospace",
}
ALLOWED_ALIGN = {'left', 'center', 'justify'}
ALLOWED_FONT_SIZES = {'0.875rem', '1rem', '1.25rem'}
ALLOWED_PAGE_SIZES = {'A4', 'A3', 'A5', 'letter', 'legal'}
ALLOWED_ORIENTATIONS = {'portrait', 'landscape'}
def _clean_color(value, default):
return value if isinstance(value, str) and HEX_COLOR_RE.match(value) else default
def sanitize_page_settings(page_settings):
"""Reduce cualquier page_settings guardado a valores conocidos y
seguros de interpolar en un atributo style/@page, ignorando (en vez de
escapar) lo que no encaje: pattern_save guarda el JSON tal cual llega en
el POST, sin validar su contenido."""
page_settings = page_settings or {}
return {
'title': str(page_settings.get('title') or '').strip(),
'author': str(page_settings.get('author') or '').strip(),
'textColor': _clean_color(page_settings.get('textColor'), '#000000'),
'accentColor': _clean_color(page_settings.get('accentColor'), '#1d4ed8'),
'bgColor': _clean_color(page_settings.get('bgColor'), '#ffffff'),
'font': page_settings.get('font') if page_settings.get('font') in ALLOWED_FONTS else 'sans-serif',
'fontSize': page_settings.get('fontSize') if page_settings.get('fontSize') in ALLOWED_FONT_SIZES else '1rem',
'align': page_settings.get('align') if page_settings.get('align') in ALLOWED_ALIGN else 'left',
'pageSize': page_settings.get('pageSize') if page_settings.get('pageSize') in ALLOWED_PAGE_SIZES else 'A4',
'orientation': (
page_settings.get('orientation') if page_settings.get('orientation') in ALLOWED_ORIENTATIONS
else 'portrait'
),
}
def _line(tag, text, css_class):
text = (text or '').strip()
if not text:
return ''
return format_html('<{0} class="{1}">{2}</{0}>', tag, css_class, text)
def _render_group(section, lang, stitch_types_by_id):
inner = mark_safe(''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', [])))
if not inner:
return ''
return format_html('<div class="my-2 flex flex-col gap-1">{0}</div>', inner)
def _render_note(section, lang):
text = (section.get('text') or {}).get(lang, '').strip()
if not text:
return ''
style = ''
text_color = _clean_color(section.get('textColor'), None)
bg_color = _clean_color(section.get('bgColor'), None)
if text_color:
style += f'color: {text_color};'
if bg_color:
style += f'background-color: {bg_color};'
return format_html('<div class="alert text-[0.875em] my-2" style="{0}">{1}</div>', style, text)
def _render_materials(section, lang):
items = [(material.get(lang) or '').strip() for material in section.get('materials', [])]
items = [item for item in items if item]
if not items:
return ''
lis = format_html_join('', '<li>{0}</li>', ((item,) for item in items))
return format_html('<ul class="list-disc list-inside my-1">{0}</ul>', lis)
def _render_dnd(section, lang, stitch_types_by_id):
parts = []
for item in section.get('items', []):
stitch_type = stitch_types_by_id.get(item.get('stitchTypeId'))
if not stitch_type:
continue
label = stitch_type.get('translations', {}).get(lang, '')
parts.append(f"{item.get('count')} {label}".strip())
return _line('p', ', '.join(parts), '')
def _render_image(section):
url = section.get('url')
if not url:
return ''
return format_html('<img src="{0}" class="max-w-full max-h-96 object-contain rounded-box my-2">', url)
def _render_section(section, lang, stitch_types_by_id):
section_type = section.get('type')
text = (section.get('text') or {}).get(lang, '') if section_type in ('title', 'subtitle', 'text') else ''
if section_type == 'title':
return _line('h3', text, 'text-[1.125em] font-bold mt-2 mb-1 text-[var(--accent-color)]')
if section_type == 'subtitle':
return _line('h4', text, 'text-[1em] font-semibold mt-1 mb-1 text-[var(--accent-color)]')
if section_type == 'text':
return _line('p', text, '')
if section_type == 'note':
return _render_note(section, lang)
if section_type == 'materials':
return _render_materials(section, lang)
if section_type == 'dnd':
return _render_dnd(section, lang, stitch_types_by_id)
if section_type == 'image':
return _render_image(section)
if section_type == 'group':
return _render_group(section, lang, stitch_types_by_id)
return ''
def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message):
"""HTML (ya escapado y marcado como seguro) del contenido de un patrón
para `lang`, en el mismo formato que genera renderOutput() en el
editor: título/autor del documento primero, y luego cada sección de
nivel superior en orden. `page_settings` debe venir ya validado por
sanitize_page_settings()."""
stitch_types_by_id = {st['id']: st for st in stitch_types}
parts = [
_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1'),
_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'),
]
if not sections:
parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message))
else:
parts.extend(_render_section(section, lang, stitch_types_by_id) for section in sections)
return mark_safe(''.join(parts))
+10 -29
View File
@@ -1,6 +1,6 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
@@ -20,13 +20,7 @@
<body>
<main>
<!-- En la vista de solo lectura solo debe verse el patrón: la cabecera
entera se oculta con `hidden` (no se quita del DOM, por lo mismo de
siempre: language-select y export-pdf los necesitan sin comprobar
null tabs.js/i18n.js). El idioma en ese caso no lo elige el select
-oculto- sino la propia URL (ver `lang`, más abajo en la <option>
marcada como `selected`). -->
<header class="flex flex-wrap items-center justify-between gap-2 p-4 no-print {% if readonly %}hidden{% endif %}">
<header class="flex flex-wrap items-center justify-between gap-2 p-4 no-print">
<h1 class="text-xl font-semibold">Crochet</h1>
<div class="flex gap-2 items-center">
<!-- Idioma del contenido (título/subtítulo/texto de cada sección), no
@@ -35,7 +29,7 @@
escrito para el idioma seleccionado. Añadir un idioma nuevo es
solo cuestión de añadir una <option> aquí. -->
<select id="language-select" class="select select-sm select-bordered">
<option value="es" {% if not lang or lang == 'es' %}selected{% endif %}>Español</option>
<option value="es" {% if lang == 'es' %}selected{% endif %}>Español</option>
<option value="en" {% if lang == 'en' %}selected{% endif %}>English</option>
</select>
<button id="save-pattern" type="button" class="btn btn-sm btn-outline"
@@ -55,17 +49,13 @@
</div>
</div>
<div class="{% if not readonly %}p-4 pt-0{% endif %}">
<div class="p-4 pt-0">
<!-- Configuración única para todo el documento (no es una sección más:
no se repite, no se reordena ni se duplica), por eso vive fuera
de #panel-top-sections. <details> nativo para no necesitar JS
solo para plegarla/desplegarla.
En la vista de solo lectura se oculta con `hidden` (no se quita
del DOM): page-settings.js necesita que sus <input>/<select>
sigan existiendo para poder aplicar la personalización guardada
al resultado, aunque aquí no haya nada que editar. -->
<details class="border border-base-300 rounded-box p-4 mb-4 no-print {% if readonly %}hidden{% endif %}">
solo para plegarla/desplegarla. -->
<details class="border border-base-300 rounded-box p-4 mb-4 no-print">
<summary class="text-lg font-semibold cursor-pointer select-none">Personalización de página</summary>
<div class="flex flex-col gap-4 mt-4">
<div class="flex flex-col sm:flex-row gap-4">
@@ -138,23 +128,14 @@
</div>
</details>
<!-- Igual que "Personalización de página": oculta (no ausente) en
solo lectura, porque tabs.js engancha sus listeners sin
comprobar antes que existan. -->
<div class="tabs tabs-boxed w-fit mb-4 md:hidden no-print {% if readonly %}hidden{% endif %}">
<div class="tabs tabs-boxed w-fit mb-4 md:hidden no-print">
<button id="tab-sections" type="button" class="tab tab-active">Secciones</button>
<button id="tab-output" type="button" class="tab">Vista previa</button>
</div>
<div class="flex flex-col md:flex-row gap-4">
<!-- El panel de edición entero se oculta con `hidden` en la vista de
solo lectura, en vez de quitarlo de la plantilla: sigue
construyendo aquí dentro (oculto) las mismas secciones a partir
de pattern_data, que es de donde renderOutput() lee para pintar
#panel-output-text, así que el resultado siempre sale con el
mismo formato y estilo que la vista previa del editor. -->
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 no-print {% if readonly %}hidden{% endif %}">
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 no-print">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-semibold">Secciones</h2>
<button id="toggle-collapse-all" type="button" class="btn btn-xs btn-outline"
@@ -189,8 +170,8 @@
data-remove-confirm="¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las secciones que contiene."></div>
</section>
<section id="panel-output" class="w-full {% if not readonly %}md:w-1/2 panel-hidden-mobile{% endif %} border border-base-300 rounded-box p-4">
<h2 id="preview-title" class="text-lg font-semibold mb-2 no-print {% if readonly %}hidden{% endif %}">Vista previa</h2>
<section id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
<h2 id="preview-title" class="text-lg font-semibold mb-2 no-print">Vista previa</h2>
<div id="panel-output-text"
data-empty-message="Añade una sección para ver aquí el resultado."></div>
</section>
@@ -0,0 +1,37 @@
{% load static %}
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_settings.title|default:"Crochet" }}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Ninguna otra <script> propia: esta página es puramente de lectura,
generada del todo en el servidor (ver pattern_render.py). No hay
ningún <input>/<button> de edición que "revelar" quitando una clase
desde las herramientas de desarrollo, porque ese marcado no existe
aquí (a diferencia de la vista de edición, que sí lo necesita). -->
<style>
:root {
--print-page-bg-color: {{ page_settings.bgColor }};
--accent-color: {{ page_settings.accentColor }};
}
@page { size: {{ page_settings.pageSize }} {{ page_settings.orientation }}; }
</style>
</head>
<body>
<main class="p-4">
<div id="panel-output" class="border border-base-300 rounded-box p-4 max-w-3xl mx-auto"
style="color: {{ page_settings.textColor }}; background-color: {{ page_settings.bgColor }}; font-family: {{ page_settings.font }}; text-align: {{ page_settings.align }};">
<div id="panel-output-text" style="font-size: {{ page_settings.fontSize }};">
{{ rendered_pattern }}
</div>
</div>
</main>
</body>
</html>
+39 -33
View File
@@ -7,32 +7,19 @@ from django.views import View
from django.views.generic import DetailView
from crochet.models import Pattern, PatternImage, StitchType
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
class PatternContextMixin:
def get_pattern_context(self):
return {
# Resuelto por LocaleMiddleware a partir del prefijo de idioma de
# la URL (/es/..., /en/...; ver i18n_patterns en config/urls.py),
# no hace falta declararlo aparte como parámetro de esta vista.
'lang': get_language(),
# Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }}
# para que main.js reconstruya el editor con el contenido guardado
# en vez del esqueleto por defecto.
'pattern_data': {
'sections': self.object.sections,
'pageSettings': self.object.page_settings,
},
# Catálogo de puntos arrastrables (ver StitchType): es el mismo para
# cualquier patrón, no depende de este objeto en concreto.
'stitch_types': [
{'id': stitch_type.id, 'translations': stitch_type.translations}
for stitch_type in StitchType.objects.all()
],
}
def _stitch_types_data():
# Catálogo de puntos arrastrables (ver StitchType): es el mismo para
# cualquier patrón, no depende de ninguno en concreto.
return [
{'id': stitch_type.id, 'translations': stitch_type.translations}
for stitch_type in StitchType.objects.all()
]
class PatternEditView(PatternContextMixin, DetailView):
class PatternEditView(DetailView):
model = Pattern
template_name = 'crochet/pattern.html'
context_object_name = 'pattern'
@@ -41,27 +28,46 @@ class PatternEditView(PatternContextMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_pattern_context())
# Resuelto por LocaleMiddleware a partir del prefijo de idioma de la
# URL (/es/..., /en/...; ver i18n_patterns en config/urls.py).
context['lang'] = get_language()
# Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }}
# para que main.js reconstruya el editor con el contenido guardado
# en vez del esqueleto por defecto.
context['pattern_data'] = {
'sections': self.object.sections,
'pageSettings': self.object.page_settings,
}
context['stitch_types'] = _stitch_types_data()
return context
class PatternDetailView(PatternContextMixin, DetailView):
# Misma plantilla que PatternEditView: así el resultado sale con el mismo
# formato y estilo que la "Vista previa" del editor sin duplicar el
# renderizado (ver pattern.html, que oculta con `hidden` en vez de
# quitar el panel de edición cuando `readonly` está activo, ya que el
# propio panel es el que construye las secciones que luego lee
# render.js para pintar el resultado).
class PatternDetailView(DetailView):
# Plantilla propia, sin nada en común con la del editor: esta vista es
# de solo lectura y no debe cargar ningún <input>/<button> de edición ni
# el JS que los activa, ni siquiera oculto con CSS, porque cualquiera
# podría "revelarlo" quitando esa clase desde las herramientas de
# desarrollo del navegador y guardar cambios reales sobre el patrón (el
# enlace de solo lectura comparte el mismo uuid que el de edición). El
# HTML se genera entero en el servidor a partir de los datos guardados
# (ver pattern_render.py), así que aquí no hay nada que "revelar".
model = Pattern
template_name = 'crochet/pattern.html'
template_name = 'crochet/pattern_detail.html'
context_object_name = 'pattern'
slug_field = 'uuid'
slug_url_kwarg = 'uuid'
EMPTY_MESSAGE = 'Este patrón todavía no tiene contenido.'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(self.get_pattern_context())
context['readonly'] = True
lang = get_language()
page_settings = sanitize_page_settings(self.object.page_settings)
context['lang'] = lang
context['page_settings'] = page_settings
context['rendered_pattern'] = render_pattern_html(
self.object.sections, page_settings, lang, _stitch_types_data(), self.EMPTY_MESSAGE,
)
return context