fix: server side rendering for pattern detail
This commit is contained in:
@@ -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))
|
||||
@@ -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>
|
||||
+43
-37
@@ -7,61 +7,67 @@ 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(),
|
||||
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(DetailView):
|
||||
model = Pattern
|
||||
template_name = 'crochet/pattern.html'
|
||||
context_object_name = 'pattern'
|
||||
slug_field = 'uuid'
|
||||
slug_url_kwarg = 'uuid'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# 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.
|
||||
'pattern_data': {
|
||||
context['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()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class PatternEditView(PatternContextMixin, DetailView):
|
||||
model = Pattern
|
||||
template_name = 'crochet/pattern.html'
|
||||
context_object_name = 'pattern'
|
||||
slug_field = 'uuid'
|
||||
slug_url_kwarg = 'uuid'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(self.get_pattern_context())
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user