fix(webapp): add responsive mobile menu with locale switching and filter support (#9281)

This commit is contained in:
Ulf Gebhardt 2026-02-21 08:47:14 +01:00 committed by GitHub
parent 30d88e9b41
commit 9548ad6e31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 1153 additions and 462 deletions

View File

@ -3,23 +3,40 @@ import gql from 'graphql-tag'
export const UpdateUser = gql` export const UpdateUser = gql`
mutation ( mutation (
$id: ID! $id: ID!
$slug: String
$name: String $name: String
$termsAndConditionsAgreedVersion: String $about: String
$locationName: String # empty string '' sets it to null $allowEmbedIframes: Boolean
$showShoutsPublicly: Boolean
$emailNotificationSettings: [EmailNotificationSettingsInput] $emailNotificationSettings: [EmailNotificationSettingsInput]
$termsAndConditionsAgreedVersion: String
$avatar: ImageInput
$locationName: String # empty string '' sets it to null
$locale: String
) { ) {
UpdateUser( UpdateUser(
id: $id id: $id
slug: $slug
name: $name name: $name
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion about: $about
locationName: $locationName allowEmbedIframes: $allowEmbedIframes
showShoutsPublicly: $showShoutsPublicly
emailNotificationSettings: $emailNotificationSettings emailNotificationSettings: $emailNotificationSettings
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatar: $avatar
locationName: $locationName
locale: $locale
) { ) {
id id
slug
name name
about
allowEmbedIframes
showShoutsPublicly
termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion
termsAndConditionsAgreedAt termsAndConditionsAgreedAt
locationName locationName
locale
location { location {
name name
nameDE nameDE
@ -33,6 +50,18 @@ export const UpdateUser = gql`
value value
} }
} }
avatar {
url
alt
sensitive
aspectRatio
type
}
badgeVerification {
id
description
icon
}
} }
} }
` `

View File

@ -1,7 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor' import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('I select {string} in the language menu', language => { defineStep('I select {string} in the language menu', language => {
cy.get('.locale-menu') cy.get('.locale-menu:visible')
.first()
.click() .click()
cy.contains('.locale-menu-popover a', language) cy.contains('.locale-menu-popover a', language)
.click() .click()

View File

@ -1,7 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor' import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('open the notification menu and click on the first item', () => { defineStep('open the notification menu and click on the first item', () => {
cy.get('.notifications-menu') cy.get('.notifications-menu:visible')
.first()
.invoke('show') .invoke('show')
.click() // 'invoke('show')' because of the delay for show the menu .click() // 'invoke('show')' because of the delay for show the menu
cy.get('.notification-content a') cy.get('.notification-content a')

View File

@ -1,6 +1,7 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor' import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('see {int} unread notifications in the top menu', count => { defineStep('see {int} unread notifications in the top menu', count => {
cy.get('.notifications-menu') cy.get('.notifications-menu:visible')
.first()
.should('contain', count) .should('contain', count)
}) })

View File

@ -1,7 +1,8 @@
import { defineStep } from '@badeball/cypress-cucumber-preprocessor' import { defineStep } from '@badeball/cypress-cucumber-preprocessor'
defineStep('the notification menu button links to the all notifications page', () => { defineStep('the notification menu button links to the all notifications page', () => {
cy.get('.notifications-menu') cy.get('.notifications-menu:visible')
.first()
.click() .click()
cy.location('pathname') cy.location('pathname')
.should('contain', '/notifications') .should('contain', '/notifications')

View File

@ -0,0 +1,5 @@
<!-- Font Awesome Free 6.7.2 "language" icon (CC BY 4.0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 640 512">
<title>language</title>
<path d="M0 128C0 92.7 28.7 64 64 64l192 0 48 0 16 0 256 0c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64l-256 0-16 0-48 0L64 448c-35.3 0-64-28.7-64-64L0 128zm320 0l0 256 256 0 0-256-256 0zM178.3 175.9c-3.2-7.2-10.4-11.9-18.3-11.9s-15.1 4.7-18.3 11.9l-64 144c-4.5 10.1 .1 21.9 10.2 26.4s21.9-.1 26.4-10.2l8.9-20.1 73.6 0 8.9 20.1c4.5 10.1 16.3 14.6 26.4 10.2s14.6-16.3 10.2-26.4l-64-144zM160 233.2L179 276l-38 0 19-42.8zM448 164c11 0 20 9 20 20l0 4 44 0 16 0c11 0 20 9 20 20s-9 20-20 20l-2 0-1.6 4.5c-8.9 24.4-22.4 46.6-39.6 65.4c.9 .6 1.8 1.1 2.7 1.6l18.9 11.3c9.5 5.7 12.5 18 6.9 27.4s-18 12.5-27.4 6.9l-18.9-11.3c-4.5-2.7-8.8-5.5-13.1-8.5c-10.6 7.5-21.9 14-34 19.4l-3.6 1.6c-10.1 4.5-21.9-.1-26.4-10.2s.1-21.9 10.2-26.4l3.6-1.6c6.4-2.9 12.6-6.1 18.5-9.8l-12.2-12.2c-7.8-7.8-7.8-20.5 0-28.3s20.5-7.8 28.3 0l14.6 14.6 .5 .5c12.4-13.1 22.5-28.3 29.8-45L448 228l-72 0c-11 0-20-9-20-20s9-20 20-20l52 0 0-4c0-11 9-20 20-20z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -359,6 +359,7 @@ $media-query-small: "(min-width: 600px)";
$media-query-medium: "(min-width: 768px)"; $media-query-medium: "(min-width: 768px)";
$media-query-large: "(min-width: 1024px)"; $media-query-large: "(min-width: 1024px)";
$media-query-x-large: "(min-width: 1200px)"; $media-query-x-large: "(min-width: 1200px)";
$container-max-width-x-large: 1200px;
/** /**
* @tokens Background Images * @tokens Background Images

View File

@ -1,26 +1,28 @@
<template> <template>
<div class="donation-info"> <page-params-link :pageParams="links.DONATE" class="donation-info">
<progress-bar :label="label" :goal="goal" :progress="progress"> <progress-bar :label="label" :goal="goal" :progress="progress">
<os-button size="sm" variant="primary" @click="redirectToPage(links.DONATE)"> <os-button size="sm" variant="primary">
{{ $t('donations.donate-now') }} {{ $t('donations.donate-now') }}
<template #suffix> <template #suffix>
<os-icon :icon="icons.heartO" /> <os-icon :icon="icons.heartO" />
</template> </template>
</os-button> </os-button>
</progress-bar> </progress-bar>
</div> </page-params-link>
</template> </template>
<script> <script>
import { OsButton, OsIcon } from '@ocelot-social/ui' import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry' import { iconRegistry } from '~/utils/iconRegistry'
import links from '~/constants/links.js' import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
import ProgressBar from '~/components/ProgressBar/ProgressBar.vue' import ProgressBar from '~/components/ProgressBar/ProgressBar.vue'
export default { export default {
components: { components: {
OsButton, OsButton,
OsIcon, OsIcon,
PageParamsLink,
ProgressBar, ProgressBar,
}, },
props: { props: {
@ -44,11 +46,6 @@ export default {
}) })
}, },
}, },
methods: {
redirectToPage(pageParams) {
pageParams.redirectToPage(this)
},
},
} }
</script> </script>
@ -58,5 +55,6 @@ export default {
flex: 1; flex: 1;
margin-bottom: $space-x-small; margin-bottom: $space-x-small;
margin-top: 16px; margin-top: 16px;
cursor: pointer;
} }
</style> </style>

View File

@ -1,16 +1,16 @@
<template> <template>
<dropdown ref="menu" placement="top-start" :offset="8" class="filter-menu"> <dropdown ref="menu" :placement="placement" :offset="offset" class="filter-menu">
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<os-button <os-button
variant="primary" variant="primary"
:appearance="filterActive ? 'filled' : 'ghost'" :appearance="filterActive ? 'filled' : 'ghost'"
circle
:aria-label="$t('common.filter')" :aria-label="$t('common.filter')"
@click.prevent="toggleMenu()" @click.prevent="toggleMenu()"
> >
<template #icon> <template #icon>
<os-icon :icon="icons.filter" /> <os-icon :icon="icons.filter" />
</template> </template>
<os-icon class="dropdown-arrow" :icon="icons.angleDown" />
</os-button> </os-button>
</template> </template>
<template #popover> <template #popover>
@ -34,8 +34,8 @@ export default {
OsIcon, OsIcon,
}, },
props: { props: {
placement: { type: String }, placement: { type: String, default: 'top-start' },
offset: { type: [String, Number] }, offset: { type: [String, Number], default: 8 },
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({

View File

@ -61,6 +61,7 @@ export default {
> .filter-list { > .filter-list {
display: flex; display: flex;
flex-wrap: wrap;
flex-basis: 100%; flex-basis: 100%;
flex-grow: 1; flex-grow: 1;
padding-left: $space-base; padding-left: $space-base;

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,11 @@ import Vuex from 'vuex'
const localVue = global.localVue const localVue = global.localVue
const stubs = { const stubs = {
'client-only': true, 'client-only': { template: '<div><slot /></div>' },
} }
describe('LocaleSwitch.vue', () => { describe('LocaleSwitch.vue', () => {
let wrapper, mocks, computed, deutschLanguageItem, getters let wrapper, mocks, computed, getters
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
@ -48,10 +48,12 @@ describe('LocaleSwitch.vue', () => {
{ {
name: 'English', name: 'English',
path: 'en', path: 'en',
flag: '🇬🇧',
}, },
{ {
name: 'Deutsch', name: 'Deutsch',
path: 'de', path: 'de',
flag: '🇩🇪',
}, },
] ]
}, },
@ -71,37 +73,61 @@ describe('LocaleSwitch.vue', () => {
} }
describe('with current user', () => { describe('with current user', () => {
beforeEach(() => { let toggleMenu
beforeEach(async () => {
toggleMenu = jest.fn()
wrapper = Wrapper() wrapper = Wrapper()
wrapper.find('.locale-menu').trigger('click') await wrapper.vm.changeLanguage('de', toggleMenu)
deutschLanguageItem = wrapper.findAll('li').at(1)
deutschLanguageItem.trigger('click')
}) })
it("sets a user's locale", () => { it("sets a user's locale", () => {
expect(mocks.$i18n.set).toHaveBeenCalledTimes(1) expect(mocks.$i18n.set).toHaveBeenCalledWith('de')
}) })
it("updates the user's locale in the database", () => { it("updates the user's locale in the database", () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1) expect(mocks.$apollo.mutate).toHaveBeenCalledTimes(1)
}) })
it('closes the menu', () => {
expect(toggleMenu).toHaveBeenCalled()
})
})
describe('when apollo mutation fails', () => {
beforeEach(async () => {
wrapper = Wrapper()
// First call succeeds (consumes mockResolvedValueOnce)
await wrapper.vm.changeLanguage('de', jest.fn())
// Second call fails (consumes mockRejectedValueOnce)
await wrapper.vm.changeLanguage('en', jest.fn())
})
it('shows an error toast', () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('Please log in!')
})
}) })
describe('no current user', () => { describe('no current user', () => {
beforeEach(() => { let toggleMenu
beforeEach(async () => {
toggleMenu = jest.fn()
getters = { getters = {
'auth/user': () => { 'auth/user': () => {
return null return null
}, },
} }
wrapper = Wrapper() wrapper = Wrapper()
wrapper.find('.locale-menu').trigger('click') await wrapper.vm.changeLanguage('de', toggleMenu)
deutschLanguageItem = wrapper.findAll('li').at(1)
deutschLanguageItem.trigger('click')
}) })
it('does not send a UpdateUser mutation', () => { it('does not send a UpdateUser mutation', () => {
expect(mocks.$apollo.mutate).not.toHaveBeenCalled() expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
}) })
it('still closes the menu', () => {
expect(toggleMenu).toHaveBeenCalled()
})
}) })
}) })

View File

@ -2,18 +2,22 @@
<client-only> <client-only>
<dropdown ref="menu" :placement="placement" :offset="offset"> <dropdown ref="menu" :placement="placement" :offset="offset">
<template #default="{ toggleMenu }"> <template #default="{ toggleMenu }">
<a <os-button
class="locale-menu" class="locale-menu"
href="#" variant="primary"
appearance="ghost"
circle
:aria-label="$t('localeSwitch.tooltip')"
v-tooltip="{ v-tooltip="{
content: $t('localeSwitch.tooltip'), content: $t('localeSwitch.tooltip'),
placement: 'bottom-start', placement: 'bottom-start',
}" }"
@click.prevent="toggleMenu()" @click.prevent="toggleMenu()"
> >
<span class="label">{{ current.code.toUpperCase() }}</span> <template #icon>
<os-icon class="dropdown-arrow" :icon="icons.angleDown" /> <os-icon :icon="icons.language" />
</a> </template>
</os-button>
</template> </template>
<template #popover="{ toggleMenu }"> <template #popover="{ toggleMenu }">
<ds-menu class="locale-menu-popover" :matcher="matcher" :routes="routes"> <ds-menu class="locale-menu-popover" :matcher="matcher" :routes="routes">
@ -24,6 +28,7 @@
:parents="item.parents" :parents="item.parents"
@click.stop.prevent="changeLanguage(item.route.path, toggleMenu)" @click.stop.prevent="changeLanguage(item.route.path, toggleMenu)"
> >
<span class="locale-flag">{{ item.route.flag }}</span>
{{ item.route.name }} {{ item.route.name }}
</ds-menu-item> </ds-menu-item>
</template> </template>
@ -34,18 +39,19 @@
</template> </template>
<script> <script>
import { OsIcon } from '@ocelot-social/ui' import { OsButton, OsIcon } from '@ocelot-social/ui'
import { iconRegistry } from '~/utils/iconRegistry' import { iconRegistry } from '~/utils/iconRegistry'
import gql from 'graphql-tag'
import Dropdown from '~/components/Dropdown' import Dropdown from '~/components/Dropdown'
import find from 'lodash/find' import find from 'lodash/find'
import orderBy from 'lodash/orderBy' import orderBy from 'lodash/orderBy'
import locales from '~/locales' import locales from '~/locales'
import { mapGetters, mapMutations } from 'vuex' import localeUpdate from '~/mixins/localeUpdate.js'
export default { export default {
mixins: [localeUpdate],
components: { components: {
Dropdown, Dropdown,
OsButton,
OsIcon, OsIcon,
}, },
props: { props: {
@ -62,17 +68,12 @@ export default {
return find(this.locales, { code: this.$i18n.locale() }) return find(this.locales, { code: this.$i18n.locale() })
}, },
routes() { routes() {
const routes = this.locales.map((locale) => { return this.locales.map((locale) => ({
return { name: locale.name,
name: locale.name, path: locale.code,
path: locale.code, flag: locale.flag,
} }))
})
return routes
}, },
...mapGetters({
currentUser: 'auth/user',
}),
}, },
created() { created() {
this.icons = iconRegistry this.icons = iconRegistry
@ -86,57 +87,11 @@ export default {
matcher(locale) { matcher(locale) {
return locale === this.$i18n.locale() return locale === this.$i18n.locale()
}, },
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
async updateUserLocale() {
if (!this.currentUser || !this.currentUser.id) return null
try {
await this.$apollo.mutate({
mutation: gql`
mutation ($id: ID!, $locale: String) {
UpdateUser(id: $id, locale: $locale) {
id
locale
}
}
`,
variables: {
id: this.currentUser.id,
locale: this.$i18n.locale(),
},
update: (store, { data: { UpdateUser } }) => {
const { locale } = UpdateUser
this.setCurrentUser({
...this.currentUser,
locale,
})
},
})
this.$toast.success(this.$t('contribution.success'))
} catch (err) {
this.$toast.error(err.message)
}
},
}, },
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.locale-menu {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $color-locale-menu;
> .label {
margin: 0 $space-xx-small;
}
}
nav.locale-menu-popover { nav.locale-menu-popover {
margin-left: -$space-small !important; margin-left: -$space-small !important;
margin-right: -$space-small !important; margin-right: -$space-small !important;
@ -146,4 +101,10 @@ nav.locale-menu-popover {
padding-right: $space-base; padding-right: $space-base;
} }
} }
.locale-flag {
margin-right: $space-xx-small;
font-size: 1.2em;
line-height: 1;
}
</style> </style>

View File

@ -118,5 +118,9 @@ export default {
.progress-bar-button { .progress-bar-button {
position: relative; position: relative;
float: right; float: right;
@media (max-width: 810px) {
display: none;
}
} }
</style> </style>

View File

@ -358,6 +358,7 @@ export const updateUserMutation = () => {
$termsAndConditionsAgreedVersion: String $termsAndConditionsAgreedVersion: String
$avatar: ImageInput $avatar: ImageInput
$locationName: String # empty string '' sets it to null $locationName: String # empty string '' sets it to null
$locale: String
) { ) {
UpdateUser( UpdateUser(
id: $id id: $id
@ -370,6 +371,7 @@ export const updateUserMutation = () => {
termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion termsAndConditionsAgreedVersion: $termsAndConditionsAgreedVersion
avatar: $avatar avatar: $avatar
locationName: $locationName locationName: $locationName
locale: $locale
) { ) {
id id
slug slug

View File

@ -1,14 +1,14 @@
<template> <template>
<div class="layout-default"> <div class="layout-default">
<div class="main-navigation"> <div class="main-navigation">
<header-menu :showMobileMenu="isMobile" /> <header-menu />
</div> </div>
<div class="ds-container ds-container-x-large"> <div class="ds-container ds-container-x-large">
<div class="main-container"> <div class="main-container">
<nuxt /> <nuxt />
</div> </div>
</div> </div>
<page-footer v-if="!isMobile" /> <page-footer class="desktop-footer" />
<div id="overlay" /> <div id="overlay" />
<client-only> <client-only>
<modal /> <modal />
@ -66,6 +66,12 @@ export default {
padding-bottom: 8rem; padding-bottom: 8rem;
} }
.desktop-footer {
@media (max-width: 810px) {
display: none;
}
}
.chat-modul { .chat-modul {
background-color: rgb(233, 228, 228); background-color: rgb(233, 228, 228);
width: 355px; width: 355px;

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": "Landkarte" "tooltip": "Landkarte"
}, },
"more": "Mehr",
"notifications": { "notifications": {
"tooltip": "Benachrichtigungen" "tooltip": "Benachrichtigungen"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": "Map" "tooltip": "Map"
}, },
"more": "More",
"notifications": { "notifications": {
"tooltip": "Notifications" "tooltip": "Notifications"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": "Mapa" "tooltip": "Mapa"
}, },
"more": "Más",
"notifications": { "notifications": {
"tooltip": "Notificaciones" "tooltip": "Notificaciones"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": "Carte" "tooltip": "Carte"
}, },
"more": "Plus",
"notifications": { "notifications": {
"tooltip": "Notifications" "tooltip": "Notifications"
} }

View File

@ -6,6 +6,7 @@ const locales = [
name: 'English', name: 'English',
code: 'en', code: 'en',
iso: 'en-US', iso: 'en-US',
flag: '🇬🇧',
enabled: true, enabled: true,
dateFnsLocale: enUS, dateFnsLocale: enUS,
}, },
@ -13,6 +14,7 @@ const locales = [
name: 'Deutsch', name: 'Deutsch',
code: 'de', code: 'de',
iso: 'de-DE', iso: 'de-DE',
flag: '🇩🇪',
enabled: true, enabled: true,
dateFnsLocale: de, dateFnsLocale: de,
}, },
@ -20,6 +22,7 @@ const locales = [
name: 'Nederlands', name: 'Nederlands',
code: 'nl', code: 'nl',
iso: 'nl-NL', iso: 'nl-NL',
flag: '🇳🇱',
enabled: true, enabled: true,
dateFnsLocale: nl, dateFnsLocale: nl,
}, },
@ -27,6 +30,7 @@ const locales = [
name: 'Français', name: 'Français',
code: 'fr', code: 'fr',
iso: 'fr-FR', iso: 'fr-FR',
flag: '🇫🇷',
enabled: true, enabled: true,
dateFnsLocale: fr, dateFnsLocale: fr,
}, },
@ -34,6 +38,7 @@ const locales = [
name: 'Italiano', name: 'Italiano',
code: 'it', code: 'it',
iso: 'it-IT', iso: 'it-IT',
flag: '🇮🇹',
enabled: true, enabled: true,
dateFnsLocale: it, dateFnsLocale: it,
}, },
@ -41,6 +46,7 @@ const locales = [
name: 'Español', name: 'Español',
code: 'es', code: 'es',
iso: 'es-ES', iso: 'es-ES',
flag: '🇪🇸',
enabled: true, enabled: true,
dateFnsLocale: es, dateFnsLocale: es,
}, },
@ -48,6 +54,7 @@ const locales = [
name: 'Português', name: 'Português',
code: 'pt', code: 'pt',
iso: 'pt-PT', iso: 'pt-PT',
flag: '🇵🇹',
enabled: true, enabled: true,
dateFnsLocale: pt, dateFnsLocale: pt,
}, },
@ -55,6 +62,7 @@ const locales = [
name: 'Polski', name: 'Polski',
code: 'pl', code: 'pl',
iso: 'pl-PL', iso: 'pl-PL',
flag: '🇵🇱',
enabled: true, enabled: true,
dateFnsLocale: pl, dateFnsLocale: pl,
}, },
@ -62,6 +70,7 @@ const locales = [
name: 'Русский', name: 'Русский',
code: 'ru', code: 'ru',
iso: 'ru-RU', iso: 'ru-RU',
flag: '🇷🇺',
enabled: true, enabled: true,
dateFnsLocale: ru, dateFnsLocale: ru,
}, },

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": null "tooltip": null
}, },
"more": "Altro",
"notifications": { "notifications": {
"tooltip": "Notifiche" "tooltip": "Notifiche"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": null "tooltip": null
}, },
"more": "Meer",
"notifications": { "notifications": {
"tooltip": "Notificaties" "tooltip": "Notificaties"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": null "tooltip": null
}, },
"more": "Więcej",
"notifications": { "notifications": {
"tooltip": "Powiadomienia" "tooltip": "Powiadomienia"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": null "tooltip": null
}, },
"more": "Mais",
"notifications": { "notifications": {
"tooltip": "Notificações" "tooltip": "Notificações"
} }

View File

@ -661,6 +661,7 @@
"map": { "map": {
"tooltip": null "tooltip": null
}, },
"more": "Ещё",
"notifications": { "notifications": {
"tooltip": "Уведомления" "tooltip": "Уведомления"
} }

View File

@ -0,0 +1,36 @@
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
export default {
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
},
methods: {
...mapMutations({
setCurrentUser: 'auth/SET_USER',
}),
async updateUserLocale() {
if (!this.currentUser || !this.currentUser.id) return
try {
await this.$apollo.mutate({
mutation: updateUserMutation(),
variables: {
id: this.currentUser.id,
locale: this.$i18n.locale(),
},
update: (_store, { data: { UpdateUser } }) => {
this.setCurrentUser({
...this.currentUser,
locale: UpdateUser.locale,
})
},
})
this.$toast.success(this.$t('contribution.success'))
} catch (err) {
this.$toast.error(err.message)
}
},
},
}

View File

@ -1,7 +1,71 @@
<template> <template>
<div> <div>
<!-- create post --> <!-- feed top row: filter (left) + create post (right) -->
<div :class="POST_ADD_BUTTON_POSITION_TOP ? 'box-add-button-top' : ''"> <div class="feed-top-row">
<div v-if="SHOW_CONTENT_FILTER_MASONRY_GRID" class="filterButtonMenu">
<os-button
class="my-filter-button"
v-if="
!postsFilter['postType_in'] &&
!postsFilter['categories_some'] &&
!postsFilter['author'] &&
!postsFilter['postsInMyGroups']
"
variant="primary"
appearance="filled"
@click="showFilter = !showFilter"
>
<template #suffix>
<os-icon :icon="filterButtonIcon" />
</template>
{{ $t('contribution.filterMasonryGrid.noFilter') }}
</os-button>
<header-button
v-if="filteredPostTypes.includes('Article')"
:title="$t('contribution.filterMasonryGrid.onlyArticles')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetPostType"
/>
<header-button
v-if="filteredPostTypes.includes('Event')"
:title="$t('contribution.filterMasonryGrid.onlyEvents')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetPostType"
/>
<header-button
v-if="postsFilter['categories_some']"
:title="$t('contribution.filterMasonryGrid.myTopics')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetCategories"
/>
<header-button
v-if="postsFilter['author']"
:title="$t('contribution.filterMasonryGrid.myFriends')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByFollowed"
/>
<header-button
v-if="postsFilter['postsInMyGroups']"
:title="$t('contribution.filterMasonryGrid.myGroups')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByGroups"
/>
<div id="my-filter" v-if="showFilter">
<div @mouseleave="mouseLeaveFilterMenu">
<filter-menu-component @showFilterMenu="showFilterMenu" />
</div>
</div>
</div>
<client-only> <client-only>
<os-button <os-button
as="nuxt-link" as="nuxt-link"
@ -11,10 +75,7 @@
placement: 'left', placement: 'left',
}" }"
class="post-add-button" class="post-add-button"
:class="[ :class="{ 'hide-filter': hideByScroll }"
POST_ADD_BUTTON_POSITION_TOP ? 'post-add-button-top' : 'post-add-button-bottom',
{ 'hide-filter': hideByScroll },
]"
variant="primary" variant="primary"
appearance="filled" appearance="filled"
circle circle
@ -26,78 +87,12 @@
</os-button> </os-button>
</client-only> </client-only>
</div> </div>
<div>
<div v-if="SHOW_CONTENT_FILTER_MASONRY_GRID" class="top-filter-menu">
<div class="filterButtonBox">
<div class="filterButtonMenu" :class="{ 'hide-filter': hideByScroll }">
<os-button
class="my-filter-button"
v-if="
!postsFilter['postType_in'] &&
!postsFilter['categories_some'] &&
!postsFilter['author'] &&
!postsFilter['postsInMyGroups']
"
variant="primary"
appearance="filled"
@click="showFilter = !showFilter"
>
<template #suffix>
<os-icon :icon="filterButtonIcon" />
</template>
{{ $t('contribution.filterMasonryGrid.noFilter') }}
</os-button>
<header-button <div
v-if="filteredPostTypes.includes('Article')" v-if="hashtag || showDonations"
:title="$t('contribution.filterMasonryGrid.onlyArticles')" class="newsfeed-controls"
:clickButton="openFilterMenu" :class="{ 'newsfeed-controls--no-filter': !SHOW_CONTENT_FILTER_MASONRY_GRID }"
:titleRemove="$t('filter-menu.deleteFilter')" >
:clickRemove="resetPostType"
/>
<header-button
v-if="filteredPostTypes.includes('Event')"
:title="$t('contribution.filterMasonryGrid.onlyEvents')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetPostType"
/>
<header-button
v-if="postsFilter['categories_some']"
:title="$t('contribution.filterMasonryGrid.myTopics')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetCategories"
/>
<header-button
v-if="postsFilter['author']"
:title="$t('contribution.filterMasonryGrid.myFriends')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByFollowed"
/>
<header-button
v-if="postsFilter['postsInMyGroups']"
:title="$t('contribution.filterMasonryGrid.myGroups')"
:clickButton="openFilterMenu"
:titleRemove="$t('filter-menu.deleteFilter')"
:clickRemove="resetByGroups"
/>
<div id="my-filter" v-if="showFilter">
<div @mouseleave="mouseLeaveFilterMenu">
<filter-menu-component @showFilterMenu="showFilterMenu" />
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="hashtag || showDonations" class="newsfeed-controls">
<div v-if="hashtag"> <div v-if="hashtag">
<hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" /> <hashtags-filter :hashtag="hashtag" @clearSearch="clearSearch" />
</div> </div>
@ -171,7 +166,6 @@ import { filterPosts } from '~/graphql/PostQuery.js'
import UpdateQuery from '~/components/utils/UpdateQuery' import UpdateQuery from '~/components/utils/UpdateQuery'
import FilterMenuComponent from '~/components/FilterMenu/FilterMenuComponent' import FilterMenuComponent from '~/components/FilterMenu/FilterMenuComponent'
import { SHOW_CONTENT_FILTER_MASONRY_GRID } from '~/constants/filter.js' import { SHOW_CONTENT_FILTER_MASONRY_GRID } from '~/constants/filter.js'
import { POST_ADD_BUTTON_POSITION_TOP } from '~/constants/posts.js'
import GetCategories from '~/mixins/getCategoriesMixin.js' import GetCategories from '~/mixins/getCategoriesMixin.js'
export default { export default {
@ -206,7 +200,6 @@ export default {
pageSize: 12, pageSize: 12,
hashtag, hashtag,
SHOW_CONTENT_FILTER_MASONRY_GRID, SHOW_CONTENT_FILTER_MASONRY_GRID,
POST_ADD_BUTTON_POSITION_TOP,
} }
}, },
computed: { computed: {
@ -364,65 +357,59 @@ export default {
display: none; display: none;
} }
.box-add-button-top { .feed-top-row {
float: right; display: flex;
align-items: flex-start;
gap: 16px;
margin-top: 0px;
} }
.post-add-button-bottom { .filterButtonMenu {
flex: 1 1 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.post-add-button {
height: 54px !important; height: 54px !important;
width: 54px !important; width: 54px !important;
min-height: 54px !important; min-height: 54px !important;
min-width: 54px !important; min-width: 54px !important;
font-size: 26px !important; font-size: 26px !important;
box-shadow: $box-shadow-x-large !important;
z-index: $z-index-sticky-float !important; z-index: $z-index-sticky-float !important;
position: fixed !important; position: fixed !important;
bottom: -5px !important; right: max(20px, calc((100vw - $container-max-width-x-large) / 2 + 48px)) !important;
left: 98vw !important; top: 81px !important;
transform: translate(-120%, -120%) !important; transition: top 0.3s ease !important;
box-shadow: $box-shadow-x-large !important;
} }
.post-add-button-top { .main-navigation:has(.hide-navbar) ~ .ds-container .post-add-button {
height: 54px !important; top: 20px !important;
width: 54px !important;
min-height: 54px !important;
min-width: 54px !important;
font-size: 26px !important;
z-index: $z-index-sticky-float !important;
position: fixed !important;
top: 80px !important;
box-shadow: $box-shadow-x-large !important;
} }
.top-filter-menu { .top-info-bar {
margin-top: 16px;
}
.top-info-bar,
.top-filter-menu {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.filterButtonMenu {
width: 95%;
position: fixed;
z-index: $z-index-sticky;
margin-top: -45px;
padding: 30px 0px 20px 0px;
background-color: #f5f4f6;
}
.newsfeed-controls { .newsfeed-controls {
margin-top: 46px; margin-top: 8px;
&.newsfeed-controls--no-filter {
margin-top: -16px;
margin-bottom: 16px;
.top-info-bar {
padding-right: 70px;
}
}
} }
.main-container .grid-column-helper { .main-container .grid-column-helper {
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 357px)) !important; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 357px)) !important;
} }
@media screen and (max-width: 656px) {
.filterButtonMenu {
margin-top: -50px;
}
}
#my-filter { #my-filter {
max-width: 1028px; max-width: 1028px;
background-color: white; background-color: white;
@ -433,7 +420,7 @@ export default {
z-index: $z-index-page-submenu; z-index: $z-index-page-submenu;
} }
.grid-margin-top { .grid-margin-top {
margin-top: 26px; margin-top: 8px;
} }
@media screen and (min-height: 401px) { @media screen and (min-height: 401px) {
#my-filter { #my-filter {
@ -475,25 +462,9 @@ export default {
padding-bottom: 80px; padding-bottom: 80px;
} }
} }
@media screen and (max-width: 1200px) {
.box-add-button-top {
padding-right: 40px;
}
.post-add-button-top {
height: 44px !important;
width: 44px !important;
min-height: 44px !important;
min-width: 44px !important;
font-size: 23px;
}
}
@media screen and (max-width: 650px) { @media screen and (max-width: 650px) {
// .top-filter-menu{
// margin-top: 24px;
// }
.newsfeed-controls { .newsfeed-controls {
margin-top: 32px; margin-top: 8px;
} }
} }
</style> </style>