fix(webapp): optimize masonry grid rendering and add SSR compatibility (#9284)

This commit is contained in:
Ulf Gebhardt 2026-02-21 13:39:12 +01:00 committed by GitHub
parent e3a41cb828
commit 6b6e77c2a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 263 additions and 97 deletions

View File

@ -7,6 +7,7 @@
url('~@@/assets/fonts/gentium-basic/GentiumBasic.woff2') format('woff2'), url('~@@/assets/fonts/gentium-basic/GentiumBasic.woff2') format('woff2'),
url('~@@/assets/fonts/gentium-basic/GentiumBasic.woff') format('woff'); url('~@@/assets/fonts/gentium-basic/GentiumBasic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
font-display: swap;
} }
@font-face { @font-face {
@ -18,4 +19,5 @@
url('~@@/assets/fonts/gentium-basic/GentiumBasic-Italic.woff2') format('woff2'), url('~@@/assets/fonts/gentium-basic/GentiumBasic-Italic.woff2') format('woff2'),
url('~@@/assets/fonts/gentium-basic/GentiumBasic-Italic.woff') format('woff'); url('~@@/assets/fonts/gentium-basic/GentiumBasic-Italic.woff') format('woff');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
font-display: swap;
} }

View File

@ -6,6 +6,7 @@
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-display: swap;
} }
/* Webfont: Lato-BoldItalic */ /* Webfont: Lato-BoldItalic */
@ -16,6 +17,7 @@
font-style: italic; font-style: italic;
font-weight: 600; font-weight: 600;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-display: swap;
} }
/* Webfont: Lato-Italic */ /* Webfont: Lato-Italic */
@ -26,6 +28,7 @@
font-style: italic; font-style: italic;
font-weight: normal; font-weight: normal;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-display: swap;
} }
/* Webfont: Lato-Regular */ /* Webfont: Lato-Regular */
@ -36,4 +39,5 @@
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
font-display: swap;
} }

View File

@ -56,5 +56,6 @@ export default {
margin-bottom: $space-x-small; margin-bottom: $space-x-small;
margin-top: 16px; margin-top: 16px;
cursor: pointer; cursor: pointer;
color: $text-color-base;
} }
</style> </style>

View File

@ -45,6 +45,7 @@ export default {
watch: { watch: {
isPopoverOpen: { isPopoverOpen: {
handler(isOpen) { handler(isOpen) {
if (typeof document === 'undefined') return
if (isOpen) { if (isOpen) {
document.body.classList.add('dropdown-open') document.body.classList.add('dropdown-open')
} else { } else {
@ -58,7 +59,9 @@ export default {
clearTimeout(mouseLeaveTimer) clearTimeout(mouseLeaveTimer)
if (this.isPopoverOpen) { if (this.isPopoverOpen) {
this.isPopoverOpen = false this.isPopoverOpen = false
document.body.classList.remove('dropdown-open') if (typeof document !== 'undefined') {
document.body.classList.remove('dropdown-open')
}
} }
}, },
methods: { methods: {

View File

@ -4,6 +4,13 @@ import MasonryGrid from './MasonryGrid'
const localVue = global.localVue const localVue = global.localVue
const GridChild = {
template: '<div>child</div>',
data() {
return { rowSpan: 0 }
},
}
describe('MasonryGrid', () => { describe('MasonryGrid', () => {
let wrapper let wrapper
@ -11,29 +18,66 @@ describe('MasonryGrid', () => {
wrapper = mount(MasonryGrid, { localVue }) wrapper = mount(MasonryGrid, { localVue })
}) })
it('adds the "reset-grid-height" class when itemsCalculating is more than 0', async () => { it('adds the "reset-grid-height" class when measuring is true', async () => {
wrapper.setData({ itemsCalculating: 1 }) wrapper.setData({ measuring: true })
await Vue.nextTick() await Vue.nextTick()
expect(wrapper.classes()).toContain('reset-grid-height') expect(wrapper.classes()).toContain('reset-grid-height')
}) })
it('removes the "reset-grid-height" class when itemsCalculating is 0', async () => { it('removes the "reset-grid-height" class when measuring is false', async () => {
wrapper.setData({ itemsCalculating: 0 }) wrapper.setData({ measuring: false })
await Vue.nextTick() await Vue.nextTick()
expect(wrapper.classes()).not.toContain('reset-grid-height') expect(wrapper.classes()).not.toContain('reset-grid-height')
}) })
it('adds 1 to itemsCalculating when "calculating-item-height" is emitted', async () => { it('sets inline grid styles', () => {
wrapper.setData({ itemsCalculating: 0 }) expect(wrapper.element.style.gridAutoRows).toBe('2px')
wrapper.vm.$emit('calculating-item-height') expect(wrapper.element.style.rowGap).toBe('2px')
await Vue.nextTick()
expect(wrapper.vm.itemsCalculating).toBe(1)
}) })
it('subtracts 1 from itemsCalculating when "finished-calculating-item-height" is emitted', async () => { it('calculates rowSpan for children via batchRecalculate', async () => {
wrapper.setData({ itemsCalculating: 2 }) wrapper = mount(MasonryGrid, {
wrapper.vm.$emit('finished-calculating-item-height') localVue,
slots: { default: GridChild },
})
const child = wrapper.vm.$children[0]
Object.defineProperty(child.$el, 'clientHeight', { value: 100, configurable: true })
await wrapper.vm.batchRecalculate()
// Math.ceil((100 + 2) / (2 + 2)) = Math.ceil(25.5) = 26
expect(child.rowSpan).toBe(26)
expect(wrapper.vm.measuring).toBe(false)
})
it('recalculates when child count changes in updated()', async () => {
const Parent = {
template: '<MasonryGrid><GridChild v-for="n in count" :key="n" /></MasonryGrid>',
components: { MasonryGrid, GridChild },
data() {
return { count: 1 }
},
}
wrapper = mount(Parent, { localVue })
await Vue.nextTick() await Vue.nextTick()
expect(wrapper.vm.itemsCalculating).toBe(1) expect(wrapper.vm.$children[0].childCount).toBe(1)
wrapper.setData({ count: 2 })
await Vue.nextTick()
await Vue.nextTick()
expect(wrapper.vm.$children[0].childCount).toBe(2)
})
it('skips children without rowSpan', async () => {
const NoRowSpan = { template: '<div>no rowSpan</div>' }
wrapper = mount(MasonryGrid, {
localVue,
slots: { default: NoRowSpan },
})
await wrapper.vm.batchRecalculate()
expect(wrapper.vm.measuring).toBe(false)
}) })
}) })

View File

@ -1,61 +1,73 @@
<template> <template>
<div class="ds-grid" :style="gridStyle" :class="[itemsCalculating ? 'reset-grid-height' : '']"> <div
class="ds-grid"
:style="{ gridAutoRows: '2px', rowGap: '2px' }"
:class="[measuring ? 'reset-grid-height' : '']"
>
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
<script> <script>
const ROW_HEIGHT = 2
const ROW_GAP = 2
export default { export default {
data() { data() {
return { return {
itemsCalculating: 0, measuring: false,
isMobile: false, childCount: 0,
} }
}, },
computed: {
gridStyle() {
const size = this.isMobile ? '1px' : '2px'
return { gridAutoRows: size, rowGap: size }
},
},
watch: {
isMobile() {
this.$nextTick(() => {
this.$children.forEach((child) => {
if (child.calculateItemHeight) child.calculateItemHeight()
})
})
},
},
created() {
this.$on('calculating-item-height', this.startCalculation)
this.$on('finished-calculating-item-height', this.endCalculation)
},
methods: {
startCalculation() {
this.itemsCalculating += 1
},
endCalculation() {
this.itemsCalculating -= 1
},
checkMobile() {
this.isMobile = window.innerWidth <= 810
},
},
mounted() { mounted() {
this.checkMobile() this.$nextTick(() => this.batchRecalculate())
// Children mount before parent recalculate their spans with correct grid values this._resizeTimer = null
this.$nextTick(() => { this._onResize = () => {
this.$children.forEach((child) => { clearTimeout(this._resizeTimer)
if (child.calculateItemHeight) child.calculateItemHeight() this._resizeTimer = setTimeout(() => this.batchRecalculate(), 150)
}) }
}) window.addEventListener('resize', this._onResize)
window.addEventListener('resize', this.checkMobile)
}, },
beforeDestroy() { beforeDestroy() {
this.$off('calculating-item-height', this.startCalculation) clearTimeout(this._resizeTimer)
this.$off('finished-calculating-item-height', this.endCalculation) window.removeEventListener('resize', this._onResize)
window.removeEventListener('resize', this.checkMobile) },
updated() {
const count = this.$children.length
if (count !== this.childCount) {
this.batchRecalculate()
}
},
methods: {
async batchRecalculate() {
this._recalcId = (this._recalcId || 0) + 1
const id = this._recalcId
this.childCount = this.$children.length
// Switch to auto-height so items take their natural height
this.measuring = true
await this.$nextTick()
// A newer call has started let it handle the measurement
if (id !== this._recalcId) return
// Read pass: measure all children in one go (single reflow)
const measurements = this.$children.map((child) => ({
child,
height: child.$el.clientHeight,
}))
// Write pass: set all rowSpans (no interleaved reads)
measurements.forEach(({ child, height }) => {
if (child.rowSpan !== undefined) {
child.rowSpan = Math.ceil((height + ROW_GAP) / (ROW_HEIGHT + ROW_GAP))
}
})
// Switch back to fixed row grid
this.measuring = false
},
}, },
} }
</script> </script>

View File

@ -27,24 +27,5 @@ export default {
rowSpan: this.imageAspectRatio ? getRowSpan(this.imageAspectRatio) : 69, rowSpan: this.imageAspectRatio ? getRowSpan(this.imageAspectRatio) : 69,
} }
}, },
methods: {
calculateItemHeight() {
this.$parent.$emit('calculating-item-height')
this.$nextTick(() => {
const gridStyle = this.$parent.$el.style
const rowHeight = parseInt(gridStyle.gridAutoRows)
const rowGapValue = gridStyle.rowGap || gridStyle.gridRowGap
const rowGap = parseInt(rowGapValue)
const itemHeight = this.$el.clientHeight
this.rowSpan = Math.ceil((itemHeight + rowGap) / (rowHeight + rowGap))
this.$parent.$emit('finished-calculating-item-height')
})
},
},
mounted() {
this.calculateItemHeight()
},
} }
</script> </script>

View File

@ -12,8 +12,17 @@
:highlight="isPinned" :highlight="isPinned"
> >
<template v-if="post.image" #heroImage> <template v-if="post.image" #heroImage>
<div class="image-placeholder" :style="{ aspectRatio: post.image.aspectRatio }"> <div
<responsive-image :image="post.image" sizes="640px" class="image" /> class="image-placeholder"
:class="{ 'image-placeholder--loaded': imageLoaded }"
:style="{ aspectRatio: post.image.aspectRatio }"
>
<responsive-image
:image="post.image"
sizes="640px"
class="image"
@loaded="imageLoaded = true"
/>
</div> </div>
</template> </template>
<client-only> <client-only>
@ -207,6 +216,11 @@ export default {
default: false, default: false,
}, },
}, },
data() {
return {
imageLoaded: false,
}
},
computed: { computed: {
...mapGetters({ ...mapGetters({
user: 'auth/user', user: 'auth/user',
@ -303,6 +317,18 @@ export default {
} }
} }
@keyframes image-placeholder-pulse {
0% {
opacity: 0.6;
}
50% {
opacity: 1;
}
100% {
opacity: 0.6;
}
}
.post-user-row { .post-user-row {
position: relative; position: relative;
@ -335,7 +361,12 @@ export default {
.image-placeholder { .image-placeholder {
width: 100%; width: 100%;
background-color: $color-neutral-80; background-color: $background-color-softer;
animation: image-placeholder-pulse 1.5s ease-in-out infinite;
&--loaded {
animation: none;
}
> .image { > .image {
display: block; display: block;

View File

@ -1,9 +1,20 @@
<template> <template>
<img :src="image.url" :sizes="sizes" :srcset="srcset" /> <img
:src="image.url"
:sizes="sizes"
:srcset="srcset"
:class="{ 'responsive-image--loaded': loaded }"
class="responsive-image"
loading="lazy"
fetchpriority="low"
@load="onLoad"
/>
</template> </template>
<script> <script>
export default { export default {
name: 'ResponsiveImage',
emits: ['loaded'],
props: { props: {
image: { image: {
type: Object, type: Object,
@ -14,11 +25,36 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
loaded: false,
}
},
computed: { computed: {
srcset() { srcset() {
const { w320, w640, w1024 } = this.image const { w320, w640, w1024 } = this.image
return `${w320} 320w, ${w640} 640w, ${w1024} 1024w` return `${w320} 320w, ${w640} 640w, ${w1024} 1024w`
}, },
}, },
mounted() {
if (this.$el.complete && this.$el.naturalWidth > 0) this.onLoad()
},
methods: {
onLoad() {
this.loaded = true
this.$emit('loaded')
},
},
} }
</script> </script>
<style lang="scss" scoped>
.responsive-image {
opacity: 0;
transition: opacity 0.3s ease;
&--loaded {
opacity: 1;
}
}
</style>

View File

@ -190,7 +190,9 @@ exports[`UserTeaser given an user user is disabled current user is a moderator r
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -321,7 +323,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop renders 1`] = `
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -411,7 +415,9 @@ exports[`UserTeaser given an user with linkToProfile, on desktop when hovering t
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -501,7 +507,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen renders 1`
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -592,7 +600,9 @@ exports[`UserTeaser given an user with linkToProfile, on touch screen when click
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -687,7 +697,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop renders 1`]
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -777,7 +789,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -867,7 +881,9 @@ exports[`UserTeaser given an user without linkToProfile, on desktop when hoverin
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -957,7 +973,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen renders
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -1048,7 +1066,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"
@ -1143,7 +1163,9 @@ exports[`UserTeaser given an user without linkToProfile, on touch screen when cl
<img <img
alt="Tilda Swinton" alt="Tilda Swinton"
class="image" class="responsive-image image"
fetchpriority="low"
loading="lazy"
sizes="320px" sizes="320px"
src="/avatars/tilda-swinton" src="/avatars/tilda-swinton"
srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w" srcset="/avatars/tilda-swinton-w320 320w, /avatars/tilda-swinton-w640 640w, /avatars/tilda-swinton-w1024 1024w"

View File

@ -1,2 +1,3 @@
export const isTouchDevice = () => export const isTouchDevice = () =>
'ontouchstart' in window || navigator.MaxTouchPoints > 0 || navigator.msMaxTouchPoints > 0 typeof window !== 'undefined' &&
('ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0)

View File

@ -52,7 +52,30 @@ export default (i18n) => {
` `
} }
export const filterPosts = (i18n) => { export const filterPosts = () => {
return gql`
${user}
${post}
${postCounts}
${tagsCategoriesAndPinned}
query Post($filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [_PostOrdering]) {
Post(filter: $filter, first: $first, offset: $offset, orderBy: $orderBy) {
postType
eventStart
eventEnd
eventVenue
eventLocationName
eventIsOnline
...post
...postCounts
...tagsCategoriesAndPinned
}
}
`
}
export const filterMapPosts = (i18n) => {
const lang = i18n.locale().toUpperCase() const lang = i18n.locale().toUpperCase()
return gql` return gql`
${user} ${user}

View File

@ -239,6 +239,12 @@ export default {
}, },
manifest, manifest,
render: {
// Generate preload hints for critical JS/CSS/font assets
resourceHints: true,
},
/* /*
** Build configuration ** Build configuration
*/ */

View File

@ -344,7 +344,7 @@ export default {
}, },
Post: { Post: {
query() { query() {
return filterPosts(this.$i18n) return filterPosts()
}, },
variables() { variables() {
return { return {

View File

@ -66,7 +66,7 @@ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import { profileUserQuery, mapUserQuery } from '~/graphql/User' import { profileUserQuery, mapUserQuery } from '~/graphql/User'
import { groupQuery } from '~/graphql/groups' import { groupQuery } from '~/graphql/groups'
import { filterPosts } from '~/graphql/PostQuery.js' import { filterMapPosts } from '~/graphql/PostQuery.js'
import mobile from '~/mixins/mobile' import mobile from '~/mixins/mobile'
import Empty from '~/components/Empty/Empty' import Empty from '~/components/Empty/Empty'
import MapStylesButtons from '~/components/Map/MapStylesButtons' import MapStylesButtons from '~/components/Map/MapStylesButtons'
@ -542,7 +542,7 @@ export default {
}, },
Post: { Post: {
query() { query() {
return filterPosts(this.$i18n) return filterMapPosts(this.$i18n)
}, },
variables() { variables() {
return { return {