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() {