Implement conference breakouts with meeting mode and programme lineup UI.
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Deploy Ladill Meet / deploy (push) Successful in 1m3s
Attendees reconnect to breakout rooms with publish access while hosts stay on stage; poll and media-token endpoints drive LiveKit reconnection, and the live room shows Events-linked programme when available. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+161
-2
@@ -65,7 +65,15 @@ function meetRoom() {
|
||||
qaItems: [],
|
||||
activePolls: [],
|
||||
breakoutBroadcast: '',
|
||||
breakoutBroadcastInput: '',
|
||||
inBreakout: false,
|
||||
breakoutsActive: false,
|
||||
activeBreakouts: [],
|
||||
assignedBreakoutRoomId: null,
|
||||
breakoutRoomName: '',
|
||||
breakoutClosesAt: '',
|
||||
featuresOpen: false,
|
||||
programmeOpen: false,
|
||||
whiteboardOpen: false,
|
||||
qaInput: '',
|
||||
chatOpen: false,
|
||||
@@ -115,7 +123,9 @@ function meetRoom() {
|
||||
pollsCreateUrl: el.dataset.pollsCreateUrl,
|
||||
breakoutsCreateUrl: el.dataset.breakoutsCreateUrl,
|
||||
breakoutsCloseUrl: el.dataset.breakoutsCloseUrl,
|
||||
breakoutsReturnUrl: el.dataset.breakoutsReturnUrl,
|
||||
breakoutsBroadcastUrl: el.dataset.breakoutsBroadcastUrl,
|
||||
mediaTokenUrl: el.dataset.mediaTokenUrl,
|
||||
filesUploadUrl: el.dataset.filesUploadUrl,
|
||||
whiteboardUrl: el.dataset.whiteboardUrl,
|
||||
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
||||
@@ -133,7 +143,9 @@ function meetRoom() {
|
||||
this.initApplauseAudio(el.dataset.applauseAudio || '');
|
||||
|
||||
this.isHost = el.dataset.isHost === '1';
|
||||
this.config.participantUuid = el.dataset.participant || '';
|
||||
this.canPublish = el.dataset.canPublish !== '0';
|
||||
this.inBreakout = el.dataset.inBreakout === '1';
|
||||
this.isWebinar = el.dataset.isWebinar === '1';
|
||||
this.isConference = el.dataset.isConference === '1';
|
||||
this.isGreenRoom = el.dataset.isGreenRoom === '1';
|
||||
@@ -177,6 +189,9 @@ function meetRoom() {
|
||||
|
||||
this.waitingCount = this.sessionParticipants.filter((p) => p.status === 'waiting').length;
|
||||
|
||||
const selfParticipant = this.sessionParticipants.find((p) => p.uuid === this.config.participantUuid);
|
||||
this.assignedBreakoutRoomId = selfParticipant?.breakout_room_id ?? null;
|
||||
|
||||
try {
|
||||
this.roomHost = JSON.parse(el.dataset.roomHost || 'null');
|
||||
} catch {
|
||||
@@ -371,7 +386,7 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
async enableLocalMedia(activeRoom, attempts = 3) {
|
||||
const wantMic = !this.muteOnJoin || this.isHost;
|
||||
const wantMic = this.inBreakout ? !this.muteOnJoin : (!this.muteOnJoin || this.isHost);
|
||||
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
|
||||
const audioOptions = {
|
||||
echoCancellation: true,
|
||||
@@ -1510,19 +1525,151 @@ function meetRoom() {
|
||||
const count = parseInt(this.breakoutCount, 10);
|
||||
if (!count || count < 1) return;
|
||||
|
||||
await fetch(this.config.breakoutsCreateUrl, {
|
||||
const res = await fetch(this.config.breakoutsCreateUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.breakoutModalOpen = false;
|
||||
this.breakoutsActive = true;
|
||||
},
|
||||
|
||||
async closeBreakouts() {
|
||||
if (!this.isHost || !this.config.breakoutsCloseUrl) return;
|
||||
|
||||
await fetch(this.config.breakoutsCloseUrl, { method: 'POST', headers: this.headers() });
|
||||
this.featuresOpen = false;
|
||||
this.breakoutsActive = false;
|
||||
this.activeBreakouts = [];
|
||||
},
|
||||
|
||||
async broadcastToBreakouts() {
|
||||
const message = this.breakoutBroadcastInput.trim();
|
||||
if (!this.isHost || !message || !this.config.breakoutsBroadcastUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(this.config.breakoutsBroadcastUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.breakoutBroadcastInput = '';
|
||||
this.breakoutBroadcast = message;
|
||||
},
|
||||
|
||||
applyMediaSessionState(media) {
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inBreakout = Boolean(media.in_breakout);
|
||||
this.assignedBreakoutRoomId = media.breakout_room_id ?? null;
|
||||
this.canPublish = Boolean(media.can_publish);
|
||||
this.usesStageLayout = Boolean(media.uses_stage_layout);
|
||||
this.breakoutRoomName = media.breakout?.name || '';
|
||||
this.breakoutClosesAt = media.breakout?.closes_at || '';
|
||||
},
|
||||
|
||||
async syncMediaSessionFromPoll(data) {
|
||||
const media = data.media;
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousBreakoutId = this.assignedBreakoutRoomId;
|
||||
const previousCanPublish = this.canPublish;
|
||||
const previousUsesStageLayout = this.usesStageLayout;
|
||||
|
||||
this.applyMediaSessionState(media);
|
||||
this.breakoutsActive = Boolean(data.breakouts_active);
|
||||
this.activeBreakouts = Array.isArray(data.breakouts) ? data.breakouts : [];
|
||||
|
||||
const needsReconnect = previousBreakoutId !== this.assignedBreakoutRoomId
|
||||
|| previousCanPublish !== this.canPublish
|
||||
|| previousUsesStageLayout !== this.usesStageLayout;
|
||||
|
||||
if (needsReconnect && this.config.configured) {
|
||||
await this.reconnectLiveKit();
|
||||
}
|
||||
},
|
||||
|
||||
async reconnectLiveKit() {
|
||||
if (!this.config.mediaTokenUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(this.config.mediaTokenUrl, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.config.token = data.token;
|
||||
this.applyMediaSessionState(data);
|
||||
this.breakoutsActive = Boolean(data.breakouts_active);
|
||||
|
||||
if (room) {
|
||||
await room.disconnect();
|
||||
room = null;
|
||||
}
|
||||
|
||||
connectPromise = null;
|
||||
document.getElementById('video-grid')?.replaceChildren();
|
||||
document.getElementById('meet-stage-spotlight')?.replaceChildren();
|
||||
document.getElementById('meet-audience-strip')?.replaceChildren();
|
||||
|
||||
this.connectionStatus = this.inBreakout ? 'Joining breakout…' : 'Returning to main room…';
|
||||
await this.connectLiveKit();
|
||||
this.applyStageLayout();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.connectionStatus = this.connectionErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
breakoutTimeRemaining() {
|
||||
if (!this.breakoutClosesAt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const closes = Date.parse(this.breakoutClosesAt);
|
||||
if (Number.isNaN(closes)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const seconds = Math.max(0, Math.floor((closes - Date.now()) / 1000));
|
||||
if (seconds === 0) {
|
||||
return 'Ending soon';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
|
||||
return minutes > 0
|
||||
? `${minutes}m ${remainder}s remaining`
|
||||
: `${remainder}s remaining`;
|
||||
},
|
||||
|
||||
toggleProgramme() {
|
||||
this.programmeOpen = !this.programmeOpen;
|
||||
if (this.programmeOpen) {
|
||||
this.chatOpen = false;
|
||||
this.participantsOpen = false;
|
||||
this.featuresOpen = false;
|
||||
this.shareOpen = false;
|
||||
}
|
||||
},
|
||||
|
||||
openUploadPicker() {
|
||||
@@ -1797,6 +1944,7 @@ function meetRoom() {
|
||||
if (this.chatOpen) {
|
||||
this.participantsOpen = false;
|
||||
this.featuresOpen = false;
|
||||
this.programmeOpen = false;
|
||||
this.shareOpen = false;
|
||||
}
|
||||
},
|
||||
@@ -1806,6 +1954,7 @@ function meetRoom() {
|
||||
if (this.participantsOpen) {
|
||||
this.chatOpen = false;
|
||||
this.featuresOpen = false;
|
||||
this.programmeOpen = false;
|
||||
this.shareOpen = false;
|
||||
}
|
||||
},
|
||||
@@ -1815,6 +1964,7 @@ function meetRoom() {
|
||||
if (this.featuresOpen) {
|
||||
this.chatOpen = false;
|
||||
this.participantsOpen = false;
|
||||
this.programmeOpen = false;
|
||||
this.shareOpen = false;
|
||||
}
|
||||
},
|
||||
@@ -1924,6 +2074,7 @@ function meetRoom() {
|
||||
if (data.broadcasts?.length) {
|
||||
this.breakoutBroadcast = data.broadcasts[0].body;
|
||||
}
|
||||
await this.syncMediaSessionFromPoll(data);
|
||||
this.processReactionsFromPoll(data.reactions || []);
|
||||
} catch (e) {
|
||||
// ignore poll errors
|
||||
@@ -1939,6 +2090,10 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
conferenceStatusLabel() {
|
||||
if (this.inBreakout) {
|
||||
return 'Breakout · meeting mode';
|
||||
}
|
||||
|
||||
if (this.isGreenRoom) {
|
||||
if (this.usesStageLayout) {
|
||||
return `Green room · ${this.stageLayoutLabel()}`;
|
||||
@@ -1955,6 +2110,10 @@ function meetRoom() {
|
||||
},
|
||||
|
||||
conferenceStatusClass() {
|
||||
if (this.inBreakout) {
|
||||
return 'text-amber-400/90';
|
||||
}
|
||||
|
||||
if (this.isGreenRoom) {
|
||||
return 'text-violet-400/90';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
@php
|
||||
$programme = $programmeSnapshot ?? null;
|
||||
$linkedItems = $linkedProgrammeItems ?? [];
|
||||
@endphp
|
||||
|
||||
@if(is_array($programme) && ! empty($programme['days']))
|
||||
<div class="space-y-4 text-sm">
|
||||
@if(! empty($programme['title']))
|
||||
<p class="text-xs text-slate-400">{{ $programme['title'] }}</p>
|
||||
@endif
|
||||
|
||||
@if($linkedItems !== [])
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-violet-400">This session</p>
|
||||
<ul class="mt-2 space-y-2">
|
||||
@foreach($linkedItems as $item)
|
||||
<li class="rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-2 text-slate-100">
|
||||
@if(! empty($item['time']))
|
||||
<span class="font-mono text-xs text-violet-200/80">{{ $item['time'] }}</span>
|
||||
@endif
|
||||
<span class="font-medium">{{ $item['title'] ?? 'Item' }}</span>
|
||||
@if(! empty($item['host']))
|
||||
<span class="text-slate-400"> · {{ $item['host'] }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach((array) ($programme['days'] ?? []) as $day)
|
||||
<div>
|
||||
@if(! empty($day['label']) || ! empty($day['date']))
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{{ trim(($day['label'] ?? '').' '.($day['date'] ?? '')) }}
|
||||
</h3>
|
||||
@endif
|
||||
<ul class="mt-2 divide-y divide-slate-800">
|
||||
@foreach((array) ($day['items'] ?? []) as $item)
|
||||
<li class="py-2.5">
|
||||
<div class="flex gap-3">
|
||||
@if(! empty($item['time']))
|
||||
<span class="w-14 shrink-0 font-mono text-xs text-slate-500">{{ $item['time'] }}</span>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-slate-100">{{ $item['title'] ?? '' }}</p>
|
||||
@if(! empty($item['description']))
|
||||
<p class="mt-0.5 text-xs text-slate-400">{{ $item['description'] }}</p>
|
||||
@endif
|
||||
@if(! empty($item['location']) || ! empty($item['host']))
|
||||
<p class="mt-0.5 text-xs text-slate-500">
|
||||
@if(! empty($item['location'])){{ $item['location'] }}@endif
|
||||
@if(! empty($item['host'])) · {{ $item['host'] }}@endif
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="px-2 py-4 text-center text-xs text-slate-500">No programme linked to this conference yet.</p>
|
||||
@endif
|
||||
@@ -27,6 +27,7 @@
|
||||
$isConferenceSession = $isConference ?? false;
|
||||
$sessionNoun = $isConferenceSession ? 'conference' : 'meeting';
|
||||
$sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting';
|
||||
$hasProgramme = is_array($programmeSnapshot ?? null) && ! empty(($programmeSnapshot ?? [])['days']);
|
||||
@endphp
|
||||
<div id="meet-config"
|
||||
data-token="{{ $mediaToken }}"
|
||||
@@ -70,11 +71,14 @@
|
||||
data-spotlight-speaker-ref="{{ $stageConfig['spotlight_speaker_ref'] ?? '' }}"
|
||||
data-presenter-refs='@json($presenterRefs ?? [])'
|
||||
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
||||
data-in-breakout="{{ ($inBreakout ?? false) ? '1' : '0' }}"
|
||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
||||
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
||||
data-breakouts-create-url="{{ route('meet.room.breakouts.create', $session) }}"
|
||||
data-breakouts-close-url="{{ route('meet.room.breakouts.close', $session) }}"
|
||||
data-breakouts-return-url="{{ route('meet.room.breakouts.return', $session) }}"
|
||||
data-breakouts-broadcast-url="{{ route('meet.room.breakouts.broadcast', $session) }}"
|
||||
data-media-token-url="{{ route('meet.room.media-token', $session) }}"
|
||||
data-files-upload-url="{{ route('meet.room.files.upload', $session) }}"
|
||||
data-whiteboard-url="{{ route('meet.room.whiteboard.show', $session) }}"
|
||||
data-whiteboard-save-url="{{ route('meet.room.whiteboard.save', $session) }}"
|
||||
@@ -167,7 +171,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="usesStageLayout && !isGreenRoom" x-cloak
|
||||
<div x-show="inBreakout" x-cloak
|
||||
class="flex shrink-0 flex-col gap-1 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-amber-100">Breakout session</p>
|
||||
<p class="truncate text-xs text-amber-200/90" x-text="breakoutRoomName ? 'You are in ' + breakoutRoomName : 'You are in a breakout room'"></p>
|
||||
</div>
|
||||
<p x-show="breakoutTimeRemaining()" class="shrink-0 text-xs font-medium text-amber-200/90" x-text="breakoutTimeRemaining()"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="isHost && breakoutsActive && !inBreakout" x-cloak
|
||||
class="flex shrink-0 flex-col gap-2 border-b border-amber-500/25 bg-amber-500/10 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 text-sm text-amber-100">
|
||||
<p class="font-medium">Breakouts are active</p>
|
||||
<p class="text-xs text-amber-200/90">Attendees are in smaller discussion groups. You remain on the main stage.</p>
|
||||
</div>
|
||||
<button @click="closeBreakouts()" type="button"
|
||||
class="shrink-0 rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-semibold text-amber-950 hover:bg-amber-400">
|
||||
End breakouts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="usesStageLayout && !isGreenRoom && !inBreakout" x-cloak
|
||||
class="flex shrink-0 flex-col gap-2 border-b px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
|
||||
:class="stageLayout === 'panel' ? 'border-violet-500/25 bg-violet-500/10' : 'border-sky-500/25 bg-sky-500/10'">
|
||||
<div class="flex min-w-0 items-start gap-2.5">
|
||||
@@ -325,6 +350,14 @@
|
||||
<button @click="sendReaction('heart')" type="button" x-show="canPublish" class="hidden rounded-full bg-slate-700 p-3 hover:bg-slate-600 sm:inline-flex" title="React">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'react'])
|
||||
</button>
|
||||
@if ($hasProgramme)
|
||||
<button @click="toggleProgramme()" type="button"
|
||||
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
|
||||
:class="programmeOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||
title="Programme">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M4.5 8.25h15M4.5 19.5h15a1.5 1.5 0 0 0 1.5-1.5V8.25a1.5 1.5 0 0 0-1.5-1.5h-15a1.5 1.5 0 0 0-1.5 1.5v9.75a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
</button>
|
||||
@endif
|
||||
<button @click="toggleChat()" type="button"
|
||||
class="hidden rounded-full p-3 transition-colors sm:inline-flex"
|
||||
:class="chatOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
|
||||
@@ -379,7 +412,7 @@
|
||||
@keydown.escape.window="breakoutModalOpen = false">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="breakoutModalOpen = false">
|
||||
<h3 class="text-base font-semibold text-white">Start breakout rooms</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">How many breakout rooms should be created?</p>
|
||||
<p class="mt-1 text-sm text-slate-400">Attendees and speakers are split into smaller groups with full meeting controls. You stay on the main stage.</p>
|
||||
<form @submit.prevent="submitBreakouts()" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="breakout-count" class="sr-only">Number of rooms</label>
|
||||
@@ -444,6 +477,21 @@
|
||||
</div>
|
||||
</x-meet.room.panel>
|
||||
|
||||
@if ($hasProgramme)
|
||||
<x-meet.room.panel show="programmeOpen" title="Programme">
|
||||
<x-slot:subtitle>
|
||||
<p>Full conference lineup from Events</p>
|
||||
</x-slot:subtitle>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
@include('meet.room.partials.programme-panel', [
|
||||
'programmeSnapshot' => $programmeSnapshot,
|
||||
'linkedProgrammeItems' => $linkedProgrammeItems ?? [],
|
||||
])
|
||||
</div>
|
||||
</x-meet.room.panel>
|
||||
@endif
|
||||
|
||||
<x-meet.room.panel show="participantsOpen" :title="null">
|
||||
<x-slot:subtitle>
|
||||
<template x-if="usesStageLayout">
|
||||
@@ -641,6 +689,18 @@
|
||||
@click="sendReaction('thumbs'); featuresOpen = false" />
|
||||
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
|
||||
@click="sendReaction('heart'); featuresOpen = false" />
|
||||
@if ($hasProgramme)
|
||||
<button @click="toggleProgramme(); featuresOpen = false" type="button"
|
||||
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M4.5 8.25h15M4.5 19.5h15a1.5 1.5 0 0 0 1.5-1.5V8.25a1.5 1.5 0 0 0-1.5-1.5h-15a1.5 1.5 0 0 0-1.5 1.5v9.75a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium">Programme</span>
|
||||
<span class="block text-xs text-slate-400">View the conference lineup</span>
|
||||
</span>
|
||||
</button>
|
||||
@endif
|
||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||
@click="toggleChat(); featuresOpen = false" />
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
@@ -690,6 +750,15 @@
|
||||
|
||||
<x-meet.room.menu-item icon="close-breakout" title="Close breakouts" subtitle="Bring everyone back to the main room"
|
||||
@click="closeBreakouts()" />
|
||||
|
||||
<div x-show="breakoutsActive" x-cloak class="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<p class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Broadcast to breakouts</p>
|
||||
<form @submit.prevent="broadcastToBreakouts()" class="flex gap-2">
|
||||
<input type="text" x-model="breakoutBroadcastInput" placeholder="Message all breakout rooms…"
|
||||
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
|
||||
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-500">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user