feat(ui): Midnight Glass v2 전면 리디자인 — 기반+셸+대시보드
- theme.ts 재작성: glass/gradient/glow 토큰군, 기본 dark=Midnight(블루), 6종 테마 변형 전부 파생 토큰 유지 (테마 시스템 보존) - DS 리스킨(API 호환): 글래스 카드/버튼, Pretendard 타이포, 소프트 LED - 신규: GradientWave(canvas 스펙트럼 음파), StatRing(컬러 링 타일) - 보더리스: frame:false + 커스텀 TitleBar, 네이티브 스크롤바 제거(폭 불변) + OverlayScrollbars 오버레이 스크롤 - AppLayout 와이드 사이드바(lucide, 그라디언트 액티브 필), 대시보드 재구축, StatusBar(브랜드/로컬시간), Pretendard 번들, i18n +20키(ko/en)
This commit is contained in:
parent
f3ca0eafd4
commit
a8a1d6e546
25 changed files with 1638 additions and 960 deletions
|
|
@ -1,16 +1,20 @@
|
|||
// src/renderer/components/AppLayout.tsx
|
||||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||
// v2 "Midnight Glass": 보더리스 셸 — 와이드 사이드바(그라디언트 액티브 필) +
|
||||
// 앰비언트 배경 + 오버레이 스크롤(레이아웃 폭 불변) + 커스텀 타이틀바
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Alert, Box, Snackbar, 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 { 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'
|
||||
|
|
@ -23,7 +27,8 @@ import { SettingsModal } from './SettingsModal'
|
|||
import { LicenseModal } from './LicenseModal'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
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'
|
||||
|
|
@ -33,18 +38,19 @@ type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation
|
|||
interface NavItem {
|
||||
route: Route
|
||||
labelKey: TranslationKey
|
||||
abbr: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
const ICON_SIZE = 18
|
||||
|
||||
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 }} /> },
|
||||
{ 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' {
|
||||
|
|
@ -91,6 +97,10 @@ export function AppLayout(): React.ReactElement {
|
|||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
|
||||
// 대시보드 CTA(키 설정/시스템 설정)에서 설정 모달 열기
|
||||
const handleOpenSettings = () => setSettingsOpen(true)
|
||||
window.addEventListener('d3ro:open-settings', handleOpenSettings)
|
||||
|
||||
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
|
||||
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
|
||||
setFallbackMsg(e.reason)
|
||||
|
|
@ -105,88 +115,108 @@ export function AppLayout(): React.ReactElement {
|
|||
unsubFallback()
|
||||
unsubUpgradeReq()
|
||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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: 72,
|
||||
width: 168,
|
||||
flexShrink: 0,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRight: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
py: 2,
|
||||
gap: 1,
|
||||
px: 1.5,
|
||||
pt: 3,
|
||||
pb: 2,
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* 로고 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} />
|
||||
{/* 로고 — 그라디언트 브랜드 텍스트 */}
|
||||
<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: '20px',
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.02em',
|
||||
backgroundImage: d3roPalette.gradient.logo,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.micro.size,
|
||||
fontFamily: d3roFontMono,
|
||||
letterSpacing: d3roTypo.micro.spacing,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.12em',
|
||||
color: d3roPalette.text.dimLabel,
|
||||
fontWeight: d3roTypo.micro.weight,
|
||||
pl: '16px',
|
||||
}}
|
||||
>
|
||||
D3RO
|
||||
Voice
|
||||
</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)}
|
||||
<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={{
|
||||
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,
|
||||
},
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: d3roTypo.nano.size,
|
||||
fontFamily: d3roFontMono,
|
||||
fontWeight: d3roTypo.nano.weight,
|
||||
letterSpacing: d3roTypo.nano.spacing,
|
||||
}}
|
||||
>
|
||||
{item.abbr}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{t(item.labelKey)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
|
|
@ -194,50 +224,64 @@ export function AppLayout(): React.ReactElement {
|
|||
<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
|
||||
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,
|
||||
overflow: 'auto',
|
||||
minWidth: 0,
|
||||
position: 'relative',
|
||||
bgcolor: d3roPalette.bg.app,
|
||||
// 미묘한 방사형 비네팅 (시안 A 배경)
|
||||
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
|
||||
// 앰비언트 그라디언트 오브 (좌상단 액센트 / 우하단 퍼플 힌트)
|
||||
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}
|
||||
`,
|
||||
}}
|
||||
>
|
||||
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||
{currentRoute === 'history' && <HistoryPage />}
|
||||
{currentRoute === 'dictionary' && <DictionaryPage />}
|
||||
{currentRoute === 'commands' && <CommandsPage />}
|
||||
{currentRoute === 'conversation' && <VoiceConversationPage />}
|
||||
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
|
||||
{currentRoute === 'meeting' && <MeetingModePage />}
|
||||
<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 />
|
||||
|
|
|
|||
|
|
@ -1,22 +1,34 @@
|
|||
// src/renderer/components/StatusBar.tsx
|
||||
// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징
|
||||
// 타이포 토큰 적용 (d3roTypo SSOT)
|
||||
// v2 "Midnight Glass" 하단 상태바 — 좌: 브랜드+버전+백엔드 상태, 우: 로컬 시간.
|
||||
// Ollama 오프라인 넛징 버블 유지.
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Fade, IconButton } from '@mui/material'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { X } from 'lucide-react'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { OllamaGuideModal } from './OllamaGuideModal'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { LLMStatus } from '@d3ro/core/types'
|
||||
|
||||
function useClock(): string {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
}
|
||||
|
||||
export function StatusBar(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||
const [showNudge, setShowNudge] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [captionActive, setCaptionActive] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const clock = useClock()
|
||||
|
||||
// 자막 상태 구독
|
||||
useEffect(() => {
|
||||
|
|
@ -30,6 +42,9 @@ export function StatusBar(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.system.getVersion().then((r) => {
|
||||
if (r.success) setVersion(r.data)
|
||||
})
|
||||
// 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신)
|
||||
window.electronAPI.llm.getStatus().then((r) => {
|
||||
if (r.success) setLlmStatus(r.data)
|
||||
|
|
@ -60,9 +75,9 @@ export function StatusBar(): React.ReactElement {
|
|||
gap: 2,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
minHeight: 28,
|
||||
minHeight: 30,
|
||||
}}
|
||||
>
|
||||
{/* 넛징 버블 */}
|
||||
|
|
@ -70,11 +85,11 @@ export function StatusBar(): React.ReactElement {
|
|||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
bottom: 34,
|
||||
left: 8,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.button,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
borderRadius: d3roRadius.inner,
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
|
|
@ -88,7 +103,7 @@ export function StatusBar(): React.ReactElement {
|
|||
width: 12,
|
||||
height: 12,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
borderTop: 'none',
|
||||
borderLeft: 'none',
|
||||
transform: 'rotate(45deg)',
|
||||
|
|
@ -100,7 +115,7 @@ export function StatusBar(): React.ReactElement {
|
|||
{t('status.nudge.title')}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}>
|
||||
<CloseIcon sx={{ fontSize: 14 }} />
|
||||
<X size={14} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, mb: 1 }}>
|
||||
|
|
@ -110,7 +125,7 @@ export function StatusBar(): React.ReactElement {
|
|||
onClick={() => { setGuideOpen(true); setShowNudge(false) }}
|
||||
sx={{
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
color: d3roPalette.accent.light,
|
||||
fontWeight: d3roTypo.label.weight,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
|
|
@ -121,62 +136,66 @@ export function StatusBar(): React.ReactElement {
|
|||
</Box>
|
||||
</Fade>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color={connected ? 'green' : 'red'} size={6} />
|
||||
{/* 좌: 브랜드 + 버전 */}
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: connected ? 'default' : 'pointer' }}
|
||||
onClick={() => !connected && setGuideOpen(true)}
|
||||
>
|
||||
<Led color={connected ? 'green' : 'red'} size={7} />
|
||||
<Typography
|
||||
onClick={() => !connected && setGuideOpen(true)}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
cursor: connected ? 'default' : 'pointer',
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
fontWeight: 600,
|
||||
color: connected ? d3roPalette.text.secondary : d3roPalette.tag.red,
|
||||
'&:hover': connected ? {} : { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{connected ? t('status.ollama') : t('status.offline')}
|
||||
D3RO Voice
|
||||
</Typography>
|
||||
{version && (
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
|
||||
v{version}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{llmStatus?.activeModel && (
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
color: d3roPalette.text.dimLabel,
|
||||
letterSpacing: d3roTypo.label.spacing,
|
||||
}}>
|
||||
{llmStatus.activeModel.toUpperCase()}
|
||||
{llmStatus.activeModel}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* 자막 상태 표시 */}
|
||||
{captionActive && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led color="amber" pulse size={6} />
|
||||
<Led color="red" pulse size={6} />
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: d3roTypo.meta.size,
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.tag.red,
|
||||
}}>
|
||||
CAPTION
|
||||
{t('dashboard.caption')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
<Typography sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.text.muted,
|
||||
letterSpacing: d3roTypo.engrave.spacing,
|
||||
fontWeight: d3roTypo.engrave.weight,
|
||||
}}>
|
||||
PRECISION DATA LINK
|
||||
</Typography>
|
||||
{/* 우: 로컬 시간 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
|
||||
{t('status.localTime')}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{clock}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<OllamaGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
|
||||
|
|
|
|||
97
apps/desktop/src/renderer/components/TitleBar.tsx
Normal file
97
apps/desktop/src/renderer/components/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// src/renderer/components/TitleBar.tsx
|
||||
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
|
||||
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
|
||||
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { Minus, Square, Copy, X } from 'lucide-react'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
|
||||
const BAR_HEIGHT = 40
|
||||
|
||||
export function TitleBar(): React.ReactElement {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
|
||||
}, [])
|
||||
|
||||
const handleMaximize = useCallback(() => {
|
||||
window.electronAPI.window.maximize()
|
||||
// 토글 후 상태 재조회 (이벤트 채널이 없어 폴링 1회)
|
||||
setTimeout(() => {
|
||||
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
|
||||
}, 120)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: BAR_HEIGHT,
|
||||
zIndex: (theme) => theme.zIndex.drawer + 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'flex-start',
|
||||
WebkitAppRegion: 'drag',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
m: '6px 8px 0 0',
|
||||
gap: '2px',
|
||||
}}
|
||||
>
|
||||
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
|
||||
<Minus size={14} />
|
||||
</ControlButton>
|
||||
<ControlButton onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
|
||||
</ControlButton>
|
||||
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
|
||||
<X size={14} />
|
||||
</ControlButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
children,
|
||||
danger = false,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
danger?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
color: d3roPalette.text.inactive,
|
||||
transition: 'background-color 0.12s ease, color 0.12s ease',
|
||||
'&:hover': {
|
||||
bgcolor: danger ? d3roPalette.tag.redBg : d3roPalette.glass.raised,
|
||||
color: danger ? d3roPalette.tag.red : d3roPalette.text.primary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue