kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch '2476-deep-upload-links' into 'develop'
Draft: feat(front): When on own channel, upload modal skips channel selection and pre-selects current channel NOCHANGELOG Closes #2476 See merge request funkwhale/funkwhale!2965merge-requests/2965/merge
commit
ad31e4994a
|
@ -25,13 +25,14 @@
|
|||
"fmt:html": "node --experimental-strip-types node_modules/prettier/bin/prettier.cjs index.html public/embed.html --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rstore/vue": "0.6.18",
|
||||
"@sentry/tracing": "7.47.0",
|
||||
"@sentry/vue": "7.47.0",
|
||||
"@tauri-apps/api": "2.0.0-beta.1",
|
||||
"@types/jsmediatags": "3.9.6",
|
||||
"@vue/runtime-core": "3.3.11",
|
||||
"@vueuse/components": "10.6.1",
|
||||
"@vueuse/core": "10.6.1",
|
||||
"@vueuse/components": "13.7.0",
|
||||
"@vueuse/core": "13.7.0",
|
||||
"@vueuse/integrations": "10.6.1",
|
||||
"@vueuse/math": "10.6.1",
|
||||
"@vueuse/router": "10.6.1",
|
||||
|
@ -52,6 +53,7 @@
|
|||
"nanoid": "5.0.4",
|
||||
"pinia": "2.1.7",
|
||||
"showdown": "2.1.0",
|
||||
"stable-hash": "0.0.6",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"standardized-audio-context": "25.3.60",
|
||||
"string-similarity-js": "2.1.4",
|
||||
|
@ -79,6 +81,7 @@
|
|||
"@iconify/vue": "4.1.1",
|
||||
"@intlify/eslint-plugin-vue-i18n": "2.0.0",
|
||||
"@intlify/unplugin-vue-i18n": "2.0.0",
|
||||
"@pinia/colada-devtools": "0.1.6",
|
||||
"@tauri-apps/cli": "^2.0.2",
|
||||
"@types/diff": "5.0.9",
|
||||
"@types/dompurify": "3.0.5",
|
||||
|
|
|
@ -20,7 +20,6 @@ import Queue from '~/components/Queue.vue'
|
|||
import Sidebar from '~/ui/components/Sidebar.vue'
|
||||
import ShortcutsModal from '~/ui/modals/Shortcuts.vue'
|
||||
import LanguagesModal from '~/ui/modals/Language.vue'
|
||||
import SearchModal from '~/ui/modals/Search.vue'
|
||||
import UploadModal from '~/ui/modals/Upload.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
|
@ -116,7 +115,6 @@ store.dispatch('auth/fetchUser')
|
|||
<FilterModal v-if="store.state.auth.authenticated" />
|
||||
<ReportModal />
|
||||
<UploadModal v-if="store.state.auth.authenticated" />
|
||||
<SearchModal />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -90,6 +90,7 @@ watchEffect(async () => {
|
|||
const list = ref()
|
||||
const el = useCurrentElement()
|
||||
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
|
||||
// @ts-expect-error TODO: el should be typed more strictly
|
||||
const item = el.value?.querySelector('.queue-item.active')
|
||||
item?.scrollIntoView({
|
||||
behavior,
|
||||
|
|
|
@ -137,19 +137,19 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
|||
>
|
||||
<div class="activity-image">
|
||||
<img
|
||||
v-if="object.track.album && object.track.album.cover"
|
||||
v-if="object.track?.album && object.track?.album.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
@error="(e) => { e.target && object.track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop) : null }"
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.cover"
|
||||
v-else-if="object.track?.cover"
|
||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
|
||||
alt=""
|
||||
@error="(e) => { e.target && object.track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop) : null }"
|
||||
>
|
||||
<img
|
||||
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
|
||||
v-else-if="object.track?.artist_credit && object.track.artist_credit.length > 1"
|
||||
v-lazy="getArtistCoverUrl(object.track.artist_credit)"
|
||||
alt=""
|
||||
>
|
||||
|
|
|
@ -25,7 +25,7 @@ const { radio, small, ...cardProps } = defineProps<{
|
|||
>
|
||||
<template #image>
|
||||
<div class="cover-name">
|
||||
{{ t('vui.radio') }}
|
||||
{{ t('radio') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -156,6 +156,9 @@ onUnmounted(() =>
|
|||
<i :class="['bi', splitIcon]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Not a split button -->
|
||||
|
||||
<button
|
||||
v-else
|
||||
ref="button"
|
||||
|
@ -188,10 +191,15 @@ onUnmounted(() =>
|
|||
v-if="icon && icon.startsWith('right ')"
|
||||
:class="['bi', icon.replace('right ', '')]"
|
||||
/>
|
||||
<Loader
|
||||
<div
|
||||
v-if="isLoading"
|
||||
:container="false"
|
||||
/>
|
||||
style="position: relative;"
|
||||
>
|
||||
<Loader
|
||||
:container="false"
|
||||
style="position: absolute;"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -19,6 +19,8 @@ const props = defineProps<{
|
|||
image?: string | { src: string, style?: 'withPadding' }
|
||||
icon?: string
|
||||
|
||||
flat?: true
|
||||
|
||||
alertProps?: AlertProps
|
||||
} & Partial<RouterLinkProps>
|
||||
&(PastelProps | ColorProps | DefaultProps)
|
||||
|
@ -186,11 +188,10 @@ const attributes = computed(() =>
|
|||
|
||||
color: var(--fw-text-color);
|
||||
background-color: var(--fw-bg-color);
|
||||
box-shadow: 0px 2px 8px 0px var(--shadow-color);
|
||||
box-shadow: 0px 2px 8px 0px v-bind("flat?'transparent':'var(--shadow-color)'");
|
||||
|
||||
border-radius: var(--fw-border-radius);
|
||||
font-size: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
>.covering {
|
||||
position: absolute;
|
||||
|
@ -282,6 +283,7 @@ const attributes = computed(() =>
|
|||
}
|
||||
|
||||
>.content {
|
||||
overflow: hidden;
|
||||
padding: 0 var(--fw-card-padding);
|
||||
/* Consider making all line height, vertical paddings, margins and borders,
|
||||
a multiple of a global vertical rhythm so that side-by-side lines coincide */
|
||||
|
|
|
@ -25,7 +25,7 @@ const size = computed(() => (Object.entries(props).find(([key, value]) => value
|
|||
|
||||
<style module>
|
||||
/* Any heading */
|
||||
:is(h1, h2, h3, h4, h5, h6).heading { margin: 0; padding:0; vertical-align: baseline; align-self: baseline;}
|
||||
:is(h1, h2, h3, h4, h5, h6).heading { margin: 0; padding:0; vertical-align: baseline; align-self: baseline; scroll-margin: 1.2em; }
|
||||
|
||||
/* Page heading */
|
||||
:is(*, .vp-doc h3).pageheading { font-size: 36px; font-weight: 900; letter-spacing: -1px; }
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, defineExpose } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import { type ColorProps, type VariantProps, type DefaultProps, type RaisedProps, type PastelProps, color } from '~/composables/color.ts'
|
||||
|
@ -44,6 +44,10 @@ const { t } = useI18n()
|
|||
|
||||
const input = ref()
|
||||
|
||||
defineExpose({
|
||||
focus: () => input.value.focus()
|
||||
})
|
||||
|
||||
const previouslyFocusedElement = ref()
|
||||
|
||||
onMounted(() => props.autofocus && nextTick(() => {
|
||||
|
@ -162,9 +166,7 @@ const model = defineModel<string | number>({ required: true })
|
|||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '~/style/funkwhale.scss';
|
||||
|
||||
<style>
|
||||
.funkwhale.input {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
|
@ -177,17 +179,17 @@ const model = defineModel<string | number>({ required: true })
|
|||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-family: $font-main;
|
||||
font-family: inherit;
|
||||
line-height: 28px;
|
||||
border-radius: var(--fw-border-radius);
|
||||
cursor: text;
|
||||
|
||||
@include light-theme {
|
||||
/*@include light-theme {
|
||||
&.raised {
|
||||
background-color: #ffffff;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 4px var(--border-color);
|
||||
|
@ -221,14 +223,14 @@ const model = defineModel<string | number>({ required: true })
|
|||
content: ' *';
|
||||
}
|
||||
|
||||
> .prefix,
|
||||
> .input-right {
|
||||
& > .prefix,
|
||||
& > .input-right {
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: var(--fw-placeholder-color);
|
||||
}
|
||||
|
||||
> .prefix {
|
||||
& > .prefix {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
@ -236,8 +238,9 @@ const model = defineModel<string | number>({ required: true })
|
|||
height: calc(100% - var(--input-label-gap));
|
||||
min-width: 48px;
|
||||
display: flex;
|
||||
color: var(--color);
|
||||
|
||||
> i {
|
||||
& > i {
|
||||
font-size:18px;
|
||||
margin: auto;
|
||||
}
|
||||
|
@ -247,7 +250,7 @@ const model = defineModel<string | number>({ required: true })
|
|||
padding-left: 40px;
|
||||
}
|
||||
|
||||
> .input-right {
|
||||
& > .input-right {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
|
@ -272,7 +275,7 @@ const model = defineModel<string | number>({ required: true })
|
|||
|
||||
}
|
||||
|
||||
> .search {
|
||||
& > .search {
|
||||
> i {
|
||||
font-size:18px;
|
||||
}
|
||||
|
@ -285,18 +288,18 @@ const model = defineModel<string | number>({ required: true })
|
|||
padding-right: 140px;
|
||||
}
|
||||
|
||||
> .show-password {
|
||||
& > .show-password {
|
||||
justify-content:center;
|
||||
}
|
||||
&:has(>.show-password)>input {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
>.reset {
|
||||
&>.reset {
|
||||
min-width: auto;
|
||||
margin: 4px;
|
||||
|
||||
// Make button fit snuggly into rounded border
|
||||
/* Make button fit snuggly into rounded border */
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { type ColorProps, type DefaultProps, color } from '~/composables/color'
|
||||
import { watchEffect, ref, nextTick } from 'vue'
|
||||
import { watchEffect, ref, nextTick, computed } from 'vue'
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
|
||||
const { width: screenWidth } = useWindowSize
|
||||
()
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
|
@ -18,10 +22,26 @@ const props = defineProps<{
|
|||
cancel?: string | true,
|
||||
icon?: string,
|
||||
autofocus?: true | 'off'
|
||||
maximizeSize?: true
|
||||
} & (ColorProps | DefaultProps)>()
|
||||
|
||||
const isOpen = defineModel<boolean>({ default: false })
|
||||
|
||||
// maxWidth: the maximum width of the modal, given the current screen size
|
||||
const width = computed(() => {
|
||||
const padding = 32, gap = 32, column= 46, minMargin=4
|
||||
const maxColumnsPerScreen = Math.trunc((screenWidth.value - 2*padding -2*minMargin + gap) / (column + gap))
|
||||
|
||||
const maxNumberOfCards = props.maximizeSize ? 10 : 4
|
||||
const cardsThatFitOnScreen = Math.trunc(maxColumnsPerScreen/3)
|
||||
|
||||
const columns = Math.min( maxNumberOfCards, cardsThatFitOnScreen)*3
|
||||
|
||||
const max = `${columns * column + (columns - 1) * gap + padding * 2}px`
|
||||
|
||||
return ({ columns, max })
|
||||
})
|
||||
|
||||
const previouslyFocusedElement = ref()
|
||||
|
||||
// Handle focus and inertness of the elements behind the modal
|
||||
|
@ -29,7 +49,9 @@ watchEffect(() => {
|
|||
if (isOpen.value) {
|
||||
nextTick(() => {
|
||||
previouslyFocusedElement.value = document.activeElement
|
||||
previouslyFocusedElement.value?.blur()
|
||||
if(props.autofocus !== 'off'){
|
||||
previouslyFocusedElement.value?.blur()
|
||||
}
|
||||
document.querySelector('#app')?.setAttribute('inert', 'true')
|
||||
})
|
||||
} else {
|
||||
|
@ -46,7 +68,7 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition mode="out-in">
|
||||
<Transition>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="funkwhale overlay"
|
||||
|
@ -93,7 +115,10 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
section-heading
|
||||
:class="{ 'destructive-header': isdestructive }"
|
||||
/>
|
||||
<Spacer grow />
|
||||
<Spacer
|
||||
v-if="title !== ''"
|
||||
grow
|
||||
/>
|
||||
<Button
|
||||
icon="bi-x-lg"
|
||||
ghost
|
||||
|
@ -120,7 +145,11 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
</div>
|
||||
</Transition>
|
||||
|
||||
<slot />
|
||||
<slot
|
||||
:columns="width.columns"
|
||||
:cards-per-row="(numberOfColumns = 3)=> Math.trunc(width.columns/numberOfColumns)"
|
||||
:activities-per-row="(numberOfColumns = 4)=> Math.trunc(width.columns/numberOfColumns)"
|
||||
/>
|
||||
|
||||
<Spacer v-if="!$slots.actions" />
|
||||
</div>
|
||||
|
@ -158,11 +187,12 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
|
||||
box-shadow: 0 2px 12px 2px var(--shadow-color);
|
||||
border-radius: 1rem;
|
||||
max-width: min(90vw, 55rem);
|
||||
max-width: v-bind("width.max");
|
||||
width: 100%;
|
||||
|
||||
display: grid;
|
||||
max-height: 90vh;
|
||||
min-height: v-bind("maximizeSize?'90vh':'2rem'");
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
position: relative;
|
||||
|
@ -263,7 +293,7 @@ onKeyboardShortcut('escape', () => { isOpen.value = false })
|
|||
|
||||
.funkwhale.overlay:has(.over-popover) {
|
||||
/* override z-index */
|
||||
z-index: 999999;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.funkwhale.overlay {
|
||||
|
|
|
@ -8,12 +8,14 @@ import Spacer from '~/components/ui/Spacer.vue'
|
|||
import Button from '~/components/ui/Button.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Heading from '~/components/ui/Heading.vue'
|
||||
import Loader from '~/components/ui/Loader.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
columnsPerItem?: 1 | 2 | 3 | 4
|
||||
alignLeft?: boolean
|
||||
action?: { text: string, id?: string } & (ComponentProps<typeof Link> | ComponentProps<typeof Button>)
|
||||
icon?: string
|
||||
badge?: 'loading' | number
|
||||
} & {
|
||||
[H in `h${ '1' | '2' | '3' | '4' | '5' | '6' }`]? : string
|
||||
} & {
|
||||
|
@ -113,6 +115,18 @@ const headingProps = computed(() =>
|
|||
<i :class="['bi', icon]" />
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="badge"
|
||||
#after
|
||||
>
|
||||
<Loader v-if="badge==='loading'" />
|
||||
<div
|
||||
v-else
|
||||
:class="['solid', $style.badge]"
|
||||
>
|
||||
{{ badge }}
|
||||
</div>
|
||||
</template>
|
||||
</Heading>
|
||||
<Spacer grow />
|
||||
</template>
|
||||
|
@ -174,6 +188,23 @@ const headingProps = computed(() =>
|
|||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.badge.badge.badge.badge {
|
||||
font-weight: 1000;
|
||||
border-radius: 100%;
|
||||
height: 21px;
|
||||
min-width: 21px;
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
vertical-align: text-top;
|
||||
line-height: 20px;
|
||||
pointer-events: none;
|
||||
background-color: var(--color);
|
||||
color: var(--background-color);
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
// Thank you, css, for offering this weird alternative to !important
|
||||
header.left.left {
|
||||
justify-content: start;
|
||||
|
|
|
@ -155,7 +155,9 @@ const { resume, pause } = useRafFn(() => {
|
|||
const now = +new Date()
|
||||
const direction = scrollDirection.value
|
||||
|
||||
// @ts-expect-error TODO: type more strictly
|
||||
if (direction && el.value?.children[0] && !isTouch.value) {
|
||||
// @ts-expect-error TODO: type more strictly
|
||||
el.value.children[0].scrollTop += 200 / (now - lastDate) * (direction === 'up' ? -1 : 1)
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ export const install: InitModule = ({ store }) => {
|
|||
// NOTE: Due to Vuex 3, when using store in watchEffect, it results in an infinite loop after committing
|
||||
const { commit } = store
|
||||
|
||||
// TODO: Check if this duplication/caching is necessary. It adds indirection,
|
||||
const { width, height } = useWindowSize()
|
||||
watchEffect(() => {
|
||||
commit('ui/window', {
|
||||
|
|
|
@ -12,10 +12,6 @@
|
|||
"pressKeyToAction": "Press {key} to {action}",
|
||||
"preview": "Preview",
|
||||
"resetTo": "Reset to {previousValue}",
|
||||
"radio": "Radio",
|
||||
"albums": "{n} album | {n} albums",
|
||||
"tracks": "{n} track | {n} tracks",
|
||||
"episodes": "{n} episode | {n} episodes",
|
||||
"by-user": "by {username}",
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
|
@ -54,6 +50,24 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"artist": "Artist",
|
||||
"artists": "No artists | 1 artist | {n} artists",
|
||||
"album": "Album",
|
||||
"albums": "No albums | 1 album | {n} albums",
|
||||
"track": "Track",
|
||||
"tracks": "No tracks | 1 track | {n} tracks",
|
||||
"tag": "Tag",
|
||||
"tags": "No tags | 1 tag | {n} tags",
|
||||
"playlist": "Playlist",
|
||||
"playlists": "No playlists | 1 playlist | {n} playlists",
|
||||
"radio": "Radio",
|
||||
"radios": "No radios | 1 radio | {n} radios",
|
||||
"podcast": "Podcast",
|
||||
"podcasts": "No podcasts | 1 podcast | {n} podcasts",
|
||||
"serie": "Series",
|
||||
"series": "No series | 1 series | {n} series",
|
||||
"episode": "Episode",
|
||||
"episodes": "No episodes | 1 episode | {n} episodes",
|
||||
"components": {
|
||||
"About": {
|
||||
"description": {
|
||||
|
|
|
@ -4,12 +4,12 @@ import VueDOMPurifyHTML from 'vue-dompurify-html'
|
|||
|
||||
import store, { key } from '~/store'
|
||||
import router from '~/router'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import useTheme from '~/composables/useTheme'
|
||||
|
||||
import App from '~/App.vue'
|
||||
|
||||
import '~/api'
|
||||
|
@ -28,6 +28,7 @@ const pinia = createPinia()
|
|||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
app.use(store, key)
|
||||
app.use(VueDOMPurifyHTML)
|
||||
|
||||
|
@ -62,10 +63,10 @@ const waitForImportantModules = async () => {
|
|||
|
||||
waitForImportantModules()
|
||||
.then(() => logger.debug('Loading rest of the modules'))
|
||||
// NOTE: We load the modules in parallel
|
||||
// NOTE: We load the modules in parallel
|
||||
.then(() => Promise.all(Object.values(modules).map(module => module.install?.(moduleContext))))
|
||||
.catch(error => logger.error('Failed to load modules:', error))
|
||||
// NOTE: We need to mount the app after all modules are loaded
|
||||
// NOTE: We need to mount the app after all modules are loaded
|
||||
.finally(() => {
|
||||
logger.debug('Mounting app')
|
||||
app.mount('#app')
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import type { NavigationGuardNext, RouteLocationNamedRaw, RouteLocationNormalized } from 'vue-router'
|
||||
import type { Permission } from '~/store/auth'
|
||||
import type { Channel, Album } from '~/types'
|
||||
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import store from '~/store'
|
||||
import axios from 'axios'
|
||||
import { TAURI_DEFAULT_INSTANCE_URL } from '~/store/instance'
|
||||
|
||||
const logger = useLogger()
|
||||
|
@ -36,3 +38,59 @@ export const forceInstanceChooser = (to: RouteLocationNormalized, from: RouteLoc
|
|||
|
||||
return next()
|
||||
}
|
||||
|
||||
export const handleUploadModalChannel = async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
|
||||
// Reset the upload modal channel by default
|
||||
store.commit('channels/setUploadModalChannel', null)
|
||||
|
||||
// Only proceed if user is authenticated
|
||||
if (!store.state.auth.authenticated) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if navigating to a channel route
|
||||
if (to.name === 'channels.detail' || to.name === 'channels.detail.episodes') {
|
||||
const channelId = to.params.id as string
|
||||
if (channelId && store.state.auth.fullUsername) {
|
||||
const response = await axios.get(`channels/${channelId}/`)
|
||||
const channel: Channel = response.data
|
||||
|
||||
// Check if user owns this channel
|
||||
if (channel?.attributed_to?.full_username === store.state.auth.fullUsername) {
|
||||
store.commit('channels/setUploadModalChannel', channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if navigating to an album route that belongs to an owned channel
|
||||
else if (to.name === 'library.albums.detail') {
|
||||
const albumId = to.params.id as string
|
||||
if (albumId && store.state.auth.fullUsername) {
|
||||
const response = await axios.get(`albums/${albumId}/`)
|
||||
const album: Album = response.data
|
||||
|
||||
// Check if the album's artist has a channel and if user owns it
|
||||
if (album?.artist_credit && album.artist_credit.length > 0) {
|
||||
const artistWithChannel = album.artist_credit.find(ac => ac.artist?.channel)
|
||||
if (artistWithChannel?.artist?.channel) {
|
||||
const channelId = artistWithChannel.artist.channel
|
||||
// Fetch the full channel data
|
||||
const channelResponse = await axios.get(`channels/${channelId}/`)
|
||||
const channel: Channel = channelResponse.data
|
||||
|
||||
// Check ownership using attributed_to
|
||||
if (channel?.attributed_to?.full_username === store.state.auth.fullUsername) {
|
||||
store.commit('channels/setUploadModalChannel', channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Error in handleUploadModalChannel guard:', error)
|
||||
// Continue navigation even if there's an error
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { forceInstanceChooser } from './guards'
|
||||
import { forceInstanceChooser, handleUploadModalChannel } from './guards'
|
||||
|
||||
import routesV1 from './routes'
|
||||
import routesV2 from '~/ui/routes'
|
||||
|
@ -39,4 +39,8 @@ router.beforeEach((to, from, next) => {
|
|||
return forceInstanceChooser(to, from, next)
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
return handleUploadModalChannel(to, from, next)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
@ -61,6 +61,9 @@ const store: Module<State, RootState> = {
|
|||
}
|
||||
}
|
||||
},
|
||||
setUploadModalChannel (state, channel) {
|
||||
state.uploadModalConfig.channel = channel
|
||||
},
|
||||
publish (state, { uploads, channel }) {
|
||||
state.latestPublication = {
|
||||
date: new Date(),
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import type { BackendError, Track } from '~/types'
|
||||
// import type { BackendError, Track } from '~/types'
|
||||
import type { RootState } from '~/store/index'
|
||||
import type { Module } from 'vuex'
|
||||
import type {Track} from '~/types'
|
||||
import type {BackendError} from '~/types'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { usePlayer } from '~/composables/audio/player'
|
||||
|
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
--hover-color: var(--fw-gray-800);
|
||||
--hover-background-color: var(--fw-beige-400);
|
||||
--hover-border-color: var(--fw-gray-500);
|
||||
--hover-border-color: var(--hover-background-color);
|
||||
|
||||
--active-color: var(--fw-gray-900);
|
||||
--active-background-color: var(--fw-beige-600);
|
||||
|
@ -243,6 +243,7 @@
|
|||
--color: var(--fw-blue-900);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: var(--fw-pastel-blue-1);
|
||||
--hover-background-color: var(--fw-pastel-blue-2);
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: var(--fw-pastel-blue-2);
|
||||
|
@ -261,6 +262,7 @@
|
|||
--color: var(--fw-red-900);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: var(--fw-pastel-red-2);
|
||||
--hover-background-color: var(--fw-pastel-red-3);
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: var(--fw-pastel-red-2);
|
||||
|
@ -279,6 +281,7 @@
|
|||
--color: var(--fw-gray-970);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: var(--fw-pastel-purple-1);
|
||||
--hover-background-color: var(--fw-pastel-purple-2);
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: var(--fw-pastel-purple-2);
|
||||
|
@ -297,6 +300,7 @@
|
|||
--color: var(--fw-gray-900);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: var(--fw-pastel-green-1);
|
||||
--hover-background-color: var(--fw-pastel-green-2);
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: var(--fw-pastel-green-2);
|
||||
|
@ -315,6 +319,7 @@
|
|||
--color: var(--fw-gray-900);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: var(--fw-pastel-yellow-1);
|
||||
--hover-background-color: var(--fw-pastel-yellow-2);
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: var(--fw-pastel-yellow-2);
|
||||
|
@ -482,7 +487,9 @@
|
|||
.blue {
|
||||
--color: var(--fw-pastel-blue-1);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--hover-color: var(--fw-pastel-blue-2);
|
||||
--background-color: color-mix(in oklab, black 60%, var(--fw-pastel-blue-1));
|
||||
--hover-background-color: color-mix(in oklab, var(--background-color) 83%, var(--hover-color));
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: color-mix(in oklab, black 65%, var(--fw-pastel-blue-2));
|
||||
|
@ -498,6 +505,8 @@
|
|||
--color: var(--fw-pastel-red-1);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--background-color: color-mix(in oklab, black 60%, var(--fw-pastel-red-2));
|
||||
--hover-color: var(--fw-pastel-red-3);
|
||||
--hover-background-color: color-mix(in oklab, var(--background-color) 83%, var(--hover-color));
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: color-mix(in oklab, black 65%, var(--fw-pastel-red-2));
|
||||
|
@ -512,7 +521,9 @@
|
|||
.purple {
|
||||
--color: var(--fw-gray-100);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--hover-color: var(--fw-pastel-purple-1);
|
||||
--background-color: color-mix(in oklab, black 50%, var(--fw-pastel-purple-1));
|
||||
--hover-background-color: color-mix(in oklab, var(--background-color) 83%, var(--hover-color));
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: color-mix(in oklab, black 61%, var(--fw-pastel-purple-2));
|
||||
|
@ -527,7 +538,9 @@
|
|||
.green {
|
||||
--color: var(--fw-pastel-green-1);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--hover-color: var(--fw-pastel-green-2);
|
||||
--background-color: color-mix(in oklab, black 55%, var(--fw-pastel-green-1));
|
||||
--hover-background-color: color-mix(in oklab, var(--background-color) 83%, var(--hover-color));
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: color-mix(in oklab, black 60%, var(--fw-pastel-green-2));
|
||||
|
@ -542,7 +555,9 @@
|
|||
.yellow {
|
||||
--color: var(--fw-pastel-yellow-1);
|
||||
--color-over-transparent: var(--background-color);
|
||||
--hover-color: var(--fw-pastel-yellow-3);
|
||||
--background-color: color-mix(in oklab, black 53%, var(--fw-pastel-yellow-3));
|
||||
--hover-background-color: color-mix(in oklab, var(--background-color) 83%, var(--hover-color));
|
||||
&.raised,
|
||||
.action > button {
|
||||
--background-color: color-mix(in oklab, black 45%, var(--fw-pastel-yellow-3));
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useUploadsStore } from '../stores/upload'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
import { useModal } from '~/ui/composables/useModal.ts'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
|
||||
import Logo from '~/components/Logo.vue'
|
||||
import Input from '~/components/ui/Input.vue'
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
|
||||
import Link from '~/components/ui/Link.vue'
|
||||
import Popover from '~/components/ui/Popover.vue'
|
||||
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
import Layout from '~/components/ui/Layout.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
import SearchModal from '~/ui/modals/Search.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const isCollapsed = ref(true)
|
||||
|
@ -29,7 +28,6 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { value: searchParameter } = useModal('search')
|
||||
|
||||
const store = useStore()
|
||||
const uploads = useUploadsStore()
|
||||
|
@ -37,19 +35,6 @@ const logoUrl = computed(() => store.state.auth.authenticated ? 'library.index'
|
|||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Search bar focus
|
||||
|
||||
const isFocusingSearch = ref<true | undefined>(undefined)
|
||||
const focusSearch = () => {
|
||||
isFocusingSearch.value = undefined
|
||||
nextTick(() => {
|
||||
isFocusingSearch.value = true
|
||||
})
|
||||
}
|
||||
onKeyboardShortcut(['shift', 'f'], focusSearch, true)
|
||||
onKeyboardShortcut(['ctrl', 'k'], focusSearch, true)
|
||||
onKeyboardShortcut(['/'], focusSearch, true)
|
||||
|
||||
// Admin notifications
|
||||
|
||||
const moderationNotifications = computed(() =>
|
||||
|
@ -209,16 +194,18 @@ const moderationNotifications = computed(() =>
|
|||
stack
|
||||
:class="[$style['menu-links'], isCollapsed && 'hide-on-mobile']"
|
||||
>
|
||||
<Input
|
||||
:key="isFocusingSearch ? 1 : 0"
|
||||
<!-- The search input will live next to the search modal. It needs -->
|
||||
<!-- <Input
|
||||
ref="globalSearchInput"
|
||||
v-model="searchParameter"
|
||||
:autofocus="isFocusingSearch"
|
||||
raised
|
||||
autocomplete="search"
|
||||
type="search"
|
||||
icon="bi-search"
|
||||
:placeholder="t('components.audio.SearchBar.placeholder.search')"
|
||||
/>
|
||||
/> -->
|
||||
|
||||
<SearchModal />
|
||||
|
||||
<Spacer />
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { useCache } from './useCache'
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const retention = 10
|
||||
|
||||
test('Cache sets value synchronously', () => {
|
||||
const cache = useCache({ retention })
|
||||
|
||||
cache('K').value = ('V')
|
||||
expect(cache('K').value).toBe('V')
|
||||
})
|
||||
|
||||
test('Cache forgets value after a set time', async () => {
|
||||
const cache = useCache({ retention })
|
||||
|
||||
cache('K').value = ('V')
|
||||
await wait(retention);
|
||||
expect(cache('K').value).toBe(undefined)
|
||||
})
|
|
@ -0,0 +1,25 @@
|
|||
import { computed, ref } from 'vue'
|
||||
|
||||
export const allCaches = ref<Map<unknown, unknown>[]>([]);
|
||||
|
||||
/**
|
||||
* Initialize a forgetful, reactive key-value store.
|
||||
*
|
||||
* @param retention: Milliseconds until a datum is deleted
|
||||
* @returns a factory for reactive values by key
|
||||
*/
|
||||
export const useCache = <K, V>({ retention }: { retention: number }) => {
|
||||
const cache = new Map<K, V>()
|
||||
|
||||
allCaches.value.push(cache);
|
||||
|
||||
return (key: K) => computed({
|
||||
get() {
|
||||
return cache.get(key)
|
||||
},
|
||||
set(value: V) {
|
||||
cache.set(key, value);
|
||||
setTimeout(() => cache.delete(key), retention)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { computed } from 'vue'
|
||||
import { useRouter, type RouteLocationRaw, type LocationQuery } from 'vue-router'
|
||||
|
||||
type Assignment<T> = { on: (value : T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean }
|
||||
type Assignment<T> = { on: (value: T | null) => string | null, isOn: (value: LocationQuery[string]) => boolean }
|
||||
|
||||
export const exactlyNull:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value === null })
|
||||
export const notUndefined:Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value !== undefined })
|
||||
export const exactlyNull: Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value === null })
|
||||
export const notUndefined: Assignment<unknown> = ({ on: (_) => null, isOn: (value) => value !== undefined })
|
||||
|
||||
/**
|
||||
* Bind a modal to a single query parameter in the URL (and vice versa)
|
||||
|
@ -16,7 +16,7 @@ export const notUndefined:Assignment<unknown> = ({ on: (_) => null, isOn: (value
|
|||
*
|
||||
* This functionality completely independent from the `router` modules.
|
||||
*/
|
||||
export const useModal = <T> (
|
||||
export const useModal = <T>(
|
||||
flag: string,
|
||||
assignment: Assignment<T> = exactlyNull
|
||||
) => {
|
||||
|
@ -49,10 +49,10 @@ export const useModal = <T> (
|
|||
* @returns a `ref`<boolean>
|
||||
*/
|
||||
const isOpen = computed({
|
||||
get () {
|
||||
get() {
|
||||
return flag in query.value && assignment.isOn(query.value[flag])
|
||||
},
|
||||
set (newValue: boolean) {
|
||||
set(newValue: boolean) {
|
||||
router?.push({
|
||||
query: {
|
||||
...query.value,
|
||||
|
@ -73,12 +73,12 @@ export const useModal = <T> (
|
|||
* Use in inputs.
|
||||
*/
|
||||
const value = computed({
|
||||
get () {
|
||||
get() {
|
||||
const flagValue = flag in query.value ? query.value[flag] : ''
|
||||
return typeof flagValue === 'string' ? flagValue : flagValue === null ? '' : flagValue.join(' ')
|
||||
},
|
||||
set (newValue: string) {
|
||||
router?.push({
|
||||
set(newValue: string) {
|
||||
router?.replace({
|
||||
query: {
|
||||
...query.value,
|
||||
[flag]: newValue
|
||||
|
@ -119,7 +119,7 @@ export const useModal = <T> (
|
|||
}
|
||||
|
||||
/* All possible useModals that produce a given `RouterLink` destination */
|
||||
export const fromProps = <T>({ to } : { to?: RouteLocationRaw }, assignment: Assignment<T> = exactlyNull): ReturnType<typeof useModal>[] =>
|
||||
export const fromProps = <T>({ to }: { to?: RouteLocationRaw }, assignment: Assignment<T> = exactlyNull): ReturnType<typeof useModal>[] =>
|
||||
to && typeof to !== 'string' && 'query' in to && to.query
|
||||
? Object.keys(to.query).map(k => useModal(k, assignment))
|
||||
: []
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { useRateLimiter, type RateLimiterError, isRateLimiterError } from './useRateLimiter'
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const config = {
|
||||
cooldown: 10,
|
||||
supersedeWhen: (newTask: string, oldTask: string) => newTask.startsWith(oldTask),
|
||||
equalWhen: (newTask: string, oldTask: string) => newTask === oldTask
|
||||
}
|
||||
|
||||
test('If not currently in cooldown, new task starts immediately', async () => {
|
||||
const { greenlight } = useRateLimiter<string>(config)
|
||||
let count = 0
|
||||
const operation = () => count = 1
|
||||
|
||||
await greenlight('1')
|
||||
operation()
|
||||
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
|
||||
test('If currently in cooldown, new, non-superseding task are deferred', async () => {
|
||||
const { greenlight } = useRateLimiter<string>(config);
|
||||
let count = 0;
|
||||
|
||||
(async () => {
|
||||
await greenlight('A')
|
||||
count++
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
await greenlight('B')
|
||||
count++
|
||||
})()
|
||||
|
||||
// Task A will not start synchronously
|
||||
expect(count).toBe(0)
|
||||
|
||||
// It will finish asynchronously
|
||||
await wait(0)
|
||||
expect(count).toBe(1)
|
||||
|
||||
// After the cooldown period, task B is expected to have finished
|
||||
await wait(config.cooldown)
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
test('Superseded tasks are rejected', async () => {
|
||||
const { greenlight } = useRateLimiter<string>(config);
|
||||
const executedTasks: string[] = []
|
||||
|
||||
// Tasks started outside of cooldown cannot be superseded
|
||||
const promiseA
|
||||
= greenlight('A').then(() => executedTasks.push('A'))
|
||||
|
||||
// During cooldown, only the latest superseding task remains
|
||||
const promiseB_superseded
|
||||
= greenlight('B').then(() => executedTasks.push('B (should not run)'))
|
||||
|
||||
const promiseB1_superseding
|
||||
= greenlight('B1').then(() => executedTasks.push('B1'))
|
||||
|
||||
const promiseB11_superseding
|
||||
= greenlight('B11').then(() => executedTasks.push('B11'))
|
||||
|
||||
// Non-superseding tasks are added to the end of the queue
|
||||
const promiseC
|
||||
= greenlight('C').then(() => executedTasks.push('C'))
|
||||
|
||||
// Tasks can only supersede other tasks that were started during cooldown
|
||||
const promiseA1
|
||||
= greenlight('A1').then(() => executedTasks.push('A1'))
|
||||
|
||||
const [resultA, resultB, resultB1, resultB11, resultC, resultA1] = await Promise.allSettled([
|
||||
promiseA,
|
||||
promiseB_superseded,
|
||||
promiseB1_superseding,
|
||||
promiseB11_superseding,
|
||||
promiseC,
|
||||
promiseA1
|
||||
])
|
||||
|
||||
expect(resultA.status).toBe('fulfilled')
|
||||
expect(resultB11.status).toBe('fulfilled')
|
||||
expect(resultC.status).toBe('fulfilled')
|
||||
expect(resultA1.status).toBe('fulfilled')
|
||||
|
||||
expect(resultB.status).toBe('rejected')
|
||||
expect(((resultB as PromiseRejectedResult).reason as RateLimiterError).name).toBe('RateLimitedTaskSuperseded')
|
||||
expect(isRateLimiterError((resultB as PromiseRejectedResult).reason)).toBe(true)
|
||||
|
||||
expect(resultB1.status).toBe('rejected')
|
||||
expect(((resultB1 as PromiseRejectedResult).reason as RateLimiterError).name).toBe('RateLimitedTaskSuperseded')
|
||||
expect(isRateLimiterError((resultB1 as PromiseRejectedResult).reason)).toBe(true)
|
||||
|
||||
expect(executedTasks).toEqual(['A', 'B11', 'C', 'A1'])
|
||||
})
|
|
@ -0,0 +1,80 @@
|
|||
export type RateLimiterError = {
|
||||
name: 'RateLimitedTaskSuperseded';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const isRateLimiterError = (e: Error) =>
|
||||
e.name === 'RateLimitedTaskSuperseded'
|
||||
|
||||
/**
|
||||
* Queued-up tasks start immediately if cooldown period is over. Else, they are postponed to keep the minimum waiting time
|
||||
* between consecutive tasks.
|
||||
* An older planned task is replaced (superseded) by a newer one if they match via `supersedeWhen`.
|
||||
* This way, you can prevent excessively long trails, for example when an input triggers tasks on each keystroke.
|
||||
*
|
||||
* @param cooldown: Minimum waiting time between two consecutive tasks. Good values might be around 100-500ms.
|
||||
* Tasks coming in at a slower rate are executed immediately.
|
||||
* @param supersedeWhen: A task planned earlier will be replaced by the new task if this predicate holds true. Note that only future tasks can be replaced.
|
||||
* @param equalWhen: Used to check if task is still in the queue.
|
||||
*/
|
||||
export const useRateLimiter = <T>({ cooldown, supersedeWhen, equalWhen }: {
|
||||
cooldown: number,
|
||||
supersedeWhen: (newTask: T, oldTask: T) => boolean,
|
||||
equalWhen: (newTask: T, oldTask: T) => boolean
|
||||
}) => {
|
||||
|
||||
// The queue contains all tasks planned for the next tick
|
||||
let queue: T[] = [];
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Delays or cancels a task to keep the rate of costly operations below 1x per `cooldown`
|
||||
*
|
||||
* @param task: A unique(!), comparable label to identify the costly operation. Use JSON.stringify or an 'id' or 'key' field.
|
||||
* @param priority: (optional) `true` inserts the new task next in the queue. `replaceAll` additionally removes other tasks. No value (default) adds it to the end op the queue.
|
||||
* @returns a promise that resolves when the task can be executed, or throws if it has been superseded by a task greenlit later.
|
||||
*/
|
||||
const greenlight = async (task: T, priority?: true | 'replaceAll') => {
|
||||
|
||||
if (queue.length === 0 || priority === 'replaceAll') {
|
||||
|
||||
// Initialize a new queue
|
||||
queue = [task]
|
||||
|
||||
} else {
|
||||
|
||||
// Remove any superseded older tasks
|
||||
queue = queue.filter(t =>
|
||||
!supersedeWhen(task, t)
|
||||
)
|
||||
|
||||
// Insert new task into queue
|
||||
queue = priority
|
||||
? [task, ...queue]
|
||||
: [...queue, task]
|
||||
|
||||
}
|
||||
|
||||
// Wait until the task is next. If the task has been superseded, throw a rejection message
|
||||
while (queue.at(0) !== task) {
|
||||
if (!queue.find(t => equalWhen(task, t))) throw ({
|
||||
name: 'RateLimitedTaskSuperseded',
|
||||
message: `Task ${task} has been superseded by a newer task. Queue is now: ${queue}`
|
||||
} satisfies RateLimiterError)
|
||||
await wait(cooldown);
|
||||
}
|
||||
|
||||
// In the background, schedule the progression of the queue
|
||||
(async () => {
|
||||
await wait(cooldown);
|
||||
|
||||
queue = queue.filter(t => t !== task)
|
||||
})();
|
||||
|
||||
// Return immediately, resolving the promise for the caller.
|
||||
return;
|
||||
}
|
||||
|
||||
return { greenlight }
|
||||
}
|
Plik diff jest za duży
Load Diff
|
@ -1,27 +1,126 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
|
||||
import axios from 'axios'
|
||||
import hash from 'stable-hash'
|
||||
|
||||
import type { paths, components } from '~/generated/types'
|
||||
import type { Split } from 'type-fest'
|
||||
|
||||
import useLogger from '~/composables/useLogger'
|
||||
import { useRateLimiter, isRateLimiterError } from '~/ui/composables/useRateLimiter'
|
||||
|
||||
import type { Album, Artist, Tag, Track } from '~/types'
|
||||
import type { components } from '~/generated/types'
|
||||
|
||||
const logger = useLogger()
|
||||
|
||||
/** @returns the most unique identifier available per item, assuming every item has at least one key field */
|
||||
export const getKey = (item: { fid: string } | { artist: { fid: string } } | { id: number } | { name: string }) =>
|
||||
'fid' in item ? item.fid : 'artist' in item ? item.artist.fid : 'id' in item ? item.id.toString() : item.name
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// Object names
|
||||
const names = [
|
||||
'artists',
|
||||
'albums',
|
||||
'playlists',
|
||||
'tracks',
|
||||
'channels',
|
||||
'radios/radios',
|
||||
'tags'] as const satisfies (Exclude<Split<keyof paths, '/'>[3], 'search'> | 'radios/radios')[]
|
||||
type Name = typeof names[number]
|
||||
|
||||
// Paginated lists
|
||||
type PathMany<N extends Name> = `/api/v2/${N}/` // Has trailing slash in API
|
||||
type GetPaginatedResponses<N extends Name> = paths[PathMany<N>]['get']['responses'][200]['content']['application/json']
|
||||
type Params<N extends Name> = paths[PathMany<N>]['get']['parameters']['query']
|
||||
|
||||
// Single items by key
|
||||
type KeyType<N extends Name> =
|
||||
{ 'artists': 'artists/{id}',
|
||||
'albums': 'albums/{id}',
|
||||
'playlists': 'playlists/{uuid}',
|
||||
'tracks': 'tracks/{id}',
|
||||
'channels': 'channels/{composite}',
|
||||
'radios/radios': 'radios/radios/{id}',
|
||||
'tags': 'tags/{name}'
|
||||
}[N]
|
||||
|
||||
type PathFirst<N extends Name> = `/api/v2/${KeyType<N>}/` // Has trailing slash in API
|
||||
export type GetFirstResponse<N extends Name> = paths[PathFirst<N>]['get']['responses'][200]['content']['application/json']
|
||||
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// Remote data
|
||||
type Data<T> =
|
||||
| { status: 'notAsked', error?: null, data?: T }
|
||||
| { status: 'pending', error?: null, data?: T, lastUpdated: number }
|
||||
| { status: 'loading', error?: null, data?: T, lastUpdated: number }
|
||||
| { status: 'success', error?: null, data: T , lastUpdated: number }
|
||||
| { status: 'error', error: Error, data?: T, lastUpdated: number }
|
||||
|
||||
const notAsked = () => ({ status: 'notAsked' } as const)
|
||||
|
||||
const setPending = <T>(remoteData: Data<T>):Data<T> & {status: 'pending'} => ({
|
||||
...remoteData, status: 'pending', error: null, lastUpdated: Date.now()
|
||||
} as const)
|
||||
|
||||
const setLoading = <T>(remoteData: Data<T> & { status: 'pending' | 'success' | 'error' }):Data<T> & {status: 'loading'} => ({
|
||||
...remoteData, status: 'loading', error: null, lastUpdated: Date.now()
|
||||
} as const)
|
||||
|
||||
const setSuccess = <T>(remoteData: Data<T> & { status: 'loading' }, data: T):Data<T> & {status: 'success'} => ({
|
||||
...remoteData, status: 'success', data, error: null, lastUpdated: Date.now()
|
||||
} as const)
|
||||
|
||||
const setError = <T>(remoteData: Data<T> & { status: 'loading' }, error: Error):Data<T> & {status: 'error'} => ({
|
||||
...remoteData, status: 'error', error, lastUpdated: Date.now()
|
||||
} as const)
|
||||
|
||||
// ======================================================================
|
||||
// Search results
|
||||
|
||||
type Key = string
|
||||
|
||||
/**Global rate limiting for any Api call */
|
||||
const rateLimiter = useRateLimiter<[Name, Params<Name>]>({
|
||||
cooldown: 200, // max. 5 requests per second
|
||||
|
||||
// While the user is typing in a search field, only send the last call generated during a cooldown period
|
||||
supersedeWhen: ([newName, newFilter], [oldName, oldFilter]) =>
|
||||
newName === oldName && newFilter !== undefined && oldFilter !== undefined,
|
||||
|
||||
// Use `hash` can normalize and compare objects deeply.
|
||||
equalWhen: (newTask, oldTask) => hash(newTask) === hash(oldTask)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* **Fetching individual items by key**
|
||||
* - Prevent duplicate fetches: Caches the result for 1 second to prevent over-fetching and request duplication (override with { immediate: true})
|
||||
* - Share reactive objects across components: Avoid data duplication, and auto-update the Ui whenever an updated version of the data is re-fetched
|
||||
*
|
||||
* **Example**
|
||||
* ```ts
|
||||
* import { useDataStore } from '~/ui/stores/data'
|
||||
*
|
||||
* const data = useDataStore()
|
||||
*
|
||||
* artist15 = data.get("artist", "15") // Ref<Artist | undefined>
|
||||
* const 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.
|
||||
*
|
||||
* **Fetching paginated search results**
|
||||
* - Rate limited (currently 2 requests per seconds)
|
||||
*
|
||||
* ```ts
|
||||
* const albums = data.albums({ query: 'xyz' }) // Ref<{ status, data?, error? }>
|
||||
*
|
||||
* if (albums.value.status === 'error') albums.refetch();
|
||||
*````
|
||||
*
|
||||
* As soon as data arrives, 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()`
|
||||
|
@ -52,6 +151,8 @@ export const useDataStore
|
|||
track: {}
|
||||
}
|
||||
|
||||
// ------------- Query tags --------------- //
|
||||
|
||||
const tagsCache = ref<Tag[]>([])
|
||||
const tagsTimestamp = ref(0)
|
||||
|
||||
|
@ -69,6 +170,9 @@ export const useDataStore
|
|||
return tagsCache;
|
||||
}
|
||||
|
||||
// ------------- Query one --------------- //
|
||||
// TODO: Errors and Loading states (`undefined` can mean an error occurred, or the request is still pending)
|
||||
|
||||
/** Inspect the cache with the Vue Devtools (Pinia tab); read-only */
|
||||
const data = computed(() => cache)
|
||||
|
||||
|
@ -86,9 +190,9 @@ export const useDataStore
|
|||
|
||||
// Initialize the object if it doesn't exist
|
||||
if (!items[id])
|
||||
items[id] = { result: ref(undefined) as Ref<ItemType[I] | undefined>, timestamp: 0 }
|
||||
items[id] = { result: ref(undefined) as Ref<ItemType[I] | undefined>, timestamp: 0 } // born in 1970
|
||||
|
||||
// Re-fetch if immediate is true or the item is not cached or older than 1 second
|
||||
// Re-fetch if immediate is true or the item is not cached (= born in 1970) 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;
|
||||
|
@ -97,10 +201,125 @@ export const useDataStore
|
|||
return items[id].result
|
||||
}
|
||||
|
||||
// ------------- Query many --------------- //
|
||||
|
||||
// TODO: Normalize Data. Each time we receive one or many items from the backend, we can
|
||||
// update all instances. Or we can change the caches to store keys instead of objects, moving away from the current API design.
|
||||
|
||||
// Initialize items cache
|
||||
const searches = ref({
|
||||
artists: new Map<Key, Data<GetPaginatedResponses<'artists'>>>(),
|
||||
albums: new Map<Key, Data<GetPaginatedResponses<'albums'>>>(),
|
||||
playlists: new Map<Key, Data<GetPaginatedResponses<'playlists'>>>(),
|
||||
tracks: new Map<Key, Data<GetPaginatedResponses<'tracks'>>>(),
|
||||
channels: new Map<Key, Data<GetPaginatedResponses<'channels'>>>(),
|
||||
'radios/radios': new Map<Key, Data<GetPaginatedResponses<'radios/radios'>>>(),
|
||||
tags: new Map<Key, Data<GetPaginatedResponses<'tags'>>>()
|
||||
} as const )
|
||||
|
||||
const maxAge = 5*60000 // Invalidate searches every 5 minutes
|
||||
|
||||
const createResource = <N extends Name>( name: N) => ( params: Params<N>) => {
|
||||
const key = hash(params)
|
||||
const cached = computed<Data<GetPaginatedResponses<N>>>({
|
||||
get: () => searches.value[name].get(key) || notAsked(),
|
||||
set: (value) => (searches.value[name] as Map<Key, Data<GetPaginatedResponses<N>>>).set(key, value)
|
||||
})
|
||||
|
||||
const isStale
|
||||
= () => cached.value.status === 'notAsked'
|
||||
|| cached.value.lastUpdated < Date.now() - maxAge
|
||||
|
||||
const fetch = async () => {
|
||||
|
||||
try {
|
||||
cached.value = setPending(cached.value)
|
||||
await rateLimiter.greenlight([name, params])
|
||||
cached.value = setLoading(cached.value as Data<GetPaginatedResponses<N>> & {status: 'pending'})
|
||||
const { data } = await axios.get<GetPaginatedResponses<N>>(name, { params })
|
||||
cached.value = setSuccess(cached.value as Data<GetPaginatedResponses<N>> & {status: 'loading'}, data)
|
||||
} catch (error) {
|
||||
if (isRateLimiterError(error as Error)) {
|
||||
logger.info(error)
|
||||
} else {
|
||||
logger.error(`Error fetching multiple ${name} with filter/params ${JSON.stringify(params)}:`, error);
|
||||
if (cached.value.status === 'loading')
|
||||
cached.value = setError(cached.value, error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale()) fetch()
|
||||
|
||||
// Add `key` and `refetch` to the reactive object
|
||||
return computed(()=> ({
|
||||
...searches.value[name].get(key) as Data<GetPaginatedResponses<N>>,
|
||||
key: hash([name, params]),
|
||||
refetch: fetch
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param params - filter to find the artists
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const artists = createResource('artists')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the albums
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const albums = createResource('albums')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the tracks
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const tracks = createResource('tracks')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the channels
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const channels = createResource('channels')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the radios
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const radios = createResource('radios/radios')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the tags
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const tags_ = createResource('tags')
|
||||
|
||||
/**
|
||||
* @param params - filter to find the playlists
|
||||
* @returns a reactive object with fields `status` (`error`, `success` or `pending`) and nullable fields `data` and `error`
|
||||
* as well as a `refetch` method (skipping the rate limiter) and a unique `key` (for use with v-for)
|
||||
*/
|
||||
const playlists = createResource('playlists')
|
||||
|
||||
return {
|
||||
data,
|
||||
get,
|
||||
tagsCache,
|
||||
tags
|
||||
tags,
|
||||
artists,
|
||||
albums,
|
||||
tracks,
|
||||
channels,
|
||||
radios,
|
||||
tags_,
|
||||
playlists,
|
||||
searches
|
||||
}
|
||||
})
|
||||
|
|
|
@ -120,10 +120,7 @@ watch([uuid, object], ([uuid, object], [lastUuid, lastObject]) => {
|
|||
const route = useRoute()
|
||||
watchEffect(() => {
|
||||
if (!object.value) {
|
||||
store.state.channels.uploadModalConfig.channel = null
|
||||
return
|
||||
} else {
|
||||
store.state.channels.uploadModalConfig.channel = object.value
|
||||
}
|
||||
|
||||
if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value.actor.domain) {
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
},
|
||||
"include": [
|
||||
"**/*.md",
|
||||
"src/*.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts",
|
||||
|
|
|
@ -408,15 +408,27 @@ Instead, use the [action area](#add-an-action) to offer the primary link:
|
|||
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
## Add color
|
||||
## Differentiate mixed cards visually
|
||||
|
||||
Consider differentiating cards by color, size and shadow when mixing several of the following types:
|
||||
|
||||
- Cards used for organizing the page spatially
|
||||
- Cards that represent objects
|
||||
- Interactive cards (buttons or links)
|
||||
|
||||
### Add color
|
||||
|
||||
- Choose a color: `default`, `primary`, `secondary`, `destructive`, or a Pastel (red, yellow, purple, green or blue)
|
||||
- Choose a variant: `raised`, `solid`, `outline`,...
|
||||
|
||||
Read more: [Using Color](/using-color)
|
||||
|
||||
## Set size
|
||||
### Set size
|
||||
|
||||
`large` (304px), `medium` (208px), `auto`, `small`, ...
|
||||
|
||||
Read more: [Using Width](/using-width)
|
||||
|
||||
### Remove shadow
|
||||
|
||||
Use the `flat` attribute to remove the automatic shadow. This helps reduce visual noise and differentiates cards.
|
||||
|
|
|
@ -431,6 +431,16 @@ const sections = ref([false, false, false])
|
|||
/>
|
||||
</Section>
|
||||
|
||||
## Add a badge
|
||||
|
||||
- Add `badge="loading"` to show a spinner after the heading
|
||||
- Add `badge="1" to show an encircled number after the heading
|
||||
|
||||
<Section
|
||||
h1="Heading"
|
||||
badge="1"
|
||||
/>
|
||||
|
||||
## Responsivity
|
||||
|
||||
- Cards and Activities snap to the grid columns. They have intrinsic widths, expressed in the number of columns they span. For `Card`, it is `3` and for `Activity`, it is `4`.
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import Toc from '~/components/ui/Toc.vue'
|
||||
import Section from '~/components/ui/Section.vue'
|
||||
import Spacer from '~/components/ui/Spacer.vue'
|
||||
</script>
|
||||
|
||||
```ts
|
||||
|
@ -55,12 +57,22 @@ By default table of contents only renders `<h1>` tags
|
|||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<h1>Heading h1 - A</h1>
|
||||
<p>
|
||||
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
|
||||
</p>
|
||||
<h1>Heading h1 - B</h1>
|
||||
<p>
|
||||
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
|
||||
</p>
|
||||
<h2>Heading h2 - C</h2>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<h1>Heading h1 - D</h1>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Toc>
|
||||
</ClientOnly>
|
||||
|
||||
|
@ -70,37 +82,95 @@ You can specify the heading level you want to render in the table of contents by
|
|||
|
||||
```vue-html{2}
|
||||
<Toc
|
||||
heading="h2"
|
||||
heading="h3"
|
||||
>
|
||||
<h1>This is a Table of Contents</h1>
|
||||
Content...
|
||||
|
||||
<h2>It automatically generates from headings</h2>
|
||||
More content...
|
||||
|
||||
<Section h3="Radios">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
<Section h3="Artists">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
<Section h3="Albums">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
</Toc>
|
||||
```
|
||||
|
||||
<ClientOnly>
|
||||
<Toc heading="h2">
|
||||
<h1>This is a Table of Contents</h1>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
|
||||
</p>
|
||||
<p>
|
||||
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
|
||||
</p>
|
||||
<h2>It automatically generates from headings</h2>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
Unde praesentium voluptates esse in placeat. Quis qui sint illo tempore omnis sed. Ab dicta omnis aut dolor voluptate maxime repudiandae ea. Aspernatur alias et architecto asperiores in. A sunt necessitatibus voluptatem veniam at dolore. Dolorum saepe est eveniet dignissimos laborum.
|
||||
</p>
|
||||
<p>
|
||||
Qui impedit dicta earum. Qui repudiandae est magnam. Illum sit ratione exercitationem fugiat aut tempore. Ut sit deserunt ratione ut architecto deleniti ea magnam. Voluptatibus dignissimos voluptatem rem fugiat.
|
||||
</p>
|
||||
</Toc>
|
||||
<Toc
|
||||
heading="h3"
|
||||
>
|
||||
<h1>This is a Table of Contents</h1>
|
||||
Content...
|
||||
|
||||
<h2>It automatically generates from headings</h2>
|
||||
More content...
|
||||
|
||||
<Spacer size-64/>
|
||||
<Section h3="Radios">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
<Spacer size-64/>
|
||||
<Section h3="Artists">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
<Spacer size-64/>
|
||||
<Section h3="Albums">
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
<p>
|
||||
In expedita ratione consequatur rerum et ullam architecto. Qui ut doloremque laboriosam perferendis corporis voluptatibus voluptates. Ad ducimus adipisci vitae mollitia quis. Aut placeat quaerat maxime velit et eius voluptas fugit. Omnis et et perspiciatis mollitia occaecati.
|
||||
</p>
|
||||
</Section>
|
||||
</Toc>
|
||||
</ClientOnly>
|
||||
|
|
|
@ -1836,6 +1836,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-4.2.0.tgz#09b41953baf14270c2af7281b7ea7b45ac4a3114"
|
||||
integrity sha512-of3dYwB4RN825qq9kBu/79QPVXDZFb5S/opLtJScLqyRhI6owkFWV4P9VmFih8dfBh/7SImdvt/B4HQTF1fthg==
|
||||
|
||||
"@pinia/colada-devtools@0.1.6":
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@pinia/colada-devtools/-/colada-devtools-0.1.6.tgz#609bf4f6e22bd43fa590e40988c97c6b4e226a16"
|
||||
integrity sha512-wRW/GxP8SiahC5TRVulQe+5NuIQ7DGtgsO4Xsf9tP2HSTTRD8ac+7pn9vbKxovPdXrgAyAo9PWzk1b+y5MYEUQ==
|
||||
|
||||
"@polka/url@^1.0.0-next.24":
|
||||
version "1.0.0-next.29"
|
||||
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
|
||||
|
@ -2041,6 +2046,29 @@
|
|||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.0.tgz#0a7eecae41f463d6591c8fecd7a5c5087345ee36"
|
||||
integrity sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==
|
||||
|
||||
"@rstore/core@^0.6.18":
|
||||
version "0.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@rstore/core/-/core-0.6.18.tgz#9eaad7d2fb02196967c593f7878e1f8f865d2021"
|
||||
integrity sha512-DNPC2L6vZkGQClNoW2z20kEZuKNExk69WL3ulcXA+gHNYOcEF0lFGof64KcfMNmK1hU2InOVJk//GHW3iIVQuw==
|
||||
dependencies:
|
||||
"@rstore/shared" "^0.6.18"
|
||||
|
||||
"@rstore/shared@^0.6.18":
|
||||
version "0.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@rstore/shared/-/shared-0.6.18.tgz#f569aea45effce98d10ff5fde909666d3ab722b9"
|
||||
integrity sha512-lU6D5EYmacxwySwWJnJG/2TvvDiqOgAnE4zCgiP/N32NeXHmHJy6lW9qL1ZnR3aqLH3/2VueJcaVI+mMF/jeNw==
|
||||
dependencies:
|
||||
"@standard-schema/spec" "^1.0.0"
|
||||
|
||||
"@rstore/vue@0.6.18":
|
||||
version "0.6.18"
|
||||
resolved "https://registry.yarnpkg.com/@rstore/vue/-/vue-0.6.18.tgz#bfe2a29867e9d1cf7b59b6b29c6d2c9f6a0ebacf"
|
||||
integrity sha512-ci+KF2mTZM4LzvOUcTqlNWGmjKoYBAUOjkL8ivDs4qR9XEDilDz2qYaE2qdjykzdCE73Df/w7EQCCyhgZ0WqfQ==
|
||||
dependencies:
|
||||
"@rstore/core" "^0.6.18"
|
||||
"@rstore/shared" "^0.6.18"
|
||||
"@vueuse/core" "^12.7.0"
|
||||
|
||||
"@rtsao/scc@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
|
||||
|
@ -2247,6 +2275,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f"
|
||||
integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==
|
||||
|
||||
"@standard-schema/spec@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c"
|
||||
integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==
|
||||
|
||||
"@surma/rollup-plugin-off-main-thread@^2.2.3":
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
|
||||
|
@ -2730,6 +2763,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597"
|
||||
integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==
|
||||
|
||||
"@types/web-bluetooth@^0.0.21":
|
||||
version "0.0.21"
|
||||
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
|
||||
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
|
||||
|
||||
"@types/wrap-ansi@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd"
|
||||
|
@ -3759,14 +3797,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.6.0.tgz#238de64e5296aac3fc076f77b5b74c8f4a493d53"
|
||||
integrity sha512-MHXNd6lzugsEHvuA6l1GqrF5jROqUon8sP/HInLPnthJiYvB0VvpHMywg7em1dBZfFZNBSkR68qH37zOdRHmCw==
|
||||
|
||||
"@vueuse/components@10.6.1":
|
||||
version "10.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/components/-/components-10.6.1.tgz#7f4c5853c4e2e6b7329760b9ef2583e062c8d74d"
|
||||
integrity sha512-Yx7h201xJG3V4+rY1wRAYy8EI9Q1r+gpwCJzgyZ0CWPyDWyZCxPXNjPhBJsXcSzJ1h1ph9tE5cVqEXHtEs6bjg==
|
||||
"@vueuse/components@13.7.0":
|
||||
version "13.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/components/-/components-13.7.0.tgz#21528ef345ed4f287599400c5fcb28c9e9de2a8b"
|
||||
integrity sha512-7kxKz1Uh9XSivRg1RJzmcnpjBii4nMaCt1BOkxsVz/Ot5krIugujyHQNrFVx2igKuObY3x6CJGTrWlb8303SDg==
|
||||
dependencies:
|
||||
"@vueuse/core" "10.6.1"
|
||||
"@vueuse/shared" "10.6.1"
|
||||
vue-demi ">=0.14.6"
|
||||
"@vueuse/core" "13.7.0"
|
||||
"@vueuse/shared" "13.7.0"
|
||||
|
||||
"@vueuse/core@10.6.1":
|
||||
version "10.6.1"
|
||||
|
@ -3788,6 +3825,25 @@
|
|||
"@vueuse/shared" "11.3.0"
|
||||
vue-demi ">=0.14.10"
|
||||
|
||||
"@vueuse/core@13.7.0":
|
||||
version "13.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.7.0.tgz#fcb63184443144858645ea87b382535226d523f7"
|
||||
integrity sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==
|
||||
dependencies:
|
||||
"@types/web-bluetooth" "^0.0.21"
|
||||
"@vueuse/metadata" "13.7.0"
|
||||
"@vueuse/shared" "13.7.0"
|
||||
|
||||
"@vueuse/core@^12.7.0":
|
||||
version "12.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-12.8.2.tgz#007c6dd29a7d1f6933e916e7a2f8ef3c3f968eaa"
|
||||
integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==
|
||||
dependencies:
|
||||
"@types/web-bluetooth" "^0.0.21"
|
||||
"@vueuse/metadata" "12.8.2"
|
||||
"@vueuse/shared" "12.8.2"
|
||||
vue "^3.5.13"
|
||||
|
||||
"@vueuse/integrations@10.6.1":
|
||||
version "10.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/integrations/-/integrations-10.6.1.tgz#8358ced20d1a976422693ae3711ad29b70948504"
|
||||
|
@ -3824,6 +3880,16 @@
|
|||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-11.3.0.tgz#be7ac12e3016c0353a3667b372a73aeeee59194e"
|
||||
integrity sha512-pwDnDspTqtTo2HwfLw4Rp6yywuuBdYnPYDq+mO38ZYKGebCUQC/nVj/PXSiK9HX5otxLz8Fn7ECPbjiRz2CC3g==
|
||||
|
||||
"@vueuse/metadata@12.8.2":
|
||||
version "12.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3"
|
||||
integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==
|
||||
|
||||
"@vueuse/metadata@13.7.0":
|
||||
version "13.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.7.0.tgz#5c825d3706ecbf6ea6c969c0080f4f3ef414c37d"
|
||||
integrity sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==
|
||||
|
||||
"@vueuse/router@10.6.1":
|
||||
version "10.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/router/-/router-10.6.1.tgz#34fccd572d4d679338415ec4b9ba4ce081615c1f"
|
||||
|
@ -3846,6 +3912,18 @@
|
|||
dependencies:
|
||||
vue-demi ">=0.14.10"
|
||||
|
||||
"@vueuse/shared@12.8.2":
|
||||
version "12.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930"
|
||||
integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==
|
||||
dependencies:
|
||||
vue "^3.5.13"
|
||||
|
||||
"@vueuse/shared@13.7.0":
|
||||
version "13.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.7.0.tgz#b7466ec90361a69621cd09d5b396fbda6d8f2f9c"
|
||||
integrity sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==
|
||||
|
||||
"@yarnpkg/lockfile@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31"
|
||||
|
@ -9372,6 +9450,11 @@ sshpk@^1.18.0:
|
|||
safer-buffer "^2.0.2"
|
||||
tweetnacl "~0.14.0"
|
||||
|
||||
stable-hash@0.0.6:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/stable-hash/-/stable-hash-0.0.6.tgz#7dd2cb7c71c2526b0d089b6bf0d1fcef38a6f97e"
|
||||
integrity sha512-0afH4mobqTybYZsXImQRLOjHV4gvOW+92HdUIax9t7a8d9v54KWykEuMVIcXhD9BCi+w3kS4x7O6fmZQ3JlG/g==
|
||||
|
||||
stack-generator@^2.0.5:
|
||||
version "2.0.10"
|
||||
resolved "https://registry.yarnpkg.com/stack-generator/-/stack-generator-2.0.10.tgz#8ae171e985ed62287d4f1ed55a1633b3fb53bb4d"
|
||||
|
|
Ładowanie…
Reference in New Issue