From ccf10610c80e67b0d1733af44305badc7d494892 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Sat, 21 Feb 2026 19:48:41 +0100 Subject: [PATCH] feat(webapp): feed view mode (#9285) --- .../components/DonationInfo/DonationInfo.vue | 10 ++ .../LayoutToggle/LayoutToggle.spec.js | 107 ++++++++++++++++++ .../components/LayoutToggle/LayoutToggle.vue | 107 ++++++++++++++++++ .../MasonryGrid/MasonryGrid.spec.js | 9 ++ webapp/components/MasonryGrid/MasonryGrid.vue | 32 +++++- webapp/components/ProgressBar/ProgressBar.vue | 8 +- webapp/constants/filter.js | 4 +- webapp/locales/de.json | 7 ++ webapp/locales/en.json | 7 ++ webapp/locales/es.json | 7 ++ webapp/locales/fr.json | 7 ++ webapp/locales/it.json | 7 ++ webapp/locales/nl.json | 7 ++ webapp/locales/pl.json | 7 ++ webapp/locales/pt.json | 7 ++ webapp/locales/ru.json | 7 ++ webapp/pages/index.vue | 74 +++++++++--- 17 files changed, 391 insertions(+), 23 deletions(-) create mode 100644 webapp/components/LayoutToggle/LayoutToggle.spec.js create mode 100644 webapp/components/LayoutToggle/LayoutToggle.vue diff --git a/webapp/components/DonationInfo/DonationInfo.vue b/webapp/components/DonationInfo/DonationInfo.vue index a80487c68..f68f5fe2d 100644 --- a/webapp/components/DonationInfo/DonationInfo.vue +++ b/webapp/components/DonationInfo/DonationInfo.vue @@ -57,5 +57,15 @@ export default { margin-top: 16px; cursor: pointer; color: $text-color-base; + text-decoration: none !important; + + &:hover { + text-decoration: none !important; + color: $text-color-base !important; + } + + .os-button { + color: revert; + } } diff --git a/webapp/components/LayoutToggle/LayoutToggle.spec.js b/webapp/components/LayoutToggle/LayoutToggle.spec.js new file mode 100644 index 000000000..abbb2c800 --- /dev/null +++ b/webapp/components/LayoutToggle/LayoutToggle.spec.js @@ -0,0 +1,107 @@ +import { mount } from '@vue/test-utils' +import LayoutToggle from './LayoutToggle' + +const localVue = global.localVue + +describe('LayoutToggle', () => { + let wrapper + let storageMap + + beforeEach(() => { + storageMap = {} + jest.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => storageMap[key] ?? null) + jest.spyOn(Storage.prototype, 'setItem').mockImplementation((key, val) => { + storageMap[key] = val + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + const mocks = { + $t: jest.fn((t) => t), + } + + const Wrapper = (propsData = {}) => { + return mount(LayoutToggle, { + localVue, + mocks, + propsData, + stubs: { ClientOnly: { template: '
' } }, + }) + } + + it('renders two buttons', () => { + wrapper = Wrapper() + const buttons = wrapper.findAll('button') + expect(buttons.length).toBe(2) + }) + + it('shows first button as filled when value is true (single column)', () => { + wrapper = Wrapper({ value: true }) + const buttons = wrapper.findAll('.os-button') + expect(buttons.at(0).attributes('data-appearance')).toBe('filled') + expect(buttons.at(1).attributes('data-appearance')).toBe('ghost') + }) + + it('shows second button as filled when value is false (multi column)', () => { + wrapper = Wrapper({ value: false }) + const buttons = wrapper.findAll('.os-button') + expect(buttons.at(0).attributes('data-appearance')).toBe('ghost') + expect(buttons.at(1).attributes('data-appearance')).toBe('filled') + }) + + it('emits input with true when single-column button is clicked', async () => { + wrapper = Wrapper({ value: false }) + const buttons = wrapper.findAll('button') + await buttons.at(0).trigger('click') + expect(wrapper.emitted('input')).toEqual([[true]]) + }) + + it('emits input with false when multi-column button is clicked', async () => { + wrapper = Wrapper({ value: true }) + const buttons = wrapper.findAll('button') + await buttons.at(1).trigger('click') + expect(wrapper.emitted('input')).toEqual([[false]]) + }) + + it('saves layout preference to localStorage on click', async () => { + wrapper = Wrapper({ value: false }) + const buttons = wrapper.findAll('button') + await buttons.at(0).trigger('click') + expect(localStorage.setItem).toHaveBeenCalledWith('ocelot-layout-single-column', 'true') + }) + + it('reads layout preference from localStorage on mount', () => { + storageMap['ocelot-layout-single-column'] = 'true' + wrapper = Wrapper({ value: false }) + expect(wrapper.emitted('input')).toEqual([[true]]) + }) + + it('adds layout-toggle--hidden class when isMobile is true', async () => { + wrapper = Wrapper() + wrapper.setData({ windowWidth: 400 }) + await wrapper.vm.$nextTick() + expect(wrapper.find('.layout-toggle').classes()).toContain('layout-toggle--hidden') + }) + + it('does not add layout-toggle--hidden class on desktop', async () => { + wrapper = Wrapper() + wrapper.setData({ windowWidth: 1200 }) + await wrapper.vm.$nextTick() + expect(wrapper.find('.layout-toggle').classes()).not.toContain('layout-toggle--hidden') + }) + + it('has radiogroup role for accessibility', () => { + wrapper = Wrapper() + expect(wrapper.find('[role="radiogroup"]').exists()).toBe(true) + }) + + it('sets aria-checked correctly on radio buttons', () => { + wrapper = Wrapper({ value: true }) + const radios = wrapper.findAll('[role="radio"]') + expect(radios.at(0).attributes('aria-checked')).toBe('true') + expect(radios.at(1).attributes('aria-checked')).toBe('false') + }) +}) diff --git a/webapp/components/LayoutToggle/LayoutToggle.vue b/webapp/components/LayoutToggle/LayoutToggle.vue new file mode 100644 index 000000000..ca4b5fc95 --- /dev/null +++ b/webapp/components/LayoutToggle/LayoutToggle.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/webapp/components/MasonryGrid/MasonryGrid.spec.js b/webapp/components/MasonryGrid/MasonryGrid.spec.js index 22c997d40..3c74e51cc 100644 --- a/webapp/components/MasonryGrid/MasonryGrid.spec.js +++ b/webapp/components/MasonryGrid/MasonryGrid.spec.js @@ -35,6 +35,15 @@ describe('MasonryGrid', () => { expect(wrapper.element.style.rowGap).toBe('2px') }) + it('does not set grid-template-columns by default', () => { + expect(wrapper.element.style.gridTemplateColumns).toBe('') + }) + + it('sets grid-template-columns to 1fr when singleColumn is true', () => { + wrapper = mount(MasonryGrid, { localVue, propsData: { singleColumn: true } }) + expect(wrapper.element.style.gridTemplateColumns).toBe('1fr') + }) + it('calculates rowSpan for children via batchRecalculate', async () => { wrapper = mount(MasonryGrid, { localVue, diff --git a/webapp/components/MasonryGrid/MasonryGrid.vue b/webapp/components/MasonryGrid/MasonryGrid.vue index a85291aad..67cc535be 100644 --- a/webapp/components/MasonryGrid/MasonryGrid.vue +++ b/webapp/components/MasonryGrid/MasonryGrid.vue @@ -1,9 +1,5 @@ @@ -13,12 +9,32 @@ const ROW_HEIGHT = 2 const ROW_GAP = 2 export default { + props: { + singleColumn: { + type: Boolean, + default: false, + }, + }, data() { return { measuring: false, childCount: 0, } }, + computed: { + gridStyle() { + return { + gridAutoRows: `${ROW_HEIGHT}px`, + rowGap: `${ROW_GAP}px`, + ...(this.singleColumn ? { gridTemplateColumns: '1fr' } : {}), + } + }, + }, + watch: { + singleColumn() { + this.$nextTick(() => this.batchRecalculate()) + }, + }, mounted() { this.$nextTick(() => this.batchRecalculate()) this._resizeTimer = null @@ -76,10 +92,16 @@ export default { .ds-grid { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); column-gap: 16px; + min-width: 0; + max-width: 100%; @media (max-width: 810px) { column-gap: 8px; } + + > * { + min-width: 0; + } } .reset-grid-height { diff --git a/webapp/components/ProgressBar/ProgressBar.vue b/webapp/components/ProgressBar/ProgressBar.vue index 25c9a5a0a..73dfd70af 100644 --- a/webapp/components/ProgressBar/ProgressBar.vue +++ b/webapp/components/ProgressBar/ProgressBar.vue @@ -117,9 +117,15 @@ export default { .progress-bar-button { position: relative; margin-left: $space-x-small; +} - @media (max-width: 810px) { +@media (max-width: 639px) { + .progress-bar { display: none; } + + .progress-bar-button { + margin-left: 0; + } } diff --git a/webapp/constants/filter.js b/webapp/constants/filter.js index 00332053b..b629dc300 100644 --- a/webapp/constants/filter.js +++ b/webapp/constants/filter.js @@ -1,2 +1,2 @@ -export const SHOW_CONTENT_FILTER_HEADER_MENU = false -export const SHOW_CONTENT_FILTER_MASONRY_GRID = true +export const SHOW_CONTENT_FILTER_HEADER_MENU = true +export const SHOW_CONTENT_FILTER_MASONRY_GRID = false diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 871b44f1f..a3724800c 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -698,6 +698,13 @@ "redeemed-count": "{count} mal eingelöst", "redeemed-count-0": "Noch von niemandem eingelöst" }, + "layout": { + "toggle": { + "label": "Layout", + "multiColumn": "Mehrspaltig", + "singleColumn": "Einspaltig" + } + }, "localeSwitch": { "tooltip": "Sprache wählen" }, diff --git a/webapp/locales/en.json b/webapp/locales/en.json index cf13bc4ee..a535ad4a8 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -698,6 +698,13 @@ "redeemed-count": "This code has been used {count} times.", "redeemed-count-0": "No one has used this code yet." }, + "layout": { + "toggle": { + "label": "Layout", + "multiColumn": "Multi-column", + "singleColumn": "Single column" + } + }, "localeSwitch": { "tooltip": "Choose language" }, diff --git a/webapp/locales/es.json b/webapp/locales/es.json index a80d3c95f..462b858a8 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Diseño", + "multiColumn": "Varias columnas", + "singleColumn": "Una columna" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index ae61ea920..6423811aa 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Mise en page", + "multiColumn": "Plusieurs colonnes", + "singleColumn": "Une colonne" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/it.json b/webapp/locales/it.json index 4b94aa09e..88dcec9da 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Layout", + "multiColumn": "Più colonne", + "singleColumn": "Colonna singola" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index ff3543cc7..5c9a26dea 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Weergave", + "multiColumn": "Meerdere kolommen", + "singleColumn": "Eén kolom" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 48b7e6be4..142cea180 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Układ", + "multiColumn": "Wiele kolumn", + "singleColumn": "Jedna kolumna" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 1cf487d21..754760c1b 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Layout", + "multiColumn": "Várias colunas", + "singleColumn": "Coluna única" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index 58ca74134..989658f09 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -698,6 +698,13 @@ "redeemed-count": null, "redeemed-count-0": null }, + "layout": { + "toggle": { + "label": "Макет", + "multiColumn": "Несколько колонок", + "singleColumn": "Одна колонка" + } + }, "localeSwitch": { "tooltip": null }, diff --git a/webapp/pages/index.vue b/webapp/pages/index.vue index 058e5d2aa..26d3a4aea 100644 --- a/webapp/pages/index.vue +++ b/webapp/pages/index.vue @@ -17,6 +17,7 @@ " variant="primary" appearance="filled" + size="sm" @click="showFilter = !showFilter" >