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/
|
||||
|
||||
# Node version (asdf)
|
||||
# Node version (asdf, mise)
|
||||
.tool-versions
|
||||
mise.toml
|
||||
|
||||
# Lychee link checker
|
||||
.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 -->
|
||||
|
||||
## 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)
|
||||
|
||||
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(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ import type { paths } from '~/generated/types'
|
|||
import { slugify } from 'transliteration'
|
||||
import { reactive, computed, ref, watchEffect, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
|
||||
import axios from 'axios'
|
||||
import AttachmentInput from '~/components/common/AttachmentInput.vue'
|
||||
|
@ -35,6 +36,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
const newValues = reactive({
|
||||
name: props.object?.artist?.name ?? '',
|
||||
|
@ -261,7 +263,7 @@ defineExpose({
|
|||
: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 }))
|
||||
others: dataStore.tags().value.map(({ name }) => ({ type: 'custom' as const, label: name }))
|
||||
})"
|
||||
:label="t('components.audio.ChannelForm.label.tags')"
|
||||
/>
|
||||
|
|
|
@ -83,7 +83,7 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
watch(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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 { computed, reactive, ref, watch } from 'vue'
|
||||
|
@ -7,6 +7,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { sum } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -35,13 +36,15 @@ interface Props {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const object = ref<Album | null>(null)
|
||||
const artist = ref<Artist | null>(null)
|
||||
const artistCredit = ref([] as ArtistCredit[])
|
||||
const object = computed(() => dataStore.get("album", props.id).value)
|
||||
const artistCredit = computed(() => object.value?.artist_credit ?? [])
|
||||
|
||||
|
||||
const libraries = ref([] as Library[])
|
||||
const paginateBy = ref(50)
|
||||
|
||||
|
@ -75,20 +78,10 @@ const {
|
|||
const isLoading = ref(false)
|
||||
const fetchData = async () => {
|
||||
isLoading.value = true
|
||||
|
||||
const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } })
|
||||
|
||||
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) {
|
||||
if (!object.value)
|
||||
return
|
||||
else
|
||||
object.value.tracks = []
|
||||
}
|
||||
|
||||
fetchTracks()
|
||||
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 route = useRoute()
|
||||
|
@ -138,7 +131,8 @@ const remove = async () => {
|
|||
try {
|
||||
await axios.delete(`albums/${object.value?.id}`)
|
||||
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) {
|
||||
useErrorHandler(error as Error)
|
||||
}
|
||||
|
@ -154,6 +148,7 @@ const remove = async () => {
|
|||
/>
|
||||
<Header
|
||||
v-if="object"
|
||||
:key="object.title /*Re-render component when title changes after an update*/"
|
||||
:h1="object.title"
|
||||
page-heading
|
||||
>
|
||||
|
@ -272,7 +267,7 @@ const remove = async () => {
|
|||
</Layout>
|
||||
</Header>
|
||||
|
||||
<div style="flex 1;">
|
||||
<div style="flex: 1;">
|
||||
<router-view
|
||||
v-if="object"
|
||||
:key="route.fullPath"
|
||||
|
|
|
@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||
fetchData()
|
||||
|
@ -150,10 +152,11 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
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')"
|
||||
style="max-width: 150px;"
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -101,6 +102,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch([() => store.state.moderation.lastUpdate, excludeCompilation], fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection, () => props.scope], fetchData)
|
||||
fetchData()
|
||||
|
@ -150,10 +152,11 @@ const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value]
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
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')"
|
||||
style="max-width: 150px;"
|
||||
|
|
|
@ -7,6 +7,7 @@ import { isEqual, clone } from 'lodash-es'
|
|||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
|
@ -34,14 +35,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
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 configs = useEditConfigs()
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
const route = useRoute()
|
||||
|
||||
const config = computed(() => configs[props.objectType])
|
||||
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
|
||||
}, {}))
|
||||
|
||||
|
@ -123,6 +128,9 @@ const submit = async () => {
|
|||
})
|
||||
|
||||
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) {
|
||||
errors.value = (error as BackendError).backendErrors
|
||||
}
|
||||
|
@ -255,7 +263,7 @@ const resetField = (fieldId: string) => {
|
|||
:id="fieldConfig.id"
|
||||
v-model="values[fieldConfig.id]"
|
||||
:type="fieldConfig.inputType || 'text'"
|
||||
:required="fieldConfig.required"
|
||||
:required="fieldConfig.required || undefined"
|
||||
:name="fieldConfig.id"
|
||||
:label="fieldConfig.label"
|
||||
/>
|
||||
|
@ -303,17 +311,18 @@ const resetField = (fieldId: string) => {
|
|||
</attachment-input>
|
||||
</template>
|
||||
<template v-else-if="fieldConfig.type === 'tags'">
|
||||
<!-- TODO: Make Tags work -->
|
||||
<Pills
|
||||
v-for="version in [JSON.stringify(values[fieldConfig.id])]"
|
||||
:id="fieldConfig.id"
|
||||
ref="tags"
|
||||
:key="version"
|
||||
:get="model => { values[fieldConfig.id] = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
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"
|
||||
required="fieldConfig.required"
|
||||
:required="fieldConfig.required"
|
||||
>
|
||||
<Button
|
||||
icon="bi-x"
|
||||
|
@ -359,13 +368,12 @@ const resetField = (fieldId: string) => {
|
|||
primary
|
||||
:disabled="isLoading || !mutationPayload"
|
||||
>
|
||||
<span v-if="canEdit">
|
||||
{{ t('components.library.EditForm.button.submit') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('components.library.EditForm.button.suggest') }}
|
||||
</span>
|
||||
{{ canEdit
|
||||
? t('components.library.EditForm.button.submit')
|
||||
: t('components.library.EditForm.button.suggest')
|
||||
}}
|
||||
</Button>
|
||||
<Spacer />
|
||||
</form>
|
||||
</Layout>
|
||||
</template>
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
|
|||
import { syncRef } from '@vueuse/core'
|
||||
import { sortedUniq } from 'lodash-es'
|
||||
import { useStore } from '~/store'
|
||||
import { useDataStore } from '~/ui/stores/data'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import axios from 'axios'
|
||||
|
@ -60,11 +61,6 @@ const submittable = ref(false)
|
|||
|
||||
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 query = ref(q.value)
|
||||
syncRef(q, query, { direction: 'ltr' })
|
||||
|
@ -116,6 +112,7 @@ const fetchData = async () => {
|
|||
}
|
||||
|
||||
const store = useStore()
|
||||
const dataStore = useDataStore()
|
||||
watch(() => store.state.moderation.lastUpdate, fetchData)
|
||||
watch([page, tags, q, ordering, orderingDirection], fetchData)
|
||||
fetchData()
|
||||
|
@ -176,10 +173,11 @@ const { to: upload } = useModal('upload')
|
|||
:placeholder="labels.searchPlaceholder"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
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')"
|
||||
style="max-width: 150px;"
|
||||
|
|
|
@ -60,7 +60,7 @@ const fetchData = async (url = props.url) => {
|
|||
fetchData()
|
||||
|
||||
watch(
|
||||
[() => store.state.moderation.lastUpdate, page],
|
||||
() => [store.state.moderation.lastUpdate, page.value],
|
||||
() => fetchData(),
|
||||
{ immediate: true }
|
||||
)
|
||||
|
|
|
@ -50,6 +50,10 @@
|
|||
font-weight:600;
|
||||
}
|
||||
|
||||
&:has(>[required])>.label:after {
|
||||
content: ' *';
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .input-right {
|
||||
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"
|
||||
/>
|
||||
<Pills
|
||||
v-if="typeof tags === 'object'"
|
||||
:get="model => { tags = model.currents.map(({ label }) => label) }"
|
||||
:set="model => ({
|
||||
...model,
|
||||
others: [],
|
||||
currents: tags.map(tag => ({ type: 'custom' as const, label: tag })),
|
||||
})"
|
||||
: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