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

@ -1,330 +0,0 @@
// src/renderer/components/shared/HistoryEntryCard.tsx
// 공유: 히스토리 항목 카드 (Dashboard + HistoryPage에서 재사용)
// Phase 10: 태그 표시/추가/삭제 기능 통합
import { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip } from '@mui/material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteIcon from '@mui/icons-material/Delete'
import LocalOfferIcon from '@mui/icons-material/LocalOffer'
import CloseIcon from '@mui/icons-material/Close'
import SummarizeIcon from '@mui/icons-material/Summarize'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { MetalCard, Led } from '../ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
import { useI18n } from '../../i18n'
import { formatDuration } from '../../utils/formatters'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@shared/types'
interface HistoryEntryCardProps {
entry: HistoryEntry
onCopy?: (text: string) => void
onDelete?: (id: string) => void
showTags?: boolean
onTagClick?: (tag: string) => void
}
export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, onTagClick }: HistoryEntryCardProps): React.ReactElement {
const { t, formatTime } = useI18n()
const displayText = entry.polishedText || entry.originalText
const [tags, setTags] = useState<MemoTag[]>([])
const [tagInput, setTagInput] = useState('')
const [showTagInput, setShowTagInput] = useState(false)
const [summaryExpanded, setSummaryExpanded] = useState(false)
const [summary, setSummary] = useState<MeetingSummaryResult | null>(null)
const [summaryLoading, setSummaryLoading] = useState(false)
const hasSummary = !!entry.summaryText
const loadTags = useCallback(async () => {
if (!showTags) return
const result = await window.electronAPI.memo.getTags(entry.id)
if (result.success) setTags(result.data)
}, [entry.id, showTags])
useEffect(() => { loadTags() }, [loadTags])
const handleAddTag = useCallback(async () => {
const trimmed = tagInput.trim()
if (!trimmed) return
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
if (result.success) {
setTags(prev => [...prev, result.data])
setTagInput('')
setShowTagInput(false)
}
}, [entry.id, tagInput])
const handleRemoveTag = useCallback(async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags(prev => prev.filter(t => t.tag !== tag))
}
}, [entry.id])
const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddTag() }
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
}, [handleAddTag])
const handleToggleSummary = useCallback(async () => {
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
}
setSummaryLoading(false)
}
}, [summaryExpanded, summary, entry.id])
const handleGenerateSummary = useCallback(async () => {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.summarize({ historyId: entry.id })
if (result.success) {
setSummary(result.data)
}
setSummaryLoading(false)
}, [entry.id])
const handleExportSummary = useCallback(async () => {
await window.electronAPI.meetingSummary.exportMarkdown({ historyId: entry.id })
}, [entry.id])
return (
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* 타이틀 */}
<Box
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 600,
color: d3roPalette.text.primary,
lineHeight: d3roTypo.compact.line,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.5,
}}
>
{entry.title ?? displayText.slice(0, 60)}
</Box>
{/* 내용 미리보기 */}
<Box
sx={{
fontSize: d3roTypo.small.size,
color: d3roPalette.text.secondary,
lineHeight: 1.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{displayText}
</Box>
<Box
sx={{
display: 'flex',
gap: 2,
mt: 1,
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
color: d3roPalette.text.dimLabel,
letterSpacing: d3roTypo.label.spacing,
}}
>
<span>{formatTime(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
{hasSummary && (
<Chip
icon={<SummarizeIcon sx={{ fontSize: '12px !important' }} />}
label={t('meetingSummary.title')}
size="small"
onClick={handleToggleSummary}
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.small,
cursor: 'pointer',
'& .MuiChip-icon': { color: d3roPalette.tag.green },
}}
/>
)}
</Box>
{/* 태그 영역 */}
{showTags && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
{tags.map(tag => (
<Chip
key={tag.id}
label={`#${tag.tag}`}
size="small"
onClick={() => onTagClick?.(tag.tag)}
onDelete={() => handleRemoveTag(tag.tag)}
deleteIcon={<CloseIcon sx={{ fontSize: '12px !important' }} />}
sx={{
height: 20,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.purpleBg,
color: d3roPalette.tag.purple,
borderRadius: d3roRadius.small,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 12 },
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.bg.card },
}}
/>
))}
{showTagInput ? (
<Box
component="input"
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }}
autoFocus
placeholder={t('memo.tagPlaceholder')}
sx={{
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
px: 1,
py: 0.25,
borderRadius: d3roRadius.xs,
outline: 'none',
width: 100,
'&:focus': { borderColor: d3roPalette.accent.amber },
}}
/>
) : (
<Tooltip title={t('memo.addTag')} arrow>
<IconButton
size="small"
onClick={() => setShowTagInput(true)}
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
>
<LocalOfferIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
)}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
{onCopy && (
<Tooltip title={t('common.copy')} arrow>
<IconButton
size="small"
onClick={() => onCopy(displayText)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<ContentCopyIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
{onDelete && (
<Tooltip title={t('common.delete')} arrow>
<IconButton
size="small"
onClick={() => onDelete(entry.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{/* Phase 12.2: 회의록 요약 확장 뷰 */}
{summaryExpanded && (
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
{summaryLoading ? (
<PhosphorText variant="dim">{t('meetingSummary.generating')}</PhosphorText>
) : summary ? (
<Box sx={{ fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{summary.summary && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.summary').toUpperCase()}
</PhosphorText>
<Box sx={{ whiteSpace: 'pre-wrap' }}>{summary.summary}</Box>
</Box>
)}
{summary.decisions.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.decisions').toUpperCase()}
</PhosphorText>
{summary.decisions.map((d, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {d}</Box>
))}
</Box>
)}
{summary.actionItems.length > 0 && (
<Box sx={{ mb: 1 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.actionItems').toUpperCase()}
</PhosphorText>
{summary.actionItems.map((a, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {a}</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Tooltip title={t('meetingSummary.exportMarkdown')} arrow>
<IconButton size="small" onClick={handleExportSummary} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<SummarizeIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
) : (
<Box sx={{ textAlign: 'center' }}>
<PhosphorText variant="dim" sx={{ mb: 1 }}>{t('meetingSummary.noSummary')}</PhosphorText>
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && (
<Chip
label={t('meetingSummary.generate')}
size="small"
onClick={handleGenerateSummary}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.chassis,
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
)}
</Box>
)}
</Box>
)}
{/* 요약 토글 버튼 (caption/file-transcription 모드만) */}
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && !summaryExpanded && !hasSummary && (
<Box
sx={{ mt: 1, textAlign: 'center', cursor: 'pointer', color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
onClick={handleToggleSummary}
>
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</Box>
)}
</MetalCard>
)
}