// src/renderer/components/AppLayout.tsx
// v2 "Midnight Glass": 보더리스 셸 — 와이드 사이드바(그라디언트 액티브 필) +
// 앰비언트 배경 + 오버레이 스크롤(레이아웃 폭 불변) + 커스텀 타이틀바
import { useState, useEffect } from 'react'
import { Alert, Box, Snackbar, Typography } from '@mui/material'
import {
LayoutDashboard,
History as HistoryIcon,
BookOpen,
SquareTerminal,
MessagesSquare,
Library,
Users,
Settings,
} from 'lucide-react'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { Led } from '@d3ro/ui/components/ds'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
import { StatusBar } from './StatusBar'
import { TitleBar } from './TitleBar'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { TranslationKey } from '@d3ro/i18n'
import type { LicenseTier } from '@d3ro/core/types'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
interface NavItem {
route: Route
labelKey: TranslationKey
icon: React.ReactElement
}
const ICON_SIZE = 18
const NAV_ITEMS: NavItem[] = [
{ route: 'dashboard', labelKey: 'nav.dashboard', icon: },
{ route: 'history', labelKey: 'nav.history', icon: },
{ route: 'dictionary', labelKey: 'nav.dictionary', icon: },
{ route: 'commands', labelKey: 'nav.commands', icon: },
{ route: 'conversation', labelKey: 'nav.conversation', icon: },
{ route: 'knowledge', labelKey: 'nav.knowledge', icon: },
{ route: 'meeting', labelKey: 'nav.meeting', icon: },
]
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
switch (tier) {
case 'free': return 'amber'
case 'pro': return 'green'
case 'pro_plus': return 'green'
}
}
export function AppLayout(): React.ReactElement {
const { t } = useI18n()
const [currentRoute, setCurrentRoute] = useState('dashboard')
const [settingsOpen, setSettingsOpen] = useState(false)
const [onboardingOpen, setOnboardingOpen] = useState(false)
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
const [currentTier, setCurrentTier] = useState('free')
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
const [fallbackMsg, setFallbackMsg] = useState(null)
// 음성 세션 에러/경고 배너 (모델 미설치, 엔진 실패, LLM 스킵 등)
const [voiceAlert, setVoiceAlert] = useState<{ message: string; severity: 'error' | 'warning' } | null>(null)
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success && !r.data.onboardingCompleted) {
setOnboardingOpen(true)
}
})
}, [])
// License: load tier + subscribe to changes + listen for open-modal events
useEffect(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setCurrentTier(r.data.tier)
})
const unsubTier = window.electronAPI.license.onTierChanged((info) => {
setCurrentTier(info.tier)
})
const unsubUpgrade = window.electronAPI.license.onUpgradePrompt(() => {
setLicenseModalOpen(true)
})
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
// 대시보드 CTA(키 설정/시스템 설정)에서 설정 모달 열기
const handleOpenSettings = () => setSettingsOpen(true)
window.addEventListener('d3ro:open-settings', handleOpenSettings)
// 음성 세션 에러/경고 — 단축키 녹음 실패를 메인 UI에서도 명확히 알림.
// 모델 미설치(101)는 온보딩 모달을 함께 연다.
const unsubVoiceError = window.electronAPI.voice.onError((e) => {
const severity = e.severity ?? 'error'
let message = e.message
if (e.errorCode === 101) {
message = t('voice.error.modelMissing')
setOnboardingOpen(true)
} else if (e.errorCode === 103 || (e.errorCode >= 130 && e.errorCode <= 132)) {
message = t('voice.error.engine')
} else if (severity === 'warning' && e.errorCode === 300) {
message = t('voice.warning.llmSkipped')
}
setVoiceAlert({ message, severity })
})
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
setFallbackMsg(e.reason)
})
const unsubUpgradeReq = window.electronAPI.llm.premium.onUpgradeRequired(() => {
setLicenseModalOpen(true)
})
return () => {
unsubTier()
unsubUpgrade()
unsubFallback()
unsubUpgradeReq()
unsubVoiceError()
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
}
}, [t])
return (
{/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
{/* 로고 — 그라디언트 브랜드 텍스트 */}
D3RO
Voice
{/* 네비게이션 */}
{NAV_ITEMS.map((item) => {
const isActive = currentRoute === item.route
return (
setCurrentRoute(item.route)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: isActive ? d3roPalette.text.primary : d3roPalette.text.inactive,
// 액티브: 글래스 필 + 그라디언트 헤어라인 보더 + 소프트 글로우
border: '1px solid transparent',
background: isActive
? `linear-gradient(${d3roPalette.glass.raised}, ${d3roPalette.glass.raised}) padding-box, ${d3roPalette.gradient.accent} border-box`
: 'transparent',
boxShadow: isActive ? d3roShadow.glowSoft : 'none',
transition: 'color 0.15s ease, background-color 0.15s ease',
'&:hover': {
color: d3roPalette.text.primary,
bgcolor: isActive ? undefined : d3roPalette.glass.raised,
},
'& svg': {
color: isActive ? d3roPalette.accent.light : 'inherit',
flexShrink: 0,
},
}}
>
{item.icon}
{t(item.labelKey)}
)
})}
{/* 스페이서 */}
{/* Settings */}
setSettingsOpen(true)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: d3roPalette.text.inactive,
transition: 'color 0.15s ease, background-color 0.15s ease',
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
{t('nav.settings')}
{/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
{currentRoute === 'dashboard' && }
{currentRoute === 'history' && }
{currentRoute === 'dictionary' && }
{currentRoute === 'commands' && }
{currentRoute === 'conversation' && }
{currentRoute === 'knowledge' && }
{currentRoute === 'meeting' && }
setSettingsOpen(false)} />
setLicenseModalOpen(false)} />
setOnboardingOpen(false)} />
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
setFallbackMsg(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
setFallbackMsg(null)} sx={{ width: '100%' }}>
{fallbackMsg}
{/* 음성 세션 에러/경고 배너 — 모델 미설치·엔진 실패·LLM 스킵 알림 */}
setVoiceAlert(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
setVoiceAlert(null)}
sx={{ width: '100%' }}
>
{voiceAlert?.message ?? ''}
)
}