- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
1436 lines
46 KiB
PHP
1436 lines
46 KiB
PHP
<?php
|
|
// 출력 버퍼링 시작 (경고 메시지 방지)
|
|
ob_start();
|
|
@error_reporting(0);
|
|
@ini_set('display_errors', 0);
|
|
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/session.php");
|
|
@require_once($_SERVER['DOCUMENT_ROOT'] . "/load_header.php");
|
|
require_once($_SERVER['DOCUMENT_ROOT'] . "/lib/mydb.php");
|
|
|
|
// 권한 체크
|
|
if ($level > 5) {
|
|
echo "<script>alert('접근 권한이 없습니다.'); history.back();</script>";
|
|
exit;
|
|
}
|
|
|
|
// 회의록 리스트 조회 (최근 10개)
|
|
// MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거
|
|
$meeting_list = [];
|
|
try {
|
|
$pdo = db_connect();
|
|
$sql = "SELECT id, title, created_at, summary_text, audio_file_path
|
|
FROM meeting_logs
|
|
ORDER BY created_at DESC
|
|
LIMIT 10";
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute();
|
|
$meeting_list = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (Exception $e) {
|
|
// DB 조회 실패 시 빈 배열
|
|
$meeting_list = [];
|
|
}
|
|
?>
|
|
|
|
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>AI 스마트 회의록 (SAM Project)</title>
|
|
<style>
|
|
.voice-container {
|
|
max-width: 900px;
|
|
margin: 40px auto;
|
|
padding: 30px;
|
|
}
|
|
|
|
.header-section {
|
|
text-align: center;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.header-section h3 {
|
|
margin-bottom: 10px;
|
|
color: #333;
|
|
}
|
|
|
|
.header-section p {
|
|
color: #6c757d;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.recording-section {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 40px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.record-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 20px;
|
|
}
|
|
|
|
.record-button {
|
|
width: 120px;
|
|
height: 120px;
|
|
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);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
@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: 32px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
font-family: 'Courier New', monospace;
|
|
min-height: 40px;
|
|
}
|
|
|
|
.timer.active {
|
|
color: #f5576c;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: inline-block;
|
|
padding: 8px 20px;
|
|
border-radius: 20px;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.status-waiting {
|
|
background: #e9ecef;
|
|
color: #495057;
|
|
}
|
|
|
|
.status-recording {
|
|
background: #f8d7da;
|
|
color: #842029;
|
|
}
|
|
|
|
.status-processing {
|
|
background: #cfe2ff;
|
|
color: #084298;
|
|
}
|
|
|
|
.status-completed {
|
|
background: #d1e7dd;
|
|
color: #0f5132;
|
|
}
|
|
|
|
.status-error {
|
|
background: #f8d7da;
|
|
color: #842029;
|
|
}
|
|
|
|
.waveform-container {
|
|
width: 100%;
|
|
height: 100px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
margin: 20px 0;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.waveform-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.transcript-section {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
margin-bottom: 30px;
|
|
min-height: 200px;
|
|
}
|
|
|
|
.transcript-section h5 {
|
|
margin-bottom: 15px;
|
|
color: #333;
|
|
}
|
|
|
|
.transcript-text {
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
min-height: 150px;
|
|
font-size: 16px;
|
|
line-height: 1.8;
|
|
color: #333;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.transcript-text.empty {
|
|
color: #999;
|
|
font-style: italic;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 12px 24px;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #0d6efd;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #0b5ed7;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #5c636a;
|
|
}
|
|
|
|
.btn-success {
|
|
background: #198754;
|
|
color: white;
|
|
}
|
|
|
|
.btn-success:hover {
|
|
background: #157347;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.info-box {
|
|
background: #e7f3ff;
|
|
border-left: 4px solid #0d6efd;
|
|
padding: 15px;
|
|
margin-bottom: 20px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.info-box p {
|
|
margin: 5px 0;
|
|
color: #084298;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.loading-spinner {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid rgba(255,255,255,0.3);
|
|
border-radius: 50%;
|
|
border-top-color: #fff;
|
|
animation: spin 1s linear infinite;
|
|
margin-left: 10px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.processing-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.7);
|
|
color: white;
|
|
display: none;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.spinner-border {
|
|
width: 3rem;
|
|
height: 3rem;
|
|
border: 0.25em solid currentColor;
|
|
border-right-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 0.75s linear infinite;
|
|
}
|
|
|
|
/* 회의록 테이블 스타일 */
|
|
.meeting-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.meeting-table th {
|
|
background: #f8f9fa;
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 2px solid #dee2e6;
|
|
font-weight: bold;
|
|
color: #495057;
|
|
}
|
|
|
|
.meeting-table td {
|
|
padding: 12px;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
|
|
.meeting-table tr:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.summary-preview {
|
|
max-width: 300px;
|
|
color: #6c757d;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 8px;
|
|
font-size: 16px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-right: 5px;
|
|
background: #6c757d;
|
|
color: white;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 36px;
|
|
height: 36px;
|
|
position: relative;
|
|
}
|
|
|
|
.btn-small:hover {
|
|
background: #5c636a;
|
|
}
|
|
|
|
.btn-small i {
|
|
margin: 0;
|
|
}
|
|
|
|
/* 툴팁 스타일 */
|
|
.btn-small[title] {
|
|
position: relative;
|
|
}
|
|
|
|
.btn-small[title]:hover::after {
|
|
content: attr(title);
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 6px 10px;
|
|
background: #333;
|
|
color: white;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
border-radius: 4px;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
animation: tooltipFadeIn 0.2s ease-in-out forwards;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.btn-small[title]:hover::before {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: calc(100% + 3px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
border: 5px solid transparent;
|
|
border-top-color: #333;
|
|
z-index: 1001;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
animation: tooltipFadeIn 0.2s ease-in-out forwards;
|
|
}
|
|
|
|
@keyframes tooltipFadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(-5px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
}
|
|
|
|
/* 모달 스타일 */
|
|
.modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.7);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 2000;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
max-width: 800px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
position: relative;
|
|
}
|
|
|
|
.close-btn {
|
|
position: absolute;
|
|
top: 15px;
|
|
right: 20px;
|
|
font-size: 28px;
|
|
font-weight: bold;
|
|
color: #999;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
color: #333;
|
|
}
|
|
|
|
.modal-body {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.detail-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.detail-section h6 {
|
|
margin-bottom: 10px;
|
|
color: #495057;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.detail-text {
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
line-height: 1.8;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
</style>
|
|
<script>
|
|
// 즉시 실행: 전자결재 다이얼로그 강제 닫기
|
|
(function() {
|
|
function closeEworksDialogs() {
|
|
// 모든 전자결재 관련 모달 닫기
|
|
const modals = ['eworks_viewmodal', 'eworks_form'];
|
|
modals.forEach(function(modalId) {
|
|
const modal = document.getElementById(modalId);
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
modal.classList.remove('show');
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
}
|
|
});
|
|
|
|
// 모든 모달 배경 제거
|
|
const backdrops = document.querySelectorAll('.modal-backdrop');
|
|
backdrops.forEach(function(backdrop) {
|
|
backdrop.remove();
|
|
});
|
|
|
|
// body 클래스 정리
|
|
document.body.classList.remove('modal-open');
|
|
document.body.style.overflow = '';
|
|
document.body.style.paddingRight = '';
|
|
}
|
|
|
|
// 즉시 실행
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', closeEworksDialogs);
|
|
} else {
|
|
closeEworksDialogs();
|
|
}
|
|
|
|
// 지속적으로 확인 (다이얼로그가 나중에 열리는 경우 대비)
|
|
setInterval(closeEworksDialogs, 500);
|
|
})();
|
|
</script>
|
|
</head>
|
|
<body>
|
|
|
|
<?php include $_SERVER['DOCUMENT_ROOT'] . "/myheader.php"; ?>
|
|
|
|
<div class="processing-overlay" id="voiceloadingOverlay" style="display: none;">
|
|
<div class="spinner-border text-light" role="status"></div>
|
|
<h4 style="margin-top: 20px;">분석 후 요약작업 수행중... 잠시만 기다려 주세요</h4>
|
|
<p>오디오 업로드 → 텍스트 변환 → AI 요약 진행 중입니다.</p>
|
|
</div>
|
|
|
|
<div class="voice-container">
|
|
<div class="header-section">
|
|
<h3><i class="bi bi-mic-fill"></i> AI 스마트 회의록 작성</h3>
|
|
<p>음성을 녹음하고 AI를 통해 자동으로 회의록을 생성합니다</p>
|
|
</div>
|
|
|
|
<div class="info-box">
|
|
<p><i class="bi bi-info-circle"></i> Chrome 브라우저에서 마이크 권한을 허용해주세요</p>
|
|
<p><i class="bi bi-clock"></i> 실시간 음성 인식 - 말하는 즉시 텍스트로 변환됩니다</p>
|
|
<p><i class="bi bi-stars"></i> 녹음이 끝나면 AI가 자동으로 회의록을 작성합니다</p>
|
|
</div>
|
|
|
|
<div class="recording-section">
|
|
<div class="record-controls">
|
|
<button id="record-button" class="record-button">
|
|
<i class="bi bi-mic-fill"></i>
|
|
</button>
|
|
|
|
<div id="status" class="status-indicator status-waiting">대기중</div>
|
|
|
|
<div id="timer" class="timer">00:00</div>
|
|
|
|
<div class="waveform-container">
|
|
<canvas id="waveform" class="waveform-canvas"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="transcript-section">
|
|
<h5><i class="bi bi-eye"></i> 실시간 인식 미리보기</h5>
|
|
<div id="preview-text" class="transcript-text empty">
|
|
녹음 버튼을 클릭하여 음성을 녹음하세요
|
|
</div>
|
|
|
|
<div class="action-buttons">
|
|
<button id="save-analyze-btn" class="btn btn-primary" disabled>
|
|
<i class="bi bi-cloud-upload"></i> 회의 종료 및 AI 분석 시작
|
|
</button>
|
|
<button id="test-sample-btn" class="btn btn-secondary" style="margin-left: 10px;" title="테스트용 샘플 오디오 생성 (개발/테스트용)">
|
|
<i class="bi bi-file-earmark-music"></i> 샘플 테스트
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 회의록 리스트 섹션 -->
|
|
<div class="transcript-section">
|
|
<h5><i class="bi bi-list-ul"></i> 최근 회의록 (최근 10개)</h5>
|
|
<div id="meeting-list">
|
|
<?php if (empty($meeting_list)): ?>
|
|
<p style="text-align: center; color: #999; padding: 30px;">
|
|
<i class="bi bi-inbox"></i><br>
|
|
저장된 회의록이 없습니다
|
|
</p>
|
|
<?php else: ?>
|
|
<table class="meeting-table">
|
|
<thead>
|
|
<tr>
|
|
<th>번호</th>
|
|
<th>제목</th>
|
|
<th>작성일</th>
|
|
<th>요약 미리보기</th>
|
|
<th>동작</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($meeting_list as $index => $meeting): ?>
|
|
<tr>
|
|
<td><?= $index + 1 ?></td>
|
|
<td><?= htmlspecialchars($meeting['title']) ?></td>
|
|
<td><?= date('m-d H:i', strtotime($meeting['created_at'])) ?></td>
|
|
<td class="summary-preview">
|
|
<?= htmlspecialchars(mb_substr($meeting['summary_text'], 0, 50)) ?>...
|
|
</td>
|
|
<td>
|
|
<button class="btn-small" onclick="viewDetail(<?= $meeting['id'] ?>)" title="회의록 상세보기">
|
|
<i class="bi bi-eye"></i>
|
|
</button>
|
|
<?php if (!empty($meeting['audio_file_path'])): ?>
|
|
<a href="download_audio.php?id=<?= $meeting['id'] ?>"
|
|
class="btn-small"
|
|
download="meeting_<?= $meeting['id'] ?>.webm"
|
|
title="오디오 파일 다운로드">
|
|
<i class="bi bi-download"></i>
|
|
</a>
|
|
<?php else: ?>
|
|
<span class="btn-small" style="opacity: 0.5; cursor: not-allowed;" title="오디오 파일이 없습니다">
|
|
<i class="bi bi-download"></i>
|
|
</span>
|
|
<?php endif; ?>
|
|
<button class="btn-small" onclick="confirmDelete(<?= $meeting['id'] ?>, '<?= htmlspecialchars(addslashes($meeting['title'] ?: '회의녹음')) ?>')"
|
|
style="background-color: #dc3545; color: white;"
|
|
title="회의록 삭제">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 상세보기 모달 -->
|
|
<div id="detail-modal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<span class="close-btn" onclick="closeModal()">×</span>
|
|
<h4 id="modal-title">회의록 상세</h4>
|
|
<div class="modal-body">
|
|
<div class="detail-section">
|
|
<h6><i class="bi bi-file-text"></i> 전체 텍스트</h6>
|
|
<div id="modal-transcript" class="detail-text"></div>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h6><i class="bi bi-stars"></i> AI 요약</h6>
|
|
<div id="modal-summary" class="detail-text"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 삭제 확인 모달 -->
|
|
<div id="delete-modal" class="modal" style="display: none;">
|
|
<div class="modal-content" style="max-width: 400px;">
|
|
<span class="close-btn" onclick="closeDeleteModal()">×</span>
|
|
<h4>회의록 삭제 확인</h4>
|
|
<div class="modal-body">
|
|
<p>정말로 이 회의록을 삭제하시겠습니까?</p>
|
|
<p style="font-weight: bold; color: #dc3545;" id="delete-meeting-title"></p>
|
|
<p style="font-size: 12px; color: #6c757d;">서버 파일과 Google Cloud Storage의 파일도 함께 삭제됩니다.</p>
|
|
</div>
|
|
<div style="text-align: right; margin-top: 20px;">
|
|
<button class="btn-small" onclick="closeDeleteModal()" style="margin-right: 10px; width: auto; padding: 8px 20px;">취소</button>
|
|
<button class="btn-small" onclick="deleteMeeting()" style="background-color: #dc3545; color: white; width: auto; padding: 8px 20px;">삭제</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let mediaRecorder;
|
|
let audioChunks = [];
|
|
let isRecording = false;
|
|
let startTime;
|
|
let timerInterval;
|
|
|
|
// Web Speech API (미리보기용)
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
let recognition = null;
|
|
let isRecognitionActive = false;
|
|
|
|
// 음성 인식 텍스트 변수 (중복 방지)
|
|
let finalTranscript = '';
|
|
let interimTranscript = '';
|
|
|
|
// 시각화 변수
|
|
let audioContext = null;
|
|
let analyser = null;
|
|
let dataArray = null;
|
|
let animationId = null;
|
|
let mediaStream = null;
|
|
let gainNode = null; // 오디오 증폭용
|
|
let audioSource = null; // 오디오 소스
|
|
|
|
// DOM 요소
|
|
const recordBtn = document.getElementById('record-button');
|
|
const saveBtn = document.getElementById('save-analyze-btn');
|
|
const previewText = document.getElementById('preview-text');
|
|
const statusEl = document.getElementById('status');
|
|
const timerEl = document.getElementById('timer');
|
|
const waveformCanvas = document.getElementById('waveform');
|
|
const canvasCtx = waveformCanvas.getContext('2d');
|
|
|
|
// Canvas 크기 초기화
|
|
function resizeCanvas() {
|
|
waveformCanvas.width = waveformCanvas.offsetWidth;
|
|
waveformCanvas.height = waveformCanvas.offsetHeight;
|
|
}
|
|
resizeCanvas();
|
|
window.addEventListener('resize', resizeCanvas);
|
|
|
|
// Web Speech API 초기화
|
|
function initSpeechRecognition() {
|
|
if (!SpeechRecognition) {
|
|
alert('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome 브라우저를 사용해주세요.');
|
|
return false;
|
|
}
|
|
|
|
recognition = new SpeechRecognition();
|
|
recognition.lang = 'ko-KR';
|
|
recognition.continuous = true;
|
|
recognition.interimResults = true;
|
|
// 민감도 향상을 위한 추가 설정
|
|
recognition.maxAlternatives = 3; // 더 많은 대안 결과 허용
|
|
|
|
return true;
|
|
}
|
|
|
|
// Waveform 시각화
|
|
function drawWaveform() {
|
|
if (!analyser || !dataArray) return;
|
|
|
|
animationId = requestAnimationFrame(drawWaveform);
|
|
|
|
analyser.getByteTimeDomainData(dataArray);
|
|
|
|
canvasCtx.fillStyle = '#f8f9fa';
|
|
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
|
|
|
|
canvasCtx.lineWidth = 2;
|
|
canvasCtx.strokeStyle = '#667eea';
|
|
canvasCtx.beginPath();
|
|
|
|
const sliceWidth = waveformCanvas.width / dataArray.length;
|
|
let x = 0;
|
|
|
|
for (let i = 0; i < dataArray.length; i++) {
|
|
const v = dataArray[i] / 128.0;
|
|
const y = v * waveformCanvas.height / 2;
|
|
|
|
if (i === 0) {
|
|
canvasCtx.moveTo(x, y);
|
|
} else {
|
|
canvasCtx.lineTo(x, y);
|
|
}
|
|
|
|
x += sliceWidth;
|
|
}
|
|
|
|
canvasCtx.lineTo(waveformCanvas.width, waveformCanvas.height / 2);
|
|
canvasCtx.stroke();
|
|
}
|
|
|
|
function stopWaveform() {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
animationId = null;
|
|
}
|
|
|
|
// Clear canvas
|
|
canvasCtx.fillStyle = '#f8f9fa';
|
|
canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
|
|
}
|
|
|
|
// 오디오 스트림 시작 (시각화용 및 증폭)
|
|
async function startAudioStream() {
|
|
try {
|
|
// 마이크 제약 조건 최적화 (민감도 향상)
|
|
const audioConstraints = {
|
|
audio: {
|
|
echoCancellation: false, // 에코 캔슬 비활성화 (작은 소리 감지 향상)
|
|
noiseSuppression: false, // 노이즈 억제 비활성화 (작은 소리 감지 향상)
|
|
autoGainControl: true, // 자동 게인 제어 활성화
|
|
sampleRate: 48000, // 높은 샘플레이트
|
|
channelCount: 1 // 모노 채널
|
|
}
|
|
};
|
|
|
|
mediaStream = await navigator.mediaDevices.getUserMedia(audioConstraints);
|
|
|
|
// Setup Audio Context for Visualization and Amplification
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
// 오디오 소스 생성
|
|
audioSource = audioContext.createMediaStreamSource(mediaStream);
|
|
|
|
// GainNode 생성 및 증폭 설정 (3배 증폭 - 작은 소리도 잘 들리도록)
|
|
gainNode = audioContext.createGain();
|
|
gainNode.gain.value = 3.0; // 3배 증폭 (최대 10까지 가능하지만 3이 적절)
|
|
|
|
// 오디오 체인: Source -> Gain -> Analyser
|
|
audioSource.connect(gainNode);
|
|
analyser = audioContext.createAnalyser();
|
|
gainNode.connect(analyser);
|
|
|
|
analyser.fftSize = 2048;
|
|
const bufferLength = analyser.frequencyBinCount;
|
|
dataArray = new Uint8Array(bufferLength);
|
|
|
|
// Start Waveform Visualization
|
|
drawWaveform();
|
|
|
|
return mediaStream;
|
|
} catch (error) {
|
|
console.error('마이크 접근 오류:', error);
|
|
alert('마이크 권한을 허용해주세요');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// 오디오 스트림 중지
|
|
function stopAudioStream() {
|
|
if (mediaStream) {
|
|
mediaStream.getTracks().forEach(track => track.stop());
|
|
mediaStream = null;
|
|
}
|
|
|
|
// 오디오 노드 연결 해제
|
|
if (audioSource) {
|
|
try {
|
|
audioSource.disconnect();
|
|
} catch (e) {
|
|
console.log('Audio source disconnect:', e);
|
|
}
|
|
audioSource = null;
|
|
}
|
|
|
|
if (gainNode) {
|
|
try {
|
|
gainNode.disconnect();
|
|
} catch (e) {
|
|
console.log('Gain node disconnect:', e);
|
|
}
|
|
gainNode = null;
|
|
}
|
|
|
|
if (analyser) {
|
|
try {
|
|
analyser.disconnect();
|
|
} catch (e) {
|
|
console.log('Analyser disconnect:', e);
|
|
}
|
|
analyser = null;
|
|
}
|
|
|
|
if (audioContext) {
|
|
audioContext.close();
|
|
audioContext = null;
|
|
}
|
|
}
|
|
|
|
// 녹음 시작/종료 처리
|
|
recordBtn.addEventListener('click', async () => {
|
|
if (!isRecording) {
|
|
// Speech Recognition 초기화
|
|
if (!recognition) {
|
|
if (!initSpeechRecognition()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 녹음 시작
|
|
const stream = await startAudioStream();
|
|
if (!stream) return;
|
|
|
|
// 변수 초기화
|
|
audioChunks = [];
|
|
finalTranscript = '';
|
|
interimTranscript = '';
|
|
|
|
// 1. MediaRecorder 시작 (파일 저장용)
|
|
mediaRecorder = new MediaRecorder(stream);
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
audioChunks.push(event.data);
|
|
};
|
|
mediaRecorder.start();
|
|
|
|
// 2. Web Speech API 시작 (화면 표시용)
|
|
if (!isRecognitionActive) {
|
|
try {
|
|
recognition.start();
|
|
isRecognitionActive = true;
|
|
} catch (e) {
|
|
console.error('음성 인식 시작 실패:', e);
|
|
}
|
|
}
|
|
|
|
isRecording = true;
|
|
recordBtn.classList.add('recording');
|
|
recordBtn.innerHTML = '<i class="bi bi-stop-fill"></i>';
|
|
updateStatus('녹음 중...', 'recording');
|
|
startTimer();
|
|
saveBtn.disabled = true;
|
|
previewText.classList.remove('empty');
|
|
|
|
} else {
|
|
// 녹음 종료
|
|
mediaRecorder.stop();
|
|
|
|
if (isRecognitionActive) {
|
|
try {
|
|
recognition.stop();
|
|
isRecognitionActive = false;
|
|
} catch (e) {
|
|
console.error('음성 인식 종료 실패:', e);
|
|
}
|
|
}
|
|
|
|
isRecording = false;
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.innerHTML = '<i class="bi bi-mic-fill"></i>';
|
|
updateStatus('녹음 완료', 'completed');
|
|
stopTimer();
|
|
stopWaveform();
|
|
stopAudioStream();
|
|
|
|
// 최종 텍스트 정리 (임시 텍스트 제거)
|
|
if (finalTranscript.trim()) {
|
|
previewText.textContent = finalTranscript.trim();
|
|
saveBtn.disabled = false;
|
|
} else {
|
|
previewText.innerHTML = '<span style="color:#999;font-style:italic;">인식된 텍스트가 없습니다</span>';
|
|
saveBtn.disabled = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Web Speech API 이벤트 핸들러 설정
|
|
function setupRecognitionHandlers() {
|
|
if (!recognition) return;
|
|
|
|
// 음성 인식 결과 처리
|
|
recognition.onresult = (event) => {
|
|
// 임시 텍스트 초기화 (중복 방지를 위해 매번 초기화)
|
|
interimTranscript = '';
|
|
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const transcript = event.results[i][0].transcript;
|
|
|
|
if (event.results[i].isFinal) {
|
|
// 확정된 텍스트는 finalTranscript에 추가
|
|
finalTranscript += transcript + ' ';
|
|
} else {
|
|
// 임시 텍스트는 interimTranscript에 저장 (누적하지 않음)
|
|
interimTranscript += transcript;
|
|
}
|
|
}
|
|
|
|
// 텍스트 업데이트 (확정된 텍스트 + 현재 임시 텍스트만 표시)
|
|
const displayText = finalTranscript + (interimTranscript ? '<span style="color:#999">' + interimTranscript + '</span>' : '');
|
|
previewText.innerHTML = displayText || '음성을 인식하고 있습니다...';
|
|
previewText.classList.remove('empty');
|
|
previewText.scrollTop = previewText.scrollHeight;
|
|
|
|
// 버튼 활성화 (확정된 텍스트가 있을 때만)
|
|
if (finalTranscript.trim()) {
|
|
saveBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
// 음성 인식 시작
|
|
recognition.onstart = () => {
|
|
isRecognitionActive = true;
|
|
updateStatus('음성 인식 중', 'recording');
|
|
};
|
|
|
|
// 음성 인식 종료
|
|
recognition.onend = () => {
|
|
isRecognitionActive = false;
|
|
|
|
if (isRecording) {
|
|
// 자동 재시작 (연속 녹음 모드)
|
|
try {
|
|
recognition.start();
|
|
} catch (e) {
|
|
console.log('Recognition restart failed:', e);
|
|
}
|
|
} else {
|
|
updateStatus('변환 완료', 'completed');
|
|
}
|
|
};
|
|
|
|
// 에러 처리
|
|
recognition.onerror = (event) => {
|
|
console.error('Speech recognition error:', event.error);
|
|
|
|
if (event.error === 'no-speech') {
|
|
// 음성이 감지되지 않음 - 자동 재시작 (작은 소리도 감지하도록 지속 시도)
|
|
if (isRecording) {
|
|
setTimeout(() => {
|
|
try {
|
|
if (isRecording && !isRecognitionActive) {
|
|
recognition.start();
|
|
}
|
|
} catch (e) {
|
|
console.log('Recognition restart after no-speech:', e);
|
|
}
|
|
}, 500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.error === 'aborted') {
|
|
// 사용자가 중단함
|
|
return;
|
|
}
|
|
|
|
// 'network' 오류는 일시적이므로 재시도
|
|
if (event.error === 'network') {
|
|
if (isRecording) {
|
|
setTimeout(() => {
|
|
try {
|
|
if (isRecording && !isRecognitionActive) {
|
|
recognition.start();
|
|
}
|
|
} catch (e) {
|
|
console.log('Recognition restart after network error:', e);
|
|
}
|
|
}, 1000);
|
|
}
|
|
return;
|
|
}
|
|
|
|
updateStatus('오류 발생: ' + event.error, 'error');
|
|
|
|
if (event.error === 'not-allowed') {
|
|
alert('마이크 권한이 거부되었습니다. 브라우저 설정에서 마이크 권한을 허용해주세요.');
|
|
}
|
|
};
|
|
}
|
|
|
|
// Update Status
|
|
function updateStatus(message, statusClass) {
|
|
statusEl.textContent = message;
|
|
statusEl.className = 'status-indicator status-' + statusClass;
|
|
}
|
|
|
|
// Timer Functions
|
|
function startTimer() {
|
|
startTime = Date.now();
|
|
timerEl.classList.add('active');
|
|
|
|
timerInterval = setInterval(() => {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
const minutes = Math.floor(elapsed / 60);
|
|
const seconds = elapsed % 60;
|
|
timerEl.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
}, 1000);
|
|
}
|
|
|
|
function stopTimer() {
|
|
if (timerInterval) {
|
|
clearInterval(timerInterval);
|
|
timerInterval = null;
|
|
}
|
|
timerEl.classList.remove('active');
|
|
}
|
|
|
|
// 테스트용 샘플 오디오 생성 (개발/테스트용)
|
|
const testSampleBtn = document.getElementById('test-sample-btn');
|
|
if (testSampleBtn) {
|
|
testSampleBtn.addEventListener('click', async () => {
|
|
if (!confirm('테스트용 샘플 오디오를 생성하시겠습니까?\n(실제 녹음 없이 테스트용 더미 데이터를 생성합니다)')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Web Audio API를 사용하여 짧은 오디오 생성
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
const sampleRate = audioContext.sampleRate;
|
|
const duration = 2; // 2초
|
|
const numSamples = sampleRate * duration;
|
|
const buffer = audioContext.createBuffer(1, numSamples, sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
|
|
// 간단한 사인파 생성 (테스트용)
|
|
for (let i = 0; i < numSamples; i++) {
|
|
data[i] = Math.sin(2 * Math.PI * 440 * i / sampleRate) * 0.1; // 440Hz, 작은 볼륨
|
|
}
|
|
|
|
// WAV로 변환
|
|
const wav = audioBufferToWav(buffer);
|
|
const blob = new Blob([wav], { type: 'audio/wav' });
|
|
|
|
// audioChunks에 추가
|
|
audioChunks = [blob];
|
|
finalTranscript = '안녕하세요. 미래기업입니다. 회의를 시작합니다.';
|
|
previewText.textContent = finalTranscript;
|
|
saveBtn.disabled = false;
|
|
updateStatus('샘플 데이터 준비 완료', 'completed');
|
|
alert('샘플 오디오가 생성되었습니다. "회의 종료 및 AI 분석 시작" 버튼을 클릭하세요.');
|
|
} catch (e) {
|
|
console.error('샘플 오디오 생성 실패:', e);
|
|
alert('샘플 오디오 생성에 실패했습니다: ' + e.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
// AudioBuffer를 WAV로 변환하는 함수
|
|
function audioBufferToWav(buffer) {
|
|
const length = buffer.length;
|
|
const arrayBuffer = new ArrayBuffer(44 + length * 2);
|
|
const view = new DataView(arrayBuffer);
|
|
const channels = buffer.numberOfChannels;
|
|
const sampleRate = buffer.sampleRate;
|
|
|
|
// WAV 헤더 작성
|
|
const writeString = (offset, string) => {
|
|
for (let i = 0; i < string.length; i++) {
|
|
view.setUint8(offset + i, string.charCodeAt(i));
|
|
}
|
|
};
|
|
|
|
writeString(0, 'RIFF');
|
|
view.setUint32(4, 36 + length * 2, true);
|
|
writeString(8, 'WAVE');
|
|
writeString(12, 'fmt ');
|
|
view.setUint32(16, 16, true);
|
|
view.setUint16(20, 1, true);
|
|
view.setUint16(22, channels, true);
|
|
view.setUint32(24, sampleRate, true);
|
|
view.setUint32(28, sampleRate * 2, true);
|
|
view.setUint16(32, 2, true);
|
|
view.setUint16(34, 16, true);
|
|
writeString(36, 'data');
|
|
view.setUint32(40, length * 2, true);
|
|
|
|
// 데이터 작성
|
|
const channelData = buffer.getChannelData(0);
|
|
let offset = 44;
|
|
for (let i = 0; i < length; i++) {
|
|
const sample = Math.max(-1, Math.min(1, channelData[i]));
|
|
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
|
|
offset += 2;
|
|
}
|
|
|
|
return arrayBuffer;
|
|
}
|
|
|
|
// 저장 및 분석 버튼 클릭
|
|
saveBtn.addEventListener('click', async () => {
|
|
// 버튼 비활성화 및 로딩 표시
|
|
saveBtn.disabled = true;
|
|
const originalBtnText = saveBtn.innerHTML;
|
|
saveBtn.innerHTML = '<span class="loading-spinner"></span> 처리 중...';
|
|
|
|
document.getElementById('voiceloadingOverlay').style.display = 'flex';
|
|
updateStatus('처리 중...', 'processing');
|
|
|
|
// Blob 생성
|
|
if (!audioChunks || audioChunks.length === 0) {
|
|
alert('녹음된 오디오 데이터가 없습니다. 녹음을 시작하고 일정 시간 이상 녹음해주세요.');
|
|
document.getElementById('voiceloadingOverlay').style.display = 'none';
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = originalBtnText;
|
|
updateStatus('오류', 'error');
|
|
return;
|
|
}
|
|
|
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
|
|
|
// 오디오 파일 크기 확인
|
|
if (audioBlob.size < 1000) { // 1KB 미만이면 너무 작음
|
|
alert('녹음된 오디오가 너무 작습니다. (' + audioBlob.size + ' bytes) 녹음을 다시 시도해주세요.');
|
|
document.getElementById('voiceloadingOverlay').style.display = 'none';
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = originalBtnText;
|
|
updateStatus('오류', 'error');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('audio_file', audioBlob, 'meeting_record.webm');
|
|
|
|
// 확정된 텍스트 전송 (참고용)
|
|
if (finalTranscript && finalTranscript.trim()) {
|
|
formData.append('preview_text', finalTranscript.trim());
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('process_meeting.php', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
// JSON 파싱 전에 텍스트 확인
|
|
let result;
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch (parseError) {
|
|
console.error('JSON 파싱 오류:', parseError);
|
|
console.error('응답 텍스트:', text);
|
|
alert('서버 응답 오류가 발생했습니다: ' + text.substring(0, 100));
|
|
throw parseError;
|
|
}
|
|
|
|
if (result.ok) {
|
|
// 성공 시 콘솔에 상세 정보 출력
|
|
console.log('=== 회의록 저장 성공 ===');
|
|
console.log('저장 ID:', result.id);
|
|
console.log('제목:', result.title);
|
|
console.log('파일 경로:', result.db_info?.file_path || 'N/A');
|
|
console.log('만료일:', result.db_info?.expiry_date || 'N/A');
|
|
console.log('전문 길이:', result.transcript?.length || 0, '자');
|
|
console.log('요약 길이:', result.summary?.length || 0, '자');
|
|
console.log('전문:', result.transcript);
|
|
console.log('요약:', result.summary);
|
|
console.log('========================');
|
|
|
|
alert('회의록이 성공적으로 생성되었습니다.\n저장 ID: ' + result.id + '\n제목: ' + result.title);
|
|
updateStatus('완료', 'completed');
|
|
|
|
// 페이지 리로드하여 리스트 갱신
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 1000);
|
|
} else {
|
|
let errorMsg = '오류 발생: ' + result.error;
|
|
if (result.details) {
|
|
errorMsg += '\n상세: ' + result.details;
|
|
}
|
|
if (result.curl_error) {
|
|
errorMsg += '\nCURL 오류: ' + result.curl_error;
|
|
}
|
|
console.error('서버 오류 응답:', result);
|
|
alert(errorMsg);
|
|
updateStatus('오류', 'error');
|
|
}
|
|
} catch (e) {
|
|
console.error('서버 통신 오류:', e);
|
|
// JSON 파싱 오류인 경우 더 자세한 정보 표시
|
|
if (e.message && e.message.includes('JSON')) {
|
|
console.error('JSON 파싱 실패 - 서버 응답에 PHP 경고가 포함되어 있을 수 있습니다.');
|
|
alert('서버 응답 오류가 발생했습니다. 페이지를 새로고침해주세요.');
|
|
} else {
|
|
alert('서버 통신 오류: ' + (e.message || '알 수 없는 오류'));
|
|
}
|
|
updateStatus('오류', 'error');
|
|
} finally {
|
|
document.getElementById('voiceloadingOverlay').style.display = 'none';
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = originalBtnText;
|
|
}
|
|
});
|
|
|
|
// 초기화: Recognition 핸들러 설정
|
|
if (SpeechRecognition) {
|
|
initSpeechRecognition();
|
|
setupRecognitionHandlers();
|
|
}
|
|
|
|
// 페이지 로드 시 로딩 오버레이 숨기기 및 전자결재 다이얼로그 닫기
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
|
|
if (voiceloadingOverlay) {
|
|
voiceloadingOverlay.style.display = 'none';
|
|
}
|
|
|
|
// 전자결재 다이얼로그 자동 닫기
|
|
const eworksModal = document.getElementById('eworks_viewmodal');
|
|
if (eworksModal) {
|
|
eworksModal.style.display = 'none';
|
|
eworksModal.classList.remove('show');
|
|
}
|
|
|
|
const eworksDialog = document.querySelector('#eworks_viewmodal .modal-dialog');
|
|
if (eworksDialog) {
|
|
eworksDialog.style.display = 'none';
|
|
}
|
|
|
|
// Bootstrap 모달 닫기
|
|
if (typeof jQuery !== 'undefined' && jQuery('#eworks_viewmodal').length) {
|
|
jQuery('#eworks_viewmodal').modal('hide');
|
|
}
|
|
});
|
|
|
|
// 즉시 숨기기 (백업)
|
|
setTimeout(function() {
|
|
const voiceloadingOverlay = document.getElementById('voiceloadingOverlay');
|
|
if (voiceloadingOverlay) {
|
|
voiceloadingOverlay.style.display = 'none';
|
|
}
|
|
|
|
// 전자결재 다이얼로그 강제 닫기
|
|
const eworksModal = document.getElementById('eworks_viewmodal');
|
|
if (eworksModal) {
|
|
eworksModal.style.display = 'none';
|
|
eworksModal.classList.remove('show');
|
|
eworksModal.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
// 모든 모달 배경 제거
|
|
const modalBackdrops = document.querySelectorAll('.modal-backdrop');
|
|
modalBackdrops.forEach(function(backdrop) {
|
|
backdrop.remove();
|
|
});
|
|
|
|
// body 클래스 정리
|
|
document.body.classList.remove('modal-open');
|
|
document.body.style.overflow = '';
|
|
document.body.style.paddingRight = '';
|
|
}, 100);
|
|
|
|
// 회의록 상세보기
|
|
async function viewDetail(meetingId) {
|
|
try {
|
|
const response = await fetch('get_meeting.php?id=' + meetingId);
|
|
const text = await response.text();
|
|
|
|
// JSON 파싱 전에 텍스트 확인
|
|
let result;
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch (parseError) {
|
|
console.error('JSON 파싱 오류:', parseError);
|
|
console.error('응답 텍스트:', text);
|
|
alert('서버 응답 오류가 발생했습니다. 콘솔을 확인해주세요.');
|
|
return;
|
|
}
|
|
|
|
if (result.ok) {
|
|
document.getElementById('modal-title').textContent = result.data.title;
|
|
document.getElementById('modal-transcript').textContent = result.data.transcript_text || '텍스트 없음';
|
|
document.getElementById('modal-summary').textContent = result.data.summary_text || '요약 없음';
|
|
document.getElementById('detail-modal').style.display = 'flex';
|
|
} else {
|
|
alert('데이터를 불러올 수 없습니다: ' + result.error);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert('서버 오류가 발생했습니다');
|
|
}
|
|
}
|
|
|
|
// 모달 닫기
|
|
function closeModal() {
|
|
document.getElementById('detail-modal').style.display = 'none';
|
|
}
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// 모달 배경 클릭 시 닫기
|
|
document.getElementById('detail-modal')?.addEventListener('click', function(event) {
|
|
if (event.target === this) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// 삭제 확인 모달 관련 변수
|
|
let deleteMeetingId = null;
|
|
|
|
// 삭제 확인 모달 열기
|
|
function confirmDelete(meetingId, meetingTitle) {
|
|
deleteMeetingId = meetingId;
|
|
document.getElementById('delete-meeting-title').textContent = meetingTitle;
|
|
document.getElementById('delete-modal').style.display = 'flex';
|
|
}
|
|
|
|
// 삭제 확인 모달 닫기
|
|
function closeDeleteModal() {
|
|
document.getElementById('delete-modal').style.display = 'none';
|
|
deleteMeetingId = null;
|
|
}
|
|
|
|
// 회의록 삭제 실행
|
|
async function deleteMeeting() {
|
|
if (!deleteMeetingId) {
|
|
alert('삭제할 회의록이 선택되지 않았습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('delete_meeting.php', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'id=' + deleteMeetingId
|
|
});
|
|
|
|
const text = await response.text();
|
|
let result;
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch (parseError) {
|
|
console.error('JSON 파싱 오류:', parseError);
|
|
console.error('응답 텍스트:', text);
|
|
alert('서버 응답 오류가 발생했습니다.');
|
|
return;
|
|
}
|
|
|
|
if (result.ok) {
|
|
alert('회의록이 삭제되었습니다.');
|
|
closeDeleteModal();
|
|
// 페이지 새로고침하여 목록 업데이트
|
|
location.reload();
|
|
} else {
|
|
alert('삭제 실패: ' + result.error);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert('서버 오류가 발생했습니다');
|
|
}
|
|
}
|
|
|
|
// ESC 키로 삭제 모달도 닫기
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === 'Escape') {
|
|
closeModal();
|
|
closeDeleteModal();
|
|
}
|
|
});
|
|
|
|
// 삭제 모달 배경 클릭 시 닫기
|
|
document.getElementById('delete-modal')?.addEventListener('click', function(event) {
|
|
if (event.target === this) {
|
|
closeDeleteModal();
|
|
}
|
|
});
|
|
|
|
</script>
|
|
<?php
|
|
// 출력 버퍼 flush
|
|
ob_end_flush();
|
|
?>
|
|
</body>
|
|
</html>
|