Add Jitsi participant copy actions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Idem
2026-05-06 14:29:27 -04:00
parent 74bc3b3625
commit 25e35f6f96
2 changed files with 78 additions and 7 deletions

View File

@@ -533,6 +533,7 @@ Shown above the meeting list when data is loaded. Stats reflect the **filtered +
- **Total Duration** — sum of all session durations (HH:MM:SS) - **Total Duration** — sum of all session durations (HH:MM:SS)
In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available). In grouped view, each room header also shows its own subtotals (meeting count, unique participants by Novi UUID when available).
Each meeting instance now includes a **Copy names** button so staff can grab the full participant list for pasting into follow-up reports.
### Caching / Load Behavior ### Caching / Load Behavior

View File

@@ -358,6 +358,41 @@ function is_room_open(room_name: string): boolean {
// --- URL Builder --- // --- URL Builder ---
let show_url_builder = $state(false); let show_url_builder = $state(false);
let copied_participants_meeting_id = $state<string | null>(null);
let copied_participants_timeout: ReturnType<typeof setTimeout> | null = null;
function build_participant_copy_text(participants: MeetingParticipant[]): string {
return participants
.map((participant) =>
participant.role === 'moderator'
? `Mod: ${participant.displayName}`
: participant.displayName
)
.join('\n');
}
async function copy_participants(
meeting_id: string,
participants: MeetingParticipant[]
) {
const text = build_participant_copy_text(participants);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
copied_participants_meeting_id = meeting_id;
if (copied_participants_timeout) {
clearTimeout(copied_participants_timeout);
}
copied_participants_timeout = setTimeout(() => {
if (copied_participants_meeting_id === meeting_id) {
copied_participants_meeting_id = null;
}
}, 2000);
} catch (err) {
console.warn('Failed to copy participants to clipboard.', err);
}
}
// --- Export --- // --- Export ---
function download_file(content: string, filename: string, mime: string) { function download_file(content: string, filename: string, mime: string) {
@@ -776,6 +811,7 @@ function export_json() {
{@const mods = m.real_participants.filter((p) => p.role === 'moderator')} {@const mods = m.real_participants.filter((p) => p.role === 'moderator')}
{@const others = m.real_participants.filter((p) => p.role !== 'moderator')} {@const others = m.real_participants.filter((p) => p.role !== 'moderator')}
{@const all_names = m.real_participants.map((p) => `${p.displayName} (${p.role})`).join('\n')} {@const all_names = m.real_participants.map((p) => `${p.displayName} (${p.role})`).join('\n')}
{@const participant_copy_text = build_participant_copy_text(m.real_participants)}
<tr <tr
class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200"> class="border-surface-200-800 hover:bg-surface-100-900 border-b transition-colors duration-200">
<td <td
@@ -813,18 +849,34 @@ function export_json() {
{#if m.real_participant_count === 0} {#if m.real_participant_count === 0}
<span class="opacity-40"></span> <span class="opacity-40"></span>
{:else} {:else}
<div class="space-y-0.5 text-xs"> <div class="space-y-1 text-xs">
{#if mods.length > 0} {#if mods.length > 0}
<div> <div class="whitespace-normal break-words">
<span class="mr-1 opacity-40">Mod:</span> <span class="mr-1 opacity-40">Mod:</span>
<span class="font-semibold">{mods.map((p) => p.displayName).join(', ')}</span> <span class="font-semibold">{mods.map((p) => p.displayName).join(', ')}</span>
</div> </div>
{/if} {/if}
{#if others.length > 0} {#if others.length > 0}
<div class="opacity-80"> <div class="whitespace-normal break-words opacity-80">
{others.slice(0, 5).map((p) => p.displayName).join(', ')}{others.length > 5 ? ` +${others.length - 5} more` : ''} {others.map((p) => p.displayName).join(', ')}
</div> </div>
{/if} {/if}
<button
type="button"
onclick={() =>
copy_participants(
m.meeting_id,
m.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === m.meeting_id
? 'Copied'
: 'Copy names'}
</button>
</div> </div>
{/if} {/if}
</td> </td>
@@ -993,9 +1045,27 @@ function export_json() {
<!-- Final Participants (exclusion-applied) --> <!-- Final Participants (exclusion-applied) -->
<div> <div>
<div <div class="mb-2 flex items-center justify-between gap-2">
class="mb-2 text-xs tracking-wide uppercase opacity-40"> <div
Final Participants ({meeting.real_participant_count}) class="text-xs tracking-wide uppercase opacity-40">
Final Participants ({meeting.real_participant_count})
</div>
<button
type="button"
onclick={() =>
copy_participants(
meeting.meeting_id,
meeting.real_participants
)}
class="inline-flex items-center gap-1 rounded border border-surface-200-800 bg-surface-100-900 px-2 py-1 text-xs font-medium transition-colors hover:bg-surface-200-800"
title="Copy participants to clipboard">
<span
class="fas fa-copy"
aria-hidden="true"></span>
{copied_participants_meeting_id === meeting.meeting_id
? 'Copied'
: 'Copy names'}
</button>
</div> </div>
{#if meeting.real_participants && meeting.real_participants.length > 0} {#if meeting.real_participants && meeting.real_participants.length > 0}
<table class="w-full text-sm"> <table class="w-full text-sm">