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
This commit is contained in:
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@ -11,7 +11,8 @@
|
|||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.100.0",
|
"@tanstack/react-router": "^1.100.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
@ -1454,6 +1455,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cookie-es": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
|
||||||
@ -1770,6 +1784,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.4",
|
"version": "4.62.4",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||||
@ -1856,6 +1908,12 @@
|
|||||||
"seroval": "^1.0"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.100.0",
|
"@tanstack/react-router": "^1.100.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|||||||
36
frontend/src/components/ContactEdit.tsx
Normal file
36
frontend/src/components/ContactEdit.tsx
Normal file
@ -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 <div className="wrap"><div className="card empty">Loading...</div></div>;
|
||||||
|
if (error || !data) return <div className="wrap"><div className="card err">Contact not found</div></div>;
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>Edit contact</h3>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
|
||||||
|
<label>Name</label><input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
<label>Phone number</label><input value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
<label>Email</label><input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<div className="row">
|
||||||
|
<a className="btn" href="/contacts">Cancel</a>
|
||||||
|
<button type="submit" className="primary" disabled={save.isPending}>Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
frontend/src/components/ContactHistory.tsx
Normal file
29
frontend/src/components/ContactHistory.tsx
Normal file
@ -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 (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card" style={{ padding: "12px 16px" }}>
|
||||||
|
<div className="meta">
|
||||||
|
<a href="/contacts">← Back to contacts</a>
|
||||||
|
{data && <> · {data.messages?.length ?? 0} message(s) from <b>{data.contact}</b></>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isLoading && <div className="card empty">Loading...</div>}
|
||||||
|
{error && <div className="card err">{(error as Error).message}</div>}
|
||||||
|
{data?.messages?.map((m) => (
|
||||||
|
<div key={m.id} className={"card" + (m.is_read ? "" : " unread")}>
|
||||||
|
<div className="who">{data.contact}</div>
|
||||||
|
<div className="meta">{m.time}{m.duration_fmt ? ` · ${m.duration_fmt}` : ""}</div>
|
||||||
|
<div className="sum">{m.summary || "(no speech detected)"}</div>
|
||||||
|
{m.has_audio && <audio controls preload="none" src={`/audio/${m.id}`} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{data?.messages?.length === 0 && <div className="card empty">No voicemails from this caller yet.</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/ContactNew.tsx
Normal file
33
frontend/src/components/ContactNew.tsx
Normal file
@ -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 (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginTop: 0 }}>New contact</h3>
|
||||||
|
{error && <div className="err">{error}</div>}
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>Name</label><input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
<label>Phone number</label><input value={number} onChange={(e) => setNumber(e.target.value)} />
|
||||||
|
<label>Email</label><input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
<div className="row">
|
||||||
|
<a className="btn" href="/contacts">Cancel</a>
|
||||||
|
<button type="submit" className="primary" disabled={save.isPending}>Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/ContactsPage.tsx
Normal file
80
frontend/src/components/ContactsPage.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="card" style={{ padding: "12px 16px" }}>
|
||||||
|
<div className="row" style={{ alignItems: "center", gap: 8 }}>
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search contacts..." style={{ flex: 1, minWidth: 180 }} />
|
||||||
|
{q && <a className="btn" href="/contacts">Clear</a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <div className="card empty">Loading...</div>}
|
||||||
|
{error && <div className="card err">{(error as Error).message}</div>}
|
||||||
|
|
||||||
|
{data?.length ? (
|
||||||
|
<div className="card">
|
||||||
|
<div className="meta">
|
||||||
|
{data.length} contacts
|
||||||
|
{" · "}
|
||||||
|
<a href="/contacts/new">+ New</a>
|
||||||
|
{" · "}
|
||||||
|
<button className="btn" onClick={() => importVcf.mutate()}>Import from VCF</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{data.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>
|
||||||
|
<b>{c.name || "Unnamed"}</b><br />
|
||||||
|
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||||
|
{c.number_e164} {c.email && `· ${c.email}`}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||||
|
<a className="btn" href={`/contacts/edit/${c.id}`}>Edit</a>
|
||||||
|
{" "}
|
||||||
|
<a className="btn" href={`/contacts/history/${encodeURIComponent(c.number_e164 || c.name)}`}>History</a>
|
||||||
|
{" "}
|
||||||
|
<DeleteBtn id={c.id} name={c.name} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card empty">
|
||||||
|
No contacts yet.
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<a className="btn" href="/contacts/new">+ New contact</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteBtn({ id, name }: { id: number; name?: string }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: () => api.deleteContact(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["contacts"] }),
|
||||||
|
});
|
||||||
|
return <button className="danger" onClick={() => del.mutate()}>Delete</button>;
|
||||||
|
}
|
||||||
23
frontend/src/components/LoginPage.tsx
Normal file
23
frontend/src/components/LoginPage.tsx
Normal file
@ -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 (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card">
|
||||||
|
{error && <div className="err">{error}</div>}
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>Mailbox number</label><input value={mailbox} onChange={(e) => setMailbox(e.target.value)} inputMode="numeric" />
|
||||||
|
<label>PIN</label><input type="password" value={pin} onChange={(e) => setPin(e.target.value)} inputMode="numeric" />
|
||||||
|
<div className="row"><button type="submit" className="primary">Log in</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/src/components/LogoutPage.tsx
Normal file
7
frontend/src/components/LogoutPage.tsx
Normal file
@ -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 <div className="wrap"><div className="card empty">Logging out...</div></div>;
|
||||||
|
}
|
||||||
109
frontend/src/components/MessagesPage.tsx
Normal file
109
frontend/src/components/MessagesPage.tsx
Normal file
@ -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<number | null>(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 (
|
||||||
|
<>
|
||||||
|
<form onSubmit={onSubmit} className="card" style={{ padding: "12px 16px" }}>
|
||||||
|
<div className="row" style={{ alignItems: "center", gap: 8 }}>
|
||||||
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search caller, transcript..." style={{ flex: 1, minWidth: 180 }} />
|
||||||
|
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} style={{ width: "auto" }} />
|
||||||
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} style={{ width: "auto" }} />
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} /> unread
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="primary">Filter</button>
|
||||||
|
{(q || dateFrom || dateTo || unreadOnly) && <a className="btn" href="/">Clear all</a>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isLoading && <div className="card empty">Loading...</div>}
|
||||||
|
{error && <div className="card err">{(error as Error).message}</div>}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div key={m.id} className={"card" + (m.is_read ? "" : " unread")}>
|
||||||
|
<div className="who">
|
||||||
|
{m.contact_name && num ? (
|
||||||
|
<a href={"/contacts/history/" + encodeURIComponent(num.replace(/^\+/, ""))}>{whoRaw}</a>
|
||||||
|
) : (
|
||||||
|
<span>{whoRaw}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="meta">
|
||||||
|
{m.time}{m.duration_fmt ? ` · ${m.duration_fmt}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="sum">{m.summary || "(no speech detected)"}</div>
|
||||||
|
{m.transcript && (
|
||||||
|
<details style={{ marginTop: 8 }}>
|
||||||
|
<summary>Full transcript</summary>
|
||||||
|
<div className="tr">{m.transcript}</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{m.tags?.length ? (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{m.tags.map((t: string) => <span key={t} className="tag">{t}</span>)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{m.numbers?.length ? (
|
||||||
|
<div className="meta" style={{ marginTop: 6 }}>
|
||||||
|
📞 Callback:{" "}
|
||||||
|
{m.numbers.map((n: string) => {
|
||||||
|
const digits = n.replace(/[^\d+]/g, "");
|
||||||
|
return <a key={n} href={"tel:" + digits}>{n}</a>;
|
||||||
|
}).reduce((prev: any, curr: any, i: number) => i === 0 ? [curr] : [...prev, " · ", curr], [])}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{m.has_audio && <audio controls preload="none" src={"/audio/" + m.id} />}
|
||||||
|
<div className="row">
|
||||||
|
<button onClick={() => toggleRead.mutate(m.id)}>
|
||||||
|
{m.is_read ? "Mark unread" : "Mark read"}
|
||||||
|
</button>
|
||||||
|
{m.has_audio && <a className="btn" href={"/audio/" + m.id + "?dl=1"}>Download</a>}
|
||||||
|
<button className="danger" onClick={() => deleteMsg.mutate(m.id)}>Delete</button>
|
||||||
|
{showAdd && (
|
||||||
|
<button className="add-btn" onClick={() => {
|
||||||
|
const name = prompt("Contact name:", whoRaw);
|
||||||
|
if (name !== null) {
|
||||||
|
(window as any).api?.addContact?.({ number: num, name }).then(() => qc.invalidateQueries({ queryKey: ["messages"] }));
|
||||||
|
}
|
||||||
|
}}>+ Add to contacts</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{data?.messages?.length === 0 && (
|
||||||
|
<div className="card empty">No voicemails match your filters.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/RootLayout.tsx
Normal file
19
frontend/src/components/RootLayout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<h1>✉ Voicemail</h1>
|
||||||
|
<span className="sp" />
|
||||||
|
<a href="/">Messages</a>
|
||||||
|
<a href="/contacts">Contacts</a>
|
||||||
|
<a href="/settings">Settings</a>
|
||||||
|
<a href="/logout">Log out</a>
|
||||||
|
</header>
|
||||||
|
<div className="wrap">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/SettingsPage.tsx
Normal file
67
frontend/src/components/SettingsPage.tsx
Normal file
@ -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 <div className="wrap"><div className="card empty">Loading...</div></div>;
|
||||||
|
if (error) return <div className="wrap"><div className="card err">{(error as Error).message}</div></div>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const s = (k: string, fallback = "") => (data as any)?.[k] ?? fallback;
|
||||||
|
const yn = (k: string) => s(k, "no") === "yes";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrap">
|
||||||
|
<div className="card">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
save.mutate(fd);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>Email address</label>
|
||||||
|
<input name="email_address" defaultValue={s("email_address")} />
|
||||||
|
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="email_enabled" defaultChecked={yn("email_enabled")} /> Email notifications
|
||||||
|
</label>
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="attach_audio" defaultChecked={yn("attach_audio")} /> Attach recording to email
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="telegram_enabled" defaultChecked={yn("telegram_enabled")} /> Send Telegram DM
|
||||||
|
</label>
|
||||||
|
<label>Telegram chat ID</label>
|
||||||
|
<input name="telegram_chat_id" defaultValue={s("telegram_chat_id")} />
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="telegram_audio" defaultChecked={yn("telegram_audio")} /> Include audio as voice note
|
||||||
|
</label>
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="telegram_transcript" defaultChecked={yn("telegram_transcript")} /> Send transcript follow-up
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="transcribe" defaultChecked={yn("transcribe")} /> Transcribe recordings
|
||||||
|
</label>
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="summarise" defaultChecked={yn("summarise")} /> Generate summary
|
||||||
|
</label>
|
||||||
|
<label style={{ fontWeight: 400, margin: 0, display: "flex", alignItems: "center", gap: 5 }}>
|
||||||
|
<input type="checkbox" name="contact_lookup" defaultChecked={yn("contact_lookup")} /> Resolve caller ID from contacts
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="row" style={{ marginTop: 16 }}>
|
||||||
|
<button type="submit" className="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
440
frontend/src/index.css
Normal file
440
frontend/src/index.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
@ -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 = {}) {
|
async function request(path: string, opts: RequestInit = {}) {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(API_BASE + path, {
|
||||||
...opts,
|
...opts,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@ -11,7 +11,7 @@ async function request(path: string, opts: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
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;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
@ -27,33 +27,37 @@ export const api = {
|
|||||||
logout: () => request("/logout", { method: "POST" }),
|
logout: () => request("/logout", { method: "POST" }),
|
||||||
messages: (params?: Record<string, string>) => {
|
messages: (params?: Record<string, string>) => {
|
||||||
const qs = new URLSearchParams(params).toString();
|
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" }),
|
toggleRead: (id: number) => request("/messages/" + id + "/read", { method: "POST" }),
|
||||||
deleteMessage: (id: number) => request(`/messages/${id}/delete`, { method: "POST" }),
|
deleteMessage: (id: number) => request("/messages/" + id + "/delete", { method: "POST" }),
|
||||||
settings: () => request("/settings"),
|
settings: () => request("/settings"),
|
||||||
saveSettings: (formData: FormData) =>
|
saveSettings: (formData: FormData) =>
|
||||||
request("/settings", { method: "POST", body: formData }),
|
request("/settings", { method: "POST", body: formData }),
|
||||||
contacts: (q?: string) =>
|
contacts: (q?: string) =>
|
||||||
request(`/contacts${q ? "?q=" + encodeURIComponent(q) : ""}`),
|
request("/contacts" + (q ? "?q=" + encodeURIComponent(q) : "")),
|
||||||
getContact: (id: number) => request(`/contacts/${id}`),
|
getContact: (id: number) => request("/contacts/" + id),
|
||||||
createContact: (data: { name: string; number?: string; email?: string | null }) =>
|
createContact: (data: { name: string; number?: string; email?: string | null }) =>
|
||||||
request("/contacts", {
|
request("/contacts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
updateContact: (
|
updateContact: (id: number, data: { name: string; number?: string; email?: string | null }) =>
|
||||||
id: number,
|
request("/contacts/" + id, {
|
||||||
data: { name: string; number?: string; email?: string | null }
|
|
||||||
) =>
|
|
||||||
request(`/contacts/${id}`, {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
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" }),
|
importVcf: () => request("/contacts/import_vcf", { method: "POST" }),
|
||||||
contactHistory: (num: string) =>
|
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),
|
||||||
};
|
};
|
||||||
|
|||||||
4
frontend/src/lib/query.ts
Normal file
4
frontend/src/lib/query.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
@ -1,25 +1,39 @@
|
|||||||
import React from "react";
|
import { StrictMode } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { queryClient } from "./lib/query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
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({
|
const router = createBrowserRouter([
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
{
|
||||||
});
|
element: <RootLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <MessagesPage /> },
|
||||||
|
{ path: "contacts", element: <ContactsPage /> },
|
||||||
|
{ path: "contacts/new", element: <ContactNew /> },
|
||||||
|
{ path: "contacts/edit/:id", element: <ContactEdit /> },
|
||||||
|
{ path: "contacts/history/:num", element: <ContactHistory /> },
|
||||||
|
{ path: "settings", element: <SettingsPage /> },
|
||||||
|
{ path: "logout", element: <LogoutPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: "/login", element: <LoginPage /> },
|
||||||
|
]);
|
||||||
|
|
||||||
const router = createRouter({ routeTree });
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
</StrictMode>
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -3,14 +3,7 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
|
||||||
host: "0.0.0.0",
|
|
||||||
port: 5173,
|
|
||||||
proxy: { "/api": "http://127.0.0.1:8098" },
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
extensions: [".tsx", ".ts", ".js", ".jsx"],
|
||||||
"@": new URL("./src/", import.meta.url).pathname,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user