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

632 lines
18 KiB
Vue

<template>
<div>
<ds-form
class="contribution-form"
ref="contributionForm"
v-model="formData"
:schema="formSchema"
@submit="submit"
>
<template #default="{ errors }">
<base-card>
<template #heroImage>
<img
v-if="formData.image"
:src="formData.image.url"
:class="['image', formData.imageBlurred && '--blur-image']"
/>
<image-uploader
:hasImage="!!formData.image"
:class="[formData.imageBlurred && '--blur-image']"
@addHeroImage="addHeroImage"
@addImageAspectRatio="addImageAspectRatio"
@addImageType="addImageType"
/>
</template>
<div v-if="formData.image" class="blur-toggle">
<label for="blur-img">{{ $t('contribution.inappropriatePicture') }}</label>
<input type="checkbox" id="blur-img" v-model="formData.imageBlurred" />
<page-params-link class="link" :pageParams="links.FAQ">
{{ $t('contribution.inappropriatePicture') }}
<base-icon name="question-circle" />
</page-params-link>
</div>
<ds-space margin-top="base" />
<ds-input
model="title"
:placeholder="$t('contribution.title')"
name="title"
autofocus
size="large"
/>
<ds-chip size="base" :color="errors && errors.title && 'danger'">
{{ formData.title.length }}/{{ formSchema.title.max }}
<base-icon v-if="errors && errors.title" name="warning" />
</ds-chip>
<editor
:users="users"
:value="formData.content"
:hashtags="hashtags"
@input="updateEditorContent"
/>
<ds-chip size="base" :color="errors && errors.content && 'danger'">
{{ contentLength }}
<base-icon v-if="errors && errors.content" name="warning" />
</ds-chip>
<!-- Eventdata -->
<div v-if="createEvent" class="eventDatas">
<hr />
<ds-space margin-top="x-small" />
<ds-grid>
<ds-grid-item class="event-grid-item">
<!-- <label>Beginn</label> -->
<div class="event-grid-item-z-helper">
<date-picker
name="eventStart"
v-model="formData.eventStart"
type="datetime"
value-type="format"
:minute-step="15"
Xformat="DD-MM-YYYY HH:mm"
class="event-grid-item-z-helper"
:placeholder="$t('post.viewEvent.eventStart')"
:disabled-date="notBeforeToday"
:disabled-time="notBeforeNow"
:show-second="false"
@change="changeEventStart($event)"
></date-picker>
</div>
<div
v-if="errors && errors.eventStart"
class="chipbox event-grid-item-margin-helper"
>
<ds-chip size="base" :color="errors && errors.eventStart && 'danger'">
<base-icon name="warning" />
</ds-chip>
</div>
</ds-grid-item>
<ds-grid-item class="event-grid-item">
<!-- <label>Ende (optional)</label> -->
<date-picker
v-model="formData.eventEnd"
name="eventEnd"
type="datetime"
value-type="format"
:minute-step="15"
:seconds-step="0"
Xformat="DD-MM-YYYY HH:mm"
:placeholder="$t('post.viewEvent.eventEnd')"
class="event-grid-item-font-helper"
:disabled-date="notBeforeEventDay"
:disabled-time="notBeforeEvent"
:show-second="false"
@change="changeEventEnd($event)"
></date-picker>
</ds-grid-item>
</ds-grid>
<ds-grid class="event-location-grid">
<ds-grid-item class="event-grid-item">
<ds-input
model="eventVenue"
name="eventVenue"
:placeholder="$t('post.viewEvent.eventVenue')"
/>
<div class="chipbox">
<ds-chip size="base" :color="errors && errors.eventVenue && 'danger'">
{{ formData.eventVenue.length }}/{{ formSchema.eventVenue.max }}
<base-icon v-if="errors && errors.eventVenue" name="warning" />
</ds-chip>
</div>
</ds-grid-item>
<ds-grid-item v-if="showEventLocationName" class="event-grid-item">
<ds-input
model="eventLocationName"
name="eventLocationName"
:placeholder="$t('post.viewEvent.eventLocationName')"
/>
<div class="chipbox">
<ds-chip size="base" :color="errors && errors.eventLocationName && 'danger'">
{{ formData.eventLocationName.length }}/{{ formSchema.eventLocationName.max }}
<base-icon v-if="errors && errors.eventLocationName" name="warning" />
</ds-chip>
</div>
</ds-grid-item>
</ds-grid>
<div>
<input
type="checkbox"
v-model="formData.eventIsOnline"
model="eventIsOnline"
name="eventIsOnline"
class="event-grid-item-font-helper"
@change="changeEventIsOnline($event)"
/>
{{ $t('post.viewEvent.eventIsOnline') }}
</div>
</div>
<ds-space margin-top="x-small" />
<categories-select
v-if="categoriesActive"
model="categoryIds"
:existingCategoryIds="formData.categoryIds"
/>
<ds-chip
v-if="categoriesActive"
size="base"
:color="errors && errors.categoryIds && 'danger'"
>
{{ formData.categoryIds.length }} / 3
<base-icon v-if="errors && errors.categoryIds" name="warning" />
</ds-chip>
<ds-flex class="buttons-footer" gutter="xxx-small">
<ds-flex-item width="3.5" class="buttons-footer-helper">
<!-- eslint-disable vue/no-v-text-v-html-on-component -->
<!-- TODO => remove v-html! only text ! no html! security first! -->
<ds-text
v-if="showGroupHint"
v-html="$t('contribution.visibleOnlyForMembersOfGroup', { name: groupName })"
/>
<!-- eslint-enable vue/no-v-text-v-html-on-component -->
</ds-flex-item>
<ds-flex-item width="0.15" />
<ds-flex-item class="action-buttons-group" width="2">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()">
{{ $t('actions.cancel') }}
</base-button>
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled>
{{ $t('actions.save') }}
</base-button>
</ds-flex-item>
</ds-flex>
</base-card>
</template>
</ds-form>
</div>
</template>
<script>
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import Editor from '~/components/Editor/Editor'
import PostMutations from '~/graphql/PostMutations.js'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import ImageUploader from '~/components/Uploader/ImageUploader'
import links from '~/constants/links.js'
import PageParamsLink from '~/components/_new/features/PageParamsLink/PageParamsLink.vue'
import DatePicker from 'vue2-datepicker'
import 'vue2-datepicker/scss/index.scss'
import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
mixins: [GetCategories],
components: {
Editor,
ImageUploader,
PageParamsLink,
CategoriesSelect,
DatePicker,
},
props: {
contribution: {
type: Object,
default: () => ({}),
},
group: {
type: Object,
default: () => null,
},
createEvent: {
type: Boolean,
default: false,
},
},
data() {
const {
title,
content,
image,
categories,
eventStart,
eventEnd,
eventLocationName,
eventVenue,
eventIsOnline,
eventLocation,
} = this.contribution
const {
sensitive: imageBlurred = false,
aspectRatio: imageAspectRatio = null,
type: imageType = null,
} = image || {}
return {
links,
formData: {
title: title || '',
content: content || '',
image: image || null,
imageAspectRatio,
imageType,
imageBlurred,
categoryIds: categories ? categories.map((category) => category.id) : [],
eventStart: eventStart || null,
eventEnd: eventEnd || null,
eventLocation: eventLocation || '',
eventLocationName: eventLocationName || '',
eventVenue: eventVenue || '',
eventIsOnline: eventIsOnline || false,
},
loading: false,
users: [],
hashtags: [],
imageUpload: null,
}
},
async mounted() {
await import(`vue2-datepicker/locale/${this.currentUser.locale}`)
},
computed: {
...mapGetters({
currentUser: 'auth/user',
}),
formSchema() {
return {
title: { required: true, min: 3, max: 100 },
content: { required: true },
imageBlurred: { required: false },
categoryIds: {
type: 'array',
required: this.categoriesActive,
validator: (_, value = []) => {
if (this.categoriesActive && (value.length === 0 || value.length > 3)) {
return [new Error(this.$t('common.validations.categories'))]
}
return []
},
},
eventStart: { required: !!this.createEvent },
eventVenue: {
required: !!this.createEvent,
min: 3,
max: 100,
validator: (_, value = '') => {
if (!this.createEvent) return []
if (!value.trim()) {
return [new Error(this.$t('common.validations.eventVenueNotEmpty'))]
}
if (value.length < 3 || value.length > 100) {
return [
new Error(this.$t('common.validations.eventVenueLength', { min: 3, max: 100 })),
]
}
return []
},
},
eventLocationName: {
required: !!this.createEvent && !this.formData.eventIsOnline,
min: 3,
max: 100,
validator: (_, value = '') => {
if (!this.createEvent) return []
if (this.formData.eventIsOnline) return []
if (!value.trim()) {
return [new Error(this.$t('common.validations.eventLocationNameNotEmpty'))]
}
if (value.length < 3 || value.length > 100) {
return [
new Error(
this.$t('common.validations.eventLocationNameLength', { min: 3, max: 100 }),
),
]
}
return []
},
},
}
},
eventInput() {
if (this.createEvent) {
return {
eventStart: new Date(this.formData.eventStart).toISOString(),
eventVenue: this.formData.eventVenue,
eventEnd: this.formData.eventEnd ? new Date(this.formData.eventEnd).toISOString() : null,
eventIsOnline: this.formData.eventIsOnline,
eventLocationName: !this.formData.eventIsOnline ? this.formData.eventLocationName : null,
}
}
return undefined
},
contentLength() {
return this.$filters.removeHtml(this.formData.content).length
},
groupId() {
return this.group && this.group.id
},
showGroupHint() {
return this.groupId && ['closed', 'hidden'].includes(this.group.groupType)
},
groupName() {
return this.group && this.group.name
},
groupCategories() {
return this.group && this.group.categories
},
showEventLocationName() {
return !this.formData.eventIsOnline
},
},
watch: {
groupCategories() {
if (!this.formData.categoryIds.length && this.groupCategories)
this.formData.categoryIds = this.groupCategories.map((cat) => cat.id)
},
},
methods: {
notBeforeToday(date) {
return date < new Date().setHours(0, 0, 0, 0)
},
notBeforeNow(date) {
return date < new Date()
},
notBeforeEventDay(date) {
return date < new Date(this.formData.eventStart).setHours(0, 0, 0, 0)
},
notBeforeEvent(date) {
return date <= new Date(this.formData.eventStart)
},
submit() {
let image = null
const { title, content, categoryIds } = this.formData
if (this.formData.image) {
image = {
sensitive: this.formData.imageBlurred,
}
if (this.imageUpload) {
image.upload = this.imageUpload
image.aspectRatio = this.formData.imageAspectRatio
image.type = this.formData.imageType
}
}
this.loading = true
this.$apollo
.mutate({
mutation: this.contribution.id ? PostMutations().UpdatePost : PostMutations().CreatePost,
variables: {
title,
content,
categoryIds,
id: this.contribution.id || null,
image,
groupId: this.groupId,
postType: !this.createEvent ? 'Article' : 'Event',
eventInput: this.eventInput,
},
})
.then(({ data }) => {
this.loading = false
this.$toast.success(this.$t('contribution.success'))
const result = data[this.contribution.id ? 'UpdatePost' : 'CreatePost']
this.$router.push({
name: 'post-id-slug',
params: { id: result.id, slug: result.slug },
})
})
.catch((err) => {
this.$toast.error(err.message)
this.loading = false
})
},
updateEditorContent(value) {
this.$refs.contributionForm.update('content', value)
},
changeEventIsOnline(event) {
this.$refs.contributionForm.update('eventIsOnline', this.formData.eventIsOnline)
},
changeEventEnd(event) {
this.$refs.contributionForm.update('eventEnd', event)
},
changeEventStart(event) {
this.$refs.contributionForm.update('eventStart', event)
},
addHeroImage(file) {
this.formData.image = null
if (file) {
const reader = new FileReader()
reader.onload = ({ target }) => {
this.formData.image = {
...this.formData.image,
url: target.result,
}
}
reader.readAsDataURL(file)
this.imageUpload = file
}
},
addImageAspectRatio(aspectRatio) {
this.formData.imageAspectRatio = aspectRatio
},
addImageType(imageType) {
this.formData.imageType = imageType
},
},
apollo: {
User: {
query() {
return gql`
query {
User(orderBy: slug_asc) {
id
slug
}
}
`
},
result({ data: { User } }) {
this.users = User
},
},
Tag: {
query() {
return gql`
query {
Tag(orderBy: id_asc) {
id
}
}
`
},
result({ data: { Tag } }) {
this.hashtags = Tag
},
},
},
}
</script>
<style lang="scss">
.eventDatas {
.chipbox {
display: flex;
justify-content: flex-end;
> .ds-chip {
margin-top: -10px;
}
}
// style override to handle dynamic inputs
.event-location-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.event-grid-item {
// important needed because of component inline style
grid-row-end: span 3 !important;
}
.event-grid-item-z-helper {
z-index: 20;
}
.event-grid-item-margin-helper {
margin-top: 10px;
}
.event-grid-item-font-helper {
font-size: larger;
}
}
.contribution-form > .base-card {
display: flex;
flex-direction: column;
> .hero-image {
position: relative;
> .image {
max-height: $size-image-max-height;
}
}
.image.--blur-image {
filter: blur($blur-radius);
}
> .ds-form-item {
margin: 0;
}
> .ds-chip {
align-self: flex-end;
margin: $space-xx-small 0 $space-base;
cursor: default;
}
> .select-field {
align-self: flex-end;
}
> .buttons-footer {
justify-content: flex-end;
align-self: flex-end;
width: 100%;
margin-top: $space-base;
> .action-buttons-group {
margin-left: auto;
display: flex;
justify-content: flex-end;
> button {
margin-left: 1em;
min-width: fit-content;
}
}
> .buttons-footer-helper {
margin-right: 16px;
// important needed because of component inline style
margin-bottom: 6px !important;
}
}
.blur-toggle {
text-align: right;
margin-bottom: $space-base;
> .link {
display: block;
}
}
@media screen and (max-width: 656px) {
> .buttons-footer {
flex-direction: column;
margin-top: 5px;
> .action-buttons-group {
> button {
margin-left: 1em;
}
}
}
}
@media screen and (max-width: 280px) {
> .buttons-footer {
> .action-buttons-group {
flex-direction: column;
> button {
margin-bottom: 5px;
}
}
}
}
.mx-datepicker {
width: 100%;
}
.mx-datepicker input {
font-size: 1rem;
height: calc(1.625rem + 18px);
padding: 8px 8px;
background-color: #faf9fa;
border-color: #c8c8c8;
color: #4b4554;
}
.mx-datepicker input:hover {
border-color: #c8c8c8;
}
.mx-datepicker input:focus {
border-color: #17b53f;
background-color: #fff;
}
.mx-datepicker-error {
border-color: #cf2619;
}
}
</style>