11<script setup lang="ts">
2+ import type { ProviderMetadata } from ' @proj-airi/stage-ui/stores/providers'
3+ import type { Ref } from ' vue'
4+
25import { IconStatusItem } from ' @proj-airi/stage-ui/components'
36import { useScrollToHash } from ' @proj-airi/stage-ui/composables/useScrollToHash'
47import { useProvidersStore } from ' @proj-airi/stage-ui/stores/providers'
8+ import { breakpointsTailwind , useBreakpoints } from ' @vueuse/core'
59import { storeToRefs } from ' pinia'
6- import { useRoute } from ' vue-router'
10+ import { computed } from ' vue'
11+ import { onBeforeRouteLeave , useRoute } from ' vue-router'
12+
13+ import { useProvidersPageStore } from ' ./store'
14+
15+ interface ProviderBlock {
16+ id: string
17+ icon: string
18+ title: string
19+ description: string
20+ providersRef: Ref <ProviderMetadata []>
21+ }
22+
23+ interface ProviderRenderData extends ProviderMetadata {
24+ renderIndex: number
25+ }
26+
27+ interface ComputedProviderBlock {
28+ id: string
29+ icon: string
30+ title: string
31+ description: string
32+ providers: ProviderRenderData []
33+ }
734
835const route = useRoute ()
936const providersStore = useProvidersStore ()
37+ const providersPageStore = useProvidersPageStore ()
38+ const breakpoints = useBreakpoints (breakpointsTailwind )
39+
1040const {
1141 allChatProvidersMetadata,
1242 allAudioSpeechProvidersMetadata,
1343 allAudioTranscriptionProvidersMetadata,
1444} = storeToRefs (providersStore )
1545
46+ const { lastClickedProviderIndex } = storeToRefs (providersPageStore )
47+
48+ const providerBlocksConfig: ProviderBlock [] = [
49+ {
50+ id: ' chat' ,
51+ icon: ' i-solar:chat-square-like-bold-duotone' ,
52+ title: ' Chat' ,
53+ description: ' Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.' ,
54+ providersRef: allChatProvidersMetadata ,
55+ },
56+ {
57+ id: ' speech' ,
58+ icon: ' i-solar:user-speak-rounded-bold-duotone' ,
59+ title: ' Speech' ,
60+ description: ' Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.' ,
61+ providersRef: allAudioSpeechProvidersMetadata ,
62+ },
63+ {
64+ id: ' transcription' ,
65+ icon: ' i-solar:microphone-3-bold-duotone' ,
66+ title: ' Transcription' ,
67+ description: ' Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech' ,
68+ providersRef: allAudioTranscriptionProvidersMetadata ,
69+ },
70+ ]
71+
72+ const providerBlocks = computed <ComputedProviderBlock []>(() => {
73+ let globalIndex = 0
74+ return providerBlocksConfig .map (block => ({
75+ id: block .id ,
76+ icon: block .icon ,
77+ title: block .title ,
78+ description: block .description ,
79+ providers: block .providersRef .value .map (provider => ({
80+ ... provider ,
81+ renderIndex: globalIndex ++ ,
82+ })),
83+ }))
84+ })
85+
86+ const cols = computed (() => {
87+ if (breakpoints .greaterOrEqual (' xl' ).value ) {
88+ return 3
89+ }
90+ if (breakpoints .greaterOrEqual (' sm' ).value ) {
91+ return 2
92+ }
93+ return 1
94+ })
95+
96+ function getAnimationDelay(renderIndex : number ) {
97+ const numCols = cols .value
98+
99+ const currentRow = Math .floor (renderIndex / numCols )
100+ const currentCol = renderIndex % numCols
101+
102+ const clickedRow = Math .floor (lastClickedProviderIndex .value / numCols )
103+ const clickedCol = lastClickedProviderIndex .value % numCols
104+
105+ // manhattan distance
106+ const distance = Math .abs (currentRow - clickedRow ) + Math .abs (currentCol - clickedCol )
107+
108+ return distance * 80
109+ }
110+
111+ function handleProviderClick(renderIndex : number ) {
112+ providersPageStore .setLastClickedProviderIndex (renderIndex )
113+ }
114+
16115useScrollToHash (() => route .hash , {
17116 auto: true , // automatically react to route hash
18117 offset: 16 , // header + margin spacing
19118 behavior: ' smooth' , // smooth scroll animation
20- maxRetries: 15 , // retry if target element isn’ t ready
119+ maxRetries: 15 , // retry if target element isn' t ready
21120 retryDelay: 150 , // wait between retries
22121})
122+
123+ onBeforeRouteLeave ((to , from ) => {
124+ if (! to .path .startsWith (' /settings/providers/' )) {
125+ providersPageStore .resetLastClickedProviderIndex ()
126+ }
127+ })
23128 </script >
24129
25130<template >
@@ -39,99 +144,48 @@ useScrollToHash(() => route.hash, {
39144 </i18n-t >
40145 </div >
41146 </div >
42- <div flex =" ~ row items-center gap-2" >
43- <div id =" chat" i-solar:chat-square-like-bold-duotone text =" neutral-500 dark:neutral-400 4xl" />
44- <div >
147+
148+ <template v-for =" (block , blockIndex ) in providerBlocks " :key =" block .id " >
149+ <div flex =" ~ row items-center gap-2" :class =" { 'my-5': blockIndex > 0 }" >
150+ <div :id =" block.id" :class =" block.icon" text =" neutral-500 dark:neutral-400 4xl" />
45151 <div >
46- <span text =" neutral-300 dark:neutral-500 sm sm:base" >Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.</span >
47- </div >
48- <div flex text-nowrap text =" 2xl sm:3xl" font-normal >
49152 <div >
50- Chat
153+ < span text = " neutral-300 dark:neutral-500 sm sm:base " >{{ block.description }}</ span >
51154 </div >
52- </div >
53- </div >
54- </div >
55- <div grid =" ~ cols-1 sm:cols-2 xl:cols-3 gap-4" >
56- <IconStatusItem
57- v-for =" (provider, index) of allChatProvidersMetadata"
58- :key =" provider.id"
59- v-motion
60- :initial =" { opacity: 0, y: 10 }"
61- :enter =" { opacity: 1, y: 0 }"
62- :duration =" 250 + index * 10"
63- :delay =" index * 50"
64- :title =" provider.localizedName || 'Unknown'"
65- :description =" provider.localizedDescription"
66- :icon =" provider.icon"
67- :icon-color =" provider.iconColor"
68- :icon-image =" provider.iconImage"
69- :to =" `/settings/providers/${provider.category}/${provider.id}`"
70- :configured =" provider.configured"
71- />
72- </div >
73- <div flex =" ~ row items-center gap-2" my-5 >
74- <div id =" speech" i-solar:user-speak-rounded-bold-duotone text =" neutral-500 dark:neutral-400 4xl" />
75- <div >
76- <div >
77- <span text =" neutral-300 dark:neutral-500 sm sm:base" >Speech (text-to-speech) model providers. e.g. ElevenLabs, Azure Speech.</span >
78- </div >
79- <div flex text-nowrap text =" 2xl sm:3xl" font-normal >
80- <div >
81- Speech
155+ <div flex text-nowrap text =" 2xl sm:3xl" font-normal >
156+ <div >
157+ {{ block.title }}
158+ </div >
82159 </div >
83160 </div >
84161 </div >
85- </div >
86- <div grid =" ~ cols-1 sm:cols-2 xl:cols-3 gap-4" >
87- <IconStatusItem
88- v-for =" (provider, index) of allAudioSpeechProvidersMetadata"
89- :key =" provider.id"
90- v-motion
91- :initial =" { opacity: 0, y: 10 }"
92- :enter =" { opacity: 1, y: 0 }"
93- :duration =" 250 + index * 10"
94- :delay =" (allChatProvidersMetadata.length + index) * 50"
95- :title =" provider.localizedName || 'Unknown'"
96- :description =" provider.localizedDescription"
97- :icon =" provider.icon"
98- :icon-color =" provider.iconColor"
99- :icon-image =" provider.iconImage"
100- :to =" `/settings/providers/${provider.category}/${provider.id}`"
101- :configured =" provider.configured"
102- />
103- </div >
104- <div flex =" ~ row items-center gap-2" my-5 >
105- <div id =" transcription" i-solar:microphone-3-bold-duotone text =" neutral-500 dark:neutral-400 4xl" />
106- <div >
107- <div >
108- <span text =" neutral-300 dark:neutral-500 sm sm:base" >Transcription (speech-to-text) model providers. e.g. Whisper.cpp, OpenAI, Azure Speech</span >
109- </div >
110- <div flex text-nowrap text =" 2xl sm:3xl" font-normal >
111- <div >
112- Transcription
113- </div >
162+ <div grid =" ~ cols-1 sm:cols-2 xl:cols-3 gap-4" >
163+ <div
164+ v-for =" provider of block.providers"
165+ :key =" provider.id"
166+ v-motion
167+ :initial =" { opacity: 0, y: 10 }"
168+ :enter =" { opacity: 1,
169+ y: 0,
170+ transition: {
171+ duration: 250,
172+ delay: getAnimationDelay(provider.renderIndex),
173+ } }"
174+ :to =" `/settings/providers/${provider.category}/${provider.id}`"
175+ @click =" handleProviderClick(provider.renderIndex)"
176+ >
177+ <IconStatusItem
178+ :title =" provider.localizedName || 'Unknown'"
179+ :description =" provider.localizedDescription"
180+ :icon =" provider.icon"
181+ :icon-color =" provider.iconColor"
182+ :icon-image =" provider.iconImage"
183+ :to =" `/settings/providers/${provider.category}/${provider.id}`"
184+ :configured =" provider.configured"
185+ />
114186 </div >
115187 </div >
116- </div >
117- <div grid =" ~ cols-1 sm:cols-2 xl:cols-3 gap-4" >
118- <IconStatusItem
119- v-for =" (provider, index) of allAudioTranscriptionProvidersMetadata"
120- :key =" provider.id"
121- v-motion
122- :initial =" { opacity: 0, y: 10 }"
123- :enter =" { opacity: 1, y: 0 }"
124- :duration =" 250 + index * 10"
125- :delay =" (allChatProvidersMetadata.length + allAudioSpeechProvidersMetadata.length + index) * 50"
126- :title =" provider.localizedName || 'Unknown'"
127- :description =" provider.localizedDescription"
128- :icon =" provider.icon"
129- :icon-color =" provider.iconColor"
130- :icon-image =" provider.iconImage"
131- :to =" `/settings/providers/${provider.category}/${provider.id}`"
132- :configured =" provider.configured"
133- />
134- </div >
188+ </template >
135189 </div >
136190 <div
137191 v-motion
0 commit comments