diff --git a/dist-server/server.js b/dist-server/server.js
new file mode 100644
index 0000000..1507e31
--- /dev/null
+++ b/dist-server/server.js
@@ -0,0 +1,149 @@
+import express from 'express';
+import nodemailer from 'nodemailer';
+import path from 'path';
+import { fileURLToPath } from 'url';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const app = express();
+app.use(express.json());
+// Serve static files from Vite build
+app.use(express.static(path.join(__dirname, 'dist')));
+// Contact API endpoint
+app.post('/api/contact', async (req, res) => {
+ const { name, organisation, email, subject, message } = req.body;
+ if (!name || !email || !subject || !message) {
+ return res.status(400).json({ success: false, message: 'All fields are required' });
+ }
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return res.status(400).json({ success: false, message: 'Invalid email address' });
+ }
+ const transporter = nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: Number(process.env.SMTP_PORT),
+ secure: true,
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS,
+ },
+ });
+ const contactEmail = process.env.CONTACT_EMAIL || 'andy@charlwood.xyz';
+ try {
+ // Admin notification
+ await transporter.sendMail({
+ from: `"${name}" <${process.env.SMTP_USER}>`,
+ replyTo: email,
+ to: contactEmail,
+ subject: `Portfolio Referral: ${subject}`,
+ html: `
+
+
+ New Patient Referral
+
+
+
Referring Clinician: ${name}
+
Organisation: ${organisation || 'Not specified'}
+
Email: ${email}
+
Subject: ${subject}
+
+
+
Clinical Details:
+
${message}
+
+
+
+ This message was sent from your portfolio contact form.
+
+
+ `,
+ });
+ // Auto-reply
+ await transporter.sendMail({
+ from: `"Andy Charlwood" <${process.env.SMTP_USER}>`,
+ to: email,
+ subject: 'Thanks for getting in touch!',
+ html: `
+
+
+ Thanks for your message, ${name}!
+
+
+ I've received your referral and will get back to you as soon as possible.
+
+
+
Your message:
+
${message}
+
+
+ Best regards,
+ Andy Charlwood
+ Informatics Pharmacist · NHS Norfolk & Waveney ICB
+
+
+
+ This is an automated confirmation. Please do not reply to this email.
+
+
+ `,
+ });
+ return res.status(200).json({ success: true, message: 'Referral sent successfully!' });
+ }
+ catch (error) {
+ console.error('Email error:', error);
+ return res.status(500).json({ success: false, message: 'Failed to send referral. Please try again.' });
+ }
+});
+// Chat proxy endpoint — keeps API key server-side
+app.post('/api/chat', async (req, res) => {
+ const apiKey = process.env.OPEN_ROUTER_API_KEY;
+ if (!apiKey) {
+ return res.status(500).json({ error: 'LLM API key not configured' });
+ }
+ try {
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ 'HTTP-Referer': req.headers.origin || req.headers.referer || '',
+ 'X-Title': 'Andy Charlwood Portfolio',
+ },
+ body: JSON.stringify(req.body),
+ });
+ if (!response.ok) {
+ return res.status(response.status).json({ error: `LLM API error: ${response.status}` });
+ }
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ const reader = response.body?.getReader();
+ if (!reader) {
+ return res.status(500).json({ error: 'No response body' });
+ }
+ const pump = async () => {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done)
+ break;
+ res.write(value);
+ }
+ res.end();
+ };
+ await pump();
+ }
+ catch (error) {
+ console.error('Chat proxy error:', error);
+ if (!res.headersSent) {
+ return res.status(500).json({ error: 'Failed to proxy chat request' });
+ }
+ res.end();
+ }
+});
+// SPA fallback
+app.get('*', (_req, res) => {
+ res.sendFile(path.join(__dirname, 'dist', 'index.html'));
+});
+const PORT = process.env.PORT || 3000;
+app.listen(PORT, () => {
+ console.log(`Server running on port ${PORT}`);
+});
diff --git a/server.ts b/server.ts
index f905c78..6248a9c 100644
--- a/server.ts
+++ b/server.ts
@@ -104,6 +104,56 @@ app.post('/api/contact', async (req, res) => {
}
})
+// Chat proxy endpoint — keeps API key server-side
+app.post('/api/chat', async (req, res) => {
+ const apiKey = process.env.OPEN_ROUTER_API_KEY
+ if (!apiKey) {
+ return res.status(500).json({ error: 'LLM API key not configured' })
+ }
+
+ try {
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ 'HTTP-Referer': req.headers.origin || req.headers.referer || '',
+ 'X-Title': 'Andy Charlwood Portfolio',
+ },
+ body: JSON.stringify(req.body),
+ })
+
+ if (!response.ok) {
+ return res.status(response.status).json({ error: `LLM API error: ${response.status}` })
+ }
+
+ res.setHeader('Content-Type', 'text/event-stream')
+ res.setHeader('Cache-Control', 'no-cache')
+ res.setHeader('Connection', 'keep-alive')
+
+ const reader = response.body?.getReader()
+ if (!reader) {
+ return res.status(500).json({ error: 'No response body' })
+ }
+
+ const pump = async () => {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ res.write(value)
+ }
+ res.end()
+ }
+ await pump()
+ } catch (error) {
+ console.error('Chat proxy error:', error)
+ if (!res.headersSent) {
+ return res.status(500).json({ error: 'Failed to proxy chat request' })
+ }
+ res.end()
+ }
+})
+
// SPA fallback
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'))
diff --git a/src/lib/llm.ts b/src/lib/llm.ts
index be23937..c8f182a 100644
--- a/src/lib/llm.ts
+++ b/src/lib/llm.ts
@@ -8,14 +8,8 @@ export interface ChatMessage {
export const LLM_MODEL = 'z-ai/glm-5'
export const LLM_DISPLAY_NAME = 'GLM-5'
-const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
-
-function getApiKey(): string | undefined {
- return import.meta.env.VITE_OPEN_ROUTER_API_KEY as string | undefined
-}
-
export function isLLMAvailable(): boolean {
- return !!getApiKey()
+ return true
}
export function buildSystemPrompt(): string {
@@ -44,22 +38,12 @@ function buildRequestBody(
export async function* sendChatMessage(
messages: ChatMessage[],
): AsyncGenerator {
- const apiKey = getApiKey()
- if (!apiKey) {
- throw new Error('LLM API key not configured')
- }
-
const systemPrompt = buildSystemPrompt()
const body = buildRequestBody(messages, systemPrompt)
- const response = await fetch(OPENROUTER_API_URL, {
+ const response = await fetch('/api/chat', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${apiKey}`,
- 'HTTP-Referer': window.location.origin,
- 'X-Title': 'Andy Charlwood Portfolio',
- },
+ headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
diff --git a/tsconfig.server.json b/tsconfig.server.json
index 3f6f237..f9957fc 100644
--- a/tsconfig.server.json
+++ b/tsconfig.server.json
@@ -1,14 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
- "module": "ESNext",
- "moduleResolution": "node",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
"esModuleInterop": true,
"outDir": "dist-server",
"rootDir": ".",
"strict": true,
- "skipLibCheck": true,
- "include": ["server.ts"]
+ "skipLibCheck": true
},
"include": ["server.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index 0d51f19..4f387b4 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -9,4 +9,9 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
+ server: {
+ proxy: {
+ '/api': 'http://localhost:3000',
+ },
+ },
})