feat(webapp): shout comments (#8600)

* shout comments

* fix notifications

* Remove whitespace for empty category sections

* Overhaul post actions

* Adjust spacing

* Allow fine-grained size control for icons and circle buttons via css variables; adjust comments layout

* Adjust spacing

* Add test for ActionButton (WIP)

* Rename import

* Remove text and add count bubble

* Use filled icons to indicate active states

* Adjust sizes and orientation

* Remove unused properties, add test

* Fix ObserveButton test

* Fix ShoutButton test

* fix tests

* Adapt styles

* Adjust style for larger numbers

* Remove unused icon

* Fix test structure

* Remove unused class names

---------

Co-authored-by: Maximilian Harz <maxharz@gmail.com>
This commit is contained in:
Ulf Gebhardt 2025-05-31 00:13:15 +02:00 committed by GitHub
parent 51564e5d9b
commit 4b3a26d517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 541 additions and 238 deletions

View File

@ -108,10 +108,14 @@ export default {
count: {
postObservingUsersCount:
'-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User) WHERE obs.active = true AND NOT related.deleted AND NOT related.disabled',
shoutedCount:
'<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true',
},
boolean: {
isPostObservedByMe:
'MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1',
shoutedByCurrentUser:
'MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))',
},
}),
},

View File

@ -1,3 +1,4 @@
enum ShoutTypeEnum {
Post
Comment
}

View File

@ -53,6 +53,14 @@ type Comment {
)
postObservingUsersCount: Int!
@cypher(statement: "MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(u:User) WHERE obs.active = true AND NOT u.disabled = true AND NOT u.deleted = true RETURN COUNT(DISTINCT u)")
shoutedByCurrentUser: Boolean!
@cypher(statement: "MATCH (this) RETURN EXISTS((this)<-[:SHOUTED]-(:User {id: $cypherParams.currentUserId}))")
shoutedCount: Int!
@cypher(
statement: "MATCH (this)<-[:SHOUTED]-(related:User) WHERE NOT related.deleted = true AND NOT related.disabled = true RETURN COUNT(DISTINCT related)"
)
}
type Query {

View File

@ -243,9 +243,9 @@ type Mutation {
markTeaserAsViewed(id: ID!): Post
# Shout the given Type and ID
shout(id: ID!, type: ShoutTypeEnum): Boolean!
shout(id: ID!, type: ShoutTypeEnum!): Boolean!
# Unshout the given Type and ID
unshout(id: ID!, type: ShoutTypeEnum): Boolean!
unshout(id: ID!, type: ShoutTypeEnum!): Boolean!
toggleObservePost(id: ID!, value: Boolean!): Post!
}

View File

@ -0,0 +1,64 @@
import { render, screen, fireEvent } from '@testing-library/vue'
import '@testing-library/jest-dom'
import ActionButton from './ActionButton.vue'
const localVue = global.localVue
describe('ActionButton.vue', () => {
let mocks
beforeEach(() => {
mocks = {
$t: jest.fn((t) => t),
}
})
let wrapper
const Wrapper = ({ isDisabled = false } = {}) => {
return render(ActionButton, {
mocks,
localVue,
propsData: {
icon: 'heart',
text: 'Click me',
count: 7,
disabled: isDisabled,
},
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('when not disabled', () => {
it('renders', () => {
const wrapper = Wrapper()
expect(wrapper.container).toMatchSnapshot()
})
it('shows count', () => {
const count = screen.getByText('7')
expect(count).toBeInTheDocument()
})
it('button emits click event', async () => {
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.emitted().click).toEqual([[]])
})
})
describe('when disabled', () => {
it('renders', () => {
const wrapper = Wrapper({ isDisabled: true })
expect(wrapper.container).toMatchSnapshot()
})
it('button does not emit click event', async () => {
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.emitted().click).toEqual([[]])
})
})
})

View File

@ -0,0 +1,61 @@
<template>
<div class="action-button">
<base-button
:loading="loading"
:disabled="disabled"
:icon="icon"
:aria-label="text"
:filled="filled"
circle
@click="click"
/>
<div class="count">{{ count }}</div>
</div>
</template>
<script>
export default {
props: {
count: { type: Number, required: true },
text: { type: String, required: true },
icon: { type: String, required: true },
filled: { type: Boolean, default: false },
disabled: { type: Boolean },
loading: { type: Boolean },
},
methods: {
click() {
this.$emit('click')
},
},
}
</script>
<style lang="scss" scoped>
.action-button {
display: flex;
justify-content: center;
align-items: center;
gap: $space-xx-small;
position: relative;
--icon-size: calc(var(--circle-button-width, #{$size-button-base}) / 2);
}
.count {
user-select: none;
color: $color-primary-dark;
background-color: $color-secondary-inverse;
border: 1px solid $color-primary-dark;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: -12px;
left: calc(100% - 16px);
--diameter: calc(var(--circle-button-width, #{$size-button-base}) * 0.7);
min-width: var(--diameter);
height: var(--diameter);
border-radius: 12px;
font-size: 12px;
padding-inline: 2px;
}
</style>

View File

@ -17,7 +17,7 @@ describe('CommentCard.vue', () => {
postId: 'post42',
}
mocks = {
$t: jest.fn(),
$t: jest.fn((t) => t),
$toast: {
success: jest.fn(),
error: jest.fn(),

View File

@ -39,15 +39,25 @@
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</base-button>
</template>
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click="reply"
/>
<div class="actions">
<shout-button
:disabled="isAuthor"
:count="comment.shoutedCount"
:is-shouted="comment.shoutedByCurrentUser"
:node-id="comment.id"
class="shout-button"
node-type="Comment"
/>
<base-button
:title="this.$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
size="small"
v-scroll-to="'.editor'"
@click="reply"
/>
</div>
</base-card>
</template>
@ -59,6 +69,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations'
import ShoutButton from '~/components/ShoutButton.vue'
import scrollToAnchor from '~/mixins/scrollToAnchor.js'
export default {
@ -67,6 +78,7 @@ export default {
ContentMenu,
ContentViewer,
CommentForm,
ShoutButton,
},
mixins: [scrollToAnchor],
data() {
@ -98,6 +110,11 @@ export default {
hasLongContent() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH
},
isAuthor() {
const { author } = this.comment
if (!author) return false
return this.$store.getters['auth/user'].id === author.id
},
isUnavailable() {
return (this.comment.deleted || this.comment.disabled) && !this.isModerator
},
@ -192,19 +209,14 @@ export default {
margin-bottom: $space-small;
}
> .base-button {
align-self: flex-end;
.actions {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
}
}
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight {
0% {
border: $border-size-base solid $color-primary;
@ -214,3 +226,17 @@ export default {
}
}
</style>
<style lang="scss" scoped>
.actions {
margin-top: $space-x-small;
display: flex;
align-items: center;
justify-content: right;
gap: calc($space-base * 0.5);
}
.shout-button {
--circle-button-width: 28px;
}
</style>

View File

@ -44,7 +44,7 @@
</ds-chip>
</div>
<!-- group categories -->
<div class="categories" v-if="categoriesActive">
<div class="categories" v-if="categoriesActive && group.categories.length > 0">
<category
v-for="category in group.categories"
:key="category.id"

View File

@ -1,14 +1,14 @@
import { mount } from '@vue/test-utils'
import { render, screen, fireEvent } from '@testing-library/vue'
import ObserveButton from './ObserveButton.vue'
const localVue = global.localVue
describe('ObserveButton', () => {
let mocks
const Wrapper = (count = 1, postId = '123', isObserved = true) => {
return mount(ObserveButton, {
mocks,
return render(ObserveButton, {
mocks: {
$t: jest.fn((t) => t),
},
localVue,
propsData: {
count,
@ -18,43 +18,39 @@ describe('ObserveButton', () => {
})
}
let wrapper
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('observed', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper(1, '123', true)
})
it('renders', () => {
expect(wrapper.element).toMatchSnapshot()
expect(wrapper.container).toMatchSnapshot()
})
it('emits toggleObservePost with false when clicked', () => {
const button = wrapper.find('.base-button')
button.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', false]])
it('emits toggleObservePost with false when clicked', async () => {
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.emitted().toggleObservePost).toEqual([['123', false]])
})
})
describe('unobserved', () => {
let wrapper
beforeEach(() => {
wrapper = Wrapper(1, '123', false)
})
it('renders', () => {
expect(wrapper.element).toMatchSnapshot()
expect(wrapper.container).toMatchSnapshot()
})
it('emits toggleObservePost with true when clicked', () => {
const button = wrapper.find('.base-button')
button.trigger('click')
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', true]])
it('emits toggleObservePost with true when clicked', async () => {
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.emitted().toggleObservePost).toEqual([['123', true]])
})
})
})

View File

@ -1,26 +1,27 @@
<template>
<ds-space margin="xx-small" class="text-align-center">
<base-button :loading="loading" :filled="isObserved" icon="bell" circle @click="toggle" />
<ds-space margin-bottom="xx-small" />
<ds-text color="soft" class="observe-button-text">
<ds-heading style="display: inline" tag="h3">{{ count }}x</ds-heading>
{{ $t('observeButton.observed') }}
</ds-text>
</ds-space>
<action-button
:loading="false"
:count="count"
:text="$t('observeButton.observed')"
:filled="isObserved"
icon="bell"
circle
@click="toggle"
/>
</template>
<script>
import ActionButton from '~/components/ActionButton.vue'
export default {
components: {
ActionButton,
},
props: {
count: { type: Number, default: 0 },
postId: { type: String, default: null },
isObserved: { type: Boolean, default: false },
},
data() {
return {
loading: false,
}
},
methods: {
toggle() {
this.$emit('toggleObservePost', this.postId, !this.isObserved)
@ -28,12 +29,3 @@ export default {
},
}
</script>
<style lang="scss">
.observe-button-text {
user-select: none;
}
.text-align-center {
text-align: center;
}
</style>

View File

@ -53,7 +53,7 @@
class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
>
<div class="categories" v-if="categoriesActive">
<div class="categories" v-if="categoriesActive && post.categories.length > 0">
<category
v-for="category in post.categories"
:key="category.id"

View File

@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils'
import ShoutButton from './ShoutButton.vue'
import { render, screen, fireEvent } from '@testing-library/vue'
import '@testing-library/jest-dom'
import Vue from 'vue'
import ShoutButton from './ShoutButton.vue'
const localVue = global.localVue
@ -9,49 +10,54 @@ describe('ShoutButton.vue', () => {
beforeEach(() => {
mocks = {
$t: jest.fn(),
$t: jest.fn((t) => t),
$apollo: {
mutate: jest.fn(),
},
}
})
describe('mount', () => {
let wrapper
const Wrapper = () => {
return mount(ShoutButton, { mocks, localVue })
}
let wrapper
beforeEach(() => {
wrapper = Wrapper()
})
const Wrapper = ({ isShouted = false } = {}) => {
return render(ShoutButton, { mocks, localVue, propsData: { isShouted } })
}
it('renders button and text', () => {
expect(mocks.$t).toHaveBeenCalledWith('shoutButton.shouted')
expect(wrapper.findAll('.base-button')).toHaveLength(1)
expect(wrapper.findAll('.shout-button-text')).toHaveLength(1)
expect(wrapper.vm.shouted).toBe(false)
expect(wrapper.vm.shoutedCount).toBe(0)
})
beforeEach(() => {
wrapper = Wrapper()
})
it('toggle the button', async () => {
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
wrapper.find('.base-button').trigger('click')
expect(wrapper.vm.shouted).toBe(true)
expect(wrapper.vm.shoutedCount).toBe(1)
await Vue.nextTick()
expect(wrapper.vm.shouted).toBe(true)
expect(wrapper.vm.shoutedCount).toBe(1)
})
it('renders button and text', () => {
expect(wrapper.container).toMatchSnapshot()
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
})
it('toggle the button, but backend fails', async () => {
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
await wrapper.find('.base-button').trigger('click')
expect(wrapper.vm.shouted).toBe(true)
expect(wrapper.vm.shoutedCount).toBe(1)
await Vue.nextTick()
expect(wrapper.vm.shouted).toBe(false)
expect(wrapper.vm.shoutedCount).toBe(0)
it('toggle the button', async () => {
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.container).toMatchSnapshot()
const shoutedCount = screen.getByText('1')
expect(shoutedCount).toBeInTheDocument()
})
it('toggle the button, but backend fails', async () => {
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
const button = screen.getByRole('button')
await fireEvent.click(button)
expect(wrapper.container).toMatchSnapshot()
let shoutedCount = screen.getByText('1')
expect(shoutedCount).toBeInTheDocument()
await Vue.nextTick()
shoutedCount = screen.getByText('0')
expect(shoutedCount).toBeInTheDocument()
})
describe('when shouted', () => {
it('renders', () => {
wrapper = Wrapper({ isShouted: true })
expect(wrapper.container).toMatchSnapshot()
})
})
})

View File

@ -1,28 +1,28 @@
<template>
<ds-space margin="xx-small" class="text-align-center">
<base-button
:loading="loading"
:disabled="disabled"
:filled="shouted"
icon="heart-o"
circle
@click="toggle"
/>
<ds-space margin-bottom="xx-small" />
<ds-text color="soft" class="shout-button-text">
<ds-heading style="display: inline" tag="h3">{{ shoutedCount }}x</ds-heading>
{{ $t('shoutButton.shouted') }}
</ds-text>
</ds-space>
<action-button
:loading="loading"
:disabled="disabled"
:count="shoutedCount"
:text="$t('shoutButton.shouted')"
:filled="shouted"
icon="heart-o"
@click="toggle"
/>
</template>
<script>
import gql from 'graphql-tag'
import ActionButton from '~/components/ActionButton.vue'
export default {
components: {
ActionButton,
},
props: {
count: { type: Number, default: 0 },
postId: { type: String, default: null },
nodeType: { type: String },
nodeId: { type: String, default: null },
isShouted: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
},
@ -58,12 +58,13 @@ export default {
this.$apollo
.mutate({
mutation: gql`
mutation($id: ID!) {
${mutation}(id: $id, type: Post)
mutation($id: ID!, $type: ShoutTypeEnum!) {
${mutation}(id: $id, type: $type)
}
`,
variables: {
id: this.postId,
id: this.nodeId,
type: this.nodeType,
},
})
.then((res) => {
@ -82,12 +83,3 @@ export default {
},
}
</script>
<style lang="scss">
.shout-button-text {
user-select: none;
}
.text-align-center {
text-align: center;
}
</style>

View File

@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ActionButton.vue when disabled renders 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="Click me"
class="base-button --icon-only --circle"
disabled="disabled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
7
</div>
</div>
</div>
`;
exports[`ActionButton.vue when not disabled renders 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="Click me"
class="base-button --icon-only --circle"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
7
</div>
</div>
</div>
`;

View File

@ -1,81 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ObserveButton observed renders 1`] = `
<div
class="ds-space text-align-center"
style="margin-top: 4px; margin-bottom: 4px;"
>
<button
class="base-button --icon-only --circle --filled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="ds-space"
style="margin-bottom: 4px;"
/>
<p
class="ds-text observe-button-text ds-text-soft"
circle=""
class="action-button"
>
<h3
class="ds-heading ds-heading-h3"
style="display: inline;"
<button
aria-label="observeButton.observed"
class="base-button --icon-only --circle --filled"
type="button"
>
1x
</h3>
</p>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
1
</div>
</div>
</div>
`;
exports[`ObserveButton unobserved renders 1`] = `
<div
class="ds-space text-align-center"
style="margin-top: 4px; margin-bottom: 4px;"
>
<button
class="base-button --icon-only --circle"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div>
<div
class="ds-space"
style="margin-bottom: 4px;"
/>
<p
class="ds-text observe-button-text ds-text-soft"
circle=""
class="action-button"
>
<h3
class="ds-heading ds-heading-h3"
style="display: inline;"
<button
aria-label="observeButton.observed"
class="base-button --icon-only --circle"
type="button"
>
1x
</h3>
</p>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
1
</div>
</div>
</div>
`;

View File

@ -0,0 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShoutButton.vue renders button and text 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
0
</div>
</div>
</div>
`;
exports[`ShoutButton.vue toggle the button 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
1
</div>
</div>
</div>
`;
exports[`ShoutButton.vue toggle the button, but backend fails 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
1
</div>
</div>
</div>
`;
exports[`ShoutButton.vue when shouted renders 1`] = `
<div>
<div
class="action-button"
>
<button
aria-label="shoutButton.shouted"
class="base-button --icon-only --circle --filled"
type="button"
>
<span
class="base-icon"
>
<!---->
</span>
<!---->
</button>
<div
class="count"
>
0
</div>
</div>
</div>
`;

View File

@ -112,7 +112,8 @@ export default {
}
&.--circle {
width: $size-button-base;
width: var(--circle-button-width, $size-button-base);
height: var(--circle-button-width, $size-button-base);
border-radius: 50%;
}

View File

@ -55,7 +55,7 @@ export default {
}
&.--regular {
height: 1.2em;
height: var(--icon-size, 1.2em);
}
&.--large {

View File

@ -158,5 +158,7 @@ export const commentFragment = gql`
contentExcerpt
isPostObservedByMe
postObservingUsersCount
shoutedByCurrentUser
shoutedCount
}
`

View File

@ -131,7 +131,7 @@
<ds-space margin="x-small" />
</ds-space>
<!-- group categories -->
<template v-if="categoriesActive">
<template v-if="categoriesActive && group && group.categories.length > 0">
<hr />
<ds-space margin-top="small" margin-bottom="small">
<ds-text class="centered-text hyphenate-text" color="soft" size="small">

View File

@ -50,7 +50,7 @@ describe('PostSlug', () => {
})
const propsData = {}
mocks = {
$t: jest.fn(),
$t: jest.fn((t) => t),
$filters: {
truncate: (a) => a,
removeHtml: (a) => a,

View File

@ -77,7 +77,7 @@
<!-- content -->
<content-viewer class="content hyphenate-text" :content="post.content" />
<!-- categories -->
<div v-if="categoriesActive" class="categories">
<div v-if="categoriesActive && post.categories.length > 0" class="categories">
<ds-space margin="xx-large" />
<ds-space margin="xx-small" />
<hc-category
@ -97,35 +97,23 @@
<ds-space margin="xx-small" />
<hc-hashtag v-for="tag in sortedTags" :key="tag.id" :id="tag.id" />
</div>
<ds-space margin-top="small">
<ds-flex :gutter="{ lg: 'small' }">
<!-- Shout Button -->
<ds-flex-item
:width="{ lg: '15%', md: '22%', sm: '22%', base: '100%' }"
class="shout-button"
>
<hc-shout-button
v-if="post.author"
:disabled="isAuthor"
:count="post.shoutedCount"
:is-shouted="post.shoutedByCurrentUser"
:post-id="post.id"
/>
</ds-flex-item>
<!-- Follow Button -->
<ds-flex-item
:width="{ lg: '15%', md: '22%', sm: '22%', base: '100%' }"
class="shout-button"
>
<observe-button
:is-observed="post.isObservedByMe"
:count="post.observingUsersCount"
:post-id="post.id"
@toggleObservePost="toggleObservePost"
/>
</ds-flex-item>
</ds-flex>
</ds-space>
<div class="actions">
<!-- Shout Button -->
<shout-button
:disabled="isAuthor"
:count="post.shoutedCount"
:is-shouted="post.shoutedByCurrentUser"
:node-id="post.id"
node-type="Post"
/>
<!-- Follow Button -->
<observe-button
:is-observed="post.isObservedByMe"
:count="post.observingUsersCount"
:post-id="post.id"
@toggleObservePost="toggleObservePost"
/>
</div>
<!-- Comments -->
<ds-section>
<comment-list
@ -168,7 +156,7 @@ import CommentList from '~/components/CommentList/CommentList'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import HcShoutButton from '~/components/ShoutButton.vue'
import ShoutButton from '~/components/ShoutButton.vue'
import ObserveButton from '~/components/ObserveButton.vue'
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
@ -198,7 +186,7 @@ export default {
DateTimeRange,
HcCategory,
HcHashtag,
HcShoutButton,
ShoutButton,
ObserveButton,
LocationTeaser,
PageParamsLink,
@ -425,10 +413,15 @@ export default {
}
}
}
</style>
@media only screen and (max-width: 960px) {
.shout-button {
float: left;
}
<style lang="scss" scoped>
.actions {
display: flex;
align-items: center;
justify-content: right;
gap: $space-small;
margin-top: $space-small;
margin-bottom: calc($space-base * 2);
}
</style>