mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
534 lines
22 KiB
TypeScript
534 lines
22 KiB
TypeScript
"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>
|
|
);
|
|
}
|