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
|
|
@ -50,6 +50,10 @@ export function createMainWindow(): BrowserWindow {
|
|||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// v2 보더리스 UI — 렌더러 TitleBar가 드래그/컨트롤 담당
|
||||
frame: false,
|
||||
// 첫 페인트 전 플래시 방지 (렌더러 테마 로드 전 임시 배경 — Midnight bg.app)
|
||||
backgroundColor: '#0a0e1c',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
|
|
|
|||
BIN
apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2
Normal file
BIN
apps/desktop/src/renderer/assets/fonts/PretendardVariable.woff2
Normal file
Binary file not shown.
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'overlayscrollbars/overlayscrollbars.css'
|
||||
import './styles/global.css'
|
||||
import { App } from './App'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
|
|
|||
|
|
@ -1,34 +1,131 @@
|
|||
// src/renderer/pages/DashboardPage.tsx
|
||||
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
|
||||
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
|
||||
// v2 "Midnight Glass" 대시보드 — 레퍼런스 시안 재현:
|
||||
// 히어로(웨이브+CTA) / 컬러 링 스탯 4타일 / 백엔드·요금제 / 사용량·실시간 자막 /
|
||||
// 시스템 상태(웨이브+레벨미터) / 파일 전사 / 최근 히스토리
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { Box, Button, Typography } from '@mui/material'
|
||||
import {
|
||||
Mic,
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
Flame,
|
||||
Settings2,
|
||||
KeyRound,
|
||||
BarChart3,
|
||||
Star,
|
||||
Check,
|
||||
Cpu,
|
||||
} from 'lucide-react'
|
||||
import { GradientWave, Led, MetalCard, PhosphorText, PhysicalButton, StatRing } from '@d3ro/ui/components/ds'
|
||||
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
|
||||
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
|
||||
import { formatHotkeyLabel } from '../utils/format-hotkey'
|
||||
import { FileDropZone } from '../components/FileDropZone'
|
||||
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
|
||||
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
|
||||
|
||||
// ── 로컬 프리미티브 ───────────────────────────────────
|
||||
|
||||
/** 옅은 헤어라인 필 (레퍼런스의 "TUE" / "로컬 연결" 칩) */
|
||||
function Pill({
|
||||
children,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
tone?: 'neutral' | 'accent' | 'green'
|
||||
}): React.ReactElement {
|
||||
const color =
|
||||
tone === 'accent' ? d3roPalette.accent.light : tone === 'green' ? d3roPalette.tag.green : d3roPalette.text.secondary
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1.25,
|
||||
py: 0.5,
|
||||
borderRadius: d3roRadius.pill,
|
||||
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
|
||||
bgcolor: d3roPalette.glass.raised,
|
||||
color,
|
||||
...typoSx('meta'),
|
||||
fontFamily: d3roFontSans,
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
|
||||
function CardHeader({
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
title: string
|
||||
right?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 1.5,
|
||||
mb: 2,
|
||||
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="heading">{title}</PhosphorText>
|
||||
{right}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** 그라디언트 프로그레스 바 */
|
||||
function GradientBar({
|
||||
ratio,
|
||||
alt = false,
|
||||
}: {
|
||||
/** 0~1 (무제한이면 1) */
|
||||
ratio: number
|
||||
alt?: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ height: 5, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, Math.max(4, ratio * 100))}%`,
|
||||
backgroundImage: alt ? d3roPalette.gradient.barAlt : d3roPalette.gradient.bar,
|
||||
borderRadius: d3roRadius.pill,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────
|
||||
|
||||
export function DashboardPage(): React.ReactElement {
|
||||
const { t, formatTime, formatRelativeDate } = useI18n()
|
||||
const { t, formatRelativeDate } = useI18n()
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [ollamaConnected, setOllamaConnected] = useState(false)
|
||||
const [llmModel, setLlmModel] = useState<string | null>(null)
|
||||
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
||||
const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
|
||||
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [licenseTier, setLicenseTier] = useState<LicenseTier>('free')
|
||||
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
||||
const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null)
|
||||
const statsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.stats.getSummary().then((r) => {
|
||||
|
|
@ -37,6 +134,9 @@ export function DashboardPage(): React.ReactElement {
|
|||
window.electronAPI.llm.getStatus().then((r) => {
|
||||
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
|
||||
})
|
||||
window.electronAPI.llm.getActiveModel().then((r) => {
|
||||
if (r.success) setLlmModel(r.data)
|
||||
})
|
||||
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
|
||||
if (r.success) setHistory(r.data.entries)
|
||||
})
|
||||
|
|
@ -103,311 +203,256 @@ export function DashboardPage(): React.ReactElement {
|
|||
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
|
||||
], [t, ollamaConnected, premiumStatus])
|
||||
|
||||
const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm'))
|
||||
const now = new Date()
|
||||
const weekday = now.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
window.dispatchEvent(new Event('d3ro:open-settings'))
|
||||
}, [])
|
||||
|
||||
const statTiles = [
|
||||
{ ring: 'blue' as const, icon: <Mic size={20} />, label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
|
||||
{ ring: 'purple' as const, icon: <BookOpen size={20} />, label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
|
||||
{ ring: 'green' as const, icon: <CalendarDays size={20} />, label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
|
||||
{ ring: 'orange' as const, icon: <Flame size={20} />, label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
|
||||
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
|
||||
<InstrumentPanel
|
||||
engravingLeft="D3RO-VOICE"
|
||||
engravingRight="v1.0.0"
|
||||
engravingBottom="LOCAL AI VOICE ASSISTANT"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 180px',
|
||||
gap: 2.5,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
|
||||
<ScreenPanel height={200}>
|
||||
{/* 상단 라벨 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<PhosphorText variant="label">
|
||||
{activeView === 'stats'
|
||||
? t('dashboard.sessionOverview').toUpperCase()
|
||||
: t('dashboard.systemStatus').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{/* 중앙: 큰 수치 or 서비스 상태 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
{activeView === 'stats' ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<PhosphorText variant="hero">
|
||||
{stats?.todaySessionCount ?? 0}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.sessionsToday').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhosphorText variant="label" sx={{ mt: 0.5, color: d3roPalette.text.dimLabel }}>
|
||||
{dictationBinding
|
||||
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() }).toUpperCase()
|
||||
: t('dashboard.hotkeyNotSet').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{services.map((svc) => (
|
||||
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
||||
>
|
||||
{svc.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
|
||||
{activeView === 'stats' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('dashboard.words').toUpperCase()}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<PhosphorText variant="value">
|
||||
{formatNumber(stats?.totalWordCount ?? 0)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.total').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<PhosphorText variant="label">{t('dashboard.streak').toUpperCase()}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, justifyContent: 'flex-end' }}>
|
||||
<PhosphorText variant="value">
|
||||
{stats?.streakDays ?? 0}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('dashboard.days').toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScreenPanel>
|
||||
|
||||
{/* 우측: 컨트롤 패널 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
{/* LED 상태 클러스터 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
|
||||
<Led color={ollamaConnected ? 'green' : 'red'} size={8} />
|
||||
<Led color="amber" pulse size={8} />
|
||||
</Box>
|
||||
|
||||
{/* 버튼 그룹 */}
|
||||
<ButtonGroup>
|
||||
<PhysicalButton
|
||||
selected={activeView === 'stats'}
|
||||
onClick={() => setActiveView('stats')}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('dashboard.stat').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton
|
||||
selected={activeView === 'status'}
|
||||
onClick={() => setActiveView('status')}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('dashboard.sys').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
</ButtonGroup>
|
||||
<Box sx={{ maxWidth: 980, mx: 'auto', p: 4, pt: 5, pb: 8 }}>
|
||||
{/* ── 1. 히어로 카드 ─────────────────────────── */}
|
||||
<MetalCard sx={{ p: 3.5 }}>
|
||||
{/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="amber" pulse size={7} />
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontFamily: d3roFontSans }}>
|
||||
{t('dashboard.sessionsToday')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{now.toLocaleDateString()}
|
||||
</PhosphorText>
|
||||
<Pill>{weekday}</Pill>
|
||||
<Pill tone={allOk ? 'green' : 'neutral'}>
|
||||
<Led color={allOk ? 'green' : 'orange'} size={6} />
|
||||
{allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
|
||||
</Pill>
|
||||
</Box>
|
||||
</Box>
|
||||
</InstrumentPanel>
|
||||
|
||||
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: 2,
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) },
|
||||
{ label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() },
|
||||
{ label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() },
|
||||
{ label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() },
|
||||
].map((card) => (
|
||||
<MetalCard key={card.label}>
|
||||
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{card.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
|
||||
<PhosphorText variant="value" sx={{ fontSize: d3roTypo.title.size, fontWeight: 300 }}>
|
||||
{card.value}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 3, alignItems: 'start' }}>
|
||||
{/* 좌: 헤드라인 + 힌트 + 키 설정 */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<PhosphorText variant="hero" sx={{ mb: 1 }}>
|
||||
{t('dashboard.heroIdle')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ mb: 2 }}>
|
||||
{dictationBinding
|
||||
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
|
||||
: t('dashboard.hotkeyNotSet')}
|
||||
</PhosphorText>
|
||||
<PhysicalButton onClick={openSettings} sx={{ height: 34, px: 1.5, gap: 0.75 }}>
|
||||
<KeyRound size={13} />
|
||||
{t('dashboard.keySetup')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
{/* 중: 수치 2종 */}
|
||||
<Box sx={{ display: 'flex', gap: 4, pt: 0.5 }}>
|
||||
{[
|
||||
{ label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
|
||||
{ label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
|
||||
].map((item) => (
|
||||
<Box key={item.label} sx={{ textAlign: 'left' }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.5, display: 'block' }}>
|
||||
{item.label}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
||||
{card.unit}
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '34px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{item.value}
|
||||
</Typography>
|
||||
{item.unit && (
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{item.unit}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 우: CTA 버튼 스택 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 150 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<BarChart3 size={15} />}
|
||||
onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
|
||||
sx={{ py: 1.25 }}
|
||||
>
|
||||
{t('dashboard.viewStats')}
|
||||
</Button>
|
||||
<PhysicalButton onClick={openSettings} sx={{ gap: 0.75 }}>
|
||||
<Settings2 size={14} />
|
||||
{t('dashboard.systemSettings')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 하단: 그라디언트 웨이브 */}
|
||||
<Box sx={{ mt: 2.5, height: 56 }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={0.3} barWidth={3} barGap={3} />
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
|
||||
<Box ref={statsRef} sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, mt: 2.5 }}>
|
||||
{statTiles.map((tile) => (
|
||||
<MetalCard key={tile.label} sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<StatRing color={tile.ring} size={52}>
|
||||
{tile.icon}
|
||||
</StatRing>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}>
|
||||
{tile.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '26px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{tile.value}
|
||||
</Typography>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{tile.unit}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2 }}>
|
||||
{/* 현재 백엔드 카드 */}
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('dashboard.currentBackend').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led
|
||||
color={premiumStatus?.backend === 'premium' ? 'green' : 'amber'}
|
||||
size={8}
|
||||
pulse={premiumStatus?.backend === 'premium'}
|
||||
/>
|
||||
<PhosphorText variant="value">
|
||||
{premiumStatus?.backend === 'premium'
|
||||
? t('dashboard.backendPremium').toUpperCase()
|
||||
: t('dashboard.backendLocal').toUpperCase()}
|
||||
{/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
|
||||
{/* 백엔드 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader
|
||||
title={t('dashboard.currentBackend')}
|
||||
right={
|
||||
<Pill tone="accent">
|
||||
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
|
||||
</Pill>
|
||||
}
|
||||
/>
|
||||
<MetalCard inset sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
<StatRing color="accent" size={46} thickness={2}>
|
||||
<Cpu size={18} />
|
||||
</StatRing>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<PhosphorText variant="heading" sx={{ mb: 0.5 }}>
|
||||
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.modelLabel')}: {llmModel ?? '—'}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
|
||||
</PhosphorText>
|
||||
<Led color={ollamaConnected ? 'green' : 'red'} size={7} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</MetalCard>
|
||||
|
||||
{/* 티어 카드 */}
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('license.currentTier').toUpperCase()}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led
|
||||
color={licenseTier === 'free' ? 'amber' : 'green'}
|
||||
size={8}
|
||||
pulse={licenseTier !== 'free'}
|
||||
/>
|
||||
<PhosphorText variant="value">
|
||||
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
|
||||
</PhosphorText>
|
||||
{/* 요금제 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader title={t('license.currentTier')} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<StatRing color="blue" size={40} thickness={2}>
|
||||
<Star size={15} />
|
||||
</StatRing>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '30px', fontWeight: 800, color: d3roPalette.text.primary, lineHeight: 1 }}>
|
||||
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
{licenseTier === 'free' && <Pill>{t('dashboard.planFree')}</Pill>}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, pl: 2, borderLeft: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
{[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
|
||||
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}><Check size={14} /></Box>
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{feat}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
||||
{/* 사용량 바 — 모든 티어에서 표시 */}
|
||||
{usageQuotas.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
|
||||
{t('license.usageToday').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{usageQuotas.map((q) => (
|
||||
<Box key={q.feature} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
||||
<PhosphorText variant="small">
|
||||
{t(`license.feature.${q.feature}`)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
|
||||
{q.limit === -1
|
||||
? t('license.unlimited')
|
||||
: `${q.used}/${q.limit}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{q.limit > 0 && (
|
||||
<Box sx={{
|
||||
height: 3,
|
||||
borderRadius: '2px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
width: `${Math.min(100, (q.used / q.limit) * 100)}%`,
|
||||
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</Box>
|
||||
)}
|
||||
{/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
|
||||
{/* 사용량 카드 */}
|
||||
<MetalCard sx={{ p: 2.5 }}>
|
||||
<CardHeader title={t('license.usageToday')} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{usageQuotas.length === 0 && (
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>—</PhosphorText>
|
||||
)}
|
||||
{usageQuotas.map((q, idx) => (
|
||||
<MetalCard inset key={q.feature} sx={{ p: 1.75 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<StatRing color={idx % 2 === 0 ? 'blue' : 'purple'} size={32} thickness={2}>
|
||||
{idx % 2 === 0 ? <Mic size={13} /> : <Cpu size={13} />}
|
||||
</StatRing>
|
||||
<PhosphorText variant="compact" sx={{ flex: 1 }}>
|
||||
{t(`license.feature.${q.feature}`)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light }}>
|
||||
{q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
<GradientBar
|
||||
ratio={q.limit === -1 ? 1 : Math.min(1, q.used / Math.max(1, q.limit))}
|
||||
alt={idx % 2 === 1}
|
||||
/>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Premium 모델별 쿼터 표시 */}
|
||||
{premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ mt: 1.5, mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
|
||||
{t('license.premiumQuota').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
|
||||
<Box key={m.model} sx={{ mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
|
||||
<PhosphorText variant="small">
|
||||
{t(m.i18nKey)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
|
||||
{m.limit === -1
|
||||
? t('license.unlimited')
|
||||
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{m.limit > 0 && (
|
||||
<Box sx={{
|
||||
height: 3,
|
||||
borderRadius: '2px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
bgcolor: d3roPalette.tag.green,
|
||||
borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── 2.5. 실시간 자막 토글 ────────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led
|
||||
color={captionState === 'active' ? 'green' : captionState === 'starting' || captionState === 'stopping' ? 'amber' : 'off'}
|
||||
size={8}
|
||||
pulse={captionState === 'active'}
|
||||
/>
|
||||
<Box>
|
||||
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight }}>
|
||||
{t('dashboard.caption').toUpperCase()}
|
||||
{/* 실시간 자막 카드 */}
|
||||
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column' }}>
|
||||
<CardHeader
|
||||
title={t('dashboard.caption')}
|
||||
right={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Led
|
||||
color={captionState === 'active' ? 'red' : 'off'}
|
||||
size={7}
|
||||
pulse={captionState === 'active'}
|
||||
/>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
|
||||
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
|
||||
</PhosphorText>
|
||||
{captionState === 'active' && (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size, color: d3roPalette.tag.green }}>
|
||||
{t('dashboard.captionActive').toUpperCase()}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<PhysicalButton
|
||||
selected={captionState === 'active'}
|
||||
}
|
||||
/>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2 }}>
|
||||
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.captionPlaceholder')}
|
||||
</PhosphorText>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Mic size={15} />}
|
||||
onClick={async () => {
|
||||
if (captionState === 'active') {
|
||||
await window.electronAPI.caption.stop()
|
||||
|
|
@ -415,46 +460,105 @@ export function DashboardPage(): React.ReactElement {
|
|||
await window.electronAPI.caption.start()
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 100 }}
|
||||
sx={{ alignSelf: 'center', px: 4 }}
|
||||
>
|
||||
{captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()}
|
||||
</PhysicalButton>
|
||||
{captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
|
||||
</Button>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
||||
{/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<CrtDisplay amplitude={0.05} frequency={6} height={100} audioLevel={audioLevel}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, px: 1 }}>
|
||||
{/* ── 5. 시스템 상태 ──────────────────────────── */}
|
||||
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
|
||||
<CardHeader title={t('dashboard.systemStatus')} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 3, alignItems: 'center' }}>
|
||||
{/* 좌: 서비스 상태 리스트 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 220 }}>
|
||||
{services.map((svc) => (
|
||||
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={6} />
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
|
||||
<Led color={svc.ok ? 'green' : 'red'} size={7} />
|
||||
<PhosphorText variant="small" sx={{ flex: 1, color: d3roPalette.text.secondary }}>
|
||||
{svc.name}
|
||||
</PhosphorText>
|
||||
<PhosphorText
|
||||
variant="value"
|
||||
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
|
||||
variant="meta"
|
||||
sx={{ color: svc.ok ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
||||
>
|
||||
{svc.status}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CrtDisplay>
|
||||
</Box>
|
||||
|
||||
{/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
{t('fileTranscription.title').toUpperCase()}
|
||||
</PhosphorText>
|
||||
{/* 중: 웨이브 + 레벨 미터 */}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Box sx={{ height: 64, mb: 1.25 }}>
|
||||
<GradientWave audioLevel={audioLevel} idleAmplitude={0.35} barWidth={2.5} barGap={2.5} glow={6} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<PhosphorText variant="meta" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{t('dashboard.inputLevel')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', gap: '3px', flex: 1 }}>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
flex: 1,
|
||||
height: 8,
|
||||
borderRadius: '2px',
|
||||
bgcolor: i < Math.round(audioLevel * 24) ? d3roPalette.tag.green : d3roPalette.bg.inset,
|
||||
transition: 'background-color 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.tag.green, minWidth: 34, textAlign: 'right' }}>
|
||||
{Math.round(audioLevel * 100)}%
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 우: 백엔드 요약 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 180 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('service.ollamaLlm')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
|
||||
{ollamaConnected ? t('service.ready') : t('service.offline')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.modelLabel')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, fontFamily: d3roFontMono }}>
|
||||
{llmModel ?? '—'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
||||
{t('dashboard.statusLabel')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="meta" sx={{ color: allOk ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
|
||||
{allOk ? 'CONNECTED' : 'CHECK'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 6. 파일 전사 ────────────────────────────── */}
|
||||
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
|
||||
<CardHeader title={t('fileTranscription.title')} />
|
||||
<FileDropZone />
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 4. 최근 히스토리 ──────────────────────── */}
|
||||
{/* ── 7. 최근 히스토리 ──────────────────────── */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
|
||||
{t('dashboard.recentTranscriptions').toUpperCase()}
|
||||
<PhosphorText variant="heading" sx={{ mb: 2, display: 'block' }}>
|
||||
{t('dashboard.recentTranscriptions')}
|
||||
</PhosphorText>
|
||||
|
||||
{history.length === 0 ? (
|
||||
|
|
@ -464,10 +568,10 @@ export function DashboardPage(): React.ReactElement {
|
|||
<Box key={group.label} sx={{ mb: 3 }}>
|
||||
{/* 날짜 구분자 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
||||
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
|
||||
{group.label}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
|
||||
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
|
||||
{t('dashboard.entries', { count: group.entries.length })}
|
||||
</PhosphorText>
|
||||
|
|
|
|||
47
apps/desktop/src/renderer/styles/global.css
Normal file
47
apps/desktop/src/renderer/styles/global.css
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/* src/renderer/styles/global.css
|
||||
* v2 "Midnight Glass" 전역 스타일 — 폰트, 보더리스 스크롤, 선택 색상.
|
||||
* 색상은 전부 CSS 변수(테마 SSOT) 참조 — hex 하드코딩 금지. */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pretendard Variable';
|
||||
font-weight: 45 920;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url('../assets/fonts/PretendardVariable.woff2') format('woff2-variations');
|
||||
}
|
||||
|
||||
/* 보더리스 UI: 네이티브 스크롤바 완전 제거 —
|
||||
* 스크롤 유무와 무관하게 레이아웃 총 폭 불변.
|
||||
* 시각적 인디케이터는 OverlayScrollbars(고유 스타일)가 담당. */
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-primary);
|
||||
}
|
||||
|
||||
/* ── OverlayScrollbars 고유 스타일 ───────────────────────────
|
||||
* 스톡 os-theme-dark의 레이아웃을 그대로 쓰고 색/크기 변수만 D3RO 토큰으로 오버라이드
|
||||
* (커스텀 테마 클래스는 포지셔닝 CSS가 없어 스크롤바가 잘못된 위치에 그려짐) */
|
||||
.os-theme-dark {
|
||||
--os-size: 10px;
|
||||
--os-padding-perpendicular: 3px;
|
||||
--os-padding-axis: 6px;
|
||||
--os-track-bg: transparent;
|
||||
--os-track-bg-hover: transparent;
|
||||
--os-track-bg-active: transparent;
|
||||
--os-handle-border-radius: 999px;
|
||||
--os-handle-bg: var(--d3-glass-hairlineStrong);
|
||||
--os-handle-bg-hover: var(--d3-accent-dim);
|
||||
--os-handle-bg-active: var(--d3-accent-main);
|
||||
--os-handle-min-size: 36px;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue