feat(stage-*): dramatically improved mobile

This commit is contained in:
Neko Ayaka
2025-07-21 01:52:48 +08:00
parent f041ec0ac0
commit 0db4fa3f7c
31 changed files with 1016 additions and 593 deletions
@@ -6,7 +6,7 @@ import WhisperWorker from '@proj-airi/stage-ui/libs/workers/worker?worker&url'
import { toWAVBase64 } from '@proj-airi/audio'
import { useMicVAD, useWhisper } from '@proj-airi/stage-ui/composables'
import { useAudioContext, useChatStore, useConsciousnessStore, useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { BasicTextarea, TransitionVertical } from '@proj-airi/ui'
import { BasicTextarea } from '@proj-airi/ui'
import { useDevicesList } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
@@ -24,8 +24,8 @@ const providersStore = useProvidersStore()
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { audioInputs, ensurePermissions } = useDevicesList({ constraints: { audio: true } })
const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { ensurePermissions } = useDevicesList({ constraints: { audio: true } })
const { isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { send, onAfterSend, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
@@ -104,17 +104,6 @@ async function handleTranscription(buffer: ArrayBufferLike) {
generate({ type: 'generate', data: { audio: audioBase64, language: 'en' } })
}
async function handleAudioInputChange(event: Event) {
const target = event.target as HTMLSelectElement
const found = audioInputs.value.find(d => d.deviceId === target.value)
if (!found) {
selectedAudioDevice.value = undefined
return
}
selectedAudioDevice.value = found
}
watch(isAudioInputOn, async (value) => {
if (value === 'false') {
destroy()
@@ -195,46 +184,5 @@ onAfterSend(async () => {
</div>
</div>
</div>
<div flex="~ row" gap-2 hidden>
<div flex="~ row" relative text-white font-normal>
<TransitionVertical>
<fieldset
v-if="showMicrophoneSelect"
right="0" bottom="[calc(100%+8px)]" text="cyan-400 dark:white" bg="white dark:cyan-900"
absolute z-30 h-fit rounded-2xl py-3 pl-3 pr-4 text-right text-nowrap text-sm font-sans
>
<label v-for="(input, index) in audioInputs" :key="index" class="[&_div_span]:dark:hover:bg-cyan-300 [&_div_span]:dark:hover:bg-cyan-900">
<input type="radio" name="audioInput" :value="input.deviceId" hidden @change="handleAudioInputChange">
<div flex="~ row" cursor-pointer items-center grid="cols-2">
<div min-w="6">
<div v-if="input.deviceId === selectedAudioDeviceId" i-solar:check-circle-line-duotone />
</div>
<span
inline-block
:class="[input.deviceId === selectedAudioDeviceId ? 'cyan-400 dark:text-white' : 'cyan-400/50 dark:text-white/50']"
transition="all duration-250 ease-in-out"
>
{{ input.label }}
</span>
</div>
</label>
</fieldset>
</TransitionVertical>
<label
bg="complementary-100 hover:complementary-200 dark:complementary-800 dark:hover:complementary-700"
transition="all duration-250 ease-in-out"
:class="{ 'transition-colors-none': themeColorsHueDynamic }"
text="complementary-400"
h-fit flex cursor-pointer items-center justify-center gap-2 rounded-full px-4 py-2
>
<input v-model="showMicrophoneSelect" type="checkbox" hidden>
<div i-solar:microphone-2-bold-duotone />
<div>
<span v-if="!listening">{{ t('settings.microphone') }}</span>
<span v-else>Listening...</span>
</div>
</label>
</div>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script lang="ts" setup>
import { Button } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
const emits = defineEmits<{
(e: 'reset'): void
}>()
const { stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const mode = defineModel<'x' | 'y' | 'z' | 'scale'>({ required: true })
function handleViewControlsToggle(targetMode: 'x' | 'y' | 'z' | 'scale') {
if (mode.value === targetMode) {
emits('reset')
return
}
mode.value = targetMode
}
</script>
<template>
<div w-full flex flex-1 items-center self-end justify-end gap-2>
<Transition name="fade">
<div v-if="stageViewControlsEnabled" w-full flex justify-between gap-2>
<Button variant="secondary-muted" :toggled="mode === 'x'" w-full @click="handleViewControlsToggle('x')">
X
</Button>
<Button variant="secondary-muted" :toggled="mode === 'y'" w-full @click="handleViewControlsToggle('y')">
Y
</Button>
<Button v-if="stageView === '3d'" variant="secondary-muted" :toggled="mode === 'z'" w-full @click="handleViewControlsToggle('z')">
Z
</Button>
<Button variant="secondary-muted" :toggled="mode === 'scale'" w-full @click="handleViewControlsToggle('scale')">
Scale
</Button>
</div>
</Transition>
<button
w-fit flex items-center self-end justify-center justify-self-end rounded-xl p-2 backdrop-blur-md
border="2 solid neutral-100/60 dark:neutral-800/30"
bg="neutral-50/70 dark:neutral-800/70"
title="View"
text="neutral-500 dark:neutral-400"
@click="stageViewControlsEnabled = !stageViewControlsEnabled"
>
<Transition name="fade" mode="out-in">
<div v-if="!stageViewControlsEnabled" i-solar:tuning-outline size-5 />
<div v-else i-solar:alt-arrow-right-outline size-5 />
</Transition>
</button>
</div>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
</style>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import HeaderLink from './HeaderLink.vue'
</script>
<template>
<header mb-1 w-full gap-2>
<div w-full flex translate-x--2 justify-center>
<HeaderLink />
</div>
</header>
</template>
@@ -4,11 +4,20 @@ import type { ChatProvider } from '@xsai-ext/shared-providers'
import { useMicVAD } from '@proj-airi/stage-ui/composables'
import { useChatStore, useConsciousnessStore, useProvidersStore, useSettings } from '@proj-airi/stage-ui/stores'
import { BasicTextarea } from '@proj-airi/ui'
import { useDark } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { onMounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import MobileChatHistory from '../Widgets/MobileChatHistory.vue'
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
import ViewControlInputs from './ViewControls/Inputs.vue'
const isDark = useDark({ disableTransition: false })
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
const messageInput = ref('')
const listening = ref(false)
@@ -19,7 +28,7 @@ const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
// const { audioInputs } = useDevicesList({ constraints: { audio: true }, requestPermissions: true })
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId, themeColorsHueDynamic } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId, themeColorsHueDynamic, stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { send, onAfterSend, discoverToolsCompatibility } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
@@ -100,34 +109,75 @@ onMounted(() => {
</script>
<template>
<div fixed bottom-0 w-full flex gap-1 max-w="[calc(100dvw-1rem)]">
<MobileChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" h="80dvh" w-full />
<div flex flex-1 gap-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-50 dark:primary-950/10"
text="primary-500 hover:primary-600 dark:primary-100 dark:hover:primary-200 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300 placeholder:dark:hover:primary-400"
bg="primary-50/80 dark:primary-950/80"
max-h="[10lh]" min-h="[calc(1lh+4px+4px)]"
w-full resize-none overflow-y-scroll rounded="[1lh]" px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="[themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : '']"
default-height="1lh"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
<div fixed bottom-0 w-full flex flex-col>
<KeepAlive>
<Transition name="fade">
<MobileChatHistory v-if="!stageViewControlsEnabled" max-w="[calc(100%-3.5rem)]" w-full self-start pl-2 />
</Transition>
</KeepAlive>
<div relative w-full self-end>
<div fixed top="50%" translate-y="[-50%]" px-2>
<ViewControlInputs ref="viewControlsInputs" :mode="viewControlsActiveMode" />
</div>
<div translate-y="[-100%]" absolute right-0 w-full px-2 pb-2 font-sans>
<div flex="~ col" w-full gap-1>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="About">
<div i-solar:info-circle-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Theme" @click="isDark = !isDark">
<Transition name="fade" mode="out-in">
<div v-if="isDark" i-solar:moon-outline size-5 text="neutral-500 dark:neutral-400" />
<div v-else i-solar:sun-2-outline size-5 text="neutral-500 dark:neutral-400" />
</Transition>
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Language">
<div i-solar:earth-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<RouterLink to="/settings" border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Settings">
<div i-solar:settings-outline size-5 text="neutral-500 dark:neutral-400" />
</RouterLink>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Model">
<div i-solar:face-scan-circle-outline size-5 text="neutral-500 dark:neutral-400" />
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Stage View" @click="stageView = stageView === '2d' ? '3d' : '2d'">
<div v-if="stageView === '2d'" size-5 text="neutral-500 dark:neutral-400" inline-flex items-center justify-center>
<span>2D</span>
</div>
<div v-if="stageView === '3d'" size-5 text="neutral-500 dark:neutral-400" inline-flex items-center justify-center>
<span>3D</span>
</div>
</button>
<ActionViewControls v-model="viewControlsActiveMode" @reset="() => viewControlsInputsRef?.resetOnMode()" />
</div>
</div>
<div bg="white dark:neutral-800" w-full flex gap-1 px-2 py-2>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-50 dark:primary-950/10"
text="neutral-500 hover:neutral-600 dark:neutral-100 dark:hover:neutral-200 placeholder:neutral-400 placeholder:hover:neutral-500 placeholder:dark:neutral-300 placeholder:dark:hover:neutral-400"
bg="neutral-100/80 dark:neutral-950/80"
max-h="[10lh]" min-h="[calc(1lh+4px+4px)]"
w-full resize-none overflow-y-scroll rounded="[1lh]" px-4 py-0.5 outline-none backdrop-blur-md scrollbar-none
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="[themeColorsHueDynamic ? 'transition-colors-none placeholder:transition-colors-none' : '']"
default-height="1lh"
@submit="handleSend"
@compositionstart="isComposing = true"
@compositionend="isComposing = false"
/>
<button
v-if="messageInput.trim() || isComposing"
w="[calc(1lh+4px+4px)]" h="[calc(1lh+4px+4px)]" aspect-square flex items-center self-end justify-center rounded-full outline-none backdrop-blur-md
text="primary-500 hover:primary-600 dark:primary-100 dark:hover:primary-200 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300 placeholder:dark:hover:primary-400"
bg="primary-50/80 dark:primary-950/80"
@click="handleSend"
>
<div i-solar:arrow-up-outline />
</button>
v-if="messageInput.trim() || isComposing"
w="[calc(1lh+4px+4px)]" h="[calc(1lh+4px+4px)]" aspect-square flex items-center self-end justify-center rounded-full outline-none backdrop-blur-md
text="neutral-500 hover:neutral-600 dark:neutral-900 dark:hover:neutral-800"
bg="primary-50/80 dark:neutral-100/80 hover:neutral-50"
transition="all duration-250 ease-in-out"
@click="handleSend"
>
<div i-solar:arrow-up-outline />
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { useLive2d, useSettings, useVRM } from '@proj-airi/stage-ui/stores'
import { RoundRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const props = defineProps<{
mode: 'x' | 'y' | 'z' | 'scale'
}>()
const { stageView, stageViewControlsEnabled } = storeToRefs(useSettings())
const { scale: vrmScale, position: vrmPosition, modelSize: vrmModelSize } = storeToRefs(useVRM())
const { scale: live2dScale, position: live2dPosition } = storeToRefs(useLive2d())
const viewControlsValueX = computed({
get: () => {
switch (stageView.value) {
case '2d':
return live2dPosition.value.x
case '3d':
return vrmPosition.value.x
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
live2dPosition.value.x = value
break
case '3d':
vrmPosition.value.x = value
break
default:
break
}
},
})
const viewControlsValueXMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.x - 10
})
const viewControlsValueXMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.x + 10
})
const viewControlsValueY = computed({
get: () => {
switch (stageView.value) {
case '2d':
return live2dPosition.value.y
case '3d':
return vrmPosition.value.y
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
live2dPosition.value.y = value
break
case '3d':
vrmPosition.value.y = value
break
default:
break
}
},
})
const viewControlsValueYMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.y - 10
})
const viewControlsValueYMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.y + 10
})
const viewControlsValueZ = computed({
get: () => {
switch (stageView.value) {
case '2d':
return 0
case '3d':
return vrmPosition.value.z
default:
return 0
}
},
set: (value) => {
switch (stageView.value) {
case '2d':
break
case '3d':
vrmPosition.value.z = value
break
default:
break
}
},
})
const viewControlsValueZMin = computed(() => {
return stageView.value === '2d' ? -500 : -vrmModelSize.value.z - 10
})
const viewControlsValueZMax = computed(() => {
return stageView.value === '2d' ? 500 : vrmModelSize.value.z + 10
})
const viewControlsValueScale = computed({
get: () => {
if (stageView.value === '2d') {
return live2dScale.value
}
return vrmScale.value
},
set: (value) => {
if (stageView.value === '2d') {
live2dScale.value = value
}
else {
vrmScale.value = value
}
},
})
function resetOnMode() {
switch (props.mode) {
case 'x':
viewControlsValueX.value = 0
break
case 'y':
viewControlsValueY.value = 0
break
case 'z':
viewControlsValueZ.value = 0
break
case 'scale':
viewControlsValueScale.value = 1
break
}
}
defineExpose({
resetOnMode,
})
</script>
<template>
<Transition name="fade-side-pops-in">
<div v-if="stageViewControlsEnabled">
<Transition name="fade-side-pops-in" mode="out-in">
<div v-if="props.mode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueX" :min="viewControlsValueXMin" :max="viewControlsValueXMax" :step="0.01" data-direction="vertical" h="50%" write-vertical-left />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueX.toFixed(2) }}
</div>
</div>
<div v-else-if="props.mode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueY" :min="viewControlsValueYMin" :max="viewControlsValueYMax" :step="0.01" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueY.toFixed(2) }}
</div>
</div>
<div v-else-if="stageView === '3d' && props.mode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueZ" :min="viewControlsValueZMin" :max="viewControlsValueZMax" :step="0.01" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueZ.toFixed(2) }}
</div>
</div>
<div v-else-if="props.mode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
<RoundRange v-model="viewControlsValueScale" :min="0" :max="3" :step="0.0001" write-vertical-left h="50%" data-direction="vertical" />
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
{{ viewControlsValueScale.toFixed(2) }}
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<style scoped>
.fade-side-pops-in-enter-active,
.fade-side-pops-in-leave-active {
transition: all 0.2s ease-in-out;
}
.fade-side-pops-in-enter-from,
.fade-side-pops-in-leave-to {
opacity: 0;
transform: translateX(-100%) scale(0.8);
}
.fade-side-pops-in-enter-to,
.fade-side-pops-in-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
</style>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -10,8 +9,6 @@ const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages, sending } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
@@ -19,16 +16,20 @@ const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
</script>
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useMarkdown } from '@proj-airi/stage-ui/composables'
import { useChatStore } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useScroll } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -10,8 +9,6 @@ const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
@@ -19,25 +16,27 @@ const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
onBeforeMessageComposed(async () => {
// Scroll down to the new sent message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
onTokenLiteral(async () => {
// Scroll down to the new responding message
nextTick().then(() => {
bounding.update()
chatHistoryContainerY.value = bounding.height.value
if (!chatHistoryRef.value)
return
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
})
})
</script>
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="40%" flex="~ col" overflow-scroll class="chat-history">
<div flex-1 /> <!-- spacer -->
<div flex="~ col" rounded="lg" overflow-hidden>
<div ref="chatHistoryRef" v-auto-animate max-h="35dvh" z-5 flex="~ col" h-full w-full overflow-scroll class="chat-history">
<div v-for="(message, index) in messages" :key="index" mb-2>
<div v-if="message.role === 'error'" flex mr="12">
<div
+1 -1
View File
@@ -138,7 +138,7 @@ onMounted(() => updateThemeColor())
<HeaderLink />
</div>
<!-- Content -->
<div class="h-full px-3 py-0 sm:h-[calc(100%-4rem)] md:py-0 xl:px-0" flex="~ col" mx-auto max-w-screen-xl>
<div class="h-[calc(100%-40px)] px-3 py-0 2xl:max-w-[2160px] sm:h-[calc(100%-56px)] md:py-0 xl:px-4" flex="~ col" mx-auto>
<PageHeader
:title="routeHeaderMetadata?.title"
:subtitle="routeHeaderMetadata?.subtitle"
+5 -3
View File
@@ -8,6 +8,7 @@ import { onMounted, ref, watch } from 'vue'
import Cross from '../components/Backgrounds/Cross.vue'
import Header from '../components/Layouts/Header.vue'
import InteractiveArea from '../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../components/Widgets/AnimatedWave.vue'
@@ -41,7 +42,8 @@ onMounted(() => updateThemeColor())
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="flex" />
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
@@ -56,8 +58,8 @@ onMounted(() => updateThemeColor())
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea class="flex <md:hidden" absolute h="85dvh" right-4 flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</AnimatedWave>
@@ -43,8 +43,14 @@ const {
</script>
<template>
<div flex class="relative h-[calc(100dvh-8rem)] flex-col-reverse md:flex-row">
<ModelSettings live-2d-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit overflow-y-scroll absolute bottom-0 right-0 top-0" :palette="palette" @extract-colors-from-model="extractColorsFromModel" />
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
live-2d-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-settings-class="w-100% md:w-40% lg:w-30% xl:w-30% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
<IconAnimation
@@ -58,6 +64,7 @@ const {
position="calc(100dvw - 9.5rem), calc(100dvh - 9.5rem)"
text-color="text-neutral-200/50 dark:text-neutral-600/20"
/>
<div
v-else
v-motion
+24 -7
View File
@@ -1,14 +1,19 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useDark, useMouse } from '@vueuse/core'
import { ref } from 'vue'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import Cross from '../../components/Backgrounds/Cross.vue'
import Header from '../../components/Layouts/Header.vue'
import InteractiveArea from '../../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../../components/Widgets/AnimatedWave.vue'
import { themeColorFromPropertyOf, useThemeColor } from '../../composables/theme-color'
const dark = useDark()
const paused = ref(false)
@@ -17,19 +22,28 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const { updateThemeColor } = useThemeColor(themeColorFromPropertyOf('.widgets.top-widgets .colored-area', 'background-color'))
watch(dark, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
</script>
<template>
<Cross>
<AnimatedWave
class="widgets top-widgets"
:fill-color="dark
? 'oklch(35% calc(var(--chromatic-chroma) * 0.6) var(--chromatic-hue))'
: 'color-mix(in srgb, oklch(95% calc(var(--chromatic-chroma-50) * 0.5) var(--chromatic-hue)) 80%, oklch(100% 0 360))'"
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div>
<Header class="flex" p2 />
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
@@ -40,9 +54,12 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea class="flex <md:hidden" flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</AnimatedWave>
@@ -50,7 +67,7 @@ const positionCursor = useMouse()
</template>
<route lang="yaml">
name: StageScenePage
name: IndexScenePage
meta:
layout: stage
stageTransition:
+1
View File
@@ -1,4 +1,5 @@
@import './transitions.css';
@import './vue-transitions.css';
:root {
--bg-color-light: rgb(255 255 255);
@@ -0,0 +1,48 @@
.fade-slide-out-r-to-l-enter-active,
.fade-slide-out-r-to-l-leave-active {
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.fade-slide-out-r-to-l-enter-from,
.fade-slide-out-r-to-l-leave-to {
opacity: 0;
transform: translateX(10px);
}
.fade-slide-out-r-to-l-enter-to,
.fade-slide-out-r-to-l-leave-from {
opacity: 1;
transform: translateX(0);
}
.fade-slide-out-l-to-r-enter-active,
.fade-slide-out-l-to-r-leave-active {
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
}
.fade-slide-out-l-to-r-enter-from,
.fade-slide-out-l-to-r-leave-to {
opacity: 0;
transform: translateX(-10px);
}
.fade-slide-out-l-to-r-enter-to,
.fade-slide-out-l-to-r-leave-from {
opacity: 1;
transform: translateX(0);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}