This commit is contained in:
beabigegg
2025-08-17 15:26:44 +08:00
commit 0fee703b84
60 changed files with 8042 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
frontend/README.md Normal file
View File

@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meeting Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

0
frontend/npm Normal file
View File

3805
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"axios": "^1.11.0",
"jwt-decode": "^4.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.0"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.1.2"
}
}

BIN
frontend/public/LOGO.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,97 @@
/* Dark Theme Adaptation for tools.html */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #121212; /* Match MUI dark theme background */
color: #e0e0e0; /* Light text color for dark background */
margin: 20px;
line-height: 1.6;
}
h1, h2 {
color: #ffffff;
border-bottom: 2px solid #424242; /* Darker border */
padding-bottom: 10px;
}
.container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.card {
background-color: #1e1e1e; /* Darker card background */
border: 1px solid #424242; /* Subtle border */
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.card h3 {
margin-top: 0;
color: #bb86fc; /* A nice accent color for dark theme */
}
input[type="file"], textarea {
width: 100%;
padding: 8px;
margin-top: 10px;
border-radius: 4px;
border: 1px solid #555;
background-color: #333;
color: #e0e0e0;
}
button {
background-color: #3700b3; /* MUI dark theme primary-like color */
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
transition: background-color 0.3s;
}
button:hover {
background-color: #6200ee;
}
button:disabled {
background-color: #444;
cursor: not-allowed;
}
.progress-container, .result-container {
margin-top: 15px;
background-color: #2c2c2c;
padding: 15px;
border-radius: 4px;
}
.progress-bar {
width: 100%;
background-color: #444;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-inner {
height: 20px;
width: 0%;
background-color: #03dac6; /* Accent color for progress */
text-align: center;
line-height: 20px;
color: black;
transition: width 0.4s ease;
}
#translation-preview {
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
border: 1px solid #555;
padding: 10px;
margin-top: 10px;
background-color: #333;
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

68
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import MeetingDetailPage from './pages/MeetingDetailPage';
import ProcessingPage from './pages/ProcessingPage'; // Restored
import AdminPage from './pages/AdminPage'; // Added
import Layout from './components/Layout';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { CircularProgress, Box } from '@mui/material';
const darkTheme = createTheme({
palette: {
mode: 'dark',
},
});
const PrivateRoute = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
return user ? <Layout><Outlet /></Layout> : <Navigate to="/login" />;
};
const AdminRoute = () => {
const { user, loading } = useAuth();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
return user && user.role === 'admin' ? <Outlet /> : <Navigate to="/" />;
};
function App() {
return (
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<PrivateRoute />}>
<Route index element={<DashboardPage />} />
<Route path="processing" element={<ProcessingPage />} />
<Route path="meeting/:meetingId" element={<MeetingDetailPage />} />
<Route path="admin" element={<AdminRoute />}>
<Route index element={<AdminPage />} />
</Route>
</Route>
</Routes>
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { AppBar, Toolbar, Typography, Button, Container, Box } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import { Link, useNavigate } from 'react-router-dom';
const Layout = ({ children }) => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static">
<Toolbar>
<Typography variant="h6" component={Link} to="/" sx={{ flexGrow: 1, color: 'inherit', textDecoration: 'none' }}>
AI Meeting Assistant
</Typography>
{user && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Button color="inherit" component={Link} to="/">
Dashboard
</Button>
<Button color="inherit" component={Link} to="/processing">
Processing Tools
</Button>
{user.role === 'admin' && (
<Button color="inherit" component={Link} to="/admin">
Admin
</Button>
)}
<Typography sx={{ mx: 2 }}>
| Welcome, {user.username} ({user.role})
</Typography>
<Button color="inherit" onClick={handleLogout}>
Logout
</Button>
</Box>
)}
</Toolbar>
</AppBar>
<Container component="main" maxWidth={false} sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
{children}
</Container>
</Box>
);
};
export default Layout;

View File

@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import {
Button, TextField, Dialog, DialogTitle, DialogContent, DialogActions,
CircularProgress, Alert
} from '@mui/material';
const NewMeetingDialog = ({ open, onClose, onCreate }) => {
const [topic, setTopic] = useState('');
const [meetingDate, setMeetingDate] = useState(new Date().toISOString().split('T')[0]); // Defaults to today
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCreate = async () => {
if (!topic || !meetingDate) {
setError('Both topic and date are required.');
return;
}
setError('');
setLoading(true);
try {
// The onCreate prop is expected to be an async function
// that returns the newly created meeting.
await onCreate(topic, meetingDate);
handleClose(); // Close the dialog on success
} catch (err) {
setError(err.message || 'Failed to create meeting.');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (loading) return; // Prevent closing while loading
setTopic('');
setMeetingDate(new Date().toISOString().split('T')[0]);
setError('');
onClose(); // Call the parent's onClose handler
};
return (
<Dialog open={open} onClose={handleClose} fullWidth maxWidth="xs">
<DialogTitle>Create a New Meeting</DialogTitle>
<DialogContent>
{error && <Alert severity="error" sx={{ mb: 2, mt: 1 }}>{error}</Alert>}
<TextField
autoFocus
margin="dense"
id="topic"
label="Meeting Topic"
type="text"
fullWidth
variant="standard"
value={topic}
onChange={(e) => setTopic(e.target.value)}
sx={{ mt: 2 }}
/>
<TextField
margin="dense"
id="meetingDate"
label="Meeting Date"
type="date"
fullWidth
variant="standard"
value={meetingDate}
onChange={(e) => setMeetingDate(e.target.value)}
InputLabelProps={{
shrink: true,
}}
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>Cancel</Button>
<Button onClick={handleCreate} variant="contained" disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Create'}
</Button>
</DialogActions>
</Dialog>
);
};
export default NewMeetingDialog;

View File

@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import {
Box, Button, Typography, Paper, TextField, Select, MenuItem, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogActions, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow
} from '@mui/material';
import { getMeetings, createMeeting, batchCreateActionItems } from '../services/api'; // Only necessary APIs
const TextProcessingTools = ({
textContent,
summary,
actionItems,
onGenerateSummary,
onPreviewActions,
onActionItemChange
}) => {
const [meetings, setMeetings] = useState([]);
const [users, setUsers] = useState([]); // Assuming users are needed for dropdown
const [isMeetingDialogOpen, setIsMeetingDialogOpen] = useState(false);
const [associationType, setAssociationType] = useState('existing');
const [selectedMeetingId, setSelectedMeetingId] = useState('');
const [newMeetingTopic, setNewMeetingTopic] = useState('');
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const fetchDropdownData = async () => {
try {
const meetingsRes = await getMeetings();
setMeetings(meetingsRes.data);
if (meetingsRes.data.length > 0) {
setSelectedMeetingId(meetingsRes.data[0].id);
}
} catch (err) { console.error('Could not fetch meetings for dropdown.'); }
};
fetchDropdownData();
}, []);
const handleInitiateSave = () => {
if (!actionItems || !Array.isArray(actionItems) || actionItems.length === 0) {
setError('No valid action items to save.');
return;
}
setError('');
setIsMeetingDialogOpen(true);
};
const handleConfirmSave = async () => {
let meetingIdToSave = selectedMeetingId;
if (associationType === 'new') {
if (!newMeetingTopic) { setError('New meeting topic is required.'); return; }
try {
const { data: newMeeting } = await createMeeting(newMeetingTopic, new Date().toISOString());
meetingIdToSave = newMeeting.id;
} catch (err) { setError('Failed to create new meeting.'); return; }
}
if (!meetingIdToSave) { setError('A meeting must be selected or created.'); return; }
setSaveLoading(true); setError('');
try {
const itemsToSave = actionItems.map(({ tempId, owner, duedate, ...rest }) => rest);
await batchCreateActionItems(meetingIdToSave, itemsToSave);
setIsMeetingDialogOpen(false);
alert('Action Items saved successfully!');
// Optionally, clear items after save by calling a prop function from parent
} catch (err) { setError(err.response?.data?.error || 'Failed to save action items.'); }
finally { setSaveLoading(false); }
};
return (
<Box sx={{ mt: 1 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Box sx={{display: 'flex', gap: 2, mb: 2}}>
<Button onClick={onGenerateSummary} disabled={!textContent} variant="outlined">Generate Summary</Button>
<Button onClick={onPreviewActions} disabled={!textContent} variant="outlined">Generate Action Items</Button>
</Box>
{summary && <Paper elevation={2} sx={{ p: 2, mb: 2 }}><Typography variant="h6">Summary</Typography><TextField fullWidth multiline rows={6} value={summary} variant="outlined" sx={{mt:1}}/></Paper>}
{actionItems && actionItems.length > 0 && (
<Paper elevation={2} sx={{ p: 2 }}>
<Typography variant="h6">Review and Edit Action Items</Typography>
<TableContainer component={Paper} sx={{ mt: 2 }}>
<Table size="small">
<TableHead><TableRow><TableCell>Context</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell></TableRow></TableHead>
<TableBody>{actionItems.map(item => (
<TableRow key={item.tempId}>
<TableCell><TextField variant="standard" fullWidth value={item.item || ''} onChange={e => onActionItemChange(item.tempId, 'item', e.target.value)}/></TableCell>
<TableCell><TextField variant="standard" fullWidth value={item.action || ''} onChange={e => onActionItemChange(item.tempId, 'action', e.target.value)}/></TableCell>
<TableCell><TextField variant="standard" fullWidth value={item.owner || ''} onChange={e => onActionItemChange(item.tempId, 'owner', e.target.value)}/></TableCell>
<TableCell><TextField variant="standard" type="date" fullWidth value={item.due_date || ''} onChange={e => onActionItemChange(item.tempId, 'due_date', e.target.value)} InputLabelProps={{ shrink: true }}/></TableCell>
</TableRow>
))}</TableBody>
</Table>
</TableContainer>
<Box mt={2} display="flex" justifyContent="flex-end">
<Button onClick={handleInitiateSave} disabled={saveLoading} variant="contained" color="primary">Save All Action Items</Button>
</Box>
</Paper>
)}
<Dialog open={isMeetingDialogOpen} onClose={() => setIsMeetingDialogOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Associate with a Meeting</DialogTitle>
<DialogContent>
<FormControl component="fieldset" sx={{mt:1}}><Select size="small" value={associationType} onChange={e => setAssociationType(e.target.value)}><MenuItem value="existing">Existing Meeting</MenuItem><MenuItem value="new">New Meeting</MenuItem></Select></FormControl>
{associationType === 'existing' ? <FormControl fullWidth sx={{mt:2}}><InputLabel>Select Meeting</InputLabel><Select value={selectedMeetingId} label="Select Meeting" onChange={e => setSelectedMeetingId(e.target.value)}>{meetings.map(m => <MenuItem key={m.id} value={m.id}>{m.topic}</MenuItem>)}</Select></FormControl> : <TextField label="New Meeting Topic" fullWidth sx={{mt:2}} value={newMeetingTopic} onChange={e => setNewMeetingTopic(e.target.value)} />}
</DialogContent>
<DialogActions>
<Button onClick={() => setIsMeetingDialogOpen(false)}>Cancel</Button>
<Button onClick={handleConfirmSave} variant="contained" disabled={saveLoading}>{saveLoading ? <CircularProgress size={24}/> : 'Confirm & Save'}</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default TextProcessingTools;

View File

@@ -0,0 +1,88 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import { jwtDecode } from 'jwt-decode';
const AuthContext = createContext(null);
const setAuthToken = token => {
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete axios.defaults.headers.common['Authorization'];
}
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) {
try {
const decoded = jwtDecode(token);
const currentTime = Date.now() / 1000;
if (decoded.exp < currentTime) {
console.log("Token expired, logging out.");
logout();
} else {
setUser({
id: decoded.sub,
role: decoded.role,
username: decoded.username
});
setAuthToken(token);
}
} catch (error) {
console.error("Invalid token on initial load");
logout();
}
}
setLoading(false);
}, [token]);
const login = async (username, password) => {
try {
const response = await axios.post('/api/login', { username, password });
const { access_token } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
const decoded = jwtDecode(access_token);
setUser({
id: decoded.sub,
role: decoded.role,
username: decoded.username
});
setAuthToken(access_token);
return { success: true };
} catch (error) {
console.error('Login failed:', error.response?.data?.msg || error.message);
return { success: false, message: error.response?.data?.msg || 'Login failed' };
}
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
setAuthToken(null);
};
const value = {
user,
token,
loading,
login,
logout
};
return (
<AuthContext.Provider value={value}>
{!loading && children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
return useContext(AuthContext);
};

27
frontend/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
/* Reset default styles and ensure full-width/height layout */
html, body, #root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* The rest of the default styles can be kept or removed as needed */
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}

16
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { BrowserRouter } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Box, Typography, Paper, CircularProgress, Alert,
TextField, Button, Select, MenuItem, FormControl, InputLabel, Grid, Link
} from '@mui/material';
import { getActionItemDetails, updateActionItem, uploadAttachment } from '../services/api';
const ActionItemPage = () => {
const { actionId } = useParams();
const navigate = useNavigate();
const [actionItem, setActionItem] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [attachment, setAttachment] = useState(null);
useEffect(() => {
const fetchActionItem = async () => {
setLoading(true);
try {
const data = await getActionItemDetails(actionId);
setActionItem(data);
} catch (err) {
setError(err.message || 'Could not fetch action item details.');
} finally {
setLoading(false);
}
};
fetchActionItem();
}, [actionId]);
const handleUpdate = async () => {
if (!actionItem) return;
setLoading(true);
try {
// Only send fields that are meant to be updated
const updateData = {
item: actionItem.item,
action: actionItem.action,
status: actionItem.status,
due_date: actionItem.due_date,
// owner_id is typically not changed from this screen, but could be added if needed
};
await updateActionItem(actionId, updateData);
if (attachment) {
// Note: The backend needs an endpoint to handle attachment uploads for an action item.
// This is a placeholder for that functionality.
// await uploadAttachment(actionId, attachment);
console.warn("Attachment upload functionality is not yet implemented on the backend.");
}
setIsEditing(false);
// Refresh data after update
const data = await getActionItemDetails(actionId);
setActionItem(data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to update action item.');
} finally {
setLoading(false);
}
};
const handleFileChange = (event) => {
setAttachment(event.target.files[0]);
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error">{error}</Alert>;
if (!actionItem) return <Alert severity="info">No action item found.</Alert>;
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom>
Action Item for: {actionItem.meeting?.topic || 'General Task'}
</Typography>
<Paper sx={{ p: 3 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label="Context/Item"
fullWidth
multiline
rows={2}
value={actionItem.item || ''}
onChange={(e) => setActionItem({ ...actionItem, item: e.target.value })}
InputProps={{ readOnly: !isEditing }}
variant={isEditing ? "outlined" : "filled"}
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Action"
fullWidth
multiline
rows={4}
value={actionItem.action || ''}
onChange={(e) => setActionItem({ ...actionItem, action: e.target.value })}
InputProps={{ readOnly: !isEditing }}
variant={isEditing ? "outlined" : "filled"}
/>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth variant={isEditing ? "outlined" : "filled"}>
<InputLabel>Status</InputLabel>
<Select
label="Status"
value={actionItem.status || 'pending'}
onChange={(e) => setActionItem({ ...actionItem, status: e.target.value })}
readOnly={!isEditing}
>
<MenuItem value="pending">Pending</MenuItem>
<MenuItem value="in_progress">In Progress</MenuItem>
<MenuItem value="completed">Completed</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Due Date"
type="date"
fullWidth
value={actionItem.due_date || ''}
onChange={(e) => setActionItem({ ...actionItem, due_date: e.target.value })}
InputProps={{ readOnly: !isEditing }}
variant={isEditing ? "outlined" : "filled"}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12}>
<Typography>Owner: {actionItem.owner?.username || 'N/A'}</Typography>
{actionItem.attachment_path && (
<Typography>
Attachment: <Link href={`/api/download/${actionItem.attachment_path.split('/').pop()}`} target="_blank" rel="noopener">Download</Link>
</Typography>
)}
</Grid>
{isEditing && (
<Grid item xs={12}>
<Button
variant="contained"
component="label"
>
Upload Attachment
<input
type="file"
hidden
onChange={handleFileChange}
/>
</Button>
{attachment && <Typography sx={{ display: 'inline', ml: 2 }}>{attachment.name}</Typography>}
</Grid>
)}
<Grid item xs={12} sx={{ mt: 2 }}>
{isEditing ? (
<Box>
<Button onClick={handleUpdate} variant="contained" color="primary" disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Save Changes'}
</Button>
<Button onClick={() => setIsEditing(false)} sx={{ ml: 2 }}>Cancel</Button>
</Box>
) : (
<Button onClick={() => setIsEditing(true)} variant="contained">Edit</Button>
)}
</Grid>
</Grid>
</Paper>
</Box>
);
};
export default ActionItemPage;

View File

@@ -0,0 +1,237 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
CircularProgress, Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField,
Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import LockResetIcon from '@mui/icons-material/LockReset';
import { getUsers, createUser, deleteUser, changeUserPassword } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
const PasswordChangeDialog = ({ open, onClose, onConfirm, user }) => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleConfirm = () => {
if (!password) {
setError('Password cannot be empty.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
onConfirm(user.id, password);
};
const handleClose = () => {
setPassword('');
setConfirmPassword('');
setError('');
onClose();
};
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle>Change Password for {user?.username}</DialogTitle>
<DialogContent>
{error && <Alert severity="error" sx={{ mb: 2, mt: 1 }}>{error}</Alert>}
<TextField autoFocus margin="dense" name="password" label="New Password" type="password" fullWidth variant="standard" value={password} onChange={(e) => setPassword(e.target.value)} />
<TextField margin="dense" name="confirmPassword" label="Confirm New Password" type="password" fullWidth variant="standard" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleConfirm}>Confirm Change</Button>
</DialogActions>
</Dialog>
);
};
const AdminPage = () => {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isUserDialogOpen, setIsUserDialogOpen] = useState(false);
const [newUser, setNewUser] = useState({ username: '', password: '', role: 'user' });
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const data = await getUsers();
setUsers(data);
setError('');
} catch (err) {
setError(err.response?.data?.msg || 'Could not fetch users.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const handleOpenUserDialog = () => setIsUserDialogOpen(true);
const handleCloseUserDialog = () => {
setIsUserDialogOpen(false);
setNewUser({ username: '', password: '', role: 'user' }); // Reset form
};
const handleOpenPasswordDialog = (user) => {
setSelectedUser(user);
setIsPasswordDialogOpen(true);
};
const handleClosePasswordDialog = () => {
setSelectedUser(null);
setIsPasswordDialogOpen(false);
};
const handleCreateUser = async () => {
if (!newUser.username || !newUser.password) {
setError('Username and password are required.');
return;
}
try {
await createUser(newUser);
handleCloseUserDialog();
fetchUsers(); // Refetch the list
setSuccess('User created successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to create user.');
}
};
const handleDeleteUser = async (userId) => {
if (window.confirm('Are you sure you want to delete this user? This will disassociate them from all meetings and action items.')) {
try {
await deleteUser(userId);
fetchUsers(); // Refresh the list
setSuccess('User deleted successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete user.');
}
}
};
const handlePasswordChange = async (userId, newPassword) => {
try {
await changeUserPassword(userId, newPassword);
handleClosePasswordDialog();
setSuccess('Password updated successfully.');
} catch (err) {
// The dialog will show specific errors, this is a fallback.
setError(err.response?.data?.error || 'Failed to change password.');
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setNewUser(prev => ({ ...prev, [name]: value }));
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" gutterBottom>
User Management
</Typography>
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={handleOpenUserDialog}>
New User
</Button>
</Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess('')}>{success}</Alert>}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Username</TableCell>
<TableCell>Role</TableCell>
<TableCell>Created At</TableCell>
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.role}</TableCell>
<TableCell>{new Date(user.created_at).toLocaleString()}</TableCell>
<TableCell align="center" sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Tooltip title="Change Password">
<IconButton color="primary" onClick={() => handleOpenPasswordDialog(user)}>
<LockResetIcon />
</IconButton>
</Tooltip>
{/* Prevent admin from deleting themselves, show placeholder otherwise */}
{String(currentUser.id) !== String(user.id) ? (
<Tooltip title="Delete User">
<IconButton color="error" onClick={() => handleDeleteUser(user.id)}>
<DeleteIcon />
</IconButton>
</Tooltip>
) : (
// Invisible placeholder to maintain alignment. An IconButton is ~40px wide.
<Box sx={{ width: 40, height: 40 }} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* Create User Dialog */}
<Dialog open={isUserDialogOpen} onClose={handleCloseUserDialog}>
<DialogTitle>Create a New User</DialogTitle>
<DialogContent>
<TextField autoFocus margin="dense" name="username" label="Username" type="text" fullWidth variant="standard" value={newUser.username} onChange={handleInputChange} />
<TextField margin="dense" name="password" label="Password" type="password" fullWidth variant="standard" value={newUser.password} onChange={handleInputChange} />
<FormControl fullWidth margin="dense" variant="standard">
<InputLabel>Role</InputLabel>
<Select name="role" value={newUser.role} onChange={handleInputChange}>
<MenuItem value="user">User</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseUserDialog}>Cancel</Button>
<Button onClick={handleCreateUser}>Create</Button>
</DialogActions>
</Dialog>
{/* Change Password Dialog */}
{selectedUser && (
<PasswordChangeDialog
open={isPasswordDialogOpen}
onClose={handleClosePasswordDialog}
onConfirm={handlePasswordChange}
user={selectedUser}
/>
)}
</Box>
);
};
export default AdminPage;

View File

@@ -0,0 +1,291 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
CircularProgress, Alert, Button, TextField, TableSortLabel, Select, MenuItem, FormControl,
InputLabel, Chip, OutlinedInput, IconButton
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import DeleteIcon from '@mui/icons-material/Delete';
import { getMeetings, updateMeeting, deleteMeeting, createMeeting } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import NewMeetingDialog from '../components/NewMeetingDialog';
// Helper function for sorting
function descendingComparator(a, b, orderBy) {
if (b[orderBy] < a[orderBy]) return -1;
if (b[orderBy] > a[orderBy]) return 1;
return 0;
}
function getComparator(order, orderBy) {
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy)
: (a, b) => -descendingComparator(a, b, orderBy);
}
const DashboardPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
const [meetings, setMeetings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [deletingIds, setDeletingIds] = useState(new Set());
const [isModalOpen, setIsModalOpen] = useState(false);
// State for filtering and searching
const [topicSearch, setTopicSearch] = useState('');
const [ownerSearch, setOwnerSearch] = useState('');
const [statusFilter, setStatusFilter] = useState([]);
// State for sorting
const [order, setOrder] = useState('asc');
const [orderBy, setOrderBy] = useState('meeting_date');
const fetchMeetings = useCallback(async () => {
try {
const data = await getMeetings();
setMeetings(data);
} catch (err) {
setError('Could not fetch meetings.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchMeetings();
}, [fetchMeetings]);
const handleCreateMeeting = async (topic, meetingDate) => {
try {
const newMeeting = await createMeeting(topic, new Date(meetingDate).toISOString());
setIsModalOpen(false);
navigate(`/meeting/${newMeeting.id}`);
} catch (err) {
throw new Error(err.response?.data?.error || "Failed to create meeting.");
}
};
const handleSortRequest = (property) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const handleStatusChange = async (meetingId, newStatus) => {
try {
await updateMeeting(meetingId, { status: newStatus });
fetchMeetings();
} catch (err) {
setError(`Failed to update status for meeting ${meetingId}.`);
}
};
const handleDelete = async (meetingId) => {
if (window.confirm('Are you sure you want to delete this meeting?')) {
setDeletingIds(prev => new Set(prev).add(meetingId));
try {
await deleteMeeting(meetingId);
fetchMeetings();
} catch (err) {
setError(`Failed to delete meeting ${meetingId}.`);
} finally {
setDeletingIds(prev => {
const newSet = new Set(prev);
newSet.delete(meetingId);
return newSet;
});
}
}
};
const uniqueStatuses = useMemo(() => {
const statuses = new Set(meetings.map(m => m.status));
return Array.from(statuses);
}, [meetings]);
const filteredAndSortedMeetings = useMemo(() => {
let filtered = meetings.filter(meeting => {
const topicMatch = meeting.topic.toLowerCase().includes(topicSearch.toLowerCase());
const ownerMatch = meeting.owner_name ? meeting.owner_name.toLowerCase().includes(ownerSearch.toLowerCase()) : ownerSearch === '';
const statusMatch = statusFilter.length === 0 || statusFilter.includes(meeting.status);
return topicMatch && ownerMatch && statusMatch;
});
return filtered.sort(getComparator(order, orderBy));
}, [meetings, topicSearch, ownerSearch, statusFilter, order, orderBy]);
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
}
const headCells = [
{ id: 'topic', label: 'Topic' },
{ id: 'owner_name', label: 'Owner' },
{ id: 'meeting_date', label: 'Meeting Date' },
{ id: 'status', label: 'Status' },
{ id: 'action_item_count', label: 'Action Items' },
{ id: 'created_at', label: 'Created At' },
];
const statusColorMap = {
'Completed': 'success',
'In Progress': 'info',
'To Do': 'warning',
'Failed': 'error',
};
const allPossibleStatuses = ['To Do', 'In Progress', 'Completed', 'Failed'];
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" gutterBottom>Dashboard</Typography>
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsModalOpen(true)}>
New Meeting
</Button>
</Box>
<NewMeetingDialog
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
onCreate={handleCreateMeeting}
/>
{error && <Alert severity="error" sx={{ mb: 2, mt: 2 }} onClose={() => setError('')}>{error}</Alert>}
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 2, flexDirection: { xs: 'column', md: 'row' } }}>
<TextField
label="Search by Topic"
variant="outlined"
value={topicSearch}
onChange={(e) => setTopicSearch(e.target.value)}
sx={{ flex: '1 1 40%' }}
/>
<TextField
label="Search by Owner"
variant="outlined"
value={ownerSearch}
onChange={(e) => setOwnerSearch(e.target.value)}
sx={{ flex: '1 1 30%' }}
/>
<FormControl sx={{ flex: '1 1 30%' }}>
<InputLabel>Filter by Status</InputLabel>
<Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
input={<OutlinedInput label="Filter by Status" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => <Chip key={value} label={value} />)}
</Box>
)}
>
{uniqueStatuses.map((status) => (
<MenuItem key={status} value={status}>{status}</MenuItem>
))}
</Select>
</FormControl>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
{headCells.map((headCell) => (
<TableCell key={headCell.id} sortDirection={orderBy === headCell.id ? order : false}>
<TableSortLabel
active={orderBy === headCell.id}
direction={orderBy === headCell.id ? order : 'asc'}
onClick={() => handleSortRequest(headCell.id)}
>
{headCell.label}
</TableSortLabel>
</TableCell>
))}
<TableCell align="center">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredAndSortedMeetings.map((meeting) => {
const isOwnerOrAdmin = user && (user.role === 'admin' || String(user.id) === String(meeting.created_by_id));
const isDeleting = deletingIds.has(meeting.id);
let taipeiTime = 'N/A';
if (meeting.created_at) {
taipeiTime = new Date(meeting.created_at).toLocaleString('zh-TW', {
timeZone: 'Asia/Taipei',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
}
return (
<TableRow key={meeting.id} hover>
<TableCell>{meeting.topic}</TableCell>
<TableCell>{meeting.owner_name || 'N/A'}</TableCell>
<TableCell>{meeting.meeting_date ? new Date(meeting.meeting_date).toLocaleDateString() : 'N/A'}</TableCell>
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={meeting.status}
onChange={(e) => handleStatusChange(meeting.id, e.target.value)}
readOnly={!isOwnerOrAdmin}
IconComponent={!isOwnerOrAdmin ? () => null : undefined}
renderValue={(selected) => (
<Chip
label={selected}
color={statusColorMap[selected] || 'default'}
size="small"
/>
)}
sx={{
'& .MuiOutlinedInput-notchedOutline': { border: 'none' },
backgroundColor: 'transparent',
}}
>
{allPossibleStatuses.map(status => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell>{meeting.action_item_count}</TableCell>
<TableCell>{taipeiTime}</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Button size="small" onClick={() => navigate(`/meeting/${meeting.id}`)}>
View Details
</Button>
{isOwnerOrAdmin ? (
<IconButton size="small" onClick={() => handleDelete(meeting.id)} color="error" disabled={isDeleting}>
{isDeleting ? <CircularProgress size={20} /> : <DeleteIcon />}
</IconButton>
) : (
// Invisible placeholder to maintain alignment
<Box sx={{ width: 34, height: 34 }} />
)}
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Button, TextField, Container, Typography, Box, Alert, Grid, Link, Avatar, Card, CardContent, CircularProgress
} from '@mui/material';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import { register } from '../services/api';
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [isRegister, setIsRegister] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
const handleLogin = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
const { success, message } = await login(username, password);
if (success) {
navigate(from, { replace: true });
} else {
setError(message || 'Failed to log in');
}
setLoading(false);
};
const handleRegister = async (e) => {
e.preventDefault();
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setError('');
setSuccess('');
setLoading(true);
try {
await register(username, password);
setSuccess('Account created successfully! Please log in.');
setIsRegister(false); // Switch back to login view
setUsername(''); // Clear fields
setPassword('');
setConfirmPassword('');
} catch (err) {
setError(err.response?.data?.error || 'Failed to create account.');
}
setLoading(false);
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Card sx={{ padding: 2, width: '100%', mt: 3 }}>
<CardContent sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
src="/LOGO.png"
sx={{ m: 1, bgcolor: 'secondary.main', width: 56, height: 56 }}
imgProps={{ style: { objectFit: 'contain' } }}
>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
AI Meeting Assistant
</Typography>
<Typography component="h2" variant="subtitle1" sx={{ mt: 1 }}>
{isRegister ? 'Create Account' : 'Sign In'}
</Typography>
<Box component="form" onSubmit={isRegister ? handleRegister : handleLogin} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Username"
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete={isRegister ? "new-password" : "current-password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{isRegister && (
<TextField
margin="normal"
required
fullWidth
name="confirmPassword"
label="Confirm Password"
type="password"
id="confirmPassword"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
)}
{error && <Alert severity="error" sx={{ width: '100%', mt: 2 }}>{error}</Alert>}
{success && <Alert severity="success" sx={{ width: '100%', mt: 2 }}>{success}</Alert>}
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : (isRegister ? 'Create Account' : 'Sign In')}
</Button>
<Grid container justifyContent="flex-end">
<Grid item>
<Link href="#" variant="body2" onClick={() => {
setIsRegister(!isRegister);
setError('');
setSuccess('');
}}>
{isRegister ? "Already have an account? Sign In" : "Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
</Box>
</Container>
);
};
export default LoginPage;

View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Box, Typography, Paper, CircularProgress, Alert, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Select, MenuItem, FormControl, InputLabel,
Grid, Card, CardContent
} from '@mui/material';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import CancelIcon from '@mui/icons-material/Cancel';
import PreviewIcon from '@mui/icons-material/Preview';
import DownloadIcon from '@mui/icons-material/Download';
import {
getMeetingDetails, updateMeeting, summarizeMeeting,
getActionItemsForMeeting, createActionItem, updateActionItem, deleteActionItem, getAllUsers,
previewActionItems, batchSaveActionItems, pollTaskStatus, uploadActionItemAttachment, downloadFile
} from '../services/api';
import { useAuth } from '../contexts/AuthContext';
const MeetingDetailPage = () => {
const { meetingId } = useParams();
const { user: currentUser } = useAuth();
const [meeting, setMeeting] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditingTranscript, setIsEditingTranscript] = useState(false);
const [isEditingSummary, setIsEditingSummary] = useState(false);
const [editData, setEditData] = useState({});
const [summaryTask, setSummaryTask] = useState(null);
const [actionItems, setActionItems] = useState([]);
const [users, setUsers] = useState([]);
const [editingActionItemId, setEditingActionItemId] = useState(null);
const [editActionItemData, setEditActionItemData] = useState({});
const [attachmentFile, setAttachmentFile] = useState(null);
const [isAddActionItemOpen, setIsAddActionItemOpen] = useState(false);
const [newActionItem, setNewActionItem] = useState({ action: '', owner_id: '', due_date: '', item: '' });
const [previewedItems, setPreviewedItems] = useState([]);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const fetchMeetingData = useCallback(async () => {
try {
setLoading(true);
const meetingRes = await getMeetingDetails(meetingId);
setMeeting(meetingRes);
setEditData(meetingRes);
const itemsRes = await getActionItemsForMeeting(meetingId);
setActionItems(itemsRes);
} catch (err) {
setError('Failed to fetch meeting data.');
} finally {
setLoading(false);
}
}, [meetingId]);
useEffect(() => {
fetchMeetingData();
}, [fetchMeetingData]);
useEffect(() => {
const fetchUsers = async () => {
try {
const usersRes = await getAllUsers();
setUsers(usersRes);
} catch (err) { console.warn('Could not fetch user list.'); }
};
fetchUsers();
}, []);
useEffect(() => {
let intervalId = null;
if (summaryTask && (summaryTask.state === 'PENDING' || summaryTask.state === 'PROGRESS')) {
intervalId = setInterval(async () => {
try {
const updatedTask = await pollTaskStatus(summaryTask.status_url);
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
clearInterval(intervalId);
setSummaryTask(null);
if (updatedTask.state === 'SUCCESS' && updatedTask.info.summary) {
// Directly update the summary instead of refetching everything
setMeeting(prevMeeting => ({...prevMeeting, summary: updatedTask.info.summary}));
setEditData(prevEditData => ({...prevEditData, summary: updatedTask.info.summary}));
} else {
// Fallback to refetch if something goes wrong or task fails
fetchMeetingData();
}
}
} catch (err) {
console.error('Polling failed:', err);
clearInterval(intervalId);
setSummaryTask(null);
}
}, 2000);
}
return () => clearInterval(intervalId);
}, [summaryTask, fetchMeetingData]);
const handleSave = async (field, value) => {
try {
await updateMeeting(meetingId, { [field]: value });
fetchMeetingData();
return true;
} catch (err) {
setError(`Failed to save ${field}.`);
return false;
}
};
const handleSaveTranscript = async () => {
if (await handleSave('transcript', editData.transcript)) setIsEditingTranscript(false);
};
const handleSaveSummary = async () => {
if (await handleSave('summary', editData.summary)) setIsEditingSummary(false);
};
const handleGenerateSummary = async () => {
try {
const taskInfo = await summarizeMeeting(meetingId);
setSummaryTask({ ...taskInfo, state: 'PENDING' });
} catch (err) {
setError('Failed to start summary generation.');
}
};
const handlePreviewActionItems = async () => {
const textToPreview = meeting?.summary || meeting?.transcript;
if (!textToPreview) return;
setIsPreviewLoading(true);
try {
const result = await previewActionItems(textToPreview);
setPreviewedItems(result.items || []);
} catch (err) {
setError('Failed to generate action item preview.');
} finally {
setIsPreviewLoading(false);
}
};
const handleBatchSave = async () => {
if (previewedItems.length === 0) return;
try {
await batchSaveActionItems(meetingId, previewedItems);
setPreviewedItems([]);
fetchMeetingData();
} catch (err) {
setError('Failed to save action items.');
}
};
const handleEditActionItemClick = (item) => { setEditingActionItemId(item.id); setEditActionItemData({ ...item, due_date: item.due_date || '' }); };
const handleCancelActionItemClick = () => { setEditingActionItemId(null); setAttachmentFile(null); };
const handleSaveActionItemClick = async (id) => {
try {
await updateActionItem(id, editActionItemData);
if (attachmentFile) await uploadActionItemAttachment(id, attachmentFile);
setEditingActionItemId(null);
setAttachmentFile(null);
fetchMeetingData();
} catch (err) {
setError('Failed to save action item.');
}
};
const handleDeleteActionItemClick = async (id) => { if (window.confirm('Are you sure?')) { try { await deleteActionItem(id); fetchMeetingData(); } catch (err) { setError('Failed to delete action item.'); }}};
const handleAddActionItemSave = async () => { if (!newActionItem.action) { setError('Action is required.'); return; } try { const newItem = await createActionItem({ ...newActionItem, meeting_id: meetingId }); if (attachmentFile) { await uploadActionItemAttachment(newItem.id, attachmentFile); } setIsAddActionItemOpen(false); setNewActionItem({ action: '', owner_id: '', due_date: '', item: '' }); setAttachmentFile(null); fetchMeetingData(); } catch (err) { setError('Failed to create action item.'); }};
const handleFileChange = (e) => { if (e.target.files[0]) setAttachmentFile(e.target.files[0]); };
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}><CircularProgress /></Box>;
if (!meeting) return <Alert severity="error">Meeting not found.</Alert>;
const canManageMeeting = currentUser && meeting && (currentUser.role === 'admin' || String(currentUser.id) === String(meeting.created_by_id));
return (
<Box sx={{ p: 3 }}>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Grid container spacing={3} direction="column">
{/* Transcript Card (Full Width) */}
<Grid item xs={12}>
<Card>
<CardContent>
{isEditingTranscript ? (
<>
<TextField label="Transcript" multiline rows={15} fullWidth value={editData.transcript || ''} onChange={e => setEditData({...editData, transcript: e.target.value})} />
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveTranscript}>Save Transcript</Button><Button variant="outlined" onClick={() => { setIsEditingTranscript(false); setEditData(meeting); }}>Cancel</Button></Box>
</>
) : (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">{meeting.topic}</Typography>
{canManageMeeting && <Button startIcon={<EditIcon />} onClick={() => setIsEditingTranscript(true)}>Edit Transcript</Button>}
</Box>
<Typography variant="body1" color="text.secondary">Status: {meeting.status}</Typography>
<Typography variant="h6" sx={{ mt: 2 }}>Transcript</Typography>
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: '300px', maxHeight: 400, overflow: 'auto' }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.transcript || 'No transcript provided. Edit to add one.'}</Typography>
</Paper>
</>
)}
</CardContent>
</Card>
</Grid>
{/* AI Tools Card (Full Width) */}
<Grid item xs={12}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h5">AI Tools</Typography>
{(canManageMeeting && !isEditingSummary) && <Button startIcon={<EditIcon />} onClick={() => setIsEditingSummary(true)}>Edit Summary</Button>}
</Box>
{canManageMeeting && <Button variant="contained" sx={{ mt: 2 }} onClick={handleGenerateSummary} disabled={!meeting.transcript || summaryTask || isEditingSummary}>{summaryTask ? 'Generating...' : 'Generate Summary'}</Button>}
<Typography variant="h6" sx={{ mt: 2 }}>Summary</Typography>
{isEditingSummary ? (
<>
<TextField label="Summary" multiline rows={8} fullWidth value={editData.summary || ''} onChange={e => setEditData({...editData, summary: e.target.value})} sx={{ mt: 1 }} />
<Box sx={{ mt: 2, display: 'flex', gap: 1 }}><Button variant="contained" onClick={handleSaveSummary}>Save Summary</Button><Button variant="outlined" onClick={() => { setIsEditingSummary(false); setEditData(meeting); }}>Cancel</Button></Box>
</>
) : (
<Paper variant="outlined" sx={{ p: 2, mt: 1, minHeight: 215, overflow: 'auto', position: 'relative' }}>
{summaryTask && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
<CircularProgress /><Typography sx={{ ml: 2 }}>Generating...</Typography>
</Box>
)}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>{meeting.summary || (summaryTask ? '' : 'No summary generated yet.')}</Typography>
</Paper>
)}
{canManageMeeting && (
<Box sx={{ mt: 3 }}>
<Button variant="outlined" startIcon={<PreviewIcon />} onClick={handlePreviewActionItems} disabled={isPreviewLoading || isEditingSummary || (!meeting.summary && !meeting.transcript)}>{isPreviewLoading ? <CircularProgress size={24} /> : "Preview Action Items"}</Button>
{previewedItems.length > 0 && (
<Box>
<TableContainer component={Paper} sx={{ mt: 2 }}><Table size="small">
<TableHead><TableRow><TableCell>Context/Item</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell></TableRow></TableHead>
<TableBody>{previewedItems.map((item, index) => (<TableRow key={index}><TableCell>{item.item}</TableCell><TableCell>{item.action}</TableCell><TableCell>{item.owner}</TableCell><TableCell>{item.due_date}</TableCell></TableRow>))}</TableBody>
</Table></TableContainer>
<Button variant="contained" sx={{ mt: 2 }} onClick={handleBatchSave}>Save All to List</Button>
</Box>
)}
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Action Items List Card (Full Width) */}
<Grid item xs={12}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Action Items</Typography>
<Button variant="contained" startIcon={<AddCircleOutlineIcon />} onClick={() => setIsAddActionItemOpen(true)}>Add Manually</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead><TableRow><TableCell>Context</TableCell><TableCell>Action</TableCell><TableCell>Owner</TableCell><TableCell>Due Date</TableCell><TableCell>Status</TableCell><TableCell>Attachment</TableCell><TableCell align="center">Actions</TableCell></TableRow></TableHead>
<TableBody>
{actionItems.map((item) => {
const isEditing = editingActionItemId === item.id;
const canEditItem = currentUser && (currentUser.role === 'admin' || String(currentUser.id) === String(item.owner_id));
return (
<TableRow key={item.id}>
<TableCell>{isEditing ? <TextField name="item" defaultValue={item.item} onChange={e => setEditActionItemData({...editActionItemData, item: e.target.value})} fullWidth /> : item.item}</TableCell>
<TableCell>{isEditing ? <TextField name="action" defaultValue={item.action} onChange={e => setEditActionItemData({...editActionItemData, action: e.target.value})} fullWidth /> : item.action}</TableCell>
<TableCell>{isEditing ? <FormControl fullWidth><Select name="owner_id" value={editActionItemData.owner_id || ''} onChange={e => setEditActionItemData({...editActionItemData, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl> : item.owner_name}</TableCell>
<TableCell>{isEditing ? <TextField name="due_date" type="date" defaultValue={editActionItemData.due_date} onChange={e => setEditActionItemData({...editActionItemData, due_date: e.target.value})} InputLabelProps={{ shrink: true }} fullWidth /> : item.due_date}</TableCell>
<TableCell>{isEditing ? <Select name="status" value={editActionItemData.status} onChange={e => setEditActionItemData({...editActionItemData, status: e.target.value})} fullWidth><MenuItem value="pending">Pending</MenuItem><MenuItem value="in_progress">In Progress</MenuItem><MenuItem value="completed">Completed</MenuItem></Select> : item.status}</TableCell>
<TableCell>
{isEditing ? <Button component="label" size="small">Upload File<input type="file" hidden onChange={handleFileChange} /></Button> : (item.attachment_path && <IconButton onClick={() => downloadFile(item.attachment_path)}><DownloadIcon /></IconButton>)}
{isEditing && attachmentFile && <Typography variant="caption">{attachmentFile.name}</Typography>}
</TableCell>
<TableCell align="center">
{isEditing ? <Box><IconButton onClick={() => handleSaveActionItemClick(item.id)}><SaveIcon /></IconButton><IconButton onClick={handleCancelActionItemClick}><CancelIcon /></IconButton></Box> : <Box>{canEditItem && <IconButton onClick={() => handleEditActionItemClick(item)}><EditIcon /></IconButton>}{canManageMeeting && <IconButton onClick={() => handleDeleteActionItemClick(item.id)}><DeleteIcon /></IconButton>}</Box>}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
</Grid>
</Grid>
<Dialog open={isAddActionItemOpen} onClose={() => setIsAddActionItemOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Add New Action Item</DialogTitle>
<DialogContent>
<TextField label="Context/Item (Optional)" fullWidth margin="dense" value={newActionItem.item} onChange={e => setNewActionItem({...newActionItem, item: e.target.value})} />
<TextField label="Action (Required)" fullWidth margin="dense" required value={newActionItem.action} onChange={e => setNewActionItem({...newActionItem, action: e.target.value})} />
<FormControl fullWidth margin="dense"><InputLabel>Owner</InputLabel><Select label="Owner" value={newActionItem.owner_id} onChange={e => setNewActionItem({...newActionItem, owner_id: e.target.value})}><MenuItem value=""><em>Unassigned</em></MenuItem>{users.map(u => <MenuItem key={u.id} value={u.id}>{u.username}</MenuItem>)}</Select></FormControl>
<TextField label="Due Date" type="date" fullWidth margin="dense" InputLabelProps={{ shrink: true }} value={newActionItem.due_date} onChange={e => setNewActionItem({...newActionItem, due_date: e.target.value})} />
<Button component="label" sx={{ mt: 1 }}>Upload Attachment<input type="file" hidden onChange={handleFileChange} /></Button>
{attachmentFile && <Typography variant="caption" sx={{ ml: 1 }}>{attachmentFile.name}</Typography>}
</DialogContent>
<DialogActions><Button onClick={() => setIsAddActionItemOpen(false)}>Cancel</Button><Button onClick={handleAddActionItemSave} variant="contained">Save</Button></DialogActions>
</Dialog>
</Box>
);
};
export default MeetingDetailPage;

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box, Typography, Paper, Button, CircularProgress, Alert, Grid, Card, CardContent, Chip, LinearProgress, TextField, Select, MenuItem, FormControl, InputLabel, IconButton, Tooltip
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadIcon from '@mui/icons-material/Download';
import {
extractAudio,
transcribeAudio,
translateText,
pollTaskStatus,
stopTask,
downloadFile
} from '../services/api';
const TaskMonitor = ({ task, onStop, title, children }) => {
if (!task) return null;
const colorMap = { PENDING: 'default', PROGRESS: 'info', SUCCESS: 'success', FAILURE: 'error', REVOKED: 'warning' };
const progress = task.info?.total ? (task.info.current / task.info.total * 100) : null;
const isRunning = task.state === 'PENDING' || task.state === 'PROGRESS';
return (
<Paper sx={{ p: 2, mt: 2, border: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="subtitle2">{title}</Typography>
{task.state && <Chip label={task.state} color={colorMap[task.state] || 'default'} size="small" />}
</Box>
{task.info?.status_msg && <Typography variant="caption" sx={{ display: 'block', mt: 1 }}>{task.info.status_msg}</Typography>}
{isRunning && !progress && <LinearProgress sx={{ mt: 1 }} />}
{progress && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1 }} />}
{task.state === 'FAILURE' && <Alert severity="error" sx={{mt:1}}>{task.info?.error || 'Task failed.'}</Alert>}
{isRunning &&
<Button size="small" color="error" variant="text" onClick={() => onStop(task.task_id)} sx={{mt: 1}}>Stop Task</Button>}
{children}
</Paper>
);
};
const ProcessingPage = () => {
const [tasks, setTasks] = useState({});
const [error, setError] = useState('');
const [copySuccess, setCopySuccess] = useState('');
const [extractFile, setExtractFile] = useState(null);
const [transcribeFile, setTranscribeFile] = useState(null);
const [transcribedText, setTranscribedText] = useState('');
const [translateFile, setTranslateFile] = useState(null);
const [translateTextContent, setTranslateTextContent] = useState('');
const [translateLang, setTranslateLang] = useState('繁體中文');
const [customLang, setCustomLang] = useState('');
const [translatedResult, setTranslatedResult] = useState('');
const handleCopyToClipboard = (text, type) => {
navigator.clipboard.writeText(text).then(() => {
setCopySuccess(type);
setTimeout(() => setCopySuccess(''), 2000);
});
};
const handleTranslateFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
setTranslateFile(file);
const reader = new FileReader();
reader.onload = (evt) => setTranslateTextContent(evt.target.result);
reader.readAsText(file);
}
};
// This function now ONLY updates the main tasks object.
const handleTaskUpdate = useCallback((key, updatedTask) => {
setTasks(prev => ({ ...prev, [key]: updatedTask }));
}, []);
// This new useEffect handles the side-effects of a task completing.
useEffect(() => {
const transcribeTask = tasks.transcribe;
if (transcribeTask?.state === 'SUCCESS' && transcribeTask.info?.content) {
setTranscribedText(transcribeTask.info.content);
}
const translateTask = tasks.translate;
if (translateTask?.state === 'SUCCESS' && translateTask.info?.content) {
setTranslatedResult(translateTask.info.content);
}
}, [tasks]);
useEffect(() => {
const intervalIds = Object.entries(tasks).map(([key, task]) => {
if (task && (task.state === 'PENDING' || task.state === 'PROGRESS')) {
const intervalId = setInterval(async () => {
try {
const updatedTask = await pollTaskStatus(task.status_url);
// Pass the full task object to avoid stale closures
handleTaskUpdate(key, { ...task, ...updatedTask });
if (['SUCCESS', 'FAILURE', 'REVOKED'].includes(updatedTask.state)) {
clearInterval(intervalId);
}
} catch (err) {
handleTaskUpdate(key, { ...task, state: 'FAILURE', info: { ...task.info, error: 'Polling failed.' } });
clearInterval(intervalId);
}
}, 2000);
return intervalId;
}
return null;
}).filter(Boolean);
return () => intervalIds.forEach(clearInterval);
}, [tasks, handleTaskUpdate]);
const handleStartTask = async (key, taskFn, ...args) => {
setError('');
setTasks(prev => ({ ...prev, [key]: { state: 'PENDING', info: { status_msg: 'Initializing...' } } }));
try {
const result = await taskFn(...args);
setTasks(prev => ({ ...prev, [key]: { ...prev[key], task_id: result.task_id, status_url: result.status_url, state: 'PENDING' } }));
} catch (err) {
const errorMsg = err.response?.data?.error || `Failed to start ${key} task.`;
setError(errorMsg);
setTasks(prev => ({ ...prev, [key]: { state: 'FAILURE', info: { error: errorMsg } } }));
}
};
const handleStopTask = async (taskId) => {
if (!taskId) return;
try {
await stopTask(taskId);
const taskKey = Object.keys(tasks).find(k => tasks[k].task_id === taskId);
if (taskKey) setTasks(prev => ({ ...prev, [taskKey]: { ...prev[taskKey], state: 'REVOKED' } }));
} catch (err) {
setError('Failed to stop the task.');
}
};
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom>Processing Tools</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Grid container spacing={3}>
{/* Left Column: Extract Audio */}
<Grid item xs={12} md={3}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="h6">1. Extract Audio</Typography>
<Typography variant="body2" color="text.secondary" sx={{mb: 2}}>Extract audio track from a video file.</Typography>
<Button variant="contained" component="label" fullWidth>Upload Video<input type="file" hidden onChange={e => setExtractFile(e.target.files[0])} /></Button>
{extractFile && <Typography sx={{ mt: 1, fontStyle: 'italic', textAlign: 'center' }}>{extractFile.name}</Typography>}
<Button size="small" variant="outlined" disabled={!extractFile} onClick={() => handleStartTask('extract', extractAudio, extractFile)} sx={{ display: 'block', mt: 2, mx: 'auto' }}>Start Extraction</Button>
<TaskMonitor task={tasks.extract} onStop={handleStopTask} title="Extraction Progress">
{tasks.extract?.state === 'SUCCESS' && tasks.extract.info.download_filename &&
<Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.extract.info.download_filename)}>Download Audio</Button>
}
</TaskMonitor>
</CardContent>
</Card>
</Grid>
{/* Right Column: Transcribe and Translate */}
<Grid item xs={12} md={9}>
<Grid container spacing={3} direction="column">
{/* Top-Right: Transcribe */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6">2. Transcribe Audio to Text</Typography>
<Button variant="contained" component="label" sx={{ mt: 2 }}>Upload Audio<input type="file" hidden onChange={e => setTranscribeFile(e.target.files[0])} /></Button>
{transcribeFile && <Typography sx={{ display: 'inline', ml: 2, fontStyle: 'italic' }}>{transcribeFile.name}</Typography>}
<Button size="small" variant="outlined" disabled={!transcribeFile} onClick={() => handleStartTask('transcribe', transcribeAudio, transcribeFile)} sx={{ ml: 2, mt: 2 }}>Start Transcription</Button>
<TaskMonitor task={tasks.transcribe} onStop={handleStopTask} title="Transcription Progress" />
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
{(tasks.transcribe?.state === 'PENDING' || tasks.transcribe?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{transcribedText || 'Transcription will appear here.'}</Typography>
{transcribedText && <Tooltip title={copySuccess === 'transcribed' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(transcribedText, 'transcribed')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
</Paper>
{tasks.transcribe?.state === 'SUCCESS' && tasks.transcribe.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.transcribe.info.result_path)}>Download Transcript (.txt)</Button>}
</CardContent>
</Card>
</Grid>
{/* Bottom-Right: Translate */}
<Grid item xs={12}>
<Card>
<CardContent>
<Typography variant="h6">3. Translate Text</Typography>
<TextField label="Paste Text or Upload .txt" multiline rows={4} fullWidth value={translateTextContent} onChange={e => setTranslateTextContent(e.target.value)} sx={{ mt: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, flexWrap: 'wrap' }}>
<Button variant="contained" component="label" size="small">Upload .txt File<input type="file" accept=".txt" hidden onChange={handleTranslateFileUpload} /></Button>
{translateFile && <Typography sx={{ ml: 2, fontStyle: 'italic' }}>{translateFile.name}</Typography>}
<Box sx={{flexGrow: 1}} />
<FormControl size="small" sx={{ minWidth: 150, mr: 1, mt: { xs: 2, sm: 0 } }}>
<InputLabel>Target Language</InputLabel>
<Select value={translateLang} label="Target Language" onChange={e => setTranslateLang(e.target.value)}>
<MenuItem value="繁體中文">繁體中文</MenuItem>
<MenuItem value="简体中文">简体中文</MenuItem>
<MenuItem value="English">English</MenuItem>
<MenuItem value="Japanese">Japanese (日本語)</MenuItem>
<MenuItem value="Korean">Korean (한국어)</MenuItem>
<MenuItem value="Thai">Thai (ภาษาไทย)</MenuItem>
<MenuItem value="Vietnamese">Vietnamese (Tiếng Việt)</MenuItem>
<MenuItem value="French">French (Français)</MenuItem>
<MenuItem value="German">German (Deutsch)</MenuItem>
<MenuItem value="Spanish">Spanish (Español)</MenuItem>
<MenuItem value="Russian">Russian (Русский)</MenuItem>
<MenuItem value="Other">Other</MenuItem>
</Select>
</FormControl>
{translateLang === 'Other' && <TextField label="Specify" size="small" value={customLang} onChange={e => setCustomLang(e.target.value)} sx={{ mt: { xs: 2, sm: 0 } }} />}
</Box>
<Button size="small" variant="outlined" disabled={!translateTextContent || (translateLang === 'Other' && !customLang)} onClick={() => handleStartTask('translate', translateText, translateTextContent, translateLang === 'Other' ? customLang : translateLang)} sx={{ display: 'block', mt: 2 }}>Start Translation</Button>
<TaskMonitor task={tasks.translate} onStop={handleStopTask} title="Translation Progress" />
<Paper sx={{p:2, mt:2, minHeight: 150, overflow: 'auto', bgcolor: '#222', position: 'relative'}}>
{(tasks.translate?.state === 'PENDING' || tasks.translate?.state === 'PROGRESS') && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}><CircularProgress size={30} /></Box>}
<Typography variant="body2" sx={{whiteSpace: 'pre-wrap'}}>{translatedResult || 'Translation will appear here.'}</Typography>
{translatedResult && <Tooltip title={copySuccess === 'translated' ? 'Copied!' : 'Copy to Clipboard'}><IconButton size="small" onClick={() => handleCopyToClipboard(translatedResult, 'translated')} sx={{position: 'absolute', top: 5, right: 5}}><ContentCopyIcon fontSize="small" /></IconButton></Tooltip>}
</Paper>
{tasks.translate?.state === 'SUCCESS' && tasks.translate.info.result_path && <Button size="small" sx={{mt:1}} startIcon={<DownloadIcon />} onClick={() => downloadFile(tasks.translate.info.result_path)}>Download Translation (.txt)</Button>}
</CardContent>
</Card>
</Grid>
</Grid>
</Grid>
</Grid>
</Box>
);
};
export default ProcessingPage;

View File

@@ -0,0 +1,94 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: false,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
const unwrap = (promise) => promise.then((r) => r.data);
// --- Task Management ---
export const pollTaskStatus = (statusUrl) => unwrap(api.get(statusUrl));
export const stopTask = (taskId) => unwrap(api.post(`/task/${taskId}/stop`));
export const downloadFile = async (filename) => {
const res = await api.get(`/download/${filename}`, { responseType: 'blob' });
const blob = new Blob([res.data], { type: res.headers['content-type'] });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
};
// --- Authentication ---
export const login = (username, password) =>
unwrap(api.post('/login', { username, password }));
export const register = (username, password) =>
unwrap(api.post('/register', { username, password }));
// --- Admin ---
export const getUsers = () => unwrap(api.get('/admin/users')); // For Admin Page
export const getAllUsers = () => unwrap(api.get('/users')); // For dropdowns
export const createUser = (userData) => unwrap(api.post('/admin/users', userData));
export const deleteUser = (userId) => unwrap(api.delete(`/admin/users/${userId}`));
export const changeUserPassword = (userId, password) =>
unwrap(api.put(`/admin/users/${userId}/password`, { password }));
// --- Meetings ---
export const getMeetings = () => unwrap(api.get('/meetings'));
export const createMeeting = (topic, meetingDate) => unwrap(api.post('/meetings', { topic, meeting_date: meetingDate }));
export const getMeetingDetails = (meetingId) => unwrap(api.get(`/meetings/${meetingId}`));
export const updateMeeting = (meetingId, data) => unwrap(api.put(`/meetings/${meetingId}`, data));
export const deleteMeeting = (meetingId) => unwrap(api.delete(`/meetings/${meetingId}`));
export const summarizeMeeting = (meetingId) => unwrap(api.post(`/meetings/${meetingId}/summarize`));
// --- Independent Tools ---
const startFileUploadTask = async (endpoint, file, options = {}) => {
const formData = new FormData();
formData.append('file', file);
for (const key in options) {
formData.append(key, options[key]);
}
return unwrap(api.post(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}));
};
export const extractAudio = (file) =>
startFileUploadTask('/tools/extract_audio', file);
export const transcribeAudio = (file) =>
startFileUploadTask('/tools/transcribe_audio', file);
export const translateText = (text, target_language) =>
unwrap(api.post('/tools/translate_text', { text, target_language }));
// --- AI Previews (for Meeting Page) ---
export const previewActionItems = (text) =>
unwrap(api.post('/action-items/preview', { text }));
// --- Action Items ---
export const getActionItemsForMeeting = (meetingId) => unwrap(api.get(`/meetings/${meetingId}/action_items`));
export const createActionItem = (payload) => unwrap(api.post('/action-items', payload));
export const batchSaveActionItems = (meetingId, items) => unwrap(api.post(`/meetings/${meetingId}/action-items/batch`, { items }));
export const updateActionItem = (itemId, updateData) => unwrap(api.put(`/action_items/${itemId}`, updateData));
export const deleteActionItem = (itemId) => unwrap(api.delete(`/action_items/${itemId}`));
export const uploadActionItemAttachment = (itemId, file) => {
const formData = new FormData();
formData.append('file', file);
return unwrap(api.post(`/action_items/${itemId}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}));
};

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:12000',
changeOrigin: true,
},
},
},
})