imeeting/frontend/src/pages/business/MeetingPreview.tsx

1041 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, Button, Empty, Input, Result, Skeleton, Tabs, message } from "antd";
import { useParams, useSearchParams } from "react-router-dom";
import {
AudioOutlined,
CalendarOutlined,
CaretRightFilled,
ClockCircleOutlined,
CopyOutlined,
FileTextOutlined,
LockOutlined,
PauseOutlined,
RobotOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
DownOutlined,
UpOutlined,
LinkOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import ReactMarkdown from "react-markdown";
import {
getMeetingPreviewAccess,
getPublicMeetingPreview,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
type MeetingChapterVO,
type MeetingTranscriptVO,
type MeetingVO,
} from "../../api/business/meeting";
import { buildMeetingAnalysis } from "./meetingAnalysis";
import "./MeetingPreview.css";
type AnalysisTab = "chapters" | "speakers" | "actions" | "todos";
type PreviewPageTab = "summary" | "catalog" | "transcript";
const TEXT = {
statusTranscribing: "转写中",
statusSummarizing: "总结中",
statusCompleted: "已完成",
statusPending: "待处理",
hintTranscribing: "会议内容仍在整理中,预览会持续补全。",
hintSummarizing: "AI 正在生成会议总结,已完成内容会优先展示。",
hintCompleted: "会议纪要、分析和转录内容已生成完成。",
hintPending: "当前会议尚未生成完整内容,请稍后重试。",
missingMeetingId: "未提供会议编号",
loadFailed: "会议预览加载失败",
noMeetingData: "未找到会议数据",
previewLabel: "会议预览",
untitledMeeting: "未命名会议",
meetingTime: "会议时间",
hostCreator: "主持/创建",
participantsCount: "参会人数",
tagsCount: "标签数量",
notSet: "未设置",
notFilled: "未填写",
pageSummary: "AI 纪要",
pageCatalog: "AI 目录",
pageTranscript: "转录原文",
copyLink: "复制链接",
shareNow: "立即分享",
shareCopied: "预览链接已复制",
shareFallbackCopied: "当前设备不支持系统分享,已为你复制链接",
shareFailed: "分享失败,请先复制链接",
accessCheck: "访问校验",
passwordRequired: "该会议需要访问密码",
passwordHint: "请输入会议的 访问密码 后继续访问预览内容。",
passwordPlaceholder: "请输入 访问密码",
openPreview: "进入预览",
invalidPassword: "访问密码错误",
basicInfo: "基本信息",
meetingOverview: "会议概况",
creator: "创建人",
host: "主持人",
createdAt: "创建时间",
audioStatus: "音频状态",
participants: "人",
tags: "会议标签",
aiAnalysis: "AI 目录",
analysis: "会议分析",
previewExtra: "预览页仅读展示",
audioPlaybackWarning: "音频保存失败,可能影响回放。",
summaryOverview: "全文概要",
summaryEmpty: "暂无概要内容",
analysisChapters: "章节",
analysisSpeakers: "发言人",
analysisKeyPoints: "关键要点",
analysisTodos: "待办事项",
noChapterAnalysis: "暂无章节分析",
noSpeakerAnalysis: "暂无发言人分析",
noKeyPoints: "暂无关键要点",
noTodos: "暂无待办事项",
chapterFallback: "章节",
speakerFallback: "发言人",
speakerSummary: "发言概述",
keyPointFallback: "要点",
noChapterSummary: "暂无章节描述",
noSpeakerSummary: "暂无发言总结",
noKeyPointSummary: "暂无要点说明",
summarySection: "会议纪要",
fullSummary: "完整纪要",
noSummary: "暂无会议纪要",
transcriptSection: "会议转录",
transcriptTitle: "逐段转录",
noDuration: "暂无时长",
audioUnavailable: "音频文件不可用,仅展示转录内容。",
noTranscript: "暂无转录内容",
unknownSpeaker: "未知发言人",
disclaimer: "智能内容由用户会议内容 + AI 模型生成,我们不对内容准确性和完整性做任何保证,亦不代表我们的观点或态度",
shareText: "我向你分享了一个会议预览链接",
audioSaved: "已保存",
audioSaveFailed: "保存失败",
audioUploaded: "已上传",
audioNotSaved: "未保存",
linkToTranscript: "关联原文",
noCatalog: "暂无 AI 目录",
};
type ChapterTranscriptLink = {
key: string;
title: string;
timeLabel: string;
transcriptIds: number[];
firstTranscriptId: number | null;
firstTranscriptStartTime: number | null;
};
function parseChapterTimeToMs(value?: string) {
const raw = String(value || "").trim();
if (!raw) return null;
const matched = raw.match(/(\d{1,2}:\d{2}(?::\d{2})?)/)?.[1];
if (!matched) return null;
const parts = matched.split(":").map((item) => Number(item));
if (parts.some((item) => Number.isNaN(item))) {
return null;
}
const totalSeconds =
parts.length === 3 ? parts[0] * 3600 + parts[1] * 60 + parts[2] : parts[0] * 60 + parts[1];
return totalSeconds * 1000;
}
const STATUS_META: Record<number, { label: string; className: string; hint: string }> = {
1: { label: TEXT.statusTranscribing, className: "is-processing", hint: TEXT.hintTranscribing },
2: { label: TEXT.statusSummarizing, className: "is-processing", hint: TEXT.hintSummarizing },
3: { label: TEXT.statusCompleted, className: "is-complete", hint: TEXT.hintCompleted },
};
function formatDurationRange(startTime?: number, endTime?: number) {
const format = (milliseconds?: number) => {
const safeMs = Math.max(0, milliseconds || 0);
const totalSeconds = Math.floor(safeMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return `${format(startTime)} - ${format(endTime)}`;
}
function splitDisplayItems(value?: string) {
return (value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function transcriptColorSeed(speakerKey: string) {
const palette = ["#315f8b", "#b86432", "#557a46", "#6d4fa7", "#a33f57", "#0f766e"];
const score = Array.from(speakerKey).reduce((sum, char) => sum + char.charCodeAt(0), 0);
return palette[score % palette.length];
}
async function copyText(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
export default function MeetingPreview() {
const { id } = useParams();
const [searchParams] = useSearchParams();
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [meeting, setMeeting] = useState<MeetingVO | null>(null);
const [transcripts, setTranscripts] = useState<MeetingTranscriptVO[]>([]);
const [meetingChapters, setMeetingChapters] = useState<MeetingChapterVO[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("speakers");
const [pageTab, setPageTab] = useState<PreviewPageTab>("summary");
const [activeTranscriptId, setActiveTranscriptId] = useState<number | null>(null);
const [passwordRequired, setPasswordRequired] = useState(false);
const [passwordVerified, setPasswordVerified] = useState(false);
const [accessPassword, setAccessPassword] = useState("");
const [passwordError, setPasswordError] = useState("");
const [audioPlaying, setAudioPlaying] = useState(false);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaybackRate, setAudioPlaybackRate] = useState(1);
const [isMetricsExpanded, setIsMetricsExpanded] = useState(false);
const [linkedTranscriptIds, setLinkedTranscriptIds] = useState<number[]>([]);
const [linkedChapterKey, setLinkedChapterKey] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" ? window.matchMedia("(max-width: 767px)").matches : false,
);
const presetAccessPassword = useMemo(() => (searchParams.get("accessPassword") || "").trim(), [searchParams]);
useEffect(() => {
let mounted = true;
const load = async () => {
if (!id) {
setError(TEXT.missingMeetingId);
setLoading(false);
return;
}
setLoading(true);
setError("");
setMeeting(null);
setTranscripts([]);
setMeetingChapters([]);
setPasswordRequired(false);
setPasswordVerified(false);
setAccessPassword(presetAccessPassword);
setPasswordError("");
try {
const meetingId = Number(id);
const accessRes = await getMeetingPreviewAccess(meetingId);
if (!mounted) {
return;
}
const requiresPassword = !!accessRes.data.data.passwordRequired;
setPasswordRequired(requiresPassword);
if (requiresPassword) {
if (!presetAccessPassword) {
setLoading(false);
return;
}
try {
const previewRes = await getPublicMeetingPreview(meetingId, presetAccessPassword);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
return;
} catch (requestError: any) {
if (!mounted) {
return;
}
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
setPasswordVerified(false);
setLoading(false);
return;
}
}
const previewRes = await getPublicMeetingPreview(meetingId);
if (!mounted) {
return;
}
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
if (!mounted) {
return;
}
setError(requestError?.response?.data?.msg || requestError?.msg || TEXT.loadFailed);
} finally {
if (mounted) {
setLoading(false);
}
}
};
load();
return () => {
mounted = false;
};
}, [id, presetAccessPassword]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(max-width: 767px)");
const handleChange = (event: MediaQueryListEvent) => {
setIsMobile(event.matches);
};
setIsMobile(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
const analysis = useMemo(
() => buildMeetingAnalysis(meeting?.analysis, meeting?.summaryContent, meeting?.tags || ""),
[meeting?.analysis, meeting?.summaryContent, meeting?.tags],
);
const participants = useMemo(() => splitDisplayItems(meeting?.participants), [meeting?.participants]);
const transcriptSpeakers = useMemo(() => {
const speakers = transcripts
.map((item) => item.speakerName || item.speakerLabel || item.speakerId || "")
.map((item) => item.trim())
.filter(Boolean);
return Array.from(new Set(speakers));
}, [transcripts]);
const tags = useMemo(() => splitDisplayItems(meeting?.tags), [meeting?.tags]);
const keywords = useMemo(() => analysis.keywords || [], [analysis.keywords]);
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
const statusMeta = STATUS_META[meeting?.status || 0] || {
label: TEXT.statusPending,
className: "is-warning",
hint: TEXT.hintPending,
};
const shareUrl = typeof window !== "undefined" ? window.location.href : "";
const participantCountValue =
isMobile && transcriptSpeakers.length > 0 ? transcriptSpeakers.length : participants.length;
const meetingDuration = useMemo(() => {
if (transcripts.length > 0) {
const last = transcripts[transcripts.length - 1];
return last.endTime || 0;
}
return 0;
}, [transcripts]);
const catalogChapterLinks = useMemo<ChapterTranscriptLink[]>(() => {
const transcriptIdToIndex = new Map(transcripts.map((item, index) => [item.id, index]));
const sourceChapters: MeetingChapterVO[] = meetingChapters.length
? meetingChapters
: analysis.chapters.map((item) => ({
title: item.title,
time: item.time,
}));
return sourceChapters.map((chapter, index) => {
let matchedTranscripts: MeetingTranscriptVO[] = [];
const sourceTranscriptIds = Array.isArray(chapter.sourceTranscriptIds)
? chapter.sourceTranscriptIds
.map((item) => Number(item))
.filter((item) => Number.isFinite(item) && transcriptIdToIndex.has(item))
: [];
if (sourceTranscriptIds.length) {
matchedTranscripts = sourceTranscriptIds
.map((item) => transcripts[transcriptIdToIndex.get(item)!])
.filter(Boolean);
} else if (chapter.startTranscriptId && chapter.endTranscriptId) {
const startIndex = transcriptIdToIndex.get(Number(chapter.startTranscriptId));
const endIndex = transcriptIdToIndex.get(Number(chapter.endTranscriptId));
if (startIndex !== undefined && endIndex !== undefined) {
matchedTranscripts = transcripts.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) + 1);
}
} else {
const startMs = typeof chapter.startTime === "number" ? chapter.startTime : parseChapterTimeToMs(chapter.time);
const nextChapterStartMs = sourceChapters
.slice(index + 1)
.map((item) => (typeof item.startTime === "number" ? item.startTime : parseChapterTimeToMs(item.time)))
.find((item): item is number => item !== null && startMs !== null && item > startMs);
if (startMs !== null) {
const firstTranscriptIndex = transcripts.findIndex((item) => item.endTime > startMs);
if (firstTranscriptIndex >= 0) {
const lastTranscriptIndex =
nextChapterStartMs === undefined
? transcripts.length
: transcripts.findIndex((item) => item.startTime >= nextChapterStartMs);
matchedTranscripts = transcripts.slice(
firstTranscriptIndex,
lastTranscriptIndex >= 0 ? lastTranscriptIndex : transcripts.length,
);
}
}
}
return {
key: `${chapter.chapterNo ?? index}-${chapter.title || "chapter"}`,
title: chapter.title || `章节 ${index + 1}`,
timeLabel: chapter.time || "--:--",
transcriptIds: matchedTranscripts.map((item) => item.id),
firstTranscriptId: matchedTranscripts[0]?.id ?? null,
firstTranscriptStartTime: matchedTranscripts[0]?.startTime ?? null,
};
});
}, [analysis.chapters, meetingChapters, transcripts]);
useEffect(() => {
if (!activeTranscriptId) {
return;
}
const target = transcriptItemRefs.current[activeTranscriptId];
if (!target) {
return;
}
// 使用 center 模式确保当前说话段落始终位于视口中央,避免被底部的浮动控件遮挡
target.scrollIntoView({ behavior: "smooth", block: "center" });
}, [activeTranscriptId]);
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
if (!audioRef.current) {
return;
}
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
audioRef.current.play().catch(() => {});
};
const handleLocateChapterTranscript = (index: number) => {
const link = catalogChapterLinks[index];
if (link && link.firstTranscriptId) {
setPageTab("transcript");
setLinkedTranscriptIds(link.transcriptIds);
setLinkedChapterKey(link.key);
setActiveTranscriptId(link.firstTranscriptId);
// 自动跳转并播放音频
if (audioRef.current && link.firstTranscriptStartTime !== null) {
audioRef.current.currentTime = Math.max(0, link.firstTranscriptStartTime / 1000);
audioRef.current.play().catch(() => {
// 部分浏览器(尤其是移动端)可能会拦截非直接交互触发的播放
// 但由于这是由用户点击目录项触发的,通常会被允许
});
}
}
};
const toggleAudioPlayback = () => {
if (!audioRef.current) return;
if (audioPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(() => {});
}
};
const handleAudioProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!audioRef.current) return;
const time = parseFloat(e.target.value);
audioRef.current.currentTime = time;
setAudioCurrentTime(time);
};
const cyclePlaybackRate = () => {
if (!audioRef.current) return;
const nextRate = audioPlaybackRate === 1 ? 1.5 : audioPlaybackRate === 1.5 ? 2 : 1;
audioRef.current.playbackRate = nextRate;
setAudioPlaybackRate(nextRate);
};
const formatPlayerTime = (seconds: number) => {
if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
const handleAudioTimeUpdate = () => {
if (!audioRef.current) return;
const currentSeconds = audioRef.current.currentTime;
setAudioCurrentTime(currentSeconds);
// Also update duration if it's available now
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
setAudioDuration(audioRef.current.duration);
}
if (transcripts.length === 0) return;
const currentMs = currentSeconds * 1000;
const currentItem = transcripts.find(
(item) => currentMs >= (item.startTime || 0) && currentMs <= (item.endTime || 0),
);
setActiveTranscriptId(currentItem?.id || null);
};
const handleAudioEnded = () => {
setAudioPlaying(false);
};
const handleAudioPlay = () => setAudioPlaying(true);
const handleAudioPause = () => setAudioPlaying(false);
const handleAudioLoadedMetadata = () => {
if (audioRef.current) {
setAudioDuration(audioRef.current.duration);
}
};
const handleAudioError = () => {
const currentAudioUrl = playbackAudioUrl || "";
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
return;
}
const normalizedUrl = currentAudioUrl.split("#")[0]?.split("?")[0]?.toLowerCase() || "";
const isM4a = normalizedUrl.endsWith(".m4a");
message.warning(
isM4a
? "当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav或下载到本地播放。"
: TEXT.audioUnavailable,
);
audioPlaybackErrorShownRef.current = currentAudioUrl;
setAudioPlaying(false);
};
const handlePasswordSubmit = async () => {
if (!id) {
return;
}
setLoading(true);
setPasswordError("");
try {
const previewRes = await getPublicMeetingPreview(Number(id), accessPassword.trim());
setMeeting(previewRes.data.data.meeting);
setTranscripts(previewRes.data.data.transcripts || []);
setMeetingChapters(previewRes.data.data.chapters || []);
setPasswordVerified(true);
} catch (requestError: any) {
setPasswordError(requestError?.response?.data?.msg || requestError?.msg || TEXT.invalidPassword);
} finally {
setLoading(false);
}
};
const handleCopyLink = async () => {
try {
await copyText(shareUrl);
message.success(TEXT.shareCopied);
} catch {
message.error(TEXT.shareFailed);
}
};
const handleShareNow = async () => {
try {
if (navigator.share) {
await navigator.share({
title: meeting?.title || TEXT.previewLabel,
text: TEXT.shareText,
url: shareUrl,
});
return;
}
await copyText(shareUrl);
message.success(TEXT.shareFallbackCopied);
} catch {
message.error(TEXT.shareFailed);
}
};
if (loading && (!passwordRequired || passwordVerified)) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-loading">
<div className="meeting-preview-card meeting-preview-hero">
<Skeleton active paragraph={{ rows: 4 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 8 }} />
</div>
<div className="meeting-preview-card meeting-preview-section">
<Skeleton active paragraph={{ rows: 10 }} />
</div>
</div>
</div>
);
}
if (passwordRequired && !passwordVerified) {
return (
<div className="meeting-preview-page is-password-gate">
<div className="password-gate-background">
<div className="bg-blob bg-blob-1" />
<div className="bg-blob bg-blob-2" />
<div className="bg-blob bg-blob-3" />
</div>
<div className="meeting-preview-shell">
<div className="meeting-preview-password-card">
<div className="password-card-header">
<div className="password-icon-wrapper">
<LockOutlined />
</div>
<h1 className="password-card-title">{TEXT.passwordRequired}</h1>
<p className="password-card-subtitle">{TEXT.passwordHint}</p>
</div>
<div className="password-card-form">
<div className="password-input-wrapper">
<Input.Password
size="large"
value={accessPassword}
placeholder={TEXT.passwordPlaceholder}
onChange={(event) => setAccessPassword(event.target.value)}
onPressEnter={handlePasswordSubmit}
prefix={<LockOutlined style={{ color: "var(--text-secondary)" }} />}
className="modern-password-input"
/>
</div>
<Button
type="primary"
size="large"
onClick={handlePasswordSubmit}
loading={loading}
disabled={!accessPassword.trim()}
className="password-submit-btn"
block
>
{TEXT.openPreview}
</Button>
</div>
{passwordError && (
<div className="password-error-message">
<Alert type="error" showIcon message={passwordError} />
</div>
)}
</div>
</div>
<div className="password-gate-footer">
<div className="footer-disclaimer">
<RobotOutlined />
<span>Secure Access Powered by iMeeting AI</span>
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Result status="error" title={TEXT.loadFailed} subTitle={error} />
</div>
</div>
</div>
);
}
if (!meeting) {
return (
<div className="meeting-preview-page">
<div className="meeting-preview-shell meeting-preview-empty">
<div className="meeting-preview-card meeting-preview-section">
<Empty description={TEXT.noMeetingData} />
</div>
</div>
</div>
);
}
const summaryTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-summary-box">
<div className="meeting-preview-summary-section">
<div className="meeting-preview-summary-section-title"></div>
<div className="meeting-preview-record-tags">
{keywords.length ? (
keywords.map((item) => (
<div key={item} className="meeting-preview-record-tag">
<span>#{item}</span>
</div>
))
) : (
<span className="meeting-preview-keywords-empty"></span>
)}
</div>
</div>
</div>
<div className="meeting-preview-markdown">
{meeting.summaryContent ? (
<ReactMarkdown>{meeting.summaryContent}</ReactMarkdown>
) : (
<Empty description={TEXT.noSummary} />
)}
</div>
</section>
</div>
);
const catalogTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <RobotOutlined />*/}
{/* {TEXT.aiAnalysis}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.pageCatalog}</h2>
</div>
</div>
<div className="meeting-preview-catalog-list">
{catalogChapterLinks.length ? (
catalogChapterLinks.map((chapter, index) => (
<div
key={chapter.key}
className={`meeting-preview-catalog-item-container ${linkedChapterKey === chapter.key ? 'active' : ''}`}
>
<div className="meeting-preview-catalog-timeline-axis">
<div className="meeting-preview-catalog-timeline-dot" />
<div className="meeting-preview-catalog-timeline-line" />
</div>
<div
className="meeting-preview-catalog-item-card"
onClick={() => handleLocateChapterTranscript(index)}
>
<div className="meeting-preview-catalog-item-time">{chapter.timeLabel}</div>
<div className="meeting-preview-catalog-item-title-row">
<div className="meeting-preview-catalog-item-title">{chapter.title}</div>
<button
type="button"
className="meeting-preview-catalog-item-link"
onClick={(e) => {
e.stopPropagation();
handleLocateChapterTranscript(index);
}}
>
<LinkOutlined /> {TEXT.linkToTranscript}
</button>
</div>
</div>
</div>
))
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={TEXT.noCatalog} />
)}
</div>
</section>
</div>
);
const transcriptTabContent = (
<div className="meeting-preview-tab-panel">
<section className="meeting-preview-card meeting-preview-section">
<div className="meeting-preview-section-header">
<div>
{/*<div className="meeting-preview-section-kicker">*/}
{/* <AudioOutlined />*/}
{/* {TEXT.transcriptSection}*/}
{/*</div>*/}
<h2 className="meeting-preview-section-title">{TEXT.transcriptTitle}</h2>
</div>
<div className="meeting-preview-section-extra">
<ClockCircleOutlined style={{ marginRight: 6 }} />
{meetingDuration > 0 ? formatDurationRange(0, meetingDuration) : TEXT.noDuration}
</div>
</div>
{meeting.audioSaveStatus === "FAILED" ? (
<Alert
className="meeting-preview-alert"
type="warning"
showIcon
message={meeting.audioSaveMessage || TEXT.audioUnavailable}
/>
) : null}
<div className="meeting-preview-transcript-list">
{transcripts.length > 0 ? (
transcripts.map((item) => {
const speakerKey = item.speakerName || item.speakerLabel || item.speakerId || "speaker";
const isLinked = linkedTranscriptIds.includes(item.id);
const isActive = activeTranscriptId === item.id;
return (
<div
key={item.id}
ref={(node) => {
transcriptItemRefs.current[item.id] = node;
}}
className={`meeting-preview-transcript-item ${isActive ? "is-active" : ""} ${isLinked ? "is-linked" : ""}`}
onClick={() => {
handleTranscriptSeek(item);
setLinkedTranscriptIds([]); // Clear linked highlight on manual seek
setLinkedChapterKey(null);
}}
>
<div
className="meeting-preview-transcript-avatar"
style={{ backgroundColor: transcriptColorSeed(speakerKey) }}
>
{(speakerKey || "S").slice(0, 1)}
</div>
<div className="meeting-preview-transcript-content">
<div className="meeting-preview-transcript-meta">
<span className="meeting-preview-transcript-speaker">{speakerKey}</span>
<span className="meeting-preview-transcript-time">
{formatDurationRange(item.startTime, item.endTime)}
</span>
</div>
<div className="meeting-preview-transcript-text">
{item.content || TEXT.noTranscript}
</div>
</div>
</div>
);
})
) : (
<Empty description={TEXT.noTranscript} />
)}
</div>
</section>
</div>
);
const formatTotalDuration = (ms: number) => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
return (
<div className={`meeting-preview-page ${isMobile ? "is-mobile" : "is-desktop"}`}>
<div className="meeting-preview-container">
<div className="meeting-preview-shell">
{/* Header Title Section */}
<div className="meeting-preview-top-hero">
<div className="meeting-preview-hero-logo">
<RobotOutlined />
</div>
<div className="meeting-preview-hero-content">
<h1 className="meeting-preview-hero-title">{meeting.title || TEXT.untitledMeeting}</h1>
<div className="meeting-preview-hero-meta">
<span className={`meeting-preview-status-tag ${statusMeta.className}`}>
{statusMeta.label}
</span>
{/*<span className="meeting-preview-hero-id">ID: {meeting.id}</span>*/}
</div>
</div>
</div>
{/* Collapsible Basic Info Section */}
<div className="meeting-preview-collapsible-section">
<div
className="meeting-preview-collapsible-trigger"
onClick={() => setIsMetricsExpanded(!isMetricsExpanded)}
>
<div className="trigger-left">
<FileTextOutlined />
<span>{TEXT.basicInfo}</span>
</div>
<div className="trigger-right">
{isMetricsExpanded ? <UpOutlined /> : <DownOutlined />}
</div>
</div>
<div className={`meeting-preview-collapsible-content ${isMetricsExpanded ? 'is-expanded' : ''}`}>
<div className="meeting-preview-metrics-grid">
<div className="metric-item">
<div className="metric-label">{TEXT.meetingTime}</div>
<div className="metric-value">
<CalendarOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.meetingTime ? dayjs(meeting.meetingTime).format("YYYY-MM-DD HH:mm") : TEXT.notSet}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.hostCreator}</div>
<div className="metric-value">
<UserOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meeting.creatorName || TEXT.notSet}
</div>
</div>
<div className="metric-item">
<div className="metric-label">{TEXT.participantsCount}</div>
<div className="metric-value">
<TeamOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{participantCountValue} {TEXT.participants}
</div>
</div>
<div className="metric-item">
<div className="metric-label"></div>
<div className="metric-value">
<ClockCircleOutlined style={{ marginRight: 8, color: 'var(--primary-blue)' }} />
{meetingDuration > 0 ? formatTotalDuration(meetingDuration) : TEXT.notSet}
</div>
</div>
{tags.length > 0 && (
<div className="metric-item metric-item-full">
<div className="metric-label">{TEXT.tags}</div>
<div className="metric-tags">
{tags.map(tag => (
<span key={tag} className="metric-tag">#{tag}</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Sharing Buttons Bar */}
<div className="meeting-preview-share-bar">
<Button
type="primary"
size="large"
icon={<ShareAltOutlined />}
onClick={handleShareNow}
className="share-btn-primary"
>
{TEXT.shareNow}
</Button>
<Button
size="large"
icon={<CopyOutlined />}
onClick={handleCopyLink}
className="share-btn-ghost"
>
{TEXT.copyLink}
</Button>
</div>
<div className="meeting-preview-layout-full">
{/* Main Content Area */}
<main className="meeting-preview-main">
<div className="meeting-preview-content-card">
<div className="meeting-preview-tabs-container">
<Tabs
activeKey={pageTab}
onChange={(key) => setPageTab(key as PreviewPageTab)}
items={[
{ key: "summary", label: TEXT.pageSummary },
{ key: "catalog", label: TEXT.pageCatalog },
{ key: "transcript", label: TEXT.pageTranscript },
]}
/>
</div>
<div className="meeting-preview-tab-content">
{pageTab === "summary" ? summaryTabContent : null}
{pageTab === "catalog" ? catalogTabContent : null}
{pageTab === "transcript" ? transcriptTabContent : null}
</div>
</div>
</main>
</div>
<div className="meeting-preview-footer">
<div className="meeting-preview-disclaimer">
<RobotOutlined />
<span>{TEXT.disclaimer}</span>
</div>
</div>
</div>
</div>
{/* Floating Audio Player - Permanent mount, visibility controlled */}
{playbackAudioUrl && (
<div
className="meeting-preview-audio-player-inline"
style={{ display: pageTab === "transcript" ? "flex" : "none" }}
>
<audio
ref={audioRef}
onTimeUpdate={handleAudioTimeUpdate}
onPlay={handleAudioPlay}
onPause={handleAudioPause}
onEnded={handleAudioEnded}
onLoadedMetadata={handleAudioLoadedMetadata}
onError={handleAudioError}
style={{ display: "none" }}
preload="auto"
>
<source src={playbackAudioUrl} type={resolveAudioMimeType(playbackAudioUrl)} />
</audio>
<div className="audio-player-content">
<button type="button" className="audio-play-btn" onClick={toggleAudioPlayback}>
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="audio-progress-container">
<div className="audio-time">{formatPlayerTime(audioCurrentTime)}</div>
<input
className="audio-range"
type="range"
min={0}
max={audioDuration || 0}
step={0.1}
value={Math.min(audioCurrentTime, audioDuration || 0)}
onChange={handleAudioProgressChange}
style={{ backgroundSize: `${audioDuration ? (audioCurrentTime / audioDuration) * 100 : 0}% 100%` }}
/>
<div className="audio-time">{formatPlayerTime(audioDuration)}</div>
</div>
<button type="button" className="audio-speed-btn" onClick={cyclePlaybackRate}>
{audioPlaybackRate}x
</button>
</div>
</div>
)}
</div>
);
}