mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
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.
This commit is contained in:
parent
3c1c2d4dcb
commit
d5ded75078
6
.github/workflows/test-backend.yml
vendored
6
.github/workflows/test-backend.yml
vendored
@ -34,7 +34,7 @@ jobs:
|
||||
run: |
|
||||
docker build --target community -t "ocelotsocialnetwork/neo4j-community:test" neo4j/
|
||||
docker save "ocelotsocialnetwork/neo4j-community:test" > /tmp/neo4j.tar
|
||||
|
||||
|
||||
- name: Cache docker images
|
||||
id: cache-neo4j
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.0.2
|
||||
@ -55,7 +55,7 @@ jobs:
|
||||
run: |
|
||||
docker build --target test -t "ocelotsocialnetwork/backend:test" backend/
|
||||
docker save "ocelotsocialnetwork/backend:test" > /tmp/backend.tar
|
||||
|
||||
|
||||
- name: Cache docker images
|
||||
id: cache-backend
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.0.2
|
||||
@ -80,7 +80,7 @@ jobs:
|
||||
if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.docker == 'true'
|
||||
needs: [files-changed, build_test_neo4j, build_test_backend]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
permissions:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@ -132,8 +132,11 @@ export interface S3Configured {
|
||||
AWS_REGION: string
|
||||
AWS_BUCKET: string
|
||||
S3_PUBLIC_GATEWAY: string | undefined
|
||||
IMAGOR_SECRET: string | undefined
|
||||
}
|
||||
|
||||
export const IMAGOR_SECRET: string | undefined = env.IMAGOR_SECRET
|
||||
|
||||
export const isS3configured = (config: typeof s3): config is S3Configured => {
|
||||
return !!(
|
||||
config.AWS_ACCESS_KEY_ID &&
|
||||
@ -176,6 +179,7 @@ const CONFIG = {
|
||||
...s3,
|
||||
...options,
|
||||
...language,
|
||||
IMAGOR_SECRET,
|
||||
}
|
||||
|
||||
export type Config = typeof CONFIG
|
||||
|
||||
52
backend/src/config/test-mock.ts
Normal file
52
backend/src/config/test-mock.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type CONFIG from '.'
|
||||
|
||||
export const TEST_CONFIG: typeof CONFIG = {
|
||||
NODE_ENV: 'test',
|
||||
DEBUG: undefined,
|
||||
TEST: true,
|
||||
PRODUCTION: false,
|
||||
PRODUCTION_DB_CLEAN_ALLOW: false,
|
||||
DISABLED_MIDDLEWARES: [],
|
||||
SEND_MAIL: false,
|
||||
|
||||
CLIENT_URI: 'http://localhost:3000',
|
||||
GRAPHQL_URI: 'http://localhost:4000',
|
||||
JWT_EXPIRES: '2y',
|
||||
|
||||
MAPBOX_TOKEN: undefined,
|
||||
JWT_SECRET: undefined,
|
||||
PRIVATE_KEY_PASSPHRASE: undefined,
|
||||
|
||||
NEO4J_URI: 'bolt://localhost:7687',
|
||||
NEO4J_USERNAME: 'neo4j',
|
||||
NEO4J_PASSWORD: 'neo4j',
|
||||
|
||||
SENTRY_DSN_BACKEND: undefined,
|
||||
COMMIT: undefined,
|
||||
|
||||
REDIS_DOMAIN: undefined,
|
||||
REDIS_PORT: undefined,
|
||||
REDIS_PASSWORD: undefined,
|
||||
|
||||
AWS_ACCESS_KEY_ID: '',
|
||||
AWS_SECRET_ACCESS_KEY: '',
|
||||
AWS_ENDPOINT: '',
|
||||
AWS_REGION: '',
|
||||
AWS_BUCKET: '',
|
||||
S3_PUBLIC_GATEWAY: undefined,
|
||||
IMAGOR_SECRET: undefined,
|
||||
|
||||
EMAIL_DEFAULT_SENDER: '',
|
||||
SUPPORT_EMAIL: '',
|
||||
SUPPORT_URL: '',
|
||||
APPLICATION_NAME: '',
|
||||
ORGANIZATION_URL: '',
|
||||
PUBLIC_REGISTRATION: false,
|
||||
INVITE_REGISTRATION: true,
|
||||
INVITE_CODES_PERSONAL_PER_USER: 7,
|
||||
INVITE_CODES_GROUP_PER_USER: 7,
|
||||
CATEGORIES_ACTIVE: false,
|
||||
MAX_PINNED_POSTS: 1,
|
||||
|
||||
LANGUAGE_DEFAULT: 'en',
|
||||
}
|
||||
@ -1,9 +1,80 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
import type { Context } from '@src/server'
|
||||
|
||||
import Resolver from './helpers/Resolver'
|
||||
|
||||
type UrlResolver = (
|
||||
parent: { url: string },
|
||||
args: { width?: number; height?: number },
|
||||
{
|
||||
config: { S3_PUBLIC_GATEWAY },
|
||||
}: Context,
|
||||
) => string
|
||||
|
||||
const changeDomain: (opts: { transformations: UrlResolver[] }) => UrlResolver =
|
||||
({ transformations }) =>
|
||||
(parent, _args, context) => {
|
||||
const { config } = context
|
||||
const { S3_PUBLIC_GATEWAY, AWS_ENDPOINT } = config
|
||||
if (!(S3_PUBLIC_GATEWAY && AWS_ENDPOINT)) {
|
||||
return parent.url
|
||||
}
|
||||
if (new URL(parent.url).host !== new URL(AWS_ENDPOINT).host) {
|
||||
// In this case it's an external upload - maybe seeded?
|
||||
// Let's not change the URL in this case
|
||||
return parent.url
|
||||
}
|
||||
|
||||
const publicUrl = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicUrl.pathname = new URL(parent.url).pathname
|
||||
const url = publicUrl.href
|
||||
return chain(...transformations)({ url }, _args, context)
|
||||
}
|
||||
|
||||
const sign: UrlResolver = ({ url }, _args, { config: { IMAGOR_SECRET } }) => {
|
||||
if (!IMAGOR_SECRET) {
|
||||
throw new Error('IMAGOR_SECRET is not set')
|
||||
}
|
||||
const newUrl = new URL(url)
|
||||
const path = newUrl.pathname.replace('/', '')
|
||||
const hash = crypto
|
||||
.createHmac('sha1', IMAGOR_SECRET)
|
||||
.update(path)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
newUrl.pathname = hash + newUrl.pathname
|
||||
return newUrl.href
|
||||
}
|
||||
|
||||
const FALLBACK_MAXIMUM_LENGTH = 5000
|
||||
const resize: UrlResolver = ({ url }, { height, width }) => {
|
||||
if (!(height || width)) {
|
||||
return url
|
||||
}
|
||||
const window = `/fit-in/${width ?? FALLBACK_MAXIMUM_LENGTH}x${height ?? FALLBACK_MAXIMUM_LENGTH}`
|
||||
const newUrl = new URL(url)
|
||||
newUrl.pathname = window + newUrl.pathname
|
||||
return newUrl.href
|
||||
}
|
||||
|
||||
const chain: (...methods: UrlResolver[]) => UrlResolver = (...methods) => {
|
||||
return (parent, args, context) => {
|
||||
let { url } = parent
|
||||
for (const method of methods) {
|
||||
url = method({ url }, args, context)
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Image: {
|
||||
...Resolver('Image', {
|
||||
undefinedToNull: ['sensitive', 'alt', 'aspectRatio', 'type'],
|
||||
}),
|
||||
url: changeDomain({ transformations: [sign] }),
|
||||
transform: changeDomain({ transformations: [resize, sign] }),
|
||||
},
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ const config: S3Configured = {
|
||||
AWS_ENDPOINT: 'AWS_ENDPOINT',
|
||||
AWS_REGION: 'AWS_REGION',
|
||||
S3_PUBLIC_GATEWAY: undefined,
|
||||
IMAGOR_SECRET: undefined,
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
|
||||
@ -16,7 +16,7 @@ import type { FileUpload } from 'graphql-upload'
|
||||
|
||||
export const images = (config: S3Configured) => {
|
||||
// const widths = [34, 160, 320, 640, 1024]
|
||||
const { AWS_BUCKET: Bucket, S3_PUBLIC_GATEWAY } = config
|
||||
const { AWS_BUCKET: Bucket } = config
|
||||
|
||||
const { AWS_ENDPOINT, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY } = config
|
||||
const s3 = new S3Client({
|
||||
@ -105,12 +105,7 @@ export const images = (config: S3Configured) => {
|
||||
const { name, ext } = path.parse(upload.filename)
|
||||
const uniqueFilename = `${uuid()}-${slug(name)}${ext}`
|
||||
const Location = await uploadCallback({ ...upload, uniqueFilename })
|
||||
if (!S3_PUBLIC_GATEWAY) {
|
||||
return Location
|
||||
}
|
||||
const publicLocation = new URL(S3_PUBLIC_GATEWAY)
|
||||
publicLocation.pathname = new URL(Location).pathname
|
||||
return publicLocation.href
|
||||
return Location
|
||||
}
|
||||
|
||||
const s3Upload: FileUploadCallback = async ({ createReadStream, uniqueFilename, mimetype }) => {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
type Image {
|
||||
url: ID!,
|
||||
transform(width: Int, height: Int): String
|
||||
# urlW34: String,
|
||||
# urlW160: String,
|
||||
# urlW320: String,
|
||||
|
||||
@ -38,6 +38,8 @@ spec:
|
||||
value: "http://{{ .Release.Name }}-backend:4000"
|
||||
- name: CLIENT_URI
|
||||
value: "https://{{ .Values.domain }}"
|
||||
- name: S3_PUBLIC_GATEWAY
|
||||
value: "https://{{ .Values.domain }}/imagor"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: {{ .Release.Name }}-backend-env
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
kind: Deployment
|
||||
apiVersion: apps/v1
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-imagor
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ .Release.Name }}-imagor
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: {{ .Release.Name }}-imagor
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
containers:
|
||||
- name: {{ .Release.Name }}-imagor
|
||||
image: "{{ .Values.imagor.image.repository }}:{{ .Values.imagor.image.tag | default (include "defaultTag" .) }}"
|
||||
imagePullPolicy: {{ quote .Values.global.image.pullPolicy }}
|
||||
{{- include "resources" .Values.imagor.resources | indent 8 }}
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: S3_FORCE_PATH_STYLE
|
||||
value: "1"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Release.Name }}-imagor-secret-env
|
||||
@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-imagor-secret-env
|
||||
type: Opaque
|
||||
stringData:
|
||||
{{ .Values.secrets.imagor.env | toYaml | indent 2 }}
|
||||
@ -0,0 +1,11 @@
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-imagor
|
||||
spec:
|
||||
ports:
|
||||
- name: {{ .Release.Name }}-http
|
||||
port: 8000
|
||||
targetPort: 8000
|
||||
selector:
|
||||
app: {{ .Release.Name }}-imagor
|
||||
@ -62,4 +62,34 @@ spec:
|
||||
regex: ^https://{{ . }}(.*)
|
||||
replacement: https://{{ $.Values.domain }}${1}
|
||||
permanent: true
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-stripprefix
|
||||
spec:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- /imagor
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-path-prefixes
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.middlewares: "{{ .Release.Namespace }}-{{ .Release.Name }}-stripprefix@kubernetescrd"
|
||||
spec:
|
||||
rules:
|
||||
- host: {{ quote .Values.domain }}
|
||||
http:
|
||||
paths:
|
||||
- path: /imagor
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-imagor
|
||||
port:
|
||||
number: 8000
|
||||
|
||||
@ -25,3 +25,9 @@ webapp:
|
||||
maintenance:
|
||||
image:
|
||||
repository: ghcr.io/ocelot-social-community/ocelot-social/maintenance
|
||||
|
||||
imagor:
|
||||
image:
|
||||
repository: shumc/imagor
|
||||
tag: 1.5.4
|
||||
|
||||
|
||||
@ -23,15 +23,25 @@ secrets:
|
||||
NEO4J_USERNAME: null
|
||||
NEO4J_PASSWORD: null
|
||||
REDIS_PASSWORD: null
|
||||
AWS_ACCESS_KEY_ID: ENC[AES256_GCM,data:iiN5ueqyo60VHb9e2bnhc19iGTg=,iv:zawYpKrFafgsu1+YRet1hzZf1G3a6BIlZgsh7xNADaE=,tag:rTsmm8cqei34b6cT6vn08w==,type:str]
|
||||
AWS_SECRET_ACCESS_KEY: ENC[AES256_GCM,data:Zl4LRXdDh/6Q8F9RVp+3L7NXGZ0F2cgFMKPhl/TVeuD5Bhy68W5ekg==,iv:AmPoinGISrSOZdoBKdeFFXfr2hwOK4nWMnniz8K5qgU=,tag:K8Q7M7e+6G9T0Oh3Sp4OzA==,type:str]
|
||||
AWS_ENDPOINT: ENC[AES256_GCM,data:/waEqUgcOmldZ+peFTNVsDQf2KrpWY8ZZMt1nT5117SkbY4=,iv:n+Kvidjb/TM4bQYKqTaFxt8GkHo02PuxEGpzgOcywr4=,tag:lrGPgCWWy3GMIcTv75IYTg==,type:str]
|
||||
AWS_REGION: ENC[AES256_GCM,data:kBPpHZ8zw4PMpg==,iv:R+QZe303do37Hd/97NpS1pt9VaBE/gqZDY2/qlIvvps=,tag:0WduW8wfJXtBqlh4qfRGNA==,type:str]
|
||||
AWS_BUCKET: ENC[AES256_GCM,data:0fAspN/PoRVPlSbz+qDBRUOieeC4,iv:JGJ/LyLpMymN0tpZmW6DjPT3xqXzK/KhYQsy9sgPd60=,tag:Y6PBs0916JkHRHSe7hqSMA==,type:str]
|
||||
IMAGOR_SECRET: null
|
||||
AWS_ACCESS_KEY_ID: null
|
||||
AWS_SECRET_ACCESS_KEY: null
|
||||
AWS_ENDPOINT: null
|
||||
AWS_REGION: null
|
||||
AWS_BUCKET: null
|
||||
neo4j:
|
||||
env:
|
||||
NEO4J_USERNAME: ""
|
||||
NEO4J_PASSWORD: ""
|
||||
imagor:
|
||||
env:
|
||||
HTTP_LOADER_BASE_URL: null
|
||||
IMAGOR_SECRET: null
|
||||
AWS_ACCESS_KEY_ID: null
|
||||
AWS_SECRET_ACCESS_KEY: null
|
||||
AWS_ENDPOINT: null
|
||||
AWS_REGION: null
|
||||
AWS_BUCKET: null
|
||||
sops:
|
||||
age:
|
||||
- recipient: age1llp6k66265q3rzqemxpnq0x3562u20989vcjf65fl9s3hjhgcscq6mhnjw
|
||||
@ -70,7 +80,7 @@ sops:
|
||||
aGNFeXZZRmlJM041OHdTM0pmM3BBdGMKGvFgYY1jhKwciAOZKyw0hlFVNbOk7CM7
|
||||
041g17JXNV1Wk6WgMZ4w8p54RKQVaWCT4wxChy6wNNdQ3IeKgqEU2w==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2025-05-29T06:57:01Z"
|
||||
mac: ENC[AES256_GCM,data:0eWUqVsJrolrVYFsG4aigAYSjBW35Z+64y/ZE8Az15GmI0E4p/1kZPIY6hv9SUiCD1R2Ro0nm1QsV9A/hvNeWla0EdARNnARxIphxMJWKRGwcVkFVk28Jh4g4jE/rTggWx/wLbR6bza1RLHA1wRMb1PYuLfZybsb/whN4+gTMfg=,iv:irQkLpj1+3egSsiV9HqZP4tgG1fotCOizubL42gRjSQ=,tag:DC0xzG/VqcL4ib6ijxQZnA==,type:str]
|
||||
lastmodified: "2025-05-30T12:50:05Z"
|
||||
mac: ENC[AES256_GCM,data:b9GHzTW9yQ2Fd+EI+bhe6D+f72ToWDwvaJfJEoIIWUC1oExU7W1uRE9tftM8iPjD9CjM/bOSH8otQYGSXcN/SM3N9DW0UnGo5yIqcz/abpLSAgXK4a5MHMFtbJ7uPlsmgEixkPo9Kc82if4qJ1lPK8LL9+W2rZC5FLTHD/a9GKU=,iv:kBUvBsxxjWlXVIzVTLvl+zGKuCeefeNWAxo7OtAoyTg=,tag:6THq7miNLRbwhqg/xt6hXw==,type:str]
|
||||
unencrypted_suffix: _unencrypted
|
||||
version: 3.10.2
|
||||
|
||||
@ -40,7 +40,8 @@ services:
|
||||
- AWS_ENDPOINT=http:/minio:9000
|
||||
- AWS_REGION=local
|
||||
- AWS_BUCKET=ocelot
|
||||
- S3_PUBLIC_GATEWAY=http:/localhost:9000
|
||||
- S3_PUBLIC_GATEWAY=http:/localhost:8000
|
||||
- IMAGOR_SECRET=mysecret
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
|
||||
@ -83,5 +84,22 @@ services:
|
||||
/usr/bin/mc anonymous set-json /tmp/readonly-policy.json dockerminio/ocelot;
|
||||
"
|
||||
|
||||
imagor:
|
||||
image: shumc/imagor:latest
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
PORT: 8000
|
||||
IMAGOR_SECRET: mysecret # secret key for URL signature
|
||||
# IMAGOR_UNSAFE: 1 # unsafe URL for testing
|
||||
AWS_ACCESS_KEY_ID: h6uWJf7Earij3143YBV7
|
||||
AWS_SECRET_ACCESS_KEY: or9ZWh34BmAIqzIbJL5QpeTrey5ChGirH0mmyMdn
|
||||
AWS_ENDPOINT: http:/minio:9000
|
||||
S3_FORCE_PATH_STYLE: 1
|
||||
S3_LOADER_BUCKET: ocelot # enable S3 loader by specifying bucket
|
||||
S3_STORAGE_BUCKET: ocelot # enable S3 storage by specifying bucket
|
||||
S3_RESULT_STORAGE_BUCKET: ocelot # enable S3 result storage by specifying bucket
|
||||
HTTP_LOADER_BASE_URL: http://minio:9000
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
@click="handleBadgeClick(badge, index)"
|
||||
>
|
||||
<div class="badge-icon">
|
||||
<img :src="badge.icon | proxyApiUrl" :alt="badge.id" />
|
||||
<img :src="'/api' + badge.icon" :alt="badge.id" />
|
||||
</div>
|
||||
<div class="badge-info">
|
||||
<div class="badge-description">{{ badge.description }}</div>
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
:class="{ selectable: selectionMode && index > 0, selected: selectedIndex === index }"
|
||||
@click="handleBadgeClick(index)"
|
||||
>
|
||||
<img :title="badge.description" :src="badge.icon | proxyApiUrl" class="hc-badge" />
|
||||
<img :title="badge.description" :src="'/api' + badge.icon" class="hc-badge" />
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -335,7 +335,7 @@ export default {
|
||||
;[...this.messages, ...Message].forEach((m) => {
|
||||
if (m.senderId !== this.currentUser.id) m.seen = true
|
||||
m.date = new Date(m.date).toDateString()
|
||||
m.avatar = this.$filters.proxyApiUrl(m.avatar)
|
||||
m.avatar = m.avatar?.w320
|
||||
msgs[m.indexId] = m
|
||||
})
|
||||
this.messages = msgs.filter(Boolean)
|
||||
@ -408,7 +408,7 @@ export default {
|
||||
const fixedRoom = {
|
||||
...room,
|
||||
index: room.lastMessage ? room.lastMessage.date : room.createdAt,
|
||||
avatar: this.$filters.proxyApiUrl(room.avatar),
|
||||
avatar: room.avatar?.w320,
|
||||
lastMessage: room.lastMessage
|
||||
? {
|
||||
...room.lastMessage,
|
||||
@ -416,7 +416,7 @@ export default {
|
||||
}
|
||||
: null,
|
||||
users: room.users.map((u) => {
|
||||
return { ...u, username: u.name, avatar: this.$filters.proxyApiUrl(u.avatar?.url) }
|
||||
return { ...u, username: u.name, avatar: u.avatar?.w320 }
|
||||
}),
|
||||
}
|
||||
if (!fixedRoom.avatar) {
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
<template #heroImage>
|
||||
<img
|
||||
v-if="formData.image"
|
||||
:src="formData.image | proxyApiUrl"
|
||||
:src="formData.image.url"
|
||||
:class="['image', formData.imageBlurred && '--blur-image']"
|
||||
/>
|
||||
<image-uploader
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
:highlight="isPinned"
|
||||
>
|
||||
<template v-if="post.image" #heroImage>
|
||||
<img :src="post.image | proxyApiUrl" class="image" />
|
||||
<responsive-image :image="post.image" sizes="640px" class="image" />
|
||||
</template>
|
||||
<client-only>
|
||||
<div class="post-user-row">
|
||||
@ -136,6 +136,7 @@ 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'
|
||||
@ -153,6 +154,7 @@ export default {
|
||||
LocationTeaser,
|
||||
DateTime,
|
||||
UserTeaser,
|
||||
ResponsiveImage,
|
||||
},
|
||||
props: {
|
||||
post: {
|
||||
|
||||
24
webapp/components/ResponsiveImage/ResponsiveImage.vue
Normal file
24
webapp/components/ResponsiveImage/ResponsiveImage.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<img :src="image.url" :sizes="sizes" :srcset="srcset" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sizes: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
srcset() {
|
||||
const { w320, w640, w1024 } = this.image
|
||||
return `${w320} 320w, ${w640} 640w, ${w1024} 1024w`
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@ -8,7 +8,7 @@
|
||||
@click="toggleBadge(badge)"
|
||||
:class="{ badge, inactive: !badge.isActive }"
|
||||
>
|
||||
<img :src="badge.icon | proxyApiUrl" :alt="badge.description" />
|
||||
<img :src="'/api' + badge.icon" :alt="badge.description" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
@ -3,20 +3,26 @@
|
||||
<!-- '--no-image' is neccessary, because otherwise we still have a little unwanted boarder araund the image for images with white backgrounds -->
|
||||
<span class="initials">{{ profileInitials }}</span>
|
||||
<base-icon v-if="isAnonymous" name="eye-slash" />
|
||||
<img
|
||||
<responsive-image
|
||||
v-if="isAvatar"
|
||||
:src="profile.avatar | proxyApiUrl"
|
||||
:image="profile.avatar"
|
||||
class="image"
|
||||
:alt="profile.name"
|
||||
:title="showProfileNameTitle ? profile.name : ''"
|
||||
@error="$event.target.style.display = 'none'"
|
||||
sizes="320px"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||
|
||||
export default {
|
||||
name: 'ProfileAvatar',
|
||||
components: {
|
||||
ResponsiveImage,
|
||||
},
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
|
||||
@ -7,6 +7,9 @@ export const userFragment = gql`
|
||||
name
|
||||
avatar {
|
||||
url
|
||||
w320: transform(width: 320)
|
||||
w640: transform(width: 640)
|
||||
w1024: transform(width: 1024)
|
||||
}
|
||||
disabled
|
||||
deleted
|
||||
@ -80,6 +83,9 @@ export const postFragment = gql`
|
||||
language
|
||||
image {
|
||||
url
|
||||
w320: transform(width: 320)
|
||||
w640: transform(width: 640)
|
||||
w1024: transform(width: 1024)
|
||||
sensitive
|
||||
aspectRatio
|
||||
type
|
||||
|
||||
@ -21,9 +21,13 @@
|
||||
:style="heroImageStyle"
|
||||
>
|
||||
<template #heroImage v-if="post.image">
|
||||
<img :src="post.image | proxyApiUrl" class="image" />
|
||||
<responsive-image
|
||||
:image="post.image"
|
||||
sizes="(max-width: 1024px) 640px, 1024px"
|
||||
class="image"
|
||||
/>
|
||||
<aside v-show="post.image && post.image.sensitive" class="blur-toggle">
|
||||
<img v-show="blurred" :src="post.image | proxyApiUrl" class="preview" />
|
||||
<img v-show="blurred" :src="post.image.url.w320" class="preview" />
|
||||
<base-button
|
||||
:icon="blurred ? 'eye' : 'eye-slash'"
|
||||
filled
|
||||
@ -167,6 +171,7 @@ import {
|
||||
deletePostMutation,
|
||||
sortTagsAlphabetically,
|
||||
} from '~/components/utils/PostHelpers'
|
||||
import ResponsiveImage from '~/components/ResponsiveImage/ResponsiveImage.vue'
|
||||
import PostQuery from '~/graphql/PostQuery'
|
||||
import { groupQuery } from '~/graphql/groups'
|
||||
import PostMutations from '~/graphql/PostMutations'
|
||||
@ -193,6 +198,7 @@ export default {
|
||||
ObserveButton,
|
||||
LocationTeaser,
|
||||
PageParamsLink,
|
||||
ResponsiveImage,
|
||||
UserTeaser,
|
||||
},
|
||||
mixins: [GetCategories, postListActions, SortCategories],
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContributionForm from '~/components/ContributionForm/ContributionForm'
|
||||
import ContributionForm from '~/components/ContributionForm/ContributionForm.vue'
|
||||
import PostQuery from '~/graphql/PostQuery'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
|
||||
@ -95,12 +95,6 @@ export default ({ app = {} }) => {
|
||||
|
||||
return contentExcerpt
|
||||
},
|
||||
proxyApiUrl: (input) => {
|
||||
const url = input && (input.url || input)
|
||||
if (!url) return url
|
||||
if (url.startsWith('/api/')) return url
|
||||
return url.startsWith('/') ? url.replace('/', '/api/') : url
|
||||
},
|
||||
})
|
||||
|
||||
// add all methods as filters on each vue component
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user