511 lines
24 KiB
TypeScript
511 lines
24 KiB
TypeScript
/**
|
|
* rMeets module — video meetings powered by Jitsi + Meeting Intelligence.
|
|
*
|
|
* Hub page with Quick Meet + room name input,
|
|
* room pages embed Jitsi via renderExternalAppShell,
|
|
* recordings/search pages pull from the Meeting Intelligence API.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { renderShell, renderExternalAppShell, escapeHtml } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { renderLanding } from "./landing";
|
|
|
|
const JITSI_URL = process.env.JITSI_URL || "https://jeffsi.localvibe.live";
|
|
const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000";
|
|
const routes = new Hono();
|
|
|
|
// ── Meeting Intelligence API helper ──
|
|
|
|
async function miApiFetch(path: string, options?: { method?: string; body?: any }): Promise<{ ok: boolean; data?: any; error?: string }> {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
const fetchOpts: RequestInit = { signal: controller.signal };
|
|
if (options?.method) fetchOpts.method = options.method;
|
|
if (options?.body) {
|
|
fetchOpts.headers = { "Content-Type": "application/json" };
|
|
fetchOpts.body = JSON.stringify(options.body);
|
|
}
|
|
const res = await fetch(`${MI_API_URL}${path}`, fetchOpts);
|
|
clearTimeout(timeout);
|
|
if (!res.ok) return { ok: false, error: `API returned ${res.status}` };
|
|
return { ok: true, data: await res.json() };
|
|
} catch (e: any) {
|
|
return { ok: false, error: e?.message || "Connection failed" };
|
|
}
|
|
}
|
|
|
|
function miUnavailableHtml(message = "Meeting intelligence is temporarily unavailable. Please try again later."): string {
|
|
return `<div style="max-width:600px;margin:3rem auto;padding:2rem;text-align:center;color:var(--rs-text-secondary)">
|
|
<div style="font-size:2.5rem;margin-bottom:1rem">🔌</div>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Shared styles for MI pages ──
|
|
|
|
const MI_STYLES = `<style>
|
|
.mi-page{max-width:900px;margin:2rem auto;padding:0 1.5rem}
|
|
.mi-page h1{font-size:1.8rem;margin-bottom:.25rem;color:var(--rs-text-primary)}
|
|
.mi-page .mi-subtitle{color:var(--rs-text-secondary);margin-bottom:2rem}
|
|
.mi-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
|
|
.mi-card{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-decoration:none;color:inherit;transition:border-color .15s,background .15s;display:block}
|
|
.mi-card:hover{border-color:var(--rs-accent);background:var(--rs-bg-hover)}
|
|
.mi-card h3{margin:0 0 .5rem;font-size:1.05rem;color:var(--rs-text-primary)}
|
|
.mi-card .mi-meta{font-size:.8rem;color:var(--rs-text-secondary);margin-bottom:.5rem}
|
|
.mi-chips{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
|
|
.mi-chip{display:inline-block;padding:.15rem .6rem;border-radius:99px;font-size:.75rem;font-weight:500}
|
|
.mi-chip--ok{background:var(--rs-accent);color:#fff;opacity:.8}
|
|
.mi-chip--pending{background:var(--rs-border);color:var(--rs-text-secondary)}
|
|
.mi-badge{display:inline-block;padding:.2rem .6rem;border-radius:6px;font-size:.75rem;font-weight:600;text-transform:uppercase}
|
|
.mi-badge--completed{background:#22c55e22;color:#22c55e}
|
|
.mi-badge--processing{background:#eab30822;color:#eab308}
|
|
.mi-badge--recording{background:#ef444422;color:#ef4444}
|
|
.mi-badge--failed{background:#ef444422;color:#ef4444}
|
|
.mi-badge--transcribing,.mi-badge--diarizing,.mi-badge--summarizing,.mi-badge--extracting_audio{background:#3b82f622;color:#3b82f6}
|
|
.mi-tabs{display:flex;gap:0;border-bottom:1px solid var(--rs-border);margin-bottom:1.5rem}
|
|
.mi-tab{padding:.75rem 1.25rem;text-decoration:none;color:var(--rs-text-secondary);font-size:.9rem;font-weight:500;border-bottom:2px solid transparent;transition:color .15s,border-color .15s}
|
|
.mi-tab:hover{color:var(--rs-text-primary)}
|
|
.mi-tab--active{color:var(--rs-accent);border-bottom-color:var(--rs-accent)}
|
|
.mi-detail-header{margin-bottom:1.5rem}
|
|
.mi-detail-header h1{margin-bottom:.5rem}
|
|
.mi-detail-meta{display:flex;gap:1.5rem;flex-wrap:wrap;font-size:.85rem;color:var(--rs-text-secondary)}
|
|
.mi-transcript{display:flex;flex-direction:column;gap:1rem}
|
|
.mi-segment{padding:.75rem 1rem;border-left:3px solid var(--rs-accent);background:var(--rs-bg-surface);border-radius:0 8px 8px 0}
|
|
.mi-segment .mi-speaker{font-weight:600;color:var(--rs-accent);font-size:.85rem}
|
|
.mi-segment .mi-timestamp{font-size:.75rem;color:var(--rs-text-secondary);margin-left:.75rem}
|
|
.mi-segment .mi-text{margin-top:.35rem;color:var(--rs-text-primary);line-height:1.5}
|
|
.mi-summary{color:var(--rs-text-primary);line-height:1.6}
|
|
.mi-summary h2{font-size:1.2rem;margin:1.5rem 0 .5rem;color:var(--rs-text-primary)}
|
|
.mi-summary h2:first-child{margin-top:0}
|
|
.mi-summary ul{padding-left:1.5rem;margin:.5rem 0}
|
|
.mi-summary li{margin:.25rem 0}
|
|
.mi-speakers{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem}
|
|
.mi-speaker-card{padding:1rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
|
|
.mi-speaker-card h4{margin:0 0 .25rem;color:var(--rs-text-primary)}
|
|
.mi-speaker-card p{margin:0;font-size:.85rem;color:var(--rs-text-secondary)}
|
|
.mi-search-form{display:flex;gap:.75rem;margin-bottom:2rem}
|
|
.mi-search-form input{flex:1;padding:.65rem 1rem;border-radius:8px;border:1px solid var(--rs-input-border);background:var(--rs-input-bg);color:var(--rs-input-text);font-size:.95rem}
|
|
.mi-search-form input:focus{outline:none;border-color:var(--rs-accent)}
|
|
.mi-search-form button{padding:.65rem 1.5rem;border-radius:8px;border:none;background:var(--rs-accent);color:#fff;font-weight:600;cursor:pointer;font-size:.95rem}
|
|
.mi-search-form button:hover{background:var(--rs-accent-hover)}
|
|
.mi-result{padding:1rem 1.25rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);margin-bottom:.75rem;display:block;text-decoration:none;color:inherit;transition:border-color .15s}
|
|
.mi-result:hover{border-color:var(--rs-accent)}
|
|
.mi-result h3{margin:0 0 .25rem;font-size:1rem;color:var(--rs-text-primary)}
|
|
.mi-result .mi-excerpt{font-size:.85rem;color:var(--rs-text-secondary);line-height:1.4}
|
|
.mi-result mark{background:var(--rs-accent);color:#fff;padding:0 .2rem;border-radius:3px}
|
|
@media(max-width:600px){.mi-page{margin:1rem auto;padding:0 .75rem}.mi-cards{grid-template-columns:1fr}}
|
|
</style>`;
|
|
|
|
// ── Room embed ──
|
|
|
|
routes.get("/room/:room", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const room = c.req.param("room");
|
|
const useApi = c.req.query("api") === "1";
|
|
|
|
if (useApi) {
|
|
const director = c.req.query("director") === "1";
|
|
const sessionId = c.req.query("session") || "";
|
|
return c.html(renderShell({
|
|
title: `${room} — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-jitsi-room room="${escapeHtml(room)}" jitsi-url="${escapeHtml(JITSI_URL)}" space="${escapeHtml(space)}"${director ? ` director="1" session="${escapeHtml(sessionId)}"` : ""}></folk-jitsi-room>`,
|
|
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js"></script>`,
|
|
}));
|
|
}
|
|
|
|
return c.html(renderExternalAppShell({
|
|
title: `${room} — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
appUrl: `${JITSI_URL}/${encodeURIComponent(room)}`,
|
|
appName: "Jitsi Meet",
|
|
theme: "dark",
|
|
}));
|
|
});
|
|
|
|
// ── Direct Jitsi lobby ──
|
|
|
|
routes.get("/meet", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderExternalAppShell({
|
|
title: `Jitsi Meet — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
appUrl: JITSI_URL,
|
|
appName: "Jitsi Meet",
|
|
theme: "dark",
|
|
}));
|
|
});
|
|
|
|
// ── Recordings list ──
|
|
|
|
routes.get("/recordings", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const base = `/${escapeHtml(space)}/rmeets`;
|
|
const result = await miApiFetch("/meetings?limit=50&sort=-created_at");
|
|
|
|
let body: string;
|
|
if (!result.ok) {
|
|
body = miUnavailableHtml();
|
|
} else {
|
|
const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []);
|
|
if (meetings.length === 0) {
|
|
body = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">No recorded meetings yet. Start a meeting and enable recording to see it here.</p></div>`;
|
|
} else {
|
|
const cards = meetings.map((m: any) => {
|
|
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "";
|
|
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)}min` : "";
|
|
const participants = m.participant_count ?? m.participants?.length ?? "";
|
|
const status = (m.status || "completed").toLowerCase();
|
|
const badgeClass = `mi-badge--${status}`;
|
|
const hasTranscript = m.has_transcript ?? (m.segment_count != null && m.segment_count > 0) ?? m.transcript_status === "completed";
|
|
const hasSummary = m.has_summary ?? m.summary_status === "completed";
|
|
return `<a class="mi-card" href="${base}/recordings/${escapeHtml(String(m.id))}">
|
|
<h3>${escapeHtml(m.title || m.room_name || `Meeting ${m.id}`)}</h3>
|
|
<div class="mi-meta">${[date, duration, participants ? `${participants} participants` : ""].filter(Boolean).join(" · ")}</div>
|
|
<span class="mi-badge ${badgeClass}">${escapeHtml(status)}</span>
|
|
<div class="mi-chips">
|
|
${hasTranscript ? `<span class="mi-chip mi-chip--ok">Transcript</span>` : `<span class="mi-chip mi-chip--pending">No transcript</span>`}
|
|
${hasSummary ? `<span class="mi-chip mi-chip--ok">Summary</span>` : `<span class="mi-chip mi-chip--pending">No summary</span>`}
|
|
</div>
|
|
</a>`;
|
|
}).join("\n");
|
|
body = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">Past meetings with transcripts and AI summaries</p><div class="mi-cards">${cards}</div></div>`;
|
|
}
|
|
}
|
|
|
|
return c.html(renderShell({
|
|
title: `Recordings — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
styles: MI_STYLES,
|
|
body,
|
|
}));
|
|
});
|
|
|
|
// ── Recording detail redirect ──
|
|
|
|
routes.get("/recordings/:id", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const id = c.req.param("id");
|
|
return c.redirect(`/${space}/rmeets/recordings/${id}/overview`);
|
|
});
|
|
|
|
// ── Recording detail with tabs ──
|
|
|
|
routes.get("/recordings/:id/:tab", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const base = `/${escapeHtml(space)}/rmeets`;
|
|
const id = c.req.param("id");
|
|
const tab = c.req.param("tab") || "overview";
|
|
const validTabs = ["overview", "transcript", "summary", "speakers"];
|
|
const activeTab = validTabs.includes(tab) ? tab : "overview";
|
|
|
|
const skip = { ok: false as const, data: undefined, error: "skipped" };
|
|
const [meetingRes, transcriptRes, summaryRes, speakersRes] = await Promise.all([
|
|
miApiFetch(`/meetings/${id}`),
|
|
activeTab === "transcript" ? miApiFetch(`/meetings/${id}/transcript`) : Promise.resolve(skip),
|
|
activeTab === "summary" ? miApiFetch(`/meetings/${id}/summary`) : Promise.resolve(skip),
|
|
(activeTab === "speakers" || activeTab === "overview") ? miApiFetch(`/meetings/${id}/speakers`) : Promise.resolve(skip),
|
|
]);
|
|
|
|
if (!meetingRes.ok) {
|
|
return c.html(renderShell({
|
|
title: `Recording — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
styles: MI_STYLES,
|
|
body: miUnavailableHtml("Could not load this recording. The meeting intelligence service may be temporarily unavailable."),
|
|
}));
|
|
}
|
|
|
|
const m = meetingRes.data;
|
|
const title = m.title || m.room_name || `Meeting ${id}`;
|
|
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) : "";
|
|
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)} minutes` : "";
|
|
const participants = m.participant_count ?? m.participants?.length ?? "";
|
|
|
|
const tabs = validTabs.map(t => {
|
|
const label = t.charAt(0).toUpperCase() + t.slice(1);
|
|
const cls = t === activeTab ? "mi-tab mi-tab--active" : "mi-tab";
|
|
return `<a class="${cls}" href="${base}/recordings/${escapeHtml(String(id))}/${t}">${label}</a>`;
|
|
}).join("");
|
|
|
|
let tabContent: string;
|
|
|
|
if (activeTab === "overview") {
|
|
const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : [];
|
|
const speakerList = speakers.length > 0
|
|
? `<div class="mi-speakers">${speakers.map((s: any) => {
|
|
const time = s.speaking_time ? `${Math.round(s.speaking_time / 60)}min speaking` : s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : "";
|
|
return `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.speaker_label || s.label || `Speaker ${s.speaker_id || s.id}`)}</h4><p>${escapeHtml(time)}</p></div>`;
|
|
}).join("")}</div>`
|
|
: `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
|
|
tabContent = `<div class="mi-detail-meta">
|
|
${date ? `<span>${escapeHtml(date)}</span>` : ""}
|
|
${duration ? `<span>${escapeHtml(duration)}</span>` : ""}
|
|
${participants ? `<span>${participants} participants</span>` : ""}
|
|
</div>
|
|
<h2 style="font-size:1.1rem;margin:1.5rem 0 .75rem;color:var(--rs-text-primary)">Speakers</h2>
|
|
${speakerList}`;
|
|
} else if (activeTab === "transcript") {
|
|
if (!transcriptRes.ok) {
|
|
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript not available yet.</p>`;
|
|
} else {
|
|
const segments = Array.isArray(transcriptRes.data) ? transcriptRes.data : (transcriptRes.data?.segments ?? transcriptRes.data?.transcript ?? []);
|
|
if (segments.length === 0) {
|
|
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript is empty.</p>`;
|
|
} else {
|
|
tabContent = `<div class="mi-transcript">${segments.map((seg: any) => {
|
|
const speaker = seg.speaker || seg.speaker_name || seg.speaker_label || seg.label || "Unknown";
|
|
const ts = seg.start_time != null ? formatTimestamp(seg.start_time) : "";
|
|
const text = seg.text || seg.content || "";
|
|
return `<div class="mi-segment">
|
|
<span class="mi-speaker">${escapeHtml(speaker)}</span><span class="mi-timestamp">${escapeHtml(ts)}</span>
|
|
<div class="mi-text">${escapeHtml(text)}</div>
|
|
</div>`;
|
|
}).join("")}</div>`;
|
|
}
|
|
}
|
|
} else if (activeTab === "summary") {
|
|
if (!summaryRes.ok) {
|
|
tabContent = `<p style="color:var(--rs-text-secondary)">Summary not available yet.</p>`;
|
|
} else {
|
|
const summary = summaryRes.data;
|
|
const overview = summary.summary_text || summary.overview || summary.summary || summary.text || "";
|
|
const keyPoints = summary.key_points ?? summary.keyPoints ?? [];
|
|
const actionItems = summary.action_items ?? summary.actionItems ?? [];
|
|
const decisions = summary.decisions ?? [];
|
|
|
|
let html = `<div class="mi-summary">`;
|
|
if (overview) html += `<h2>Overview</h2><p>${escapeHtml(overview)}</p>`;
|
|
if (keyPoints.length > 0) html += `<h2>Key Points</h2><ul>${keyPoints.map((p: string) => `<li>${escapeHtml(p)}</li>`).join("")}</ul>`;
|
|
if (actionItems.length > 0) html += `<h2>Action Items</h2><ul>${actionItems.map((a: any) => `<li>${escapeHtml(typeof a === "string" ? a : a.task || a.text || a.description || "")}</li>`).join("")}</ul>`;
|
|
if (decisions.length > 0) html += `<h2>Decisions</h2><ul>${decisions.map((d: any) => `<li>${escapeHtml(typeof d === "string" ? d : d.text || d.description || "")}</li>`).join("")}</ul>`;
|
|
if (!overview && keyPoints.length === 0 && actionItems.length === 0) html += `<p style="color:var(--rs-text-secondary)">Summary content is empty.</p>`;
|
|
html += `</div>`;
|
|
tabContent = html;
|
|
}
|
|
} else if (activeTab === "speakers") {
|
|
const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : [];
|
|
if (speakers.length === 0) {
|
|
tabContent = `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
|
|
} else {
|
|
tabContent = `<div class="mi-speakers">${speakers.map((s: any) => {
|
|
const time = s.speaking_time ? `${Math.round(s.speaking_time / 60)}min speaking` : s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : "";
|
|
const segments = s.segment_count ?? s.num_segments ?? "";
|
|
return `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.speaker_label || s.label || `Speaker ${s.speaker_id || s.id}`)}</h4><p>${[time, segments ? `${segments} segments` : ""].filter(Boolean).join(" · ")}</p></div>`;
|
|
}).join("")}</div>`;
|
|
}
|
|
} else {
|
|
tabContent = "";
|
|
}
|
|
|
|
return c.html(renderShell({
|
|
title: `${title} — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
styles: MI_STYLES,
|
|
body: `<div class="mi-page">
|
|
<div class="mi-detail-header"><h1>${escapeHtml(title)}</h1></div>
|
|
<div class="mi-tabs">${tabs}</div>
|
|
${tabContent}
|
|
</div>`,
|
|
}));
|
|
});
|
|
|
|
// ── Search ──
|
|
|
|
routes.get("/search", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const base = `/${escapeHtml(space)}/rmeets`;
|
|
const q = c.req.query("q") || "";
|
|
|
|
let resultsHtml = "";
|
|
if (q) {
|
|
const result = await miApiFetch("/search", { method: "POST", body: { query: q, limit: 30 } });
|
|
if (!result.ok) {
|
|
resultsHtml = miUnavailableHtml();
|
|
} else {
|
|
const hits = Array.isArray(result.data) ? result.data : (result.data?.results ?? result.data?.hits ?? []);
|
|
if (hits.length === 0) {
|
|
resultsHtml = `<p style="color:var(--rs-text-secondary)">No results found for "${escapeHtml(q)}".</p>`;
|
|
} else {
|
|
resultsHtml = hits.map((h: any) => {
|
|
const meetingId = h.meeting_id ?? h.meetingId ?? h.id;
|
|
const title = h.meeting_title || h.title || `Meeting ${meetingId}`;
|
|
const excerpt = h.highlight || h.excerpt || h.text || "";
|
|
return `<a class="mi-result" href="${base}/recordings/${escapeHtml(String(meetingId))}/transcript">
|
|
<h3>${escapeHtml(title)}</h3>
|
|
<div class="mi-excerpt">${excerpt}</div>
|
|
</a>`;
|
|
}).join("");
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.html(renderShell({
|
|
title: `Search — rMeets | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
styles: MI_STYLES,
|
|
body: `<div class="mi-page">
|
|
<h1>Search Transcripts</h1>
|
|
<p class="mi-subtitle">Full-text search across all meeting transcripts</p>
|
|
<form class="mi-search-form" method="get" action="${base}/search">
|
|
<input type="text" name="q" placeholder="Search meetings..." value="${escapeHtml(q)}">
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
${resultsHtml}
|
|
</div>`,
|
|
}));
|
|
});
|
|
|
|
// ── MI API proxy (for client-side JS, avoids CORS) ──
|
|
|
|
routes.all("/api/mi-proxy/*", async (c) => {
|
|
const proxyPath = c.req.path.replace(/^\/[^/]+\/rmeets\/api\/mi-proxy/, "");
|
|
const url = new URL(`${MI_API_URL}${proxyPath}`);
|
|
// Forward query params
|
|
const query = c.req.query();
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (typeof v === "string") url.searchParams.set(k, v);
|
|
}
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
const upstream = await fetch(url.toString(), {
|
|
method: c.req.method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.text() : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timeout);
|
|
const body = await upstream.text();
|
|
return new Response(body, {
|
|
status: upstream.status,
|
|
headers: { "Content-Type": upstream.headers.get("Content-Type") || "application/json" },
|
|
});
|
|
} catch {
|
|
return c.json({ error: "Meeting intelligence service unavailable" }, 502);
|
|
}
|
|
});
|
|
|
|
// ── Hub page ──
|
|
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const base = `/${escapeHtml(space)}/rmeets`;
|
|
const randomId = Math.random().toString(36).slice(2, 10);
|
|
return c.html(renderShell({
|
|
title: `rMeets — ${space} | rSpace`,
|
|
moduleId: "rmeets",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
styles: `<style>
|
|
.rs-hub{max-width:720px;margin:2rem auto;padding:0 1.5rem}
|
|
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem;color:var(--rs-text-primary)}
|
|
.rs-hub p{color:var(--rs-text-secondary);margin-bottom:2rem}
|
|
.rs-nav{display:flex;flex-direction:column;gap:1rem}
|
|
.rs-nav a,.rs-nav .room-form{display:flex;align-items:center;gap:1rem;padding:1.25rem 1.5rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-decoration:none;color:inherit;transition:border-color .15s,background .15s}
|
|
.rs-nav a:hover,.rs-nav .room-form:hover{border-color:var(--rs-accent);background:var(--rs-bg-hover)}
|
|
.rs-nav .nav-icon{font-size:2rem;flex-shrink:0}
|
|
.rs-nav .nav-body h3{margin:0 0 .25rem;font-size:1.1rem;color:var(--rs-text-primary)}
|
|
.rs-nav .nav-body p{margin:0;font-size:.85rem;color:var(--rs-text-secondary)}
|
|
.room-form{flex-wrap:wrap}
|
|
.room-form input[type="text"]{flex:1;min-width:140px;padding:.5rem .75rem;border-radius:8px;border:1px solid var(--rs-input-border);background:var(--rs-input-bg);color:var(--rs-input-text);font-size:.95rem}
|
|
.room-form input[type="text"]:focus{outline:none;border-color:var(--rs-accent)}
|
|
.room-form button{padding:.5rem 1.25rem;border-radius:8px;border:none;background:var(--rs-accent);color:#fff;font-weight:600;cursor:pointer;font-size:.95rem}
|
|
.room-form button:hover{background:var(--rs-accent-hover)}
|
|
@media(max-width:600px){.rs-hub{margin:1rem auto;padding:0 .75rem}.rs-nav a,.rs-nav .room-form{padding:1rem;gap:.75rem}.rs-nav .nav-icon{font-size:1.5rem}}
|
|
</style>`,
|
|
body: `<div class="rs-hub">
|
|
<h1>rMeets</h1>
|
|
<p>Video meetings powered by Jitsi — no account required</p>
|
|
<nav class="rs-nav">
|
|
<a href="${base}/room/${randomId}">
|
|
<span class="nav-icon">🚀</span>
|
|
<div class="nav-body">
|
|
<h3>Quick Meet</h3>
|
|
<p>Start an instant meeting with a random room name</p>
|
|
</div>
|
|
</a>
|
|
<form class="room-form" onsubmit="event.preventDefault();var n=this.querySelector('input').value.trim();if(n)location.href='${base}/room/'+encodeURIComponent(n)">
|
|
<span class="nav-icon">🔗</span>
|
|
<div class="nav-body">
|
|
<h3>Join a Room</h3>
|
|
<p>Enter a room name to join or create a meeting</p>
|
|
</div>
|
|
<input type="text" placeholder="room-name" required>
|
|
<button type="submit">Join</button>
|
|
</form>
|
|
<a href="${base}/meet">
|
|
<span class="nav-icon">📹</span>
|
|
<div class="nav-body">
|
|
<h3>Jitsi Lobby</h3>
|
|
<p>Open the full Jitsi Meet interface directly</p>
|
|
</div>
|
|
</a>
|
|
<a href="${base}/recordings">
|
|
<span class="nav-icon">🎥</span>
|
|
<div class="nav-body">
|
|
<h3>Recordings</h3>
|
|
<p>Browse past meetings, transcripts and AI summaries</p>
|
|
</div>
|
|
</a>
|
|
<a href="${base}/search">
|
|
<span class="nav-icon">🔍</span>
|
|
<div class="nav-body">
|
|
<h3>Search</h3>
|
|
<p>Full-text search across all meeting transcripts</p>
|
|
</div>
|
|
</a>
|
|
</nav>
|
|
</div>`,
|
|
}));
|
|
});
|
|
|
|
// ── Helpers ──
|
|
|
|
function formatTimestamp(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
// ── Module export ──
|
|
|
|
export const meetsModule: RSpaceModule = {
|
|
id: "rmeets",
|
|
name: "rMeets",
|
|
icon: "📹",
|
|
description: "Video meetings powered by Jitsi",
|
|
scoping: { defaultScope: "space", userConfigurable: false },
|
|
routes,
|
|
landingPage: renderLanding,
|
|
externalApp: { url: JITSI_URL, name: "Jitsi Meet" },
|
|
outputPaths: [
|
|
{ path: "meet", name: "Create Call", icon: "🚀", description: "Start a new video meeting" },
|
|
{ path: "rooms", name: "Rooms", icon: "🚪", description: "Meeting rooms and video calls" },
|
|
],
|
|
onboardingActions: [
|
|
{ label: "Start a Meeting", icon: "🚀", description: "Jump into an instant video call", type: "create", href: "/{space}/rmeets/room/quick" },
|
|
{ label: "View Recordings", icon: "🎥", description: "Browse past meetings with transcripts and AI summaries", type: "link", href: "/{space}/rmeets/recordings" },
|
|
],
|
|
};
|