feat(stage-ui): improve animation delay in provider settings (#743)
This commit is contained in:
@@ -1,25 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProviderMetadata } from '@proj-airi/stage-ui/stores/providers'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { IconStatusItem } from '@proj-airi/stage-ui/components'
|
||||
import { useScrollToHash } from '@proj-airi/stage-ui/composables/useScrollToHash'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||
|
||||
import { useProvidersPageStore } from './store'
|
||||
|
||||
interface ProviderBlock {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
providersRef: Ref<ProviderMetadata[]>
|
||||
}
|
||||
|
||||
interface ProviderRenderData extends ProviderMetadata {
|
||||
renderIndex: number
|
||||
}
|
||||
|
||||
interface ComputedProviderBlock {
|
||||
id: string
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
providers: ProviderRenderData[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const providersStore = useProvidersStore()
|
||||
const providersPageStore = useProvidersPageStore()
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
|
||||
const {
|
||||
allChatProvidersMetadata,
|
||||
allAudioSpeechProvidersMetadata,
|
||||
allAudioTranscriptionProvidersMetadata,
|
||||
} = storeToRefs(providersStore)
|
||||
|
||||
const { lastClickedProviderIndex } = storeToRefs(providersPageStore)
|
||||
|
||||
const providerBlocksConfig: ProviderBlock[] = [
|
||||
{
|
||||
id: 'chat',
|
||||
icon: 'i-solar:chat-square-like-bold-duotone',
|
||||
title: 'Chat',
|
||||
description: 'Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.',
|
||||
providersRef: allChatProvidersMetadata,
|
||||
},
|
||||
{
|
||||
id: 'speech',
|
||||
icon: 'i-solar:user-speak-rounded-bold-duotone',
|
||||
title: 'Speech',
|
||||
description: 'Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.',
|
||||
providersRef: allAudioSpeechProvidersMetadata,
|
||||
},
|
||||
{
|
||||
id: 'transcription',
|
||||
icon: 'i-solar:microphone-3-bold-duotone',
|
||||
title: 'Transcription',
|
||||
description: 'Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech',
|
||||
providersRef: allAudioTranscriptionProvidersMetadata,
|
||||
},
|
||||
]
|
||||
|
||||
const providerBlocks = computed<ComputedProviderBlock[]>(() => {
|
||||
let globalIndex = 0
|
||||
return providerBlocksConfig.map(block => ({
|
||||
id: block.id,
|
||||
icon: block.icon,
|
||||
title: block.title,
|
||||
description: block.description,
|
||||
providers: block.providersRef.value.map(provider => ({
|
||||
...provider,
|
||||
renderIndex: globalIndex++,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
if (breakpoints.greaterOrEqual('xl').value) {
|
||||
return 3
|
||||
}
|
||||
if (breakpoints.greaterOrEqual('sm').value) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
})
|
||||
|
||||
function getAnimationDelay(renderIndex: number) {
|
||||
const numCols = cols.value
|
||||
|
||||
const currentRow = Math.floor(renderIndex / numCols)
|
||||
const currentCol = renderIndex % numCols
|
||||
|
||||
const clickedRow = Math.floor(lastClickedProviderIndex.value / numCols)
|
||||
const clickedCol = lastClickedProviderIndex.value % numCols
|
||||
|
||||
// manhattan distance
|
||||
const distance = Math.abs(currentRow - clickedRow) + Math.abs(currentCol - clickedCol)
|
||||
|
||||
return distance * 80
|
||||
}
|
||||
|
||||
function handleProviderClick(renderIndex: number) {
|
||||
providersPageStore.setLastClickedProviderIndex(renderIndex)
|
||||
}
|
||||
|
||||
useScrollToHash(() => route.hash, {
|
||||
auto: true, // automatically react to route hash
|
||||
offset: 16, // header + margin spacing
|
||||
behavior: 'smooth', // smooth scroll animation
|
||||
maxRetries: 15, // retry if target element isn’t ready
|
||||
maxRetries: 15, // retry if target element isn't ready
|
||||
retryDelay: 150, // wait between retries
|
||||
})
|
||||
|
||||
onBeforeRouteLeave((to, from) => {
|
||||
if (!to.path.startsWith('/settings/providers/')) {
|
||||
providersPageStore.resetLastClickedProviderIndex()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -39,99 +144,48 @@ useScrollToHash(() => route.hash, {
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
<div flex="~ row items-center gap-2">
|
||||
<div id="chat" i-solar:chat-square-like-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
|
||||
<template v-for="(block, blockIndex) in providerBlocks" :key="block.id">
|
||||
<div flex="~ row items-center gap-2" :class="{ 'my-5': blockIndex > 0 }">
|
||||
<div :id="block.id" :class="block.icon" text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
Chat
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">{{ block.description }}</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
{{ block.title }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
|
||||
<IconStatusItem
|
||||
v-for="(provider, index) of allChatProvidersMetadata"
|
||||
:key="provider.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + index * 10"
|
||||
:delay="index * 50"
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
<div flex="~ row items-center gap-2" my-5>
|
||||
<div id="speech" i-solar:user-speak-rounded-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
<div>
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
Speech
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
|
||||
<div
|
||||
v-for="provider of block.providers"
|
||||
:key="provider.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 250,
|
||||
delay: getAnimationDelay(provider.renderIndex),
|
||||
} }"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
@click="handleProviderClick(provider.renderIndex)"
|
||||
>
|
||||
<IconStatusItem
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
|
||||
<IconStatusItem
|
||||
v-for="(provider, index) of allAudioSpeechProvidersMetadata"
|
||||
:key="provider.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + index * 10"
|
||||
:delay="(allChatProvidersMetadata.length + index) * 50"
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
<div flex="~ row items-center gap-2" my-5>
|
||||
<div id="transcription" i-solar:microphone-3-bold-duotone text="neutral-500 dark:neutral-400 4xl" />
|
||||
<div>
|
||||
<div>
|
||||
<span text="neutral-300 dark:neutral-500 sm sm:base">Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech</span>
|
||||
</div>
|
||||
<div flex text-nowrap text="2xl sm:3xl" font-normal>
|
||||
<div>
|
||||
Transcription
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div grid="~ cols-1 sm:cols-2 xl:cols-3 gap-4">
|
||||
<IconStatusItem
|
||||
v-for="(provider, index) of allAudioTranscriptionProvidersMetadata"
|
||||
:key="provider.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 10 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
:duration="250 + index * 10"
|
||||
:delay="(allChatProvidersMetadata.length + allAudioSpeechProvidersMetadata.length + index) * 50"
|
||||
:title="provider.localizedName || 'Unknown'"
|
||||
:description="provider.localizedDescription"
|
||||
:icon="provider.icon"
|
||||
:icon-color="provider.iconColor"
|
||||
:icon-image="provider.iconImage"
|
||||
:to="`/settings/providers/${provider.category}/${provider.id}`"
|
||||
:configured="provider.configured"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-motion
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useProvidersPageStore = defineStore('providersPage', {
|
||||
state: () => ({
|
||||
lastClickedProviderIndex: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
setLastClickedProviderIndex(index: number) {
|
||||
this.lastClickedProviderIndex = index
|
||||
},
|
||||
|
||||
resetLastClickedProviderIndex() {
|
||||
this.lastClickedProviderIndex = 0
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user