mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Replace state-based screen navigation with proper URL-based routing. This includes: - New router configuration with routes for sessions, machines, and spawn pages - App context provider to share API and token across the app - useAppGoBack hook for handling browser and Telegram back navigation - Refactored App component to render outlet and use router hooks - Memory history for Telegram app, browser history for web
177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
import { useCallback, useEffect, useMemo } from 'react'
|
|
import { Outlet, useLocation, useMatchRoute } from '@tanstack/react-router'
|
|
import { useQueryClient } from '@tanstack/react-query'
|
|
import { getTelegramWebApp } from '@/hooks/useTelegram'
|
|
import { initializeTheme } from '@/hooks/useTheme'
|
|
import { useAuth } from '@/hooks/useAuth'
|
|
import { useAuthSource } from '@/hooks/useAuthSource'
|
|
import { useSSE } from '@/hooks/useSSE'
|
|
import { queryKeys } from '@/lib/query-keys'
|
|
import { AppContextProvider } from '@/lib/app-context'
|
|
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
|
import { LoginPrompt } from '@/components/LoginPrompt'
|
|
import { InstallPrompt } from '@/components/InstallPrompt'
|
|
import { OfflineBanner } from '@/components/OfflineBanner'
|
|
|
|
export function App() {
|
|
const { authSource, isLoading: isAuthSourceLoading, setAccessToken } = useAuthSource()
|
|
const { token, api, isLoading: isAuthLoading, error: authError } = useAuth(authSource)
|
|
const goBack = useAppGoBack()
|
|
const pathname = useLocation({ select: (location) => location.pathname })
|
|
const matchRoute = useMatchRoute()
|
|
|
|
useEffect(() => {
|
|
const tg = getTelegramWebApp()
|
|
tg?.ready()
|
|
tg?.expand()
|
|
initializeTheme()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const preventDefault = (event: Event) => {
|
|
event.preventDefault()
|
|
}
|
|
|
|
const onWheel = (event: WheelEvent) => {
|
|
if (event.ctrlKey) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
const modifier = event.ctrlKey || event.metaKey
|
|
if (!modifier) return
|
|
if (event.key === '+' || event.key === '-' || event.key === '=' || event.key === '0') {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
document.addEventListener('gesturestart', preventDefault as EventListener, { passive: false })
|
|
document.addEventListener('gesturechange', preventDefault as EventListener, { passive: false })
|
|
document.addEventListener('gestureend', preventDefault as EventListener, { passive: false })
|
|
|
|
window.addEventListener('wheel', onWheel, { passive: false })
|
|
window.addEventListener('keydown', onKeyDown)
|
|
|
|
return () => {
|
|
document.removeEventListener('gesturestart', preventDefault as EventListener)
|
|
document.removeEventListener('gesturechange', preventDefault as EventListener)
|
|
document.removeEventListener('gestureend', preventDefault as EventListener)
|
|
|
|
window.removeEventListener('wheel', onWheel)
|
|
window.removeEventListener('keydown', onKeyDown)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const tg = getTelegramWebApp()
|
|
const backButton = tg?.BackButton
|
|
if (!backButton) return
|
|
|
|
if (pathname === '/' || pathname === '/sessions') {
|
|
backButton.offClick(goBack)
|
|
backButton.hide()
|
|
return
|
|
}
|
|
|
|
backButton.show()
|
|
backButton.onClick(goBack)
|
|
return () => {
|
|
backButton.offClick(goBack)
|
|
backButton.hide()
|
|
}
|
|
}, [goBack, pathname])
|
|
const queryClient = useQueryClient()
|
|
const sessionMatch = matchRoute({ to: '/sessions/$sessionId' })
|
|
const spawnMatch = matchRoute({ to: '/machines/$machineId/spawn' })
|
|
const selectedSessionId = sessionMatch ? sessionMatch.sessionId : null
|
|
const spawnMachineId = spawnMatch ? spawnMatch.machineId : null
|
|
|
|
const handleSseConnect = useCallback(() => {
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions })
|
|
if (selectedSessionId) {
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) })
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) })
|
|
}
|
|
}, [queryClient, selectedSessionId])
|
|
|
|
const handleSseEvent = useCallback(() => {}, [])
|
|
|
|
const eventSubscription = useMemo(() => {
|
|
if (selectedSessionId) {
|
|
return { sessionId: selectedSessionId }
|
|
}
|
|
if (spawnMachineId) {
|
|
return { machineId: spawnMachineId }
|
|
}
|
|
return { all: true }
|
|
}, [selectedSessionId, spawnMachineId])
|
|
|
|
useSSE({
|
|
enabled: Boolean(api && token),
|
|
token: token ?? '',
|
|
subscription: eventSubscription,
|
|
onConnect: handleSseConnect,
|
|
onEvent: handleSseEvent,
|
|
})
|
|
|
|
// Loading auth source
|
|
if (isAuthSourceLoading) {
|
|
return (
|
|
<div className="p-4">
|
|
<div className="text-sm text-[var(--app-hint)]">Loading…</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// No auth source (browser environment, not logged in)
|
|
if (!authSource) {
|
|
return <LoginPrompt onLogin={setAccessToken} />
|
|
}
|
|
|
|
// Authenticating
|
|
if (isAuthLoading) {
|
|
return (
|
|
<div className="p-4">
|
|
<div className="text-sm text-[var(--app-hint)]">Authorizing…</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Auth error
|
|
if (authError || !token || !api) {
|
|
// If using access token and auth failed, show login again
|
|
if (authSource.type === 'accessToken') {
|
|
return (
|
|
<LoginPrompt
|
|
onLogin={setAccessToken}
|
|
error={authError ?? 'Authentication failed'}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Telegram auth failed
|
|
return (
|
|
<div className="p-4 space-y-3">
|
|
<div className="text-base font-semibold">Hapi</div>
|
|
<div className="text-sm text-red-600">
|
|
{authError ?? 'Not authorized'}
|
|
</div>
|
|
<div className="text-xs text-[var(--app-hint)]">
|
|
Open this page from Telegram using the bot's "Open App" button (not "Open in browser").
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppContextProvider value={{ api, token }}>
|
|
<OfflineBanner />
|
|
<div className="h-full flex flex-col">
|
|
<Outlet />
|
|
</div>
|
|
<InstallPrompt />
|
|
</AppContextProvider>
|
|
)
|
|
}
|