feat(stage-pocket): WebSocket status indicator + shared i18n (#1586)

This commit is contained in:
LemonNeko
2026-04-06 19:54:21 +08:00
committed by GitHub
parent e35b3d228c
commit 883ec945d2
33 changed files with 344 additions and 172 deletions
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useLampFlickerAnimation } from '@proj-airi/stage-ui/composables/use-lamp-flicker-animation'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { lampFlickerAnimationClass } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const { t } = useI18n()
const router = useRouter()
const { connected } = storeToRefs(useModsServerChannelStore())
const { flickerStyle, onAnimationIteration } = useLampFlickerAnimation(() => !connected.value)
const statusSize = {
border: 'border-2',
icon: 'size-6',
padding: 'p-2.5',
} as const
const buttonClass = computed(() => {
return [
statusSize.border,
statusSize.padding,
'transition-all duration-300 ease-in-out',
'border-solid rounded-xl backdrop-blur-md',
'w-fit flex items-center self-end justify-center',
connected.value
? 'border-emerald-200/60 bg-white/85 hover:bg-emerald-50/90 dark:border-emerald-400/15 dark:bg-neutral-900/75 dark:hover:bg-neutral-900/88'
: 'border-amber-300/80 bg-amber-50/90 hover:bg-amber-100/90 dark:border-amber-400/30 dark:bg-amber-950/25 dark:hover:bg-amber-950/38',
]
})
const iconClasses = computed(() => {
return [
connected.value ? 'i-ph:wifi-high' : `i-ph:wifi-slash ${lampFlickerAnimationClass}`,
statusSize.icon,
'shrink-0 transition-colors duration-300 ease-in-out',
connected.value
? 'text-emerald-600 dark:text-emerald-300'
: 'text-amber-600 dark:text-amber-300',
]
})
const buttonLabel = computed(() => {
return connected.value
? t('stage.websocket-status.connected')
: t('stage.websocket-status.disconnected')
})
const tooltipLabel = computed(() => {
return `${buttonLabel.value}. ${t('stage.websocket-status.open-settings')}`
})
function openConnectionSettings() {
void router.push('/settings/connection')
}
</script>
<template>
<div
class="fixed right-3 z-20"
:style="{ top: 'max(0.75rem, env(safe-area-inset-top, 0px))' }"
>
<TooltipProvider :delay-duration="0" :skip-delay-duration="0">
<TooltipRoot>
<TooltipTrigger as-child>
<button
type="button"
:class="buttonClass"
:aria-label="tooltipLabel"
:title="tooltipLabel"
@click="openConnectionSettings"
>
<div
:class="iconClasses"
:style="flickerStyle"
@animationiteration="onAnimationIteration"
/>
</button>
</TooltipTrigger>
<Transition name="pocket-ws-tooltip-fade">
<TooltipContent
:class="[
'border border-solid border-neutral-200/60 dark:border-neutral-800/10',
'bg-neutral-50/80 dark:bg-neutral-800/70',
'w-fit flex items-center px-1.5 py-1 rounded-lg backdrop-blur-md text-xs',
]"
side="left"
:side-offset="4"
>
{{ tooltipLabel }}
</TooltipContent>
</Transition>
</TooltipRoot>
</TooltipProvider>
</div>
</template>
<style scoped>
.pocket-ws-tooltip-fade-enter-active,
.pocket-ws-tooltip-fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.pocket-ws-tooltip-fade-enter-from,
.pocket-ws-tooltip-fade-leave-to {
opacity: 0;
}
.pocket-ws-tooltip-fade-enter-to,
.pocket-ws-tooltip-fade-leave-from {
opacity: 1;
}
</style>
+3
View File
@@ -23,6 +23,8 @@ import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import WebSocketStatusButton from '../components/websocket-status-button.vue'
const paused = ref(false)
function handleSettingsOpen(open: boolean) {
@@ -181,6 +183,7 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
:top-color="sampledColor"
>
<div flex="~ col" relative z-2 h-100dvh w-100vw of-hidden py-safe>
<WebSocketStatusButton />
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
+1
View File
@@ -1,3 +1,4 @@
@import '@proj-airi/ui/main.css';
@import './transitions.css';
@import './vue-transitions.css';
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useLampFlickerAnimation } from '@proj-airi/stage-ui/composables/use-lamp-flicker-animation'
import { useModsServerChannelStore } from '@proj-airi/stage-ui/stores/mods/api/channel-server'
import { lampFlickerAnimationClass } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ControlButtonTooltip from '../controls-island/control-button-tooltip.vue'
@@ -10,11 +12,11 @@ import ControlButton from '../controls-island/control-button.vue'
import { electronOpenSettings } from '../../../../shared/eventa'
const { t, te } = useI18n()
const { t } = useI18n()
const { connected } = storeToRefs(useModsServerChannelStore())
const openSettings = useElectronEventaInvoke(electronOpenSettings)
const flickerDuration = ref('6.4s')
const flickerDelay = ref('0s')
const { flickerStyle, onAnimationIteration } = useLampFlickerAnimation(() => !connected.value)
const statusIslandSize = {
border: 'border-2',
@@ -35,7 +37,7 @@ const buttonStyle = computed(() => {
const iconClasses = computed(() => {
return [
connected.value ? 'i-ph:wifi-high' : 'i-ph:wifi-slash status-island-lamp-flicker',
connected.value ? 'i-ph:wifi-high' : `i-ph:wifi-slash ${lampFlickerAnimationClass}`,
statusIslandSize.icon,
'shrink-0 transition-colors duration-300 ease-in-out',
connected.value
@@ -44,62 +46,15 @@ const iconClasses = computed(() => {
]
})
const iconStyle = computed(() => {
if (connected.value) {
return undefined
}
return {
'--status-island-flicker-delay': flickerDelay.value,
'--status-island-flicker-duration': flickerDuration.value,
}
})
const buttonLabel = computed(() => {
if (connected.value) {
return te('tamagotchi.stage.status-island.connected')
? t('tamagotchi.stage.status-island.connected')
: 'WebSocket connected'
}
return te('tamagotchi.stage.status-island.disconnected')
? t('tamagotchi.stage.status-island.disconnected')
: 'WebSocket disconnected'
return connected.value
? t('stage.websocket-status.connected')
: t('stage.websocket-status.disconnected')
})
const tooltipLabel = computed(() => {
const openSettingsLabel = te('tamagotchi.stage.status-island.open-settings')
? t('tamagotchi.stage.status-island.open-settings')
: 'Open WebSocket settings'
return `${buttonLabel.value}. ${openSettingsLabel}`
return `${buttonLabel.value}. ${t('stage.websocket-status.open-settings')}`
})
function randomizeFlicker(resetPhase = false) {
flickerDuration.value = `${(5.8 + Math.random() * 1.8).toFixed(2)}s`
if (resetPhase) {
flickerDelay.value = `${(-Math.random() * 5.4).toFixed(2)}s`
return
}
flickerDelay.value = '0s'
}
function handleFlickerIteration() {
if (!connected.value) {
randomizeFlicker()
}
}
watch(connected, (isConnected) => {
if (isConnected) {
flickerDelay.value = '0s'
return
}
randomizeFlicker(true)
}, { immediate: true })
</script>
<template>
@@ -111,7 +66,7 @@ watch(connected, (isConnected) => {
:title="tooltipLabel"
@click="openSettings({ route: '/settings/connection' })"
>
<div :class="iconClasses" :style="iconStyle" @animationiteration="handleFlickerIteration" />
<div :class="iconClasses" :style="flickerStyle" @animationiteration="onAnimationIteration" />
</ControlButton>
<template #tooltip>
{{ tooltipLabel }}
@@ -119,88 +74,3 @@ watch(connected, (isConnected) => {
</ControlButtonTooltip>
</div>
</template>
<style scoped>
@keyframes status-island-lamp-flicker {
0%,
6%,
22%,
33%,
52%,
68%,
86%,
100% {
opacity: 1;
}
3% {
opacity: 0.74;
}
9% {
opacity: 0.92;
}
13% {
opacity: 0.38;
}
18% {
opacity: 0.58;
}
27% {
opacity: 0.44;
}
29% {
opacity: 0.84;
}
41% {
opacity: 0.42;
}
45% {
opacity: 0.88;
}
57% {
opacity: 0.62;
}
61% {
opacity: 0.8;
}
73% {
opacity: 0.36;
}
74.4% {
opacity: 0.08;
}
75.2% {
opacity: 0.82;
}
78% {
opacity: 0.94;
}
91% {
opacity: 0.52;
}
}
.status-island-lamp-flicker {
animation-delay: var(--status-island-flicker-delay, 0s);
animation-duration: var(--status-island-flicker-duration, 6.4s);
animation-iteration-count: infinite;
animation-name: status-island-lamp-flicker;
animation-timing-function: ease-in-out;
filter: drop-shadow(0 0 0.14rem rgb(251 191 36 / 0.18));
will-change: opacity;
}
</style>
@@ -1,3 +1,4 @@
@import '@proj-airi/ui/main.css';
@import './transitions.css';
html,
+1
View File
@@ -1,3 +1,4 @@
@import '@proj-airi/ui/main.css';
@import './transitions.css';
@import './vue-transitions.css';
+1
View File
@@ -1,3 +1,4 @@
@import '@proj-airi/ui/main.css';
@import './transitions.css';
@import './vue-transitions.css';
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: Waiting
websocket-status:
connected: WebSocket connected
disconnected: WebSocket disconnected
open-settings: Open WebSocket settings
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket connected
disconnected: WebSocket disconnected
'open-settings': Open WebSocket settings
notice:
'fade-on-hover':
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: Esperando...
websocket-status:
connected: WebSocket conectado
disconnected: WebSocket desconectado
open-settings: Abrir ajustes de WebSocket
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket conectado
disconnected: WebSocket desconectado
'open-settings': Abrir ajustes de WebSocket
notice:
'fade-on-hover':
title: Desvanecer al pasar el cursor
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Ticket de tirage de thème gratuit à la connexion
cta: Voir le thème
waiting: En attente
websocket-status:
connected: WebSocket connecté
disconnected: WebSocket déconnecté
open-settings: Ouvrir les paramètres WebSocket
@@ -25,6 +25,10 @@ docs:
'close': Fermer
'expand': Développer
'collapse': Réduire
'login': Sign in
'logging-in': Signing in...
'account': Account
'logout': Sign out
'login': Se connecter
'logging-in': Connexion en cours...
'account': Compte
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: ログイン時の無料テーマ抽選チケット
cta: テーマを表示
waiting: 待機中
websocket-status:
connected: WebSocket が接続されました
disconnected: WebSocket が切断されました
open-settings: WebSocketの設定を開く
@@ -25,6 +25,10 @@ docs:
'close': 閉じる
'expand': 展開
'collapse': 折りたたむ
'login': Sign in
'logging-in': Signing in...
'account': Account
'logout': Sign out
'login': サインイン
'logging-in': サインイン中…
'account': アカウント
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: 대기 중
websocket-status:
connected: WebSocket에 연결됨
disconnected: WebSocket 연결 끊김
open-settings: WebSocket 설정 열기
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket connected
disconnected: WebSocket disconnected
'open-settings': Open WebSocket settings
notice:
'fade-on-hover':
title: 호버 시 페이드
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: Ожидание
websocket-status:
connected: WebSocket подключен
disconnected: WebSocket отключен
open-settings: Открыть настройки WebSocket
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket подключен
disconnected: WebSocket отключен
'open-settings': Открыть настройки WebSocket
notice:
'fade-on-hover':
title: Исчезать при наведении
+4
View File
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: Đang chờ
websocket-status:
connected: WebSocket đã kết nối
disconnected: WebSocket đã ngắt kết nối
open-settings: Mở cài đặt WebSocket
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket connected
disconnected: WebSocket disconnected
'open-settings': Open WebSocket settings
notice:
'fade-on-hover':
title: Mờ dần khi rê chuột
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: 等待中
websocket-status:
connected: WebSocket 已连接
disconnected: WebSocket 已断开
open-settings: 打开 WebSocket 设置
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket 已连接
disconnected: WebSocket 已断开
'open-settings': 打开 WebSocket 设置
notice:
'fade-on-hover':
title: 悬停淡出
@@ -84,3 +84,7 @@ promo-banner:
reward: Free Theme Draw Ticket on Login
cta: View Theme
waiting: 等待中
websocket-status:
connected: WebSocket 已連線
disconnected: WebSocket 已中斷
open-settings: 開啟 WebSocket 設定
@@ -29,10 +29,6 @@ docs:
'logging-in': Signing in...
'account': Account
'logout': Sign out
'status-island':
connected: WebSocket connected
disconnected: WebSocket disconnected
'open-settings': Open WebSocket settings
notice:
'fade-on-hover':
title: 懸停淡出
@@ -8,6 +8,7 @@ export * from './use-async-state'
export * from './use-breakpoints'
export * from './use-build-info'
export * from './use-chat-session/summary'
export * from './use-lamp-flicker-animation'
export * from './use-optimistic'
export * from './use-scroll-to-hash'
export * from './vision'
@@ -0,0 +1,64 @@
import type { MaybeRefOrGetter } from 'vue'
import { lampFlickerAnimationClass } from '@proj-airi/ui'
import { computed, ref, toValue, watch } from 'vue'
export { lampFlickerAnimationClass }
const LAMP_FLICKER_DELAY_VAR = '--lamp-flicker-delay'
const LAMP_FLICKER_DURATION_VAR = '--lamp-flicker-duration'
/**
* Drives randomized keyframe timing for `.lamp-flicker-animation` from `@proj-airi/ui/main.css` while `flickerActive` is true.
* Import `@proj-airi/ui/main.css` from the app global stylesheet (e.g. `styles/main.css`). When inactive, delay resets to 0s.
*/
export function useLampFlickerAnimation(flickerActive: MaybeRefOrGetter<boolean>) {
const flickerDuration = ref('6.4s')
const flickerDelay = ref('0s')
function randomizeFlicker(resetPhase = false) {
flickerDuration.value = `${(5.8 + Math.random() * 1.8).toFixed(2)}s`
if (resetPhase) {
flickerDelay.value = `${(-Math.random() * 5.4).toFixed(2)}s`
return
}
flickerDelay.value = '0s'
}
function onAnimationIteration() {
if (toValue(flickerActive)) {
randomizeFlicker()
}
}
watch(
() => toValue(flickerActive),
(active) => {
if (!active) {
flickerDelay.value = '0s'
return
}
randomizeFlicker(true)
},
{ immediate: true },
)
const flickerStyle = computed(() => {
if (!toValue(flickerActive)) {
return undefined
}
return {
[LAMP_FLICKER_DELAY_VAR]: flickerDelay.value,
[LAMP_FLICKER_DURATION_VAR]: flickerDuration.value,
} as Record<string, string>
})
return {
flickerStyle,
onAnimationIteration,
}
}
@@ -1,3 +1,4 @@
@import '@proj-airi/ui/main.css';
@import './transitions.css';
html,
+1
View File
@@ -16,6 +16,7 @@
},
"exports": {
".": "./src/index.ts",
"./main.css": "./src/main.css",
"./components/animations": "./src/components/animations/index.ts",
"./components/form": "./src/components/form/index.ts",
"./components/layouts": "./src/components/layouts/index.ts",
@@ -0,0 +1,2 @@
/** Global class from `@proj-airi/ui/main.css`; pairs with useLampFlickerAnimation in stage-ui. */
export const lampFlickerAnimationClass = 'lamp-flicker-animation' as const
+1 -2
View File
@@ -1,8 +1,7 @@
import './fallback.css'
export * from './components/animations'
export * from './components/form'
export * from './components/layouts'
export * from './components/misc'
export * from './composables/use-deferred-mount'
export * from './composables/use-theme'
export { lampFlickerAnimationClass } from './constants/lamp-flicker-animation'
+88
View File
@@ -0,0 +1,88 @@
/**
* Opacity “lamp flicker” for offline / warning icons.
* Timing is driven by useLampFlickerAnimation (@proj-airi/stage-ui) via
* --lamp-flicker-delay and --lamp-flicker-duration.
* Loaded via @proj-airi/ui/main.css (import from app `styles/main.css`).
*/
@keyframes lamp-flicker-keyframes {
0%,
6%,
22%,
33%,
52%,
68%,
86%,
100% {
opacity: 1;
}
3% {
opacity: 0.74;
}
9% {
opacity: 0.92;
}
13% {
opacity: 0.38;
}
18% {
opacity: 0.58;
}
27% {
opacity: 0.44;
}
29% {
opacity: 0.84;
}
41% {
opacity: 0.42;
}
45% {
opacity: 0.88;
}
57% {
opacity: 0.62;
}
61% {
opacity: 0.8;
}
73% {
opacity: 0.36;
}
74.4% {
opacity: 0.08;
}
75.2% {
opacity: 0.82;
}
78% {
opacity: 0.94;
}
91% {
opacity: 0.52;
}
}
.lamp-flicker-animation {
animation-delay: var(--lamp-flicker-delay, 0s);
animation-duration: var(--lamp-flicker-duration, 6.4s);
animation-iteration-count: infinite;
animation-name: lamp-flicker-keyframes;
animation-timing-function: ease-in-out;
filter: drop-shadow(0 0 0.14rem rgb(251 191 36 / 0.18));
will-change: opacity;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Global styles for @proj-airi/ui. Import once from the app entry stylesheet
* (e.g. `src/styles/main.css`) so CSS is not tied to JS side-effect imports.
*/
@import './fallback.css';
@import './lamp-flicker.css';