diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b6e08bd..4553aac 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,8 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.100.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.18.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
@@ -1454,6 +1455,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cookie-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
@@ -1770,6 +1784,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rollup": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
@@ -1856,6 +1908,12 @@
"seroval": "^1.0"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 48062d0..e21fc19 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,8 @@
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.100.0",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.18.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
diff --git a/frontend/src/components/ContactEdit.tsx b/frontend/src/components/ContactEdit.tsx
new file mode 100644
index 0000000..6be1a01
--- /dev/null
+++ b/frontend/src/components/ContactEdit.tsx
@@ -0,0 +1,36 @@
+import { useState, useEffect } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { api } from "../lib/api";
+
+export default function ContactEdit() {
+ const { id } = useParams();
+ const [name, setName] = useState("");
+ const [number, setNumber] = useState("");
+ const [email, setEmail] = useState("");
+ const qc = useQueryClient();
+ const { data, isLoading, error } = useQuery({ queryKey: ["contacts", id], queryFn: () => api.getContact(Number(id)), enabled: !!id });
+ useEffect(() => { if (data) { setName(data.name || ""); setNumber(data.number_e164 || ""); setEmail(data.email || ""); } }, [data]);
+ const save = useMutation({
+ mutationFn: () => api.updateContact(Number(id), { name, number, email }),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); window.location.href = "/contacts?msg=Saved"; },
+ });
+ if (isLoading) return
;
+ if (error || !data) return ;
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ContactHistory.tsx b/frontend/src/components/ContactHistory.tsx
new file mode 100644
index 0000000..0450b15
--- /dev/null
+++ b/frontend/src/components/ContactHistory.tsx
@@ -0,0 +1,29 @@
+import { useQuery } from "@tanstack/react-query";
+import { useParams } from "react-router-dom";
+import { api } from "../lib/api";
+
+export default function ContactHistory() {
+ const { num } = useParams();
+ const { data, isLoading, error } = useQuery({ queryKey: ["history", num], queryFn: () => api.contactHistory(num || ""), enabled: !!num });
+ return (
+
+
+
+
← Back to contacts
+ {data && <> · {data.messages?.length ?? 0} message(s) from
{data.contact}>}
+
+
+ {isLoading &&
Loading...
}
+ {error &&
{(error as Error).message}
}
+ {data?.messages?.map((m) => (
+
+
{data.contact}
+
{m.time}{m.duration_fmt ? ` · ${m.duration_fmt}` : ""}
+
{m.summary || "(no speech detected)"}
+ {m.has_audio &&
}
+
+ ))}
+ {data?.messages?.length === 0 &&
No voicemails from this caller yet.
}
+
+ );
+}
diff --git a/frontend/src/components/ContactNew.tsx b/frontend/src/components/ContactNew.tsx
new file mode 100644
index 0000000..7792ec2
--- /dev/null
+++ b/frontend/src/components/ContactNew.tsx
@@ -0,0 +1,33 @@
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../lib/api";
+
+export default function ContactNew() {
+ const [name, setName] = useState("");
+ const [number, setNumber] = useState("");
+ const [email, setEmail] = useState("");
+ const [error, setError] = useState("");
+ const qc = useQueryClient();
+ const save = useMutation({
+ mutationFn: () => api.createContact({ name, number, email }),
+ onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); window.location.href = "/contacts?msg=Created"; },
+ });
+ const submit = (e: React.FormEvent) => { e.preventDefault(); if (!name.trim()) return setError("Name required"); save.mutate(); };
+ return (
+
+
+
New contact
+ {error &&
{error}
}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ContactsPage.tsx b/frontend/src/components/ContactsPage.tsx
new file mode 100644
index 0000000..7f2d4e1
--- /dev/null
+++ b/frontend/src/components/ContactsPage.tsx
@@ -0,0 +1,80 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../lib/api";
+import { useState } from "react";
+
+export default function ContactsPage() {
+ const [q, setQ] = useState("");
+ const qc = useQueryClient();
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["contacts", q],
+ queryFn: () => api.contacts(q || undefined),
+ });
+
+ const importVcf = useMutation({
+ mutationFn: api.importVcf,
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["contacts"] }),
+ });
+
+ return (
+ <>
+
+
+
setQ(e.target.value)} placeholder="Search contacts..." style={{ flex: 1, minWidth: 180 }} />
+ {q &&
Clear}
+
+
+
+ {isLoading && Loading...
}
+ {error && {(error as Error).message}
}
+
+ {data?.length ? (
+
+
+ {data.length} contacts
+ {" · "}
+
+ New
+ {" · "}
+
+
+
+
+ {data.map((c) => (
+
+
+ {c.name || "Unnamed"}
+
+ {c.number_e164} {c.email && `· ${c.email}`}
+
+ |
+
+ Edit
+ {" "}
+ History
+ {" "}
+
+ |
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+}
+
+function DeleteBtn({ id, name }: { id: number; name?: string }) {
+ const qc = useQueryClient();
+ const del = useMutation({
+ mutationFn: () => api.deleteContact(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["contacts"] }),
+ });
+ return ;
+}
diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx
new file mode 100644
index 0000000..db1ba67
--- /dev/null
+++ b/frontend/src/components/LoginPage.tsx
@@ -0,0 +1,23 @@
+import { useState } from "react";
+import { useMutation } from "@tanstack/react-query";
+import { api } from "../lib/api";
+
+export default function LoginPage() {
+ const [mailbox, setMailbox] = useState("");
+ const [pin, setPin] = useState("");
+ const [error, setError] = useState("");
+ const login = useMutation({ mutationFn: () => api.login(mailbox, pin) });
+ const submit = (e: React.FormEvent) => { e.preventDefault(); setError(""); login.mutate(undefined, { onSuccess: () => { window.location.href = "/"; }, onError: (err) => setError((err as Error).message) }); };
+ return (
+
+ );
+}
diff --git a/frontend/src/components/LogoutPage.tsx b/frontend/src/components/LogoutPage.tsx
new file mode 100644
index 0000000..948cec0
--- /dev/null
+++ b/frontend/src/components/LogoutPage.tsx
@@ -0,0 +1,7 @@
+import { useEffect } from "react";
+import { api } from "../lib/api";
+
+export default function LogoutPage() {
+ useEffect(() => { api.logout().then(() => { window.location.href = "/login"; }); }, []);
+ return ;
+}
diff --git a/frontend/src/components/MessagesPage.tsx b/frontend/src/components/MessagesPage.tsx
new file mode 100644
index 0000000..8e07f32
--- /dev/null
+++ b/frontend/src/components/MessagesPage.tsx
@@ -0,0 +1,109 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../lib/api";
+import { useState } from "react";
+
+export default function MessagesPage() {
+ const [q, setQ] = useState("");
+ const [dateFrom, setDateFrom] = useState("");
+ const [dateTo, setDateTo] = useState("");
+ const [unreadOnly, setUnreadOnly] = useState(false);
+ const [expanded, setExpanded] = useState(null);
+ const qc = useQueryClient();
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["messages", q, dateFrom, dateTo, unreadOnly],
+ queryFn: () => api.messages({ q, date_from: dateFrom, date_to: dateTo, unread: unreadOnly ? "1" : "" }),
+ });
+
+ const toggleRead = useMutation({
+ mutationFn: (id: number) => api.toggleRead(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }),
+ });
+ const deleteMsg = useMutation({
+ mutationFn: (id: number) => api.deleteMessage(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["messages"] }),
+ });
+
+ const onSubmit = (e: React.FormEvent) => e.preventDefault();
+
+ return (
+ <>
+
+
+ {isLoading && Loading...
}
+ {error && {(error as Error).message}
}
+
+ {data?.messages?.map((m) => {
+ const whoRaw = m.contact_name || m.callerid || "Unknown caller";
+ const num = (m.callerid || "").replace(/[^\d+]/g, "");
+ const showAdd = !m.contact_name && num;
+ return (
+
+
+ {m.contact_name && num ? (
+
{whoRaw}
+ ) : (
+
{whoRaw}
+ )}
+
+
+ {m.time}{m.duration_fmt ? ` · ${m.duration_fmt}` : ""}
+
+
{m.summary || "(no speech detected)"}
+ {m.transcript && (
+
+ Full transcript
+ {m.transcript}
+
+ )}
+ {m.tags?.length ? (
+
+ {m.tags.map((t: string) => {t})}
+
+ ) : null}
+ {m.numbers?.length ? (
+
+ 📞 Callback:{" "}
+ {m.numbers.map((n: string) => {
+ const digits = n.replace(/[^\d+]/g, "");
+ return
{n};
+ }).reduce((prev: any, curr: any, i: number) => i === 0 ? [curr] : [...prev, " · ", curr], [])}
+
+ ) : null}
+ {m.has_audio &&
}
+
+
+ {m.has_audio &&
Download}
+
+ {showAdd && (
+
+ )}
+
+
+ );
+ })}
+
+ {data?.messages?.length === 0 && (
+ No voicemails match your filters.
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/RootLayout.tsx b/frontend/src/components/RootLayout.tsx
new file mode 100644
index 0000000..cd37851
--- /dev/null
+++ b/frontend/src/components/RootLayout.tsx
@@ -0,0 +1,19 @@
+import { Outlet } from "react-router-dom";
+
+export default function RootLayout() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
new file mode 100644
index 0000000..554a490
--- /dev/null
+++ b/frontend/src/components/SettingsPage.tsx
@@ -0,0 +1,67 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { api } from "../lib/api";
+
+export default function SettingsPage() {
+ const qc = useQueryClient();
+ const { data, isLoading, error } = useQuery({ queryKey: ["settings"], queryFn: api.settings });
+ const save = useMutation({
+ mutationFn: api.saveSettings,
+ onSuccess: () => alert("Settings saved"),
+ });
+ if (isLoading) return ;
+ if (error) return {(error as Error).message}
;
+ if (!data) return null;
+
+ const s = (k: string, fallback = "") => (data as any)?.[k] ?? fallback;
+ const yn = (k: string) => s(k, "no") === "yes";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..429064d
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,440 @@
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ --bg-base: #05070a;
+ --bg-surface: #0a0e17;
+ --bg-card: #0d1320;
+ --bg-elevated: #111827;
+ --border: #1a2332;
+ --border-glow: #00d4ff33;
+ --text-primary: #e6f1ff;
+ --text-secondary: #8b9fc5;
+ --text-muted: #4a5a74;
+ --accent-cyan: #00d4ff;
+ --accent-purple: #a855f7;
+ --accent-pink: #ec4899;
+ --accent-green: #00ff88;
+ --accent-amber: #f59e0b;
+ --danger: #ef4444;
+ --danger-hover: #dc2626;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+ --font-sans: 'Inter', system-ui, sans-serif;
+ --glow-cyan: 0 0 8px rgba(0, 212, 255, 0.5), 0 0 20px rgba(0, 212, 255, 0.2);
+ --glow-purple: 0 0 8px rgba(168, 85, 247, 0.5), 0 0 20px rgba(168, 85, 247, 0.2);
+ --glow-green: 0 0 8px rgba(0, 255, 136, 0.5), 0 0 20px rgba(0, 255, 136, 0.2);
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+html, body {
+ background: var(--bg-base);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.6;
+ min-height: 100vh;
+ background-image:
+ linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
+ background-size: 50px 50px;
+}
+
+a { color: var(--accent-cyan); text-decoration: none; transition: all 0.2s; }
+a:hover { color: #fff; text-shadow: var(--glow-cyan); }
+
+header {
+ background: linear-gradient(180deg, rgba(10, 14, 23, 0.98) 0%, rgba(5, 7, 10, 0.95) 100%);
+ border-bottom: 1px solid var(--border);
+ padding: 14px 22px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 2px 20px rgba(0, 0, 0, 0.5);
+}
+
+header h1 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+ font-family: var(--font-mono);
+ background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ letter-spacing: -0.02em;
+ text-shadow: none;
+}
+
+header .sp { flex: 1; }
+
+header a {
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 600;
+ padding: 6px 12px;
+ border-radius: 6px;
+ transition: all 0.2s;
+ border: 1px solid transparent;
+}
+
+header a:hover {
+ color: var(--accent-cyan);
+ border-color: var(--border-glow);
+ background: rgba(0, 212, 255, 0.05);
+}
+
+.wrap { max-width: 960px; margin: 24px auto; padding: 0 16px; }
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 18px 20px;
+ margin-bottom: 14px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ transition: all 0.2s;
+}
+
+.card:hover {
+ border-color: #2a3a52;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 212, 255, 0.05);
+}
+
+.card.unread {
+ border-left: 3px solid var(--accent-cyan);
+ background: linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0%, var(--bg-card) 30%);
+}
+
+.meta {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 6px;
+ font-family: var(--font-mono);
+ letter-spacing: 0.01em;
+}
+
+.who {
+ font-weight: 700;
+ font-size: 16px;
+ margin-bottom: 4px;
+ color: var(--text-primary);
+}
+
+.who a { color: var(--accent-cyan); font-weight: 700; }
+.who a:hover { color: #fff; text-shadow: var(--glow-cyan); }
+
+.sum {
+ background: rgba(0, 212, 255, 0.03);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px 14px;
+ margin: 10px 0;
+ color: var(--text-primary);
+ font-size: 14px;
+}
+
+.tag {
+ display: inline-block;
+ background: rgba(168, 85, 247, 0.1);
+ color: var(--accent-purple);
+ border: 1px solid rgba(168, 85, 247, 0.2);
+ border-radius: 999px;
+ padding: 3px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ font-family: var(--font-mono);
+ margin: 0 5px 5px 0;
+ letter-spacing: 0.02em;
+}
+
+.tr {
+ white-space: pre-wrap;
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin-top: 8px;
+ background: rgba(0, 0, 0, 0.3);
+ padding: 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.row {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+button, .btn {
+ font: 600 13px/1 var(--font-sans);
+ padding: 8px 14px;
+ border-radius: 7px;
+ border: 1px solid var(--border);
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+button:hover, .btn:hover {
+ background: #1a2332;
+ border-color: #3a4a62;
+ transform: translateY(-1px);
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.danger {
+ border-color: rgba(239, 68, 68, 0.3);
+ color: var(--danger);
+ background: rgba(239, 68, 68, 0.05);
+}
+
+.danger:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: var(--danger);
+ box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
+}
+
+.primary {
+ background: linear-gradient(135deg, var(--accent-cyan), #0099cc);
+ border-color: var(--accent-cyan);
+ color: #000;
+ font-weight: 700;
+ box-shadow: 0 0 8px rgba(0, 212, 255, 0.3);
+}
+
+.primary:hover {
+ background: linear-gradient(135deg, #33ddff, var(--accent-cyan));
+ box-shadow: var(--glow-cyan);
+ transform: translateY(-1px);
+}
+
+.add-btn {
+ font-size: 12px;
+ padding: 5px 10px;
+ border-radius: 6px;
+ background: rgba(168, 85, 247, 0.08);
+ color: var(--accent-purple);
+ border: 1px solid rgba(168, 85, 247, 0.25);
+ cursor: pointer;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+.add-btn:hover {
+ background: rgba(168, 85, 247, 0.15);
+ border-color: var(--accent-purple);
+ box-shadow: var(--glow-purple);
+}
+
+input[type="text"],
+input[type="password"],
+input[type="email"],
+input[type="date"] {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ font: 14px var(--font-sans);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ transition: all 0.2s;
+}
+
+input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1), var(--glow-cyan);
+}
+
+input[type="date"] {
+ width: auto;
+ color-scheme: dark;
+}
+
+label {
+ display: block;
+ margin: 12px 0 5px;
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-secondary);
+ letter-spacing: 0.02em;
+}
+
+.hint {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-weight: 400;
+}
+
+.empty {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 40px 10px;
+ font-family: var(--font-mono);
+}
+
+.unread { border-left: 4px solid var(--accent-cyan); }
+
+.err {
+ background: rgba(239, 68, 68, 0.08);
+ border: 1px solid rgba(239, 68, 68, 0.25);
+ color: #fca5a5;
+ padding: 10px 12px;
+ border-radius: 8px;
+ margin-bottom: 12px;
+ font-size: 14px;
+}
+
+.ok {
+ background: rgba(0, 255, 136, 0.06);
+ border: 1px solid rgba(0, 255, 136, 0.2);
+ color: #6ee7a3;
+ padding: 10px 12px;
+ border-radius: 8px;
+ margin-bottom: 12px;
+ font-size: 14px;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+td {
+ padding: 10px 6px;
+ vertical-align: top;
+ border-bottom: 1px solid var(--border);
+}
+
+tr:hover td { background: rgba(255, 255, 255, 0.01); }
+
+.modal-bg {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+}
+
+.modal-bg.open { display: flex; }
+
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--accent-cyan);
+ border-radius: 12px;
+ padding: 22px 24px;
+ max-width: 420px;
+ width: 92%;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 30px rgba(0, 212, 255, 0.1);
+}
+
+.modal h3 {
+ margin: 0 0 12px;
+ font-size: 16px;
+ font-family: var(--font-mono);
+ color: var(--accent-cyan);
+}
+
+.modal label {
+ font-weight: 600;
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin: 10px 0 4px;
+ display: block;
+}
+
+.modal input {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ font: 14px var(--font-sans);
+ background: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+.modal input:focus {
+ outline: none;
+ border-color: var(--accent-cyan);
+ box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1);
+}
+
+.modal .row {
+ display: flex;
+ gap: 8px;
+ margin-top: 14px;
+ justify-content: flex-end;
+}
+
+audio {
+ width: 100%;
+ margin-top: 10px;
+ filter: invert(0.9) hue-rotate(180deg);
+ opacity: 0.9;
+}
+
+details {
+ margin-top: 8px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+summary {
+ cursor: pointer;
+ color: var(--accent-cyan);
+ font-size: 13px;
+ font-weight: 600;
+ padding: 8px 12px;
+ background: rgba(0, 212, 255, 0.03);
+ transition: all 0.2s;
+ user-select: none;
+}
+
+summary:hover {
+ background: rgba(0, 212, 255, 0.07);
+ color: #fff;
+}
+
+::selection {
+ background: rgba(0, 212, 255, 0.3);
+ color: #fff;
+}
+
+::-webkit-scrollbar { width: 8px; }
+::-webkit-scrollbar-track { background: var(--bg-base); }
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+::-webkit-scrollbar-thumb:hover { background: #3a4a62; }
+
+@media (max-width: 640px) {
+ header { padding: 12px 14px; gap: 8px; }
+ header h1 { font-size: 17px; }
+ .wrap { padding: 0 10px; margin: 14px auto; }
+ .card { padding: 14px 15px; }
+ .row { gap: 6px; }
+ button, .btn { padding: 7px 11px; font-size: 12px; }
+}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 45eedb9..2ccaa04 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,7 +1,7 @@
-const API_BASE = import.meta.env.VITE_API_URL || "/api";
+const API_BASE = "/api";
async function request(path: string, opts: RequestInit = {}) {
- const res = await fetch(`${API_BASE}${path}`, {
+ const res = await fetch(API_BASE + path, {
...opts,
credentials: "include",
});
@@ -11,7 +11,7 @@ async function request(path: string, opts: RequestInit = {}) {
}
if (!res.ok) {
const text = await res.text();
- throw new Error(text || `HTTP ${res.status}`);
+ throw new Error(text || "HTTP " + res.status);
}
if (res.status === 204) return null;
return res.json();
@@ -27,33 +27,37 @@ export const api = {
logout: () => request("/logout", { method: "POST" }),
messages: (params?: Record) => {
const qs = new URLSearchParams(params).toString();
- return request(`/messages${qs ? "?" + qs : ""}`);
+ return request("/messages" + (qs ? "?" + qs : ""));
},
- toggleRead: (id: number) => request(`/messages/${id}/read`, { method: "POST" }),
- deleteMessage: (id: number) => request(`/messages/${id}/delete`, { method: "POST" }),
+ toggleRead: (id: number) => request("/messages/" + id + "/read", { method: "POST" }),
+ deleteMessage: (id: number) => request("/messages/" + id + "/delete", { method: "POST" }),
settings: () => request("/settings"),
saveSettings: (formData: FormData) =>
request("/settings", { method: "POST", body: formData }),
contacts: (q?: string) =>
- request(`/contacts${q ? "?q=" + encodeURIComponent(q) : ""}`),
- getContact: (id: number) => request(`/contacts/${id}`),
+ request("/contacts" + (q ? "?q=" + encodeURIComponent(q) : "")),
+ getContact: (id: number) => request("/contacts/" + id),
createContact: (data: { name: string; number?: string; email?: string | null }) =>
request("/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
- updateContact: (
- id: number,
- data: { name: string; number?: string; email?: string | null }
- ) =>
- request(`/contacts/${id}`, {
+ updateContact: (id: number, data: { name: string; number?: string; email?: string | null }) =>
+ request("/contacts/" + id, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
- deleteContact: (id: number) => request(`/contacts/${id}`, { method: "DELETE" }),
+ deleteContact: (id: number) => request("/contacts/" + id, { method: "DELETE" }),
importVcf: () => request("/contacts/import_vcf", { method: "POST" }),
contactHistory: (num: string) =>
- request(`/contacts/history/${encodeURIComponent(num)}`),
+ request("/contacts/history/" + encodeURIComponent(num)),
+ addContact: (data: { number?: string; name: string; email?: string | null }) =>
+ request("/add_contact", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ }),
+ getTranscript: (id: number) => request("/transcript/" + id),
};
diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts
new file mode 100644
index 0000000..6a32a9e
--- /dev/null
+++ b/frontend/src/lib/query.ts
@@ -0,0 +1,4 @@
+import { QueryClient } from "@tanstack/react-query";
+export const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
+});
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index c8ec102..2d87848 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,25 +1,39 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { RouterProvider, createRouter } from "@tanstack/react-router";
-import { routeTree } from "./routeTree.gen";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "./lib/query";
+import { createBrowserRouter, RouterProvider } from "react-router-dom";
+import RootLayout from "./components/RootLayout";
+import MessagesPage from "./components/MessagesPage";
+import ContactsPage from "./components/ContactsPage";
+import ContactNew from "./components/ContactNew";
+import ContactEdit from "./components/ContactEdit";
+import ContactHistory from "./components/ContactHistory";
+import SettingsPage from "./components/SettingsPage";
+import LoginPage from "./components/LoginPage";
+import LogoutPage from "./components/LogoutPage";
+import "./index.css";
-const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
-});
+const router = createBrowserRouter([
+ {
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "contacts", element: },
+ { path: "contacts/new", element: },
+ { path: "contacts/edit/:id", element: },
+ { path: "contacts/history/:num", element: },
+ { path: "settings", element: },
+ { path: "logout", element: },
+ ],
+ },
+ { path: "/login", element: },
+]);
-const router = createRouter({ routeTree });
-
-function App() {
- return (
+createRoot(document.getElementById("root")!).render(
+
- );
-}
-
-ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
-
+
);
diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts
deleted file mode 100644
index 5fd30fe..0000000
--- a/frontend/src/routeTree.gen.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { createRouteTree } from "@tanstack/react-router";
-import { Route as loginRoute } from "./login";
-import { Route as indexRoute } from "./index";
-import { Route as contactsRoute } from "./contacts";
-import { Route as contactsNewRoute } from "./contacts/new";
-import { Route as contactsEditIdRoute } from "./contacts/edit/$id";
-import { Route as contactsHistoryNumRoute } from "./contacts/history/$num";
-
-export const routeTree = createRouteTree()
- .add(loginRoute)
- .add(indexRoute)
- .add(contactsRoute)
- .add(contactsNewRoute)
- .add(contactsEditIdRoute)
- .add(contactsHistoryNumRoute);
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 076d4dc..5270665 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,14 +3,7 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
- server: {
- host: "0.0.0.0",
- port: 5173,
- proxy: { "/api": "http://127.0.0.1:8098" },
- },
resolve: {
- alias: {
- "@": new URL("./src/", import.meta.url).pathname,
- },
+ extensions: [".tsx", ".ts", ".js", ".jsx"],
},
});