554 lines
19 KiB
PHP
554 lines
19 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '웹 녹음 AI 요약')
|
|
|
|
@push('styles')
|
|
<style>
|
|
.recording-container {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.record-button {
|
|
width: 100px;
|
|
height: 100px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.record-button:hover {
|
|
transform: scale(1.05);
|
|
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
|
}
|
|
|
|
.record-button.recording {
|
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.record-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { box-shadow: 0 4px 15px rgba(245, 87, 108, 0.4); }
|
|
50% { box-shadow: 0 4px 30px rgba(245, 87, 108, 0.8); }
|
|
}
|
|
|
|
.timer {
|
|
font-size: 2.5rem;
|
|
font-weight: bold;
|
|
font-family: 'Courier New', monospace;
|
|
min-height: 50px;
|
|
color: #374151;
|
|
}
|
|
|
|
.timer.active { color: #f5576c; }
|
|
|
|
.waveform {
|
|
height: 60px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
}
|
|
|
|
.waveform-bar {
|
|
width: 4px;
|
|
background: #667eea;
|
|
border-radius: 2px;
|
|
animation: wave 0.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes wave {
|
|
0%, 100% { height: 10px; }
|
|
50% { height: 40px; }
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
.status-waiting { background: #f1f5f9; color: #64748b; }
|
|
.status-recording { background: #fee2e2; color: #dc2626; }
|
|
.status-processing { background: #dbeafe; color: #1d4ed8; }
|
|
.status-completed { background: #dcfce7; color: #16a34a; }
|
|
.status-error { background: #fee2e2; color: #dc2626; }
|
|
|
|
.meeting-card {
|
|
transition: all 0.2s ease;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.meeting-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.spinner {
|
|
width: 24px;
|
|
height: 24px;
|
|
border: 3px solid #e5e7eb;
|
|
border-top-color: #3b82f6;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.accordion-header {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.accordion-content {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease;
|
|
}
|
|
|
|
.accordion-content.open {
|
|
max-height: 500px;
|
|
}
|
|
|
|
.accordion-icon {
|
|
transition: transform 0.3s ease;
|
|
}
|
|
|
|
.accordion-icon.open {
|
|
transform: rotate(180deg);
|
|
}
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<div class="recording-container">
|
|
{{-- 헤더 --}}
|
|
<div class="text-center mb-8">
|
|
<h1 class="text-2xl font-bold text-gray-800 mb-2">웹 녹음 AI 요약</h1>
|
|
<p class="text-gray-500">브라우저에서 녹음하고 AI가 자동으로 회의록을 작성합니다</p>
|
|
</div>
|
|
|
|
{{-- 녹음 섹션 --}}
|
|
<div class="bg-white rounded-xl shadow-md mb-8 p-8">
|
|
<div class="flex flex-col items-center text-center">
|
|
{{-- 타이머 --}}
|
|
<div class="timer" id="timer">00:00</div>
|
|
|
|
{{-- 파형 (녹음 중에만 표시) --}}
|
|
<div class="waveform hidden mt-4" id="waveform">
|
|
@for($i = 0; $i < 20; $i++)
|
|
<div class="waveform-bar" style="animation-delay: {{ $i * 0.05 }}s"></div>
|
|
@endfor
|
|
</div>
|
|
|
|
{{-- 상태 표시 --}}
|
|
<div class="my-4">
|
|
<span class="status-badge status-waiting" id="statusBadge">대기 중</span>
|
|
</div>
|
|
|
|
{{-- 녹음 버튼 --}}
|
|
<div class="flex gap-4 items-center">
|
|
<button class="record-button" id="recordBtn">
|
|
<svg id="micIcon" class="w-10 h-10 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
|
</svg>
|
|
<svg id="stopIcon" class="w-10 h-10 mx-auto hidden" fill="currentColor" viewBox="0 0 24 24">
|
|
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{{-- 안내 텍스트 --}}
|
|
<p class="text-sm text-gray-500 mt-4" id="helpText">
|
|
버튼을 클릭하여 녹음을 시작하세요
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 처리 중 상태 (숨김) --}}
|
|
<div class="bg-white rounded-xl shadow-md mb-8 p-8 hidden" id="processingCard">
|
|
<div class="flex flex-col items-center text-center">
|
|
<div class="spinner"></div>
|
|
<h3 class="text-lg font-semibold mt-4 text-gray-800">AI가 회의록을 작성하고 있습니다</h3>
|
|
<p class="text-sm text-gray-500 mt-2" id="processingStatus">음성을 텍스트로 변환 중...</p>
|
|
<div class="w-56 h-2 bg-gray-200 rounded-full mt-4 overflow-hidden">
|
|
<div class="h-full bg-blue-500 rounded-full transition-all" id="progressBar" style="width: 0%"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 결과 섹션 (숨김) --}}
|
|
<div class="hidden" id="resultSection">
|
|
<div class="bg-white rounded-xl shadow-md mb-6 p-6">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
AI 요약 결과
|
|
</h2>
|
|
<button class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" onclick="newRecording()">새 녹음</button>
|
|
</div>
|
|
|
|
{{-- 제목 입력 --}}
|
|
<div class="mb-4">
|
|
<input type="text" id="meetingTitle" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="회의록 제목을 입력하세요" />
|
|
</div>
|
|
|
|
{{-- 요약 내용 --}}
|
|
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
|
<h3 class="font-semibold text-gray-800 mb-2">요약</h3>
|
|
<div id="summaryContent" class="text-gray-700 prose max-w-none"></div>
|
|
</div>
|
|
|
|
{{-- 원본 텍스트 (아코디언) --}}
|
|
<div class="bg-gray-50 rounded-lg overflow-hidden">
|
|
<div class="accordion-header flex items-center justify-between p-4" onclick="toggleAccordion(this)">
|
|
<span class="font-medium text-gray-800">원본 텍스트 보기</span>
|
|
<svg class="accordion-icon w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
<div class="accordion-content">
|
|
<div id="transcriptContent" class="px-4 pb-4 text-sm text-gray-600 whitespace-pre-wrap"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 최근 회의록 목록 --}}
|
|
<div class="bg-white rounded-xl shadow-md p-6">
|
|
<h2 class="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
|
|
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
최근 회의록
|
|
</h2>
|
|
|
|
<div id="meetingList" hx-get="{{ route('api.admin.meeting-logs.index') }}" hx-trigger="load" hx-swap="innerHTML">
|
|
<div class="flex justify-center py-8">
|
|
<div class="spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
let mediaRecorder = null;
|
|
let audioChunks = [];
|
|
let timerInterval = null;
|
|
let startTime = null;
|
|
let currentMeetingId = null;
|
|
|
|
// 아코디언 토글
|
|
function toggleAccordion(header) {
|
|
const content = header.nextElementSibling;
|
|
const icon = header.querySelector('.accordion-icon');
|
|
content.classList.toggle('open');
|
|
icon.classList.toggle('open');
|
|
}
|
|
|
|
// 녹음 토글
|
|
async function toggleRecording() {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
stopRecording();
|
|
} else {
|
|
await startRecording();
|
|
}
|
|
}
|
|
|
|
// 녹음 시작
|
|
async function startRecording() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
|
audioChunks = [];
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
audioChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = async () => {
|
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
await processAudio(audioBlob);
|
|
stream.getTracks().forEach(track => track.stop());
|
|
};
|
|
|
|
// 회의록 생성
|
|
const response = await fetch('{{ route("api.admin.meeting-logs.store") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
},
|
|
body: JSON.stringify({ title: '무제 회의록' })
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
currentMeetingId = result.data.id;
|
|
}
|
|
|
|
mediaRecorder.start(1000);
|
|
startTime = Date.now();
|
|
updateUI('recording');
|
|
startTimer();
|
|
|
|
} catch (error) {
|
|
console.error('녹음 시작 실패:', error);
|
|
showToast('마이크 접근 권한이 필요합니다.', 'error');
|
|
}
|
|
}
|
|
|
|
// 녹음 중지
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
mediaRecorder.stop();
|
|
stopTimer();
|
|
updateUI('processing');
|
|
}
|
|
}
|
|
|
|
// 오디오 처리
|
|
async function processAudio(audioBlob) {
|
|
const duration = Math.floor((Date.now() - startTime) / 1000);
|
|
|
|
// Base64 변환
|
|
const reader = new FileReader();
|
|
reader.onloadend = async () => {
|
|
const base64Audio = reader.result;
|
|
|
|
try {
|
|
updateProgress(10);
|
|
document.getElementById('processingStatus').textContent = '서버에 업로드 중...';
|
|
|
|
const response = await fetch(`/api/meeting-logs/${currentMeetingId}/process`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
},
|
|
body: JSON.stringify({
|
|
audio: base64Audio,
|
|
duration: duration
|
|
})
|
|
});
|
|
|
|
updateProgress(100);
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
showResult(result.data);
|
|
} else {
|
|
throw new Error(result.message || '처리 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('처리 실패:', error);
|
|
showToast('처리 중 오류가 발생했습니다: ' + error.message, 'error');
|
|
updateUI('ready');
|
|
}
|
|
};
|
|
|
|
reader.readAsDataURL(audioBlob);
|
|
}
|
|
|
|
// 진행률 업데이트
|
|
function updateProgress(percent) {
|
|
document.getElementById('progressBar').style.width = percent + '%';
|
|
}
|
|
|
|
// 결과 표시
|
|
function showResult(meeting) {
|
|
document.getElementById('meetingTitle').value = meeting.title || '';
|
|
document.getElementById('summaryContent').innerHTML = marked.parse(meeting.summary_text || '요약 내용이 없습니다.');
|
|
document.getElementById('transcriptContent').textContent = meeting.transcript_text || '';
|
|
|
|
updateUI('result');
|
|
|
|
// 목록 새로고침
|
|
refreshMeetingList();
|
|
}
|
|
|
|
// UI 업데이트
|
|
function updateUI(state) {
|
|
const recordBtn = document.getElementById('recordBtn');
|
|
const micIcon = document.getElementById('micIcon');
|
|
const stopIcon = document.getElementById('stopIcon');
|
|
const waveform = document.getElementById('waveform');
|
|
const statusBadge = document.getElementById('statusBadge');
|
|
const helpText = document.getElementById('helpText');
|
|
const processingCard = document.getElementById('processingCard');
|
|
const resultSection = document.getElementById('resultSection');
|
|
|
|
switch (state) {
|
|
case 'recording':
|
|
recordBtn.classList.add('recording');
|
|
micIcon.classList.add('hidden');
|
|
stopIcon.classList.remove('hidden');
|
|
waveform.classList.remove('hidden');
|
|
statusBadge.textContent = '녹음 중';
|
|
statusBadge.className = 'status-badge status-recording';
|
|
helpText.textContent = '버튼을 클릭하여 녹음을 종료하세요';
|
|
processingCard.classList.add('hidden');
|
|
resultSection.classList.add('hidden');
|
|
break;
|
|
|
|
case 'processing':
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.disabled = true;
|
|
micIcon.classList.remove('hidden');
|
|
stopIcon.classList.add('hidden');
|
|
waveform.classList.add('hidden');
|
|
statusBadge.textContent = '처리 중';
|
|
statusBadge.className = 'status-badge status-processing';
|
|
helpText.textContent = 'AI가 회의록을 작성하고 있습니다...';
|
|
processingCard.classList.remove('hidden');
|
|
resultSection.classList.add('hidden');
|
|
updateProgress(0);
|
|
break;
|
|
|
|
case 'result':
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.disabled = false;
|
|
micIcon.classList.remove('hidden');
|
|
stopIcon.classList.add('hidden');
|
|
waveform.classList.add('hidden');
|
|
statusBadge.textContent = '완료';
|
|
statusBadge.className = 'status-badge status-completed';
|
|
helpText.textContent = '버튼을 클릭하여 새로운 녹음을 시작하세요';
|
|
processingCard.classList.add('hidden');
|
|
resultSection.classList.remove('hidden');
|
|
break;
|
|
|
|
default: // ready
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.disabled = false;
|
|
micIcon.classList.remove('hidden');
|
|
stopIcon.classList.add('hidden');
|
|
waveform.classList.add('hidden');
|
|
statusBadge.textContent = '대기 중';
|
|
statusBadge.className = 'status-badge status-waiting';
|
|
helpText.textContent = '버튼을 클릭하여 녹음을 시작하세요';
|
|
processingCard.classList.add('hidden');
|
|
resultSection.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// 타이머 시작
|
|
function startTimer() {
|
|
const timerEl = document.getElementById('timer');
|
|
timerEl.classList.add('active');
|
|
|
|
timerInterval = setInterval(() => {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
|
|
const seconds = (elapsed % 60).toString().padStart(2, '0');
|
|
timerEl.textContent = `${minutes}:${seconds}`;
|
|
}, 1000);
|
|
}
|
|
|
|
// 타이머 중지
|
|
function stopTimer() {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
}
|
|
document.getElementById('timer').classList.remove('active');
|
|
}
|
|
|
|
// 새 녹음
|
|
function newRecording() {
|
|
currentMeetingId = null;
|
|
document.getElementById('timer').textContent = '00:00';
|
|
updateUI('ready');
|
|
}
|
|
|
|
// 회의록 삭제
|
|
async function deleteMeeting(id) {
|
|
showDeleteConfirm('이 회의록', async () => {
|
|
try {
|
|
const response = await fetch(`/api/meeting-logs/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
showToast('삭제되었습니다.', 'success');
|
|
refreshMeetingList();
|
|
} else {
|
|
showToast(result.message || '삭제 실패', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('삭제 실패:', error);
|
|
showToast('삭제 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
// 목록 새로고침
|
|
async function refreshMeetingList() {
|
|
try {
|
|
const response = await fetch('{{ route("api.admin.meeting-logs.index") }}', {
|
|
headers: {
|
|
'HX-Request': 'true',
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
});
|
|
const html = await response.text();
|
|
document.getElementById('meetingList').innerHTML = html;
|
|
} catch (error) {
|
|
console.error('목록 새로고침 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 회의록 상세 보기
|
|
async function viewMeeting(id) {
|
|
try {
|
|
const response = await fetch(`/api/meeting-logs/${id}/summary`, {
|
|
headers: {
|
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
}
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
document.getElementById('meetingTitle').value = result.data.transcript ? '회의록' : '';
|
|
document.getElementById('summaryContent').innerHTML = marked.parse(result.data.summary || '요약 내용이 없습니다.');
|
|
document.getElementById('transcriptContent').textContent = result.data.transcript || '';
|
|
updateUI('result');
|
|
}
|
|
} catch (error) {
|
|
console.error('조회 실패:', error);
|
|
showToast('조회 중 오류가 발생했습니다.', 'error');
|
|
}
|
|
}
|
|
|
|
// 녹음 버튼 이벤트 리스너
|
|
document.getElementById('recordBtn')?.addEventListener('click', toggleRecording);
|
|
</script>
|
|
|
|
{{-- Markdown 파서 --}}
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
@endpush
|