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:
Flupsi 2025-06-09 23:09:11 +00:00
commit 199c5efc7d
19 changed files with 3180 additions and 3999 deletions

3
.gitignore vendored
View File

@ -135,8 +135,9 @@ flake.lock
# Zed
.zed/
# Node version (asdf)
# Node version (asdf, mise)
.tool-versions
mise.toml
# Lychee link checker
.lycheecache

View File

@ -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.

View File

@ -1 +0,0 @@
Only refresh_nodeinfo_known_nodes for Funkwhale instances (#2442)

View File

@ -1 +0,0 @@
Fixed database migrations for trackfavorite, playlist and playlisttrack

View File

@ -1 +0,0 @@
Make playlist detail page reactive to plugin upload updates (#2464)

1
front/.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

View File

@ -67,7 +67,7 @@ const performSearch = () => {
}
watch(
[() => store.state.moderation.lastUpdate, page],
() => [store.state.moderation.lastUpdate, page.value],
() => fetchData(),
{ immediate: true }
)

View File

@ -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')"
/>

View File

@ -83,7 +83,7 @@ onMounted(() => {
})
watch(
[() => store.state.moderation.lastUpdate, page],
() => [store.state.moderation.lastUpdate, page.value],
() => fetchData(),
{ immediate: true }
)

View File

@ -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"

View File

@ -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;"

View File

@ -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;"

View File

@ -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>

View File

@ -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;"

View File

@ -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 }
)

View File

@ -50,6 +50,10 @@
font-weight:600;
}
&:has(>[required])>.label:after {
content: ' *';
}
> .prefix,
> .input-right {
align-items: center;

106
front/src/ui/stores/data.ts Normal file
View File

@ -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
}
})

View File

@ -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')"

File diff suppressed because it is too large Load Diff