- accent.amber/amberDim/amberGlow -> accent.main/dim/glow 91건 (34파일). theme.ts의 @deprecated 별칭 참조 제거 (본체 main 사용). - inline fontSize/fontWeight -> d3roTypo, borderRadius -> d3roRadius 토큰 37건. - P4: 화면 고유 수치(스탯 fontSize, 레이아웃 width)는 토큰화 제외. 정책: docs/REFACTOR_POLICY.md DP1, 이식 인사이트 P4
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
// 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: <LayoutDashboard size={ICON_SIZE} /> },
|
|
{ route: 'history', labelKey: 'nav.history', icon: <HistoryIcon size={ICON_SIZE} /> },
|
|
{ route: 'dictionary', labelKey: 'nav.dictionary', icon: <BookOpen size={ICON_SIZE} /> },
|
|
{ route: 'commands', labelKey: 'nav.commands', icon: <SquareTerminal size={ICON_SIZE} /> },
|
|
{ route: 'conversation', labelKey: 'nav.conversation', icon: <MessagesSquare size={ICON_SIZE} /> },
|
|
{ route: 'knowledge', labelKey: 'nav.knowledge', icon: <Library size={ICON_SIZE} /> },
|
|
{ route: 'meeting', labelKey: 'nav.meeting', icon: <Users size={ICON_SIZE} /> },
|
|
]
|
|
|
|
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<Route>('dashboard')
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
|
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
|
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
|
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
|
|
const [fallbackMsg, setFallbackMsg] = useState<string | null>(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 (
|
|
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
|
|
<TitleBar />
|
|
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
{/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
|
|
<Box
|
|
sx={{
|
|
width: 168,
|
|
flexShrink: 0,
|
|
bgcolor: d3roPalette.bg.sidebar,
|
|
borderRight: `1px solid ${d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
px: 1.5,
|
|
pt: 3,
|
|
pb: 2,
|
|
gap: 0.5,
|
|
}}
|
|
>
|
|
{/* 로고 — 그라디언트 브랜드 텍스트 */}
|
|
<Box sx={{ px: 1.5, mb: 3, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={8} />
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: d3roTypo.value.size,
|
|
fontWeight: 800, // P4: 토큰에 없는 800 보존
|
|
letterSpacing: '0.02em', // P4: 토큰에 없는 0.02em 보존
|
|
backgroundImage: d3roPalette.gradient.logo,
|
|
backgroundClip: 'text',
|
|
WebkitBackgroundClip: 'text',
|
|
color: 'transparent',
|
|
lineHeight: 1.1,
|
|
}}
|
|
>
|
|
D3RO
|
|
</Typography>
|
|
</Box>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: d3roTypo.compact.size,
|
|
fontWeight: 500, // P4: 토큰에 없는 500 보존
|
|
letterSpacing: '0.12em', // P4: 토큰에 없는 0.12em 보존
|
|
color: d3roPalette.text.dimLabel,
|
|
pl: '16px',
|
|
}}
|
|
>
|
|
Voice
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 네비게이션 */}
|
|
{NAV_ITEMS.map((item) => {
|
|
const isActive = currentRoute === item.route
|
|
return (
|
|
<Box
|
|
key={item.route}
|
|
onClick={() => 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}
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: d3roTypo.compact.size,
|
|
fontWeight: isActive ? 600 : 500,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{t(item.labelKey)}
|
|
</Typography>
|
|
</Box>
|
|
)
|
|
})}
|
|
|
|
{/* 스페이서 */}
|
|
<Box sx={{ flex: 1 }} />
|
|
|
|
{/* Settings */}
|
|
<Box
|
|
onClick={() => 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 },
|
|
}}
|
|
>
|
|
<Settings size={ICON_SIZE} />
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, fontWeight: 500, lineHeight: 1 }}>
|
|
{t('nav.settings')}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
|
|
<Box
|
|
component="main"
|
|
sx={{
|
|
flexGrow: 1,
|
|
minWidth: 0,
|
|
position: 'relative',
|
|
bgcolor: d3roPalette.bg.app,
|
|
// 앰비언트 그라디언트 오브 (좌상단 액센트 / 우하단 퍼플 힌트)
|
|
background: `
|
|
radial-gradient(ellipse 55% 40% at 12% -5%, ${d3roPalette.accent.dim} 0%, transparent 60%),
|
|
radial-gradient(ellipse 50% 35% at 95% 105%, ${d3roPalette.tag.purpleBg} 0%, transparent 60%),
|
|
${d3roPalette.bg.app}
|
|
`,
|
|
}}
|
|
>
|
|
<OverlayScrollbarsComponent
|
|
defer
|
|
options={{
|
|
scrollbars: {
|
|
theme: 'os-theme-dark',
|
|
autoHide: 'move',
|
|
autoHideDelay: 800,
|
|
clickScroll: true,
|
|
},
|
|
}}
|
|
style={{ height: '100%', width: '100%' }}
|
|
>
|
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
|
{currentRoute === 'history' && <HistoryPage />}
|
|
{currentRoute === 'dictionary' && <DictionaryPage />}
|
|
{currentRoute === 'commands' && <CommandsPage />}
|
|
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
|
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
|
{currentRoute === 'meeting' && <MeetingModePage />}
|
|
</OverlayScrollbarsComponent>
|
|
</Box>
|
|
</Box>
|
|
<StatusBar />
|
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
|
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
|
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
|
|
|
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
|
|
<Snackbar
|
|
open={fallbackMsg !== null}
|
|
autoHideDuration={8000}
|
|
onClose={() => setFallbackMsg(null)}
|
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
>
|
|
<Alert severity="warning" variant="filled" onClose={() => setFallbackMsg(null)} sx={{ width: '100%' }}>
|
|
{fallbackMsg}
|
|
</Alert>
|
|
</Snackbar>
|
|
|
|
{/* 음성 세션 에러/경고 배너 — 모델 미설치·엔진 실패·LLM 스킵 알림 */}
|
|
<Snackbar
|
|
open={voiceAlert !== null}
|
|
autoHideDuration={6000}
|
|
onClose={() => setVoiceAlert(null)}
|
|
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
|
>
|
|
<Alert
|
|
severity={voiceAlert?.severity ?? 'error'}
|
|
variant="filled"
|
|
onClose={() => setVoiceAlert(null)}
|
|
sx={{ width: '100%' }}
|
|
>
|
|
{voiceAlert?.message ?? ''}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
)
|
|
}
|