From 658f88ca486b77cbe9cf2a35ef05c85b2612391d Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 16 Aug 2026 15:23:30 +0800 Subject: [PATCH] feat(recipes): add toolbar toggle and settings preview for masonry layout --- locales/de.json | 5 ++ locales/en.json | 5 ++ locales/es.json | 5 ++ locales/fr.json | 5 ++ locales/he.json | 5 ++ locales/ja.json | 5 ++ locales/ko.json | 5 ++ locales/ru.json | 5 ++ locales/zh-CN.json | 5 ++ locales/zh-TW.json | 5 ++ .../css/components/modal/settings-modal.css | 87 +++++++++++++++++++ static/css/layout.css | 28 ++++++ static/js/managers/SettingsManager.js | 61 ++++++++++--- static/js/recipes.js | 24 +++++ templates/components/controls.html | 10 +++ .../components/modals/settings_modal.html | 18 ++-- .../managers/settingsManager.library.test.js | 45 ++++++++++ tests/frontend/pages/recipesPage.test.js | 33 +++++++ 18 files changed, 338 insertions(+), 18 deletions(-) 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 @@
+ {% if page_id == 'recipes' %} +
+ + +
+ {% endif %}
+ +
diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 60cffae7..115db1eb 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => { dispatchSpy.mockRestore(); delete state.virtualScroller; }); + + it('saveRecipesLayout persists, dispatches the layout event, and syncs controls', async () => { + const manager = createManager(); + + const gridBtn = document.createElement('button'); + gridBtn.dataset.recipesLayout = 'grid'; + gridBtn.setAttribute('aria-pressed', 'false'); + const masonryBtn = document.createElement('button'); + masonryBtn.dataset.recipesLayout = 'masonry'; + masonryBtn.setAttribute('aria-pressed', 'false'); + masonryBtn.setAttribute('role', 'radio'); + masonryBtn.setAttribute('aria-checked', 'false'); + document.body.appendChild(gridBtn); + document.body.appendChild(masonryBtn); + + const calculateLayout = vi.fn(); + state.virtualScroller = { calculateLayout }; + + const dispatchSpy = vi.spyOn(window, 'dispatchEvent'); + + await manager.saveRecipesLayout('masonry'); + + expect(state.global.settings.recipes_layout).toBe('masonry'); + expect(masonryBtn.classList.contains('active')).toBe(true); + expect(masonryBtn.getAttribute('aria-pressed')).toBe('true'); + expect(masonryBtn.getAttribute('aria-checked')).toBe('true'); + expect(gridBtn.classList.contains('active')).toBe(false); + expect(gridBtn.getAttribute('aria-pressed')).toBe('false'); + + const layoutEvent = dispatchSpy.mock.calls + .map(([event]) => event) + .find(event => event.type === 'lm:recipes-layout-changed'); + expect(layoutEvent).toBeInstanceOf(CustomEvent); + expect(calculateLayout).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + + dispatchSpy.mockRestore(); + delete state.virtualScroller; + }); + + it('ignores invalid recipes layout values', async () => { + const manager = createManager(); + await manager.saveRecipesLayout('bogus'); + expect(state.global.settings.recipes_layout).toBeUndefined(); + }); }); diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js index ff5b9f6b..7958ce27 100644 --- a/tests/frontend/pages/recipesPage.test.js +++ b/tests/frontend/pages/recipesPage.test.js @@ -163,6 +163,7 @@ describe('RecipeManager', () => { afterEach(() => { delete window.recipeManager; delete window.importManager; + delete window.settingsManager; }); it('initializes page controls, restores filters, and wires sort interactions', async () => { @@ -227,6 +228,38 @@ describe('RecipeManager', () => { expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); }); + it('wires the layout toggle and reflects the saved recipes layout setting', async () => { + const gridBtn = document.createElement('button'); + gridBtn.className = 'layout-toggle-btn'; + gridBtn.dataset.recipesLayout = 'grid'; + gridBtn.setAttribute('aria-pressed', 'false'); + const masonryBtn = document.createElement('button'); + masonryBtn.className = 'layout-toggle-btn'; + masonryBtn.dataset.recipesLayout = 'masonry'; + masonryBtn.setAttribute('aria-pressed', 'false'); + document.body.appendChild(gridBtn); + document.body.appendChild(masonryBtn); + + const saveRecipesLayoutMock = vi.fn().mockResolvedValue(); + window.settingsManager = { saveRecipesLayout: saveRecipesLayoutMock }; + + const manager = new RecipeManager(); + await manager.initialize(); + + // Initial state follows the saved setting (default grid) + expect(gridBtn.classList.contains('active')).toBe(true); + expect(gridBtn.getAttribute('aria-pressed')).toBe('true'); + expect(masonryBtn.classList.contains('active')).toBe(false); + + // Clicking the inactive option saves the new layout + masonryBtn.dispatchEvent(new Event('click', { bubbles: true })); + expect(saveRecipesLayoutMock).toHaveBeenCalledWith('masonry'); + + // Clicking the already-active option is a no-op + gridBtn.dispatchEvent(new Event('click', { bubbles: true })); + expect(saveRecipesLayoutMock).toHaveBeenCalledTimes(1); + }); + it('skips loading when duplicates mode is active and refreshes otherwise', async () => { const manager = new RecipeManager();