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:
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user