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: { count: {
postObservingUsersCount: postObservingUsersCount:
'-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User) WHERE obs.active = true AND NOT related.deleted AND NOT related.disabled', '-[: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: { boolean: {
isPostObservedByMe: isPostObservedByMe:
'MATCH (this)-[:COMMENTS]->(:Post)<-[obs:OBSERVES]-(related:User {id: $cypherParams.currentUserId}) WHERE obs.active = true RETURN COUNT(related) >= 1', '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 { enum ShoutTypeEnum {
Post Post
Comment
} }

View File

@ -53,6 +53,14 @@ type Comment {
) )
postObservingUsersCount: Int! 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)") @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 { type Query {

View File

@ -243,9 +243,9 @@ type Mutation {
markTeaserAsViewed(id: ID!): Post markTeaserAsViewed(id: ID!): Post
# Shout the given Type and ID # 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 the given Type and ID
unshout(id: ID!, type: ShoutTypeEnum): Boolean! unshout(id: ID!, type: ShoutTypeEnum!): Boolean!
toggleObservePost(id: ID!, value: Boolean!): Post! 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', postId: 'post42',
} }
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn((t) => t),
$toast: { $toast: {
success: jest.fn(), success: jest.fn(),
error: jest.fn(), error: jest.fn(),

View File

@ -39,15 +39,25 @@
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }} {{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</base-button> </base-button>
</template> </template>
<base-button <div class="actions">
:title="this.$t('post.comment.reply')" <shout-button
icon="level-down" :disabled="isAuthor"
class="reply-button" :count="comment.shoutedCount"
circle :is-shouted="comment.shoutedByCurrentUser"
size="small" :node-id="comment.id"
v-scroll-to="'.editor'" class="shout-button"
@click="reply" 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> </base-card>
</template> </template>
@ -59,6 +69,7 @@ import ContentMenu from '~/components/ContentMenu/ContentMenu'
import ContentViewer from '~/components/Editor/ContentViewer' import ContentViewer from '~/components/Editor/ContentViewer'
import CommentForm from '~/components/CommentForm/CommentForm' import CommentForm from '~/components/CommentForm/CommentForm'
import CommentMutations from '~/graphql/CommentMutations' import CommentMutations from '~/graphql/CommentMutations'
import ShoutButton from '~/components/ShoutButton.vue'
import scrollToAnchor from '~/mixins/scrollToAnchor.js' import scrollToAnchor from '~/mixins/scrollToAnchor.js'
export default { export default {
@ -67,6 +78,7 @@ export default {
ContentMenu, ContentMenu,
ContentViewer, ContentViewer,
CommentForm, CommentForm,
ShoutButton,
}, },
mixins: [scrollToAnchor], mixins: [scrollToAnchor],
data() { data() {
@ -98,6 +110,11 @@ export default {
hasLongContent() { hasLongContent() {
return this.$filters.removeHtml(this.comment.content).length > COMMENT_MAX_UNTRUNCATED_LENGTH 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() { isUnavailable() {
return (this.comment.deleted || this.comment.disabled) && !this.isModerator return (this.comment.deleted || this.comment.disabled) && !this.isModerator
}, },
@ -192,19 +209,14 @@ export default {
margin-bottom: $space-small; margin-bottom: $space-small;
} }
> .base-button { .actions {
align-self: flex-end; display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
} }
} }
.reply-button {
float: right;
top: 0px;
}
.reply-button:after {
clear: both;
}
@keyframes highlight { @keyframes highlight {
0% { 0% {
border: $border-size-base solid $color-primary; border: $border-size-base solid $color-primary;
@ -214,3 +226,17 @@ export default {
} }
} }
</style> </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> </ds-chip>
</div> </div>
<!-- group categories --> <!-- group categories -->
<div class="categories" v-if="categoriesActive"> <div class="categories" v-if="categoriesActive && group.categories.length > 0">
<category <category
v-for="category in group.categories" v-for="category in group.categories"
:key="category.id" :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' import ObserveButton from './ObserveButton.vue'
const localVue = global.localVue const localVue = global.localVue
describe('ObserveButton', () => { describe('ObserveButton', () => {
let mocks
const Wrapper = (count = 1, postId = '123', isObserved = true) => { const Wrapper = (count = 1, postId = '123', isObserved = true) => {
return mount(ObserveButton, { return render(ObserveButton, {
mocks, mocks: {
$t: jest.fn((t) => t),
},
localVue, localVue,
propsData: { propsData: {
count, count,
@ -18,43 +18,39 @@ describe('ObserveButton', () => {
}) })
} }
let wrapper
beforeEach(() => {
mocks = {
$t: jest.fn(),
}
})
describe('observed', () => { describe('observed', () => {
let wrapper
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper(1, '123', true) wrapper = Wrapper(1, '123', true)
}) })
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot() expect(wrapper.container).toMatchSnapshot()
}) })
it('emits toggleObservePost with false when clicked', () => { it('emits toggleObservePost with false when clicked', async () => {
const button = wrapper.find('.base-button') const button = screen.getByRole('button')
button.trigger('click') await fireEvent.click(button)
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', false]]) expect(wrapper.emitted().toggleObservePost).toEqual([['123', false]])
}) })
}) })
describe('unobserved', () => { describe('unobserved', () => {
let wrapper
beforeEach(() => { beforeEach(() => {
wrapper = Wrapper(1, '123', false) wrapper = Wrapper(1, '123', false)
}) })
it('renders', () => { it('renders', () => {
expect(wrapper.element).toMatchSnapshot() expect(wrapper.container).toMatchSnapshot()
}) })
it('emits toggleObservePost with true when clicked', () => { it('emits toggleObservePost with true when clicked', async () => {
const button = wrapper.find('.base-button') const button = screen.getByRole('button')
button.trigger('click') await fireEvent.click(button)
expect(wrapper.emitted('toggleObservePost')).toEqual([['123', true]]) expect(wrapper.emitted().toggleObservePost).toEqual([['123', true]])
}) })
}) })
}) })

View File

@ -1,26 +1,27 @@
<template> <template>
<ds-space margin="xx-small" class="text-align-center"> <action-button
<base-button :loading="loading" :filled="isObserved" icon="bell" circle @click="toggle" /> :loading="false"
<ds-space margin-bottom="xx-small" /> :count="count"
<ds-text color="soft" class="observe-button-text"> :text="$t('observeButton.observed')"
<ds-heading style="display: inline" tag="h3">{{ count }}x</ds-heading> :filled="isObserved"
{{ $t('observeButton.observed') }} icon="bell"
</ds-text> circle
</ds-space> @click="toggle"
/>
</template> </template>
<script> <script>
import ActionButton from '~/components/ActionButton.vue'
export default { export default {
components: {
ActionButton,
},
props: { props: {
count: { type: Number, default: 0 }, count: { type: Number, default: 0 },
postId: { type: String, default: null }, postId: { type: String, default: null },
isObserved: { type: Boolean, default: false }, isObserved: { type: Boolean, default: false },
}, },
data() {
return {
loading: false,
}
},
methods: { methods: {
toggle() { toggle() {
this.$emit('toggleObservePost', this.postId, !this.isObserved) this.$emit('toggleObservePost', this.postId, !this.isObserved)
@ -28,12 +29,3 @@ export default {
}, },
} }
</script> </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" class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)" 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 <category
v-for="category in post.categories" v-for="category in post.categories"
:key="category.id" :key="category.id"

View File

@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils' import { render, screen, fireEvent } from '@testing-library/vue'
import ShoutButton from './ShoutButton.vue' import '@testing-library/jest-dom'
import Vue from 'vue' import Vue from 'vue'
import ShoutButton from './ShoutButton.vue'
const localVue = global.localVue const localVue = global.localVue
@ -9,49 +10,54 @@ describe('ShoutButton.vue', () => {
beforeEach(() => { beforeEach(() => {
mocks = { mocks = {
$t: jest.fn(), $t: jest.fn((t) => t),
$apollo: { $apollo: {
mutate: jest.fn(), mutate: jest.fn(),
}, },
} }
}) })
describe('mount', () => { let wrapper
let wrapper
const Wrapper = () => {
return mount(ShoutButton, { mocks, localVue })
}
beforeEach(() => { const Wrapper = ({ isShouted = false } = {}) => {
wrapper = Wrapper() return render(ShoutButton, { mocks, localVue, propsData: { isShouted } })
}) }
it('renders button and text', () => { beforeEach(() => {
expect(mocks.$t).toHaveBeenCalledWith('shoutButton.shouted') wrapper = Wrapper()
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)
})
it('toggle the button', async () => { it('renders button and text', () => {
mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } }) expect(wrapper.container).toMatchSnapshot()
wrapper.find('.base-button').trigger('click') const button = screen.getByRole('button')
expect(wrapper.vm.shouted).toBe(true) expect(button).toBeInTheDocument()
expect(wrapper.vm.shoutedCount).toBe(1) })
await Vue.nextTick()
expect(wrapper.vm.shouted).toBe(true)
expect(wrapper.vm.shoutedCount).toBe(1)
})
it('toggle the button, but backend fails', async () => { it('toggle the button', async () => {
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' }) mocks.$apollo.mutate = jest.fn().mockResolvedValue({ data: { shout: 'WeDoShout' } })
await wrapper.find('.base-button').trigger('click') const button = screen.getByRole('button')
expect(wrapper.vm.shouted).toBe(true) await fireEvent.click(button)
expect(wrapper.vm.shoutedCount).toBe(1) expect(wrapper.container).toMatchSnapshot()
await Vue.nextTick() const shoutedCount = screen.getByText('1')
expect(wrapper.vm.shouted).toBe(false) expect(shoutedCount).toBeInTheDocument()
expect(wrapper.vm.shoutedCount).toBe(0) })
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> <template>
<ds-space margin="xx-small" class="text-align-center"> <action-button
<base-button :loading="loading"
:loading="loading" :disabled="disabled"
:disabled="disabled" :count="shoutedCount"
:filled="shouted" :text="$t('shoutButton.shouted')"
icon="heart-o" :filled="shouted"
circle icon="heart-o"
@click="toggle" @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>
</template> </template>
<script> <script>
import gql from 'graphql-tag' import gql from 'graphql-tag'
import ActionButton from '~/components/ActionButton.vue'
export default { export default {
components: {
ActionButton,
},
props: { props: {
count: { type: Number, default: 0 }, count: { type: Number, default: 0 },
postId: { type: String, default: null }, nodeType: { type: String },
nodeId: { type: String, default: null },
isShouted: { type: Boolean, default: false }, isShouted: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
}, },
@ -58,12 +58,13 @@ export default {
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: gql` mutation: gql`
mutation($id: ID!) { mutation($id: ID!, $type: ShoutTypeEnum!) {
${mutation}(id: $id, type: Post) ${mutation}(id: $id, type: $type)
} }
`, `,
variables: { variables: {
id: this.postId, id: this.nodeId,
type: this.nodeType,
}, },
}) })
.then((res) => { .then((res) => {
@ -82,12 +83,3 @@ export default {
}, },
} }
</script> </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 // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ObserveButton observed renders 1`] = ` exports[`ObserveButton observed renders 1`] = `
<div <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" circle=""
style="margin-bottom: 4px;" class="action-button"
/>
<p
class="ds-text observe-button-text ds-text-soft"
> >
<h3 <button
class="ds-heading ds-heading-h3" aria-label="observeButton.observed"
style="display: inline;" class="base-button --icon-only --circle --filled"
type="button"
> >
1x <span
</h3> class="base-icon"
>
<!---->
</span>
</p>
<!---->
</button>
<div
class="count"
>
1
</div>
</div>
</div> </div>
`; `;
exports[`ObserveButton unobserved renders 1`] = ` exports[`ObserveButton unobserved renders 1`] = `
<div <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" circle=""
style="margin-bottom: 4px;" class="action-button"
/>
<p
class="ds-text observe-button-text ds-text-soft"
> >
<h3 <button
class="ds-heading ds-heading-h3" aria-label="observeButton.observed"
style="display: inline;" class="base-button --icon-only --circle"
type="button"
> >
1x <span
</h3> class="base-icon"
>
<!---->
</span>
</p>
<!---->
</button>
<div
class="count"
>
1
</div>
</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 { &.--circle {
width: $size-button-base; width: var(--circle-button-width, $size-button-base);
height: var(--circle-button-width, $size-button-base);
border-radius: 50%; border-radius: 50%;
} }

View File

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

View File

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

View File

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

View File

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

View File

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