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
+}