feat: added pattern detail view and localization
This commit is contained in:
+13
-2
@@ -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
@@ -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
@@ -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',)
|
||||
|
||||
@@ -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/"
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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}'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user