summaryrefslogtreecommitdiff
path: root/frontend/src/components/MissionDrawer.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-01 18:44:33 +0000
committersoryu <soryu@soryu.co>2025-12-23 14:47:17 +0000
commit4b21765ab94f0b40469dc1205c5057f480316c20 (patch)
tree3ec9814c29d148f4d4301337cf9132dc79bec603 /frontend/src/components/MissionDrawer.tsx
parenta69fff7c31c6b76fc7cbddf74e34587a6c378bc2 (diff)
downloadsoryu-4b21765ab94f0b40469dc1205c5057f480316c20.tar.gz
soryu-4b21765ab94f0b40469dc1205c5057f480316c20.zip
Frontend: mission screen transform with dimming, auto-pan image; hide stats in mission mode
Diffstat (limited to 'frontend/src/components/MissionDrawer.tsx')
-rw-r--r--frontend/src/components/MissionDrawer.tsx120
1 files changed, 120 insertions, 0 deletions
diff --git a/frontend/src/components/MissionDrawer.tsx b/frontend/src/components/MissionDrawer.tsx
new file mode 100644
index 0000000..8290851
--- /dev/null
+++ b/frontend/src/components/MissionDrawer.tsx
@@ -0,0 +1,120 @@
+import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+
+type AnchorRect = { top: number; left: number; width: number; height: number }
+
+type Props = {
+ isOpen: boolean
+ onClose: () => void
+ anchorRect: AnchorRect | null
+}
+
+export const MissionDrawer: React.FC<Props> = ({ isOpen, onClose, anchorRect }) => {
+ const [box, setBox] = useState<AnchorRect | null>(null)
+ const [expanded, setExpanded] = useState(false)
+ const closingRef = useRef(false)
+
+ const finalBox = useMemo<AnchorRect | null>(() => {
+ if (!isOpen) return null
+ const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
+ const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0)
+
+ const margin = 24
+ const maxW = Math.min(640, vw - margin * 2)
+ const maxH = Math.min(Math.round(vh * 0.86), 560)
+ const left = Math.round((vw - maxW) / 2)
+ const top = Math.round((vh - maxH) / 2)
+ return { top, left, width: maxW, height: maxH }
+ }, [isOpen])
+
+ useLayoutEffect(() => {
+ if (!isOpen || !anchorRect) return
+ closingRef.current = false
+ setBox(anchorRect)
+ // Defer to next frame to allow transition from anchor -> final
+ const id = requestAnimationFrame(() => {
+ setExpanded(true)
+ if (finalBox) setBox(finalBox)
+ })
+ return () => cancelAnimationFrame(id)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen, anchorRect])
+
+ useEffect(() => {
+ if (!isOpen) return
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') handleClose()
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isOpen])
+
+ if (!isOpen || !anchorRect || !box) return null
+
+ const handleClose = () => {
+ if (!anchorRect) return onClose()
+ closingRef.current = true
+ setExpanded(false)
+ setBox(anchorRect)
+ }
+
+ const onTransitionEnd = () => {
+ if (closingRef.current) {
+ closingRef.current = false
+ onClose()
+ }
+ }
+
+ const style: React.CSSProperties = {
+ position: 'fixed',
+ top: box.top,
+ left: box.left,
+ width: box.width,
+ height: box.height
+ }
+
+ return (
+ <div className="mission-overlay" onClick={handleClose}>
+ <div
+ className={`mission-morph ${expanded ? 'expanded' : ''}`}
+ style={style}
+ onClick={(e) => e.stopPropagation()}
+ onTransitionEnd={onTransitionEnd}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="mission-title"
+ >
+ <div className={`mission-modal ${expanded ? 'visible' : ''}`}>
+ <div className="mission-modal-header">
+ <div className="mission-brand">
+ <span className="jp">そりゅう</span>
+ <span className="en">SORYU</span>
+ </div>
+ <h2 id="mission-title" className="mission-title">Our Mission</h2>
+ <button className="mission-close-btn" aria-label="Close" onClick={handleClose}>×</button>
+ </div>
+ <div className="mission-content">
+ <p>
+ At Soryu, our mission is to make real‑time conversation
+ understanding feel instant, reliable, and human. We build low‑latency
+ infrastructure for streaming transcription and interaction so
+ products can turn live dialogue into actionable, privacy‑respecting
+ insight.
+ </p>
+ <p>
+ We obsess over end‑to‑end performance — from the first byte on the
+ wire to the words on the screen — so teams can deliver
+ conversational experiences that are responsive, accessible, and
+ trustworthy.
+ </p>
+ <p>
+ By combining efficient streaming (SSE/WS), robust clients, and
+ thoughtful design, we help developers ship experiences where every
+ millisecond matters and every conversation counts.
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}