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!2965
merge-requests/2965/merge
Flupsi 2025-10-08 12:16:05 +00:00
commit ad31e4994a
34 zmienionych plików z 1500 dodań i 519 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -25,7 +25,7 @@ const { radio, small, ...cardProps } = defineProps<{
>
<template #image>
<div class="cover-name">
{{ t('vui.radio') }}
{{ t('radio') }}
</div>
</template>

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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', {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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(),

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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))
: []

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -22,6 +22,7 @@
},
"include": [
"**/*.md",
"src/*.ts",
"src/**/*.ts",
"src/**/*.vue",
"vite.config.ts",

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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