mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* fix(web): use theme colors for storage refresh button * fix(web): use theme colors for share fallback action
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate, useSearch } from '@tanstack/react-router'
|
|
import { useAppContext } from '@/lib/app-context'
|
|
import { useSessions } from '@/hooks/queries/useSessions'
|
|
import { useTranslation } from '@/lib/use-translation'
|
|
import { LoadingState } from '@/components/LoadingState'
|
|
import {
|
|
deleteShareTransfer,
|
|
getShareTransfer,
|
|
type ShareTransferPayload,
|
|
} from '@/lib/shareTransfer'
|
|
import { setSharePendingTransfer } from '@/lib/sharePendingState'
|
|
import { getSessionTitle } from '@/lib/sessionTitle'
|
|
import type { SessionSummary } from '@/types/api'
|
|
|
|
type LoadState =
|
|
| { state: 'loading' }
|
|
| { state: 'missing'; reason: 'not-found' | 'ingest-error' | 'no-id' }
|
|
| { state: 'ready'; payload: ShareTransferPayload }
|
|
|
|
function shortenText(text: string, max = 200): string {
|
|
const trimmed = text.trim()
|
|
if (trimmed.length <= max) return trimmed
|
|
return trimmed.slice(0, max).trimEnd() + '…'
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
}
|
|
|
|
function SharePreview(props: { payload: ShareTransferPayload }) {
|
|
const { payload } = props
|
|
const { t } = useTranslation()
|
|
const firstImage = payload.files.find((f) => f.type.startsWith('image/'))
|
|
const previewUrl = useMemo(() => {
|
|
if (!firstImage) return null
|
|
return URL.createObjectURL(firstImage.blob)
|
|
}, [firstImage])
|
|
useEffect(() => {
|
|
return () => {
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
|
}
|
|
}, [previewUrl])
|
|
|
|
if (payload.files.length === 0) {
|
|
const fallback = payload.text || payload.url || payload.title
|
|
return (
|
|
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-sm text-[var(--app-fg)]">
|
|
<div className="text-xs font-semibold text-[var(--app-hint)]">
|
|
{t('share.preview.text')}
|
|
</div>
|
|
<div className="mt-1 break-words">
|
|
{fallback ? shortenText(fallback) : t('share.preview.empty')}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (payload.files.length === 1) {
|
|
const file = payload.files[0]
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-md bg-[var(--app-secondary-bg)] p-3">
|
|
{previewUrl ? (
|
|
<img
|
|
src={previewUrl}
|
|
alt={file.name}
|
|
className="h-16 w-16 rounded object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-16 w-16 items-center justify-center rounded bg-[var(--app-subtle-bg)] text-xs uppercase text-[var(--app-hint)]">
|
|
{file.type.split('/')[1]?.slice(0, 4) ?? 'file'}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-[var(--app-fg)]">
|
|
{file.name}
|
|
</div>
|
|
<div className="text-xs text-[var(--app-hint)]">
|
|
{formatBytes(file.blob.size)} · {file.type || 'application/octet-stream'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const summary = payload.files.slice(0, 3).map((f) => f.name).join(', ')
|
|
+ (payload.files.length > 3 ? '…' : '')
|
|
return (
|
|
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-sm text-[var(--app-fg)]">
|
|
<div className="text-xs font-semibold text-[var(--app-hint)]">
|
|
{t('share.preview.files', { n: payload.files.length })}
|
|
</div>
|
|
<div className="mt-1 break-words">{summary}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function SharePage() {
|
|
const { api } = useAppContext()
|
|
const { t } = useTranslation()
|
|
const navigate = useNavigate()
|
|
const [load, setLoad] = useState<LoadState>({ state: 'loading' })
|
|
const { sessions, isLoading: sessionsLoading } = useSessions(api)
|
|
|
|
// Pulled via the typed validateSearch in router.tsx; reading
|
|
// `window.location.search` directly would diverge from the rest of the
|
|
// codebase and miss future schema tightening.
|
|
const search = useSearch({ from: '/share' }) as { id?: string; error?: string }
|
|
const transferId = search.id ?? null
|
|
const ingestError = search.error === 'ingest'
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
if (ingestError) {
|
|
setLoad({ state: 'missing', reason: 'ingest-error' })
|
|
return
|
|
}
|
|
if (!transferId) {
|
|
setLoad({ state: 'missing', reason: 'no-id' })
|
|
return
|
|
}
|
|
getShareTransfer(transferId).then((payload) => {
|
|
if (cancelled) return
|
|
if (!payload) {
|
|
setLoad({ state: 'missing', reason: 'not-found' })
|
|
return
|
|
}
|
|
setLoad({ state: 'ready', payload })
|
|
}).catch(() => {
|
|
if (cancelled) return
|
|
setLoad({ state: 'missing', reason: 'not-found' })
|
|
})
|
|
return () => { cancelled = true }
|
|
}, [transferId, ingestError])
|
|
|
|
// Snapshot the active session list once when sessions finish loading so
|
|
// the picker doesn't re-shuffle under the operator's finger as SSE
|
|
// updates roll in (activeAt heartbeats nudge the order every few
|
|
// seconds; even updatedAt-keyed sorts visually flicker on every
|
|
// metadata patch). The picker is a one-shot interaction — closing the
|
|
// share sheet and re-sharing produces a fresh snapshot. Sorted by
|
|
// updatedAt desc to match SessionList's canonical "most recent
|
|
// interaction first" order.
|
|
const [pickerSessions, setPickerSessions] = useState<SessionSummary[] | null>(null)
|
|
useEffect(() => {
|
|
if (pickerSessions !== null) return
|
|
if (sessionsLoading) return
|
|
setPickerSessions(
|
|
[...sessions]
|
|
.filter((s) => s.active)
|
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
)
|
|
}, [pickerSessions, sessions, sessionsLoading])
|
|
|
|
const handlePickSession = useCallback((sessionId: string) => {
|
|
if (!transferId) return
|
|
// Don't await deleteShareTransfer here — SessionChat consumes the
|
|
// payload then deletes the IDB row (it owns the lifecycle once we
|
|
// hand off). If we delete here, SessionChat won't find it.
|
|
setSharePendingTransfer(transferId)
|
|
navigate({ to: '/sessions/$sessionId', params: { sessionId } })
|
|
}, [navigate, transferId])
|
|
|
|
const handleNewSession = useCallback(() => {
|
|
if (!transferId) return
|
|
// Pass the transfer id via route search — do NOT arm sessionStorage here.
|
|
// Arming before the session exists leaves a stale id that the next
|
|
// unrelated SessionChat mount would consume (cancel/spawn-fail path).
|
|
navigate({ to: '/sessions/new', search: { shareTransferId: transferId } })
|
|
}, [navigate, transferId])
|
|
|
|
const handleDiscard = useCallback(() => {
|
|
if (transferId) {
|
|
void deleteShareTransfer(transferId)
|
|
}
|
|
navigate({ to: '/sessions', replace: true })
|
|
}, [navigate, transferId])
|
|
|
|
if (load.state === 'loading') {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center p-4">
|
|
<LoadingState label={t('share.loading')} className="text-sm" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (load.state === 'missing') {
|
|
const reasonKey = load.reason === 'ingest-error'
|
|
? 'share.error.ingest'
|
|
: load.reason === 'no-id'
|
|
? 'share.error.noId'
|
|
: 'share.notFound.body'
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-4 text-center">
|
|
<div className="text-sm font-medium text-[var(--app-fg)]">
|
|
{t('share.notFound.title')}
|
|
</div>
|
|
<div className="max-w-md text-xs text-[var(--app-hint)]">
|
|
{t(reasonKey)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => navigate({ to: '/sessions', replace: true })}
|
|
className="rounded-md bg-[var(--app-button)] px-3 py-1.5 text-sm text-[var(--app-button-text)]"
|
|
>
|
|
{t('share.backToSessions')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { payload } = load
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col bg-[var(--app-bg)]">
|
|
<div className="border-b border-[var(--app-border)] bg-[var(--app-bg)] p-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">
|
|
<div className="mx-auto w-full max-w-content">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="font-semibold">{t('share.title')}</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleDiscard}
|
|
className="rounded-md px-2 py-1 text-xs text-[var(--app-hint)] hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
|
|
>
|
|
{t('share.discard')}
|
|
</button>
|
|
</div>
|
|
<div className="mt-1 text-xs text-[var(--app-hint)]">
|
|
{t('share.subtitle')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="app-scroll-y flex-1 min-h-0">
|
|
<div className="mx-auto w-full max-w-content space-y-4 p-3">
|
|
<SharePreview payload={payload} />
|
|
|
|
<div>
|
|
<div className="px-1 pb-1 text-xs font-semibold uppercase tracking-wide text-[var(--app-hint)]">
|
|
{t('share.recentSessions')}
|
|
</div>
|
|
{pickerSessions === null ? (
|
|
<LoadingState label={t('share.loading')} className="text-sm py-4" />
|
|
) : pickerSessions.length === 0 ? (
|
|
<div className="rounded-md bg-[var(--app-secondary-bg)] p-3 text-xs text-[var(--app-hint)]">
|
|
{t('share.noActiveSessions')}
|
|
</div>
|
|
) : (
|
|
<ul className="overflow-hidden rounded-md bg-[var(--app-secondary-bg)]">
|
|
{pickerSessions.map((session) => (
|
|
<li key={session.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => handlePickSession(session.id)}
|
|
className="flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-[var(--app-fg)]">
|
|
{getSessionTitle(session)}
|
|
</div>
|
|
{session.metadata?.path ? (
|
|
<div className="truncate text-xs text-[var(--app-hint)]">
|
|
{session.metadata.path}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleNewSession}
|
|
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-[var(--app-border)] px-3 py-3 text-sm font-medium text-[var(--app-link)] hover:bg-[var(--app-secondary-bg)]"
|
|
>
|
|
+ {t('share.newSession')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|