diff --git a/src/Pages/ShowManager.vue b/src/Pages/ShowManager.vue
index b1519a4e37b8546c78d37646ecf4d8999de1e436..af21eb71243431fe21b8125b52cb3935033baf6d 100644
--- a/src/Pages/ShowManager.vue
+++ b/src/Pages/ShowManager.vue
@@ -17,10 +17,14 @@
       <TimeSlotList />
 
       <SectionTitle class="tw-mb-4">{{ t('showManager.generalSettings') }}</SectionTitle>
-      <ShowMetaSimpleTypes />
-      <ShowMetaArrays />
-      <ShowMetaOwners />
-      <ShowMetaImages />
+      <div class="tw-grid tw-gap-x-6 tw-grid-cols-3">
+        <ShowMetaSimpleTypes />
+        <hr class="tw-col-span-3 tw-w-full" />
+        <ShowMetaArrays />
+        <ShowMetaOwners />
+        <hr class="tw-col-span-3 tw-w-full" />
+        <ShowMetaImages />
+      </div>
     </template>
   </b-container>
 </template>
diff --git a/src/api.ts b/src/api.ts
index bd767acaff8989dd938b47385504452b8caa5638..22af207e8028b7895fcf9cc354744c4ac8a671c8 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -4,7 +4,7 @@ import { merge } from 'lodash'
 
 export const SERVER_ERRORS_GLOBAL: unique symbol = Symbol('ERRORS_GLOBAL')
 
-type ID = number | string
+export type ID = number | string
 
 type ErrorDetail = {
   message: string
diff --git a/src/components/shows/MetaArrays.vue b/src/components/shows/MetaArrays.vue
index 04bac84a95d7c4cfb7f88ef7e72b25c163339c1a..53df7f5e4db99a7aaee2c146f0ed2d49f76de3c1 100644
--- a/src/components/shows/MetaArrays.vue
+++ b/src/components/shows/MetaArrays.vue
@@ -1,413 +1,234 @@
 <template>
-  <div>
-    <b-row>
-      <!-- Categories -->
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.categories') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editCategories')"
-          @click="openModalCategories()"
-        />
-      </b-col>
-      <b-col lg="3">
-        <div v-if="loaded.categories">
-          <p v-if="categoryNames.length === 0">
-            <small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            >
-          </p>
-          <ul v-else>
-            <li v-for="cat in categoryNames" :key="cat.id">
-              {{ cat.name }}
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-
-      <!-- Topics -->
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.topics') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editTopics')"
-          @click="openModalTopics()"
-        />
-      </b-col>
-      <b-col lg="3">
-        <div v-if="loaded.topics">
-          <p v-if="topicNames.length === 0">
-            <small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            >
-          </p>
-          <ul v-else>
-            <li v-for="topic in topicNames" :key="topic.id">
-              {{ topic.name }}
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-
-      <!-- Music focus -->
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.genres') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editGenres')"
-          @click="openModalMusicFocus()"
-        />
-      </b-col>
-      <b-col lg="3">
-        <div v-if="loaded.musicFocus">
-          <p v-if="musicFocusNames.length === 0">
-            <small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            >
-          </p>
-          <ul v-else>
-            <li v-for="focus in musicFocusNames" :key="focus.id">
-              {{ focus.name }}
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-
-      <!-- Languages -->
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.languages') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editLanguages')"
-          @click="openModalLanguages()"
-        />
-      </b-col>
-      <b-col lg="3">
-        <div v-if="loaded.languages">
-          <p v-if="languageNames.length === 0">
-            <small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            >
-          </p>
-
-          <ul v-else>
-            <li v-for="lang in languageNames" :key="lang.id">
-              {{ lang.name }}
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-
-      <!-- Hosts -->
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.hosts') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editHosts')"
-          @click="openModalHosts()"
-        />
-      </b-col>
-      <b-col lg="3">
-        <div v-if="loaded.hosts">
-          <p v-if="hostNames.length === 0">
-            <small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            >
-          </p>
-          <!-- TODO: make link on name; when user clicks, open modal to edit host -->
-          <ul v-else>
-            <li v-for="host in hostNames" :key="host.id">
-              {{ host.name }}
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-    </b-row>
-
-    <!-- Modals to edit the above -->
-    <div>
-      <b-modal
-        ref="modalCategories"
-        :title="$t('showMeta.editCategories')"
-        :cancel-title="$t('cancel')"
-        size="lg"
-        @ok="saveCategories"
-      >
-        <b-row>
-          <b-col align="center">
-            <div v-if="!loaded.categories">
-              <img src="/assets/radio.gif" :alt="$t('loading')" />
-            </div>
-            <div v-else>
-              <p>{{ $t('showMeta.categoriesLabel') }}:</p>
-              <b-form-select
-                v-model="array"
-                multiple
-                :options="categorySelector"
-                :select-size="5"
-              />
-              <br /><br />
-              <b-alert show dismissible variant="info">
-                <span v-html="$t('showMeta.multiselect')" />
-              </b-alert>
-            </div>
-          </b-col>
-        </b-row>
-      </b-modal>
+  <FormGroup
+    :label="t('showMeta.categories')"
+    with-edit-button
+    @edit="() => modalCategories.show()"
+  >
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="category in usedCategories" :key="category.id">
+        <span>
+          <span class="tw-block">{{ category.name }}</span>
+          <span v-if="category.subtitle.trim()" class="tw-text-xs">
+            {{ category.subtitle }}
+          </span>
+        </span>
+      </Tag>
+    </div>
+  </FormGroup>
 
-      <b-modal
-        ref="modalTopics"
-        :title="$t('showMeta.editTopics')"
-        :cancel-title="$t('cancel')"
-        size="lg"
-        @ok="saveTopics"
-      >
-        <b-row>
-          <b-col align="center">
-            <div v-if="!loaded.topics">
-              <img src="/assets/radio.gif" :alt="$t('loading')" />
-            </div>
-            <div v-else>
-              <p>{{ $t('showMeta.topicsLabel') }}:</p>
-              <b-form-select v-model="array" multiple :options="topicSelector" :select-size="5" />
-              <br /><br />
-              <b-alert show dismissible variant="info">
-                <span v-html="$t('showMeta.multiselect')" />
-              </b-alert>
-            </div>
-          </b-col>
-        </b-row>
-      </b-modal>
+  <FormGroup :label="t('showMeta.topics')" with-edit-button @edit="modalTopics.show()">
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="topic in usedTopics" :key="topic.id" :label="topic.name" />
+    </div>
+  </FormGroup>
 
-      <b-modal
-        ref="modalMusicFocus"
-        :title="$t('showMeta.editGenres')"
-        :cancel-title="$t('cancel')"
-        size="lg"
-        @ok="saveMusicFocus"
-      >
-        <b-row>
-          <b-col align="center">
-            <div v-if="!loaded.musicFocus">
-              <img src="/assets/radio.gif" :alt="$t('loading')" />
-            </div>
-            <div v-else>
-              <p>{{ $t('showMeta.genresLabel') }}:</p>
-              <b-form-select
-                v-model="array"
-                multiple
-                :options="musicFocusSelector"
-                :select-size="5"
-              />
-              <br /><br />
-              <b-alert show dismissible variant="info">
-                <span v-html="$t('showMeta.multiselect')" />
-              </b-alert>
-            </div>
-          </b-col>
-        </b-row>
-      </b-modal>
+  <FormGroup :label="t('showMeta.genres')" with-edit-button @edit="modalMusicFocus.show()">
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="musicFocus in usedMusicFocuses" :key="musicFocus.id" :label="musicFocus.name" />
+    </div>
+  </FormGroup>
 
-      <b-modal
-        ref="modalLanguages"
-        :title="$t('showMeta.editLanguages')"
-        :cancel-title="$t('cancel')"
-        size="lg"
-        @ok="saveLanguages"
-      >
-        <b-row>
-          <b-col align="center">
-            <div v-if="!loaded.languages">
-              <img src="/assets/radio.gif" :alt="$t('loading')" />
-            </div>
-            <div v-else>
-              <p>{{ $t('showMeta.languagesLabel') }}:</p>
-              <b-form-select
-                v-model="array"
-                multiple
-                :options="languageSelector"
-                :select-size="5"
-              />
-              <br /><br />
-              <b-alert show dismissible variant="info">
-                <span v-html="$t('showMeta.multiselect')" />
-              </b-alert>
-            </div>
-          </b-col>
-        </b-row>
-      </b-modal>
+  <FormGroup :label="t('showMeta.languages')" with-edit-button @edit="modalLanguages.show()">
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="language in usedLanguages" :key="language.id" :label="language.name" />
+    </div>
+  </FormGroup>
 
-      <b-modal
-        ref="modalHosts"
-        :title="$t('showMeta.hosts')"
-        :cancel-title="$t('cancel')"
-        size="lg"
-        @ok="saveHosts"
-      >
-        <b-row>
-          <b-col align="center">
-            <div v-if="!loaded.hosts">
-              <img src="/assets/radio.gif" :alt="$t('loading')" />
-            </div>
-            <div v-else>
-              <p>{{ $t('showMeta.hostsLabel') }}</p>
-              <b-form-select v-model="array" multiple :options="hostSelector" :select-size="5" />
-              <br /><br />
-              <b-alert show dismissible variant="info">
-                <span v-html="$t('showMeta.multiselect')" />
-              </b-alert>
-            </div>
-          </b-col>
-        </b-row>
-      </b-modal>
+  <FormGroup :label="t('showMeta.hosts')" with-edit-button @edit="modalHosts.show()">
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="host in usedHosts" :key="host.id" :label="host.name" />
     </div>
-  </div>
+  </FormGroup>
+
+  <Teleport to="body">
+    <b-modal
+      ref="modalCategories"
+      :title="t('showMeta.editCategories')"
+      :cancel-title="t('cancel')"
+      size="lg"
+      @ok="saveArray('categoryIds', categoryIds, modalCategories)"
+    >
+      <b-row>
+        <b-col align="center">
+          <div>
+            <p>{{ t('showMeta.categoriesLabel') }}:</p>
+            <b-form-select
+              v-model="categoryIds"
+              multiple
+              :options="categoryChoices"
+              :select-size="5"
+            />
+            <br /><br />
+            <b-alert show dismissible variant="info">
+              <span v-html="t('showMeta.multiselect')" />
+            </b-alert>
+          </div>
+        </b-col>
+      </b-row>
+    </b-modal>
+
+    <b-modal
+      ref="modalTopics"
+      :title="t('showMeta.editTopics')"
+      :cancel-title="t('cancel')"
+      size="lg"
+      @ok="saveArray('topicIds', topicIds, modalTopics)"
+    >
+      <b-row>
+        <b-col align="center">
+          <div>
+            <p>{{ t('showMeta.topicsLabel') }}:</p>
+            <b-form-select v-model="topicIds" multiple :options="topicChoices" :select-size="5" />
+            <br /><br />
+            <b-alert show dismissible variant="info">
+              <span v-html="t('showMeta.multiselect')" />
+            </b-alert>
+          </div>
+        </b-col>
+      </b-row>
+    </b-modal>
+
+    <b-modal
+      ref="modalMusicFocus"
+      :title="t('showMeta.editGenres')"
+      :cancel-title="t('cancel')"
+      size="lg"
+      @ok="saveArray('musicFocusIds', musicFocusIds, modalMusicFocus)"
+    >
+      <b-row>
+        <b-col align="center">
+          <div>
+            <p>{{ t('showMeta.genresLabel') }}:</p>
+            <b-form-select
+              v-model="musicFocusIds"
+              multiple
+              :options="musicFocusChoices"
+              :select-size="5"
+            />
+            <br /><br />
+            <b-alert show dismissible variant="info">
+              <span v-html="t('showMeta.multiselect')" />
+            </b-alert>
+          </div>
+        </b-col>
+      </b-row>
+    </b-modal>
+
+    <b-modal
+      ref="modalLanguages"
+      :title="t('showMeta.editLanguages')"
+      :cancel-title="t('cancel')"
+      size="lg"
+      @ok="saveArray('languageIds', languageIds, modalLanguages)"
+    >
+      <b-row>
+        <b-col align="center">
+          <div>
+            <p>{{ t('showMeta.languagesLabel') }}:</p>
+            <b-form-select
+              v-model="languageIds"
+              multiple
+              :options="languageChoices"
+              :select-size="5"
+            />
+            <br /><br />
+            <b-alert show dismissible variant="info">
+              <span v-html="t('showMeta.multiselect')" />
+            </b-alert>
+          </div>
+        </b-col>
+      </b-row>
+    </b-modal>
+
+    <b-modal
+      ref="modalHosts"
+      :title="t('showMeta.hosts')"
+      :cancel-title="t('cancel')"
+      size="lg"
+      @ok="saveArray('hostIds', hostIds, modalHosts)"
+    >
+      <b-row>
+        <b-col align="center">
+          <div>
+            <p>{{ t('showMeta.hostsLabel') }}</p>
+            <b-form-select v-model="hostIds" multiple :options="hostChoices" :select-size="5" />
+            <br /><br />
+            <b-alert show dismissible variant="info">
+              <span v-html="t('showMeta.multiselect')" />
+            </b-alert>
+          </div>
+        </b-col>
+      </b-row>
+    </b-modal>
+  </Teleport>
 </template>
 
-<script>
-import { mapGetters } from 'vuex'
-import { mapStores } from 'pinia'
-import { useAuthStore } from '@/stores/auth'
-
-function mapChoices({ id, isActive, name }) {
-  return { value: id, text: name, disabled: !isActive }
-}
-
-function getUsedObjects(objectIds, objects) {
-  const idMap = Object.fromEntries(objects.map((obj) => [obj.id, obj]))
-  return objectIds.map((id) => idMap[id])
-}
-
-export default {
-  data() {
-    return {
-      array: [],
-    }
-  },
-  computed: {
-    ...mapStores(useAuthStore),
-    shows() {
-      return this.$store.state.shows.shows
-    },
-    isSuperuser() {
-      return this.authStore.isSuperuser
-    },
-    loaded() {
-      return {
-        shows: this.$store.state.shows.loaded.shows,
-        types: this.$store.state.shows.loaded.types,
-        fundingCategories: this.$store.state.shows.loaded.fundingCategories,
-        owners: false,
-        categories: this.$store.state.shows.loaded.categories,
-        topics: this.$store.state.shows.loaded.topics,
-        musicFocus: this.$store.state.shows.loaded.musicFocus,
-        languages: this.$store.state.shows.loaded.languages,
-        hosts: this.$store.state.shows.loaded.hosts,
-      }
-    },
-    categorySelector() {
-      return this.categories.map(mapChoices)
-    },
-    categoryNames() {
-      return getUsedObjects(this.selectedShow.categoryIds, this.categories)
-    },
-    topicSelector() {
-      return this.topics.map(mapChoices)
-    },
-    topicNames() {
-      return getUsedObjects(this.selectedShow.topicIds, this.topics)
-    },
-    musicFocusSelector() {
-      return this.musicFocus.map(mapChoices)
-    },
-    musicFocusNames() {
-      return getUsedObjects(this.selectedShow.musicFocusIds, this.musicFocus)
-    },
-    languageSelector() {
-      return this.languages.map(mapChoices)
-    },
-    languageNames() {
-      return getUsedObjects(this.selectedShow.languageIds, this.languages)
-    },
-    hostSelector() {
-      return this.hosts.map(mapChoices)
-    },
-    hostNames() {
-      return getUsedObjects(this.selectedShow.hostIds, this.hosts)
-    },
-    ...mapGetters({
-      selectedShow: 'shows/selectedShow',
-      categories: 'shows/categories',
-      topics: 'shows/topics',
-      musicFocus: 'shows/musicFocus',
-      languages: 'shows/languages',
-      hosts: 'shows/hosts',
-    }),
-  },
-  methods: {
-    openModalCategories() {
-      this.array = this.selectedShow.categoryIds
-      this.$refs.modalCategories.show()
-    },
-    openModalTopics() {
-      this.array = this.selectedShow.topicIds
-      this.$refs.modalTopics.show()
-    },
-    openModalMusicFocus() {
-      this.array = this.selectedShow.musicFocusIds
-      this.$refs.modalMusicFocus.show()
-    },
-    openModalLanguages() {
-      this.array = this.selectedShow.languageIds
-      this.$refs.modalLanguages.show()
-    },
-    openModalHosts() {
-      this.array = this.selectedShow.hostIds
-      this.$refs.modalHosts.show()
-    },
-
-    saveArray(property, value, modal, event) {
-      if (
-        value.length !== this.selectedShow[property].length ||
-        !value.every((value, index) => value === this.selectedShow[property][index])
-      ) {
-        event.preventDefault()
-        this.$store.dispatch('shows/updateProperty', {
-          id: this.selectedShow.id,
-          property: property,
-          value: value,
-          callback: () => {
-            modal.hide()
-          },
-        })
-      }
-    },
-
-    saveCategories(event) {
-      this.saveArray('categoryIds', this.array, this.$refs.modalCategories, event)
-    },
-    saveTopics(event) {
-      this.saveArray('topicIds', this.array, this.$refs.modalTopics, event)
-    },
-    saveMusicFocus(event) {
-      this.saveArray('musicFocusIds', this.array, this.$refs.modalMusicFocus, event)
-    },
-    saveLanguages(event) {
-      this.saveArray('languageIds', this.array, this.$refs.modalLanguages, event)
-    },
-    saveHosts(event) {
-      this.saveArray('hostIds', this.array, this.$refs.modalHosts, event)
-    },
-  },
+<script setup lang="ts">
+import { useStore } from 'vuex'
+import { computed, ref } from 'vue'
+import { Category, Language, MusicFocus, Topic } from '@/types'
+import { useCopy, useSelectedShow } from '@/util'
+import { useHostStore } from '@/stores/hosts'
+import FormGroup from '@/components/generic/FormGroup.vue'
+import { useI18n } from '@/i18n'
+import Tag from '@/components/generic/Tag.vue'
+import { useItems } from './helper'
+
+const { t } = useI18n()
+const store = useStore()
+const { items: hosts } = useHostStore()
+const selectedShow = useSelectedShow()
+const categories = computed<Category[]>(() => store.state.shows.categories)
+const topics = computed<Topic[]>(() => store.state.shows.topics)
+const musicFocuses = computed<MusicFocus[]>(() => store.state.shows.musicFocus)
+const languages = computed<Language[]>(() => store.state.shows.languages)
+const { choices: categoryChoices, usedItems: usedCategories } = useItems(
+  categories,
+  computed(() => selectedShow.value.categoryIds),
+)
+const { choices: topicChoices, usedItems: usedTopics } = useItems(
+  topics,
+  computed(() => selectedShow.value.topicIds),
+)
+const { choices: musicFocusChoices, usedItems: usedMusicFocuses } = useItems(
+  musicFocuses,
+  computed(() => selectedShow.value.musicFocusIds),
+)
+const { choices: languageChoices, usedItems: usedLanguages } = useItems(
+  languages,
+  computed(() => selectedShow.value.languageIds),
+)
+const { choices: hostChoices, usedItems: usedHosts } = useItems(
+  computed(() => hosts),
+  computed(() => selectedShow.value.hostIds),
+)
+
+const categoryIds = useCopy(computed(() => selectedShow.value.categoryIds))
+const topicIds = useCopy(computed(() => selectedShow.value.topicIds))
+const musicFocusIds = useCopy(computed(() => selectedShow.value.musicFocusIds))
+const languageIds = useCopy(computed(() => selectedShow.value.languageIds))
+const hostIds = useCopy(computed(() => selectedShow.value.hostIds))
+const modalCategories = ref()
+const modalTopics = ref()
+const modalMusicFocus = ref()
+const modalLanguages = ref()
+const modalHosts = ref()
+
+async function saveArray(
+  property: 'categoryIds' | 'topicIds' | 'musicFocusIds' | 'languageIds' | 'hostIds',
+  value: number[],
+  modal: { hide: () => void },
+) {
+  if (
+    value.length !== selectedShow.value[property].length ||
+    !value.every((value, index) => value === selectedShow.value[property][index])
+  ) {
+    await store.dispatch('shows/updateProperty', {
+      id: selectedShow.value.id,
+      property: property,
+      value: value,
+    })
+    modal.hide()
+  }
 }
 </script>
diff --git a/src/components/shows/MetaImages.vue b/src/components/shows/MetaImages.vue
index 2e83e2b1c7f3c45a7e74d168f5c5481f36546842..17dc437db97b8bfd621595a5d0f212dfdcaf3500 100644
--- a/src/components/shows/MetaImages.vue
+++ b/src/components/shows/MetaImages.vue
@@ -1,17 +1,15 @@
 <template>
-  <div class="tw-flex tw-gap-6">
-    <FormGroup :label="t('showMeta.logo')" custom-control>
-      <template #default="attrs">
-        <ImagePicker v-model="logoId" v-bind="attrs" />
-      </template>
-    </FormGroup>
+  <FormGroup :label="t('showMeta.logo')" custom-control>
+    <template #default="attrs">
+      <ImagePicker v-model="logoId" v-bind="attrs" />
+    </template>
+  </FormGroup>
 
-    <FormGroup :label="t('showMeta.image')" custom-control>
-      <template #default="attrs">
-        <ImagePicker v-model="imageId" v-bind="attrs" />
-      </template>
-    </FormGroup>
-  </div>
+  <FormGroup :label="t('showMeta.image')" custom-control>
+    <template #default="attrs">
+      <ImagePicker v-model="imageId" v-bind="attrs" />
+    </template>
+  </FormGroup>
 </template>
 
 <script lang="ts" setup>
diff --git a/src/components/shows/MetaOwners.vue b/src/components/shows/MetaOwners.vue
index 20925ceb129979b11ff58ccc7f92e3ea8be2a6b0..8f23c9a3fcec8c5f6dba1655f5c260ac76924290 100644
--- a/src/components/shows/MetaOwners.vue
+++ b/src/components/shows/MetaOwners.vue
@@ -1,69 +1,49 @@
 <template>
-  <div>
-    <hr v-if="isSuperuser" />
-    <b-row v-if="isSuperuser">
-      <b-col lg="3">
-        <b-badge class="tw-w-10/12 tw-mr-1"> {{ $t('showMeta.owners') }}: </b-badge>
-        <img
-          src="/assets/edit.svg"
-          class="tw-w-4 tw-cursor-pointer"
-          :alt="$t('showMeta.editOwners')"
-          @click="openModalOwners()"
-        />
-      </b-col>
-      <b-col lg="9">
-        <div v-if="users.length > 0">
-          <p v-if="selectedShow.ownerIds.length === 0">
-            <small
-              ><i>{{ $t('showMeta.owners') }}</i></small
-            >
-          </p>
-          <!-- TODO: make link on name; when user clicks, open modal to edit host -->
-          <ul v-else>
-            <li v-for="owner in ownerDetails" :key="owner.id">
-              {{ owner.firstName }} {{ owner.lastName }}
+  <FormGroup
+    :label="t('showMeta.owners')"
+    :with-edit-button="isSuperuser"
+    @edit="() => modalOwners.show()"
+  >
+    <div class="tw-flex tw-flex-wrap tw-gap-2">
+      <Tag v-for="user in owners" :key="user.id">
+        <span>
+          <span class="tw-block empty:tw-hidden">{{
+            `${user.firstName} ${user.lastName}`.trim()
+          }}</span>
+          <span :class="{ 'tw-text-xs': user.firstName || user.lastName }">
+            {{ user.username }}
+          </span>
+        </span>
+      </Tag>
+    </div>
+  </FormGroup>
 
-              <b-badge variant="light"> {{ $t('showMeta.username') }}: </b-badge>
-              <small>{{ owner.username }}</small>
-
-              <span v-if="owner.email.length > 0">
-                <br />
-                <b-badge variant="light"> {{ $t('showMeta.email') }}: </b-badge>
-                <small>{{ owner.email }}</small>
-              </span>
-            </li>
-          </ul>
-        </div>
-        <div v-else><img src="/assets/radio.gif" height="24px" :alt="$t('loading')" /><br /></div>
-      </b-col>
-    </b-row>
-    <hr v-if="isSuperuser" />
-
-    <!-- Modal to edit show owners -->
+  <Teleport to="body">
     <b-modal
       ref="modalOwners"
-      :title="$t('showMeta.editOwners')"
-      :cancel-title="$t('cancel')"
+      :title="t('showMeta.editOwners')"
+      :cancel-title="t('cancel')"
       size="lg"
       @ok="saveOwners"
     >
       <div v-if="users.length > 0">
         <p>
           <span
-            v-html="$t('showMeta.usersWithAccess', { show: sanitizeHTML(selectedShow.name) })"
+            v-html="t('showMeta.usersWithAccess', { show: sanitizeHTML(selectedShow.name) })"
           />:
         </p>
-        <div v-if="owners.length === 0">
+        <div v-if="localOwners.length === 0">
           <ul>
             <li>
-              <small
-                ><i>{{ $t('noneSetFeminine') }}</i></small
-              >
+              <small>
+                <i>{{ t('noneSetFeminine') }}</i>
+              </small>
             </li>
           </ul>
         </div>
+
         <div v-else>
-          <b-table striped hover :items="owners" :fields="ownersTableFields">
+          <b-table striped hover :items="localOwners" :fields="ownersTableFields">
             <template #cell(name)="data">
               {{ data.item.firstName }} {{ data.item.lastName }}
             </template>
@@ -74,8 +54,8 @@
               <small>{{ data.value }}</small>
             </template>
             <template #cell(options)="data">
-              <b-button variant="danger" size="sm" @click="deleteOwner(data.item.id)">
-                {{ $t('delete') }}
+              <b-button variant="danger" size="sm" @click="localOwnerIds.delete(data.item.id)">
+                {{ t('delete') }}
               </b-button>
             </template>
           </b-table>
@@ -83,12 +63,8 @@
 
         <hr />
 
-        <!-- TODO: as the list of users might be quite large we need some sort
-                  of pagination and/or a search box. also those users who already have
-                  access should not be listed (or at least the add button should be disabled)
-                -->
-        <p>{{ $t('showMeta.addNewUsers') }}:</p>
-        <b-table striped hover :items="users" :fields="ownersTableFields">
+        <p>{{ t('showMeta.addNewUsers') }}:</p>
+        <b-table striped hover :items="nonAddedUsers" :fields="ownersTableFields">
           <template #cell(name)="data">
             {{ data.item.firstName }} {{ data.item.lastName }}
           </template>
@@ -99,108 +75,65 @@
             <small>{{ data.value }}</small>
           </template>
           <template #cell(options)="data">
-            <b-button variant="success" size="sm" @click="addOwner(data.item.id)">
-              {{ $t('add') }}
+            <b-button variant="success" size="sm" @click="localOwnerIds.add(data.item.id)">
+              {{ t('add') }}
             </b-button>
           </template>
         </b-table>
       </div>
-      <div v-else>
-        <img src="/assets/radio.gif" height="32px" :alt="$t('loading')" />
-      </div>
     </b-modal>
-  </div>
+  </Teleport>
 </template>
 
-<script>
-import { mapGetters } from 'vuex'
-import { mapStores } from 'pinia'
-import { useAuthStore, useUserStore } from '@/stores/auth'
-import { sanitizeHTML } from '@/util'
+<script lang="ts" setup>
+import { computed, Ref, ref } from 'vue'
+import { useStore } from 'vuex'
+import { computedAsync } from '@vueuse/core'
+import { SteeringUser, useAuthStore, useUserStore } from '@/stores/auth'
+import { sanitizeHTML, useCopy, useSelectedShow } from '@/util'
+import FormGroup from '@/components/generic/FormGroup.vue'
+import { useI18n } from '@/i18n'
+import Tag from '@/components/generic/Tag.vue'
 
-export default {
-  data() {
-    return {
-      owners: [],
-    }
-  },
-  computed: {
-    ...mapStores(useAuthStore, useUserStore),
-    shows() {
-      return this.$store.state.shows.shows
-    },
-    isSuperuser() {
-      return this.authStore.isSuperuser
-    },
-    loaded() {
-      return {
-        owners: false,
-      }
-    },
+const { t } = useI18n()
+const { isSuperuser } = useAuthStore()
+const { retrieve: retrieveUser, items: users, list: listUsers } = useUserStore()
+const store = useStore()
+const selectedShow = useSelectedShow()
+const modalOwners = ref()
+const nonAddedUsers = computed(() => users.filter((u) => !localOwnerIds.value.has(u.id)))
+const owners = computedAsync(() => fetchUsers(selectedShow.value.ownerIds), [])
+const localOwnerIds = useCopy(
+  computed(() => selectedShow.value.ownerIds),
+  (v) => new Set(v),
+)
+const localOwners: Ref<SteeringUser[]> = computedAsync<SteeringUser[]>(
+  () => fetchUsers(Array.from(localOwnerIds.value)),
+  [],
+)
 
-    ownerDetails() {
-      const owners = []
-      for (const id of this.selectedShow.ownerIds) {
-        owners.push(this.users.find((o) => o.id === id))
-      }
-      return owners
-    },
+const ownersTableFields = computed(() => [
+  { key: 'name', label: t('showMeta.name') },
+  { key: 'username', label: t('showMeta.username') },
+  { key: 'email', label: t('showMeta.email') },
+  { key: 'options', label: t('showMeta.options') },
+])
 
-    ownersTableFields() {
-      return [
-        { key: 'name', label: this.$t('showMeta.name') },
-        { key: 'username', label: this.$t('showMeta.username') },
-        { key: 'email', label: this.$t('showMeta.email') },
-        { key: 'options', label: this.$t('showMeta.options') },
-      ]
-    },
-    users() {
-      return this.steeringUserStore.items
-    },
-    ...mapGetters({
-      selectedShow: 'shows/selectedShow',
-    }),
-  },
-  methods: {
-    sanitizeHTML,
-    openModalOwners() {
-      this.owners = []
-      for (const id of this.selectedShow.ownerIds) {
-        this.owners.push(this.users.find((o) => o.id === id))
-      }
-      this.$refs.modalOwners.show()
-    },
+listUsers()
 
-    saveOwners(event) {
-      event.preventDefault()
-      this.$store.dispatch('shows/updateProperty', {
-        id: this.selectedShow.id,
-        property: 'ownerIds',
-        value: this.owners.map(({ id }) => id),
-        callback: () => {
-          this.$refs.modalOwners.hide()
-        },
-      })
-    },
-
-    // remove an owner from the list of show owners
-    deleteOwner(id) {
-      // we only have to find the item in our array and splice it out
-      // saving is done when the saveOwners method is called
-      const i = this.owners.findIndex((o) => o.id === id)
-      this.owners.splice(i, 1)
-    },
+async function saveOwners() {
+  await store.dispatch('shows/updateProperty', {
+    id: selectedShow.value.id,
+    property: 'ownerIds',
+    value: Array.from(localOwnerIds.value),
+  })
+  modalOwners.value.hide()
+}
 
-    // add a user as an owner to the list of show owners
-    addOwner(id) {
-      // we only have to push the user object to our owners array, if it is
-      // not already there. saving is done by the saveOwners method.
-      if (this.owners.findIndex((o) => o.id === id) >= 0) {
-        alert(this.$t('showMeta.accessAlreadyGiven'))
-      } else {
-        this.owners.push(this.users.find((u) => u.id === id))
-      }
-    },
-  },
+async function fetchUsers(userIds: number[]) {
+  const owners = await Promise.all(
+    userIds.map((id) => retrieveUser(id, undefined, { useCached: true })),
+  )
+  return owners.filter((u) => u !== null) as SteeringUser[]
 }
 </script>
diff --git a/src/components/shows/MetaSimpleTypes.vue b/src/components/shows/MetaSimpleTypes.vue
index b276514b3c2de13987208a82daca496579848cad..d3beef5848f26a1928e2cc4f9f89a4215b3ac999 100644
--- a/src/components/shows/MetaSimpleTypes.vue
+++ b/src/components/shows/MetaSimpleTypes.vue
@@ -1,429 +1,164 @@
 <template>
-  <div>
-    <b-row>
-      <b-col lg="6">
-        <p>
-          <b-badge variant="light"> {{ $t('showMeta.email') }}: </b-badge>
-          <span v-if="selectedShow.email === null"
-            ><small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            ></span
-          >
-          <span v-else>{{ selectedShow.email }}</span>
-          <img
-            src="/assets/edit.svg"
-            class="tw-w-4 tw-cursor-pointer"
-            :alt="$t('showMeta.editEmail')"
-            @click="openModalEmail()"
-          />
-        </p>
-      </b-col>
-
-      <b-col lg="6">
-        <p>
-          <b-badge variant="light">
-            {{ $t('showMeta.type') }}
-          </b-badge>
-          <!-- TODO: discuss: should this be visible to show owners or only to administrators? -->
-          <span v-if="loaded.types">
-            <span v-if="selectedShow.typeId === null"
-              ><small
-                ><i>{{ $t('noneSetFeminine') }}</i></small
-              ></span
-            >
-            <span v-else>{{ type }}</span>
-            <img
-              src="/assets/edit.svg"
-              class="tw-w-4 tw-cursor-pointer"
-              :alt="$t('showMeta.editType')"
-              @click="openModalType()"
-            />
-          </span>
-
-          <span v-else><img src="/assets/radio.gif" height="24px" alt="loading data" /></span>
-        </p>
-      </b-col>
-
-      <b-col lg="6">
-        <p>
-          <b-badge variant="light">
-            {{ $t('showMeta.fundingCategory') }} {{ $t('showMeta.fundingCategoryRtr') }}
-          </b-badge>
-          <!-- TODO: discuss: should this be visible to show owners or only to administrators? -->
-          <span v-if="loaded.fundingCategories">
-            <span v-if="fundingCategory === null"
-              ><small
-                ><i>{{ $t('noneSetFeminine') }}</i></small
-              ></span
-            >
-            <span v-else>{{ fundingCategory }}</span>
-            <img
-              src="/assets/edit.svg"
-              class="tw-w-4 tw-cursor-pointer"
-              :alt="$t('showMeta.editFundingCategory')"
-              @click="openModalFundingCategory()"
-            />
-          </span>
-          <span v-else><img src="/assets/radio.gif" height="24px" alt="loading data" /></span>
-        </p>
-      </b-col>
-
-      <b-col lg="6">
-        <p>
-          <!-- TODO: discuss: should this be visible to show owners or only to administrators? -->
-          <!-- TODO: fetch name for predecessor from steering api -->
-          <b-badge variant="light"> {{ $t('showMeta.predecessor') }}: </b-badge>
-          <span v-if="selectedShow.predecessorId === null"
-            ><small
-              ><i>{{ $t('showMeta.noPredecessor') }}</i></small
-            ></span
-          >
-          <span v-else>{{ predecessorName }}</span>
-          <img
-            src="/assets/edit.svg"
-            class="tw-w-4 tw-cursor-pointer"
-            :alt="$t('showMeta.editPredecessor')"
-            @click="openModalPredecessor()"
-          />
-        </p>
-      </b-col>
-
-      <b-col lg="6">
-        <p>
-          <b-badge variant="light"> {{ $t('showMeta.cbaSeriesId') }}: </b-badge>
-          <span v-if="selectedShow.cbaSeriesId === null"
-            ><small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            ></span
-          >
-          <span v-else>{{ selectedShow.cbaSeriesId }}</span>
-          <img
-            src="/assets/edit.svg"
-            class="tw-w-4 tw-cursor-pointer"
-            :alt="$t('showMeta.editCbaSeriesId')"
-            @click="openModalCBAid()"
-          />
-        </p>
-      </b-col>
-
-      <b-col lg="6">
-        <p>
-          <b-badge variant="light"> {{ $t('showMeta.defaultPlaylistId') }}: </b-badge>
-          <span v-if="!selectedShow.defaultPlaylistId"
-            ><small
-              ><i>{{ $t('noneSetFeminine') }}</i></small
-            ></span
-          >
-          <span v-else>{{ fallbackInfo }}</span>
-          <img
-            src="/assets/edit.svg"
-            class="tw-w-4 tw-cursor-pointer"
-            :alt="$t('showMeta.editDefaultPlaylistId')"
-            @click="openModalFallback()"
-          />
-        </p>
-      </b-col>
-    </b-row>
-
-    <b-modal
-      ref="modalEmail"
-      :title="$t('showMeta.editEmail')"
-      :cancel-title="$t('cancel')"
-      size="lg"
-      @ok="
-        (modalEvt) => {
-          modalEvt.preventDefault()
-          saveEmail()
-        }
-      "
-    >
-      <form ref="emailForm" @submit.stop.prevent="saveEmail">
-        <b-form-group
-          :state="emailState"
-          :label="$t('showMeta.email')"
-          label-for="show-email"
-          :invalid-feedback="$t('showMeta.invalidEmail')"
-        >
-          <b-form-input
-            id="show-email"
-            v-model="email"
-            type="email"
-            :state="emailState"
-            :placeholder="$t('showMeta.emailPlaceholder')"
-          />
-        </b-form-group>
-      </form>
-    </b-modal>
-
-    <b-modal
-      ref="modalCBAid"
-      :title="$t('showMeta.editCbaSeriesId')"
-      :cancel-title="$t('cancel')"
-      size="lg"
-      @ok="saveCBAid"
-    >
-      <b-form-input
-        v-model="id"
-        :state="validId"
-        :placeholder="$t('showMeta.cbaSeriesIdPlaceholder')"
+  <FormGroup :label="t('showMeta.email')" :errors="emailErrors">
+    <template #default="attrs">
+      <input v-model="email" type="email" v-bind="attrs" @blur="save" />
+    </template>
+  </FormGroup>
+
+  <FormGroup :label="t('showMeta.type')" :errors="typeIdErrors">
+    <template #default="attrs">
+      <select v-model="typeId" v-bind="attrs" @blur="save">
+        <option
+          v-for="choice in types"
+          :key="choice.id"
+          :value="choice.id"
+          :label="choice.name"
+          :disabled="!choice.isActive"
+        />
+      </select>
+    </template>
+  </FormGroup>
+
+  <FormGroup
+    :label="`${t('showMeta.fundingCategory')} ${t('showMeta.fundingCategoryRtr')}`"
+    :errors="fundingCategoryIdErrors"
+  >
+    <template #default="attrs">
+      <select v-model="fundingCategoryId" v-bind="attrs" @blur="save">
+        <option
+          v-for="choice in fundingCategories"
+          :key="choice.id"
+          :value="choice.id"
+          :label="choice.name"
+          :disabled="!choice.isActive"
+        />
+      </select>
+    </template>
+  </FormGroup>
+
+  <FormGroup :label="t('showMeta.predecessor')" :errors="predecessorIdErrors">
+    <template #default="attrs">
+      <select v-model="predecessorId" v-bind="attrs" @blur="save">
+        <option
+          v-for="choice in shows"
+          :key="choice.id"
+          :value="choice.id"
+          :label="sanitizeHTML(choice.name)"
+          :disabled="!choice.isActive"
+        />
+      </select>
+    </template>
+  </FormGroup>
+
+  <FormGroup :label="t('showMeta.cbaSeriesId')" :errors="cbaSeriesIdErrors">
+    <template #default="attrs">
+      <input
+        v-model="cbaSeriesId"
+        type="text"
+        inputmode="numeric"
+        pattern="[0-9]+"
+        v-bind="attrs"
+        @blur="save"
       />
-    </b-modal>
-
-    <app-modalFallback ref="modalFallback" />
-
-    <b-modal
-      ref="modalPredecessor"
-      :title="$t('showMeta.editPredecessor')"
-      :cancel-title="$t('cancel')"
-      size="lg"
-      @ok="savePredecessor"
-    >
-      <b-form-select v-model="id" :options="predecessorSelector" />
-    </b-modal>
-
-    <b-modal
-      ref="modalType"
-      :title="$t('showMeta.editType')"
-      :cancel-title="$t('cancel')"
-      size="lg"
-      @ok="saveShowType"
-    >
-      <b-row>
-        <b-col align="center">
-          <div v-if="!loaded">
-            <img src="/assets/radio.gif" :alt="$t('loading')" />
-          </div>
-          <div v-else>
-            <b-form-select v-model="id" :options="typeSelector" />
-          </div>
-        </b-col>
-      </b-row>
-    </b-modal>
-
-    <b-modal
-      ref="modalFundingCategory"
-      :title="$t('showMeta.editFundingCategory')"
-      :cancel-title="$t('cancel')"
-      size="lg"
-      @ok="saveFundingCategory"
-    >
-      <b-row>
-        <b-col align="center">
-          <div v-if="!loaded">
-            <img src="/assets/radio.gif" :alt="$t('loading')" />
-          </div>
-          <div v-else>
-            <b-form-select v-model="id" :options="fundingCategorySelector" />
-          </div>
-        </b-col>
-      </b-row>
-    </b-modal>
-  </div>
+    </template>
+  </FormGroup>
+
+  <FormGroup
+    :label="t('showMeta.defaultPlaylistId')"
+    :errors="defaultPlaylistIdErrors"
+    with-edit-button
+    @edit="openDefaultPlaylistSelectorModal"
+  >
+    <template v-if="playlist">{{ playlist.description }} ({{ playlist.id }})</template>
+    <template v-else-if="isLoadingPlaylist">{{ t('loading') }}</template>
+    <template v-else>-</template>
+  </FormGroup>
+
+  <Teleport to="body">
+    <FallbackSelector ref="defaultPlaylistSelectorModal" />
+  </Teleport>
 </template>
 
-<script>
-import { mapGetters } from 'vuex'
-import modalFallback from './FallbackSelector.vue'
-import { mapStores } from 'pinia'
-import { useAuthStore } from '@/stores/auth'
-
-export default {
-  components: {
-    'app-modalFallback': modalFallback,
-  },
+<script lang="ts" setup>
+import { computed, ref } from 'vue'
+import { useStore } from 'vuex'
+import { FundingCategory, Playlist, Show, Type } from '@/types'
+import { useI18n } from '@/i18n'
+import { sanitizeHTML, useCopy, useSelectedShow } from '@/util'
+import FormGroup from '@/components/generic/FormGroup.vue'
+import { useAPIObject, useServerFieldErrors } from '@/api'
+import { usePlaylistStore } from '@/stores/playlists'
+import FallbackSelector from '@/components/shows/FallbackSelector.vue'
+import { APIError } from '@/store/api-helper'
+
+const { t } = useI18n()
+const store = useStore()
+const selectedShow = useSelectedShow()
+const playlistStore = usePlaylistStore()
+const shows = computed<Show[]>(() => store.state.shows.shows)
+const types = computed<Type[]>(() => store.state.shows.types)
+const fundingCategories = computed<FundingCategory[]>(() => store.state.shows.fundingCategories)
+const defaultPlaylistSelectorModal = ref()
+
+const email = useCopy(computed(() => selectedShow.value.email ?? ''))
+const cbaSeriesId = useCopy(computed(() => selectedShow.value.cbaSeriesId))
+const predecessorId = useCopy(computed(() => selectedShow.value.predecessorId))
+const typeId = useCopy(computed(() => selectedShow.value.typeId))
+const fundingCategoryId = useCopy(computed(() => selectedShow.value.fundingCategoryId))
+const defaultPlaylistId = useCopy(computed(() => selectedShow.value.defaultPlaylistId))
+const { obj: playlist, isLoading: isLoadingPlaylist } = useAPIObject<Playlist>(
+  playlistStore,
+  computed(() => selectedShow.value.defaultPlaylistId),
+)
+
+const error = ref<Error>()
+const [
+  emailErrors,
+  cbaSeriesIdErrors,
+  predecessorIdErrors,
+  typeIdErrors,
+  fundingCategoryIdErrors,
+  defaultPlaylistIdErrors,
+] = useServerFieldErrors(
+  error,
+  'email',
+  'cbaSeriesId',
+  'predecessorId',
+  'typeId',
+  'fundingCategoryId',
+  'defaultPlaylistId',
+)
+
+function openDefaultPlaylistSelectorModal() {
+  defaultPlaylistSelectorModal.value.open(async (id: number | null) => {
+    defaultPlaylistId.value = id
+    await save()
+    defaultPlaylistSelectorModal.value.hide()
+  })
+}
 
-  data() {
-    return {
-      email: '',
-      url: '',
-      emailState: null,
-      urlState: null,
-      id: 0,
+async function save() {
+  const updatedShow: Show = {
+    ...selectedShow.value,
+    email: email.value,
+    cbaSeriesId: cbaSeriesId.value,
+    predecessorId: predecessorId.value,
+    typeId: typeId.value,
+    fundingCategoryId: fundingCategoryId.value,
+    defaultPlaylistId: defaultPlaylistId.value,
+  }
+
+  try {
+    await store.dispatch('shows/updateShow', {
+      id: selectedShow.value.id,
+      show: updatedShow,
+    })
+  } catch (e) {
+    if (e instanceof APIError && 'responseErrorStub' in e) {
+      error.value = e.responseErrorStub as Error
+    } else {
+      console.error('An unknown error occurred', e)
     }
-  },
-
-  computed: {
-    ...mapStores(useAuthStore),
-    shows() {
-      return this.$store.state.shows.shows
-    },
-    isSuperuser() {
-      return this.authStore.isSuperuser
-    },
-    loaded() {
-      return {
-        shows: this.$store.state.shows.loaded.shows,
-        types: this.$store.state.shows.loaded.types,
-        fundingCategories: this.$store.state.shows.loaded.fundingCategories,
-      }
-    },
-
-    typeSelector: function () {
-      return this.types.map(({ id, name }) => ({
-        value: id,
-        text: name,
-      }))
-    },
-
-    fundingCategorySelector: function () {
-      return this.fundingCategories.map(({ id, isActive, name }) => ({
-        value: id,
-        text: name,
-        disabled: !isActive,
-      }))
-    },
-
-    // In order to not only just show the predecessor of a show as an ID. we
-    // have to find it in our shows array to then output the predecessors name.
-    // This currently assumes that a user has access to all the predecessors
-    // of the shows as well.
-    // TODO/discuss: if all predecessor names should be accessible, independent
-    // of access rights, then we would need to load all predecessors show after
-    // loading our initial shows as well.
-    predecessorName: function () {
-      for (const show of this.shows) {
-        if (show.id === this.selectedShow.predecessorId) {
-          return show.name
-        }
-      }
-      return this.$t('showMeta.noPredecessorName')
-    },
-
-    predecessorSelector: function () {
-      const options = this.shows.map(({ id, name }) => ({
-        value: id,
-        text: name,
-      }))
-      options.unshift({ value: null, text: this.$t('showMeta.noPredecessor') })
-      return options
-    },
-
-    type: function () {
-      return this.types.find((t) => t.id === this.selectedShow.typeId)?.name ?? null
-    },
-    fundingCategory: function () {
-      const { selectedShow, fundingCategories } = this
-      return fundingCategories.find((c) => c.id === selectedShow.fundingCategoryId)?.name ?? null
-    },
-
-    validId() {
-      return this.id === null || RegExp('^[0-9]*$').test(this.id)
-    },
-
-    fallbackInfo() {
-      const list = this.playlists.find((p) => p.id === this.selectedShow.defaultPlaylistId)
-      if (!list) {
-        return this.selectedShow.defaultPlaylistId
-      } else {
-        return list.description + ' (ID: ' + list.id + ')'
-      }
-    },
-
-    ...mapGetters({
-      selectedShow: 'shows/selectedShow',
-      types: 'shows/types',
-      fundingCategories: 'shows/fundingCategories',
-      playlists: 'playlists/playlists',
-    }),
-  },
-  methods: {
-    openModalEmail() {
-      this.emailState = null
-
-      if (this.selectedShow.email !== null) {
-        this.email = this.selectedShow.email
-      } else {
-        this.email = ''
-      }
-      this.$refs.modalEmail.show()
-    },
-
-    openModalCBAid() {
-      this.id = this.selectedShow.cbaSeriesId
-      this.$refs.modalCBAid.show()
-    },
-
-    openModalFallback() {
-      this.$refs.modalFallback.open(this.saveFallback)
-    },
-
-    openModalPredecessor() {
-      this.id = this.selectedShow.predecessorId
-      this.$refs.modalPredecessor.show()
-    },
-
-    openModalFundingCategory() {
-      this.id = this.selectedShow.fundingCategoryId
-      this.$refs.modalFundingCategory.show()
-    },
-
-    openModalType() {
-      this.id = this.selectedShow.typeId
-      this.$refs.modalType.show()
-    },
-
-    saveProperty(property, value, modal) {
-      if (value !== this.selectedShow[property]) {
-        this.$store.dispatch('shows/updateProperty', {
-          id: this.selectedShow.id,
-          property: property,
-          value: value,
-          callback: () => {
-            modal.hide()
-          },
-          callbackCancel: (error) => {
-            if (error.data.email) {
-              this.emailState = false
-            }
-          },
-        })
-      }
-    },
-
-    saveEmail() {
-      const valid = this.$refs.emailForm.checkValidity()
-      this.emailState = valid
-
-      if (!valid) {
-        return
-      }
-
-      this.saveProperty('email', this.email, this.$refs.modalEmail)
-    },
-
-    saveCBAid(event) {
-      if (this.validId) {
-        const id = this.id === '' ? null : this.id
-        this.saveProperty('cbaSeriesId', id, this.$refs.modalCBAid, event)
-      } else {
-        event.preventDefault()
-        alert(this.$t('showMeta.invalidCbaSeriesId'))
-      }
-    },
-
-    saveFallback(id) {
-      this.saveProperty('defaultPlaylistId', id, this.$refs.modalFallback)
-      this.$log.debug(this.playlists)
-    },
-
-    savePredecessor(event) {
-      const id = this.id === '' ? null : this.id
-      this.saveProperty('predecessorId', id, this.$refs.modalPredecessor, event)
-    },
-
-    saveFundingCategory(event) {
-      this.saveProperty('fundingCategoryId', this.id, this.$refs.modalFundingCategory, event)
-    },
-
-    saveShowType(event) {
-      this.saveProperty('typeId', this.id, this.$refs.modalType, event)
-    },
-
-    // Just a placeholder function we can use in the UI, to signal if something
-    // is not yet implemented
-    notYetImplemented: function () {
-      alert(this.$t('unimplemented'))
-    },
-  },
+  }
 }
 </script>
diff --git a/src/components/shows/helper.ts b/src/components/shows/helper.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ccb33824d8ac34d6ef08b679a9d348c0be768ce9
--- /dev/null
+++ b/src/components/shows/helper.ts
@@ -0,0 +1,20 @@
+import { computed, Ref } from 'vue'
+import { ID } from '@/api'
+
+export function useItems<T extends { id: ID; isActive: boolean; name: string }>(
+  objects: Ref<T[]>,
+  usedIds: Ref<ID | undefined | null | ID[]>,
+) {
+  const idMap = computed(() => new Map(objects.value.map((obj) => [obj.id, obj])))
+  const choices = computed(() =>
+    objects.value.map((obj) => ({ value: obj.id, text: obj.name, disabled: !obj.isActive })),
+  )
+  const usedItems = computed(() => {
+    const value = usedIds.value
+    return (Array.isArray(value) ? value : value ? [value] : [])
+      .filter((id) => idMap.value.has(id))
+      .map((id) => idMap.value.get(id) as T)
+  })
+  const usedItem = computed(() => usedItems.value[0])
+  return { choices, usedItem, usedItems }
+}
diff --git a/src/store/modules/shows.js b/src/store/modules/shows.js
index 5d188aae32527b50a02877497705f7e58d91fcd6..9a9296bc5047a1322d685955eeb6ebbe33d18822 100644
--- a/src/store/modules/shows.js
+++ b/src/store/modules/shows.js
@@ -100,6 +100,15 @@ const mutations = {
   setShows(state, shows) {
     state.shows = shows
   },
+  replaceShow(state, show) {
+    const index = state.shows.findIndex(({ id }) => id === show.id)
+    if (index !== -1) {
+      state.shows.splice(index, 1, show)
+    } else {
+      state.shows.push(show)
+    }
+    state.shows.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase())
+  },
   addShow(state, show) {
     state.shows.push(show)
     state.shows.sort((a, b) => a.name.toLowerCase() > b.name.toLowerCase())
@@ -183,7 +192,6 @@ const actions = {
       })
       .catch((error) => {
         handleApiError(this, error, 'could not load shows')
-        console.error(error)
         if (data && typeof data.callbackCancel === 'function') {
           data.callbackCancel()
         }
@@ -342,6 +350,12 @@ const actions = {
     return axios
       .put(uri, data.show)
       .then((response) => {
+        if (!data.callback) {
+          // `updateProperty` does commit calls in its callback,
+          // so we don’t commit changes, if the old callback syntax
+          // is used as it’s not used anywhere else.
+          ctx.commit('replaceShow', response.data)
+        }
         return callOrReturn(response, data?.callback)
       })
       .catch((error) => {
diff --git a/src/types.ts b/src/types.ts
index 3b7663ed4e83b94dbb45e941bd095f3781c48276..8102130c285340430338d382fb1a4a722d213b11 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -21,3 +21,9 @@ export type Show = Required<steeringComponents['schemas']['Show']>
 export type Playlist = Required<tankComponents['schemas']['store.Playlist']>
 export type Schedule = Required<steeringComponents['schemas']['Schedule']>
 export type Host = Required<steeringComponents['schemas']['Host']>
+export type Category = Required<steeringComponents['schemas']['Category']>
+export type FundingCategory = Required<steeringComponents['schemas']['FundingCategory']>
+export type Language = Required<steeringComponents['schemas']['Language']>
+export type MusicFocus = Required<steeringComponents['schemas']['MusicFocus']>
+export type Type = Required<steeringComponents['schemas']['Type']>
+export type Topic = Required<steeringComponents['schemas']['Topic']>
diff --git a/src/util.ts b/src/util.ts
index 435a343a36a3d256a38e2c08ca360e8a0f66f66f..b29cd59fbea51941a71039c81bc3289c90e2bf8c 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -60,6 +60,15 @@ export function useUpdatableState<T>(
   return localRef
 }
 
+export function useCopy<T, R = T>(externalStateRef: Ref<T>, transform?: (value: T) => R) {
+  const _transform = transform ?? ((v: T) => v as unknown as R)
+  const localRef = shallowRef<R>(_transform(externalStateRef.value))
+  watch(externalStateRef, (newValue) => {
+    localRef.value = _transform(newValue)
+  })
+  return localRef
+}
+
 export function useFormattedISODate(date: Ref<Date>) {
   return computed({
     get() {