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:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
|
|
@ -1,366 +0,0 @@
|
|||
// 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' }}>
|
||||
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue