From 489241b798732620a55eb98f1701462bd4e1bdb6 Mon Sep 17 00:00:00 2001 From: jp Date: Thu, 13 Aug 2026 18:22:03 +0100 Subject: [PATCH] Sync React frontend into git - Replace stale TanStack Router scaffold with working react-router-dom app - Add all 9 page components + cyber theme CSS - Remove routeTree.gen.ts, add index.css, query.ts --- frontend/package-lock.json | 60 ++- frontend/package.json | 3 +- frontend/src/components/ContactEdit.tsx | 36 ++ frontend/src/components/ContactHistory.tsx | 29 ++ frontend/src/components/ContactNew.tsx | 33 ++ frontend/src/components/ContactsPage.tsx | 80 ++++ frontend/src/components/LoginPage.tsx | 23 ++ frontend/src/components/LogoutPage.tsx | 7 + frontend/src/components/MessagesPage.tsx | 109 +++++ frontend/src/components/RootLayout.tsx | 19 + frontend/src/components/SettingsPage.tsx | 67 ++++ frontend/src/index.css | 440 +++++++++++++++++++++ frontend/src/lib/api.ts | 34 +- frontend/src/lib/query.ts | 4 + frontend/src/main.tsx | 52 ++- frontend/src/routeTree.gen.ts | 15 - frontend/vite.config.ts | 9 +- 17 files changed, 961 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/ContactEdit.tsx create mode 100644 frontend/src/components/ContactHistory.tsx create mode 100644 frontend/src/components/ContactNew.tsx create mode 100644 frontend/src/components/ContactsPage.tsx create mode 100644 frontend/src/components/LoginPage.tsx create mode 100644 frontend/src/components/LogoutPage.tsx create mode 100644 frontend/src/components/MessagesPage.tsx create mode 100644 frontend/src/components/RootLayout.tsx create mode 100644 frontend/src/components/SettingsPage.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/query.ts delete mode 100644 frontend/src/routeTree.gen.ts 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
Loading...
; + if (error || !data) return
Contact not found
; + return ( +
+
+

Edit contact

+
{ e.preventDefault(); save.mutate(); }}> + setName(e.target.value)} required /> + setNumber(e.target.value)} /> + setEmail(e.target.value)} /> +
+ Cancel + +
+
+
+
+ ); +} 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}
} +
+ setName(e.target.value)} required /> + setNumber(e.target.value)} /> + setEmail(e.target.value)} /> +
+ Cancel + +
+
+
+
+ ); +} 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 + {" "} + +
+
+ ) : ( +
+ No contacts yet. +
+ + New contact +
+
+ )} + + ); +} + +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 ( +
+
+ {error &&
{error}
} +
+ setMailbox(e.target.value)} inputMode="numeric" /> + setPin(e.target.value)} inputMode="numeric" /> +
+
+
+
+ ); +} 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
Logging out...
; +} 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 ( + <> +
+
+ setQ(e.target.value)} placeholder="Search caller, transcript..." style={{ flex: 1, minWidth: 180 }} /> + setDateFrom(e.target.value)} style={{ width: "auto" }} /> + setDateTo(e.target.value)} style={{ width: "auto" }} /> + + + {(q || dateFrom || dateTo || unreadOnly) && Clear all} +
+
+ + {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 &&
+ ); + })} + + {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 ( + <> +
+

✉ Voicemail

+ + Messages + Contacts + Settings + Log out +
+
+ +
+ + ); +} 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
Loading...
; + 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 ( +
+
+
{ + e.preventDefault(); + const fd = new FormData(e.currentTarget); + save.mutate(fd); + }} + > + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ ); +} 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"], }, });