summaryrefslogblamecommitdiff
path: root/frontend/src/components/VNApp.tsx
blob: 0f73d2cd93c13d8fd1026d399c2aaa870ad58423 (plain) (tree)



































































































































































































































                                                                                                                                                                                                      
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { TopBar } from './TopBar'
import { VNViewport } from './VNViewport'
import { DialogueBox } from './DialogueBox'
import { ChoiceMenu } from './ChoiceMenu'
import { BottomBar } from './BottomBar'
import { LoadingScreen } from './LoadingScreen'
import { ConfigModal } from './ConfigModal'
import { VNWebSocket } from '../services/ws'
import { ChatMessage, Choice } from '../types'
// Using direct PNG logo in VN header

const DEFAULT_CHOICES: Choice[] = [
  { id: 'greet', label: '"Hello?"' },
  { id: 'who', label: '"Who are you?"' },
  { id: 'silence', label: '(Stay silent)' },
]

export function VNApp() {
  const [loading, setLoading] = useState(true)
  const [loadingComplete, setLoadingComplete] = useState(false)
  const [messages, setMessages] = useState<ChatMessage[]>([
    { id: 'm1', role: 'assistant', content: 'A warm CRT glow fills the room. A figure turns towards you...' },
  ])
  const [choices, setChoices] = useState<Choice[]>(DEFAULT_CHOICES)
  const [status, setStatus] = useState('OFFLINE')
  const [name, setName] = useState('???')
  const [bg, setBg] = useState('/__gaogao__56242cbde8f18ac64522e410bad04e68_waifu2x_art_noise2.png')
  const [money, setMoney] = useState(15000)
  const [currentTime, setCurrentTime] = useState(new Date())
  const [location, setLocation] = useState('Tokyo')
  const [weather, setWeather] = useState('Sunny 22°C')
  const [configModalOpen, setConfigModalOpen] = useState(false)
  const [skipIntro, setSkipIntro] = useState(() => {
    const saved = localStorage.getItem('skipIntro')
    return saved === 'true'
  })

  const ws = useMemo(() => new VNWebSocket('ws://localhost:8080/ws'), [])
  const lastId = useRef(2)

  // Check if intro should be skipped on initial load
  useEffect(() => {
    if (skipIntro) {
      setLoading(false)
      setLoadingComplete(true)
      // Set skip intro to true after first time loading screen is shown
      localStorage.setItem('skipIntro', 'true')
    }
  }, [])

  // Handle skip intro toggle
  const handleSkipIntroChange = (skip: boolean) => {
    setSkipIntro(skip)
    localStorage.setItem('skipIntro', skip.toString())
  }

  // Handle loading screen completion
  const handleLoadingComplete = () => {
    setLoading(false)
    setLoadingComplete(true)
    // Set skip intro to true after first time loading screen is shown
    if (!skipIntro) {
      setSkipIntro(true)
      localStorage.setItem('skipIntro', 'true')
    }
  }

  useEffect(() => {
    ws.on('open', () => setStatus('ONLINE'))
    ws.on('close', () => setStatus('OFFLINE'))
    ws.on('message', (data) => {
      // Expecting { type: 'assistant'|'choices'|'bg'|'name'|'system', ... }
      if (data?.type === 'assistant') {
        pushMessage('assistant', data.content || '')
      } else if (data?.type === 'choices') {
        setChoices((data.items || []).map((it: any, idx: number) => ({ id: it.id ?? String(idx), label: it.label ?? String(it) })))
      } else if (data?.type === 'bg') {
        setBg(data.src || '/bg.jpg')
      } else if (data?.type === 'name') {
        setName(data.value || name)
      } else if (data?.type === 'system') {
        pushMessage('system', data.content || '')
      }
    })
    ws.connect()
    return () => ws.close()
  }, [ws])

  // Update clock every second
  useEffect(() => {
    const timer = setInterval(() => {
      setCurrentTime(new Date())
    }, 1000)
    return () => clearInterval(timer)
  }, [])

  function pushMessage(role: 'user' | 'assistant' | 'system', content: string) {
    lastId.current += 1
    setMessages((prev) => [...prev, { id: 'm' + lastId.current, role, content }])
  }

  function handleChoice(id: string) {
    const choice = choices.find(c => c.id === id)
    const content = choice?.label || id
    pushMessage('user', content)
    ws.send({ type: 'user_choice', id, label: content })
    setChoices([])
    setTimeout(() => {
      pushMessage('assistant', `The figure nods: "${content}"...`)
      setChoices(DEFAULT_CHOICES)
      setName('Guide')
    }, 600)
  }

  const lastAssistant = messages.slice().reverse().find(m => m.role !== 'user')

  return (
    <div>
      {!loadingComplete && (
        <LoadingScreen 
          onComplete={handleLoadingComplete}
        />
      )}
      <div className={`main-interface ${loadingComplete ? 'visible' : 'hidden'}`}>
    <div className="screen" role="application">
      <div className="left-pillar">
        <div className="pillar-image">
          Character A
        </div>
        <div className="pillar-bottom-section">
          <div className="pillar-buttons">
            <button className="pillar-btn" onClick={() => {}}>SAVE</button>
            <button className="pillar-btn" onClick={() => {}}>LOAD</button>
            <button className="pillar-btn" onClick={() => setConfigModalOpen(true)}>CONFIG</button>
            <button className="pillar-btn" onClick={() => {}}>EXIT</button>
          </div>
        </div>
      </div>
      
      <div className="screen-content">
        <div className="screen-header header-footer-border">
          <div className="brand">
            <img
              src="/logo/crane-logo-transparent.png"
              alt="Soryu"
              height={24}
              className="brand-mark"
              onError={(e) => { const img = (e.currentTarget as HTMLImageElement); img.onerror = null; img.src = '/logo/crane-logo.png'; }}
            />
          </div>
        </div>
        <VNViewport bgSrc={bg}>
          <div className="ui-layer">
            <div className="weather-display">
              {location} - {weather}
            </div>
          </div>
        </VNViewport>
        <DialogueBox name={name} text={lastAssistant?.content || ''} />
        <div className="text-input-area">
          <input 
            type="text" 
            className="text-input" 
            placeholder="Type your response..."
            onKeyPress={(e) => {
              if (e.key === 'Enter') {
                const target = e.target as HTMLInputElement;
                if (target.value.trim()) {
                  pushMessage('user', target.value);
                  target.value = '';
                }
              }
            }}
          />
        </div>
        <div className="screen-footer header-footer-border">
          <div className="desktop-time">{new Date().toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {new Date().toLocaleTimeString('ja-JP', { hour12: false })}</div>
          <div className="mobile-time-money">
            <div className="mobile-clock">
              {currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })} {currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
            </div>
            <div className="mobile-money">
              {money.toLocaleString()}
            </div>
          </div>
          <div className="mobile-buttons">
            <button className="mobile-btn" onClick={() => {}}>SAVE</button>
            <button className="mobile-btn" onClick={() => {}}>LOAD</button>
            <button className="mobile-btn" onClick={() => setConfigModalOpen(true)}>CONFIG</button>
            <button className="mobile-btn" onClick={() => {}}>EXIT</button>
          </div>
        </div>
        <BottomBar 
          onSkip={() => {}} 
          onAuto={() => {}} 
          onLog={() => {}} 
          location={location}
        />
      </div>
      
      <div className="right-pillar">
        <div className="pillar-image">
          Character B
        </div>
        <div className="pillar-bottom-section">
          <div className="pillar-display digital-clock">
            {currentTime.toLocaleDateString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' })}
            <br />
            {currentTime.toLocaleTimeString('ja-JP', { hour12: false })}
          </div>
          <div className="pillar-display yen-counter">
            {money.toLocaleString()}
          </div>
        </div>
      </div>
    </div>
      </div>
      
      <ConfigModal 
        isOpen={configModalOpen}
        onClose={() => setConfigModalOpen(false)}
        skipIntro={skipIntro}
        onSkipIntroChange={handleSkipIntroChange}
      />
    </div>
  )
}