first commit

This commit is contained in:
KevinB-T 2026-05-14 15:00:56 +05:30
commit 9c2a56ee1c
26 changed files with 5984 additions and 0 deletions

20
.env Normal file
View File

@ -0,0 +1,20 @@
PORT=5000
HTTPS_KEY_PATH=certs/localhost-key.pem
HTTPS_CERT_PATH=certs/localhost-cert.pem
CLIENT_ORIGIN=https://127.0.0.1:5173,https://localhost:5173,https://172.16.11.27:5173
WHISPER_VM_HOST=172.16.10.51
WHISPER_VM_PORT=22
WHISPER_VM_USER=kevin
WHISPER_VM_PASSWORD=1234
WHISPER_VM_AUDIO_DIR=/home/kevin/mom_audio
WHISPER_VM_TRANSCRIPT_DIR=/home/kevin/mom_transcripts
WHISPER_MODEL=medium
WHISPER_LANGUAGE=English
WHISPER_ENV_ACTIVATE=/home/kevin/whisper-env/bin/activate
WHISPER_ENV_NAME=whisper-env
WHISPER_COMMAND=whisper
FFMPEG_COMMAND=ffmpeg
WHISPER_SSH_READY_TIMEOUT_MS=60000
WHISPER_TIMEOUT_MS=1800000
MAX_AUDIO_MB=250

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
HOST=0.0.0.0
PORT=5001
HTTPS_KEY_PATH=certs/localhost-key.pem
HTTPS_CERT_PATH=certs/localhost-cert.pem
CLIENT_ORIGIN=https://127.0.0.1:5173,https://localhost:5173
WHISPER_VM_HOST=172.16.10.51
WHISPER_VM_PORT=22
WHISPER_VM_USER=kevin
WHISPER_VM_PASSWORD=change-me
WHISPER_VM_AUDIO_DIR=/home/kevin/mom_audio
WHISPER_VM_TRANSCRIPT_DIR=/home/kevin/mom_transcripts
WHISPER_MODEL=medium
WHISPER_LANGUAGE=English
WHISPER_ENV_ACTIVATE=/home/kevin/whisper-env/bin/activate
WHISPER_ENV_NAME=whisper-env
WHISPER_COMMAND=whisper
FFMPEG_COMMAND=ffmpeg
WHISPER_SSH_READY_TIMEOUT_MS=60000
WHISPER_TIMEOUT_MS=1800000
MAX_AUDIO_MB=250

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules
.npm-cache
dist
dist-server
tmp
certs/*.pem
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

3
.npmrc Normal file
View File

@ -0,0 +1,3 @@
cache=.npm-cache
fund=false
update-notifier=false

View File

@ -0,0 +1,18 @@
[req]
distinguished_name = dn
x509_extensions = v3_req
prompt = no
[dn]
CN = localhost
[v3_req]
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
IP.2 = 172.16.11.27

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Premium single-page meeting voice recorder with Whisper transcription."
/>
<title>Meeting Recorder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4122
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "mom-portal",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:client\" \"npm:dev:server\"",
"dev:client": "vite --host 0.0.0.0",
"dev:server": "tsx watch server/index.ts",
"server": "tsx server/index.ts",
"server:build": "tsc -p tsconfig.server.json",
"server:start": "node dist-server/server/index.js",
"typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
"build": "npm run typecheck && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"lucide-react": "^0.468.0",
"multer": "^2.0.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"ssh2": "^1.16.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/ssh2": "^1.15.4",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.2",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vite": "^8.0.12"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

183
server/index.ts Normal file
View File

@ -0,0 +1,183 @@
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import https, { type ServerOptions as HttpsServerOptions } from 'node:https';
import os from 'node:os';
import path from 'node:path';
import cors from 'cors';
import express from 'express';
import multer from 'multer';
import { transcribeOnWhisperVm } from './whisper.js';
const app = express();
const host = process.env.HOST ?? '0.0.0.0';
const port = Number(process.env.PORT ?? 5000);
const maxAudioMb = Number(process.env.MAX_AUDIO_MB ?? 250);
const tempUploadDir = path.resolve(process.cwd(), 'tmp', 'uploads');
const getConfiguredPath = (value: string | undefined) =>
value ? path.resolve(process.cwd(), value) : undefined;
const getHttpsOptions = (): HttpsServerOptions | undefined => {
const keyPath = getConfiguredPath(process.env.HTTPS_KEY_PATH);
const certPath = getConfiguredPath(process.env.HTTPS_CERT_PATH);
if (!keyPath && !certPath) return undefined;
if (!keyPath || !certPath) {
throw new Error('HTTPS_KEY_PATH and HTTPS_CERT_PATH must both be set.');
}
return {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
};
};
const httpsOptions = getHttpsOptions();
const protocol = httpsOptions ? 'https' : 'http';
const getClientErrorMessage = (message: string) =>
/whisper/i.test(message) || /WHISPER_/.test(message)
? 'The transcription service is unavailable. Check the processing machine and try again.'
: message;
const allowedOrigins = (process.env.CLIENT_ORIGIN ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
fs.mkdirSync(tempUploadDir, { recursive: true });
app.use(
cors({
origin: allowedOrigins.length > 0 ? allowedOrigins : true
})
);
app.use(express.json());
const storage = multer.diskStorage({
destination: tempUploadDir,
filename: (_request, file, callback) => {
const extension = path.extname(file.originalname).toLowerCase() || '.webm';
callback(null, `${Date.now()}-${randomUUID()}${extension}`);
}
});
const upload = multer({
fileFilter: (_request, file, callback) => {
const isAudio =
file.mimetype.startsWith('audio/') ||
file.mimetype === 'video/webm' ||
file.mimetype === 'application/octet-stream';
if (!isAudio) {
callback(new Error('Only audio uploads are supported.'));
return;
}
callback(null, true);
},
limits: {
fileSize: maxAudioMb * 1024 * 1024
},
storage
});
app.get('/api/health', (_request, response) => {
response.json({ success: true, status: 'ready' });
});
app.post('/api/transcribe', upload.single('audio'), async (request, response, next) => {
const file = request.file;
if (!file) {
response.status(400).json({
success: false,
error: 'Audio file is required.'
});
return;
}
try {
const result = await transcribeOnWhisperVm({
localFilePath: file.path,
originalName: file.originalname
});
response.json({
success: true,
transcript: result.transcript
});
} catch (error) {
next(error);
} finally {
await fs.promises.unlink(file.path).catch(() => undefined);
}
});
const distDir = path.resolve(process.cwd(), 'dist');
if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) {
app.use(express.static(distDir));
app.get('*', (_request, response) => {
response.sendFile(path.join(distDir, 'index.html'));
});
}
app.use(
(
error: unknown,
_request: express.Request,
response: express.Response,
_next: express.NextFunction
) => {
const message =
error instanceof multer.MulterError
? error.code === 'LIMIT_FILE_SIZE'
? `Audio file is too large. Maximum size is ${maxAudioMb} MB.`
: error.message
: error instanceof Error
? getClientErrorMessage(error.message)
: 'Transcription failed.';
console.error('[transcribe]', error);
response.status(500).json({
success: false,
error: message
});
}
);
const getNetworkUrls = () =>
Object.values(os.networkInterfaces())
.flatMap((networkInterfaces) => networkInterfaces ?? [])
.filter(
(networkInterface) =>
networkInterface?.family === 'IPv4' && !networkInterface.internal
)
.map((networkInterface) => `${protocol}://${networkInterface.address}:${port}`);
const server = httpsOptions
? https.createServer(httpsOptions, app)
: http.createServer(app);
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
console.error(
`Port ${port} is already in use. Stop the existing dev server or change PORT in .env.`
);
process.exit(1);
}
throw error;
});
server.listen(port, host, () => {
const networkUrls = getNetworkUrls();
console.log(`MoM transcription API running on ${protocol}://localhost:${port}`);
if (networkUrls.length > 0) {
console.log(`Network API URL: ${networkUrls.join(', ')}`);
}
});
server.requestTimeout = Number(process.env.REQUEST_TIMEOUT_MS ?? 1_860_000);

356
server/whisper.ts Normal file
View File

@ -0,0 +1,356 @@
import { randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { extname, posix } from 'node:path';
import { Client } from 'ssh2';
import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2';
interface WhisperConfig {
audioDir: string;
command: string;
envActivatePath: string;
envName: string;
ffmpegCommand: string;
host: string;
language: string;
model: string;
password: string;
port: number;
sshReadyTimeoutMs: number;
timeoutMs: number;
transcriptDir: string;
username: string;
}
interface TranscriptionInput {
localFilePath: string;
originalName: string;
}
interface TranscriptionOutput {
remoteAudioPath: string;
remoteTranscriptPath: string;
transcript: string;
}
const requiredEnv = (name: string, fallback?: string) => {
const value = process.env[name] ?? fallback;
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
};
const getConfig = (): WhisperConfig => ({
audioDir: requiredEnv('WHISPER_VM_AUDIO_DIR', '/home/kevin/mom_audio'),
command: requiredEnv('WHISPER_COMMAND', 'whisper'),
envActivatePath: requiredEnv(
'WHISPER_ENV_ACTIVATE',
'/home/kevin/whisper-env/bin/activate'
),
envName: requiredEnv('WHISPER_ENV_NAME', 'whisper-env'),
ffmpegCommand: requiredEnv('FFMPEG_COMMAND', 'ffmpeg'),
host: requiredEnv('WHISPER_VM_HOST', '172.16.10.51'),
language: requiredEnv('WHISPER_LANGUAGE', 'English'),
model: requiredEnv('WHISPER_MODEL', 'medium'),
password: requiredEnv('WHISPER_VM_PASSWORD'),
port: Number(process.env.WHISPER_VM_PORT ?? 22),
sshReadyTimeoutMs: Number(process.env.WHISPER_SSH_READY_TIMEOUT_MS ?? 60_000),
timeoutMs: Number(process.env.WHISPER_TIMEOUT_MS ?? 1_800_000),
transcriptDir: requiredEnv(
'WHISPER_VM_TRANSCRIPT_DIR',
'/home/kevin/mom_transcripts'
),
username: requiredEnv('WHISPER_VM_USER', 'kevin')
});
const trimRemoteDir = (dir: string) => dir.replace(/\/+$/, '');
const shellQuote = (value: string) => `'${value.replace(/'/g, "'\\''")}'`;
const runInsideWhisperEnv = (config: WhisperConfig, command: string) => {
const script = [
'set -e',
`if [ -f ${shellQuote(config.envActivatePath)} ]; then`,
` . ${shellQuote(config.envActivatePath)}`,
'elif command -v conda >/dev/null 2>&1; then',
' eval "$(conda shell.bash hook)"',
` conda activate ${shellQuote(config.envName)}`,
'else',
` echo ${shellQuote(
'Unable to activate the transcription environment. Check the remote activation path.'
)} >&2`,
' exit 127',
'fi',
command
].join('\n');
return `bash -lc ${shellQuote(script)}`;
};
const safeBaseName = (fileName: string) => {
const withoutExtension = fileName.replace(/\.[^.]+$/, '');
const safe = withoutExtension
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return safe || 'meeting-audio';
};
const connectSsh = (config: WhisperConfig) =>
new Promise<Client>((resolve, reject) => {
const client = new Client();
const connection: ConnectConfig = {
host: config.host,
keepaliveInterval: 15_000,
password: config.password,
port: config.port,
readyTimeout: config.sshReadyTimeoutMs,
username: config.username
};
client
.on('ready', () => resolve(client))
.on('error', (error) => {
const message =
error instanceof Error
? error.message
: 'Unknown SSH connection error.';
reject(
new Error(
`Unable to connect to the transcription service at ${config.host}:${config.port}. ` +
`Check that the VM is powered on, reachable from this machine, and accepting SSH. Details: ${message}`
)
);
})
.connect(connection);
});
const getSftp = (client: Client) =>
new Promise<SFTPWrapper>((resolve, reject) => {
client.sftp((error, sftp) => {
if (error) {
reject(error);
return;
}
resolve(sftp);
});
});
const execCommand = (client: Client, command: string, timeoutMs: number) =>
new Promise<string>((resolve, reject) => {
let stream: ClientChannel | null = null;
let stdout = '';
let stderr = '';
let settled = false;
const finish = (error?: Error, output?: string) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (error) {
reject(error);
return;
}
resolve(output ?? '');
};
const timeout = setTimeout(() => {
stream?.close();
finish(new Error('Transcription timed out on the processing machine.'));
}, timeoutMs);
client.exec(command, (error, commandStream) => {
if (error) {
finish(error);
return;
}
stream = commandStream;
commandStream.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
commandStream.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
commandStream.on('close', (code: number | null) => {
if (code && code !== 0) {
finish(
new Error(
stderr.trim() ||
stdout.trim() ||
`Transcription failed with exit code ${code}.`
)
);
return;
}
finish(undefined, `${stdout}${stderr}`.trim());
});
});
});
const uploadFile = (
sftp: SFTPWrapper,
localFilePath: string,
remoteFilePath: string
) =>
new Promise<void>((resolve, reject) => {
let settled = false;
const source = createReadStream(localFilePath);
const target = sftp.createWriteStream(remoteFilePath, {
flags: 'w',
mode: 0o640
});
const finish = (error?: Error) => {
if (settled) return;
settled = true;
if (error) {
reject(error);
return;
}
resolve();
};
source.on('error', finish);
target.on('error', finish);
target.on('close', () => finish());
source.pipe(target);
});
const readRemoteFile = (sftp: SFTPWrapper, remoteFilePath: string) =>
new Promise<string>((resolve, reject) => {
let content = '';
let settled = false;
const source = sftp.createReadStream(remoteFilePath);
const finish = (error?: Error) => {
if (settled) return;
settled = true;
if (error) {
reject(error);
return;
}
resolve(content.trim());
};
source.on('data', (chunk: Buffer | string) => {
content += chunk.toString();
});
source.on('error', finish);
source.on('close', () => finish());
});
const deleteRemoteFile = (sftp: SFTPWrapper, remoteFilePath: string) =>
new Promise<void>((resolve) => {
sftp.unlink(remoteFilePath, () => resolve());
});
export const transcribeOnWhisperVm = async ({
localFilePath,
originalName
}: TranscriptionInput): Promise<TranscriptionOutput> => {
const config = getConfig();
const audioDir = trimRemoteDir(config.audioDir);
const transcriptDir = trimRemoteDir(config.transcriptDir);
const extension = extname(originalName).toLowerCase() || '.webm';
const remoteBaseName = `${Date.now()}-${randomUUID()}-${safeBaseName(
originalName
)}`;
const remoteSourcePath = posix.join(
audioDir,
`${remoteBaseName}.source${extension}`
);
const remoteAudioPath = posix.join(audioDir, `${remoteBaseName}.wav`);
const remoteTranscriptPath = posix.join(transcriptDir, `${remoteBaseName}.txt`);
const client = await connectSsh(config);
let sftp: SFTPWrapper | null = null;
try {
await execCommand(
client,
`mkdir -p ${shellQuote(audioDir)} ${shellQuote(transcriptDir)}`,
30_000
);
sftp = await getSftp(client);
await uploadFile(sftp, localFilePath, remoteSourcePath);
// Browser recordings are commonly WebM/Opus; the remote job is more reliable with WAV.
await execCommand(
client,
[
config.ffmpegCommand,
'-y',
'-hide_banner',
'-loglevel',
'error',
'-i',
shellQuote(remoteSourcePath),
'-vn',
'-ac',
'1',
'-ar',
'16000',
'-c:a',
'pcm_s16le',
shellQuote(remoteAudioPath)
].join(' '),
config.timeoutMs
);
// Transcription runs remotely so the Node process stays lightweight.
await execCommand(
client,
runInsideWhisperEnv(
config,
[
shellQuote(config.command),
shellQuote(remoteAudioPath),
'--model',
shellQuote(config.model),
'--language',
shellQuote(config.language),
'--task',
'transcribe',
'--output_dir',
shellQuote(transcriptDir),
'--output_format',
'txt',
'--fp16',
'False'
].join(' ')
),
config.timeoutMs
);
const transcript = await readRemoteFile(sftp, remoteTranscriptPath);
if (!transcript) {
throw new Error('The transcription service returned an empty transcript.');
}
return {
remoteAudioPath,
remoteTranscriptPath,
transcript
};
} finally {
if (sftp) {
await deleteRemoteFile(sftp, remoteSourcePath);
sftp.end();
}
client.end();
}
};

230
src/App.tsx Normal file
View File

@ -0,0 +1,230 @@
import { AlertCircle, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { uploadAudioForTranscription } from './api/transcription';
import { MicButton } from './components/MicButton';
import { StatusPill } from './components/StatusPill';
import { TranscriptCard } from './components/TranscriptCard';
import { Waveform } from './components/Waveform';
import { formatDuration, useAudioRecorder } from './hooks/useAudioRecorder';
import type { RecorderPhase } from './types';
const getErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : 'Something went wrong.';
function App() {
const { duration, error: recorderError, isRecording, startRecording, stopRecording } =
useAudioRecorder();
const [phase, setPhase] = useState<RecorderPhase>('idle');
const [uploadProgress, setUploadProgress] = useState(0);
const [transcript, setTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [recordingBlob, setRecordingBlob] = useState<Blob | null>(null);
const [recordingUrl, setRecordingUrl] = useState<string | null>(null);
const recordingUrlRef = useRef<string | null>(null);
useEffect(() => {
if (recorderError) {
setError(recorderError);
setPhase('error');
}
}, [recorderError]);
useEffect(
() => () => {
if (recordingUrlRef.current) {
URL.revokeObjectURL(recordingUrlRef.current);
}
},
[]
);
const replaceRecordingUrl = (audioBlob: Blob | null) => {
if (recordingUrlRef.current) {
URL.revokeObjectURL(recordingUrlRef.current);
recordingUrlRef.current = null;
}
if (!audioBlob) {
setRecordingBlob(null);
setRecordingUrl(null);
return;
}
const nextUrl = URL.createObjectURL(audioBlob);
setRecordingBlob(audioBlob);
recordingUrlRef.current = nextUrl;
setRecordingUrl(nextUrl);
};
const transcribeRecording = async (audioBlob: Blob) => {
setError(null);
setUploadProgress(0);
setPhase('uploading');
const result = await uploadAudioForTranscription(
audioBlob,
setUploadProgress,
() => setPhase('transcribing')
);
setTranscript(result);
setPhase('complete');
};
const start = async () => {
setError(null);
setCopied(false);
setUploadProgress(0);
replaceRecordingUrl(null);
await startRecording();
setPhase('recording');
};
const stopAndUpload = async () => {
setError(null);
setUploadProgress(0);
setPhase('uploading');
const audioBlob = await stopRecording();
if (audioBlob.size === 0) {
throw new Error('No audio was captured. Please try recording again.');
}
replaceRecordingUrl(audioBlob);
await transcribeRecording(audioBlob);
};
const retryTranscription = async () => {
if (!recordingBlob) return;
try {
await transcribeRecording(recordingBlob);
} catch (retryError) {
setError(getErrorMessage(retryError));
setPhase('error');
}
};
const handlePrimaryAction = async () => {
const busy = phase === 'uploading' || phase === 'transcribing';
if (busy) return;
try {
if (phase === 'recording' || isRecording) {
await stopAndUpload();
return;
}
await start();
} catch (actionError) {
setError(getErrorMessage(actionError));
setPhase('error');
}
};
const copyTranscript = async () => {
if (!transcript.trim()) return;
try {
await navigator.clipboard.writeText(transcript);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
} catch {
setError('Clipboard access failed. Select and copy the transcript manually.');
setPhase('error');
}
};
const clearTranscript = () => {
setTranscript('');
setCopied(false);
setError(null);
setUploadProgress(0);
replaceRecordingUrl(null);
setPhase('idle');
};
const showProgress = phase === 'uploading';
const showTranscribing = phase === 'transcribing';
return (
<main className="relative flex min-h-screen items-center px-4 py-6 text-white sm:px-6 lg:px-8">
<section className="mx-auto flex w-full max-w-5xl flex-col gap-5 sm:gap-6">
<section className="glass-panel relative overflow-hidden px-5 py-8 text-center shadow-panel sm:px-8 sm:py-10">
<div className="glow-line" />
<div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center">
<StatusPill phase={phase} uploadProgress={uploadProgress} />
<h1 className="mt-7 text-4xl font-semibold text-white sm:text-6xl">
Meeting Recorder
</h1>
<p className="mt-3 max-w-xl text-base leading-7 text-slate-400 sm:text-lg">
Record the conversation, turn it into minutes, and keep everything
in one polished workspace.
</p>
<div className="mt-9">
<MicButton phase={phase} onClick={handlePrimaryAction} />
</div>
<div className="mt-6 flex min-h-14 flex-col items-center justify-center gap-2">
{phase === 'recording' ? (
<div className="font-mono text-4xl font-semibold text-cyan-100 sm:text-5xl">
{formatDuration(duration)}
</div>
) : showTranscribing ? (
<div className="inline-flex items-center gap-2 text-sm text-cyan-100">
<Loader2 className="h-4 w-4 animate-spin" />
Preparing your transcript
</div>
) : (
<div className="font-mono text-4xl font-semibold text-slate-500 sm:text-5xl">
00:00
</div>
)}
{showProgress ? (
<div className="w-full max-w-xs">
<div className="mb-2 flex items-center justify-between text-xs text-slate-400">
<span>Uploading audio</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-cyan-200 transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : null}
</div>
<Waveform active={phase === 'recording' || showTranscribing} />
{error ? (
<div className="mt-2 flex max-w-xl items-start gap-2 rounded-[8px] border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-left text-sm text-rose-100">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
</div>
</section>
<TranscriptCard
copied={copied}
onClear={clearTranscript}
onCopy={copyTranscript}
onTranscribe={retryTranscription}
phase={phase}
recordingUrl={recordingUrl}
transcript={transcript}
/>
</section>
</main>
);
}
export default App;

63
src/api/transcription.ts Normal file
View File

@ -0,0 +1,63 @@
export interface TranscriptionResult {
success: boolean;
transcript?: string;
error?: string;
}
const extensionForMimeType = (mimeType: string) => {
if (mimeType.includes('mp4')) return 'mp4';
if (mimeType.includes('mpeg')) return 'mp3';
if (mimeType.includes('ogg')) return 'ogg';
if (mimeType.includes('wav')) return 'wav';
return 'webm';
};
export const uploadAudioForTranscription = (
audioBlob: Blob,
onProgress: (progress: number) => void,
onUploadComplete: () => void
) =>
new Promise<string>((resolve, reject) => {
const request = new XMLHttpRequest();
const formData = new FormData();
const extension = extensionForMimeType(audioBlob.type);
const fileName = `meeting-recording-${new Date()
.toISOString()
.replace(/[:.]/g, '-')}.${extension}`;
formData.append('audio', audioBlob, fileName);
request.open('POST', '/api/transcribe');
request.responseType = 'json';
request.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)));
};
request.upload.onload = () => {
onProgress(100);
onUploadComplete();
};
request.onerror = () => {
reject(new Error('Network error while uploading audio.'));
};
request.onload = () => {
const response = request.response as TranscriptionResult | null;
if (request.status >= 200 && request.status < 300 && response?.success) {
resolve(response.transcript ?? '');
return;
}
reject(
new Error(
response?.error ?? `Transcription failed with status ${request.status}.`
)
);
};
request.send(formData);
});

View File

@ -0,0 +1,60 @@
import { Loader2, Mic, Square } from 'lucide-react';
import type { RecorderPhase } from '../types';
interface MicButtonProps {
phase: RecorderPhase;
onClick: () => void;
}
const buttonCopy: Record<RecorderPhase, string> = {
idle: 'Start recording',
recording: 'Stop recording',
uploading: 'Uploading audio',
transcribing: 'Transcribing audio',
complete: 'Record again',
error: 'Try again'
};
export const MicButton = ({ phase, onClick }: MicButtonProps) => {
const isRecording = phase === 'recording';
const isBusy = phase === 'uploading' || phase === 'transcribing';
return (
<div className="flex flex-col items-center gap-4">
<button
type="button"
onClick={onClick}
disabled={isBusy}
aria-label={buttonCopy[phase]}
className={[
'mic-button group relative grid h-32 w-32 place-items-center rounded-full transition duration-500 ease-out sm:h-40 sm:w-40',
isRecording
? 'bg-rose-500/95 shadow-[0_0_52px_rgba(244,63,94,0.42)]'
: 'bg-cyan-300 text-slate-950 shadow-glow',
isBusy ? 'cursor-wait opacity-80' : 'hover:scale-[1.03] active:scale-[0.98]'
].join(' ')}
>
<span
className={[
'absolute inset-[-10px] rounded-full border transition duration-500',
isRecording
? 'border-rose-300/40 animate-soft-pulse'
: 'border-cyan-200/30 group-hover:border-cyan-100/70'
].join(' ')}
/>
<span className="absolute inset-4 rounded-full bg-white/16 blur-xl" />
{isBusy ? (
<Loader2 className="relative h-12 w-12 animate-spin sm:h-14 sm:w-14" />
) : isRecording ? (
<Square className="relative h-11 w-11 fill-white text-white sm:h-14 sm:w-14" />
) : (
<Mic className="relative h-12 w-12 sm:h-14 sm:w-14" />
)}
</button>
<div className="min-h-6 text-sm font-medium text-slate-200">
{buttonCopy[phase]}
</div>
</div>
);
};

View File

@ -0,0 +1,139 @@
import { Pause, Play, RotateCcw } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
interface RecordingPlayerProps {
src: string;
}
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const minutes = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const remainingSeconds = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${remainingSeconds}`;
};
export const RecordingPlayer = ({ src }: RecordingPlayerProps) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
}, [src]);
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const togglePlayback = async () => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
return;
}
await audio.play();
};
const restart = () => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = 0;
setCurrentTime(0);
};
const seek = (nextTime: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = nextTime;
setCurrentTime(nextTime);
};
return (
<div className="rounded-[8px] border border-white/10 bg-slate-950/60 p-3">
<audio
ref={audioRef}
className="hidden"
preload="metadata"
src={src}
onDurationChange={(event) =>
setDuration(event.currentTarget.duration || 0)
}
onLoadedMetadata={(event) =>
setDuration(event.currentTarget.duration || 0)
}
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onEnded={() => setIsPlaying(false)}
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={togglePlayback}
className="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-cyan-200 text-slate-950 shadow-[0_0_24px_rgba(103,232,249,0.24)] transition hover:bg-cyan-100 active:scale-95"
title={isPlaying ? 'Pause recording' : 'Play recording'}
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-slate-950" />
) : (
<Play className="ml-0.5 h-5 w-5 fill-slate-950" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
<span className="truncate text-slate-200">Recorded audio</span>
<span className="shrink-0 font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="relative h-5">
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-cyan-200"
style={{ width: `${progress}%` }}
/>
</div>
<input
type="range"
min={0}
max={duration || 0}
step="0.01"
value={Math.min(currentTime, duration || currentTime)}
onChange={(event) => seek(Number(event.target.value))}
className="recording-range absolute inset-0 h-5 w-full"
aria-label="Seek recording"
disabled={duration <= 0}
/>
</div>
</div>
<button
type="button"
onClick={restart}
className="grid h-10 w-10 shrink-0 place-items-center rounded-full border border-white/10 bg-white/[0.06] text-slate-200 transition hover:border-cyan-200/30 hover:bg-cyan-200/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Restart recording"
aria-label="Restart recording"
disabled={duration <= 0}
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,36 @@
import { AlertCircle, CheckCircle2, Loader2, Radio } from 'lucide-react';
import type { RecorderPhase } from '../types';
interface StatusPillProps {
phase: RecorderPhase;
uploadProgress: number;
}
export const StatusPill = ({ phase, uploadProgress }: StatusPillProps) => {
const label: Record<RecorderPhase, string> = {
idle: 'Ready',
recording: 'Recording live',
uploading: `Uploading ${uploadProgress}%`,
transcribing: 'Transcribing',
complete: 'Transcript ready',
error: 'Needs attention'
};
const icon =
phase === 'complete' ? (
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
) : phase === 'error' ? (
<AlertCircle className="h-4 w-4 text-rose-300" />
) : phase === 'uploading' || phase === 'transcribing' ? (
<Loader2 className="h-4 w-4 animate-spin text-cyan-200" />
) : (
<Radio className="h-4 w-4 text-cyan-200" />
);
return (
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm text-slate-200 shadow-[0_0_26px_rgba(34,211,238,0.12)]">
{icon}
<span>{label[phase]}</span>
</div>
);
};

View File

@ -0,0 +1,103 @@
import { Clipboard, FileText, Loader2, RotateCcw, Trash2 } from 'lucide-react';
import type { RecorderPhase } from '../types';
import { RecordingPlayer } from './RecordingPlayer';
interface TranscriptCardProps {
copied: boolean;
onClear: () => void;
onCopy: () => void;
onTranscribe: () => void;
phase: RecorderPhase;
recordingUrl: string | null;
transcript: string;
}
export const TranscriptCard = ({
copied,
onClear,
onCopy,
onTranscribe,
phase,
recordingUrl,
transcript
}: TranscriptCardProps) => {
const hasTranscript = transcript.trim().length > 0;
const hasRecording = Boolean(recordingUrl);
const hasContent = hasTranscript || hasRecording;
const isTranscribing = phase === 'uploading' || phase === 'transcribing';
const showTranscribeButton = hasRecording && !hasTranscript;
return (
<section className="glass-panel w-full p-4 sm:p-6">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-white sm:text-xl">
Transcript
</h2>
<p className="mt-1 text-sm text-slate-400">
Meeting minutes appear here as soon as processing finishes.
</p>
</div>
<div className="flex gap-2">
{showTranscribeButton ? (
<button
type="button"
onClick={onTranscribe}
disabled={isTranscribing}
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-emerald-200/20 bg-emerald-200/10 px-3 text-sm font-medium text-emerald-100 transition hover:border-emerald-100/40 hover:bg-emerald-200/15 disabled:cursor-not-allowed disabled:opacity-40"
title="Transcribe recording"
>
{isTranscribing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileText className="h-4 w-4" />
)}
Transcribe
</button>
) : null}
<button
type="button"
onClick={onCopy}
disabled={!hasTranscript}
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-cyan-200/20 bg-cyan-200/10 px-3 text-sm font-medium text-cyan-100 transition hover:border-cyan-100/40 hover:bg-cyan-200/15 disabled:cursor-not-allowed disabled:opacity-40"
title="Copy transcript"
>
{copied ? (
<RotateCcw className="h-4 w-4" />
) : (
<Clipboard className="h-4 w-4" />
)}
{copied ? 'Copied' : 'Copy'}
</button>
<button
type="button"
onClick={onClear}
disabled={!hasContent}
className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-white/10 bg-white/[0.06] px-3 text-sm font-medium text-slate-200 transition hover:border-rose-200/30 hover:bg-rose-400/10 disabled:cursor-not-allowed disabled:opacity-40"
title="Clear recording and transcript"
>
<Trash2 className="h-4 w-4" />
Clear
</button>
</div>
</div>
{recordingUrl ? (
<div className="mb-4">
<RecordingPlayer src={recordingUrl} />
</div>
) : null}
<div className="transcript-scroll min-h-48 max-h-72 overflow-y-auto rounded-[8px] border border-white/10 bg-black/30 p-4 text-sm leading-7 text-slate-200 sm:min-h-56 sm:max-h-80 sm:text-base">
{hasTranscript ? (
<p className="whitespace-pre-wrap">{transcript}</p>
) : (
<p className="text-slate-500">
Your meeting transcript will appear here after recording.
</p>
)}
</div>
</section>
);
};

View File

@ -0,0 +1,33 @@
import type { CSSProperties } from 'react';
interface WaveformProps {
active: boolean;
}
const heights = [20, 34, 48, 30, 56, 76, 42, 64, 88, 46, 74, 36, 58, 80, 44];
export const Waveform = ({ active }: WaveformProps) => (
<div
aria-hidden="true"
className={[
'flex h-28 items-center justify-center gap-2 transition duration-500',
active ? 'opacity-100' : 'opacity-35'
].join(' ')}
>
{heights.map((height, index) => (
<span
key={`${height}-${index}`}
className={[
'wave-bar w-1.5 rounded-full bg-cyan-200/85 shadow-[0_0_18px_rgba(103,232,249,0.55)] sm:w-2',
active ? 'is-active' : ''
].join(' ')}
style={
{
'--bar-index': index,
height
} as CSSProperties
}
/>
))}
</div>
);

View File

@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const MIME_TYPES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4'
];
const getSupportedMimeType = () =>
MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)) ?? '';
const getPermissionErrorMessage = (error: unknown) => {
if (error instanceof DOMException) {
if (error.name === 'NotAllowedError' || error.name === 'SecurityError') {
return 'Microphone permission was denied. Allow microphone access and try again.';
}
if (error.name === 'NotFoundError') {
return 'No microphone was found on this device.';
}
}
return error instanceof Error
? error.message
: 'Unable to start microphone recording.';
};
export const formatDuration = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const remainingSeconds = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${remainingSeconds}`;
};
export const useAudioRecorder = () => {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const startingRef = useRef(false);
const mimeTypeRef = useRef('audio/webm');
const startedAtRef = useRef(0);
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, []);
const startRecording = useCallback(async () => {
if (isRecording || startingRef.current) return;
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
throw new Error('This browser does not support audio recording.');
}
try {
startingRef.current = true;
setError(null);
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
const mimeType = getSupportedMimeType();
const mediaRecorder = new MediaRecorder(
stream,
mimeType ? { mimeType } : undefined
);
chunksRef.current = [];
streamRef.current = stream;
mediaRecorderRef.current = mediaRecorder;
mimeTypeRef.current = mimeType || 'audio/webm';
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
mediaRecorder.onerror = () => {
setError('Recording failed. Please try again.');
clearTimer();
cleanupStream();
setIsRecording(false);
};
mediaRecorder.start(250);
startedAtRef.current = Date.now();
setDuration(0);
setIsRecording(true);
timerRef.current = window.setInterval(() => {
setDuration(Math.floor((Date.now() - startedAtRef.current) / 1000));
}, 300);
} catch (recordingError) {
cleanupStream();
const message = getPermissionErrorMessage(recordingError);
setError(message);
throw new Error(message);
} finally {
startingRef.current = false;
}
}, [clearTimer, cleanupStream, isRecording]);
const stopRecording = useCallback(
() =>
new Promise<Blob>((resolve, reject) => {
const mediaRecorder = mediaRecorderRef.current;
if (!mediaRecorder || mediaRecorder.state === 'inactive') {
reject(new Error('No active recording to stop.'));
return;
}
mediaRecorder.onstop = () => {
clearTimer();
cleanupStream();
setIsRecording(false);
mediaRecorderRef.current = null;
const audioBlob = new Blob(chunksRef.current, {
type: mimeTypeRef.current
});
chunksRef.current = [];
resolve(audioBlob);
};
mediaRecorder.stop();
}),
[clearTimer, cleanupStream]
);
useEffect(
() => () => {
clearTimer();
cleanupStream();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
},
[clearTimer, cleanupStream]
);
return {
duration,
error,
isRecording,
startRecording,
stopRecording
};
};

207
src/index.css Normal file
View File

@ -0,0 +1,207 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #f8fafc;
background: #020305;
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
min-height: 100%;
background: #020305;
}
body {
min-height: 100vh;
margin: 0;
overflow-x: hidden;
background:
linear-gradient(180deg, rgba(10, 18, 22, 0.9) 0%, #020305 42%),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 96px
),
#020305;
}
body::before {
position: fixed;
inset: 0;
pointer-events: none;
content: '';
background:
linear-gradient(90deg, transparent 0%, rgba(34, 211, 238, 0.08) 50%, transparent 100%),
linear-gradient(180deg, rgba(34, 211, 238, 0.1) 0%, transparent 34%);
opacity: 0.9;
}
button {
font: inherit;
}
::selection {
color: #041014;
background: #67e8f9;
}
#root {
min-height: 100vh;
}
.glass-panel {
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 8px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.105), rgba(255, 255, 255, 0.035)),
rgba(3, 8, 12, 0.78);
backdrop-filter: blur(26px);
box-shadow:
0 24px 90px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
.glow-line {
position: absolute;
top: 0;
left: 12%;
width: 76%;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(103, 232, 249, 0.9),
transparent
);
box-shadow: 0 0 34px rgba(34, 211, 238, 0.6);
}
.mic-button {
isolation: isolate;
}
.mic-button::after {
position: absolute;
inset: -28px;
z-index: -1;
content: '';
border-radius: 9999px;
background: radial-gradient(circle, rgba(34, 211, 238, 0.2), transparent 68%);
filter: blur(4px);
}
.wave-bar {
transform-origin: center;
transition: height 300ms ease, opacity 300ms ease;
}
.wave-bar.is-active {
animation: waveform 1.05s ease-in-out infinite;
animation-delay: calc(var(--bar-index) * 62ms);
}
.transcript-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(103, 232, 249, 0.55) rgba(255, 255, 255, 0.06);
}
.transcript-scroll::-webkit-scrollbar {
width: 8px;
}
.transcript-scroll::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
}
.transcript-scroll::-webkit-scrollbar-thumb {
background: rgba(103, 232, 249, 0.55);
border-radius: 999px;
}
.recording-range {
cursor: pointer;
appearance: none;
background: transparent;
}
.recording-range:disabled {
cursor: default;
}
.recording-range::-webkit-slider-runnable-track {
height: 20px;
background: transparent;
}
.recording-range::-webkit-slider-thumb {
width: 14px;
height: 14px;
margin-top: 3px;
appearance: none;
border: 2px solid #ecfeff;
border-radius: 999px;
background: #22d3ee;
box-shadow: 0 0 18px rgba(34, 211, 238, 0.45);
}
.recording-range:disabled::-webkit-slider-thumb {
opacity: 0;
}
.recording-range::-moz-range-track {
height: 20px;
background: transparent;
}
.recording-range::-moz-range-thumb {
width: 14px;
height: 14px;
border: 2px solid #ecfeff;
border-radius: 999px;
background: #22d3ee;
box-shadow: 0 0 18px rgba(34, 211, 238, 0.45);
}
.recording-range:disabled::-moz-range-thumb {
opacity: 0;
}
@keyframes waveform {
0%,
100% {
transform: scaleY(0.34);
opacity: 0.52;
}
45% {
transform: scaleY(1.08);
opacity: 1;
}
}
@media (max-width: 640px) {
body {
background:
linear-gradient(180deg, rgba(10, 18, 22, 0.94) 0%, #020305 50%),
#020305;
}
.glass-panel {
backdrop-filter: blur(18px);
}
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

7
src/types.ts Normal file
View File

@ -0,0 +1,7 @@
export type RecorderPhase =
| 'idle'
| 'recording'
| 'uploading'
| 'transcribing'
| 'complete'
| 'error';

39
tailwind.config.ts Normal file
View File

@ -0,0 +1,39 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'sans-serif'
]
},
boxShadow: {
glow: '0 0 48px rgba(34, 211, 238, 0.28)',
panel: '0 24px 90px rgba(0, 0, 0, 0.55)'
},
animation: {
'soft-pulse': 'soft-pulse 2.8s ease-in-out infinite',
shimmer: 'shimmer 1.8s linear infinite'
},
keyframes: {
'soft-pulse': {
'0%, 100%': { transform: 'scale(1)', opacity: '0.82' },
'50%': { transform: 'scale(1.03)', opacity: '1' }
},
shimmer: {
'0%': { transform: 'translateX(-120%)' },
'100%': { transform: 'translateX(120%)' }
}
}
}
},
plugins: []
} satisfies Config;

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": []
}

16
tsconfig.server.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"types": ["node"],
"rootDir": ".",
"outDir": "dist-server"
},
"include": ["server/**/*.ts"]
}

49
vite.config.ts Normal file
View File

@ -0,0 +1,49 @@
import fs from 'node:fs';
import type { ServerOptions as HttpsServerOptions } from 'node:https';
import path from 'node:path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const apiPort = Number(env.PORT ?? 5000);
const httpsOptions = getHttpsOptions(env);
const apiProtocol = httpsOptions ? 'https' : 'http';
return {
plugins: [react()],
server: {
https: httpsOptions,
port: 5173,
strictPort: true,
proxy: {
'/api': {
target: `${apiProtocol}://127.0.0.1:${apiPort}`,
changeOrigin: true,
secure: false
}
}
}
};
});
const getConfiguredPath = (value: string | undefined) =>
value ? path.resolve(process.cwd(), value) : undefined;
const getHttpsOptions = (
env: Record<string, string>
): HttpsServerOptions | undefined => {
const keyPath = getConfiguredPath(env.HTTPS_KEY_PATH);
const certPath = getConfiguredPath(env.HTTPS_CERT_PATH);
if (!keyPath && !certPath) return undefined;
if (!keyPath || !certPath) {
throw new Error('HTTPS_KEY_PATH and HTTPS_CERT_PATH must both be set.');
}
return {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath)
};
};