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,366 @@
// src/renderer/components/LicenseModal.tsx
// Full-screen license management modal with instrument aesthetic
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
Box,
TextField,
IconButton,
Divider,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import { MetalCard, PhosphorText, Led, ScreenPanel, PhysicalButton } from './ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '../theme'
import { useI18n } from '../i18n'
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@shared/types'
interface LicenseModalProps {
open: boolean
onClose: () => void
}
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
function tierToLedColor(tier: LicenseTier): LedColor {
switch (tier) {
case 'free': return 'amber'
case 'pro': return 'green'
case 'pro_plus': return 'green'
}
}
function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
switch (tier) {
case 'free': return t('license.free')
case 'pro': return t('license.pro')
case 'pro_plus': return t('license.proPlus')
}
}
function maskKey(key: string): string {
if (key.length <= 8) return key
return key.slice(0, 4) + '-****-****-' + key.slice(-4)
}
function formatDate(timestamp: number | null): string {
if (!timestamp) return '-'
return new Date(timestamp).toLocaleDateString()
}
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
const { t } = useI18n()
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
const [keyInput, setKeyInput] = useState('')
const [activating, setActivating] = useState(false)
const [activateMessage, setActivateMessage] = useState<string | null>(null)
const [activateSuccess, setActivateSuccess] = useState(false)
const loadData = useCallback(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setLicenseInfo(r.data)
})
window.electronAPI.license.getTierComparison().then((r) => {
if (r.success) setTierComparison(r.data)
})
window.electronAPI.license.getAllUsage().then((r) => {
if (r.success) setUsageQuotas(r.data)
})
}, [])
useEffect(() => {
if (open) {
loadData()
setKeyInput('')
setActivateMessage(null)
setActivateSuccess(false)
}
}, [open, loadData])
// Subscribe to tier changes
useEffect(() => {
const unsub = window.electronAPI.license.onTierChanged((info) => {
setLicenseInfo(info)
loadData()
})
return unsub
}, [loadData])
const handleActivate = useCallback(async () => {
if (!keyInput.trim()) return
setActivating(true)
setActivateMessage(null)
try {
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
if (result.success) {
setActivateSuccess(result.data.success)
setActivateMessage(
result.data.success
? t('license.activated')
: t('license.activateError', { message: result.data.message }),
)
if (result.data.success) {
loadData()
setKeyInput('')
}
}
} finally {
setActivating(false)
}
}, [keyInput, t, loadData])
const handleDeactivate = useCallback(async () => {
await window.electronAPI.license.deactivate()
setActivateMessage(t('license.deactivated'))
setActivateSuccess(false)
loadData()
}, [t, loadData])
const isFree = licenseInfo?.tier === 'free'
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.app,
backgroundImage: 'none',
borderRadius: d3roRadius.card,
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: d3roShadow.card,
maxHeight: '85vh',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 3,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<PhosphorText variant="meta">{t('license.title')}</PhosphorText>
<IconButton size="small" onClick={onClose} sx={{ color: d3roPalette.text.inactive }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* ---- Current Tier ---- */}
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="meta">{t('license.currentTier')}</PhosphorText>
<PhosphorText variant="value">
{licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'}
</PhosphorText>
</Box>
</Box>
</MetalCard>
{/* ---- Activate / Info ---- */}
<MetalCard>
{isFree ? (
// Free tier: show activation form
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="meta">{t('license.activate')}</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
placeholder={t('license.keyPlaceholder')}
size="small"
fullWidth
disabled={activating}
onKeyDown={(e) => {
if (e.key === 'Enter') handleActivate()
}}
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.input,
},
}}
/>
<PhysicalButton
onClick={handleActivate}
disabled={activating || !keyInput.trim()}
sx={{ minWidth: 100 }}
>
{activating ? (
<CircularProgress size={16} sx={{ color: d3roPalette.accent.amber }} />
) : (
t('license.activate')
)}
</PhysicalButton>
</Box>
{activateMessage && (
<PhosphorText
variant="small"
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{activateMessage}
</PhosphorText>
)}
</Box>
) : (
// Pro/Pro+: show license info
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<PhosphorText variant="meta">{t('license.keyLabel')}</PhosphorText>
<ScreenPanel>
<Box sx={{ px: 2, py: 1.5 }}>
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
{licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'}
</PhosphorText>
</Box>
</ScreenPanel>
<Box sx={{ display: 'flex', gap: 3 }}>
<Box>
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
<PhosphorText variant="compact">
{formatDate(licenseInfo?.activatedAt ?? null)}
</PhosphorText>
</Box>
<Box>
<PhosphorText variant="label">{t('license.machineId')}</PhosphorText>
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
{licenseInfo?.machineId?.slice(0, 12) ?? '-'}...
</PhosphorText>
</Box>
</Box>
<PhysicalButton
onClick={handleDeactivate}
sx={{ alignSelf: 'flex-start', mt: 1, color: d3roPalette.tag.red }}
>
{t('license.deactivate')}
</PhysicalButton>
</Box>
)}
</MetalCard>
{/* ---- Daily Usage ---- */}
{usageQuotas.length > 0 && (
<MetalCard>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{usageQuotas.map((q) => (
<Box key={q.feature} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ flex: 1 }}>
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
</PhosphorText>
<Box sx={{ flex: 1 }}>
<Box
sx={{
height: 4,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
borderRadius: d3roRadius.pill,
bgcolor:
q.limit < 0
? d3roPalette.tag.green
: q.used >= q.limit
? d3roPalette.tag.red
: d3roPalette.accent.amber,
width: q.limit < 0 ? '100%' : `${Math.min(100, (q.used / q.limit) * 100)}%`,
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
<PhosphorText variant="dim" sx={{ minWidth: 60, textAlign: 'right' }}>
{q.limit < 0
? t('license.quotaUnlimited')
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
</PhosphorText>
</Box>
))}
</Box>
</MetalCard>
)}
{/* ---- Tier Comparison ---- */}
{tierComparison.length > 0 && (
<MetalCard>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
<TableContainer>
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
<TableHead>
<TableRow>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing, textTransform: 'uppercase' }}>
&nbsp;
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing }}>
{t('license.free')}
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.green, letterSpacing: d3roTypo.label.spacing }}>
{t('license.pro')}
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.purple, letterSpacing: d3roTypo.label.spacing }}>
{t('license.proPlus')}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tierComparison.map((row) => (
<TableRow key={row.feature}>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
{row.featureLabel}
</TableCell>
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
<TableCell align="center">{renderTierCell(row.proPlus)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</MetalCard>
)}
</DialogContent>
</Dialog>
)
}
function renderTierCell(value: boolean | string): React.ReactElement {
if (typeof value === 'boolean') {
return value ? (
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.tag.green }} />
) : (
<CancelIcon sx={{ fontSize: 16, color: d3roPalette.text.disabled }} />
)
}
return (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.compact.size }}>
{value}
</PhosphorText>
)
}