feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,240 @@
// src/renderer/components/AppLayout.tsx
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
import { useState, useEffect } from 'react'
import { Box, Typography, Tooltip } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import GroupsIcon from '@mui/icons-material/Groups'
import SettingsIcon from '@mui/icons-material/Settings'
import { Led } from './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 { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../theme'
import { useI18n } from '../i18n'
import type { TranslationKey } from '../i18n'
import type { LicenseTier } from '@shared/types'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
interface NavItem {
route: Route
labelKey: TranslationKey
abbr: string
icon: React.ReactElement
}
const NAV_ITEMS: NavItem[] = [
{ route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
]
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')
// 첫 실행 감지
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success) {
const cfg = r.data as Record<string, unknown>
if (!cfg['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)
return () => {
unsubTier()
unsubUpgrade()
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
}
}, [])
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
<Box
sx={{
width: 72,
flexShrink: 0,
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.subtle}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
py: 2,
gap: 1,
}}
>
{/* 로고 LED — reflects license tier */}
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
<Typography
sx={{
fontSize: d3roTypo.micro.size,
fontFamily: d3roFontMono,
letterSpacing: d3roTypo.micro.spacing,
color: d3roPalette.text.dimLabel,
fontWeight: d3roTypo.micro.weight,
}}
>
D3RO
</Typography>
</Box>
{/* 네비게이션 버튼 */}
{NAV_ITEMS.map((item) => {
const isActive = currentRoute === item.route
return (
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
<Box
onClick={() => setCurrentRoute(item.route)}
sx={{
width: 48,
height: 48,
borderRadius: d3roRadius.button,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
cursor: 'pointer',
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
boxShadow: isActive
? d3roShadow.buttonPressed
: d3roShadow.buttonRaised,
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
transition: 'all 0.05s linear',
transform: isActive ? 'translateY(1px)' : 'none',
'&:active': {
transform: 'translateY(2px)',
boxShadow: d3roShadow.buttonPressed,
},
'&:hover': {
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
},
}}
>
{item.icon}
<Typography
sx={{
fontSize: d3roTypo.nano.size,
fontFamily: d3roFontMono,
fontWeight: d3roTypo.nano.weight,
letterSpacing: d3roTypo.nano.spacing,
}}
>
{item.abbr}
</Typography>
</Box>
</Tooltip>
)
})}
{/* 스페이서 */}
<Box sx={{ flex: 1 }} />
{/* Settings */}
<Tooltip title={t('nav.settings')} placement="right" arrow>
<Box
onClick={() => setSettingsOpen(true)}
sx={{
width: 48,
height: 48,
borderRadius: d3roRadius.button,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: d3roPalette.text.inactive,
boxShadow: d3roShadow.buttonRaised,
transition: 'all 0.05s linear',
'&:active': {
transform: 'translateY(2px)',
boxShadow: d3roShadow.buttonPressed,
},
'&:hover': { color: d3roPalette.text.hover },
}}
>
<SettingsIcon sx={{ fontSize: 20 }} />
</Box>
</Tooltip>
</Box>
{/* ── 콘텐츠 영역 ────────────────────────── */}
<Box
component="main"
sx={{
flexGrow: 1,
overflow: 'auto',
bgcolor: d3roPalette.bg.app,
// 미묘한 방사형 비네팅 (시안 A 배경)
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
</Box>
</Box>
<StatusBar />
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
</Box>
)
}