mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 22:33:25 +09:00
feat: 회의록 자동 작성 웹 서비스 구현
- 오디오 파일 업로드 + 유효성 검사 - 실시간 음성 인식 (Web Speech API) - 회의록 생성 (단순 STT→MD 변환 / Gemini AI 요약) - 마크다운/HTML 내보내기 - PostgreSQL + Prisma ORM - Docker Compose 배포 지원 - TDD: 31개 테스트, 96% 커버리지
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { exportAsMarkdown, exportAsHtml, generateDownloadFilename } from '@/lib/export-minutes'
|
||||
|
||||
interface MinutesViewerProps {
|
||||
markdown: string
|
||||
title: string
|
||||
mode: 'simple' | 'gemini'
|
||||
}
|
||||
|
||||
export function MinutesViewer({ markdown, title, mode }: MinutesViewerProps) {
|
||||
function download(type: 'md' | 'html') {
|
||||
const filename = generateDownloadFilename(title, new Date())
|
||||
|
||||
if (type === 'md') {
|
||||
const blob = exportAsMarkdown(markdown)
|
||||
triggerDownload(blob, `${filename}.md`)
|
||||
} else {
|
||||
const html = exportAsHtml(markdown, title)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
triggerDownload(blob, `${filename}.html`)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(markdown)
|
||||
}
|
||||
|
||||
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-800">회의록</h2>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
mode === 'gemini'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{modeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
<button
|
||||
onClick={() => download('md')}
|
||||
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
.md 다운로드
|
||||
</button>
|
||||
<button
|
||||
onClick={() => download('html')}
|
||||
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
.html 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-neutral-200 bg-white p-6 max-h-[500px] overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-neutral-700 font-mono leading-relaxed">
|
||||
{markdown}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||
|
||||
interface LiveRecorderProps {
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
}
|
||||
|
||||
export function LiveRecorder({ onTranscriptReady }: LiveRecorderProps) {
|
||||
const {
|
||||
isListening,
|
||||
isSupported,
|
||||
chunks,
|
||||
interimText,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
} = useSpeechRecognition()
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-6 text-center">
|
||||
<p className="text-amber-800">
|
||||
이 브라우저는 음성 인식을 지원하지 않습니다.
|
||||
Chrome 브라우저를 사용해주세요.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
stopListening()
|
||||
const transcript = formatTranscriptChunks(chunks)
|
||||
if (transcript.length > 0) {
|
||||
onTranscriptReady(transcript)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{isListening ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 rounded-full bg-red-500 px-6 py-3 text-white font-medium shadow-lg shadow-red-500/25 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-white" />
|
||||
</span>
|
||||
녹음 중지
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startListening}
|
||||
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<span className="text-lg">🎤</span>
|
||||
녹음 시작
|
||||
</button>
|
||||
)}
|
||||
|
||||
{chunks.length > 0 && !isListening && (
|
||||
<button
|
||||
onClick={resetChunks}
|
||||
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(chunks.length > 0 || interimText) && (
|
||||
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 max-h-64 overflow-y-auto">
|
||||
<h3 className="text-sm font-semibold text-neutral-500 mb-3 uppercase tracking-wider">
|
||||
실시간 텍스트
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm font-mono text-neutral-700">
|
||||
{chunks.map((chunk, i) => (
|
||||
<p key={i}>{chunk.text}</p>
|
||||
))}
|
||||
{interimText && (
|
||||
<p className="text-neutral-400 italic">{interimText}...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, type DragEvent } from 'react'
|
||||
import { validateAudioFile } from '@/lib/audio-validation'
|
||||
|
||||
interface AudioUploaderProps {
|
||||
onFileSelected: (file: File) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AudioUploader({ onFileSelected, disabled }: AudioUploaderProps) {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleFile(file: File) {
|
||||
const result = validateAudioFile(file)
|
||||
if (!result.valid) {
|
||||
setError(result.error)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
onFileSelected(file)
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent<HTMLDivElement>) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={`
|
||||
relative cursor-pointer rounded-2xl border-2 border-dashed p-12
|
||||
text-center transition-all duration-200
|
||||
${dragOver
|
||||
? 'border-blue-400 bg-blue-50/50 scale-[1.01]'
|
||||
: 'border-neutral-300 hover:border-neutral-400 hover:bg-neutral-50/50'
|
||||
}
|
||||
${disabled ? 'pointer-events-none opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="text-4xl">🎙️</div>
|
||||
<p className="text-lg font-medium text-neutral-700">
|
||||
오디오 파일을 드래그하거나 클릭하여 업로드
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
MP3, WAV, WebM, M4A, OGG, FLAC (최대 500MB)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 bg-red-50 rounded-lg px-4 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user