diff --git a/src/Pages/Profile.vue b/src/Pages/Profile.vue new file mode 100644 index 0000000000000000000000000000000000000000..325c341a1ec36fc0edfb0bdd8064541f55276933 --- /dev/null +++ b/src/Pages/Profile.vue @@ -0,0 +1,16 @@ +<template> + <router-view v-if="host" :profile="host" /> +</template> + +<script setup lang="ts"> +import { useRoute } from 'vue-router' +import { useObjectFromStore } from '@rokoli/bnb/drf' +import { useHostStore } from '@/stores' + +const route = useRoute() +const hostStore = useHostStore() +const { obj: host } = useObjectFromStore( + () => parseInt(route.params.profileId as string), + hostStore, +) +</script> diff --git a/src/Pages/ProfileDetails.vue b/src/Pages/ProfileDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..1892721ecb47ac8fdf86a5708c4f7baf619a8459 --- /dev/null +++ b/src/Pages/ProfileDetails.vue @@ -0,0 +1,73 @@ +<template> + <PageHeader :title="profile.name" :editing-metadata="profile" /> + + <div class="tw-flex tw-gap-6 tw-items-start tw-flex-wrap"> + <ASection :title="t('profile.singular')" class="tw-w-fit tw-flex-none"> + <template #header> + <AProfileDeletionFlow :profile="profile" standalone class="tw-ml-auto" /> + </template> + + <AFieldset + class="tw-bg-white md:tw-min-w-[500px] tw-max-w-2xl" + :title="t('profileDetails.data')" + > + <AProfileEditor :profile="profile" admin-mode /> + </AFieldset> + </ASection> + + <ASection + v-if="owners.value.length > 0" + :title="t('profileDetails.assignedAccounts')" + class="tw-flex-1 md:tw-flex-none tw-w-fit" + > + <div class="tw-flex tw-gap-6 tw-items-start tw-flex-wrap"> + <template v-for="user in owners.value" :key="user.id"> + <AUserIdentity v-slot="{ identity }" :user="user"> + <AFieldset + as="form" + title-tag="h3" + :title="identity.name || identity.username" + class="tw-bg-white md:tw-min-w-[380px] tw-max-w-lg tw-flex-1" + > + <AUserEditor :user="user" /> + </AFieldset> + </AUserIdentity> + </template> + </div> + </ASection> + </div> +</template> + +<script setup lang="ts"> +import { useRelationList } from '@/form' +import { useI18n } from '@/i18n' +import { useHostStore, useUserStore } from '@/stores' +import { useBreadcrumbs } from '@/stores/nav' +import { Host } from '@/types' + +import PageHeader from '@/components/PageHeader.vue' +import AUserIdentity from '@/components/identities/AUserIdentity.vue' +import AUserEditor from '@/components/identities/AUserEditor.vue' +import AProfileEditor from '@/components/identities/AProfileEditor.vue' +import AFieldset from '@/components/generic/AFieldset.vue' +import ASection from '@/components/generic/ASection.vue' +import AProfileDeletionFlow from '@/components/identities/AProfileDeletionFlow.vue' + +const props = defineProps<{ + profile: Host +}>() + +const { t } = useI18n() +const hostStore = useHostStore() +const userStore = useUserStore() + +const owners = useRelationList(hostStore, () => props.profile, 'ownerIds', userStore) + +useBreadcrumbs(() => [ + { title: t('navigation.profiles'), route: { name: 'profiles' } }, + { + title: props.profile.name, + route: { name: 'profile', params: { profileId: props.profile.id.toString() } }, + }, +]) +</script> diff --git a/src/Pages/ProfileList.vue b/src/Pages/ProfileList.vue new file mode 100644 index 0000000000000000000000000000000000000000..d380baaf5e8816aaeef6252d0b84cf42087751a0 --- /dev/null +++ b/src/Pages/ProfileList.vue @@ -0,0 +1,80 @@ +<template> + <PageHeader :title="t('profileList.title')" /> + + <ATable + v-model:page="page" + v-model:items-per-page="profilesPerPage" + :items="result.items" + :pagination-data="result" + > + <template #tableStart> + <colgroup> + <col width="0" /> + <col width="0" /> + </colgroup> + </template> + <template #header> + <th>{{ t('profile.fields.name') }}</th> + </template> + <template #items="{ items }"> + <tr v-for="profile in items" :key="profile.id" class="tw-transition hover:tw-bg-gray-50"> + <td> + <RouterLink + :to="{ name: 'profile-details', params: { profileId: profile.id } }" + class="tw-text-inherit tw-group tw-no-underline" + > + <div class="tw-flex tw-gap-3 tw-items-center"> + <Image :image="profile.imageId" class="tw-w-12 tw-bg-gray-300 tw-rounded" square /> + <span class="tw-flex tw-flex-col tw-gap-1"> + <span class="tw-block group-hocus:tw-underline">{{ profile.name }}</span> + <span class="tw-flex tw-gap-2 tw-items-center tw-text-xs tw-leading-loose"> + <AColorBadge + class="tw-rounded-full tw-px-2" + :class="profile.isActive ? 'tw-text-emerald-700' : 'tw-text-rose-700'" + > + {{ profile.isActive ? t('active') : t('inactive') }} + </AColorBadge> + + <AColorBadge + v-if="profile.ownerIds.length > 0" + class="tw-rounded-full tw-px-2 tw-text-blue-700 tw-inline-flex tw-gap-1 tw-items-center" + > + <icon-fluent-key-20-regular class="tw-flex-none" /> + {{ t('profile.labels.hasAccount') }} + </AColorBadge> + </span> + </span> + </div> + </RouterLink> + </td> + </tr> + </template> + </ATable> +</template> +<script setup lang="ts"> +import ATable from '@/components/generic/ATable.vue' + +import { computedDebounced, useQuery } from '@/util' +import { useStorage } from '@vueuse/core' +import { ref } from 'vue' +import { usePaginatedList } from '@rokoli/bnb/drf' +import { useHostStore } from '@/stores' +import PageHeader from '@/components/PageHeader.vue' +import { useI18n } from '@/i18n' +import AColorBadge from '@/components/generic/AColorBadge.vue' +import Image from '@/components/generic/Image.vue' + +const { t } = useI18n() +const hostStore = useHostStore() + +const searchTerm = ref('') +const debouncedSearchTerm = computedDebounced(searchTerm, (q: string) => (q.trim() ? 0.3 : 0)) +const query = useQuery(() => ({ + search: debouncedSearchTerm.value.trim(), +})) +const page = ref(1) +const profilesPerPage = useStorage('aura:profileList:profilesPerPage', 12) +const { result } = usePaginatedList(hostStore.listIsolated, page, profilesPerPage, { + query, +}) +</script> diff --git a/src/components/identities/AProfileDeletionFlow.vue b/src/components/identities/AProfileDeletionFlow.vue new file mode 100644 index 0000000000000000000000000000000000000000..febbd7707543741c60733c88be9fb0d1cc4d8f50 --- /dev/null +++ b/src/components/identities/AProfileDeletionFlow.vue @@ -0,0 +1,83 @@ +<template> + <FormGroup :errors="errors" v-bind="attrs"> + <div> + <button + type="button" + class="btn tw-w-full tw-justify-center" + :class="standalone ? 'btn-danger' : 'btn-default'" + @click="confirmDeleteProfile" + > + <Loading v-if="isProcessing" class="tw-h-2" /> + {{ t('profile.editor.deletion.label') }} + </button> + <ADescription v-if="!standalone">{{ t('profile.editor.deletion.description') }}</ADescription> + </div> + </FormGroup> + + <ConfirmDeletion v-slot="{ resolve }"> + <Teleport to="body"> + <AConfirmDialog + :title="t('profile.editor.deletion.label')" + :confirm-label=" + t('profile.editor.deletion.confirm.confirmLabel', { profile: profile.name }) + " + class="tw-max-w-xl" + @confirm="resolve(true)" + @cancel="resolve(false)" + > + <SafeHTML + as="p" + :html="t('profile.editor.deletion.confirm.text', { profile: profile.name })" + sanitize-preset="inline-noninteractive" + /> + </AConfirmDialog> + </Teleport> + </ConfirmDeletion> +</template> +<script setup lang="ts"> +import { createTemplatePromise } from '@vueuse/core' +import { useRouter } from 'vue-router' +import { useErrorList } from '@rokoli/bnb/drf' +import { useAttrs } from 'vue' + +import { useAsyncFunction } from '@/util' +import { Host } from '@/types' +import { useI18n } from '@/i18n' +import { useHostStore } from '@/stores' + +import FormGroup from '@/components/generic/FormGroup.vue' +import Loading from '@/components/generic/Loading.vue' +import SafeHTML from '@/components/generic/SafeHTML' +import AConfirmDialog from '@/components/generic/AConfirmDialog.vue' +import ADescription from '@/components/generic/ADescription.vue' + +const props = defineProps<{ + profile: Host + standalone?: boolean +}>() + +const { t } = useI18n() +const router = useRouter() +const hostStore = useHostStore() +const attrs = useAttrs() + +const ConfirmDeletion = createTemplatePromise<boolean>() + +const { + fn: deleteProfile, + isProcessing, + error, +} = useAsyncFunction(() => hostStore.remove(props.profile.id)) +const errors = useErrorList(error) + +async function confirmDeleteProfile() { + if (await ConfirmDeletion.start()) { + try { + await deleteProfile() + await router.push({ name: 'profiles' }) + } catch (e) { + // error handling is done above + } + } +} +</script> diff --git a/src/components/identities/AProfileEditor.vue b/src/components/identities/AProfileEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..409002ed11bc4b04e4f0db0ee6f5aa48dc6fbe2e --- /dev/null +++ b/src/components/identities/AProfileEditor.vue @@ -0,0 +1,138 @@ +<!-- +This is an editor for the Host steering model. +--> + +<template> + <FormTable> + <FormGroup + v-slot="attrs" + :label="t('profile.fields.name')" + :errors="name.errors" + :is-saving="name.isSaving" + > + <input v-model="name.value" required v-bind="attrs" @blur="name.save()" /> + </FormGroup> + + <FormGroup + v-slot="attrs" + :label="t('profile.fields.email')" + :errors="email.errors" + :is-saving="email.isSaving" + > + <input v-model="email.value" type="email" required v-bind="attrs" @blur="email.save()" /> + </FormGroup> + + <FormGroup + v-slot="{ id, disabled }" + :label="t('profile.fields.biography')" + :errors="biography.errors" + :is-saving="biography.isSaving" + custom-control + > + <AHTMLEditor + :id="id" + v-model="biography.value" + :disabled="disabled" + @blur="biography.save()" + /> + </FormGroup> + + <FormGroup + v-slot="{ disabled }" + :label="t('profile.fields.image')" + :errors="imageId.errors" + :is-saving="imageId.isSaving" + custom-control + > + <ImagePicker v-model="imageId.value" :disabled="disabled" class="tw-flex-none tw-w-min" /> + </FormGroup> + + <FormGroup + v-slot="{ disabled }" + :label="t('profile.fields.links')" + :is-saving="links.isSaving" + :errors="links.errors.forField('links', '')" + :has-error="links.errors.length > 0" + custom-control + > + <ALinkCollectionEditor + v-model="links.value" + :error-lists="links.errors.siblings('links')" + :disabled="disabled" + allow-add + @save="links.save()" + /> + </FormGroup> + + <FormGroup + v-slot="attrs" + :label="t('profile.fields.isActive')" + :errors="isActive.errors" + :is-saving="isActive.isSaving" + custom-control + > + <label class="tw-inline-flex tw-gap-2 tw-items-center"> + <input + v-model="isActive.value" + type="checkbox" + class="tw-size-5" + :true-value="true" + :false-value="false" + v-bind="attrs" + switch + @blur="isActive.save()" + /> + {{ t('profile.fields.isActive') }} + </label> + </FormGroup> + + <template v-if="adminMode"> + <FormGroup + v-slot="{ disabled }" + :label="t('profile.fields.ownerIds')" + class="tw-order-last" + :is-saving="owners.isSaving" + :errors="owners.errors" + > + <AUserSelector v-model="owners.value" :disabled="disabled" /> + </FormGroup> + </template> + </FormTable> +</template> + +<script lang="ts" setup> +import { computed } from 'vue' + +import { useAPIObjectFieldCopy, useRelationList } from '@/form' +import { useI18n } from '@/i18n' +import { useHostStore, useUserStore } from '@/stores' +import { Host } from '@/types' + +import FormTable from '@/components/generic/FormTable.vue' +import FormGroup from '@/components/generic/FormGroup.vue' +import AHTMLEditor from '@/components/generic/AHTMLEditor.vue' +import ImagePicker from '@/components/images/ImagePicker.vue' +import ALinkCollectionEditor from '@/components/generic/ALinkCollectionEditor.vue' +import AUserSelector from '@/components/identities/AUserSelector.vue' + +const props = defineProps<{ + profile: Host + adminMode?: boolean +}>() + +const { t } = useI18n() +const hostStore = useHostStore() +const userStore = useUserStore() +const profile = computed(() => props.profile) + +const name = useAPIObjectFieldCopy(hostStore, profile, 'name', { debounce: 2 }) +const biography = useAPIObjectFieldCopy(hostStore, profile, 'biography', { noAutoSave: true }) +const email = useAPIObjectFieldCopy(hostStore, profile, 'email', { debounce: 2 }) +const imageId = useAPIObjectFieldCopy(hostStore, profile, 'imageId', { debounce: 0 }) +const links = useAPIObjectFieldCopy(hostStore, profile, 'links', { debounce: 2 }) +const isActive = useAPIObjectFieldCopy(hostStore, profile, 'isActive', { debounce: 0 }) +const owners = useRelationList(hostStore, profile, 'ownerIds', userStore, { + debounce: 2, + sortBy: ['lastName', 'firstName', 'username', 'email'], +}) +</script> diff --git a/src/components/identities/AUserCBAIdentityEditor.vue b/src/components/identities/AUserCBAIdentityEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..15563ddb17f64b894c5fa145f62e00a90fa8ca8b --- /dev/null +++ b/src/components/identities/AUserCBAIdentityEditor.vue @@ -0,0 +1,43 @@ +<template> + <fieldset class="tw-flex tw-flex-col tw-gap-2" @focusout="save()"> + <FormGroup v-slot="attrs" :label="t('user.fields.profile.cbaUsername')"> + <input v-model="name" v-bind="attrs" /> + </FormGroup> + + <FormGroup v-slot="attrs" :label="t('user.fields.profile.cbaUserToken')"> + <input v-model="token" type="password" v-bind="attrs" /> + </FormGroup> + </fieldset> +</template> + +<script lang="ts" setup> +import { SteeringUser } from '@/stores/auth' +import { useCopy } from '@/form' +import FormGroup from '@/components/generic/FormGroup.vue' +import { useI18n } from '@/i18n' + +type CBAProfileData = Pick<SteeringUser['profile'], 'cbaUsername' | 'cbaUserToken'> + +const modelValue = defineModel<CBAProfileData>({ required: true }) +const { t } = useI18n() + +const name = useCopy(() => modelValue.value?.cbaUsername ?? '', { + debounce: 0, + save: (cbaUsername) => { + save({ cbaUsername }) + }, +}) +const token = useCopy(() => modelValue.value?.cbaUserToken ?? '', { + debounce: 0, + save: (cbaUserToken) => { + save({ cbaUserToken }) + }, +}) + +function save(data?: Partial<CBAProfileData>) { + let { cbaUsername, cbaUserToken } = data ?? {} + cbaUsername = (cbaUsername ?? name.value)?.trim() + cbaUserToken = (cbaUserToken ?? token.value)?.trim() + if (cbaUsername && cbaUserToken) modelValue.value = { cbaUsername, cbaUserToken } +} +</script> diff --git a/src/components/identities/AUserEditor.vue b/src/components/identities/AUserEditor.vue new file mode 100644 index 0000000000000000000000000000000000000000..dce82b2f7143dd26029380c7954701bdaff5a748 --- /dev/null +++ b/src/components/identities/AUserEditor.vue @@ -0,0 +1,73 @@ +<template> + <FormTable> + <FormGroup v-slot="attrs" :label="t('user.fields.username')"> + <input v-model="user.username" required v-bind="attrs" disabled /> + </FormGroup> + + <FormGroup + v-slot="attrs" + :label="t('user.fields.firstName')" + :errors="firstName.errors" + :is-saving="firstName.isSaving" + > + <input v-model="firstName.value" required v-bind="attrs" @blur="firstName.save()" /> + </FormGroup> + + <FormGroup + v-slot="attrs" + :label="t('user.fields.lastName')" + :errors="lastName.errors" + :is-saving="lastName.isSaving" + > + <input v-model="lastName.value" required v-bind="attrs" @blur="lastName.save()" /> + </FormGroup> + + <FormGroup + v-slot="attrs" + :label="t('user.fields.email')" + :errors="email.errors" + :is-saving="email.isSaving" + > + <input v-model="email.value" type="email" required v-bind="attrs" @blur="email.save()" /> + </FormGroup> + + <FormGroup + v-slot="{ disabled }" + :errors="profile.errors" + :is-saving="profile.isSaving" + label="CBA" + custom-control + > + <AUserCBAIdentityEditor v-model="profile.value" :disabled="disabled" /> + </FormGroup> + </FormTable> +</template> + +<script lang="ts" setup> +import { computed } from 'vue' +import { useAPIObjectFieldCopy } from '@/form' +import { useI18n } from '@/i18n' +import { SteeringUser, useUserStore } from '@/stores/auth' + +import FormTable from '@/components/generic/FormTable.vue' +import FormGroup from '@/components/generic/FormGroup.vue' +import AUserCBAIdentityEditor from '@/components/identities/AUserCBAIdentityEditor.vue' + +const props = defineProps<{ + user: SteeringUser +}>() + +const { t } = useI18n() +const userStore = useUserStore() +const user = computed(() => props.user) + +const firstName = useAPIObjectFieldCopy(userStore, user, 'firstName', { debounce: 2 }) +const lastName = useAPIObjectFieldCopy(userStore, user, 'lastName', { debounce: 2 }) +const email = useAPIObjectFieldCopy(userStore, user, 'email', { debounce: 2 }) +const profile = useAPIObjectFieldCopy(userStore, user, 'profile', { + debounce: 0, + isEqual(v1, v2) { + return v1?.cbaUsername === v2?.cbaUsername && v1?.cbaUserToken === v2?.cbaUserToken + }, +}) +</script> diff --git a/src/components/identities/AUserIdentity.vue b/src/components/identities/AUserIdentity.vue new file mode 100644 index 0000000000000000000000000000000000000000..4b38cfda0ea495a34edbfae1688f188f49db574d --- /dev/null +++ b/src/components/identities/AUserIdentity.vue @@ -0,0 +1,13 @@ +<template> + <slot :identity="identity" /> +</template> + +<script lang="ts" setup> +import { SteeringUser } from '@/stores/auth' +import { usePersonName } from '@/util' + +const props = defineProps<{ + user: SteeringUser +}>() +const identity = usePersonName(() => props.user) +</script> diff --git a/src/components/nav/AMainNavMenu.vue b/src/components/nav/AMainNavMenu.vue index 51c2dbf833de72f3f4d4264ae8faa68683a3b2ea..f824afffcae2edc0a992c266efe8c89707717921 100644 --- a/src/components/nav/AMainNavMenu.vue +++ b/src/components/nav/AMainNavMenu.vue @@ -46,6 +46,12 @@ </ANavSubMenu> </ANavListItem> + <ANavListItem> + <ANavLink :route="{ name: 'profiles' }" active-if-child-active> + {{ t('navigation.profiles') }} + </ANavLink> + </ANavListItem> + <ANavListItem> <ANavLink :route="{ name: 'calendar' }"> {{ t('navigation.calendar') }} diff --git a/src/i18n/de.js b/src/i18n/de.js index bd96153747f15cb555c1600ee0968fef73454901..44aec4c8d1661a0dc710f11c558cf5c903551806 100644 --- a/src/i18n/de.js +++ b/src/i18n/de.js @@ -29,6 +29,34 @@ export default { otherShows: 'Weitere Sendereihen', }, + profile: { + singular: 'Profil', + plural: 'Profiles', + fields: { + name: 'Name', + email: 'Email', + biography: 'Biographie', + image: 'Bild', + links: 'Links', + isActive: 'Aktiv', + ownerIds: 'Zugewiesene Nutzer:innenkonten', + }, + labels: { + hasAccount: 'Hat Konto', + }, + editor: { + deletion: { + label: 'Profil löschen', + description: + 'Entfernt das Profil und alle bestehenden Verknüpfungen zu Sendereihen und Episoden.', + confirm: { + text: 'Das Profil wird <strong>unwiederbringlich gelöscht</strong>. Eine spätere Wiederherstellung ist nicht möglich. Bestehende Verknüpfungen zu Sendereihen und Episoden werden ebenfalls entfernt. Bist du sicher, dass du das Profil <strong>%{profile}</strong> löschen willst?', + confirmLabel: 'Lösche <strong>%{profile}</strong>', + }, + }, + }, + }, + show: { singular: 'Sendereihe', plural: 'Sendereihen', @@ -164,6 +192,10 @@ export default { loadingData: 'Lade %{items}', loading: 'Lädt..', cancel: 'Abbrechen', + yes: 'Ja', + no: 'Nein', + active: 'Aktiv', + inactive: 'Inaktiv', help: 'Hilfe', delete: 'Löschen', add: 'Hinzufügen', @@ -205,6 +237,7 @@ export default { details: 'Beschreibung', }, filesPlaylists: 'Medien', + profiles: 'Profile', calendar: 'Kalender', settings: 'Einstellungen', profile: 'Profil', @@ -212,6 +245,15 @@ export default { help: 'Hilfe', }, + profileList: { + title: 'Profile', + }, + + profileDetails: { + assignedAccounts: 'Zugewiesene Konten', + data: 'Daten', + }, + footer: { tagline: 'Alles was Du für ein Freies Radio brauchst', }, @@ -422,8 +464,14 @@ export default { user: { fields: { username: 'Benutzername', + firstName: 'Vorname', + lastName: 'Nachname', name: 'Name', email: 'E-Mail', + profile: { + cbaUsername: 'Kontoname', + cbaUserToken: 'Token', + }, }, }, diff --git a/src/i18n/en.js b/src/i18n/en.js index 47342cdaabaa9af8b8800a626d3aded2920a5232..ff507ee7bdfd0ef193fbc47bb12dde4a6dca5b0c 100644 --- a/src/i18n/en.js +++ b/src/i18n/en.js @@ -29,6 +29,33 @@ export default { otherShows: 'Other Shows', }, + profile: { + singular: 'Profile', + plural: 'Profiles', + fields: { + name: 'Name', + email: 'Email', + biography: 'Biography', + image: 'Image', + links: 'Links', + isActive: 'Active', + ownerIds: 'Assigned user accounts', + }, + labels: { + hasAccount: 'Has account', + }, + editor: { + deletion: { + label: 'Delete profile', + description: 'Removes the profile and all existing relations to shows and episodes.', + confirm: { + text: 'The profile will be <strong>irretrievably deleted</strong>. It is not possible to restore it later. Existing relations to shows and episodes will be deleted as well. Are you sure you want to delete the profile <strong>%{profile}</strong>?', + confirmLabel: 'Delete <strong>%{profile}</strong>', + }, + }, + }, + }, + show: { singular: 'Show', plural: 'Shows', @@ -163,6 +190,10 @@ export default { loadingData: 'Loading %{items}', loading: 'Loading..', cancel: 'Cancel', + yes: 'Yes', + no: 'No', + active: 'Active', + inactive: 'Inactive', help: 'Help', delete: 'Delete', new: 'New', @@ -206,6 +237,7 @@ export default { details: 'Description', }, filesPlaylists: 'Media', + profiles: 'Profiles', calendar: 'Calendar', settings: 'Settings', profile: 'Profile', @@ -213,6 +245,15 @@ export default { help: 'Help', }, + profileList: { + title: 'Profiles', + }, + + profileDetails: { + assignedAccounts: 'Assigned Accounts', + data: 'Data', + }, + footer: { tagline: 'All the UI you need to run a community radio', }, @@ -423,8 +464,14 @@ export default { user: { fields: { username: 'Username', + firstName: 'Forename', + lastName: 'Surname', name: 'Name', email: 'Email', + profile: { + cbaUsername: 'Username', + cbaUserToken: 'Token', + }, }, }, diff --git a/src/router.ts b/src/router.ts index 8741a7cc90308570b126f3de8d3b34598c0176c6..f81dd4eb98c2f7c362123d64800ef21be256f900 100644 --- a/src/router.ts +++ b/src/router.ts @@ -45,6 +45,29 @@ const routes: RouteRecordRaw[] = [ }, ], }, + { + path: '/profiles', + children: [ + { + path: '/profiles', + name: 'profiles', + component: () => import('@/Pages/ProfileList.vue'), + }, + { + path: ':profileId', + name: 'profile', + component: () => import('@/Pages/Profile.vue'), + redirect: (to) => ({ path: `/profiles/${to.params.profileId}/details` }), + children: [ + { + path: 'details', + name: 'profile-details', + component: () => import('@/Pages/ProfileDetails.vue'), + }, + ], + }, + ], + }, { path: '/calendar', name: 'calendar', component: () => import('@/Pages/Calendar.vue') }, { path: '/credits', name: 'credits', component: () => import('@/Pages/Credits.vue') }, ] diff --git a/src/steering-types.ts b/src/steering-types.ts index 5a68be04bab6766cd5c69f6933ebfd1e75551354..0ae4a268c3c61159e520652a15acd8e16693f6cb 100644 --- a/src/steering-types.ts +++ b/src/steering-types.ts @@ -468,7 +468,7 @@ export interface components { isActive?: boolean links?: components['schemas']['HostLink'][] name: string - ownerIds: (number | null)[] + ownerIds: number[] /** Format: date-time */ createdAt: string createdBy: string