feat: Meeting Assistant MVP - Complete implementation

Enterprise Meeting Knowledge Management System with:

Backend (FastAPI):
- Authentication proxy with JWT (pj-auth-api integration)
- MySQL database with 4 tables (users, meetings, conclusions, actions)
- Meeting CRUD with system code generation (C-YYYYMMDD-XX, A-YYYYMMDD-XX)
- Dify LLM integration for AI summarization
- Excel export with openpyxl
- 20 unit tests (all passing)

Client (Electron):
- Login page with company auth
- Meeting list with create/delete
- Meeting detail with real-time transcription
- Editable transcript textarea (single block, easy editing)
- AI summarization with conclusions/action items
- 5-second segment recording (efficient for long meetings)

Sidecar (Python):
- faster-whisper medium model with int8 quantization
- ONNX Runtime VAD (lightweight, ~20MB vs PyTorch ~2GB)
- Chinese punctuation processing
- OpenCC for Traditional Chinese conversion
- Anti-hallucination parameters
- Auto-cleanup of temp audio files

OpenSpec:
- add-meeting-assistant-mvp (47 tasks, archived)
- add-realtime-transcription (29 tasks, archived)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-10 20:17:44 +08:00
commit 8b6184ecc5
65 changed files with 10510 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Assistant - Login</title>
<link rel="stylesheet" href="../styles/main.css">
</head>
<body>
<div class="login-container">
<div class="login-box">
<h1>Meeting Assistant</h1>
<div id="error-alert" class="alert alert-error hidden"></div>
<form id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required placeholder="your.email@company.com">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Enter your password">
</div>
<button type="submit" class="btn btn-primary btn-full" id="login-btn">Login</button>
</form>
</div>
</div>
<script type="module">
import { login } from '../services/api.js';
const form = document.getElementById('login-form');
const errorAlert = document.getElementById('error-alert');
const loginBtn = document.getElementById('login-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
loginBtn.disabled = true;
loginBtn.textContent = 'Logging in...';
errorAlert.classList.add('hidden');
try {
await login(email, password);
window.electronAPI.navigate('meetings');
} catch (error) {
errorAlert.textContent = error.message;
errorAlert.classList.remove('hidden');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Login';
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,685 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Assistant - Meeting Detail</title>
<link rel="stylesheet" href="../styles/main.css">
<style>
.transcript-segments {
display: flex;
flex-direction: column;
gap: 8px;
}
.transcript-segment {
position: relative;
padding: 10px 12px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
transition: all 0.2s;
}
.transcript-segment:hover {
background: #fff;
border-color: #adb5bd;
}
.transcript-segment.active {
background: #e3f2fd;
border-color: #2196f3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
.transcript-segment.edited {
border-left: 3px solid #ff9800;
}
.transcript-segment textarea {
width: 100%;
min-height: 40px;
padding: 0;
border: none;
background: transparent;
resize: vertical;
font-size: 14px;
line-height: 1.5;
font-family: inherit;
}
.transcript-segment textarea:focus {
outline: none;
}
.segment-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
font-size: 11px;
color: #6c757d;
}
.segment-id {
background: #e9ecef;
padding: 2px 6px;
border-radius: 3px;
}
.segment-edited {
color: #ff9800;
font-weight: 500;
}
.streaming-status {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
background: #e3f2fd;
border-radius: 6px;
margin-bottom: 10px;
}
.streaming-status.hidden {
display: none;
}
.pulse-dot {
width: 10px;
height: 10px;
background: #f44336;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.segment-count {
margin-left: auto;
font-size: 12px;
color: #666;
}
.processing-indicator {
text-align: center;
padding: 15px;
color: #666;
font-style: italic;
}
.transcript-textarea {
width: 100%;
min-height: 400px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 15px;
line-height: 1.8;
resize: vertical;
font-family: 'Microsoft JhengHei', 'PingFang TC', sans-serif;
}
.transcript-textarea:focus {
outline: none;
border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2);
}
</style>
</head>
<body>
<header class="header">
<h1 id="meeting-title">Meeting Details</h1>
<nav class="header-nav">
<a href="#" id="back-btn">Back to List</a>
<a href="#" id="export-btn">Export Excel</a>
<a href="#" id="delete-btn" style="color: #ff6b6b;">Delete</a>
</nav>
</header>
<div class="container" style="padding: 10px;">
<!-- Meeting Info Bar -->
<div class="card" style="margin-bottom: 10px;">
<div class="card-body" style="padding: 10px 20px;">
<div id="meeting-info" style="display: flex; flex-wrap: wrap; gap: 20px;">
<span><strong>Time:</strong> <span id="info-time"></span></span>
<span><strong>Location:</strong> <span id="info-location"></span></span>
<span><strong>Chair:</strong> <span id="info-chair"></span></span>
<span><strong>Recorder:</strong> <span id="info-recorder"></span></span>
</div>
</div>
</div>
<!-- Dual Panel Layout -->
<div class="dual-panel">
<!-- Left Panel: Transcript -->
<div class="panel">
<div class="panel-header">
<span>Transcript (逐字稿)</span>
<div class="recording-controls" style="padding: 0;">
<button class="btn btn-danger" id="record-btn">Start Recording</button>
</div>
</div>
<div class="panel-body">
<!-- Streaming Status -->
<div id="streaming-status" class="streaming-status hidden">
<span class="pulse-dot"></span>
<span>Recording & Transcribing...</span>
<span class="segment-count" id="segment-count">Segments: 0</span>
</div>
<!-- Single Transcript Textarea -->
<div id="transcript-container">
<textarea
id="transcript-text"
class="transcript-textarea"
placeholder="Click 'Start Recording' to begin transcription. You can also type or paste text directly here."
></textarea>
<div class="processing-indicator hidden" id="processing-indicator">
Processing audio...
</div>
</div>
</div>
</div>
<!-- Right Panel: Notes & Actions -->
<div class="panel">
<div class="panel-header">
<span>Notes & Actions</span>
<button class="btn btn-primary" id="summarize-btn">AI Summarize</button>
</div>
<div class="panel-body">
<!-- Conclusions -->
<div class="mb-20">
<h3 class="mb-10">Conclusions (結論)</h3>
<div id="conclusions-list"></div>
<button class="btn btn-secondary mt-10" id="add-conclusion-btn">+ Add Conclusion</button>
</div>
<!-- Action Items -->
<div>
<h3 class="mb-10">Action Items (待辦事項)</h3>
<div id="actions-list"></div>
<button class="btn btn-secondary mt-10" id="add-action-btn">+ Add Action Item</button>
</div>
</div>
<div style="padding: 15px 20px; border-top: 1px solid #dee2e6;">
<button class="btn btn-success btn-full" id="save-btn">Save Changes</button>
</div>
</div>
</div>
</div>
<script type="module">
import {
getMeeting,
updateMeeting,
deleteMeeting,
exportMeeting,
summarizeTranscript
} from '../services/api.js';
const meetingId = localStorage.getItem('currentMeetingId');
let currentMeeting = null;
let isRecording = false;
let audioContext = null;
let mediaStream = null;
let audioWorklet = null;
let transcriptionCount = 0; // Track number of transcription chunks
// Elements
const titleEl = document.getElementById('meeting-title');
const timeEl = document.getElementById('info-time');
const locationEl = document.getElementById('info-location');
const chairEl = document.getElementById('info-chair');
const recorderEl = document.getElementById('info-recorder');
const transcriptTextEl = document.getElementById('transcript-text');
const streamingStatusEl = document.getElementById('streaming-status');
const segmentCountEl = document.getElementById('segment-count');
const processingIndicatorEl = document.getElementById('processing-indicator');
const conclusionsEl = document.getElementById('conclusions-list');
const actionsEl = document.getElementById('actions-list');
const recordBtn = document.getElementById('record-btn');
const summarizeBtn = document.getElementById('summarize-btn');
const saveBtn = document.getElementById('save-btn');
const backBtn = document.getElementById('back-btn');
const exportBtn = document.getElementById('export-btn');
const deleteBtn = document.getElementById('delete-btn');
const addConclusionBtn = document.getElementById('add-conclusion-btn');
const addActionBtn = document.getElementById('add-action-btn');
// Load meeting data
async function loadMeeting() {
try {
currentMeeting = await getMeeting(meetingId);
renderMeeting();
} catch (error) {
alert('Error loading meeting: ' + error.message);
window.electronAPI.navigate('meetings');
}
}
function renderMeeting() {
titleEl.textContent = currentMeeting.subject;
timeEl.textContent = new Date(currentMeeting.meeting_time).toLocaleString('zh-TW');
locationEl.textContent = currentMeeting.location || '-';
chairEl.textContent = currentMeeting.chairperson || '-';
recorderEl.textContent = currentMeeting.recorder || '-';
// Load existing transcript
if (currentMeeting.transcript_blob) {
transcriptTextEl.value = currentMeeting.transcript_blob;
}
renderConclusions();
renderActions();
}
// Append new transcription text
function appendTranscription(text) {
if (!text || !text.trim()) return;
transcriptionCount++;
const currentText = transcriptTextEl.value;
if (currentText.trim()) {
// Append with proper separator
transcriptTextEl.value = currentText.trimEnd() + '\n' + text.trim();
} else {
transcriptTextEl.value = text.trim();
}
// Scroll to bottom
transcriptTextEl.scrollTop = transcriptTextEl.scrollHeight;
segmentCountEl.textContent = `Chunks: ${transcriptionCount}`;
}
function getTranscript() {
return transcriptTextEl.value;
}
// === Recording with Segmented Transcription ===
// Each segment is a fresh 5-second recording (fixed size, efficient for long meetings)
let mediaRecorder = null;
let currentChunks = [];
let recordingCycleTimer = null;
let isProcessing = false;
const SEGMENT_DURATION = 5000; // 5 seconds per segment
recordBtn.addEventListener('click', async () => {
if (!isRecording) {
await startRecording();
} else {
await stopRecording();
}
});
async function startRecording() {
try {
const status = await window.electronAPI.getSidecarStatus();
if (!status.ready) {
alert('Transcription engine is not ready. Please wait a moment and try again.');
return;
}
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true }
});
isRecording = true;
recordBtn.textContent = 'Stop Recording';
recordBtn.classList.remove('btn-danger');
recordBtn.classList.add('btn-secondary');
streamingStatusEl.classList.remove('hidden');
// Start first recording cycle
startRecordingCycle();
console.log('Recording started with segmented approach');
} catch (error) {
console.error('Start recording error:', error);
alert('Error starting recording: ' + error.message);
await cleanupRecording();
}
}
function startRecordingCycle() {
if (!isRecording || !mediaStream) return;
currentChunks = [];
mediaRecorder = new MediaRecorder(mediaStream, {
mimeType: 'audio/webm;codecs=opus'
});
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
currentChunks.push(e.data);
}
};
mediaRecorder.onstop = async () => {
if (currentChunks.length > 0 && !isProcessing) {
isProcessing = true;
await transcribeCurrentSegment();
isProcessing = false;
}
// Start next cycle if still recording
if (isRecording && mediaStream) {
startRecordingCycle();
}
};
mediaRecorder.start(100); // Collect frequently for smooth stopping
// Schedule stop after SEGMENT_DURATION
recordingCycleTimer = setTimeout(() => {
if (mediaRecorder && mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
}, SEGMENT_DURATION);
}
async function transcribeCurrentSegment() {
if (currentChunks.length === 0) return;
try {
processingIndicatorEl.classList.remove('hidden');
const blob = new Blob(currentChunks, { type: 'audio/webm' });
// Skip very small blobs (likely silence)
if (blob.size < 1000) {
processingIndicatorEl.classList.add('hidden');
return;
}
const arrayBuffer = await blob.arrayBuffer();
const filePath = await window.electronAPI.saveAudioFile(arrayBuffer);
const result = await window.electronAPI.transcribeAudio(filePath);
if (result.result) {
const text = result.result.trim();
if (text) {
appendTranscription(text);
}
}
} catch (err) {
console.error('Segment transcription error:', err);
} finally {
processingIndicatorEl.classList.add('hidden');
}
}
async function stopRecording() {
try {
recordBtn.disabled = true;
recordBtn.textContent = 'Processing...';
isRecording = false;
// Clear the cycle timer
if (recordingCycleTimer) {
clearTimeout(recordingCycleTimer);
recordingCycleTimer = null;
}
// Stop current recorder and process final segment
if (mediaRecorder && mediaRecorder.state === 'recording') {
await new Promise(resolve => {
const originalOnStop = mediaRecorder.onstop;
mediaRecorder.onstop = async () => {
if (currentChunks.length > 0) {
await transcribeCurrentSegment();
}
resolve();
};
mediaRecorder.stop();
});
}
} catch (error) {
console.error('Stop recording error:', error);
} finally {
await cleanupRecording();
}
}
async function cleanupRecording() {
isRecording = false;
if (recordingCycleTimer) {
clearTimeout(recordingCycleTimer);
recordingCycleTimer = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
mediaRecorder = null;
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
currentChunks = [];
isProcessing = false;
recordBtn.disabled = false;
recordBtn.textContent = 'Start Recording';
recordBtn.classList.remove('btn-secondary');
recordBtn.classList.add('btn-danger');
streamingStatusEl.classList.add('hidden');
processingIndicatorEl.classList.add('hidden');
}
// === Streaming Event Handlers (legacy, kept for future use) ===
window.electronAPI.onTranscriptionSegment((segment) => {
console.log('Received segment:', segment);
processingIndicatorEl.classList.add('hidden');
if (segment.text) {
appendTranscription(segment.text);
}
if (isRecording) {
processingIndicatorEl.classList.remove('hidden');
}
});
window.electronAPI.onStreamStopped((data) => {
console.log('Stream stopped event:', data);
if (data.final_segments) {
data.final_segments.forEach(seg => {
if (seg.text) appendTranscription(seg.text);
});
}
});
// === Conclusions Rendering ===
function renderConclusions() {
if (!currentMeeting.conclusions || currentMeeting.conclusions.length === 0) {
conclusionsEl.innerHTML = '<p style="color: #666;">No conclusions yet.</p>';
return;
}
conclusionsEl.innerHTML = currentMeeting.conclusions.map((c, i) => `
<div class="action-item">
<div class="action-item-header">
<span class="action-item-code">${c.system_code || 'NEW'}</span>
<button class="btn btn-danger" style="padding: 4px 8px; font-size: 0.8rem;" onclick="removeConclusion(${i})">Remove</button>
</div>
<textarea
class="conclusion-content"
data-index="${i}"
style="width: 100%; min-height: 60px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
>${c.content}</textarea>
</div>
`).join('');
}
function renderActions() {
if (!currentMeeting.actions || currentMeeting.actions.length === 0) {
actionsEl.innerHTML = '<p style="color: #666;">No action items yet.</p>';
return;
}
actionsEl.innerHTML = currentMeeting.actions.map((a, i) => `
<div class="action-item">
<div class="action-item-header">
<span class="action-item-code">${a.system_code || 'NEW'}</span>
<select class="action-status" data-index="${i}" style="padding: 4px 8px;">
<option value="Open" ${a.status === 'Open' ? 'selected' : ''}>Open</option>
<option value="In Progress" ${a.status === 'In Progress' ? 'selected' : ''}>In Progress</option>
<option value="Done" ${a.status === 'Done' ? 'selected' : ''}>Done</option>
<option value="Delayed" ${a.status === 'Delayed' ? 'selected' : ''}>Delayed</option>
</select>
</div>
<textarea
class="action-content"
data-index="${i}"
style="width: 100%; min-height: 40px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 8px;"
>${a.content}</textarea>
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 10px; align-items: center;">
<input type="text" class="action-owner" data-index="${i}" value="${a.owner || ''}" placeholder="Owner" style="padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<input type="date" class="action-due" data-index="${i}" value="${a.due_date || ''}" style="padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
<button class="btn btn-danger" style="padding: 8px 12px;" onclick="removeAction(${i})">Remove</button>
</div>
</div>
`).join('');
}
window.removeConclusion = function(index) {
currentMeeting.conclusions.splice(index, 1);
renderConclusions();
};
window.removeAction = function(index) {
currentMeeting.actions.splice(index, 1);
renderActions();
};
addConclusionBtn.addEventListener('click', () => {
if (!currentMeeting.conclusions) currentMeeting.conclusions = [];
currentMeeting.conclusions.push({ content: '' });
renderConclusions();
});
addActionBtn.addEventListener('click', () => {
if (!currentMeeting.actions) currentMeeting.actions = [];
currentMeeting.actions.push({ content: '', owner: '', due_date: null, status: 'Open' });
renderActions();
});
// === AI Summarize ===
summarizeBtn.addEventListener('click', async () => {
const transcript = getTranscript();
if (!transcript || transcript.trim() === '') {
alert('Please record or add transcript segments first.');
return;
}
summarizeBtn.disabled = true;
summarizeBtn.textContent = 'Summarizing...';
try {
const result = await summarizeTranscript(transcript);
if (result.conclusions && result.conclusions.length > 0) {
if (!currentMeeting.conclusions) currentMeeting.conclusions = [];
result.conclusions.forEach(c => {
currentMeeting.conclusions.push({ content: c });
});
}
if (result.action_items && result.action_items.length > 0) {
if (!currentMeeting.actions) currentMeeting.actions = [];
result.action_items.forEach(a => {
currentMeeting.actions.push({
content: a.content,
owner: a.owner || '',
due_date: a.due_date || null,
status: 'Open'
});
});
}
renderConclusions();
renderActions();
} catch (error) {
alert('Error summarizing: ' + error.message);
} finally {
summarizeBtn.disabled = false;
summarizeBtn.textContent = 'AI Summarize';
}
});
// === Save ===
saveBtn.addEventListener('click', async () => {
const conclusions = [];
document.querySelectorAll('.conclusion-content').forEach((el) => {
conclusions.push({ content: el.value });
});
const actions = [];
document.querySelectorAll('.action-content').forEach((el, i) => {
const statusEl = document.querySelector(`.action-status[data-index="${i}"]`);
const ownerEl = document.querySelector(`.action-owner[data-index="${i}"]`);
const dueEl = document.querySelector(`.action-due[data-index="${i}"]`);
actions.push({
content: el.value,
owner: ownerEl?.value || '',
due_date: dueEl?.value || null,
status: statusEl?.value || 'Open'
});
});
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
await updateMeeting(meetingId, {
transcript_blob: getTranscript(),
conclusions: conclusions,
actions: actions
});
alert('Meeting saved successfully!');
loadMeeting();
} catch (error) {
alert('Error saving: ' + error.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Changes';
}
});
// === Export ===
exportBtn.addEventListener('click', async (e) => {
e.preventDefault();
try {
const blob = await exportMeeting(meetingId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meeting_${currentMeeting.uuid}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
alert('Error exporting: ' + error.message);
}
});
// === Delete ===
deleteBtn.addEventListener('click', async (e) => {
e.preventDefault();
if (!confirm('Are you sure you want to delete this meeting? This cannot be undone.')) return;
try {
await deleteMeeting(meetingId);
alert('Meeting deleted.');
window.electronAPI.navigate('meetings');
} catch (error) {
alert('Error deleting: ' + error.message);
}
});
// === Back ===
backBtn.addEventListener('click', (e) => {
e.preventDefault();
window.electronAPI.navigate('meetings');
});
// === Initialize ===
if (meetingId) {
loadMeeting();
} else {
window.electronAPI.navigate('meetings');
}
</script>
</body>
</html>

View File

@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Assistant - Meetings</title>
<link rel="stylesheet" href="../styles/main.css">
</head>
<body>
<header class="header">
<h1>Meeting Assistant</h1>
<nav class="header-nav">
<a href="#" id="new-meeting-btn">New Meeting</a>
<a href="#" id="logout-btn">Logout</a>
</nav>
</header>
<div class="container">
<div class="card">
<div class="card-header">
My Meetings
</div>
<div class="card-body" id="meetings-container">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- New Meeting Modal -->
<div id="new-meeting-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>New Meeting</h2>
<button class="modal-close" id="close-modal">&times;</button>
</div>
<div class="modal-body">
<form id="new-meeting-form">
<div class="form-group">
<label for="subject">Subject *</label>
<input type="text" id="subject" name="subject" required>
</div>
<div class="form-group">
<label for="meeting_time">Date & Time *</label>
<input type="datetime-local" id="meeting_time" name="meeting_time" required>
</div>
<div class="form-group">
<label for="chairperson">Chairperson</label>
<input type="text" id="chairperson" name="chairperson">
</div>
<div class="form-group">
<label for="location">Location</label>
<input type="text" id="location" name="location">
</div>
<div class="form-group">
<label for="attendees">Attendees (comma separated)</label>
<input type="text" id="attendees" name="attendees" placeholder="email1@company.com, email2@company.com">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-btn">Cancel</button>
<button class="btn btn-primary" id="create-btn">Create Meeting</button>
</div>
</div>
</div>
<script type="module">
import { getMeetings, createMeeting, clearToken } from '../services/api.js';
const meetingsContainer = document.getElementById('meetings-container');
const newMeetingBtn = document.getElementById('new-meeting-btn');
const logoutBtn = document.getElementById('logout-btn');
const modal = document.getElementById('new-meeting-modal');
const closeModalBtn = document.getElementById('close-modal');
const cancelBtn = document.getElementById('cancel-btn');
const createBtn = document.getElementById('create-btn');
const form = document.getElementById('new-meeting-form');
async function loadMeetings() {
try {
const meetings = await getMeetings();
if (meetings.length === 0) {
meetingsContainer.innerHTML = '<p class="text-center">No meetings yet. Create your first meeting!</p>';
return;
}
meetingsContainer.innerHTML = `
<ul class="meeting-list">
${meetings.map(m => `
<li class="meeting-item" data-id="${m.meeting_id}">
<div class="meeting-info">
<h3>${escapeHtml(m.subject)}</h3>
<p>${new Date(m.meeting_time).toLocaleString('zh-TW')} | ${escapeHtml(m.chairperson || 'No chairperson')}</p>
</div>
<div class="meeting-actions">
<button class="btn btn-primary btn-small view-btn">View</button>
</div>
</li>
`).join('')}
</ul>
`;
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = btn.closest('.meeting-item').dataset.id;
localStorage.setItem('currentMeetingId', id);
window.electronAPI.navigate('meeting-detail');
});
});
document.querySelectorAll('.meeting-item').forEach(item => {
item.addEventListener('click', () => {
localStorage.setItem('currentMeetingId', item.dataset.id);
window.electronAPI.navigate('meeting-detail');
});
});
} catch (error) {
meetingsContainer.innerHTML = `<div class="alert alert-error">${error.message}</div>`;
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function openModal() {
modal.classList.remove('hidden');
// Set default datetime to now
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
document.getElementById('meeting_time').value = now.toISOString().slice(0, 16);
// Set default recorder to current user
document.getElementById('recorder') && (document.getElementById('recorder').value = localStorage.getItem('userEmail') || '');
}
function closeModal() {
modal.classList.add('hidden');
form.reset();
}
newMeetingBtn.addEventListener('click', (e) => {
e.preventDefault();
openModal();
});
closeModalBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
createBtn.addEventListener('click', async () => {
const formData = new FormData(form);
const meeting = {
subject: formData.get('subject'),
meeting_time: formData.get('meeting_time'),
chairperson: formData.get('chairperson'),
location: formData.get('location'),
attendees: formData.get('attendees'),
recorder: localStorage.getItem('userEmail') || '',
};
if (!meeting.subject || !meeting.meeting_time) {
alert('Please fill in required fields');
return;
}
createBtn.disabled = true;
createBtn.textContent = 'Creating...';
try {
const created = await createMeeting(meeting);
closeModal();
localStorage.setItem('currentMeetingId', created.meeting_id);
window.electronAPI.navigate('meeting-detail');
} catch (error) {
alert('Error creating meeting: ' + error.message);
} finally {
createBtn.disabled = false;
createBtn.textContent = 'Create Meeting';
}
});
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
clearToken();
window.electronAPI.navigate('login');
});
// Close modal when clicking outside
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
// Load meetings on page load
loadMeetings();
</script>
</body>
</html>