Skip to content
Snippets Groups Projects
Commit e0fd0a76 authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

feat: add new image picker

closes #139
parent 9010be26
No related branches found
No related tags found
No related merge requests found
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
......@@ -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:
......
......@@ -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:
......
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
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment