feat: support for multi language

This commit is contained in:
2026-07-13 12:09:18 +02:00
parent 50c07e83ac
commit bfb020531b
2 changed files with 85 additions and 14 deletions
+10 -1
View File
@@ -15,7 +15,16 @@
<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">
<div class="flex gap-2 items-center">
<!-- Idioma del contenido (título/subtítulo/texto de cada sección), no
de la interfaz: la estructura de secciones es la misma para
todos los idiomas, solo cambian los textos que el usuario ha
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>
</select>
<button id="save-pattern" type="button" class="btn btn-sm btn-outline">Guardar</button>
<button id="load-pattern" type="button" class="btn btn-sm btn-outline">Cargar</button>
<button id="export-pdf" type="button" class="btn btn-sm">Exportar a PDF</button>
+75 -13
View File
@@ -4,6 +4,39 @@ document.addEventListener('DOMContentLoaded', () => {
const panelTop = document.getElementById('panel-top'); // Todo el panel "Secciones" (incluye los botones "+ tipo"), usado como zona de detección al reordenar.
const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
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".
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.
// Idioma actualmente seleccionado para el contenido. La estructura de
// secciones (cuáles hay, su orden, su tipo) es la misma para todos los
// idiomas; lo único que cambia es qué texto se muestra en cada campo de
// título/subtítulo/texto libre.
let currentLanguage = languageSelect.value;
// ---------------------------------------------------------------------
// Multi-idioma del contenido: título, subtítulo y texto libre guardan un
// texto por idioma en vez de uno solo, para poder editarlos y asociarlos
// al idioma seleccionado sin perder lo ya escrito en los demás.
// ---------------------------------------------------------------------
// Cada input/textarea traducible guarda su propio mapa { idioma: texto }
// serializado en el propio elemento (data-translations), así viaja solo
// con guardar/cargar la sección (ver serializeSection/addTitleSection...),
// sin necesidad de un almacén aparte que haya que mantener sincronizado.
function getTranslations(el) {
try {
return JSON.parse(el.dataset.translations || '{}');
} catch {
return {};
}
}
// Actualiza, dentro del mapa ya guardado en el elemento, el texto del
// idioma indicado (por defecto el actual), sin tocar el resto de idiomas.
function setTranslation(el, text, lang = currentLanguage) {
const translations = getTranslations(el);
translations[lang] = text;
el.dataset.translations = JSON.stringify(translations);
}
// ---------------------------------------------------------------------
// Reordenar secciones (arrastrando toda la cabecera de cada una)
@@ -407,10 +440,31 @@ document.addEventListener('DOMContentLoaded', () => {
// añadir/quitar/reordenar secciones o elementos soltados (MutationObserver).
// Al estar en #panel-top-sections y usar subtree:true, esto ya cubre
// también los cambios dentro de las secciones anidadas en un "Grupo".
sectionsContainer.addEventListener('input', renderOutput);
// Si el campo es traducible (título/subtítulo/texto), cada pulsación de
// tecla también se guarda en su mapa de traducciones para el idioma
// actual, así no hace falta un paso aparte de "guardar" al cambiar de
// idioma o al serializar la sección.
sectionsContainer.addEventListener('input', (e) => {
if (e.target.matches('.title-input, .subtitle-input, textarea')) {
setTranslation(e.target, e.target.value);
}
renderOutput();
});
sectionsContainer.addEventListener('change', renderOutput);
new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true });
// Al cambiar el idioma del contenido, se sustituye en cada campo
// traducible (a cualquier nivel de anidamiento) el texto mostrado por el
// que tenga guardado para el nuevo idioma (vacío si aún no se ha escrito
// nada en ese idioma), y se repinta la vista previa.
languageSelect.addEventListener('change', () => {
currentLanguage = languageSelect.value;
sectionsContainer.querySelectorAll('.title-input, .subtitle-input, textarea').forEach(el => {
el.value = getTranslations(el)[currentLanguage] || '';
});
renderOutput();
});
// ---------------------------------------------------------------------
// Creación de secciones (título, texto, imagen, drag & drop, grupo)
// ---------------------------------------------------------------------
@@ -544,17 +598,20 @@ document.addEventListener('DOMContentLoaded', () => {
// Sección de título: un input de una línea que se muestra como <h3> en el
// resultado. `data` es el dataset del botón "+ Título" (data-type-label,
// data-placeholder). `text`, si se pasa, precarga el valor (se usa al
// restaurar un patrón guardado). `container` es dónde se añade la
// sección: #panel-top-sections por defecto, o el de un grupo.
function addTitleSection(data, text = '', container = sectionsContainer) {
// data-placeholder). `translations`, si se pasa, es el mapa { idioma:
// texto } completo (se usa al restaurar un patrón guardado o al
// duplicar una sección); se muestra el que corresponda al idioma actual.
// `container` es dónde se añade la sección: #panel-top-sections por
// defecto, o el de un grupo.
function addTitleSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'title-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.value = text;
input.dataset.translations = JSON.stringify(translations);
input.value = translations[currentLanguage] || '';
body.appendChild(input);
container.appendChild(section);
@@ -562,27 +619,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Sección de subtítulo: igual que la de título pero se muestra como <h4>
// (más pequeño) en el resultado, para marcar un encabezado secundario.
function addSubtitleSection(data, text = '', container = sectionsContainer) {
function addSubtitleSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'subtitle-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.value = text;
input.dataset.translations = JSON.stringify(translations);
input.value = translations[currentLanguage] || '';
body.appendChild(input);
container.appendChild(section);
}
// Sección de texto libre.
function addTextSection(data, text = '', container = sectionsContainer) {
function addTextSection(data, translations = {}, container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const textarea = document.createElement('textarea');
textarea.className = 'textarea textarea-bordered w-full';
textarea.placeholder = data.placeholder;
textarea.value = text;
textarea.dataset.translations = JSON.stringify(translations);
textarea.value = translations[currentLanguage] || '';
body.appendChild(textarea);
container.appendChild(section);
@@ -809,14 +868,17 @@ document.addEventListener('DOMContentLoaded', () => {
return { type: 'group', children: Array.from(groupContainer.children).map(serializeSection).filter(Boolean) };
}
// Título/subtítulo/texto libre: se serializa el mapa de traducciones
// completo (todos los idiomas ya escritos), no solo el texto que se ve
// ahora mismo, para no perder lo escrito en otros idiomas al guardar.
const titleInput = section.querySelector('.title-input');
if (titleInput) return { type: 'title', text: titleInput.value };
if (titleInput) return { type: 'title', text: getTranslations(titleInput) };
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
if (subtitleInput) return { type: 'subtitle', text: getTranslations(subtitleInput) };
const textarea = section.querySelector('textarea');
if (textarea) return { type: 'text', text: textarea.value };
if (textarea) return { type: 'text', text: getTranslations(textarea) };
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {