diff --git a/locales/de.json b/locales/de.json index e96423a3..6e62c58b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -937,6 +937,11 @@ "favorites": { "title": "Nur Favoriten anzeigen", "action": "Favoriten" + }, + "layout": { + "title": "Rezepte-Layout", + "grid": "Raster-Layout", + "masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)" } }, "duplicates": { diff --git a/locales/en.json b/locales/en.json index 2312d47a..f5271973 100644 --- a/locales/en.json +++ b/locales/en.json @@ -937,6 +937,11 @@ "favorites": { "title": "Show Favorites Only", "action": "Favorites" + }, + "layout": { + "title": "Recipes Layout", + "grid": "Grid layout", + "masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)" } }, "duplicates": { diff --git a/locales/es.json b/locales/es.json index 7e16662f..6610b289 100644 --- a/locales/es.json +++ b/locales/es.json @@ -937,6 +937,11 @@ "favorites": { "title": "Mostrar solo favoritos", "action": "Favoritos" + }, + "layout": { + "title": "Diseño de recetas", + "grid": "Vista de cuadrícula", + "masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)" } }, "duplicates": { diff --git a/locales/fr.json b/locales/fr.json index 1a96b4cf..12b306df 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -937,6 +937,11 @@ "favorites": { "title": "Afficher uniquement les favoris", "action": "Favoris" + }, + "layout": { + "title": "Disposition des recettes", + "grid": "Disposition en grille", + "masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)" } }, "duplicates": { diff --git a/locales/he.json b/locales/he.json index 1dd3a234..7e5e4f9c 100644 --- a/locales/he.json +++ b/locales/he.json @@ -937,6 +937,11 @@ "favorites": { "title": "הצג מועדפים בלבד", "action": "מועדפים" + }, + "layout": { + "title": "פריסת מתכונים", + "grid": "פריסת רשת", + "masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)" } }, "duplicates": { diff --git a/locales/ja.json b/locales/ja.json index c02ca7d6..3f565d7e 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -937,6 +937,11 @@ "favorites": { "title": "お気に入りのみ表示", "action": "お気に入り" + }, + "layout": { + "title": "レシピのレイアウト", + "grid": "グリッドレイアウト", + "masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)" } }, "duplicates": { diff --git a/locales/ko.json b/locales/ko.json index 009a0580..ac29ec85 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -937,6 +937,11 @@ "favorites": { "title": "즐겨찾기만 표시", "action": "즐겨찾기" + }, + "layout": { + "title": "레시피 레이아웃", + "grid": "그리드 레이아웃", + "masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)" } }, "duplicates": { diff --git a/locales/ru.json b/locales/ru.json index 595080c7..fd9fce54 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -937,6 +937,11 @@ "favorites": { "title": "Только избранные", "action": "Избранное" + }, + "layout": { + "title": "Макет рецептов", + "grid": "Макет сеткой", + "masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)" } }, "duplicates": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 0b6c0be8..497874f9 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -937,6 +937,11 @@ "favorites": { "title": "仅显示收藏", "action": "收藏" + }, + "layout": { + "title": "配方布局", + "grid": "网格布局", + "masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)" } }, "duplicates": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c4551a9d..007a74cd 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -937,6 +937,11 @@ "favorites": { "title": "僅顯示收藏", "action": "收藏" + }, + "layout": { + "title": "配方版面", + "grid": "網格版面", + "masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)" } }, "duplicates": { diff --git a/static/css/components/modal/settings-modal.css b/static/css/components/modal/settings-modal.css index 3ca0be7f..887cff88 100644 --- a/static/css/components/modal/settings-modal.css +++ b/static/css/components/modal/settings-modal.css @@ -911,6 +911,93 @@ outline: none; } +/* Recipes layout segmented control with visual previews */ +.layout-options-control { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.layout-options { + display: flex; + gap: 6px; + width: 100%; +} + +.layout-option { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 8px; + border-radius: var(--border-radius-sm); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.layout-option:hover, +.layout-option:focus-visible { + border-color: var(--lora-accent); + outline: none; +} + +.layout-option.active { + border-color: var(--lora-accent); + background-color: rgba(from var(--lora-accent) r g b / 0.12); + color: var(--lora-accent); +} + +.layout-option-label { + font-size: 0.85em; + white-space: nowrap; +} + +.layout-option-preview { + width: 72px; + height: 44px; + padding: 4px; + border-radius: var(--border-radius-xs); + background-color: var(--card-bg); + border: 1px solid var(--border-color); + box-sizing: border-box; +} + +.layout-option-preview span { + background: currentColor; + opacity: 0.4; + border-radius: 1px; +} + +.layout-preview-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 3px; +} + +.layout-preview-masonry { + display: flex; + gap: 3px; + align-items: flex-start; +} + +.layout-preview-masonry span { + flex: 1; + height: 100%; +} + +.layout-preview-masonry span:nth-child(2) { + height: 60%; +} + +.layout-preview-masonry span:nth-child(3) { + height: 80%; +} + /* Range Slider Control */ .range-control { width: 100%; diff --git a/static/css/layout.css b/static/css/layout.css index 863a3ba1..6ab1766d 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -168,6 +168,34 @@ border-color: var(--lora-accent); } +/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */ +.layout-toggle-group { + display: flex; + gap: 0; +} + +.layout-toggle-group .layout-toggle-btn { + min-width: 36px; + width: 36px; + padding: 4px 0; + border-radius: 0; +} + +.layout-toggle-group .layout-toggle-btn:first-child { + border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs); + border-right: none; +} + +.layout-toggle-group .layout-toggle-btn:last-child { + border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0; +} + +.layout-toggle-group .layout-toggle-btn:hover, +.layout-toggle-group .layout-toggle-btn:focus-visible { + transform: none; + box-shadow: var(--shadow-xs); +} + /* Keyboard shortcut indicator styling */ .shortcut-key { display: inline-flex; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 27323b5b..23f53bc9 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1017,11 +1017,8 @@ export class SettingsManager { displayDensitySelect.value = state.global.settings.display_density || 'default'; } - // Set recipes layout setting - const recipesLayoutSelect = document.getElementById('recipesLayout'); - if (recipesLayoutSelect) { - recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid'; - } + // Set recipes layout setting (segmented control active state) + this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid'); // Set card info display setting const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay'); @@ -2294,19 +2291,18 @@ export class SettingsManager { : element.value; try { + // Recipes layout has its own shared entry point used by both the + // settings modal segmented control and the recipes page toolbar toggle + if (settingKey === 'recipes_layout') { + return this.saveRecipesLayout(element.value); + } + // Update frontend state with mapped keys await this.saveSetting(settingKey, value); // Apply frontend settings immediately this.applyFrontendSettings(); - // Dispatch layout change event; the scroller instance is about to be rebuilt, - // so calculateLayout() must NOT run on the old instance here - if (settingKey === 'recipes_layout') { - window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed')); - return; - } - // Recalculate layout when display density changes if (settingKey === 'display_density' && state.virtualScroller) { state.virtualScroller.calculateLayout(); @@ -2334,6 +2330,47 @@ export class SettingsManager { } } + /** + * Save the recipes page layout (grid | masonry) and rebuild the scroller. + * Shared entry point for the settings modal segmented control and the + * recipes page toolbar toggle; both stay in sync via + * updateRecipesLayoutControls(). + */ + async saveRecipesLayout(value) { + if (value !== 'grid' && value !== 'masonry') { + return; + } + + // Update frontend state with mapped keys + await this.saveSetting('recipes_layout', value); + + // Apply frontend settings immediately + this.applyFrontendSettings(); + + // Dispatch layout change event; the scroller instance is about to be rebuilt, + // so calculateLayout() must NOT run on the old instance here + window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed')); + + this.updateRecipesLayoutControls(value); + } + + /** + * Sync the active state of every recipes layout control + * (settings modal segmented control and recipes page toolbar toggle). + */ + updateRecipesLayoutControls(value) { + document.querySelectorAll('[data-recipes-layout]').forEach((control) => { + const active = control.dataset.recipesLayout === value; + control.classList.toggle('active', active); + if (control.hasAttribute('aria-pressed')) { + control.setAttribute('aria-pressed', String(active)); + } + if (control.hasAttribute('aria-checked')) { + control.setAttribute('aria-checked', String(active)); + } + }); + } + async saveRangeSetting(elementId, displayId, settingKey) { const element = document.getElementById(elementId); if (!element) return; diff --git a/static/js/recipes.js b/static/js/recipes.js index 580e8b96..c2336998 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -282,6 +282,30 @@ class RecipeManager { }); } + // Layout toggle (grid / masonry) — shares the recipes_layout setting with + // the settings modal segmented control; active states stay in sync via + // settingsManager.updateRecipesLayoutControls() after each save + const layoutToggleBtns = document.querySelectorAll('.layout-toggle-btn'); + if (layoutToggleBtns.length) { + const currentLayout = state.global.settings?.recipes_layout || 'grid'; + layoutToggleBtns.forEach((btn) => { + const isActive = btn.dataset.recipesLayout === currentLayout; + btn.classList.toggle('active', isActive); + btn.setAttribute('aria-pressed', String(isActive)); + btn.addEventListener('click', async () => { + const layout = btn.dataset.recipesLayout; + if ((state.global.settings?.recipes_layout || 'grid') === layout) { + return; + } + try { + await window.settingsManager?.saveRecipesLayout(layout); + } catch (error) { + console.error('Failed to switch recipes layout:', error); + } + }); + }); + } + // Rebuild the scroller on layout switch; in duplicates mode defer until // exitDuplicateMode re-enables the scroller (direct recreation would dispose // the old instance while initializeVirtualScroll skips duplicates mode) diff --git a/templates/components/controls.html b/templates/components/controls.html index b88055bb..1aafdad1 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -134,6 +134,16 @@