This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,533 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import Sidebar from "../components/Sidebar";
import TopBar from "../components/TopBar";
import {
fetchMeetings,
fetchMeetingDetail,
fetchMeetingTranscript,
fetchMeetingSignals,
Meeting,
MeetingDetail,
TranscriptChunk,
Signal,
formatRelativeTime,
getSeverityColor,
getSignalIcon,
parseMetaList,
} from "../lib/api";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string): string {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("en-GB", {
day: "2-digit", month: "short", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
}
function SignalTypeBadge({ type }: { type: string }) {
const colors: Record<string, string> = {
meet_decision: "#34D399",
meet_action_item: "#60A5FA",
meet_blocker: "#F87171",
meet_risk: "#FBBF24",
meet_open_q: "#A78BFA",
meet_summary: "#A78BFA",
meet_chunk_raw: "#71717A",
};
const color = colors[type] || "#A78BFA";
return (
<span
className="text-[9px] font-bold uppercase tracking-[0.1em] px-2 py-0.5 rounded-full border"
style={{ color, borderColor: `${color}40`, background: `${color}15` }}
>
{type.replace("meet_", "").replace(/_/g, " ")}
</span>
);
}
// ─── Meeting List Item ─────────────────────────────────────────────────────────
function MeetingListItem({
meeting,
isSelected,
onClick,
dateFrom,
dateTo,
}: {
meeting: Meeting;
isSelected: boolean;
onClick: () => void;
dateFrom: string;
dateTo: string;
}) {
const ts = meeting.started_at || "";
if (dateFrom && ts && ts < dateFrom) return null;
if (dateTo && ts && ts > dateTo) return null;
return (
<button
onClick={onClick}
className={`w-full text-left px-4 py-3 rounded-xl border transition-all duration-200 mb-2 ${
isSelected
? "border-[#A78BFA]/60 bg-[rgba(167,139,250,0.12)]"
: "border-white/5 bg-[rgba(22,16,36,0.5)] hover:border-[#A78BFA]/30 hover:bg-[rgba(167,139,250,0.06)]"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(167,139,250,0.15)" }}
>
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "16px" }}>
video_camera_front
</span>
</div>
<div className="min-w-0">
<p className="text-[12px] font-semibold text-zinc-200 truncate font-mono-data">
{meeting.meeting_id}
</p>
<p className="text-[10px] text-zinc-500">
{meeting.started_at ? formatDate(meeting.started_at) : "Date unknown"}
</p>
</div>
</div>
<span
className="text-[11px] font-bold text-[#A78BFA] flex-shrink-0 mt-0.5"
>
{meeting.signal_count}
</span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{Object.entries(meeting.types || {})
.filter(([t]) => t !== "meet_started" && t !== "meet_chunk_raw")
.slice(0, 4)
.map(([type, count]) => (
<SignalTypeBadge key={type} type={type} />
))}
</div>
</button>
);
}
// ─── Transcript Panel ──────────────────────────────────────────────────────────
function TranscriptPanel({ chunks }: { chunks: TranscriptChunk[] }) {
if (chunks.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<span className="material-symbols-outlined text-4xl mb-2">mic_off</span>
<p className="text-[12px] uppercase tracking-wider">No transcript chunks stored</p>
</div>
);
}
return (
<div className="space-y-3">
{chunks.map((chunk, i) => (
<div
key={chunk.id || i}
className="rounded-xl border border-white/5 bg-[rgba(22,16,36,0.5)] p-4"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ background: "rgba(167,139,250,0.2)" }}
>
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "12px" }}>
record_voice_over
</span>
</div>
<span className="text-[11px] font-semibold text-[#A78BFA]">
{chunk.speaker || "Unknown Speaker"}
</span>
<span className="text-[9px] text-zinc-600 font-mono-data">
CHUNK {String(i + 1).padStart(2, "0")}
</span>
</div>
<span className="text-[10px] text-zinc-600 font-mono-data">
{formatDate(chunk.timestamp)}
</span>
</div>
<p className="text-[13px] text-zinc-300 leading-relaxed">{chunk.text}</p>
</div>
))}
</div>
);
}
// ─── Signals Panel ─────────────────────────────────────────────────────────────
function SignalsPanel({ signals }: { signals: Signal[] }) {
const filtered = signals.filter(
(s) => !["meet_chunk_raw", "meet_started"].includes(s.metadata?.type || "")
);
if (filtered.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<span className="material-symbols-outlined text-4xl mb-2">sensors_off</span>
<p className="text-[12px] uppercase tracking-wider">No signals extracted yet</p>
</div>
);
}
return (
<div className="space-y-3">
{filtered.map((sig, i) => {
const meta = sig.metadata;
const color = getSeverityColor(meta.severity);
const icon = getSignalIcon(meta.type);
return (
<div
key={sig.id || i}
className="rounded-xl border border-white/5 bg-[rgba(22,16,36,0.5)] p-4 border-l-[3px]"
style={{ borderLeftColor: color }}
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg" style={{ background: `${color}18` }}>
<span className="material-symbols-outlined" style={{ fontSize: "14px", color }}>
{icon}
</span>
</div>
<SignalTypeBadge type={meta.type} />
</div>
<span className="text-[10px] text-zinc-600 font-mono-data flex-shrink-0">
{formatRelativeTime(meta.timestamp)}
</span>
</div>
<p className="text-[13px] text-zinc-200 leading-relaxed">{meta.summary || sig.document}</p>
{meta.raw_quote && meta.raw_quote !== meta.summary && (
<p className="text-[11px] text-zinc-500 mt-2 italic border-l-2 border-zinc-700 pl-3">
"{meta.raw_quote.slice(0, 200)}"
</p>
)}
{meta.entities && (
<div className="flex flex-wrap gap-1 mt-2">
{parseMetaList(meta.entities).map((e, j) => (
<span
key={j}
className="text-[10px] text-[#A78BFA] bg-[rgba(167,139,250,0.1)] px-2 py-0.5 rounded-full"
>
{e}
</span>
))}
</div>
)}
</div>
);
})}
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────
export default function MeetingsPage() {
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<MeetingDetail | null>(null);
const [transcript, setTranscript] = useState<TranscriptChunk[]>([]);
const [signals, setSignals] = useState<Signal[]>([]);
const [activeTab, setActiveTab] = useState<"transcript" | "signals">("signals");
const [detailLoading, setDetailLoading] = useState(false);
// Filters
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [search, setSearch] = useState("");
useEffect(() => {
fetchMeetings()
.then((data) => setMeetings(data))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const selectMeeting = useCallback(async (id: string) => {
setSelectedId(id);
setDetail(null);
setTranscript([]);
setSignals([]);
setDetailLoading(true);
try {
const [det, trans, sigs] = await Promise.all([
fetchMeetingDetail(id),
fetchMeetingTranscript(id),
fetchMeetingSignals(id),
]);
setDetail(det);
setTranscript(trans.transcript);
setSignals(sigs);
} catch (e) {
console.error(e);
} finally {
setDetailLoading(false);
}
}, []);
const filteredMeetings = meetings.filter((m) => {
if (search && !m.meeting_id.toLowerCase().includes(search.toLowerCase())) return false;
if (dateFrom && m.started_at && m.started_at < dateFrom) return false;
if (dateTo && m.started_at && m.started_at > dateTo) return false;
return true;
});
const totalSignals = meetings.reduce((sum, m) => sum + m.signal_count, 0);
return (
<div className="min-h-screen flex" style={{ backgroundColor: "#09090B" }}>
<Sidebar />
<div className="flex-1 ml-[240px] flex flex-col min-h-screen">
<TopBar />
<main className="flex-1 p-8">
{/* Header */}
<div className="mb-8 animate-fade-in-up opacity-0">
<div className="flex items-center gap-3 mb-2">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{ background: "rgba(167,139,250,0.15)" }}
>
<span className="material-symbols-outlined text-[#A78BFA]">video_camera_front</span>
</div>
<div>
<h1 className="text-2xl font-bold text-white tracking-tight">Meeting History</h1>
<p className="text-[11px] text-zinc-500 uppercase tracking-[0.2em]">
Google Meet · Signal Intelligence
</p>
</div>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-4 mb-6 animate-fade-in-up opacity-0 delay-100">
{[
{ label: "Total Meetings", value: meetings.length, icon: "video_camera_front" },
{ label: "Total Signals", value: totalSignals, icon: "sensors" },
{
label: "Avg Signals / Meeting",
value: meetings.length ? Math.round(totalSignals / meetings.length) : 0,
icon: "analytics",
},
].map((stat) => (
<div
key={stat.label}
className="neon-card-gradient rounded-xl p-4 border border-white/5 flex items-center gap-4"
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ background: "rgba(167,139,250,0.1)" }}
>
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "18px" }}>
{stat.icon}
</span>
</div>
<div>
<p className="text-2xl font-bold text-white">{stat.value}</p>
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">{stat.label}</p>
</div>
</div>
))}
</div>
{/* Filter bar */}
<div className="flex items-center gap-3 mb-6 animate-fade-in-up opacity-0 delay-200">
<div className="relative flex-1 max-w-xs">
<span
className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
style={{ fontSize: "16px" }}
>
search
</span>
<input
type="text"
placeholder="Search meeting ID..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-[#141419] border border-white/10 rounded-lg pl-9 pr-4 py-2.5 text-[12px] text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-[#A78BFA]/50"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">From</span>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value ? new Date(e.target.value).toISOString() : "")}
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
/>
<span className="text-[10px] text-zinc-500 uppercase tracking-wider">To</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value ? new Date(e.target.value).toISOString() : "")}
className="bg-[#141419] border border-white/10 rounded-lg px-3 py-2 text-[12px] text-zinc-300 focus:outline-none focus:border-[#A78BFA]/50"
/>
{(dateFrom || dateTo || search) && (
<button
onClick={() => { setDateFrom(""); setDateTo(""); setSearch(""); }}
className="text-[10px] text-zinc-500 hover:text-zinc-300 uppercase tracking-wider px-3 py-2 rounded-lg border border-white/10 transition-colors"
>
Clear
</button>
)}
</div>
</div>
{/* Two-panel layout */}
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 400px)" }}>
{/* Left: Meeting List */}
<div className="w-[320px] flex-shrink-0">
{loading ? (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<div className="w-8 h-8 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-3" />
<p className="text-[11px] uppercase tracking-wider">Loading meetings...</p>
</div>
) : filteredMeetings.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-zinc-600">
<span className="material-symbols-outlined text-4xl mb-2">video_camera_front</span>
<p className="text-[12px] uppercase tracking-wider">No meetings found</p>
<p className="text-[10px] text-zinc-700 mt-1">Meetings appear when Google Meet extension sends data</p>
</div>
) : (
<div className="custom-scrollbar overflow-y-auto" style={{ maxHeight: "calc(100vh - 420px)" }}>
{filteredMeetings.map((m) => (
<MeetingListItem
key={m.meeting_id}
meeting={m}
isSelected={selectedId === m.meeting_id}
onClick={() => selectMeeting(m.meeting_id)}
dateFrom={dateFrom}
dateTo={dateTo}
/>
))}
</div>
)}
</div>
{/* Right: Detail Panel */}
<div className="flex-1 min-w-0">
{!selectedId ? (
<div className="neon-card-gradient rounded-2xl border border-white/5 h-full flex flex-col items-center justify-center text-zinc-600 p-12">
<span className="material-symbols-outlined text-5xl mb-4 text-zinc-700">
video_camera_front
</span>
<p className="text-[14px] uppercase tracking-wider text-zinc-500">Select a meeting</p>
<p className="text-[11px] text-zinc-700 mt-2">to view transcript and signals</p>
</div>
) : detailLoading ? (
<div className="neon-card-gradient rounded-2xl border border-white/5 h-full flex flex-col items-center justify-center">
<div className="w-10 h-10 border-2 border-[#A78BFA]/30 border-t-[#A78BFA] rounded-full animate-spin mb-4" />
<p className="text-[12px] text-zinc-500 uppercase tracking-wider">Loading meeting data...</p>
</div>
) : detail ? (
<div className="neon-card-gradient rounded-2xl border border-white/5 flex flex-col h-full">
{/* Meeting header */}
<div className="p-6 border-b border-white/5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] text-[#A78BFA] font-bold uppercase tracking-wider bg-[rgba(167,139,250,0.1)] px-2 py-0.5 rounded-full">
MEETING
</span>
<span className="text-[10px] text-zinc-500 font-mono-data">{detail.group_id}</span>
</div>
<h2 className="text-[15px] font-bold text-white font-mono-data break-all">
{detail.meeting_id}
</h2>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-1 text-zinc-400">
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>schedule</span>
<span className="text-[11px]">{formatDate(detail.started_at)}</span>
</div>
{detail.speaker && detail.speaker !== "Unknown" && (
<div className="flex items-center gap-1 text-zinc-400">
<span className="material-symbols-outlined" style={{ fontSize: "14px" }}>person</span>
<span className="text-[11px]">{detail.speaker}</span>
</div>
)}
</div>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-[#A78BFA]">{detail.total_signals}</p>
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">signals</p>
</div>
</div>
{/* Signal type counts */}
<div className="flex flex-wrap gap-2 mt-4">
{Object.entries(detail.signal_counts)
.filter(([t]) => t !== "meet_started" && t !== "meet_chunk_raw")
.map(([type, count]) => (
<div
key={type}
className="flex items-center gap-1.5 bg-[rgba(255,255,255,0.04)] border border-white/5 rounded-lg px-3 py-1"
>
<SignalTypeBadge type={type} />
<span className="text-[11px] font-bold text-zinc-300">{count}</span>
</div>
))}
</div>
{/* Summary */}
{detail.summary && (
<div className="mt-4 p-4 rounded-xl bg-[rgba(167,139,250,0.06)] border border-[#A78BFA]/15">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[#A78BFA]" style={{ fontSize: "14px" }}>
summarize
</span>
<span className="text-[10px] font-bold text-[#A78BFA] uppercase tracking-wider">
AI Summary
</span>
</div>
<p className="text-[12px] text-zinc-300 leading-relaxed whitespace-pre-line">
{detail.summary}
</p>
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b border-white/5">
{(["signals", "transcript"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-3 text-[11px] font-semibold uppercase tracking-wider transition-colors ${
activeTab === tab
? "text-[#A78BFA] border-b-2 border-[#A78BFA]"
: "text-zinc-500 hover:text-zinc-300"
}`}
>
{tab === "signals" ? `Signals (${signals.filter(s => !["meet_chunk_raw","meet_started"].includes(s.metadata?.type)).length})` : `Transcript (${transcript.length} chunks)`}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
{activeTab === "signals" ? (
<SignalsPanel signals={signals} />
) : (
<TranscriptPanel chunks={transcript} />
)}
</div>
</div>
) : null}
</div>
</div>
</main>
</div>
</div>
);
}