diff --git a/src/components/generic/Browser.vue b/src/components/generic/Browser.vue new file mode 100644 index 0000000000000000000000000000000000000000..46ecb455762048ed6e39e5c7be1ea34bb69b8c1c --- /dev/null +++ b/src/components/generic/Browser.vue @@ -0,0 +1,50 @@ +<template> + <div + ref="scroller" + class="tw-relative -tw-m-4 tw-pb-8 tw-overflow-x-visible tw-overflow-y-scroll tw-overscroll-contain" + > + <ol class="tw-m-0 tw-p-4" v-bind="attrs"> + <slot /> + </ol> + <div + v-if="isLoading" + class="tw-absolute tw-bottom-0 tw-left-1/2 -tw-translate-x-1/2 tw-inline-flex tw-justify-center tw-text-blue-300" + > + <Loading class="tw-h-2" /> + </div> + </div> +</template> + +<script lang="ts" setup> +import { useInfiniteScroll } from '@vueuse/core' +import { ref, useAttrs } from 'vue' +import Loading from './Loading.vue' + +const props = defineProps<{ + loadMore: () => void | Promise<void> +}>() +const attrs = useAttrs() +const scroller = ref<HTMLOListElement>() +const isLoading = ref(false) + +async function loadMoreData() { + isLoading.value = true + try { + await props.loadMore() + } finally { + isLoading.value = false + } +} + +loadMoreData() +useInfiniteScroll(scroller, loadMoreData) +</script> + +<script lang="ts"> +export default { + inheritAttrs: false, + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/components/generic/Dialog.vue b/src/components/generic/Dialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..94ff56ff8dd17bdc2af584637a78c912cd42b7ef --- /dev/null +++ b/src/components/generic/Dialog.vue @@ -0,0 +1,112 @@ +<template> + <dialog + v-if="modelValue" + ref="dialogEl" + class="tw-shadow-xl tw-border tw-border-gray-200 tw-rounded tw-max-w-[95%] tw-p-0 tw-bg-white tw-z-30 tw-flex tw-flex-col" + @click="maybeClose" + @keydown.esc="close" + > + <header class="tw-flex tw-justify-between tw-p-4 flex-none"> + <div> + <slot name="header" /> + </div> + <button + type="button" + class="tw-w-8 tw-h-8 tw-ml-auto tw-p-0 tw-flex tw-items-center tw-justify-center tw-bg-gray-100 tw-rounded-full tw-border-none" + tabindex="-1" + @click="close" + > + <icon-system-uicons-close class="tw-w-6 tw-h-6" /> + </button> + </header> + <div class="tw-flex-1 tw-p-4 tw-overflow-y-auto tw-shadow-inner"> + <slot /> + </div> + <footer + v-if="slots.footer" + class="tw-flex-none tw-p-4 tw-border-0 tw-border-t tw-border-solid tw-border-gray-200" + > + <slot name="footer" /> + </footer> + </dialog> +</template> + +<script lang="ts" setup> +import { onClickOutside } from '@vueuse/core' +import { nextTick, ref, useSlots, watch } from 'vue' + +const props = withDefaults( + defineProps<{ + modelValue: boolean + isModal?: boolean + }>(), + { + isModal: false, + }, +) +const emit = defineEmits<{ + (e: 'update:modelValue', value: boolean): void +}>() +const slots = useSlots() + +const dialogEl = ref<HTMLDialogElement>() + +function close() { + emit('update:modelValue', false) +} + +function maybeClose(event: MouseEvent) { + // when dialog is a modal the backdrop of it is still considered the dialog element + // so the onClickOutside handler below won’t be triggered + if (dialogEl.value) { + const rect = dialogEl.value.getBoundingClientRect() + const isInDialog = + rect.top <= event.clientY && + event.clientY <= rect.top + rect.height && + rect.left <= event.clientX && + event.clientX <= rect.left + rect.width + if (!isInDialog) { + close() + } + } +} + +onClickOutside(dialogEl, close) + +watch( + () => props.modelValue, + async (isOpen) => { + if (isOpen && !dialogEl.value) { + await nextTick() + } + + if (dialogEl.value) { + if (isOpen) { + if (props.isModal) { + dialogEl.value.showModal() + } else { + dialogEl.value.show() + } + } else if (dialogEl.value.open) { + dialogEl.value.close() + } + } + }, + { immediate: true }, +) +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> + +<style lang="postcss" scoped> +dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(3px); +} +</style> diff --git a/src/components/generic/Loading.vue b/src/components/generic/Loading.vue new file mode 100644 index 0000000000000000000000000000000000000000..33dcaf10b73206c422ff2ca70599aa15df43c3dc --- /dev/null +++ b/src/components/generic/Loading.vue @@ -0,0 +1,31 @@ +<template> + <svg viewBox="0 0 8 2" xmlns="http://www.w3.org/2000/svg"> + <circle cx="1" cy="1" fill="currentColor" r="1" stroke="none"> + <animate + attributeName="opacity" + begin="0.1" + dur="1s" + repeatCount="indefinite" + values="0;1;0" + /> + </circle> + <circle cx="4" cy="1" fill="currentColor" r="1" stroke="none"> + <animate + attributeName="opacity" + begin="0.2" + dur="1s" + repeatCount="indefinite" + values="0;1;0" + /> + </circle> + <circle cx="7" cy="1" fill="currentColor" r="1" stroke="none"> + <animate + attributeName="opacity" + begin="0.3" + dur="1s" + repeatCount="indefinite" + values="0;1;0" + /> + </circle> + </svg> +</template> diff --git a/src/components/images/ImageBrowser.vue b/src/components/images/ImageBrowser.vue new file mode 100644 index 0000000000000000000000000000000000000000..562b92c2dd41ae8ee0aea44f6ef485c7e6ee5d0b --- /dev/null +++ b/src/components/images/ImageBrowser.vue @@ -0,0 +1,41 @@ +<template> + <Browser + :load-more="loadMore" + class="tw-grid tw-gap-3 tw-grid-cols-2 sm:tw-grid-cols-3 md:tw-grid-cols-4 tw-max-h-[50vh] lg:tw-max-h-[600px]" + > + <li v-for="image in imageStore.items" :key="image.id" class="tw-block"> + <ImagePreview + :url="image.image" + :alt="image.alt_text ?? ''" + class="tw-h-full tw-aspect-square tw-cursor-pointer tw-border tw-border-gray-200 tw-origin-center tw-transition-all tw-ring-blue-200 tw-ring-offset-2 tw-rounded tw-overflow-hidden hover:tw-z-20 hover:tw-ring-4 focus:tw-ring-4 focus:tw-outline-none" + tabindex="0" + @enter.stop.prevent="emit('input', image)" + @click.stop="emit('input', image)" + /> + </li> + </Browser> +</template> + +<script lang="ts" setup> +import { Image, useImageStore } from '@/stores/images' +import Browser from '../generic/Browser.vue' +import ImagePreview from './ImagePreview.vue' + +const emit = defineEmits<{ + (e: 'input', value: Image): void +}>() + +const imageStore = useImageStore() + +function loadMore() { + imageStore.list(imageStore.count === 0 ? 1 : imageStore.currentPage + 1) +} +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/components/images/ImageEditor.vue b/src/components/images/ImageEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..56ce0d1933dce608e995ec14924fe451915e9a49 --- /dev/null +++ b/src/components/images/ImageEditor.vue @@ -0,0 +1,67 @@ +<template> + <ImagePreview + :url="image.image" + class="tw-p-6 tw-bg-gray-700 tw-shadow-inner tw-flex tw-rounded tw-max-h-[300px]" + :cover="false" + /> + + <div class="tw-grid tw-gap-x-6 tw-gap-y-3 tw-grid-cols-6 tw-mt-6"> + <div class="form-group tw-col-span-6"> + <label class="control-label"> + {{ t('image.altText') }} + <textarea + ref="descriptionEl" + v-model="image.alt_text" + rows="1" + class="form-control" + ></textarea> + </label> + </div> + <div class="form-group tw-col-span-6"> + <label class="control-label"> + {{ t('image.credits') }} + <input v-model="image.credits" class="form-control" /> + </label> + </div> + </div> +</template> + +<script lang="ts" setup> +import { useTextareaAutosize } from '@vueuse/core' +import { computed } from 'vue' +import { useI18n } from '@/i18n' +import { Image, NewImage } from '@/stores/images' +import { useUpdatableState } from '@/util' +import ImagePreview from './ImagePreview.vue' + +const props = defineProps<{ + modelValue: Image | NewImage +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', value: Image | NewImage): void +}>() +const { t } = useI18n() +const image = useUpdatableState<Image | NewImage>( + computed(() => props.modelValue), + (image) => emit('update:modelValue', image), + (image) => ({ ...image }), +) + +const { textarea: descriptionEl } = useTextareaAutosize({ watch: () => image.value.alt_text }) +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> + +<style lang="postcss" scoped> +.form-group, +.control-label { + @apply tw-mb-0; + @apply tw-block; +} +</style> diff --git a/src/components/images/ImagePicker.vue b/src/components/images/ImagePicker.vue new file mode 100644 index 0000000000000000000000000000000000000000..3c572d83c94d38bfd9dc8551896d6668f989bf25 --- /dev/null +++ b/src/components/images/ImagePicker.vue @@ -0,0 +1,67 @@ +<template> + <ImagePreview + :url="image?.image ?? null" + class="tw-inline-flex tw-h-64 tw-max-w-full tw-min-w-[300px] tw-bg-gray-700 tw-rounded" + v-bind="attrs" + > + <div + class="tw-absolute tw-bottom-0 tw-left-0 tw-right-0 tw-p-2 tw-flex tw-items-center tw-justify-between tw-bg-black/75 tw-text-white" + > + <button + type="button" + class="btn btn-secondary btn-sm tw-inline-flex tw-items-center" + @click="showPicker = true" + > + <icon-carbon-image-search class="tw-mr-1 tw-w-6 tw-h-6" /> + {{ t('imagePicker.chooseAnImage') }} + </button> + <button + v-if="modelValue" + type="button" + class="btn text-white btn-sm tw-inline-flex tw-items-center" + @click="localImageId = null" + > + {{ t('imagePicker.reset') }} + </button> + </div> + </ImagePreview> + <ImagePickerDialog + v-if="showPicker" + v-model="localImageId" + :is-open="true" + @show="showPicker = $event" + /> +</template> + +<script lang="ts" setup> +import { computed, ref, useAttrs } from 'vue' +import { useI18n } from '@/i18n' +import { useImage } from '@/stores/images' +import { useUpdatableState } from '@/util' +import ImagePickerDialog from './ImagePickerDialog.vue' +import ImagePreview from './ImagePreview.vue' + +const props = defineProps<{ + modelValue: number | null +}>() +const emit = defineEmits<{ + (e: 'update:modelValue', imageId: number | null): void +}>() +const attrs = useAttrs() +const { t } = useI18n() +const showPicker = ref(false) +const localImageId = useUpdatableState<number | null>( + computed(() => props.modelValue), + (value) => emit('update:modelValue', value), +) +const image = useImage(computed(() => props.modelValue)) +</script> + +<script lang="ts"> +export default { + inheritAttrs: false, + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/components/images/ImagePickerDialog.vue b/src/components/images/ImagePickerDialog.vue new file mode 100644 index 0000000000000000000000000000000000000000..8b90f4b1e951540c14382fe513fcfde6caee0795 --- /dev/null +++ b/src/components/images/ImagePickerDialog.vue @@ -0,0 +1,148 @@ +<template> + <Dialog v-model="localIsOpen" class="tw-w-screen lg:tw-w-[60vw] tw-max-w-[800px]" is-modal> + <template #header> + <p class="tw-text-lg tw-font-semibold tw-m-0">{{ t('imagePicker.title') }}</p> + </template> + + <div + v-if="!selectedImage && !showBrowser" + class="tw-flex tw-flex-wrap tw-items-center tw-gap-2" + > + <ImageUploader class="tw-grow md:tw-grow-0" @input="setImage" /> + <button + type="button" + class="btn btn-sm btn-secondary tw-flex tw-items-center tw-grow md:tw-grow-0" + @click.stop="showBrowser = true" + > + <icon-carbon-search class="tw-mr-1 tw-w-6 tw-h-6" /> + {{ t('imagePicker.browseImages') }} + </button> + <button + v-if="currentImage" + type="button" + class="btn btn-sm btn-secondary tw-flex tw-items-center tw-grow md:tw-grow-0" + @click.stop="selectedImage = currentImage" + > + <icon-carbon-pen class="tw-mr-1 tw-w-6 tw-h-6" /> + {{ t('imagePicker.editCurrentImage') }} + </button> + </div> + + <p v-if="error" role="alert" aria-live="assertive" class="alert alert-danger"> + {{ error.message }} + </p> + + <ImageBrowser v-if="showBrowser" @input="setImage" /> + <ImageEditor v-if="selectedImage" v-model="selectedImage" /> + + <template v-if="showBrowser || selectedImage" #footer> + <div class="tw-flex tw-gap-x-6"> + <button + v-if="selectedImage" + :disabled="isSaving" + type="button" + class="btn btn-primary" + @click.stop="selectedImage && saveAndSelectImage(selectedImage)" + > + <template v-if="isSelectedImageNew">{{ t('imagePicker.useImage') }}</template> + <template v-else>{{ t('imagePicker.saveChanges') }}</template> + </button> + <button v-if="selectedImage" type="button" class="btn" @click.stop="deselectImage"> + {{ t('imagePicker.abort') }} + </button> + <button + v-if="showBrowser" + type="button" + class="btn btn-secondary" + @click.stop="showBrowser = false" + > + {{ t('imagePicker.abort') }} + </button> + </div> + </template> + </Dialog> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue' +import { useI18n } from '@/i18n' +import { Image, NewImage, useImage, useImageStore } from '@/stores/images' +import { useAsyncFunction, useUpdatableState } from '@/util' +import Dialog from '../generic/Dialog.vue' +import ImageBrowser from './ImageBrowser.vue' +import ImageEditor from './ImageEditor.vue' +import ImageUploader from './ImageUploader.vue' +import { APIResponseError } from '@/api' + +const props = defineProps<{ + isOpen: boolean + modelValue: number | null +}>() +const emit = defineEmits<{ + (e: 'show', state: boolean): void + (e: 'update:modelValue', value: number | null): void +}>() +const { t } = useI18n() +const imageStore = useImageStore() +const localIsOpen = useUpdatableState( + computed(() => props.isOpen), + (isOpen) => emit('show', isOpen), +) +const error = ref<Error>() +const showBrowser = ref(false) +const selectedImage = ref<Image | NewImage | null>(null) + +const currentImage = useImage(computed(() => props.modelValue)) +const isSelectedImageNew = computed(() => { + if (!selectedImage.value) return false + else if ('id' in selectedImage.value) return selectedImage.value.id !== props.modelValue + else return true +}) +const { isLoading: isSaving, fn: saveAndSelectImage } = useAsyncFunction( + async (image: Image | NewImage) => { + error.value = undefined + const data = new FormData() + data.set('ppoi', image.ppoi) + data.set('alt_text', image.alt_text ?? '') + data.set('credits', image.credits ?? '') + let newOrUpdatedImage: Image + try { + if ('id' in image) { + newOrUpdatedImage = await imageStore.update(image.id, data) + } else { + data.set('image', image.file, image.file.name) + newOrUpdatedImage = await imageStore.create(data) + } + } catch (_error) { + const _errorObj = _error instanceof Error ? _error : new Error(String(_error)) + const message = + _errorObj instanceof APIResponseError && _errorObj.response.status === 413 + ? t('imagePicker.error.tooLarge') + : t('imagePicker.error.default') + // eslint-disable-next-line no-undef + error.value = new AggregateError([_errorObj], message) + return + } + emit('update:modelValue', newOrUpdatedImage.id) + emit('show', false) + }, +) + +function setImage(image: Image | NewImage | null) { + selectedImage.value = image + showBrowser.value = false +} + +function deselectImage() { + selectedImage.value = null + error.value = undefined +} +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/components/images/ImagePreview.vue b/src/components/images/ImagePreview.vue new file mode 100644 index 0000000000000000000000000000000000000000..e92934d1d57206273c166cb6f99aafb1275ac56e --- /dev/null +++ b/src/components/images/ImagePreview.vue @@ -0,0 +1,39 @@ +<template> + <div class="tw-justify-center tw-w-auto tw-relative tw-overflow-hidden tw-bg-gray-700"> + <img + v-if="url" + :src="url" + class="tw-max-w-full tw-max-h-full tw-block tw-mx-auto" + :class="{ 'tw-object-cover tw-h-full': cover, 'tw-object-contain': !cover }" + /> + <div + v-else + class="tw-bg-blue-100 tw-w-full tw-h-full tw-text-blue-300 tw-flex tw-items-center tw-justify-center" + > + <icon-carbon-no-image class="tw-w-16 tw-h-16" /> + </div> + <slot /> + </div> +</template> + +<script lang="ts" setup> +withDefaults( + defineProps<{ + url: string | null + alt?: string + cover?: boolean + }>(), + { + cover: true, + alt: '', + }, +) +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/components/images/ImageUploader.vue b/src/components/images/ImageUploader.vue new file mode 100644 index 0000000000000000000000000000000000000000..9b5d0a4372122b07005eb6e3a49d97486e7c6807 --- /dev/null +++ b/src/components/images/ImageUploader.vue @@ -0,0 +1,70 @@ +<template> + <button + type="button" + class="btn btn-sm btn-primary tw-inline-flex tw-items-center" + @click.stop="open()" + > + <icon-carbon-cloud-upload class="tw-mr-1 tw-w-6 tw-h-6" /> + {{ t('imagePicker.uploadImage') }} + </button> +</template> + +<script lang="ts" setup> +import { useFileDialog } from '@vueuse/core' +import { watch } from 'vue' +import { useI18n } from '@/i18n' +import type { NewImage } from '@/stores/images' + +const emit = defineEmits<{ + (e: 'input', value: NewImage): void +}>() +const { t } = useI18n() +const { files, open } = useFileDialog({ + multiple: false, + accept: 'image/*', + capture: 'environment', +}) + +watch(files, async () => { + if (files.value && files.value.length > 0) { + const file = files.value.item(0) + if (file) { + const content = await readFile(file) + emit('input', newImage(file, content)) + } + } +}) + +function readFile(file: File): Promise<string> { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = function (event) { + if (event.target) { + resolve(event.target.result as string) + } + } + reader.readAsDataURL(file) + }) +} + +function newImage(file: File, content: string): NewImage { + return { + image: content, + file, + alt_text: file.name, + credits: '', + width: 0, + height: 0, + ppoi: '0.5x0.5', + thumbnails: [], + } +} +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + }, +} +</script> diff --git a/src/i18n/de.js b/src/i18n/de.js index a65a2c17d85db202b97c1921e5ac4710f5127ba9..1426511eced367f6a0d661915733878f0cd55409 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -341,6 +341,27 @@ export default { chooseImage: 'Bild auswählen', }, + imagePicker: { + title: 'Suche ein Bild aus', + browseImages: 'Bilder durchstöbern', + editCurrentImage: 'Aktuelles Bild bearbeiten', + saveChanges: 'Änderungen speichern', + useImage: 'Dieses Bild wählen', + chooseAnImage: 'Bild auswählen', + uploadImage: 'Neues Bild hochladen', + abort: 'Abbrechen', + reset: 'Bild zurücksetzen', + error: { + default: 'Das Bild konnte nicht hochgeladen werden.', + tooLarge: 'Die Dateigröße des Bilds, das von dir ausgewählt wurde, ist zu groß.', + }, + }, + + image: { + altText: 'Alternativtext (für Screenreader)', + credits: 'Bildnachweis', + }, + showCreator: { title: 'Neue Sendereihe erstellen', missingShowTypes: diff --git a/src/i18n/en.js b/src/i18n/en.js index 5fe33276740b8fcc76a5d98d7064e5b65fdf2253..4ca7e17e649485f1580bd8b4ace8623b3955af8b 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -307,14 +307,7 @@ export default { options: 'Options', logo: 'Logo', - editLogo: 'Edit logo', - currentLogo: 'Current logo', - chooseLogo: 'Choose new logo', - image: 'Image', - editImage: 'Edit image', - currentImage: 'Current image', - chooseImage: 'Choose new image', multiselect: '<b>Hint:</b> use <code>CTRL+click</code> for multiple selection', }, @@ -340,6 +333,27 @@ export default { chooseImage: 'Choose image', }, + imagePicker: { + title: 'Pick an Image', + browseImages: 'Browse Images', + editCurrentImage: 'Edit the current image', + saveChanges: 'Save changes', + useImage: 'Use this image', + chooseAnImage: 'Choose image', + uploadImage: 'Upload new image', + abort: 'Abort', + reset: 'Reset Image', + error: { + default: 'Could not upload image.', + tooLarge: 'The image you’ve selected is too large in filesize.', + }, + }, + + image: { + altText: 'Alternative Text (for screenreaders)', + credits: 'Credits', + }, + showCreator: { title: 'Create new show', missingShowTypes: diff --git a/src/util.ts b/src/util.ts index 3da88909d1feb79c344e8a7fb0dbab9f6569a697..aa9cbd5744c9dce665b4e3bc99dd57078fb4a1a9 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,5 @@ -import { computed, ComputedGetter, ComputedRef, readonly, ref } from 'vue' +import { cloneDeep, isEqual } from 'lodash' +import { computed, ComputedGetter, ComputedRef, readonly, Ref, ref, watch, watchEffect } from 'vue' export function computedIter<T>(fn: ComputedGetter<Iterable<T>>): ComputedRef<T[]> { return computed(() => Array.from(fn())) @@ -10,3 +11,39 @@ export const useId = (() => { return readonly(ref(`${prefix}-${_id++}`)) } })() + +export function useAsyncFunction<F extends (...args: never[]) => ReturnType<F>>( + fn: F, +): { isLoading: Ref<boolean>; fn: (...args: Parameters<F>) => ReturnType<F> } { + const isLoading = ref(false) + function wrapper(...args: Parameters<F>): ReturnType<F> { + isLoading.value = true + try { + return fn(...args) + } finally { + isLoading.value = false + } + } + return { fn: wrapper, isLoading } +} + +export function useUpdatableState<T>( + externalStateRef: Ref<T>, + onUpdate: (value: T) => void, + clone: (value: T) => T = cloneDeep, +): Ref<T> { + const localRef = ref() + watchEffect(() => { + localRef.value = clone(externalStateRef.value) + }) + watch( + localRef, + (newValue) => { + if (!isEqual(newValue, externalStateRef.value)) { + onUpdate(newValue) + } + }, + { deep: true }, + ) + return localRef +}