funkwhale/front/src/components/audio/ChannelForm.vue

389 lines
12 KiB
Vue

<script setup lang="ts">
import type { ContentCategory, Channel, BackendError } from '~/types'
import type { paths } from '~/generated/types'
import { slugify } from 'transliteration'
import { reactive, computed, ref, watchEffect, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import axios from 'axios'
import AttachmentInput from '~/components/common/AttachmentInput.vue'
import Layout from '~/components/ui/Layout.vue'
import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Textarea from '~/components/ui/Textarea.vue'
import Pills from '~/components/ui/Pills.vue'
interface Props {
object?: Channel | null
step?: number
}
const emit = defineEmits<{
category: [contentCategory: ContentCategory]
submittable: [value: boolean]
loading: [value: boolean]
errored: [errors: string[]]
created: [channel: Channel]
updated: [channel: Channel]
}>()
const props = withDefaults(defineProps<Props>(), {
object: null,
step: 1
})
const { t } = useI18n()
const newValues = reactive({
name: props.object?.artist?.name ?? '',
username: props.object?.actor.preferred_username ?? '',
tags: props.object?.artist?.tags ?? [] as string[],
description: props.object?.artist?.description?.text ?? '',
cover: props.object?.artist?.cover?.uuid ?? null,
content_category: props.object?.artist?.content_category ?? 'podcast',
metadata: { ...(props.object?.metadata ?? {}) } as Channel['metadata']
})
// If props has an object, then this form edits, else it creates
// TODO: rename to `process : 'creating' | 'editing'`
const creating = computed(() => props.object === null)
const categoryChoices = computed(() => [
{
value: 'podcast',
label: t('components.audio.ChannelForm.label.podcast'),
helpText: t('components.audio.ChannelForm.help.podcast')
},
{
value: 'music',
label: t('components.audio.ChannelForm.label.discography'),
helpText: t('components.audio.ChannelForm.help.discography')
}
])
interface ITunesCategory {
value: string
label: string
children: []
}
interface MetadataChoices {
itunes_category?: ITunesCategory[] | null
language: {
value: string
label: string
}[]
}
const metadataChoices = ref({ itunes_category: null } as MetadataChoices)
const itunesSubcategories = computed(() => {
for (const element of metadataChoices.value.itunes_category ?? []) {
// TODO: Backend: Define schema for `metadata` field
// @ts-expect-error No types defined by backend schema for `metadata` field
if (element.value === newValues.metadata.itunes_category) {
return element.children ?? []
}
}
return []
})
const labels = computed(() => ({
namePlaceholder: t('components.audio.ChannelForm.placeholder.name'),
usernamePlaceholder: t('components.audio.ChannelForm.placeholder.username')
}))
const submittable = computed(() => !!(
newValues.content_category === 'podcast'
// @ts-expect-error No types defined by backend schema for `metadata` field
? newValues.name && newValues.username && newValues.metadata.itunes_category && newValues.metadata.language
: newValues.name && newValues.username
))
watch(() => newValues.name, (name) => {
if (creating.value) {
newValues.username = slugify(name)
}
})
// @ts-expect-error No types defined by backend schema for `metadata` field
watch(() => newValues.metadata.itunes_category, () => {
// @ts-expect-error No types defined by backend schema for `metadata` field
newValues.metadata.itunes_subcategory = null
})
const isLoading = ref(false)
const errors = ref([] as string[])
// @ts-expect-error Re-check emits
watchEffect(() => emit('category', newValues.content_category))
watchEffect(() => emit('loading', isLoading.value))
watchEffect(() => emit('submittable', submittable.value))
// TODO (wvffle): Add loader / Use Suspense
const fetchMetadataChoices = async () => {
try {
const response = await axios.get<paths['/api/v2/channels/metadata-choices/']['get']['responses']['200']['content']['application/json']>('channels/metadata-choices/')
// TODO: Fix schema generation so we don't need to typecast here!
metadataChoices.value = response.data as unknown as MetadataChoices
} catch (error) {
errors.value = (error as BackendError).backendErrors
}
}
fetchMetadataChoices()
const submit = async () => {
isLoading.value = true
const payload = {
...newValues,
description: newValues.description
? {
content_type: 'text/markdown',
text: newValues.description
}
: null
}
try {
const request = () => creating.value
? axios.post('channels/', payload)
: axios.patch(`channels/${props.object?.uuid}`, payload)
const response = await request()
if (creating.value) emit('created', response.data)
else emit('updated', response.data)
} catch (error) {
errors.value = (error as BackendError).backendErrors
emit('errored', errors.value)
}
isLoading.value = false
}
defineExpose({
submit
})
</script>
<template>
<Layout
form
class="ui form"
@submit.prevent.stop="submit"
>
<Alert
v-if="errors.length > 0"
red
>
<h4 class="header">
{{ t('components.audio.ChannelForm.header.error') }}
</h4>
<ul class="list">
<li
v-for="(error, key) in errors"
:key="key"
>
{{ error }}
</li>
</ul>
</Alert>
<template v-if="metadataChoices">
<fieldset
v-if="creating && step === 1"
class="ui grouped channel-type required field"
>
<legend>
{{ t('components.audio.ChannelForm.legend.purpose') }}
</legend>
<div class="ui hidden divider" />
<div class="field">
<div
v-for="(choice, key) in categoryChoices"
:key="key"
:class="['ui', 'radio', 'checkbox', {selected: choice.value == newValues.content_category}]"
>
<input
:id="`category-${choice.value}`"
v-model="newValues.content_category"
type="radio"
name="channel-category"
:value="choice.value"
>
<label :for="`category-${choice.value}`">
<span :class="['right floated', 'placeholder', 'image', 'shifted', {circular: choice.value === 'music'}]" />
<strong>{{ choice.label }}</strong>
<div class="ui small hidden divider" />
{{ choice.helpText }}
</label>
</div>
</div>
</fieldset>
<template v-if="!creating || step === 2">
<div class="ui required field">
<Input
v-model="newValues.name"
type="text"
required
:placeholder="labels.namePlaceholder"
:label="t('components.audio.ChannelForm.label.name')"
/>
</div>
<div class="ui required field">
<Input
v-model="newValues.username"
type="text"
:required="creating"
:disabled="!creating"
:placeholder="labels.usernamePlaceholder"
:label="t('components.audio.ChannelForm.label.username')"
/>
<template v-if="creating">
<div class="ui small hidden divider" />
<p>
{{ t('components.audio.ChannelForm.help.username') }}
</p>
</template>
</div>
<div class="six wide column">
<attachment-input
v-model="newValues.cover"
:image-class="newValues.content_category === 'podcast' ? '' : 'circular'"
@delete="newValues.cover = null"
>
{{ t('components.audio.ChannelForm.label.image') }}
</attachment-input>
</div>
<Pills
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
:set="model => ({
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
})"
:label="t('components.audio.ChannelForm.label.tags')"
/>
<div
v-if="newValues.content_category === 'podcast'"
>
<label for="channel-language">
{{ t('components.audio.ChannelForm.label.language') }}
</label>
<!-- @vue-ignore -->
<select
id="channel-language"
v-model="newValues.metadata.language"
name="channel-language"
required
class="ui search selection dropdown"
>
<option
v-for="(v, key) in metadataChoices.language"
:key="key"
:value="v.value"
>
{{ v.label }}
</option>
</select>
</div>
<div class="ui field">
<Textarea
v-model="newValues.description"
:label="t('components.audio.ChannelForm.label.description')"
initial-lines="3"
/>
</div>
<template
v-if="newValues.content_category === 'podcast'"
>
<div class="ui required field">
<label for="channel-itunes-category">
{{ t('components.audio.ChannelForm.label.category') }}
</label>
<!-- @vue-ignore -->
<select
id="itunes-category"
v-model="newValues.metadata.itunes_category"
name="itunes-category"
required
class="ui dropdown"
>
<option
v-for="(v, key) in metadataChoices.itunes_category"
:key="key"
:value="v.value"
>
{{ v.label }}
</option>
</select>
</div>
<div class="ui field">
<label for="channel-itunes-category">
{{ t('components.audio.ChannelForm.label.subcategory') }}
</label>
<!-- @vue-ignore -->
<select
id="itunes-category"
v-model="newValues.metadata.itunes_subcategory"
name="itunes-category"
:disabled="!newValues.metadata.itunes_category"
class="ui dropdown"
>
<option
v-for="(v, key) in itunesSubcategories"
:key="key"
:value="v"
>
{{ v }}
</option>
</select>
</div>
</template>
<template
v-if="newValues.content_category === 'podcast'"
>
<Alert blue>
<span>
<i class="bi bi-info-circle-fill" />
{{ t('components.audio.ChannelForm.help.podcastFields') }}
</span>
</Alert>
<div class="ui field">
<!-- @vue-ignore -->
<Input
id="channel-itunes-email"
v-model="newValues.metadata.owner_email"
name="channel-itunes-email"
type="email"
:label="t('components.audio.ChannelForm.label.email')"
/>
</div>
<div class="ui field">
<!-- @vue-ignore -->
<Input
id="channel-itunes-name"
v-model="newValues.metadata.owner_name"
name="channel-itunes-name"
maxlength="255"
:label="t('components.audio.ChannelForm.label.owner')"
/>
</div>
</template>
</template>
</template>
<div
v-else
class="ui active inverted dimmer"
>
<div class="ui text loader">
{{ t('components.audio.ChannelForm.loader.loading') }}
</div>
</div>
</Layout>
</template>