mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-03-01 12:44:37 +00:00
feat(webapp): feed view mode (#9285)
This commit is contained in:
parent
1b2c5e68b6
commit
ccf10610c8
@ -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>
|
||||
|
||||
107
webapp/components/LayoutToggle/LayoutToggle.spec.js
Normal file
107
webapp/components/LayoutToggle/LayoutToggle.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
107
webapp/components/LayoutToggle/LayoutToggle.vue
Normal file
107
webapp/components/LayoutToggle/LayoutToggle.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -698,6 +698,13 @@
|
||||
"redeemed-count": null,
|
||||
"redeemed-count-0": null
|
||||
},
|
||||
"layout": {
|
||||
"toggle": {
|
||||
"label": "Layout",
|
||||
"multiColumn": "Più colonne",
|
||||
"singleColumn": "Colonna singola"
|
||||
}
|
||||
},
|
||||
"localeSwitch": {
|
||||
"tooltip": null
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -698,6 +698,13 @@
|
||||
"redeemed-count": null,
|
||||
"redeemed-count-0": null
|
||||
},
|
||||
"layout": {
|
||||
"toggle": {
|
||||
"label": "Макет",
|
||||
"multiColumn": "Несколько колонок",
|
||||
"singleColumn": "Одна колонка"
|
||||
}
|
||||
},
|
||||
"localeSwitch": {
|
||||
"tooltip": null
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user