fix(front): [WIP] use generated types to make the CI (`lint:tsc`) happy

This commit is contained in:
upsiflu 2025-04-04 10:08:46 +02:00
parent a5098c0952
commit 864ab4a758
58 changed files with 313 additions and 273 deletions

View File

@ -18,7 +18,6 @@ import { useI18n } from 'vue-i18n'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from '~/components/audio/PlayerControls.vue' import PlayerControls from '~/components/audio/PlayerControls.vue'
import ActorLink from '~/components/audio/ArtistCreditLabel.vue'
import VirtualList from '~/components/vui/list/VirtualList.vue' import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue' import QueueItem from '~/components/QueueItem.vue'
@ -27,7 +26,6 @@ import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Link from '~/components/ui/Link.vue' import Link from '~/components/ui/Link.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Pill from '~/components/ui/Pill.vue'
import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue' import ArtistCreditLabel from '~/components/audio/ArtistCreditLabel.vue'
const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue')) const MilkDrop = defineAsyncComponent(() => import('~/components/audio/visualizer/MilkDrop.vue'))
@ -299,7 +297,8 @@ if (!isWebGLSupported) {
</h2> </h2>
<span> <span>
<ArtistCreditLabel <ArtistCreditLabel
:artist-credit="currentTrack.artistCredit || undefined" v-if="currentTrack.artistCredit"
:artist-credit="currentTrack.artistCredit"
/> />
</span> </span>
<div <div

View File

@ -24,6 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
values: null values: null
}) })
// TODO: check if `position: 0` is a good default
const newValues = reactive<Values>({ const newValues = reactive<Values>({
position: 0, position: 0,
description: '', description: '',

View File

@ -1,11 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import axios from 'axios' import { useVModel, useTextareaAutosize, syncRef } from '@vueuse/core'
import { useVModel, watchDebounced, useTextareaAutosize, syncRef } from '@vueuse/core' import { computed } from 'vue'
import { ref, computed, watchEffect, onMounted, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import useLogger from '~/composables/useLogger'
import Textarea from '~/components/ui/Textarea.vue' import Textarea from '~/components/ui/Textarea.vue'
interface Events { interface Events {
@ -30,17 +27,11 @@ const props = withDefaults(defineProps<Props>(), {
required: false required: false
}) })
const logger = useLogger()
const { t } = useI18n() const { t } = useI18n()
const { textarea, input } = useTextareaAutosize() const { textarea, input } = useTextareaAutosize()
const value = useVModel(props, 'modelValue', emit) const value = useVModel(props, 'modelValue', emit)
syncRef(value, input) syncRef(value, input)
const isPreviewing = ref(false)
const preview = ref()
const isLoadingPreview = ref(false)
const labels = computed(() => ({ const labels = computed(() => ({
placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input') placeholder: props.placeholder ?? t('components.common.ContentForm.placeholder.input')
})) }))

View File

@ -28,11 +28,6 @@ const labels = computed(() => ({
searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'), searchPlaceholder: t('components.common.InlineSearchBar.placeholder.search'),
clear: t('components.common.InlineSearchBar.button.clear') clear: t('components.common.InlineSearchBar.button.clear')
})) }))
const search = () => {
value.value = ''
emit('search', value.value)
}
</script> </script>
<template> <template>

View File

@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useClipboard, useVModel } from '@vueuse/core' import { useClipboard, useVModel } from '@vueuse/core'
import { useStore } from '~/store' import { useStore } from '~/store'
import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
interface Events { interface Events {

View File

@ -68,7 +68,6 @@ const labels = computed(() => ({
})) }))
const { const {
isShuffled,
shuffle shuffle
} = useQueue() } = useQueue()
@ -221,7 +220,7 @@ const remove = async () => {
:is-playable="object.is_playable" :is-playable="object.is_playable"
/> />
<Button <Button
v-if="object.tracks.length > 2" v-if="object?.tracks?.length && object?.tracks?.length > 2"
primary primary
icon="bi-shuffle" icon="bi-shuffle"
:aria-label="labels.shuffle" :aria-label="labels.shuffle"
@ -232,8 +231,11 @@ const remove = async () => {
<DangerousButton <DangerousButton
v-if="artistCredit[0] && v-if="artistCredit[0] &&
store.state.auth.authenticated && store.state.auth.authenticated &&
artistCredit[0].artist.channel && artistCredit[0].artist.channel
artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername" /* attributed_to is a number
TODO: Re-implement the intention behind
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
:is-loading="isLoading" :is-loading="isLoading"
icon="bi-trash" icon="bi-trash"
@confirm="remove()" @confirm="remove()"

View File

@ -34,22 +34,20 @@ const { t } = useI18n()
const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? '' const getDiscKey = (disc: Track[]) => disc?.map(track => track.id).join('|') ?? ''
const page = ref(1) const page = ref(1)
const discCount = computed(() => props.object.tracks.reduce((acc, track) => { const discCount = computed(() => props.object?.tracks?.reduce((acc, track) => {
acc.add(track.disc_number) acc.add(track.disc_number)
return acc return acc
}, new Set()).size) }, new Set()).size)
const discs = computed(() => props.object.tracks const discs = computed(() => props.object?.tracks?.reduce((acc: Track[][], track: Track) => {
.reduce((acc: Track[][], track: Track) => { const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1) acc[discNumber].push(track)
acc[discNumber].push(track) return acc
return acc }, Array(discCount.value).fill(undefined).map(() => [])))
}, Array(discCount.value).fill(undefined).map(() => []))
)
const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value) const paginatedDiscs = computed(() => props.object?.tracks?.slice(props.paginateBy * (page.value - 1), props.paginateBy * page.value)
.reduce((acc: Track[][], track: Track) => { .reduce((acc: Track[][], track: Track) => {
const discNumber = track.disc_number - (props.object.tracks[0]?.disc_number ?? 1) const discNumber = track.disc_number - (props.object?.tracks?.[0]?.disc_number ?? 1)
acc[discNumber].push(track) acc[discNumber].push(track)
return acc return acc
}, Array(discCount.value).fill(undefined).map(() => [])) }, Array(discCount.value).fill(undefined).map(() => []))
@ -72,9 +70,10 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
<channel-entries <channel-entries
v-if="artistCredit && artistCredit[0].artist.channel && isSerie" v-if="artistCredit && artistCredit[0].artist.channel && isSerie"
:default-cover="null"
:is-podcast="isSerie" :is-podcast="isSerie"
:limit="50" :limit="50"
:filters="{channel: artistCredit[0].artist.channel.uuid, album: object.id, ordering: '-creation_date'}" :filters="{channel: artistCredit[0].artist.channel, album: object.id, ordering: '-creation_date'}"
/> />
<Loader v-if="isLoadingTracks" /> <Loader v-if="isLoadingTracks" />
@ -85,7 +84,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
style="margin-top: -16px;" style="margin-top: -16px;"
:tags="object.tags" :tags="object.tags"
/> />
<template v-if="discCount > 1"> <template v-if="(discCount || 0) > 1">
<div <div
v-for="tracks, index in paginatedDiscs" v-for="tracks, index in paginatedDiscs"
:key="index + getDiscKey(tracks)" :key="index + getDiscKey(tracks)"
@ -94,10 +93,10 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
<div class="ui hidden divider" /> <div class="ui hidden divider" />
<PlayButton <PlayButton
class="right floated mini inverted vibrant" class="right floated mini inverted vibrant"
:tracks="discs[index]" :tracks="discs ? discs[index] : []"
/> />
<h3> <h3>
{{ t('components.library.AlbumDetail.meta.volume', {number: tracks[0].disc_number}) }} {{ t('components.library.AlbumDetail.meta.volume', { number: tracks[0].disc_number }) }}
</h3> </h3>
<track-table <track-table
:is-album="true" :is-album="true"
@ -111,7 +110,7 @@ const paginatedDiscs = computed(() => props.object.tracks.slice(props.paginateBy
</template> </template>
</div> </div>
</template> </template>
<template v-else> <template v-else-if="object.tracks">
<track-table <track-table
:is-album="true" :is-album="true"
:tracks="object.tracks" :tracks="object.tracks"

View File

@ -132,8 +132,14 @@ const open = ref(false)
<PopoverItem <PopoverItem
v-if="artistCredit[0] && v-if="artistCredit[0] &&
store.state.auth.authenticated && store.state.auth.authenticated &&
artistCredit[0].artist.channel && artistCredit[0].artist.channel
artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername" /*
TODO: Re-implement what was the intention behind the following line:
&& artistCredit[0].artist.attributed_to?.full_username === store.state.auth.fullUsername
*/"
> >
<DangerousButton <DangerousButton
:is-loading="isLoading" :is-loading="isLoading"
@ -148,8 +154,14 @@ const open = ref(false)
<PopoverItem <PopoverItem
v-for="obj in getReportableObjects({ v-for="obj in getReportableObjects({
album: object, album: object
channel: artistCredit[0]?.artist.channel /*
TODO: The type of the following field has changed to number.
Find out if we want to load the corresponding channel instead.
, channel: artistCredit[0]?.artist.channel
*/
})" })"
:key="obj.target.type + obj.target.id" :key="obj.target.type + obj.target.id"
icon="bi-flag" icon="bi-flag"

View File

@ -1,22 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { OrderingProps } from '~/composables/navigation/useOrdering' import type { OrderingProps } from '~/composables/navigation/useOrdering'
import type { Album, PaginatedAlbumList } from '~/types' import type { PaginatedAlbumList } from '~/types'
import { type operations } from '~/generated/types.ts' import type { operations } from '~/generated/types.ts'
import type { RouteRecordName } from 'vue-router' import type { RouteRecordName } from 'vue-router'
import type { OrderingField } from '~/store/ui' import type { OrderingField } from '~/store/ui'
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouteQuery } from '@vueuse/router' import { useRouteQuery } from '@vueuse/router'
import { useI18n } from 'vue-i18n' 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 { useModal } from '~/ui/composables/useModal.ts' import { useModal } from '~/ui/composables/useModal.ts'
import { useTags } from '~/ui/composables/useTags.ts'
import axios from 'axios' import axios from 'axios'
import TagsSelector from '~/components/library/TagsSelector.vue'
import Pagination from '~/components/ui/Pagination.vue' import Pagination from '~/components/ui/Pagination.vue'
import Card from '~/components/ui/Card.vue' import Card from '~/components/ui/Card.vue'
import AlbumCard from '~/components/album/Card.vue' import AlbumCard from '~/components/album/Card.vue'

View File

@ -49,17 +49,21 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const domain = computed(() => getDomain(object.value?.fid ?? '')) const domain = computed(() => getDomain(object.value?.fid ?? ''))
const isPlayable = computed(() => !!object.value?.albums.some(album => album.is_playable))
// TODO: Re-implement `!!object.value?.albums.some(album => album.is_playable)` instead of `true`
const isPlayable = computed(() => true)
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`) const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`)
const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null) const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null)
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? []) const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
// TODO: This is cover logic. We use it a lot. Should all go into a single, smart, parametrised function.
// Something like `useCover.ts`!
const cover = computed(() => { const cover = computed(() => {
const artistCover: Cover | undefined = object.value?.cover const artistCover = object.value?.cover
const albumCover: Cover | undefined = object.value?.albums // const albumCover: Cover | null = object.value?.albums
.find(album => album.cover?.urls.large_square_crop)?.cover // .find(album => album.cover?.urls.large_square_crop)?.cover
const trackCover = tracks.value?.find( const trackCover = tracks.value?.find(
(track: Track) => track.cover (track: Track) => track.cover
@ -67,15 +71,19 @@ const cover = computed(() => {
const fallback : Cover = { const fallback : Cover = {
uuid: '', uuid: '',
mimetype: 'jpeg',
creation_date: '',
size: 0,
urls: { urls: {
original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`, original: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
small_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
medium_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`, medium_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`,
large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg` large_square_crop: `${import.meta.env.BASE_URL}embed-default-cover.jpeg`
} }
} }
return artistCover return artistCover
|| albumCover // || albumCover
|| trackCover || trackCover
|| fallback || fallback
}) })
@ -182,7 +190,7 @@ watch(() => props.id, fetchData, { immediate: true })
<template #items> <template #items>
<PopoverItem <PopoverItem
v-if="domain != store.getters['instance/domain']" v-if="object.fid && domain != store.getters['instance/domain']"
:to="object.fid" :to="object.fid"
target="_blank" target="_blank"
icon="bi-box-arrow-up-right" icon="bi-box-arrow-up-right"

View File

@ -100,11 +100,9 @@ const loadMoreAlbums = async () => {
<Loader v-if="isLoadingAlbums" /> <Loader v-if="isLoadingAlbums" />
<template v-else-if="albums && albums.length > 0"> <template v-else-if="albums && albums.length > 0">
<Heading <Heading
h2 :h2="t('components.library.ArtistDetail.header.album')"
section-heading section-heading
> />
{{ t('components.library.ArtistDetail.header.album') }}
</Heading>
<Layout flex> <Layout flex>
<album-card <album-card
v-for="album in allAlbums" v-for="album in allAlbums"
@ -127,11 +125,9 @@ const loadMoreAlbums = async () => {
</template> </template>
<template v-if="tracks.length > 0"> <template v-if="tracks.length > 0">
<Heading <Heading
h2 :h2="t('components.library.ArtistDetail.header.track')"
section-heading section-heading
> />
{{ t('components.library.ArtistDetail.header.track') }}
</Heading>
<TrackTable <TrackTable
:is-artist="true" :is-artist="true"
:show-position="false" :show-position="false"
@ -140,11 +136,9 @@ const loadMoreAlbums = async () => {
/> />
</template> </template>
<Heading <Heading
h2 :h2="t('components.library.ArtistDetail.header.library')"
section-heading section-heading
> />
{{ t('components.library.ArtistDetail.header.library') }}
</Heading>
<LibraryWidget <LibraryWidget
:url="'artists/' + object.id + '/libraries/'" :url="'artists/' + object.id + '/libraries/'"
@loaded="emit('libraries-loaded', $event)" @loaded="emit('libraries-loaded', $event)"

View File

@ -41,10 +41,11 @@ const canEdit = store.state.auth.availablePermissions.library
> >
{{ t('components.library.ArtistEdit.message.remote') }} {{ t('components.library.ArtistEdit.message.remote') }}
</Alert> </Alert>
<!-- TODO: Check if we need to load the corresponding Actor (field: `attributed_to`) -->
<edit-form <edit-form
v-else v-else
:object-type="objectType" :object-type="objectType"
:object="object" :object="{ ...object, attributed_to: undefined }"
/> />
</Layout> </Layout>
</template> </template>

View File

@ -160,10 +160,10 @@ const approve = async (approved: boolean) => {
const alertProps = computed(() => { const alertProps = computed(() => {
return { return {
green: props.obj.is_approved && props.obj.is_applied, green: props.obj.is_approved && props.obj.is_applied || undefined,
red: props.obj.is_approved === false, red: props.obj.is_approved === false || undefined,
yellow: props.obj.is_applied === false yellow: props.obj.is_applied === false || undefined
} } as const
}) })
</script> </script>
@ -177,8 +177,8 @@ const alertProps = computed(() => {
<div class="meta"> <div class="meta">
<router-link <router-link
v-if="obj.target && obj.target.type === 'track'" v-if="obj.target && obj.target.type === 'track'"
:to="{name: 'library.tracks.detail', params: {id: obj.target.id }}" :to="{ name: 'library.tracks.detail', params: { id: obj.target.id } }"
:class="isInteractive" :class="/* TODO: find out: what is isInteractive? */ undefined"
> >
<i class="bi bi-file-music-fill" /> <i class="bi bi-file-music-fill" />
{{ t('components.library.EditCard.link.track', {id: obj.target.id, name: obj.target.repr}) }} {{ t('components.library.EditCard.link.track', {id: obj.target.id, name: obj.target.repr}) }}

View File

@ -47,7 +47,6 @@ const { t } = useI18n()
const store = useStore() const store = useStore()
const upload = ref() const upload = ref()
const currentTab = ref('uploads')
const supportedExtensions = computed(() => store.state.ui.supportedExtensions) const supportedExtensions = computed(() => store.state.ui.supportedExtensions)
const labels = computed(() => ({ const labels = computed(() => ({
@ -82,7 +81,7 @@ const options = {
everyone: sharedLabels.fields.privacy_level.choices.everyone everyone: sharedLabels.fields.privacy_level.choices.everyone
} as const satisfies Record<PrivacyLevel, string> } as const satisfies Record<PrivacyLevel, string>
const privacyLevel = defineModel<keyof typeof options>({ required: true }) const privacyLevel = defineModel<PrivacyLevel | undefined>({ required: true })
const library = ref<Library>() const library = ref<Library>()
@ -105,12 +104,12 @@ watch(privacyLevel, async (newValue) => {
try { try {
const response = await axios.get<paths['/api/v2/libraries/']['get']['responses']['200']['content']['application/json']>('libraries/', { const response = await axios.get<paths['/api/v2/libraries/']['get']['responses']['200']['content']['application/json']>('libraries/', {
params: { params: {
privacy_level: privacyLevel.value, privacy_level: newValue,
scope: 'me' scope: 'me'
} }
}) })
library.value = response.data.results.find(({ name }) => name === privacyLevel.value) library.value = response.data.results.find(({ name }) => name === newValue)
} catch (error) { } catch (error) {
useErrorHandler(error as Error) useErrorHandler(error as Error)
} }

View File

@ -4,7 +4,6 @@ import { useVModel } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
interface ErrorEntry { interface ErrorEntry {
key: string key: string

View File

@ -24,7 +24,6 @@ import useErrorHandler from '~/composables/useErrorHandler'
import usePage from '~/composables/navigation/usePage' import usePage from '~/composables/navigation/usePage'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useTags } from '~/ui/composables/useTags.ts'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
@ -140,7 +139,7 @@ const labels = computed(() => ({
const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b))) const paginateOptions = computed(() => sortedUniq([12, 30, 50, paginateBy.value].sort((a, b) => a - b)))
const { isOpen: subscribeIsOpen, to: subscribe } = useModal('subscribe') const { isOpen: subscribeIsOpen, to: subscribe } = useModal('subscribe')
const { isOpen: channelIsOpen, to: channel } = useModal('channel') const { isOpen: channelIsOpen } = useModal('channel')
const { to: upload } = useModal('upload') const { to: upload } = useModal('upload')
</script> </script>

View File

@ -25,14 +25,20 @@ const store = useStore()
const dropdown = ref() const dropdown = ref()
watch(() => props.modelValue, (value) => { watch(() => props.modelValue, (value) => {
// TODO: root out jquery and all its malevolent offspring!
// @ts-expect-error drop up and down and up again
const current = $(dropdown.value).dropdown('get value').split(',').sort() const current = $(dropdown.value).dropdown('get value').split(',').sort()
if (!isEqual([...value].sort(), current)) { if (!isEqual([...value].sort(), current)) {
// TODO: root out jquery and all its malevolent offspring!
// @ts-expect-error drop up and down and up again
$(dropdown.value).dropdown('set exactly', value) $(dropdown.value).dropdown('set exactly', value)
} }
}) })
const handleUpdate = () => { const handleUpdate = () => {
// TODO: root out jquery and all its malevolent offspring!
// @ts-expect-error drop up and down and up again
const value = $(dropdown.value).dropdown('get value').split(',') const value = $(dropdown.value).dropdown('get value').split(',')
emit('update:modelValue', value) emit('update:modelValue', value)
return value return value
@ -41,6 +47,8 @@ const handleUpdate = () => {
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
// TODO: root out jquery and all its malevolent offspring!
// @ts-expect-error drop up and down and up again
$(dropdown.value).dropdown({ $(dropdown.value).dropdown({
keys: { delimiter: 32 }, keys: { delimiter: 32 },
forceSelection: false, forceSelection: false,
@ -49,12 +57,14 @@ onMounted(async () => {
preserveHTML: false, preserveHTML: false,
apiSettings: { apiSettings: {
url: store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'), url: store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'),
// @ts-expect-error I'm not curious to research what xhr is but I'm sure it served its purpose well
beforeXHR: function (xhrObject) { beforeXHR: function (xhrObject) {
if (store.state.auth.oauth.accessToken) { if (store.state.auth.oauth.accessToken) {
xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
} }
return xhrObject return xhrObject
}, },
// @ts-expect-error yes, semantic-ui has a large API.
onResponse (response) { onResponse (response) {
response = { results: [], ...response } response = { results: [], ...response }
@ -85,6 +95,8 @@ onMounted(async () => {
onChange: handleUpdate onChange: handleUpdate
}) })
// TODO: root out jquery and all its malevolent offspring!
// @ts-expect-error drop up and down and up again
$(dropdown.value).dropdown('set exactly', props.modelValue) $(dropdown.value).dropdown('set exactly', props.modelValue)
}) })
</script> </script>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Track, Artist, Library } from '~/types' import type { Track, Library } from '~/types'
import type { Operations } from '~/generated/types' import type { operations, components } from '~/generated/types'
import { momentFormat } from '~/utils/filters' import { momentFormat } from '~/utils/filters'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@ -47,7 +47,7 @@ const props = defineProps<Props>()
const { report, getReportableObjects } = useReport() const { report, getReportableObjects } = useReport()
const track = ref<Track | null>(null) const track = ref<Track | null>(null)
const artist = ref<Artist | null>(null) const artist = ref<components['schemas']['ArtistWithAlbums'] | null>(null)
const showEmbedModal = ref(false) const showEmbedModal = ref(false)
const showDeleteModal = ref(false) const showDeleteModal = ref(false)
const libraries = ref([] as Library[]) const libraries = ref([] as Library[])
@ -58,8 +58,15 @@ const route = useRoute()
const store = useStore() const store = useStore()
const domain = computed(() => getDomain(track.value?.fid ?? '')) const domain = computed(() => getDomain(track.value?.fid ?? ''))
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length) // TODO: Why is nobody using the public libraries?
// const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
// TODO: Make it make sense:
// const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length)
const isEmbedable = computed(() => false)
const upload = computed(() => track.value?.uploads?.[0] ?? null) const upload = computed(() => track.value?.uploads?.[0] ?? null)
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist_credit?.[0].artist?.name ?? ''}`)}`) const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist_credit?.[0].artist?.name ?? ''}`)}`)
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist_credit?.[0].artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`) const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist_credit?.[0].artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`)
@ -70,15 +77,17 @@ const downloadUrl = computed(() => {
: url : url
}) })
const attributedToUrl = computed(() => router.resolve({ // TODO: Still needed?:
name: 'profile.full.overview',
params: {
username: track.value?.attributed_to?.preferred_username,
domain: track.value?.attributed_to?.domain
}
})?.href)
const artistCredit = track.value?.artist_credit // const attributedToUrl = computed(() => router.resolve({
// name: 'profile.full.overview',
// params: {
// username: track.value?.attributed_to?.preferred_username,
// domain: track.value?.attributed_to?.domain
// }
// })?.href)
// const artistCredit = track.value?.artist_credit
const totalDuration = computed(() => track.value?.uploads?.[0]?.duration ?? 0) const totalDuration = computed(() => track.value?.uploads?.[0]?.duration ?? 0)
@ -89,15 +98,17 @@ const labels = computed(() => ({
more: t('components.library.TrackBase.button.more') more: t('components.library.TrackBase.button.more')
})) }))
type TrackResponse = Operations['get_tracks_2']['responses']['200']['content']['application/json'] // Note: Mind the singular!
type TrackParams = Operations['get_tracks_2']['parameters']['query']
type ArtistResponse = Operations['get_artists_2']['responses']['200']['content']['application/json'] type TrackResponse = operations['get_track_2']['responses']['200']['content']['application/json']
type ArtistResponse = operations['get_artist_2']['responses']['200']['content']['application/json']
/* Too bad the following is just wrong now:
const params: TrackParams = { const params: TrackParams = {
refresh: 'true' refresh: 'true'
// TypeScript will now show all available parameters with their types // TypeScript will now show all available parameters with their types
} }
*/
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {
@ -287,7 +298,7 @@ watch(showDeleteModal, (newValue) => {
v-if="artist && v-if="artist &&
store.state.auth.authenticated && store.state.auth.authenticated &&
artist.channel && artist.channel &&
artist.attributed_to.full_username === store.state.auth.fullUsername" artist.attributed_to?.full_username === store.state.auth.fullUsername"
icon="bi-trash" icon="bi-trash"
@click="showDeleteModal = true" @click="showDeleteModal = true"
> >

View File

@ -4,7 +4,6 @@ import type { Track, Library } from '~/types'
import { humanSize, momentFormat } from '~/utils/filters' import { humanSize, momentFormat } from '~/utils/filters'
import { computed, ref, watchEffect } from 'vue' import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import time from '~/utils/time' import time from '~/utils/time'
import axios from 'axios' import axios from 'axios'

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, reactive, watch, onMounted } from 'vue' import { computed, ref, reactive, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'

View File

@ -55,8 +55,11 @@ const el = useCurrentElement()
onMounted(() => { onMounted(() => {
for (const field of data.value.filter.fields) { for (const field of data.value.filter.fields) {
// @ts-expect-error We threw out Semantic UI types
const settings: SemanticUI.DropdownSettings = { const settings: SemanticUI.DropdownSettings = {
// @ts-expect-error value? any!
onChange (value) { onChange (value) {
// @ts-expect-error dropdown? any!
value = $(this).dropdown('get value').split(',') value = $(this).dropdown('get value').split(',')
if (field.type === 'list' && field.subtype === 'number') { if (field.type === 'list' && field.subtype === 'number') {
@ -75,11 +78,11 @@ onMounted(() => {
if (field.autocomplete) { if (field.autocomplete) {
selector += '.autocomplete' selector += '.autocomplete'
// @ts-expect-error Semantic UI types are incomplete
settings.fields = field.autocomplete_fields settings.fields = field.autocomplete_fields
settings.minCharacters = 1 settings.minCharacters = 1
settings.apiSettings = { settings.apiSettings = {
url: store.getters['instance/absoluteUrl'](`${field.autocomplete}?${field.autocomplete_qs}`), url: store.getters['instance/absoluteUrl'](`${field.autocomplete}?${field.autocomplete_qs}`),
// @ts-expect-error xhr? any!
beforeXHR (xhrObject) { beforeXHR (xhrObject) {
if (store.state.auth.oauth.accessToken) { if (store.state.auth.oauth.accessToken) {
xhrObject.setRequestHeader('Authorization', store.getters['auth/header']) xhrObject.setRequestHeader('Authorization', store.getters['auth/header'])
@ -87,6 +90,7 @@ onMounted(() => {
return xhrObject return xhrObject
}, },
// @ts-expect-error initialResponse? any!
onResponse (initialResponse) { onResponse (initialResponse) {
return !settings.fields?.remoteValues return !settings.fields?.remoteValues
? { results: initialResponse.results } ? { results: initialResponse.results }
@ -95,6 +99,7 @@ onMounted(() => {
} }
} }
// @ts-expect-error jquery lives!
$(el.value).find(selector).dropdown(settings) $(el.value).find(selector).dropdown(settings)
} }
}) })

View File

@ -56,7 +56,15 @@ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
const { t } = useI18n() const { t } = useI18n()
const actionFilters = computed(() => ({ q: query.value, ...props.filters })) const actionFilters = computed(() => ({ q: query.value, ...props.filters }))
const actions = computed(() => [ const actions = computed<{
name: string
label: string
isDangerous?: boolean
allowAll?: boolean
confirmColor?: 'success' | 'danger'
confirmationMessage?: string
filterChackable?: (item: any) => boolean
}[]>(() => [
{ {
name: 'delete', name: 'delete',
label: t('components.manage.library.AlbumsTable.action.delete.label'), label: t('components.manage.library.AlbumsTable.action.delete.label'),

View File

@ -54,16 +54,14 @@ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
] ]
const actionFilters = computed(() => ({ q: query.value, ...props.filters })) const actionFilters = computed(() => ({ q: query.value, ...props.filters }))
const actions = computed(() => [ const actions = computed(() => [{
{
name: 'delete', name: 'delete',
label: t('components.manage.library.ArtistsTable.action.delete.label'), label: t('components.manage.library.ArtistsTable.action.delete.label'),
confirmationMessage: t('components.manage.library.ArtistsTable.action.delete.warning'), confirmationMessage: t('components.manage.library.ArtistsTable.action.delete.warning'),
isDangerous: true, isDangerous: true,
allowAll: false, allowAll: false,
confirmColor: 'danger' confirmColor: 'danger'
} } as const])
])
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {

View File

@ -56,16 +56,14 @@ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
const { t } = useI18n() const { t } = useI18n()
const actionFilters = computed(() => ({ q: query.value, ...props.filters })) const actionFilters = computed(() => ({ q: query.value, ...props.filters }))
const actions = computed(() => [ const actions = computed(() => [{
{ name: 'delete',
name: 'delete', label: t('components.manage.library.LibrariesTable.action.delete.label'),
label: t('components.manage.library.LibrariesTable.action.delete.label'), confirmationMessage: t('components.manage.library.LibrariesTable.action.delete.warning'),
confirmationMessage: t('components.manage.library.LibrariesTable.action.delete.warning'), isDangerous: true,
isDangerous: true, allowAll: false,
allowAll: false, confirmColor: 'danger'
confirmColor: 'danger' } as const ])
}
])
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {

View File

@ -59,16 +59,14 @@ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
const { t } = useI18n() const { t } = useI18n()
const actionFilters = computed(() => ({ q: query.value, ...props.filters })) const actionFilters = computed(() => ({ q: query.value, ...props.filters }))
const actions = computed(() => [ const actions = computed(() => [{
{ name: 'delete',
name: 'delete', label: t('components.manage.library.TagsTable.action.delete.label'),
label: t('components.manage.library.TagsTable.action.delete.label'), confirmationMessage: t('components.manage.library.TagsTable.action.delete.warning'),
confirmationMessage: t('components.manage.library.TagsTable.action.delete.warning'), isDangerous: true,
isDangerous: true, allowAll: false,
allowAll: false, confirmColor: 'danger'
confirmColor: 'danger' } as const ])
}
])
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {

View File

@ -54,16 +54,14 @@ const orderingOptions: [OrderingField, keyof typeof sharedLabels.filters][] = [
const { t } = useI18n() const { t } = useI18n()
const actionFilters = computed(() => ({ q: query.value, ...props.filters })) const actionFilters = computed(() => ({ q: query.value, ...props.filters }))
const actions = computed(() => [ const actions = computed(() => [{
{ name: 'delete',
name: 'delete', label: t('components.manage.library.TracksTable.action.delete.label'),
label: t('components.manage.library.TracksTable.action.delete.label'), confirmationMessage: t('components.manage.library.TracksTable.action.delete.warning'),
confirmationMessage: t('components.manage.library.TracksTable.action.delete.warning'), isDangerous: true,
isDangerous: true, allowAll: false,
allowAll: false, confirmColor: 'danger'
confirmColor: 'danger' } as const ])
}
])
const isLoading = ref(false) const isLoading = ref(false)
const fetchData = async () => { const fetchData = async () => {

View File

@ -232,8 +232,9 @@ const labels = computed(() => ({
<div> <div>
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:current="page" v-model:page="page"
:paginate-by="paginateBy" :paginate-by="paginateBy"
:pages="result.count"
/> />
<span v-if="result && result.results.length > 0"> <span v-if="result && result.results.length > 0">

View File

@ -263,7 +263,8 @@ const labels = computed(() => ({
<div> <div>
<pagination <pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:current="page" v-model:page="page"
v-model:pages="result.count"
:compact="true" :compact="true"
:paginate-by="paginateBy" :paginate-by="paginateBy"
:total="result.count" :total="result.count"

View File

@ -231,7 +231,8 @@ const labels = computed(() => ({
<div> <div>
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:current="page" v-model:page="page"
v-model:pages="result.count"
:paginate-by="paginateBy" :paginate-by="paginateBy"
/> />

View File

@ -269,7 +269,8 @@ const labels = computed(() => ({
<div> <div>
<Pagination <Pagination
v-if="result && result.count > paginateBy" v-if="result && result.count > paginateBy"
v-model:current="page" v-model:page="page"
v-model:pages="result.count"
:paginate-by="paginateBy" :paginate-by="paginateBy"
/> />

View File

@ -10,7 +10,9 @@ import type { Playlist } from '~/types'
const { t } = useI18n() const { t } = useI18n()
const play = defineEmit<[playlist: Playlist]>() // TODO: Check if the following emit is ever caught anywhere
// const play = defineEmit<[playlist: Playlist]>()
const { playlist } = defineProps<{playlist: Playlist}>() const { playlist } = defineProps<{playlist: Playlist}>()
const covers = computed(() => playlist.album_covers const covers = computed(() => playlist.album_covers

View File

@ -4,7 +4,7 @@ import type { Playlist } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue' import PlayButton from '~/components/audio/PlayButton.vue'
import defaultCover from '~/assets/audio/default-cover.png' import defaultCover from '~/assets/audio/default-cover.png'
import { momentFormat } from '~/utils/filters' import { momentFormat } from '~/utils/filters'
import { ref, computed, reactive } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -54,9 +54,10 @@ function shuffleArray (array: string[]): string[] {
const randomizedColors = computed(() => shuffleArray(bgcolors.value)) const randomizedColors = computed(() => shuffleArray(bgcolors.value))
const goToPlaylist = () => { // TODO: Chseck if the following function has a use
router.push({ name: 'library.playlists.detail', params: { id: props.playlist.id } }) // const goToPlaylist = () => {
} // router.push({ name: 'library.playlists.detail', params: { id: props.playlist.id } })
// }
const updatedTitle = computed(() => { const updatedTitle = computed(() => {
const date = momentFormat(new Date(props.playlist.modification_date ?? '1970-01-01')) const date = momentFormat(new Date(props.playlist.modification_date ?? '1970-01-01'))

View File

@ -2,7 +2,6 @@
import type { Playlist } from '~/types' import type { Playlist } from '~/types'
import PlaylistsCard from '~/components/playlists/Card.vue' import PlaylistsCard from '~/components/playlists/Card.vue'
import Layout from '~/components/ui/Layout.vue'
interface Props { interface Props {
playlists: Playlist[] playlists: Playlist[]

View File

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Playlist, PrivacyLevel, BackendError } from '~/types' import type { Playlist, BackendError } from '~/types'
import type { components } from '~/generated/types'
import { useVModels, useCurrentElement } from '@vueuse/core' import { useVModels } from '@vueuse/core'
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
@ -16,7 +17,6 @@ import Alert from '~/components/ui/Alert.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Input from '~/components/ui/Input.vue' import Input from '~/components/ui/Input.vue'
import Slider from '~/components/ui/Slider.vue' import Slider from '~/components/ui/Slider.vue'
import Textarea from '~/components/ui/Textarea.vue'
interface Events { interface Events {
(e: 'update:playlist', value: Playlist): void (e: 'update:playlist', value: Playlist): void
@ -56,10 +56,9 @@ const sharedLabels = useSharedLabels()
const privacyLevelChoices = { const privacyLevelChoices = {
me: sharedLabels.fields.privacy_level.choices.me, me: sharedLabels.fields.privacy_level.choices.me,
instance: sharedLabels.fields.privacy_level.choices.instance, instance: sharedLabels.fields.privacy_level.choices.instance,
followers: sharedLabels.fields.privacy_level.choices.followers,
everyone: sharedLabels.fields.privacy_level.choices.everyone everyone: sharedLabels.fields.privacy_level.choices.everyone
} as const satisfies Record<PrivacyLevel, string> } as const satisfies Record<components['schemas']['PrivacyLevelEnum'], string>
const el = useCurrentElement()
const isLoading = ref(false) const isLoading = ref(false)
const submit = async () => { const submit = async () => {

View File

@ -15,7 +15,6 @@ import { generateTrackCreditString } from '~/utils/utils'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Link from '~/components/ui/Link.vue' import Link from '~/components/ui/Link.vue'
import Alert from '~/components/ui/Alert.vue' import Alert from '~/components/ui/Alert.vue'
import Input from '~/components/ui/Input.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
const logger = useLogger() const logger = useLogger()

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { useRouter } from 'vue-router'
import type { Channel } from '~/types' import type { Channel } from '~/types'
import OptionsButton from '~/components/ui/button/Options.vue' import OptionsButton from '~/components/ui/button/Options.vue'
@ -13,17 +12,12 @@ const timeAgo = useTimeAgo(new Date(podcast.artist?.modification_date ?? new Dat
<template> <template>
<Card <Card
:title="podcast.uuid" :title="podcast.uuid || '' /* TODO: This is probably not what we want as a title? */"
:image="podcast.artist?.cover?.urls.original" :image="podcast.artist?.cover?.urls.original"
class="podcast-card" class="podcast-card"
@click="navigate" :to="/* TODO: where should it link? */ ''"
> >
<a {{ podcast.artist?.name }}
class="funkwhale link"
@click.stop="navigate"
>
{{ podcast.artist?.name }}
</a>
<template #footer> <template #footer>
{{ timeAgo }} {{ timeAgo }}

View File

@ -1,30 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePastel } from '~/composables/color' import type { ComponentProps } from 'vue-component-type-helpers'
import { FwCard, FwPlayButton } from '~/components' import type { Radio } from '~/types'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { PastelProps } from '~/types/common-props'
import type { Radio } from '~/types/models' import Card from '~/components/ui/Card.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
const { t } = useI18n() const { t } = useI18n()
interface Props { const play = defineEmit<[radio: Radio]>()
const { radio, small, ...cardProps } = defineProps<{
radio: Radio radio: Radio
small?: boolean small?: boolean
} } & ComponentProps<typeof Card>>()
const play = defineEmit<[radio: Radio]>()
const props = defineProps<Props & PastelProps>()
const pastel = usePastel(() => props.color)
</script> </script>
<template> <template>
<fw-card <Card
v-bind="cardProps"
:title="radio.name" :title="radio.name"
:class="pastel"
class="radio-card" class="radio-card"
@click="navigate" :to="/* TODO: get correct route here */ ''"
> >
<template #image> <template #image>
<div class="cover-name"> <div class="cover-name">
@ -32,7 +29,7 @@ const pastel = usePastel(() => props.color)
</div> </div>
</template> </template>
<fw-play-button @play="play(props.radio)" /> <PlayButton @play="play(radio)" />
<div <div
v-if="!small" v-if="!small"
@ -40,7 +37,7 @@ const pastel = usePastel(() => props.color)
> >
{{ radio.description }} {{ radio.description }}
</div> </div>
</fw-card> </Card>
</template> </template>
<style lang="scss"> <style lang="scss">

View File

@ -16,15 +16,10 @@ const { track, user } = defineProps<{ track: Track, user: User }>()
const router = useRouter() const router = useRouter()
const navigate = (to: 'track' | 'artist' | 'user') => const navigate = (to: 'track' | 'user') =>
to === 'track' to === 'track'
? router.push({ name: 'library.tracks.detail', params: { id: track.id } }) ? router.push({ name: 'library.tracks.detail', params: { id: track.id } })
: to === 'artist' : router.push({ name: 'profile.full', params: profileParams.value })
? router.push({ name: 'library.artists.detail', params: { id: track.artist.id } })
: router.push({ name: 'profile.full', params: profileParams.value })
const artistCredit = track.artist_credit || []
const firstArtist = artistCredit.length > 0 ? artistCredit[0].artist : null
const profileParams = computed(() => { const profileParams = computed(() => {
const [username, domain] = user.full_username.split('@') const [username, domain] = user.full_username.split('@')
@ -50,10 +45,15 @@ const profileParams = computed(() => {
{{ track.title }} {{ track.title }}
</div> </div>
<a <a
v-for="{ artist } in track.artist_credit"
:key="artist.id"
class="funkwhale link artist" class="funkwhale link artist"
@click.stop="navigate('artist')" @click.stop="router.push({
name: 'library.artists.detail',
params: { id: artist.id }
})"
> >
{{ firstArtist?.name /* TODO: Multi-Artist! */ }} {{ artist.name }}
</a> </a>
<a <a
class="funkwhale link user" class="funkwhale link user"

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, useSlots } from 'vue' import { computed, onMounted, ref, useSlots } from 'vue'
import { type RouterLinkProps, RouterLink, useLink } from 'vue-router' import { type RouterLinkProps, RouterLink } from 'vue-router'
import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color' import { type ColorProps, type DefaultProps, type VariantProps, color, isNoColors } from '~/composables/color'
import { type WidthProps, width } from '~/composables/width' import { type WidthProps, width } from '~/composables/width'
import { type AlignmentProps, align } from '~/composables/alignment' import { type AlignmentProps, align } from '~/composables/alignment'

View File

@ -6,7 +6,7 @@ import showdown from 'showdown'
import SanitizedHtml from './SanitizedHtml.vue' import SanitizedHtml from './SanitizedHtml.vue'
interface Props { interface Props {
md: string | null md: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()

View File

@ -62,6 +62,8 @@ const props = defineProps<{
@click="() => expand ? expand() : collapse ? collapse() : (() => { return })()" @click="() => expand ? expand() : collapse ? collapse() : (() => { return })()"
> >
<slot name="topleft" /> <slot name="topleft" />
<!-- @vue-ignore -->
<Heading v-bind="props" /> <Heading v-bind="props" />
</Button> </Button>
<i <i

View File

@ -163,9 +163,6 @@ onMounted(() => {
pointer-events: none; pointer-events: none;
opacity: var(--slider-opacity); opacity: var(--slider-opacity);
} }
input:focus~.range {
// focused style
}
input[type=range]::-moz-range-thumb { input[type=range]::-moz-range-thumb {
background-color: var(--fw-primary); background-color: var(--fw-primary);
transition: all .1s; transition: all .1s;

View File

@ -1,15 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from '../Button.vue' import Button from '../Button.vue'
interface Props { defineProps<{
isSquare?: boolean isSquare?: boolean
isGhost?: boolean isGhost?: boolean
} }>()
const props = withDefaults(defineProps<Props>(), {
isSquare: false,
isGhost: false
})
</script> </script>
<template> <template>

View File

@ -1,4 +1,5 @@
import type { PrivacyLevel, ImportStatus } from '~/types' import type { ImportStatus } from '~/types'
import type { components } from '~/generated/types'
import type { ScopeId } from '~/composables/auth/useScopes' import type { ScopeId } from '~/composables/auth/useScopes'
import { i18n } from '~/init/locale' import { i18n } from '~/init/locale'
@ -13,13 +14,15 @@ export default () => ({
choices: { choices: {
me: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.private'), me: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.private'),
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.instance'), instance: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.instance'),
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.public') everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.public')
} as Record<PrivacyLevel, string>, } satisfies Record<components['schemas']['PrivacyLevelEnum'], string>,
shortChoices: { shortChoices: {
me: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.private'), me: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.private'),
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.instance'), instance: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.instance'),
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.public') everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.public')
} as Record<PrivacyLevel, string> } satisfies Record<components['schemas']['PrivacyLevelEnum'], string>
}, },
import_status: { import_status: {
label: t('composables.locale.useSharedLabels.fields.importStatus.label'), label: t('composables.locale.useSharedLabels.fields.importStatus.label'),

View File

@ -16,7 +16,7 @@ export interface EditableConfigField extends ConfigField {
id: EditObjectType id: EditObjectType
} }
export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to: Actor } export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to?: Actor }
export type EditObjectType = 'artist' | 'album' | 'track' export type EditObjectType = 'artist' | 'album' | 'track'
type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }> type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }>

View File

@ -42,13 +42,15 @@ const styles = {
...widths, ...sizes ...widths, ...sizes
} as const satisfies Record<Key, string | ((w: string) => string)> } as const satisfies Record<Key, string | ((w: string) => string)>
const getStyle = (props: Partial<WidthProps>) => (key: Key) => const getStyle = (props: Partial<WidthProps>) => (key: Key):string =>
typeof styles[key] === 'function' && key in props key in props
? styles[key]( ? typeof styles[key] === 'function'
// @ts-expect-error Typescript is hard. Make the typescript compiler understand `key in props` ? styles[key](
props[key] // @ts-expect-error Typescript is hard. Make the typescript compiler understand `key in props`
) props[key]
: styles[key] )
: styles[key] as string
: ''
// All keys are exclusive // All keys are exclusive
const conflicts: Set<Key>[] = [ const conflicts: Set<Key>[] = [

View File

@ -5,7 +5,6 @@ import { parseAPIErrors } from '~/utils'
import { i18n } from './locale' import { i18n } from './locale'
import createAuthRefreshInterceptor from 'axios-auth-refresh' import createAuthRefreshInterceptor from 'axios-auth-refresh'
import moment from 'moment'
import axios from 'axios' import axios from 'axios'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
@ -61,23 +60,31 @@ export const install: InitModule = ({ store, router }) => {
break break
case 429: { case 429: {
let message let message = ''
// TODO: Find out if the following fields are still relevant
const rateLimitStatus: RateLimitStatus = { const rateLimitStatus: RateLimitStatus = {
limit: error.response?.headers['x-ratelimit-limit'], limit: error.response?.headers['x-ratelimit-limit'],
scope: error.response?.headers['x-ratelimit-scope'], // scope: error.response?.headers['x-ratelimit-scope'],
remaining: error.response?.headers['x-ratelimit-remaining'], remaining: error.response?.headers['x-ratelimit-remaining'],
duration: error.response?.headers['x-ratelimit-duration'], duration: error.response?.headers['x-ratelimit-duration'],
availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'), // availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
reset: error.response?.headers['x-ratelimit-reset'], reset: error.response?.headers['x-ratelimit-reset'],
resetSeconds: error.response?.headers['x-ratelimit-resetseconds'] // resetSeconds: error.response?.headers['x-ratelimit-resetseconds']
// The following fields were missing:
id: '',
rate: '',
description: '',
available: 0,
available_seconds: 0,
reset_seconds: 0
} }
if (rateLimitStatus.availableSeconds) { message = t('init.axios.rateLimitLater')
const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true) // if (rateLimitStatus.availableSeconds) {
message = t('init.axios.rateLimitDelay', { delay: tryAgain }) // const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
} else { // message = t('init.axios.rateLimitDelay', { delay: tryAgain })
message = t('init.axios.rateLimitLater') // }
}
error.backendErrors.push(message) error.backendErrors.push(message)
error.isHandled = true error.isHandled = true

View File

@ -3017,6 +3017,7 @@
"privacyLevel": { "privacyLevel": {
"choices": { "choices": {
"instance": "Everyone on this instance", "instance": "Everyone on this instance",
"followers": "My followers",
"private": "Nobody except me", "private": "Nobody except me",
"public": "Everyone, across all instances" "public": "Everyone, across all instances"
}, },
@ -3024,7 +3025,8 @@
"label": "Activity visibility", "label": "Activity visibility",
"shortChoices": { "shortChoices": {
"instance": "Instance", "instance": "Instance",
"private": "Private", "followers": "Followers",
"private": "Nobody except me",
"public": "Everyone" "public": "Everyone"
} }
}, },

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { paths, components } from '~/generated/types.ts' import type { paths } from '~/generated/types.ts'
import type { RadioConfig } from '~/store/radios' import type { RadioConfig } from '~/store/radios'
import axios from 'axios' import axios from 'axios'
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'

View File

@ -1,25 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useStore } from '~/store' import { useStore } from '~/store'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { useModal } from '~/ui/composables/useModal.ts' import { useModal } from '~/ui/composables/useModal.ts'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue' import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue' import Layout from '~/components/ui/Layout.vue'
import Spacer from '~/components/ui/Spacer.vue' import Spacer from '~/components/ui/Spacer.vue'
import Alert from '~/components/ui/Alert.vue'
import Card from '~/components/ui/Card.vue' import Card from '~/components/ui/Card.vue'
import type { Actor, Channel, PrivacyLevel } from '~/types' import type { Channel, PrivacyLevel } from '~/types'
import type { paths } from '~/generated/types'
import axios from 'axios'
import FileUploadWidget from '~/components/library/FileUploadWidget.vue'
import type { VueUploadItem } from 'vue-upload-component'
import ChannelUpload from '~/components/channels/UploadForm.vue' import ChannelUpload from '~/components/channels/UploadForm.vue'
import LibraryUpload from '~/components/library/FileUpload.vue' import LibraryUpload from '~/components/library/FileUpload.vue'
import LibraryWidget from '~/components/federation/LibraryWidget.vue'
const { t } = useI18n() const { t } = useI18n()
const store = useStore() const store = useStore()

View File

@ -1,4 +1,5 @@
import type { Track, SimpleArtist, Album, ArtistCredit, QueueItemSource } from '~/types' import type { Track, Album, ArtistCredit, QueueItemSource } from '~/types'
import type { components } from '~/generated/types'
import { useStore } from '~/store' import { useStore } from '~/store'
import type { QueueTrack } from '~/composables/audio/queue' import type { QueueTrack } from '~/composables/audio/queue'
@ -39,7 +40,7 @@ export function getArtistCoverUrl (artistCredits: ArtistCredit[]): string | unde
return undefined return undefined
} }
const getSimpleArtistCover = (artist: SimpleArtist) => const getSimpleArtistCover = (artist: components['schemas']['SimpleChannelArtist'] | components['schemas']['Artist'] | components['schemas']['ArtistWithAlbums']) =>
(field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') => (field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') =>
artist.cover artist.cover
? (field in artist.cover ? artist.cover.urls[field] : null) ? (field in artist.cover ? artist.cover.urls[field] : null)
@ -50,5 +51,5 @@ const getSimpleArtistCover = (artist: SimpleArtist) =>
* @param artist: a simple artist * @param artist: a simple artist
* @param field: the size you want * @param field: the size you want
*/ */
export const getSimpleArtistCoverUrl = (artist: SimpleArtist, field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') => export const getSimpleArtistCoverUrl = (artist: components['schemas']['SimpleChannelArtist'] | components['schemas']['Artist'] | components['schemas']['ArtistWithAlbums'], field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') =>
store.getters['instance/absoluteUrl'](getSimpleArtistCover(artist)(field)) store.getters['instance/absoluteUrl'](getSimpleArtistCover(artist)(field))

View File

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store' import { useStore } from '~/store'
import { useI18n } from 'vue-i18n'
import { onMounted } from 'vue' import { onMounted } from 'vue'
interface Props { interface Props {
@ -12,6 +13,7 @@ const props = defineProps<Props>()
const router = useRouter() const router = useRouter()
const store = useStore() const store = useStore()
const { t } = useI18n()
onMounted(async () => { onMounted(async () => {
await store.dispatch('auth/handleOauthCallback', props.code) await store.dispatch('auth/handleOauthCallback', props.code)

View File

@ -57,7 +57,7 @@ const isOver = computed(() => pendingUploads.length === processedUploads.value.l
const isSuccessfull = computed(() => pendingUploads.length === finishedUploads.value.length) const isSuccessfull = computed(() => pendingUploads.length === finishedUploads.value.length)
watch(() => store.state.channels.latestPublication, (value) => { watch(() => store.state.channels.latestPublication, (value) => {
if (value?.channel.uuid === props.object.uuid && value.uploads.length > 0) { if (value?.channel.uuid === props.object.uuid && value?.uploads && value?.uploads.length > 0) {
pendingUploads.push(...value.uploads) pendingUploads.push(...value.uploads)
} }
}) })

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Library, PrivacyLevel } from '~/types' import type { Library, PrivacyLevel } from '~/types'
import { humanSize } from '~/utils/filters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed } from 'vue' import { computed } from 'vue'
@ -24,7 +23,8 @@ const { t } = useI18n()
const sharedLabels = useSharedLabels() const sharedLabels = useSharedLabels()
const sizeLabel = computed(() => t('views.content.libraries.Card.label.size')) // TODO: Check if this is still needed:
// const sizeLabel = computed(() => t('views.content.libraries.Card.label.size'))
const privacyTooltips = (level: PrivacyLevel) => `Visibility: ${sharedLabels.fields.privacy_level.choices[level].toLowerCase()}` const privacyTooltips = (level: PrivacyLevel) => `Visibility: ${sharedLabels.fields.privacy_level.choices[level].toLowerCase()}`
</script> </script>
@ -59,10 +59,12 @@ const privacyTooltips = (level: PrivacyLevel) => `Visibility: ${sharedLabels.fie
</span> </span>
</h4> </h4>
<div class="description"> <!-- TODO: Does library have a description? Its schema has not. -->
<!-- <div class="description">
{{ library.description }} {{ library.description }}
<div class="ui hidden divider" /> <div class="ui hidden divider" />
</div> </div> -->
<div class="content"> <div class="content">
<i class="music icon" /> <i class="music icon" />
{{ t('views.content.libraries.Card.meta.tracks', library.uploads_count) }} {{ t('views.content.libraries.Card.meta.tracks', library.uploads_count) }}

View File

@ -1,20 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { humanSize } from '~/utils/filters' import { humanSize } from '~/utils/filters'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed, ref, type Ref, defineAsyncComponent } from 'vue' import { computed, ref, type Ref } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
// LIBRARIES BEGIN // LIBRARIES BEGIN
import type { Library, Channel } from '~/types' import type { Library, Channel } from '~/types'
import { useRouter } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import LibraryForm from '../libraries/Form.vue'
import LibraryCard from '../libraries/CardUpload.vue' import LibraryCard from '../libraries/CardUpload.vue'
import ChannelCard from '../channels/CardUpload.vue' import ChannelCard from '../channels/CardUpload.vue'
import Quota from '../libraries/Quota.vue'
import Upload from '~/ui/pages/upload.vue' import Upload from '~/ui/pages/upload.vue'
// import UploadModal from '~/ui/pages/upload.vue' // import UploadModal from '~/ui/pages/upload.vue'
// const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue')) // const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
@ -25,8 +22,6 @@ import useErrorHandler from '~/composables/useErrorHandler'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter()
const libraries = ref([] as Library[]) const libraries = ref([] as Library[])
const channels = ref([] as Channel[]) const channels = ref([] as Channel[])
@ -69,9 +64,10 @@ const fetchChannels = async () => {
fetchLibraries() fetchLibraries()
fetchChannels() fetchChannels()
const libraryCreated = (library: Library) => { // TODO: Check if this is needed:
router.push({ name: 'library.detail', params: { id: library.uuid } }) // const libraryCreated = (library: Library) => {
} // router.push({ name: 'library.detail', params: { id: library.uuid } })
// }
// LIBRARIES END // LIBRARIES END
@ -122,9 +118,11 @@ const openModal = (object_: Library | Channel) => {
:title="'New Library'" :title="'New Library'"
/> />
<!-- TODO: Check what value `new` should be -->
<library-card <library-card
v-for="library in libraries" v-for="library in libraries"
:key="library.uuid" :key="library.uuid"
:new="false"
:library="library" :library="library"
/> />

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import type { Library } from '~/types' import type { Library } from '~/types'
import { onBeforeRouteLeave } from 'vue-router' import { onBeforeRouteLeave } from 'vue-router'
@ -15,8 +16,10 @@ interface Props {
defaultImportReference?: string defaultImportReference?: string
} }
const privacyLevel= computed(() => props.object.privacy_level)
const emit = defineEmits<Events>() const emit = defineEmits<Events>()
withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
defaultImportReference: '' defaultImportReference: ''
}) })
@ -39,6 +42,7 @@ onBeforeRouteLeave((to, from, next) => {
<section> <section>
<file-upload <file-upload
ref="fileupload" ref="fileupload"
v-model="privacyLevel"
:default-import-reference="defaultImportReference" :default-import-reference="defaultImportReference"
:library="object" :library="object"
@uploads-finished="emit('uploads-finished', $event)" @uploads-finished="emit('uploads-finished', $event)"

View File

@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PlaylistTrack, Playlist, Library } from '~/types' import type { PlaylistTrack, Playlist, Track } from '~/types'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useStore } from '~/store' import { useStore } from '~/store'
import { momentFormat } from '~/utils/filters'
import axios from 'axios' import axios from 'axios'
@ -28,9 +26,10 @@ import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
import useErrorHandler from '~/composables/useErrorHandler' import useErrorHandler from '~/composables/useErrorHandler'
interface Events { // TODO: Is this event ever caught somewhere?
(e: 'libraries-loaded', libraries: Library[]): void // interface Events {
} // (e: 'libraries-loaded', libraries: Library[]): void
// }
interface Props { interface Props {
id: number id: number
@ -42,7 +41,6 @@ const props = withDefaults(defineProps<Props>(), {
}) })
const store = useStore() const store = useStore()
const router = useRouter()
const edit = ref(props.defaultEdit) const edit = ref(props.defaultEdit)
const playlist = ref<Playlist | null>(null) const playlist = ref<Playlist | null>(null)
@ -50,7 +48,13 @@ const playlistTracks = ref<PlaylistTrack[]>([])
const showEmbedModal = ref(false) const showEmbedModal = ref(false)
const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ({ ...track, position: index + 1 }))) // TODO: Compute `tracks` with `track`. In the new types, `track` is just a string.
// We probably have to load each track before loading it into `tracks`.
// Original line: const tracks = computed(() => playlistTracks.value.map(({ track }, index) => ({ ...track, position: index + 1 })))
const tracks = computed(() => playlistTracks.value.map(({ track }, index) => (
// @i-would-expect-ts-to-error because this typecasting is evil
{ position: index + 1 } as Track
)))
const { t } = useI18n() const { t } = useI18n()
const labels = computed(() => ({ const labels = computed(() => ({
@ -108,20 +112,25 @@ function shuffleArray (array: string[]): string[] {
const randomizedColors = computed(() => shuffleArray(bgcolors.value)) const randomizedColors = computed(() => shuffleArray(bgcolors.value))
const updatedTitle = computed(() => { // TODO: Check if this ref is still needed
const date = momentFormat(new Date(playlist.value.modification_date ?? '1970-01-01')) // const updatedTitle = computed(() => {
return t('components.audio.ChannelCard.title', { date }) // const date = momentFormat(new Date(playlist.value?.modification_date ?? '1970-01-01'))
}) // return t('components.audio.ChannelCard.title', { date })
// })
const deletePlaylist = async () => { // TODO: Check if this function is still needed
try { // const deletePlaylist = async () => {
await axios.delete(`playlists/${props.id}/`) // try {
store.dispatch('playlists/fetchOwn') // await axios.delete(`playlists/${props.id}/`)
return router.push({ path: '/library' }) // store.dispatch('playlists/fetchOwn')
} catch (error) { // return router.push({ path: '/library' })
useErrorHandler(error as Error) // } catch (error) {
} // useErrorHandler(error as Error)
} // }
// }
// TODO: Implement shuffle
const shuffle = () => {}
</script> </script>
<template> <template>

View File

@ -12,8 +12,13 @@ import VueMacros from 'unplugin-vue-macros/vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills' import { nodePolyfills } from 'vite-plugin-node-polyfills'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
// We don't use port but, magically, it is necessary to set it here.
const port = +(process.env.VUE_PORT ?? 8080) const port = +(process.env.VUE_PORT ?? 8080)
// To prevent a linter warning, here is a partial Haiku:
export const exPort = port
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
envPrefix: ['VUE_', 'TAURI_', 'FUNKWHALE_SENTRY_'], envPrefix: ['VUE_', 'TAURI_', 'FUNKWHALE_SENTRY_'],