Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Image on demand service #658

Open
kubilaymelnikov opened this issue Oct 9, 2023 · 8 comments
Open

Image on demand service #658

kubilaymelnikov opened this issue Oct 9, 2023 · 8 comments

Comments

@kubilaymelnikov
Copy link
Contributor

We have been developing exclusively headless websites for over 2 years now. However, I have always found dealing with image sizes on these sites to be quite cumbersome and insufficient. That's why I extended your FilesProcessor with breakpoints. After several optimizations, we achieved a rather streamlined result.

This is how it looked in the end:

images = TEXT
images  {
    dataProcessing {
        10 = Wineworlds\ContentElements\DataProcessing\FilesProcessor
        10 {
            breakpoints {
                0 = 500, 499, mobile
                1 = 500, 500, mobile
                2 = 640, 640, mobile
                3 = 768, 768, tablet
                3 = 1024, 1024, tablet
                5 = 1280, 640, desktop
                6 = 1440, 720, desktop
                7 = 1680, 840, desktop
                8 = 1920, 960, desktop
            }

            processingConfiguration {
                cropVariant = desktop
                maxWidth = 960

                breakpoints {
                    newVersion = true
                    webp = true
                }
            }

            references.fieldName = assets
            as = images
        }
    }
}

However, I was still not satisfied with this, as the configuration was still rather laborious. That's why I decided to develop an extension that allows you to load the image in the exact size you need.

Wouldn't something like this also be interesting for the Headless Core? After all, it's about making images available in the Headless mode.

I want to emphasize that this is not intended as self-promotion. I am not the best PHP developer and still need to work on the entire code, improve the structure, and so on.

Here is the link to my repository: https://github.com/wineworlds/image-on-demand-service/tree/develop

Furthermore, I plan to complete the Nuxt3 module and make it available in this repository via npm.

This new extension is already in use in our current web project, although the website is not yet officially online.

@kubilaymelnikov

This comment was marked as outdated.

@kubilaymelnikov
Copy link
Contributor Author

I have released the version 1.1.1 With an adapted URL structure and a caching.

@kubilaymelnikov
Copy link
Contributor Author

kubilaymelnikov commented Oct 16, 2023

Here is my temporary solution in the frontend:

src/components/Util/UtilImageOnDemand.vue

<template>
  <img
    ref="image"
    :src="src"
    :width="width"
    :height="height"
    :class="[$style.image, { [$style.loaded]: loaded }]"
  />
</template>

<script lang="ts" setup>
import type { T3File } from '@t3headless/nuxt-typo3'

interface UtilImageOnDemand {
  image?: T3File
  type?: string
  lazy?: boolean
  dummy?: boolean
  text?: string
  bgColor?: string
  textColor?: string
  crop?: string
}

interface Querys {
  id?: number
  type?: string
  text?: string
  bgColor?: string
  textColor?: string
  crop?: string
}

const props = withDefaults(defineProps<UtilImageOnDemand>(), {
  type: 'webp',
  lazy: true,
  dummy: false,
})

const { options } = useT3Options()
const baseUrl: string = `${options.api.baseUrl}/image-service`
const image = ref<HTMLElement>()
const loaded = ref<boolean>(false)
const observer = ref<IntersectionObserver>()
const src = ref<string>()
const name = ref<string>('dateiname.jpg')
const width = ref<number>(300)
const height = ref<number>(300)
const querys = reactive<Querys>({
  type: props.type,
})

const queryString = computed<string>(() =>
  Object.entries(querys)
    .filter(([key, value]) => !!value)
    .map(([key, value]) => `${key}=${encodeURIComponent(value as any)}`)
    .join('&')
)

if (props.type) querys.type = props.type
if (props.crop) querys.crop = props.crop

if (props.image) {
  const { fileReferenceUid: uid, filename, dimensions } = props.image.properties

  querys.id = uid
  name.value = filename
  width.value = dimensions.width
  height.value = dimensions.height
}

if (props.image && !props.lazy) {
  src.value = `${baseUrl}/${width.value}/${height.value}/${name.value}?${queryString.value}`
}

if (props.image && props.lazy) {
  const lazyWidth = 100
  const lazyHeight = Math.round((height.value / width.value) * 100)

  src.value = `${baseUrl}/${lazyWidth}/${lazyHeight}/${name.value}?${queryString.value}`

  const onEnter = () => {
    if (!image.value) return

    image.value.addEventListener('load', () => {
      loaded.value = true

      observer.value?.disconnect()
    })

    const { offsetWidth, offsetHeight } = image.value
    src.value = `${baseUrl}/${offsetWidth}/${offsetHeight}/${name.value}?${queryString.value}`
  }

  onMounted(() => {
    observer.value = useIntersect(
      image as Ref<HTMLElement>,
      onEnter,
      undefined,
      undefined,
      {
        threshold: 0.4,
      }
    )
  })

  onUnmounted(() => {
    observer.value?.disconnect()
  })
}

if (!props.image && props.dummy) {
  if (props.text) querys.text = props.text
  if (props.bgColor) querys.bgColor = props.bgColor
  if (props.textColor) querys.textColor = props.bgColor

  src.value = `${baseUrl}/${width.value}/${height.value}?${queryString.value}`
  loaded.value = true
}
</script>

<!-- TODO: Remove css from this utility component. Provide only functionality -->
<style lang="postcss" module>
.image {
  @apply transition-all;
}

.image:not(.loaded) {
  @apply blur-xl;
}
</style>

src/composables/useIntersect.ts

export const useIntersect = (
  elementToWatch: Ref<HTMLElement> | HTMLElement,
  callback: Function,
  outCallback?: Function,
  once: Boolean = true,
  options = { threshold: 1.0 }
) => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry && entry.isIntersecting) {
      callback(entry.target)

      if (once) {
        observer.unobserve(entry.target)
      }
    } else if (typeof outCallback === 'function') {
      outCallback(entry.target)
    }
  }, options)

  if (isRef(elementToWatch)) {
    elementToWatch = elementToWatch.value
  }

  observer.observe(elementToWatch)

  return observer
}

@lukaszuznanski
Copy link
Collaborator

@mercs600 please take a look at frontend solution, is this suitable for us?

@kubilaymelnikov
Copy link
Contributor Author

@lukaszuznanski & @mercs600

There are still a few issues & questions...

  • What happens to the generated images: Is there a routine in place that deletes images that haven't been accessed for 3 days? (Of course, this should be configurable.)

  • Do we change the path to /300x300/ instead of /300/300/ so that we can simply specify the width or height separately?

  • How can we define the IntersectionObserver only once and still deliver the callback to the respective component?

@kubilaymelnikov
Copy link
Contributor Author

@mercs600 please take a look at frontend solution, is this suitable for us?

Another possibility could be to only provide the composable to generate the link. In the end, everyone can decide when and how to use the link.

@kubilaymelnikov
Copy link
Contributor Author

kubilaymelnikov commented Oct 29, 2023

@mercs600; I have now written a composable function.
I believe this is the right approach if you'd like to include it.
I'm also happy to create a Pull Request in the new private repository on GitLab.

Just let me know how you decide, looking forward to it, and hopefully you'll like it!

interface ImageServiceOptions {
  image?: T3File;
  type?: Ref<string> | string; // webp
  text?: Ref<string> | string; // Dummy Image
  bgColor?: Ref<string> | string; // #fff
  textColor?: Ref<string> | string; // #000
  crop?: Ref<string> | string; // desktop
}

interface ImageServiceResizeProps {
  width?: number;
  height?: number;
  crop?: string;
}

interface ImageServiceQuery {
  id?: number;
  type?: string;
  text?: string;
  bgColor?: string;
  textColor?: string;
  crop?: string;
}

export const useImageService = (options: ImageServiceOptions) => {
  const { options: t3Options } = useT3Options();
  const baseUrl: string = `${t3Options.api.baseUrl}/image-service`;

  const width = ref<number>(300);
  const height = ref<number>(300);
  const name = ref<string>("dummy.jpg");
  const query = reactive<ImageServiceQuery>({
    type: "jpg",
  });
  const queryString = computed<string>(() =>
    Object.entries(query)
      .filter(([key, value]) => !!value)
      .map(([key, value]) => `${key}=${encodeURIComponent(value as any)}`)
      .join("&")
  );

  // watchEffect(() => {
  if (options?.image) {
    const { fileReferenceUid: uid, filename, dimensions } = options.image.properties // prettier-ignore

    query.id = uid;
    name.value = filename;
    width.value = dimensions.width;
    height.value = dimensions.height;
  }
  if (options?.type) query.type = toValue(options.type);
  if (options?.text) query.text = toValue(options.text);
  if (options?.bgColor) query.bgColor = toValue(options.bgColor);
  if (options?.textColor) query.textColor = toValue(options.textColor);
  if (options?.crop) query.crop = toValue(options.crop);
  // })

  const url = computed<string>(() => `${baseUrl}/${width.value}/${height.value}/${name.value}?${queryString.value}`) // prettier-ignore

  const resize = (props: ImageServiceResizeProps): string => {
    const { width: w, height: h, crop: c } = props;

    if (w) width.value = w;
    if (h) height.value = h;
    if (c) query.crop = c;

    // adjust height in proportion if only the width is specified and an image is present.
    if (w && !h && options.image) {
      const { dimensions } = options.image.properties;
      width.value = w;
      height.value = (dimensions.height / dimensions.width) * w;
    }

    // adjust width in proportion if only the height is specified and an image is present.
    if (h && !w && options.image) {
      const { dimensions } = options.image.properties;
      width.value = h;
      height.value = (dimensions.height / dimensions.width) * h;
    }

    return url.value;
  };

  return {
    resize,
    url,
  };
};

@lukaszuznanski
Copy link
Collaborator

@kubilaymelnikov usually we are handling image generation and resizing in imgproxy in nuxt-typo3 application, I think @mercs600 can give you more insight

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants