Merge branch '2448-complete-tags' into 'develop'
Resolve "Tags v2 completion" Closes #2448 and #2390 See merge request funkwhale/funkwhale!2930
This commit is contained in:
commit
199c5efc7d
|
@ -135,8 +135,9 @@ flake.lock
|
||||||
# Zed
|
# Zed
|
||||||
.zed/
|
.zed/
|
||||||
|
|
||||||
# Node version (asdf)
|
# Node version (asdf, mise)
|
||||||
.tool-versions
|
.tool-versions
|
||||||
|
mise.toml
|
||||||
|
|
||||||
# Lychee link checker
|
# Lychee link checker
|
||||||
.lycheecache
|
.lycheecache
|
||||||
|
|
19
CHANGELOG.md
19
CHANGELOG.md
|
@ -9,6 +9,25 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
||||||
|
|
||||||
<!-- towncrier -->
|
<!-- towncrier -->
|
||||||
|
|
||||||
|
## 2.0.0-alpha.2 (2025-06-06)
|
||||||
|
|
||||||
|
Carefully read [this blog post](https://blog.funkwhale.audio/2025-funkwhale-2-news.html) before upgrading. This alpha release might break your db.
|
||||||
|
|
||||||
|
Upgrade instructions are available at https://docs.funkwhale.audio/administrator/upgrade/index.html
|
||||||
|
|
||||||
|
Enhancements:
|
||||||
|
|
||||||
|
- Make playlist detail page reactive to plugin upload updates (#2464)
|
||||||
|
- Only refresh_nodeinfo_known_nodes for Funkwhale instances (#2442)
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
|
||||||
|
- Fixed database migrations for trackfavorite, playlist and playlisttrack
|
||||||
|
|
||||||
|
Other:
|
||||||
|
|
||||||
|
- Fixed regressions in Tags selector after removal of jQuery (#2440, #2390)
|
||||||
|
|
||||||
## 2.0.0-alpha.1 (2025-05-23)
|
## 2.0.0-alpha.1 (2025-05-23)
|
||||||
|
|
||||||
Carefully read [this blog post](https://blog.funkwhale.audio/2025-funkwhale-2-news.html) before upgrading. This alpha release might break your db.
|
Carefully read [this blog post](https://blog.funkwhale.audio/2025-funkwhale-2-news.html) before upgrading. This alpha release might break your db.
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Only refresh_nodeinfo_known_nodes for Funkwhale instances (#2442)
|
|
|
@ -1 +0,0 @@
|
||||||
Fixed database migrations for trackfavorite, playlist and playlisttrack
|
|
|
@ -1 +0,0 @@
|
||||||
Make playlist detail page reactive to plugin upload updates (#2464)
|
|
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
|
@ -67,7 +67,7 @@ const performSearch = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => store.state.moderation.lastUpdate, page],
|
() => [store.state.moderation.lastUpdate, page.value],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,6 +5,7 @@ import type { paths } from '~/generated/types'
|
||||||
import { slugify } from 'transliteration'
|
import { slugify } from 'transliteration'
|
||||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||||
|
@ -35,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
|
||||||
const newValues = reactive({
|
const newValues = reactive({
|
||||||
name: props.object?.artist?.name ?? '',
|
name: props.object?.artist?.name ?? '',
|
||||||
|
@ -261,7 +263,7 @@ defineExpose({
|
||||||
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
:get="model => { newValues.tags = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: newValues.tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
others: [].map(tag => ({ type: 'custom' as const, label: tag }))
|
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name }))
|
||||||
})"
|
})"
|
||||||
:label="t('components.audio.ChannelForm.label.tags')"
|
:label="t('components.audio.ChannelForm.label.tags')"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -83,7 +83,7 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => store.state.moderation.lastUpdate, page],
|
() => [store.state.moderation.lastUpdate, page.value],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Track, Album, Artist, Library, ArtistCredit } from '~/types'
|
import type { Track, Library } from '~/types'
|
||||||
|
|
||||||
import { momentFormat } from '~/utils/filters'
|
import { momentFormat } from '~/utils/filters'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { sum } from 'lodash-es'
|
import { sum } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
@ -35,13 +36,15 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const dataStore = useDataStore()
|
||||||
|
|
||||||
const emit = defineEmits<Events>()
|
const emit = defineEmits<Events>()
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
const object = ref<Album | null>(null)
|
const object = computed(() => dataStore.get("album", props.id).value)
|
||||||
const artist = ref<Artist | null>(null)
|
const artistCredit = computed(() => object.value?.artist_credit ?? [])
|
||||||
const artistCredit = ref([] as ArtistCredit[])
|
|
||||||
|
|
||||||
const libraries = ref([] as Library[])
|
const libraries = ref([] as Library[])
|
||||||
const paginateBy = ref(50)
|
const paginateBy = ref(50)
|
||||||
|
|
||||||
|
@ -75,20 +78,10 @@ const {
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
if (!object.value)
|
||||||
const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } })
|
return
|
||||||
|
else
|
||||||
artistCredit.value = albumResponse.data.artist_credit
|
|
||||||
|
|
||||||
// fetch the first artist of the album
|
|
||||||
const artistResponse = await axios.get(`artists/${albumResponse.data.artist_credit[0].artist.id}/`)
|
|
||||||
|
|
||||||
artist.value = artistResponse.data
|
|
||||||
|
|
||||||
object.value = albumResponse.data
|
|
||||||
if (object.value) {
|
|
||||||
object.value.tracks = []
|
object.value.tracks = []
|
||||||
}
|
|
||||||
|
|
||||||
fetchTracks()
|
fetchTracks()
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
@ -128,7 +121,7 @@ const fetchTracks = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.id, fetchData, { immediate: true })
|
watch(() => [props.id, object.value], fetchData, { immediate: true })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
@ -138,7 +131,8 @@ const remove = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`albums/${object.value?.id}`)
|
await axios.delete(`albums/${object.value?.id}`)
|
||||||
emit('deleted')
|
emit('deleted')
|
||||||
router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } })
|
if (artistCredit.value)
|
||||||
|
router.push({ name: 'library.artists.detail', params: { id: artistCredit.value[0].artist.id } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
}
|
}
|
||||||
|
@ -154,6 +148,7 @@ const remove = async () => {
|
||||||
/>
|
/>
|
||||||
<Header
|
<Header
|
||||||
v-if="object"
|
v-if="object"
|
||||||
|
:key="object.title /*Re-render component when title changes after an update*/"
|
||||||
:h1="object.title"
|
:h1="object.title"
|
||||||
page-heading
|
page-heading
|
||||||
>
|
>
|
||||||
|
@ -272,7 +267,7 @@ const remove = async () => {
|
||||||
</Layout>
|
</Layout>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<div style="flex 1;">
|
<div style="flex: 1;">
|
||||||
<router-view
|
<router-view
|
||||||
v-if="object"
|
v-if="object"
|
||||||
:key="route.fullPath"
|
:key="route.fullPath"
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { syncRef } from '@vueuse/core'
|
import { syncRef } from '@vueuse/core'
|
||||||
import { sortedUniq } from 'lodash-es'
|
import { sortedUniq } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
import { useModal } from '~/ui/composables/useModal.ts'
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const dataStore = useDataStore()
|
||||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
@ -150,10 +152,11 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
||||||
:placeholder="labels.searchPlaceholder"
|
:placeholder="labels.searchPlaceholder"
|
||||||
/>
|
/>
|
||||||
<Pills
|
<Pills
|
||||||
|
v-if="typeof tags === 'object'"
|
||||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
...model,
|
|
||||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
|
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name })),
|
||||||
})"
|
})"
|
||||||
:label="t('components.library.Albums.label.tags')"
|
:label="t('components.library.Albums.label.tags')"
|
||||||
style="max-width: 150px;"
|
style="max-width: 150px;"
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { syncRef } from '@vueuse/core'
|
import { syncRef } from '@vueuse/core'
|
||||||
import { sortedUniq } from 'lodash-es'
|
import { sortedUniq } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
import { useModal } from '~/ui/composables/useModal.ts'
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const dataStore = useDataStore()
|
||||||
watch([() => store.state.moderation.lastUpdate, excludeCompilation], fetchData)
|
watch([() => store.state.moderation.lastUpdate, excludeCompilation], fetchData)
|
||||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
@ -150,10 +152,11 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
||||||
:placeholder="labels.searchPlaceholder"
|
:placeholder="labels.searchPlaceholder"
|
||||||
/>
|
/>
|
||||||
<Pills
|
<Pills
|
||||||
|
v-if="typeof tags === 'object'"
|
||||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
...model,
|
|
||||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
|
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name })),
|
||||||
})"
|
})"
|
||||||
:label="t('components.library.Artists.label.tags')"
|
:label="t('components.library.Artists.label.tags')"
|
||||||
style="max-width: 150px;"
|
style="max-width: 150px;"
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { isEqual, clone } from 'lodash-es'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
@ -34,14 +35,18 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
licenses: () => []
|
licenses: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Since the object may be reactive (self-updating), we need a clone to compare changes
|
||||||
|
const originalObject = Object.assign({}, props.object)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const configs = useEditConfigs()
|
const configs = useEditConfigs()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const dataStore = useDataStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const config = computed(() => configs[props.objectType])
|
const config = computed(() => configs[props.objectType])
|
||||||
const currentState = computed(() => config.value.fields.reduce((state: ReviewState, field) => {
|
const currentState = computed(() => config.value.fields.reduce((state: ReviewState, field) => {
|
||||||
state[field.id] = { value: field.getValue(props.object) }
|
state[field.id] = { value: field.getValue(originalObject) }
|
||||||
return state
|
return state
|
||||||
}, {}))
|
}, {}))
|
||||||
|
|
||||||
|
@ -123,6 +128,9 @@ const submit = async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
submittedMutation.value = response.data
|
submittedMutation.value = response.data
|
||||||
|
|
||||||
|
// Immediately re-fetch the updated object into the store
|
||||||
|
dataStore.get(props.objectType, props.object.id!.toString(), { immediate: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errors.value = (error as BackendError).backendErrors
|
errors.value = (error as BackendError).backendErrors
|
||||||
}
|
}
|
||||||
|
@ -255,7 +263,7 @@ const resetField = (fieldId: string) => {
|
||||||
:id="fieldConfig.id"
|
:id="fieldConfig.id"
|
||||||
v-model="values[fieldConfig.id]"
|
v-model="values[fieldConfig.id]"
|
||||||
:type="fieldConfig.inputType || 'text'"
|
:type="fieldConfig.inputType || 'text'"
|
||||||
:required="fieldConfig.required"
|
:required="fieldConfig.required || undefined"
|
||||||
:name="fieldConfig.id"
|
:name="fieldConfig.id"
|
||||||
:label="fieldConfig.label"
|
:label="fieldConfig.label"
|
||||||
/>
|
/>
|
||||||
|
@ -303,17 +311,18 @@ const resetField = (fieldId: string) => {
|
||||||
</attachment-input>
|
</attachment-input>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="fieldConfig.type === 'tags'">
|
<template v-else-if="fieldConfig.type === 'tags'">
|
||||||
<!-- TODO: Make Tags work -->
|
|
||||||
<Pills
|
<Pills
|
||||||
|
v-for="version in [JSON.stringify(values[fieldConfig.id])]"
|
||||||
:id="fieldConfig.id"
|
:id="fieldConfig.id"
|
||||||
ref="tags"
|
:key="version"
|
||||||
:get="model => { values[fieldConfig.id] = model.currents.map(({ label }) => label) }"
|
:get="model => { values[fieldConfig.id] = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
...model,
|
...model,
|
||||||
currents: (values[fieldConfig.id] as string[]).map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: (values[fieldConfig.id] as string[]).map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
|
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name })),
|
||||||
})"
|
})"
|
||||||
:label="fieldConfig.label"
|
:label="fieldConfig.label"
|
||||||
required="fieldConfig.required"
|
:required="fieldConfig.required"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="bi-x"
|
icon="bi-x"
|
||||||
|
@ -359,13 +368,12 @@ const resetField = (fieldId: string) => {
|
||||||
primary
|
primary
|
||||||
:disabled="isLoading || !mutationPayload"
|
:disabled="isLoading || !mutationPayload"
|
||||||
>
|
>
|
||||||
<span v-if="canEdit">
|
{{ canEdit
|
||||||
{{ t('components.library.EditForm.button.submit') }}
|
? t('components.library.EditForm.button.submit')
|
||||||
</span>
|
: t('components.library.EditForm.button.suggest')
|
||||||
<span v-else>
|
}}
|
||||||
{{ t('components.library.EditForm.button.suggest') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Spacer />
|
||||||
</form>
|
</form>
|
||||||
</Layout>
|
</Layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { syncRef } from '@vueuse/core'
|
import { syncRef } from '@vueuse/core'
|
||||||
import { sortedUniq } from 'lodash-es'
|
import { sortedUniq } from 'lodash-es'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
import { useDataStore } from '~/ui/stores/data'
|
||||||
import { useModal } from '~/ui/composables/useModal.ts'
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
@ -60,11 +61,6 @@ const submittable = ref(false)
|
||||||
|
|
||||||
const tags = useRouteQuery<string[]>('tag', [])
|
const tags = useRouteQuery<string[]>('tag', [])
|
||||||
|
|
||||||
computed(() => ({
|
|
||||||
currents: [].map(tag => ({ type: 'custom' as const, label: tag })),
|
|
||||||
others: tags.value.map(tag => ({ type: 'custom' as const, label: tag }))
|
|
||||||
}))
|
|
||||||
|
|
||||||
const q = useRouteQuery('query', '')
|
const q = useRouteQuery('query', '')
|
||||||
const query = ref(q.value)
|
const query = ref(q.value)
|
||||||
syncRef(q, query, { direction: 'ltr' })
|
syncRef(q, query, { direction: 'ltr' })
|
||||||
|
@ -116,6 +112,7 @@ const fetchData = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
const dataStore = useDataStore()
|
||||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||||
watch([page, tags, q, ordering, orderingDirection], fetchData)
|
watch([page, tags, q, ordering, orderingDirection], fetchData)
|
||||||
fetchData()
|
fetchData()
|
||||||
|
@ -176,10 +173,11 @@ const { to: upload } = useModal('upload')
|
||||||
:placeholder="labels.searchPlaceholder"
|
:placeholder="labels.searchPlaceholder"
|
||||||
/>
|
/>
|
||||||
<Pills
|
<Pills
|
||||||
|
v-if="typeof tags === 'object'"
|
||||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
...model,
|
|
||||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
|
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name })),
|
||||||
})"
|
})"
|
||||||
:label="t('components.library.Podcasts.label.tags')"
|
:label="t('components.library.Podcasts.label.tags')"
|
||||||
style="max-width: 150px;"
|
style="max-width: 150px;"
|
||||||
|
|
|
@ -60,7 +60,7 @@ const fetchData = async (url = props.url) => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => store.state.moderation.lastUpdate, page],
|
() => [store.state.moderation.lastUpdate, page.value],
|
||||||
() => fetchData(),
|
() => fetchData(),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
|
@ -50,6 +50,10 @@
|
||||||
font-weight:600;
|
font-weight:600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(>[required])>.label:after {
|
||||||
|
content: ' *';
|
||||||
|
}
|
||||||
|
|
||||||
> .prefix,
|
> .prefix,
|
||||||
> .input-right {
|
> .input-right {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed, type Ref } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import type { Album, Artist, Tag, Track } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an item from the API.
|
||||||
|
* - Rate limiting: Caches the result for 1 second to prevent over-fetching and request duplication (override with { immediate: true})
|
||||||
|
* - Sharing reactive objects across components: Avoid data duplication, and auto-update the Ui whenever an updated version of the data is re-fetched
|
||||||
|
* - Strongly typed results
|
||||||
|
* - TODO: Errors and Loading states (`undefined` can mean an error occurred, or the request is still pending)
|
||||||
|
*
|
||||||
|
* **Example**
|
||||||
|
* ```ts
|
||||||
|
* import { useDataStore } from '~/ui/stores/data'
|
||||||
|
*
|
||||||
|
* const data = useDataStore()
|
||||||
|
*
|
||||||
|
* artist15 = data.get("artist", "15") // Ref<Artist | undefined>
|
||||||
|
* const album23 = data.get("album", "23") // Ref<Album | undefined>
|
||||||
|
* ```
|
||||||
|
* As soon as you re-fetch data, all references to the same object in all components using this store will be updated.
|
||||||
|
*
|
||||||
|
* Note: Pinia does not support destructuring.
|
||||||
|
* Do not write `{ get } = useDataStore()`
|
||||||
|
*/
|
||||||
|
export const useDataStore
|
||||||
|
= defineStore('data', () => {
|
||||||
|
|
||||||
|
// Type map that associates cache keys with their corresponding types
|
||||||
|
type ItemType = {
|
||||||
|
artist: Artist
|
||||||
|
album: Album
|
||||||
|
track: Track
|
||||||
|
// Add new types here (channel: Channel...)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Items<I extends keyof ItemType> = Record<number | string, {
|
||||||
|
result: Ref<ItemType[I] | undefined>;
|
||||||
|
timestamp: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type Cache = {
|
||||||
|
[I in keyof ItemType]: Items<I>
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache: Cache = {
|
||||||
|
artist: {},
|
||||||
|
album: {},
|
||||||
|
track: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsCache = ref<Tag[]>([])
|
||||||
|
const tagsTimestamp = ref(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns an auto-updating reference to all tags or `[]` if either none are loaded yet, or there was an error
|
||||||
|
*/
|
||||||
|
const tags = () => {
|
||||||
|
// Re-fetch if immediate is true or the item is not cached or older than 1 second
|
||||||
|
if (tagsTimestamp.value < Date.now() - 1000) {
|
||||||
|
axios.get<components['schemas']['PaginatedTagList']>('tags/', { params: { page_size: 10000 } }).then(({ data }) => {
|
||||||
|
tagsTimestamp.value = Date.now();
|
||||||
|
tagsCache.value = data.results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tagsCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inspect the cache with the Vue Devtools (Pinia tab); read-only */
|
||||||
|
const data = computed(() => cache)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param type - either 'artist' or 'album' etc.
|
||||||
|
* @param id - The ID of the item to fetch.
|
||||||
|
* @param immediate - Whether to re-fetch immediately (default: only re-fetch data older than 1 second)
|
||||||
|
* @returns an auto-updating reference to `undefined` if there is an error or the item is not yet loaded, or the actual item once it is loaded.
|
||||||
|
*
|
||||||
|
* Tip: Re-run after 1 second to refresh the data.
|
||||||
|
*/
|
||||||
|
const get = <I extends keyof ItemType>(type: I, id: number | string, options?: { immediate?: boolean }) => {
|
||||||
|
// Override limited typescript inference (Remove assertion once typescript can infer correctly)
|
||||||
|
const items = cache[type] as Items<I>
|
||||||
|
|
||||||
|
// Initialize the object if it doesn't exist
|
||||||
|
if (!items[id])
|
||||||
|
items[id] = { result: ref(undefined) as Ref<ItemType[I] | undefined>, timestamp: 0 }
|
||||||
|
|
||||||
|
// Re-fetch if immediate is true or the item is not cached or older than 1 second
|
||||||
|
if (options?.immediate || items[id].timestamp < Date.now() - 1000)
|
||||||
|
axios.get<ItemType[I]>(`${type}s/${id}/`, { params: { refresh: 'true' } }).then(({ data }) => {
|
||||||
|
items[id].result.value = data;
|
||||||
|
items[id].timestamp = Date.now();
|
||||||
|
})
|
||||||
|
return items[id].result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
get,
|
||||||
|
tagsCache,
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
})
|
|
@ -234,9 +234,11 @@ const showCreateModal = ref(false)
|
||||||
:placeholder="labels.searchPlaceholder"
|
:placeholder="labels.searchPlaceholder"
|
||||||
/>
|
/>
|
||||||
<Pills
|
<Pills
|
||||||
|
v-if="typeof tags === 'object'"
|
||||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||||
:set="model => ({
|
:set="model => ({
|
||||||
...model,
|
...model,
|
||||||
|
others: [],
|
||||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||||
})"
|
})"
|
||||||
:label="t('components.library.Podcasts.label.tags')"
|
:label="t('components.library.Podcasts.label.tags')"
|
||||||
|
|
6946
front/yarn.lock
6946
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue