feat: added pattern detail view and localization

This commit is contained in:
2026-07-16 09:22:33 +02:00
parent cd4be4a794
commit b0c341a189
18 changed files with 410 additions and 89 deletions
+13 -2
View File
@@ -25,7 +25,7 @@ SECRET_KEY = 'django-insecure-#vd9ii4-r#6!3s%w(g3+s2-dh_!j1sj^(q$()dq5*1=*2b4n1a
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']
# Application definition
@@ -43,6 +43,11 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
# Debe ir después de SessionMiddleware y antes de CommonMiddleware (así
# lo pide Django): resuelve el idioma activo a partir del prefijo de la
# URL (/es/..., /en/...) para que las vistas de crochet no tengan que
# declararlo a mano como parámetro.
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -103,7 +108,13 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = 'es'
# Mismos idiomas que ofrece el <select id="language-select"> de pattern.html.
LANGUAGES = [
('es', 'Español'),
('en', 'English'),
]
TIME_ZONE = 'UTC'
+9 -1
View File
@@ -15,15 +15,23 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('crochet.urls', namespace='crochet')),
]
# i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL,
# resuelto por LocaleMiddleware (ver MIDDLEWARE en settings.py): así el
# idioma del patrón viaja en la propia URL sin tener que declararlo a mano
# como parámetro en cada ruta de crochet/urls.py.
urlpatterns += i18n_patterns(
path('', include('crochet.urls', namespace='crochet')),
)
if settings.DEBUG:
# Solo para desarrollo: en producción esto lo sirve el propio servidor
# web (nginx, etc.), no Django.
+7 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from crochet.models import Pattern, PatternImage
from crochet.models import Pattern, PatternImage, StitchType
class PatternImageInline(admin.TabularInline):
@@ -20,3 +20,9 @@ class PatternAdmin(admin.ModelAdmin):
class PatternImageAdmin(admin.ModelAdmin):
list_display = ('__str__', 'pattern', 'uploaded_at')
readonly_fields = ('uploaded_at',)
@admin.register(StitchType)
class StitchTypeAdmin(admin.ModelAdmin):
list_display = ('__str__', 'order')
list_editable = ('order',)
+22
View File
@@ -0,0 +1,22 @@
msgid ""
msgstr ""
"Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/"
msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/"
msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/"
msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/"
+24
View File
@@ -0,0 +1,24 @@
# Generated by Django 6.0.7 on 2026-07-16 06:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crochet', '0002_patternimage'),
]
operations = [
migrations.CreateModel(
name='StitchType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('translations', models.JSONField(blank=True, default=dict)),
('order', models.PositiveIntegerField(default=0)),
],
options={
'ordering': ['order', 'id'],
},
),
]
@@ -0,0 +1,32 @@
from django.db import migrations
# Mismos dos puntos que antes venían hardcodeados en el <ul> oculto de
# pattern.html; se siembran aquí para que el catálogo no empiece vacío.
STITCH_TYPES = [
{'translations': {'es': 'Punto bajo', 'en': 'SC'}, 'order': 1},
{'translations': {'es': 'Disminución', 'en': 'Decrease'}, 'order': 2},
]
def seed_stitch_types(apps, schema_editor):
StitchType = apps.get_model('crochet', 'StitchType')
for stitch_type in STITCH_TYPES:
StitchType.objects.get_or_create(**stitch_type)
def remove_seeded_stitch_types(apps, schema_editor):
StitchType = apps.get_model('crochet', 'StitchType')
for stitch_type in STITCH_TYPES:
StitchType.objects.filter(**stitch_type).delete()
class Migration(migrations.Migration):
dependencies = [
('crochet', '0003_stitchtype'),
]
operations = [
migrations.RunPython(seed_stitch_types, remove_seeded_stitch_types),
]
@@ -0,0 +1,42 @@
from django.db import migrations
# Vocabulario estándar de puntos de crochet en castellano/inglés, además de
# los dos ya sembrados en 0004 (Punto bajo/Disminución).
STITCH_TYPES = [
{'translations': {'es': 'Cadeneta', 'en': 'Chain'}, 'order': 3},
{'translations': {'es': 'Punto deslizado', 'en': 'Slip stitch'}, 'order': 4},
{'translations': {'es': 'Medio punto alto', 'en': 'Half double crochet'}, 'order': 5},
{'translations': {'es': 'Punto alto', 'en': 'Double crochet'}, 'order': 6},
{'translations': {'es': 'Punto alto doble', 'en': 'Treble crochet'}, 'order': 7},
{'translations': {'es': 'Aumento', 'en': 'Increase'}, 'order': 8},
{'translations': {'es': 'Anillo mágico', 'en': 'Magic ring'}, 'order': 9},
{'translations': {'es': 'Punto piña', 'en': 'Puff stitch'}, 'order': 10},
{'translations': {'es': 'Punto concha', 'en': 'Shell stitch'}, 'order': 11},
{'translations': {'es': 'Punto racimo', 'en': 'Cluster stitch'}, 'order': 12},
{'translations': {'es': 'Punto popcorn', 'en': 'Popcorn stitch'}, 'order': 13},
{'translations': {'es': 'Vuelta', 'en': 'Round'}, 'order': 14},
]
def seed_stitch_types(apps, schema_editor):
StitchType = apps.get_model('crochet', 'StitchType')
for stitch_type in STITCH_TYPES:
StitchType.objects.get_or_create(**stitch_type)
def remove_seeded_stitch_types(apps, schema_editor):
StitchType = apps.get_model('crochet', 'StitchType')
for stitch_type in STITCH_TYPES:
StitchType.objects.filter(**stitch_type).delete()
class Migration(migrations.Migration):
dependencies = [
('crochet', '0004_seed_stitch_types'),
]
operations = [
migrations.RunPython(seed_stitch_types, remove_seeded_stitch_types),
]
+17
View File
@@ -28,3 +28,20 @@ class PatternImage(models.Model):
pattern = models.ForeignKey(Pattern, related_name='images', on_delete=models.CASCADE)
image = models.ImageField(upload_to='patterns/%Y/%m/')
uploaded_at = models.DateTimeField(auto_now_add=True)
class StitchType(models.Model):
# Catálogo de tipos de punto arrastrables en una sección de "Patrón"
# (antes una lista fija en el HTML). Vive en la base de datos para
# poder gestionarlo sin tocar código, y para que cada uno tenga su
# propio texto por idioma, igual que el resto del contenido traducible
# de la aplicación: un mapa { idioma: texto }, no una tabla de
# traducciones aparte.
translations = models.JSONField(default=dict, blank=True)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['order', 'id']
def __str__(self):
return self.translations.get('es') or f'StitchType #{self.pk}'
+10 -2
View File
@@ -1,8 +1,16 @@
/* HTML: <div class="breadcrumb"></div> */
.breadcrumb {
--s: 20px; /* control the shape */
line-height: 1.8; /* control the height */
/* line-height se aplica a CADA línea, no solo una vez a la caja entera:
con 1.8 (pensado para una etiqueta de una sola línea, como "PB") un
punto de nombre largo ("Medio punto alto") que envuelve a dos líneas
quedaba con un hueco enorme entre ellas. Se baja a algo normal y se
recupera aire con padding-block, que sí solo se aplica una vez
(arriba y abajo de toda la etiqueta), no entre líneas envueltas.
control the height */
line-height: 1.3;
padding-block: 0.35em;
padding-inline: calc(var(--s) + .3em);
clip-path: polygon(0 0,calc(100% - var(--s)) 0,100% 50%,calc(100% - var(--s)) 100%,0 100%,var(--s) 50%);
background: #EAEAEA;
+69 -35
View File
@@ -1,18 +1,59 @@
// Drag & drop de elementos arrastrables (los <li> de "Elementos") hacia una
// zona de destino (el "dnd-canvas" de una sección de "Patrón").
// Drag & drop de elementos arrastrables (los tipos de punto del catálogo,
// ver stitch-types.js) hacia una zona de destino (el "dnd-canvas" de una
// sección de "Patrón").
import { draggableItemsSource } from './dom-refs.js';
import { getTouchPoint, moveIfNeeded, getItemAfterPoint, itemDrag } from './drag-reorder.js';
import { getCurrentLanguage } from './i18n.js';
import { getStitchTypes, getStitchTypeById } from './stitch-types.js';
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en el
// dataTransfer para poder clonarlo al soltarlo. addDndItem() se usa aquí
// dentro del listener aunque se declare más abajo: en JS los `function` se
// izan (hoisting), así que el orden dentro del módulo no importa.
export function makeDraggable(item) {
// Crea el <li> visible de un tipo de punto (tanto en la lista de
// "Elementos" de una sección de "Patrón" como, más abajo, el que se coloca
// dentro del canvas al soltarlo). 'stitch-item-label' y data-translations
// permiten que i18n.js lo reconozca como campo traducible más (aunque su
// texto no lo edite el usuario, solo lo elija del catálogo) y lo actualice
// solo con cambiar de idioma, sin código específico aquí.
function createStitchItemLabel(stitchType, className) {
const item = document.createElement('li');
item.className = className;
item.dataset.stitchTypeId = stitchType.id;
item.dataset.translations = JSON.stringify(stitchType.translations);
item.textContent = stitchType.translations[getCurrentLanguage()] || '';
return item;
}
// Construye la lista de "Elementos" (el catálogo completo) para una nueva
// sección de "Patrón". Antes era clonar una fuente fija en el HTML; ahora
// el catálogo viene del servidor (ver stitch-types.js).
//
// Es un flex-wrap propio (gap-2), no el componente "menu" de daisyUI: ese
// componente trae su propia disposición y espaciado verticales pensados
// para un menú de navegación de una columna, no para una nube de
// etiquetas que envuelve en varias filas; combinado con el margen propio
// de cada etiqueta (antes "my-2") se sumaban ambos espaciados y con
// muchos tipos de punto (ahora hay 14, antes solo 2) se notaba mucho.
export function createElementsList() {
const list = document.createElement('ul');
list.className = 'flex flex-wrap gap-2';
getStitchTypes().forEach(stitchType => {
const item = createStitchItemLabel(stitchType, 'stitch-item-label breadcrumb cursor-pointer select-none');
makeDraggable(item);
list.appendChild(item);
});
return list;
}
// Hace arrastrable un <li> de la lista de "Elementos" y mete su id de tipo
// de punto en el dataTransfer para poder recrearlo al soltarlo. addDndItem()
// se usa aquí dentro del listener aunque se declare más abajo: en JS los
// `function` se izan (hoisting), así que el orden dentro del módulo no
// importa.
function makeDraggable(item) {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/html', item.outerHTML);
e.dataTransfer.setData('text/plain', item.dataset.stitchTypeId);
});
// Equivalente táctil: crea un clon flotante que sigue al dedo y, al
@@ -51,7 +92,7 @@ export function makeDraggable(item) {
const point = getTouchPoint(endEvent);
const target = document.elementFromPoint(point.x, point.y);
const dropCanvas = target && target.closest('.dnd-canvas');
if (dropCanvas) addDndItem(dropCanvas, item.textContent.trim(), 1);
if (dropCanvas) addDndItem(dropCanvas, Number(item.dataset.stitchTypeId), 1);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
@@ -59,19 +100,17 @@ export function makeDraggable(item) {
}, { passive: false });
}
// Añade a una zona de drop un grupo: cantidad (input numérico), el elemento
// (buscado en draggableItemsSource por su texto) y un botón para
// eliminarlo. Se usa tanto al soltar un elemento como al restaurar un
// patrón guardado previamente.
export function addDndItem(dropCanvas, label, count) {
const sourceItem = Array.from(draggableItemsSource.children)
.find(li => li.textContent.trim() === label);
if (!sourceItem) return;
// Añade a una zona de drop un grupo: cantidad (input numérico), el tipo de
// punto (buscado en el catálogo por id, no por texto: así el patrón
// guardado no se rompe al cambiar de idioma) y un botón para eliminarlo.
// Se usa tanto al soltar un elemento como al restaurar un patrón guardado
// previamente.
export function addDndItem(dropCanvas, stitchTypeId, count) {
const stitchType = getStitchTypeById(stitchTypeId);
if (!stitchType) return;
const clone = sourceItem.cloneNode(true);
clone.removeAttribute('id');
clone.classList.add('inline-block', 'cursor-move');
clone.setAttribute('draggable', 'true'); // Arrastrable, pero para reordenar dentro del canvas (ver más abajo), no para volver a soltarlo como si fuera nuevo.
const label = createStitchItemLabel(stitchType, 'stitch-item-label inline-block cursor-move');
label.setAttribute('draggable', 'true'); // Arrastrable, pero para reordenar dentro del canvas (ver más abajo), no para volver a soltarlo como si fuera nuevo.
// Resalta la misma zona ampliada que usa setupDropZone() (la sección
// completa), no solo dropCanvas, para que el resaltado visual coincida
@@ -97,7 +136,7 @@ export function addDndItem(dropCanvas, label, count) {
// Reordenar el elemento dentro de su zona de drop: se distingue de
// "soltar un elemento nuevo" porque ese arrastre parte de la lista de
// "Elementos", no de un punto ya colocado en el canvas.
clone.addEventListener('dragstart', (e) => {
label.addEventListener('dragstart', (e) => {
itemDrag.item = group;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'item');
@@ -105,7 +144,7 @@ export function addDndItem(dropCanvas, label, count) {
highlightTarget.classList.add('drop-target-active');
});
clone.addEventListener('dragend', () => {
label.addEventListener('dragend', () => {
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
itemDrag.item = null;
@@ -113,7 +152,7 @@ export function addDndItem(dropCanvas, label, count) {
// Equivalente táctil: mientras el dedo se mueve por encima del canvas,
// reutiliza getItemAfterPoint() para ir desplazando el grupo en vivo.
clone.addEventListener('touchstart', (e) => {
label.addEventListener('touchstart', (e) => {
e.preventDefault();
itemDrag.item = group;
group.classList.add('dragging');
@@ -139,7 +178,7 @@ export function addDndItem(dropCanvas, label, count) {
}, { passive: false });
group.appendChild(countInput);
group.appendChild(clone);
group.appendChild(label);
group.appendChild(removeItemBtn);
dropCanvas.appendChild(group);
@@ -174,15 +213,10 @@ export function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
e.preventDefault();
if (itemDrag.item) return; // El reordenado ya se hizo en el dragover.
const html = e.dataTransfer.getData('text/html');
if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada).
const stitchTypeId = e.dataTransfer.getData('text/plain');
if (!stitchTypeId) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada).
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const clone = wrapper.firstElementChild;
if (!clone) return;
addDndItem(dropCanvas, clone.textContent.trim(), 1);
addDndItem(dropCanvas, Number(stitchTypeId), 1);
});
if (onChange) {
@@ -196,7 +230,7 @@ export function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
export function getDndCanvasText(dndCanvas) {
const parts = Array.from(dndCanvas.children).map(group => {
const input = group.querySelector('input');
const label = group.querySelector('li');
const label = group.querySelector('.stitch-item-label');
if (!input || !label) return '';
return `${input.value} ${label.textContent}`;
});
+1 -2
View File
@@ -6,12 +6,11 @@
export const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
export const panelTop = document.getElementById('panel-top'); // Todo el panel "Secciones" (incluye los botones "+ tipo"), usado como zona de detección al reordenar.
export const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
export const draggableItemsSource = document.getElementById('draggable-items-source'); // Lista oculta con los elementos disponibles para arrastrar: fuente de datos única, se clona (visible) en cada sección de tipo "arrastrar y soltar".
export const languageSelect = document.getElementById('language-select'); // Idioma del contenido (no de la interfaz): controla qué versión de los textos de título/subtítulo/texto se muestra y edita.
export const panelOutput = document.getElementById('panel-output');
// Controles de "Personalización de página": configuración única para todo
// el documento (no una sección más), ver page-config.js.
// el documento (no una sección más), ver page-settings.js.
export const pageTitleInput = document.getElementById('page-title-input');
export const pageAuthorInput = document.getElementById('page-author-input');
export const pageTextColorInput = document.getElementById('page-text-color');
+18 -2
View File
@@ -9,7 +9,11 @@ import { sectionsContainer, languageSelect } from './dom-refs.js';
// mapa de traducciones en cada pulsación de tecla como para refrescar los
// campos visibles al cambiar de idioma. `textarea` cubre tanto "Texto"
// (.text-input) como "Nota" (.note-input): ambas son un <textarea>.
export const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, textarea';
// `.stitch-item-label` (los tipos de punto de una sección de "Patrón", ver
// dnd-items.js) no lo edita el usuario -solo lo elige del catálogo-, pero
// también cambia de texto según el idioma, así que reutiliza el mismo
// mecanismo en vez de tener uno aparte.
export const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, .stitch-item-label, textarea';
// Idioma actualmente seleccionado para el contenido. La estructura de
// secciones (cuáles hay, su orden, su tipo) es la misma para todos los
@@ -50,7 +54,19 @@ export function setupLanguageSelect(onLanguageChange) {
languageSelect.addEventListener('change', () => {
currentLanguage = languageSelect.value;
sectionsContainer.querySelectorAll(TRANSLATABLE_FIELDS_SELECTOR).forEach(el => {
el.value = getTranslations(el)[currentLanguage] || '';
const text = getTranslations(el)[currentLanguage] || '';
// Los campos editables (input/textarea) muestran su texto en
// `.value`; un tipo de punto (.stitch-item-label) es un <li> de solo
// lectura, así que se pinta con `.textContent`. OJO: `'value' in el`
// NO sirve para distinguirlos, porque <li> también tiene su propia
// propiedad `.value` nativa (el número de orden en una lista <ol>),
// así que ese chequeo daba falso positivo y nunca llegaba a tocar el
// textContent de los tipos de punto.
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
el.value = text;
} else {
el.textContent = text;
}
});
onLanguageChange();
});
+13 -4
View File
@@ -231,12 +231,15 @@ const SECTION_TYPES = [
button: addDndBtn,
create: (sectionData, container) => addDndSection(addDndBtn.dataset, sectionData?.items, container),
matches: (section) => section.querySelector('.dnd-canvas'),
// Se guarda el id del tipo de punto (no su texto): así el patrón
// guardado sigue mostrando la etiqueta correcta al cambiar de idioma,
// en vez de quedarse con el texto de cuando se soltó.
serialize: (section, dndCanvas) => {
const items = Array.from(dndCanvas.children).map(group => {
const input = group.querySelector('input');
const label = group.querySelector('li');
const label = group.querySelector('.stitch-item-label');
if (!input || !label) return null;
return { label: label.textContent.trim(), count: Number(input.value) };
return { stitchTypeId: Number(label.dataset.stitchTypeId), count: Number(input.value) };
}).filter(Boolean);
return { type: 'dnd', items };
},
@@ -253,11 +256,17 @@ const SECTION_TYPES = [
),
matches: (section) => section.querySelector('img'),
// imageId + url (no la imagen en sí): el archivo vive en PatternImage,
// un modelo aparte; aquí solo se guarda la referencia.
// un modelo aparte; aquí solo se guarda la referencia. Se usa
// img.dataset.url (la ruta tal cual la devolvió el servidor, p.ej.
// "/media/...") y no img.src: la propiedad .src del DOM siempre
// devuelve la URL absoluta resuelta contra el host actual, así que si
// se guardase eso el patrón quedaría con "http://<host-de-quien-lo-
// guardó>/media/..." grabado y dejaría de verse la imagen al abrir el
// patrón desde cualquier otro host o IP.
serialize: (section, img) => ({
type: 'image',
imageId: img.dataset.imageId ? Number(img.dataset.imageId) : null,
url: !img.classList.contains('hidden') ? img.src : '',
url: !img.classList.contains('hidden') ? (img.dataset.url || '') : '',
}),
// Si ya hay una imagen cargada, se copia (con el tamaño limitado) al resultado.
render: (section, img, target) => {
+18 -11
View File
@@ -5,10 +5,10 @@
// completo de tipos (y este módulo no lo conoce, para que no haya un
// import circular entre ambos).
import { panelTop, sectionsContainer, draggableItemsSource } from './dom-refs.js';
import { panelTop, sectionsContainer } from './dom-refs.js';
import { getCurrentLanguage } from './i18n.js';
import { getTouchPoint, moveIfNeeded, getSectionAfterY, sectionDrag } from './drag-reorder.js';
import { makeDraggable, addDndItem, setupDropZone } from './dnd-items.js';
import { createElementsList, addDndItem, setupDropZone } from './dnd-items.js';
import { getCsrfToken } from './csrf.js';
// Crea el "armazón" común a toda sección: el recuadro, la cabecera (asa
@@ -331,6 +331,7 @@ export function addImageSection(data, image = null, container = sectionsContaine
if (image) {
img.src = image.url;
img.dataset.imageId = image.id;
img.dataset.url = image.url;
img.classList.remove('hidden');
}
@@ -352,6 +353,7 @@ export function addImageSection(data, image = null, container = sectionsContaine
const uploaded = await response.json();
img.src = uploaded.url;
img.dataset.imageId = uploaded.id;
img.dataset.url = uploaded.url;
img.classList.remove('hidden');
// El 'change' del propio fileInput ya burbujeó antes de que la subida
// terminase. Se dispara sobre `img` (no sobre fileInput, para no
@@ -371,9 +373,10 @@ export function addImageSection(data, image = null, container = sectionsContaine
}
// Sección de "arrastrar y soltar": una zona de drop propia junto a su
// propia lista de elementos arrastrables (clonada de la fuente), para que
// cada sección de este tipo sea independiente de las demás. `items`, si se
// pasa, precarga los elementos ya soltados: [{ label, count }].
// propia lista de elementos arrastrables (el catálogo de tipos de punto,
// ver stitch-types.js), para que cada sección de este tipo sea
// independiente de las demás. `items`, si se pasa, precarga los elementos
// ya soltados: [{ stitchTypeId, count }].
export function addDndSection(data, items = [], container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
@@ -381,7 +384,14 @@ export function addDndSection(data, items = [], container = sectionsContainer) {
row.className = 'flex flex-col sm:flex-row gap-4';
const dndCanvas = document.createElement('div');
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24';
// content-start (align-content: flex-start): sin esto, un flex-wrap con
// varias filas reparte el espacio sobrante de min-h-24 ENTRE las filas
// (el valor por defecto de align-content se comporta como "stretch" en
// un contenedor multilínea), así que en cuanto había más de una fila de
// elementos soltados quedaba un hueco enorme entre ellas. Con esto las
// filas se apilan juntas arriba y el hueco sobrante (si lo hay) queda
// debajo, no repartido.
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap content-start items-center gap-2 min-h-24 py-2';
const elementsPanel = document.createElement('aside');
elementsPanel.className = 'w-full sm:w-48 sm:shrink-0 border border-base-300 rounded-box p-4';
@@ -390,10 +400,7 @@ export function addDndSection(data, items = [], container = sectionsContainer) {
elementsTitle.className = 'font-semibold mb-2';
elementsTitle.textContent = data.elementsLabel;
const list = draggableItemsSource.cloneNode(true);
list.removeAttribute('id'); // Evitar ids duplicados: puede haber varias secciones de este tipo.
list.classList.remove('hidden'); // La fuente está oculta; el clon sí debe mostrarse.
list.querySelectorAll('li').forEach(makeDraggable);
const list = createElementsList();
elementsPanel.appendChild(elementsTitle);
elementsPanel.appendChild(list);
@@ -404,7 +411,7 @@ export function addDndSection(data, items = [], container = sectionsContainer) {
container.appendChild(section);
setupDropZone(dndCanvas, undefined, section);
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
items.forEach(item => addDndItem(dndCanvas, item.stitchTypeId, item.count));
}
// ---------------------------------------------------------------------
+15
View File
@@ -0,0 +1,15 @@
// Catálogo de tipos de punto arrastrables en una sección de "Patrón" (antes
// una lista fija en el HTML). Viene del servidor vía
// {{ stitch_types|json_script:"stitch-types" }} (ver StitchType en
// crochet/models.py), con sus propias traducciones por idioma. Se lee una
// única vez al cargar el módulo: el catálogo es el mismo durante toda la
// sesión de edición.
const STITCH_TYPES = JSON.parse(document.getElementById('stitch-types').textContent);
export function getStitchTypes() {
return STITCH_TYPES;
}
export function getStitchTypeById(id) {
return STITCH_TYPES.find(stitchType => stitchType.id === id) || null;
}
+33 -16
View File
@@ -20,7 +20,13 @@
<body>
<main>
<header class="flex flex-wrap items-center justify-between gap-2 p-4 no-print">
<!-- 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 %}">
<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
@@ -29,8 +35,8 @@
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" selected>Español</option>
<option value="en">English</option>
<option value="es" {% if not lang or 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"
data-save-url="{% url 'crochet:pattern_save' pattern.uuid %}"
@@ -49,13 +55,17 @@
</div>
</div>
<div class="p-4 pt-0">
<div class="{% if not readonly %}p-4 pt-0{% endif %}">
<!-- 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. -->
<details class="border border-base-300 rounded-box p-4 mb-4 no-print">
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 %}">
<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">
@@ -128,14 +138,23 @@
</div>
</details>
<div class="tabs tabs-boxed w-fit mb-4 md:hidden no-print">
<!-- 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 %}">
<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">
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 no-print">
<!-- 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 %}">
<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"
@@ -170,8 +189,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 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>
<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>
<div id="panel-output-text"
data-empty-message="Añade una sección para ver aquí el resultado."></div>
</section>
@@ -180,12 +199,10 @@
</div>
<!-- Fuente de datos de los elementos arrastrables: se mantiene oculta y
se clona (haciéndola visible) dentro de cada sección de arrastrar y soltar. -->
<ul id="draggable-items-source" class="menu bg-base-100 w-full hidden">
<li class="my-2 breadcrumb cursor-pointer select-none" id="1"> PB</li>
<li class="my-2 breadcrumb cursor-pointer select-none" id="2">Disminución</li>
</ul>
<!-- Catálogo de tipos de punto arrastrables (StitchType), leído por
dnd-items.js. Antes era una lista fija en este HTML; ahora viene de
la base de datos, con sus propias traducciones por idioma. -->
{{ stitch_types|json_script:"stitch-types" }}
<!-- Contenido guardado de este patrón (sections/pageSettings), leído
por main.js al arrancar. json_script escapa el JSON de forma
+20 -4
View File
@@ -1,11 +1,27 @@
from django.urls import path
from django.utils.translation import gettext_lazy as _
from crochet.views import PatternImageUploadView, PatternSaveView, PatternView
from crochet.views import (
PatternDetailView,
PatternEditView,
PatternImageUploadView,
PatternSaveView,
)
app_name = 'crochet'
# Cada ruta entera es una única cadena traducible (ver crochet/locale/), no
# la concatenación de un prefijo traducido con literales sueltos: Django solo
# recompila la regex de la ruta por idioma (ver LocaleRegexRouteDescriptor)
# si `route` sigue siendo el propio objeto perezoso de gettext_lazy al llegar
# a path(); en cuanto se concatena con '+' deja de serlo (se evalúa entero
# de golpe, con el idioma activo en ese momento, y queda fijo para siempre).
# Con esto, con i18n_patterns (ver config/urls.py) la URL sale traducida
# entera según el prefijo de idioma, p.ej. /en/pattern/<uuid>/edit/ frente a
# /es/patron/<uuid>/editar/.
urlpatterns = [
path('pattern/<uuid:uuid>/', name='pattern_detail', view=PatternView.as_view()),
path('pattern/<uuid:uuid>/save/', name='pattern_save', view=PatternSaveView.as_view()),
path('pattern/<uuid:uuid>/images/', name='pattern_image_upload', view=PatternImageUploadView.as_view()),
path(_('pattern/<uuid:uuid>/'), name='pattern_detail', view=PatternDetailView.as_view()),
path(_('pattern/<uuid:uuid>/edit/'), name='pattern_edit', view=PatternEditView.as_view()),
path(_('pattern/<uuid:uuid>/save/'), name='pattern_save', view=PatternSaveView.as_view()),
path(_('pattern/<uuid:uuid>/images/'), name='pattern_image_upload', view=PatternImageUploadView.as_view()),
]
+47 -9
View File
@@ -2,13 +2,37 @@ import json
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import get_language
from django.views import View
from django.views.generic import DetailView
from crochet.models import Pattern, PatternImage
from crochet.models import Pattern, PatternImage, StitchType
class PatternView(DetailView):
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()
],
}
class PatternEditView(PatternContextMixin, DetailView):
model = Pattern
template_name = 'crochet/pattern.html'
context_object_name = 'pattern'
@@ -17,13 +41,27 @@ class PatternView(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# 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.update(self.get_pattern_context())
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).
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['readonly'] = True
return context