feat(stage-*): apply ripple grid to other pages (#751)

This commit is contained in:
Mr-Quin
2025-11-23 15:36:05 +08:00
committed by GitHub
parent 3db6315879
commit 8275529aee
11 changed files with 434 additions and 175 deletions
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { IconItem, RippleGrid } from '@proj-airi/stage-ui/components'
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
const settings = computed(() => [
{
@@ -31,21 +33,22 @@ const settings = computed(() => [
<div flex="~ col gap-4" font-normal>
<div />
<div flex="~ col gap-4">
<IconItem
v-for="(setting, index) in settings"
:key="setting.to"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="setting.title"
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
/>
<RippleGrid
:items="settings"
:get-key="item => item.to"
:columns="1"
:origin-index="lastClickedIndex"
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
>
<template #item="{ item }">
<IconItem
:title="item.title"
:description="item.description"
:icon="item.icon"
:to="item.to"
/>
</template>
</RippleGrid>
</div>
<div
v-motion
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { IconItem } from '@proj-airi/stage-ui/components'
import { IconItem, RippleGrid } from '@proj-airi/stage-ui/components'
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -8,6 +9,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const resolveAnimation = ref<() => void>()
const { t } = useI18n()
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
const settingsStore = useSettings()
@@ -72,23 +74,23 @@ const settings = computed(() => [
<template>
<div flex="~ col gap-4" font-normal>
<div />
<div flex="~ col gap-4" pb-12>
<IconItem
v-for="(setting, index) in settings"
:key="setting.to"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250"
:style="{
transitionDelay: `${index * 50}ms`, // delay between each item, unocss doesn't support dynamic generation of classes now
}"
:title="setting.title"
:description="setting.description"
:icon="setting.icon"
:to="setting.to"
/>
<div pb-12>
<RippleGrid
:items="settings"
:get-key="item => item.to"
:columns="1"
:origin-index="lastClickedIndex"
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
>
<template #item="{ item }">
<IconItem
:title="item.title"
:description="item.description"
:icon="item.icon"
:to="item.to"
/>
</template>
</RippleGrid>
</div>
<div
v-motion
@@ -1,28 +1,32 @@
<script setup lang="ts">
import { IconStatusItem } from '@proj-airi/stage-ui/components'
import { IconStatusItem, RippleGrid } from '@proj-airi/stage-ui/components'
import { useModulesList } from '@proj-airi/stage-ui/composables/use-modules-list'
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
const { modulesList } = useModulesList()
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
</script>
<template>
<div grid="~ cols-1 sm:cols-2 gap-4">
<IconStatusItem
v-for="(module, index) of modulesList"
:key="module.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0 }"
:duration="250 + index * 10"
:delay="index * 50"
:title="module.name"
:description="module.description"
:icon="module.icon"
:icon-color="module.iconColor"
:icon-image="module.iconImage"
:to="module.to"
:configured="module.configured"
/>
<div>
<RippleGrid
:items="modulesList"
:columns="{ default: 1, sm: 2 }"
:origin-index="lastClickedIndex"
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
>
<template #item="{ item: module }">
<IconStatusItem
:title="module.name"
:description="module.description"
:icon="module.icon"
:icon-color="module.iconColor"
:icon-image="module.iconImage"
:to="module.to"
:configured="module.configured"
/>
</template>
</RippleGrid>
</div>
<div
v-motion
@@ -1,41 +1,15 @@
<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 { IconStatusItem, RippleGrid } from '@proj-airi/stage-ui/components'
import { useRippleGridState } from '@proj-airi/stage-ui/composables/use-ripple-grid-state'
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 { 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[]
}
import { useRoute } from 'vue-router'
const route = useRoute()
const providersStore = useProvidersStore()
const providersPageStore = useProvidersPageStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
const { lastClickedIndex, setLastClickedIndex } = useRippleGridState()
const {
allChatProvidersMetadata,
@@ -43,9 +17,7 @@ const {
allAudioTranscriptionProvidersMetadata,
} = storeToRefs(providersStore)
const { lastClickedProviderIndex } = storeToRefs(providersPageStore)
const providerBlocksConfig: ProviderBlock[] = [
const providerBlocksConfig = [
{
id: 'chat',
icon: 'i-solar:chat-square-like-bold-duotone',
@@ -69,7 +41,7 @@ const providerBlocksConfig: ProviderBlock[] = [
},
]
const providerBlocks = computed<ComputedProviderBlock[]>(() => {
const providerBlocks = computed(() => {
let globalIndex = 0
return providerBlocksConfig.map(block => ({
id: block.id,
@@ -83,35 +55,6 @@ const providerBlocks = computed<ComputedProviderBlock[]>(() => {
}))
})
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
@@ -119,12 +62,6 @@ useScrollToHash(() => route.hash, {
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>
@@ -145,47 +82,41 @@ onBeforeRouteLeave((to, from) => {
</div>
</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>
<RippleGrid
:sections="providerBlocks"
:get-items="block => block.providers"
:columns="{ default: 1, sm: 2, xl: 3 }"
:origin-index="lastClickedIndex"
@item-click="({ globalIndex }) => setLastClickedIndex(globalIndex)"
>
<template #header="{ section: block }">
<div flex="~ row items-center gap-2">
<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">{{ block.description }}</span>
</div>
<div flex text-nowrap text="2xl sm:3xl" font-normal>
<div>
{{ block.title }}
<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">
<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),
} }"
</template>
<template #item="{ item: provider }">
<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}`"
@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>
</template>
:configured="provider.configured"
/>
</template>
</RippleGrid>
</div>
<div
v-motion
@@ -1,17 +0,0 @@
import { defineStore } from 'pinia'
export const useProvidersPageStore = defineStore('providersPage', {
state: () => ({
lastClickedProviderIndex: 0,
}),
actions: {
setLastClickedProviderIndex(index: number) {
this.lastClickedProviderIndex = index
},
resetLastClickedProviderIndex() {
this.lastClickedProviderIndex = 0
},
},
})
@@ -1,6 +1,7 @@
export * from './data-pane'
export * from './gadgets'
export * from './graphics'
export { default as RippleGrid } from './layout/ripple-grid/RippleGrid.vue'
export * from './layouts'
export * from './markdown'
export * from './menu'
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue'
import RippleGrid from './RippleGrid.vue'
const sections = ref([
{
title: 'Section 1',
items: Array.from({ length: 51 }, (_, i) => ({ id: `s1-${i}`, label: `Item ${i + 1}` })),
},
{
title: 'Section 2',
items: Array.from({ length: 11 }, (_, i) => ({ id: `s2-${i}`, label: `Item ${i + 1}` })),
},
])
const flatItems = ref(Array.from({ length: 10 }, (_, i) => ({ id: `flat-${i}`, label: `Flat Item ${i + 1}` })))
const lastClickedIndex = ref(0)
const renderKey = ref(0)
function handleItemClick({ globalIndex }: { globalIndex: number }) {
lastClickedIndex.value = globalIndex
renderKey.value++
}
</script>
<template>
<Story
title="RippleGrid"
group="layout"
:layout="{ type: 'grid', width: '100%' }"
>
<Variant
id="sections"
title="With Sections"
>
<div p-4>
<RippleGrid
:key="renderKey"
:sections="sections"
:get-items="s => s.items"
:get-key="i => i.id"
:columns="{ default: 6, md: 8, xl: 10 }"
:origin-index="lastClickedIndex"
@item-click="handleItemClick"
>
<template #header="{ section }">
<h2 mb-2 text-xl font-bold>
{{ section.title }}
</h2>
</template>
<template #item="{ item, active }">
<div
h-24 flex items-center justify-center border-2 rounded-lg transition-colors duration-200
:class="active ? 'bg-primary-500 text-white border-primary-600' : 'bg-neutral-100 dark:bg-neutral-800 border-transparent hover:bg-neutral-200 dark:hover:bg-neutral-700'"
>
{{ item.label }}
</div>
</template>
</RippleGrid>
</div>
</Variant>
<Variant
id="flat"
title="No Sections (Flat List)"
>
<div p-4>
<RippleGrid
:key="renderKey"
:items="flatItems"
:columns="1"
:origin-index="lastClickedIndex"
@item-click="handleItemClick"
>
<template #item="{ item, active }">
<div
h-24 flex items-center justify-center border-2 rounded-lg transition-colors duration-200
:class="active ? 'bg-emerald-500 text-white border-emerald-600' : 'bg-neutral-100 dark:bg-neutral-800 border-transparent hover:bg-neutral-200 dark:hover:bg-neutral-700'"
>
{{ item.label }}
</div>
</template>
</RippleGrid>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts" generic="TSection, TItem">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, toRef } from 'vue'
import { useGridRipple } from './useGridRipple'
interface VirtualSection {
_isVirtual: true
items: TItem[]
}
const props = withDefaults(defineProps<{
items?: TItem[]
sections?: TSection[]
getItems?: (section: TSection) => TItem[]
getKey?: (item: TItem) => string | number
columns?: number | Record<string, number>
originIndex?: number
animationInitial?: Record<string, unknown>
animationEnter?: Record<string, unknown>
animationDuration?: number
delayPerUnit?: number
}>(), {
columns: () => ({ default: 1, sm: 2, xl: 3 }),
originIndex: 0,
animationInitial: () => ({ opacity: 0, y: 10 }),
animationEnter: () => ({ opacity: 1, y: 0 }),
animationDuration: 250,
delayPerUnit: 80,
getItems: (section: any) => section.items || [],
getKey: (item: any) => item.id ?? item.key,
})
const emit = defineEmits<{
itemClick: [payload: { item: TItem, globalIndex: number }]
}>()
const breakpoints = useBreakpoints(breakpointsTailwind)
const COLUMN_ORDER = ['2xl', 'xl', 'lg', 'md', 'sm'] as const
const isFlat = computed(() => !!props.items && !props.sections)
const normalizedSections = computed(() => {
if (isFlat.value && props.items) {
return [{ _isVirtual: true, items: props.items }] as unknown as TSection[]
}
return props.sections || []
})
const currentCols = computed(() => {
if (typeof props.columns === 'number')
return props.columns
for (const key of COLUMN_ORDER) {
if ((props.columns as any)[key] && breakpoints.greaterOrEqual(key).value) {
return (props.columns as any)[key]
}
}
return props.columns.default || 1
})
const sectionMeta = computed(() => {
let globalCounter = 0
return normalizedSections.value.map((section) => {
const items = isFlat.value ? (section as unknown as VirtualSection).items : props.getItems(section)
const startIndex = globalCounter
globalCounter += items.length
return { items, startIndex, count: items.length }
})
})
const sectionItemCounts = computed(() => sectionMeta.value.map(m => m.count))
const { getDelay } = useGridRipple({
cols: currentCols,
originIndex: toRef(props, 'originIndex'),
sectionItemCounts,
delayPerUnit: props.delayPerUnit,
})
function handleItemClick(item: TItem, globalIndex: number) {
emit('itemClick', { item, globalIndex })
}
</script>
<template>
<div class="flex flex-col gap-5">
<template v-for="(section, sIndex) in normalizedSections" :key="sIndex">
<div v-if="$slots.header && !isFlat" :class="{ 'my-5': sIndex > 0 }">
<slot name="header" :section="section" :index="sIndex" />
</div>
<div
class="grid gap-4"
:style="{
gridTemplateColumns: `repeat(${currentCols}, minmax(0, 1fr))`,
}"
>
<div
v-for="(item, iIndex) in sectionMeta[sIndex].items"
:key="props.getKey(item)"
v-motion
:initial="animationInitial"
:enter="{
...animationEnter,
transition: {
duration: animationDuration,
delay: getDelay(sectionMeta[sIndex].startIndex + iIndex),
},
}"
@click="handleItemClick(item, sectionMeta[sIndex].startIndex + iIndex)"
>
<slot
name="item"
:item="item"
:index="sectionMeta[sIndex].startIndex + iIndex"
:active="originIndex === sectionMeta[sIndex].startIndex + iIndex"
/>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,71 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
export interface UseGridRippleOptions {
cols: MaybeRefOrGetter<number>
originIndex: MaybeRefOrGetter<number>
sectionItemCounts: MaybeRefOrGetter<number[]>
delayPerUnit?: number
}
export function useGridRipple(options: UseGridRippleOptions) {
const { cols, originIndex, sectionItemCounts, delayPerUnit = 80 } = options
// to property propagate the ripple, we need to know the layout of the grid and map each item onto a coordinate
const sectionLayout = computed(() => {
const layout: { startLinearIndex: number, startRow: number, itemCount: number }[] = []
let currentLinearIndex = 0
let currentRow = 0
const numCols = toValue(cols)
const counts = toValue(sectionItemCounts)
for (const count of counts) {
const rows = Math.ceil(count / numCols)
layout.push({
startLinearIndex: currentLinearIndex,
startRow: currentRow,
itemCount: count,
})
currentLinearIndex += count
currentRow += rows
}
return layout
})
// precompute the coordinates
const coordinateMap = computed(() => {
const map = new Map<number, { row: number, col: number }>()
const numCols = toValue(cols)
for (const meta of sectionLayout.value) {
for (let i = 0; i < meta.itemCount; i++) {
const linearIndex = meta.startLinearIndex + i
const localRow = Math.floor(i / numCols)
const col = i % numCols
const row = meta.startRow + localRow
map.set(linearIndex, { row, col })
}
}
return map
})
function getCoordinates(linearIndex: number) {
return coordinateMap.value.get(linearIndex) || { row: 0, col: 0 }
}
const originCoordinates = computed(() => getCoordinates(toValue(originIndex) || 0))
function getDelay(index: number) {
const target = getCoordinates(index)
const origin = originCoordinates.value
const distance = Math.abs(target.row - origin.row) + Math.abs(target.col - origin.col)
return distance * delayPerUnit
}
return {
getDelay,
}
}
@@ -22,9 +22,9 @@ const subtitle = ref(props.subtitle)
const finalizedDisableBackButton = ref(props.disableBackButton)
const { apply } = useMotion(pageHeaderRef, {
initial: { opacity: 0, x: 10, transition: { duration: 250 } },
initial: { opacity: 0, x: 10, transition: { duration: 50 } },
enter: { opacity: 1, x: 0, transition: { duration: 250 } },
leave: { opacity: 0, x: -5, transition: { duration: 100 } },
leave: { opacity: 0, x: -5, transition: { duration: 25 } },
})
onMounted(async () => {
@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
// inlined store since this shouldn't be used anywhere else
const useRippleGridStore = defineStore('rippleGrid', {
state: () => ({
clickedIndices: new Map<string, number>(),
}),
actions: {
setClickedIndex(key: string, index: number) {
this.clickedIndices.set(key, index)
},
getClickedIndex(key: string) {
return this.clickedIndices.get(key) ?? 0
},
resetClickedIndex(key: string) {
this.clickedIndices.delete(key)
},
},
})
export function useRippleGridState(key?: string) {
const route = useRoute()
const store = useRippleGridStore()
const stateKey = key || route.fullPath
const lastClickedIndex = computed(() => store.getClickedIndex(stateKey))
function setLastClickedIndex(index: number) {
store.setClickedIndex(stateKey, index)
}
onBeforeRouteLeave((to) => {
if (!to.path.startsWith(stateKey)) {
store.resetClickedIndex(stateKey)
}
})
return {
lastClickedIndex,
setLastClickedIndex,
}
}