feat(webapp): feed view mode (#9285)

This commit is contained in:
Ulf Gebhardt 2026-02-21 19:48:41 +01:00 committed by GitHub
parent 1b2c5e68b6
commit ccf10610c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 391 additions and 23 deletions

View File

@ -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;
}
}
</style>

View File

@ -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: '<div><slot /></div>' } },
})
}
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')
})
})

View File

@ -0,0 +1,107 @@
<template>
<client-only>
<div
class="layout-toggle"
:class="{ 'layout-toggle--hidden': isMobile }"
role="radiogroup"
:aria-label="$t('layout.toggle.label')"
@keydown.left.prevent="setLayout(true)"
@keydown.right.prevent="setLayout(false)"
>
<os-button
ref="singleBtn"
circle
size="sm"
:appearance="value ? 'filled' : 'ghost'"
variant="primary"
role="radio"
:aria-checked="String(value)"
:aria-label="$t('layout.toggle.singleColumn')"
:tabindex="value ? '0' : '-1'"
@click="setLayout(true)"
>
<template #icon>
<os-icon :icon="icons.list" />
</template>
</os-button>
<os-button
ref="multiBtn"
circle
size="sm"
:appearance="!value ? 'filled' : 'ghost'"
variant="primary"
role="radio"
:aria-checked="String(!value)"
:aria-label="$t('layout.toggle.multiColumn')"
:tabindex="!value ? '0' : '-1'"
@click="setLayout(false)"
>
<template #icon>
<os-icon :icon="icons.columns" />
</template>
</os-button>
</div>
</client-only>
</template>
<script>
import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry'
import mobile from '~/mixins/mobile'
const STORAGE_KEY = 'ocelot-layout-single-column'
export default {
components: {
OsButton,
OsIcon,
},
mixins: [mobile(639)],
props: {
value: {
type: Boolean,
default: false,
},
},
created() {
this.icons = iconRegistry
},
mounted() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored !== null) {
this.$emit('input', stored === 'true')
}
} catch (e) {
// localStorage not available
}
},
methods: {
setLayout(val) {
try {
localStorage.setItem(STORAGE_KEY, String(val))
} catch (e) {
// localStorage not available
}
this.$emit('input', val)
this.$nextTick(() => {
const ref = val ? this.$refs.singleBtn : this.$refs.multiBtn
const el = ref?.$el || ref
if (el) el.focus()
})
},
},
}
</script>
<style lang="scss">
.layout-toggle {
display: inline-flex;
gap: 4px;
align-items: center;
}
.layout-toggle--hidden {
display: none;
}
</style>

View File

@ -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,

View File

@ -1,9 +1,5 @@
<template>
<div
class="ds-grid"
:style="{ gridAutoRows: '2px', rowGap: '2px' }"
:class="[measuring ? 'reset-grid-height' : '']"
>
<div class="ds-grid" :style="gridStyle" :class="[measuring ? 'reset-grid-height' : '']">
<slot></slot>
</div>
</template>
@ -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 {

View File

@ -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;
}
}
</style>

View File

@ -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

View File

@ -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"
},

View File

@ -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"
},

View File

@ -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
},

View File

@ -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
},

View File

@ -698,6 +698,13 @@
"redeemed-count": null,
"redeemed-count-0": null
},
"layout": {
"toggle": {
"label": "Layout",
"multiColumn": "Più colonne",
"singleColumn": "Colonna singola"
}
},
"localeSwitch": {
"tooltip": null
},

View File

@ -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
},

View File

@ -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
},

View File

@ -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
},

View File

@ -698,6 +698,13 @@
"redeemed-count": null,
"redeemed-count-0": null
},
"layout": {
"toggle": {
"label": "Макет",
"multiColumn": "Несколько колонок",
"singleColumn": "Одна колонка"
}
},
"localeSwitch": {
"tooltip": null
},

View File

@ -17,6 +17,7 @@
"
variant="primary"
appearance="filled"
size="sm"
@click="showFilter = !showFilter"
>
<template #suffix>
@ -64,12 +65,20 @@
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByGroups"
/>
<div v-if="showDonations" class="donation-mobile-only">
<donation-info :goal="goal" :progress="progress" />
</div>
<layout-toggle v-model="singleColumn" />
<div id="my-filter" v-if="showFilter">
<div @mouseleave="mouseLeaveFilterMenu">
<filter-menu-component @showFilterMenu="showFilterMenu" />
</div>
</div>
</div>
<layout-toggle v-if="!SHOW_CONTENT_FILTER_MASONRY_GRID" v-model="singleColumn" />
<div v-if="showDonations && !SHOW_CONTENT_FILTER_MASONRY_GRID" class="donation-mobile-only">
<donation-info :goal="goal" :progress="progress" />
</div>
<client-only>
<os-button
as="nuxt-link"
@ -99,15 +108,16 @@
<div v-if="hashtag">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</div>
<div v-if="showDonations" class="top-info-bar">
<div v-if="showDonations" class="top-info-bar donation-desktop-only">
<donation-info :goal="goal" :progress="progress" />
</div>
</div>
<!-- content grid -->
<masonry-grid
:single-column="singleColumn"
:class="[
!hashtag && !showDonations ? 'grid-margin-top' : '',
!isMobile && posts.length <= 2 ? 'grid-column-helper' : '',
!isMobile && !singleColumn && posts.length <= 2 ? 'grid-column-helper' : '',
]"
>
<!-- skeleton placeholders while loading -->
@ -170,6 +180,7 @@ import PostTeaserSkeleton from '~/components/PostTeaser/PostTeaserSkeleton.vue'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'
import MasonryGridItem from '~/components/MasonryGrid/MasonryGridItem.vue'
import HeaderButton from '~/components/FilterMenu/HeaderButton'
import LayoutToggle from '~/components/LayoutToggle/LayoutToggle'
import { mapGetters, mapMutations } from 'vuex'
import { DonationsQuery } from '~/graphql/Donations'
import { filterPosts } from '~/graphql/PostQuery.js'
@ -192,6 +203,7 @@ export default {
MasonryGridItem,
FilterMenuComponent,
HeaderButton,
LayoutToggle,
},
mixins: [postListActions, mobile(), GetCategories],
data() {
@ -211,6 +223,7 @@ export default {
pageSize: 12,
hashtag,
SHOW_CONTENT_FILTER_MASONRY_GRID,
singleColumn: false,
}
},
computed: {
@ -249,6 +262,14 @@ export default {
this.icons = iconRegistry
},
mounted() {
try {
const stored = localStorage.getItem('ocelot-layout-single-column')
if (stored !== null) {
this.singleColumn = stored === 'true'
}
} catch (e) {
// localStorage not available
}
if (this.categoryId) {
this.resetCategories()
this.toggleCategory(this.categoryId)
@ -370,9 +391,9 @@ export default {
.feed-top-row {
display: flex;
align-items: flex-start;
align-items: center;
gap: 16px;
margin-top: 0px;
margin-top: -8px;
}
.filterButtonMenu {
@ -392,8 +413,8 @@ export default {
box-shadow: $box-shadow-x-large !important;
z-index: $z-index-sticky-float !important;
position: fixed !important;
right: max(20px, calc((100vw - $container-max-width-x-large) / 2 + 48px)) !important;
top: 81px !important;
right: max(20px, calc((100vw - $container-max-width-x-large) / 2 + 52px)) !important;
top: 88px !important;
transition: top 0.3s ease !important;
}
@ -406,15 +427,14 @@ export default {
align-items: center;
}
.newsfeed-controls {
margin-top: 8px;
margin-top: 0;
&.newsfeed-controls--no-filter {
margin-top: -16px;
margin-bottom: 16px;
.donation-info {
margin-top: 4px;
}
.top-info-bar {
padding-right: 70px;
}
.top-info-bar {
padding-right: 70px;
}
}
.main-container .grid-column-helper {
@ -431,7 +451,7 @@ export default {
z-index: $z-index-page-submenu;
}
.grid-margin-top {
margin-top: 8px;
margin-top: 4px;
}
@media screen and (min-height: 401px) {
#my-filter {
@ -473,7 +493,31 @@ export default {
padding-bottom: 80px;
}
}
@media screen and (max-width: 650px) {
.donation-mobile-only {
display: none;
.donation-info {
margin: 0;
}
.progress-bar-component {
top: 0;
}
}
@media (max-width: 639px) {
.donation-mobile-only {
display: block;
}
.donation-desktop-only {
display: none;
}
.post-add-button {
top: 67px !important;
}
.newsfeed-controls {
margin-top: 8px;
}