feat(recipes): add toolbar toggle and settings preview for masonry layout

This commit is contained in:
Will Miao
2026-08-16 15:23:30 +08:00
parent f53352efb2
commit 658f88ca48
18 changed files with 338 additions and 18 deletions
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -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": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
},
"layout": {
"title": "פריסת מתכונים",
"grid": "פריסת רשת",
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
}
},
"duplicates": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
},
"layout": {
"title": "レシピのレイアウト",
"grid": "グリッドレイアウト",
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
}
},
"duplicates": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "즐겨찾기만 표시",
"action": "즐겨찾기"
},
"layout": {
"title": "레시피 레이아웃",
"grid": "그리드 레이아웃",
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
}
},
"duplicates": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "Только избранные",
"action": "Избранное"
},
"layout": {
"title": "Макет рецептов",
"grid": "Макет сеткой",
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
}
},
"duplicates": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
},
"layout": {
"title": "配方布局",
"grid": "网格布局",
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
}
},
"duplicates": {
+5
View File
@@ -937,6 +937,11 @@
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
},
"layout": {
"title": "配方版面",
"grid": "網格版面",
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
}
},
"duplicates": {
@@ -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%;
+28
View File
@@ -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;
+49 -12
View File
@@ -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;
+24
View File
@@ -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)
+10
View File
@@ -134,6 +134,16 @@
</div>
<div class="controls-right">
{% if page_id == 'recipes' %}
<div class="control-group layout-toggle-group" role="group" aria-label="{{ t('recipes.controls.layout.title') }}" title="{{ t('recipes.controls.layout.title') }}">
<button type="button" class="layout-toggle-btn" data-recipes-layout="grid" aria-pressed="false" title="{{ t('recipes.controls.layout.grid') }}" aria-label="{{ t('recipes.controls.layout.grid') }}">
<i class="fas fa-th-large" aria-hidden="true"></i>
</button>
<button type="button" class="layout-toggle-btn" data-recipes-layout="masonry" aria-pressed="false" title="{{ t('recipes.controls.layout.masonry') }}" aria-label="{{ t('recipes.controls.layout.masonry') }}">
<i class="fas fa-columns" aria-hidden="true"></i>
</button>
</div>
{% endif %}
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
@@ -629,16 +629,22 @@
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
<label id="recipesLayoutLabel">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
<div class="setting-control layout-options-control">
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</span>
</button>
<button type="button" class="layout-option" data-recipes-layout="masonry" onclick="settingsManager.saveRecipesLayout('masonry')" role="radio" aria-checked="false">
<span class="layout-option-preview layout-preview-masonry" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</span>
</button>
</div>
</div>
</div>
</div>
@@ -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();
});
});
+33
View File
@@ -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();