Robert Schäfer 00da9e8ecb
feat(backend): resize images with imagor (#8558)
* feat(backend): resize images with imagor

Open questions:
* Do we have external URLs for images? E.g. we have them for seeds. But
  in production?

* Do we want to apply image transformations on these as well? My current
implementation does not apply image transformations as of now. If we
want to do that, we will also expose internal URLs in the kubernetes
Cluster to the S3 endpoint to the client.

TODOs:
* The chat component is using a fixed size for all avatars at the moment.
Maybe we can pair-program on this how to implement responsive images in
this component library.

Commits:
* do not replace upload domain url in the database

* fix all webapp specs

* refactor: remove behaviour we won't need

We don't want to apply image transformations on files, right?

* refactor: replace the domain on read not on write

* wip: webapp fixes

* refactor(backend): add another url to config

I've given up. There seems to be no nice way to tell the minio to return
a location which differs from it's host name.

* refactor: add test for s3Service

* refactor(backend): proxy minio via backend in local development

Commits:
* provide tests for message attachments
* remove S3_PUBLIC_URL config value

* refactor: follow @ulfgebhardt's review

* add missing environment variable

---------

Co-authored-by: Ulf Gebhardt <ulf.gebhardt@webcraft-media.de>
2025-08-19 10:11:12 +02:00

355 lines
9.4 KiB
Vue

<template>
<nuxt-link
class="post-teaser"
:to="{ name: 'post-id-slug', params: { id: post.id, slug: post.slug } }"
>
<base-card
:lang="post.language"
:class="{
'disabled-content': post.disabled,
'--blur-image': post.image && post.image.sensitive,
}"
:highlight="isPinned"
>
<template v-if="post.image" #heroImage>
<responsive-image :image="post.image" sizes="640px" class="image" />
</template>
<client-only>
<div class="post-user-row">
<user-teaser :user="post.author" :group="post.group" />
<hc-ribbon
:class="[isPinned ? '--pinned' : '', post.image ? 'post-ribbon-w-img' : 'post-ribbon']"
:text="ribbonText"
:typ="post.postType[0]"
/>
</div>
</client-only>
<h2 class="title hyphenate-text">{{ post.title }}</h2>
<client-only>
<ds-space
v-if="post && post.postType[0] === 'Event'"
margin-bottom="small"
style="padding: 5px"
>
<location-teaser
class="event-info"
size="base"
:venue="post.eventVenue"
:locationName="post.eventLocationName"
:isOnline="post.eventIsOnline"
/>
<date-time-range
class="event-info"
size="base"
:startDate="post.eventStart"
:endDate="post.eventEnd"
/>
</ds-space>
</client-only>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="content hyphenate-text" v-html="excerpt" />
<footer
class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
>
<div
class="categories"
v-if="categoriesActive && post.categories && post.categories.length > 0"
>
<category
v-for="category in post.categories"
:key="category.id"
v-tooltip="{
content: `
${$t(`contribution.category.name.${category.slug}`)}:
${$t(`contribution.category.description.${category.slug}`)}
`,
placement: 'bottom-start',
}"
:icon="category.icon"
:filterActive="postsFilter ? postsFilter.id_in.includes(category.id) : false"
/>
</div>
<div v-else class="categories-placeholder"></div>
<counter-icon
icon="heart-o"
:count="post.shoutedCount"
v-tooltip="{
content: $t('contribution.amount-shouts', { amount: post.shoutedCount }),
placement: 'bottom-start',
}"
/>
<counter-icon
icon="comments"
:count="post.commentsCount"
v-tooltip="{
content: $t('contribution.amount-comments', { amount: post.commentsCount }),
placement: 'bottom-start',
}"
/>
<counter-icon
icon="hand-pointer"
:count="post.clickedCount"
v-tooltip="{
content: $t('contribution.amount-clicks', { amount: post.clickedCount }),
placement: 'bottom-start',
}"
/>
<counter-icon
icon="eye"
:count="post.viewedTeaserCount"
v-tooltip="{
content: $t('contribution.amount-views', { amount: post.viewedTeaserCount }),
placement: 'bottom-start',
}"
/>
<client-only>
<content-menu
resource-type="contribution"
:resource="post"
:modalsData="menuModalsData"
:is-owner="isAuthor"
@pinPost="pinPost"
@unpinPost="unpinPost"
@pushPost="pushPost"
@unpushPost="unpushPost"
@toggleObservePost="toggleObservePost"
/>
</client-only>
</footer>
<client-only>
<div class="date-row" v-if="post.createdAt">
<span class="text">
<date-time :date-time="post.createdAt" />
<slot name="dateTime"></slot>
</span>
</div>
</client-only>
</base-card>
</nuxt-link>
</template>
<script>
import Category from '~/components/Category'
import ContentMenu from '~/components/ContentMenu/ContentMenu'
import CounterIcon from '~/components/_new/generic/CounterIcon/CounterIcon'
import DateTimeRange from '~/components/DateTimeRange/DateTimeRange'
import HcRibbon from '~/components/Ribbon'
import LocationTeaser from '~/components/LocationTeaser/LocationTeaser'
import DateTime from '~/components/DateTime'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
import { mapGetters } from 'vuex'
import PostMutations from '~/graphql/PostMutations'
import { postMenuModalsData, deletePostMutation } from '~/components/utils/PostHelpers'
import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
name: 'PostTeaser',
mixins: [GetCategories],
components: {
Category,
ContentMenu,
CounterIcon,
DateTimeRange,
HcRibbon,
LocationTeaser,
DateTime,
UserTeaser,
ResponsiveImage,
},
props: {
post: {
type: Object,
required: true,
},
width: {
type: Object,
default: () => {},
},
postsFilter: {
type: Object,
default: () => {},
},
},
mounted() {
const { image } = this.post
if (!image) return
const width = this.$el.offsetWidth
const height = Math.min(width / image.aspectRatio, 2000)
const imageElement = this.$el.querySelector('.hero-image')
if (imageElement) {
imageElement.style.height = `${height}px`
}
},
computed: {
...mapGetters({
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
},
isAuthor() {
const { author } = this.post
if (!author) return false
return this.user.id === this.post.author.id
},
menuModalsData() {
return postMenuModalsData(
// "this.post" may not always be defined at the beginning …
this.post ? this.$filters.truncate(this.post.title, 30) : '',
this.deletePostCallback,
)
},
isPinned() {
return this.post && this.post.pinned
},
ribbonText() {
if (this.post.pinned) return this.$t('post.pinned')
if (this.post.postType[0] === 'Event') return this.$t('post.event')
return this.$t('post.name')
},
},
methods: {
async deletePostCallback() {
try {
const {
data: { DeletePost },
} = await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$emit('removePostFromList', DeletePost)
} catch (err) {
this.$toast.error(err.message)
}
},
pinPost(post) {
this.$emit('pinPost', post)
},
unpinPost(post) {
this.$emit('unpinPost', post)
},
pushPost(post) {
this.$emit('pushPost', post)
},
unpushPost(post) {
this.$emit('unpushPost', post)
},
toggleObservePost(postId, value) {
this.$emit('toggleObservePost', postId, value)
},
visibilityChanged(isVisible, entry, id) {
if (!this.post.viewedTeaserByCurrentUser && isVisible) {
this.$apollo
.mutate({
mutation: PostMutations().markTeaserAsViewed,
variables: { id },
})
.catch((error) => this.$toast.error(error.message))
this.post.viewedTeaserByCurrentUser = true
this.post.viewedTeaserCount++
}
},
},
}
</script>
<style lang="scss">
.post-teaser,
.post-teaser:hover,
.post-teaser:active {
position: relative;
display: block;
height: 100%;
color: $text-color-base;
padding-top: 16px;
}
.post-user-row {
position: relative;
> .post-ribbon-w-img {
position: absolute;
// 14px (~height of ribbon element) + 24px(=margin of hero image)
top: -38px;
// 7px+24px(=padding of parent)
right: -31px;
}
> .post-ribbon {
position: absolute;
// 14px (~height of ribbon element) + 24px(=margin of hero image)
top: -24px;
// 7px(=offset)+24px(=margin of parent)
right: -31px;
}
}
.post-teaser > .base-card {
display: flex;
flex-direction: column;
overflow: visible;
height: 100%;
padding-bottom: $space-x-small;
> .hero-image {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.--blur-image > .hero-image > .image {
filter: blur($blur-radius);
}
> .content {
flex-grow: 1;
margin-bottom: $space-small;
}
> .footer {
display: flex;
justify-content: space-between;
align-items: center;
> .categories-placeholder {
flex-grow: 1;
}
> .counter-icon {
display: block;
margin-right: $space-small;
opacity: $opacity-disabled;
}
> .content-menu {
position: relative;
z-index: $z-index-post-teaser-link;
}
.ds-tag {
margin: 0;
margin-right: $space-xx-small;
}
}
.user-teaser {
margin-bottom: $space-small;
}
> .date-row {
display: flex;
justify-content: flex-end;
margin-top: $space-small;
> .text {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: $text-color-soft;
font-size: $font-size-small;
> .ds-text {
display: inline;
}
}
}
}
</style>