Add Conferences and audio-only Rooms, plus mobile schedule icon.
Deploy Ladill Meet / deploy (push) Successful in 1m29s

Split town_hall into a Conferences sidebar flow and Spaces-style Rooms with host/speaker/listener roles; enforce audio-only LiveKit UI for rooms; show schedule icon on mobile meetings index.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 19:16:54 +00:00
co-authored by Cursor
parent 1705e6b435
commit 63af522380
25 changed files with 897 additions and 37 deletions
+13
View File
@@ -45,6 +45,19 @@ html.meet-room-page {
box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9);
}
.meet-room-page .meet-audio-grid {
place-content: center;
place-items: center;
grid-template-columns: repeat(auto-fill, minmax(7rem, 9rem));
gap: 1rem;
}
.meet-room-page .meet-audio-tile [data-tile-avatar-circle] {
height: 4.5rem;
width: 4.5rem;
font-size: 1.25rem;
}
.meet-room-page .meet-speaking-bar {
transform-origin: center bottom;
animation: meet-speaking-pulse 0.9s ease-in-out infinite;
+20 -7
View File
@@ -38,6 +38,8 @@ function meetRoom() {
needsMediaUnlock: false,
waitingCount: 0,
isWebinar: false,
audioOnly: false,
isSpace: false,
qaItems: [],
activePolls: [],
breakoutBroadcast: '',
@@ -105,8 +107,10 @@ function meetRoom() {
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
this.audioOnly = el.dataset.audioOnly === '1';
this.isSpace = el.dataset.isSpace === '1';
this.muteOnJoin = el.dataset.muteOnJoin === '1';
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1';
this.videoOffOnJoin = el.dataset.videoOffOnJoin === '1' || this.audioOnly;
this.isRecording = el.dataset.recordingActive === '1';
this.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1';
@@ -275,7 +279,7 @@ function meetRoom() {
if (this.canPublish) {
await this.enableLocalMedia(room);
} else {
this.connectionStatus = 'View-only audience mode';
this.connectionStatus = this.audioOnly ? 'Listening' : 'View-only audience mode';
this.isMuted = true;
this.isVideoOff = true;
}
@@ -315,7 +319,7 @@ function meetRoom() {
async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = !this.muteOnJoin || this.isHost;
const wantCamera = !this.videoOffOnJoin;
const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
const audioOptions = {
echoCancellation: true,
noiseSuppression: true,
@@ -651,7 +655,9 @@ function meetRoom() {
if (!tile) {
tile = document.createElement('div');
tile.dataset.participantTile = identity;
tile.className = 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900';
tile.className = this.audioOnly
? 'meet-audio-tile relative flex aspect-square w-full max-w-[9rem] flex-col items-center justify-center overflow-hidden rounded-2xl bg-slate-900'
: 'relative aspect-video w-full overflow-hidden rounded-xl bg-slate-900';
tile.innerHTML = `
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div>
<div data-tile-avatar class="absolute inset-0 flex flex-col items-center justify-center gap-3 px-4">
@@ -716,7 +722,7 @@ function meetRoom() {
const hasVideo = this.isParticipantVideoActive(participant);
micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant));
camBadge.classList.toggle('hidden', hasVideo);
camBadge.classList.toggle('hidden', hasVideo || this.audioOnly);
if (hasVideo) {
avatar.classList.add('hidden');
@@ -1032,6 +1038,8 @@ function meetRoom() {
}
participant.videoTrackPublications.forEach((pub) => {
if (this.audioOnly) return;
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
this.attachTrack(pub.track, pub, participant);
}
@@ -1061,6 +1069,11 @@ function meetRoom() {
if (track.kind !== Track.Kind.Video) return;
if (this.audioOnly) {
this.updateParticipantTile(participant);
return;
}
const isScreen = publication?.source === Track.Source.ScreenShare
|| track.source === Track.Source.ScreenShare;
@@ -1151,7 +1164,7 @@ function meetRoom() {
},
async toggleVideo() {
if (!room) return;
if (!room || this.audioOnly) return;
await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled);
@@ -1170,7 +1183,7 @@ function meetRoom() {
},
async toggleScreenShare() {
if (!room) return;
if (!room || this.audioOnly) return;
this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!this.isScreenSharing) {
@@ -0,0 +1,73 @@
<x-app-layout title="Schedule conference">
<div class="mx-auto max-w-xl">
<h1 class="text-xl font-semibold text-slate-900">Schedule a conference</h1>
<p class="mt-1 text-sm text-slate-500">Large-format events with assigned hosts and speakers. Optional green room for presenters before going live.</p>
<form method="POST" action="{{ route('meet.conferences.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
@if ($templates->isNotEmpty())
<div>
<label class="block text-sm font-medium text-slate-700">Template</label>
<select name="template_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">None</option>
@foreach ($templates as $template)
<option value="{{ $template->id }}" @selected(old('template_id') == $template->id)>{{ $template->name }}</option>
@endforeach
</select>
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Title</label>
<input type="text" name="title" value="{{ old('title') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="3" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('description') }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Date & time</label>
<input type="datetime-local" name="scheduled_at" value="{{ old('scheduled_at') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Duration (minutes)</label>
<input type="number" name="duration_minutes" value="{{ old('duration_minutes', 90) }}" min="15" max="480" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Timezone</label>
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach (['UTC', 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'Europe/London', 'Africa/Lagos'] as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Invite by email (comma-separated)</label>
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="speaker@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Passcode (optional)</label>
<input type="text" name="passcode" value="{{ old('passcode') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-medium text-slate-700">Options</legend>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="green_room" value="1" @checked(old('green_room', true)) class="rounded border-slate-300"> Green room for hosts & speakers before going live</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="practice_mode" value="1" @checked(old('practice_mode')) class="rounded border-slate-300"> Practice mode in green room</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', true)) class="rounded border-slate-300"> Waiting room for attendees</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', true)) class="rounded border-slate-300"> Mute attendees on join</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" @checked(old('auto_record')) class="rounded border-slate-300"> Auto-record</label>
</fieldset>
<button type="submit" class="btn-primary btn-primary-lg w-full">Create conference</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,22 @@
<x-app-layout title="Conferences">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-slate-900">Conferences</h1>
<p class="mt-1 text-sm text-slate-500">Multi-speaker events with hosts and presenters. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.</p>
</div>
<a href="{{ route('meet.conferences.create') }}" class="btn-primary">Schedule conference</a>
</div>
<div class="mt-4 space-y-2">
@forelse ($conferences as $room)
@include('meet.partials.room-card', [
'room' => $room,
'detailsRoute' => route('meet.conferences.show', $room),
])
@empty
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">No conferences yet.</p>
@endforelse
</div>
<div class="mt-4">{{ $conferences->links() }}</div>
</x-app-layout>
@@ -0,0 +1,81 @@
<x-app-layout title="{{ $room->title }}">
<div class="mx-auto max-w-2xl">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-violet-600">Conference</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
<div class="mt-4 rounded-xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm text-violet-900">
Multi-speaker event with assigned hosts and presenters.
Billed at <span class="font-medium">GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant</span>.
@if ($room->setting('green_room'))
Green room is enabled presenters rehearse before the audience joins live.
@endif
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
<div class="mt-2 flex gap-2">
<input type="text" readonly value="{{ $room->joinUrl() }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
<x-copy-button :text="$room->joinUrl()" class="btn-secondary btn-secondary-sm shrink-0" />
</div>
<div class="btn-group mt-4">
@if ($room->status === 'live' && $room->activeSession())
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-sm">Join conference</a>
@elseif ($room->status === 'scheduled')
<form method="POST" action="{{ route('meet.conferences.start', $room) }}" class="inline">
@csrf
<button type="submit" class="btn-primary btn-primary-sm">Start conference</button>
</form>
@elseif ($room->canRestart())
<form method="POST" action="{{ route('meet.conferences.start', $room) }}" class="inline">
@csrf
<button type="submit" class="btn-primary btn-primary-sm">{{ $room->restartLabel() }}</button>
</form>
@endif
<a href="{{ route('meet.rooms.ical', $room) }}" class="btn-secondary btn-secondary-sm">Download iCal</a>
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
@if ($room->sessions->isNotEmpty())
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
@endif
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
</div>
@if ($room->passcode)
<p class="mt-3 text-sm text-slate-600">Passcode: <span class="font-mono font-medium">{{ $room->passcode }}</span></p>
@endif
@if ($room->scheduled_at)
<p class="mt-3 text-sm text-slate-600">
Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}
</p>
@endif
@if ($room->description)
<p class="mt-3 text-sm text-slate-600">{{ $room->description }}</p>
@endif
</div>
@if ($room->sessions->isNotEmpty())
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Sessions</h2>
<ul class="mt-3 divide-y divide-slate-100 text-sm">
@foreach ($room->sessions as $session)
<li class="flex items-center justify-between py-2">
<span>{{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }}</span>
<span class="text-slate-500">{{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }}</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
</x-app-layout>
@@ -4,7 +4,21 @@
<h1 class="text-xl font-semibold text-slate-900">Invitations</h1>
<p class="mt-1 text-sm text-slate-500">{{ $room->title }}</p>
</div>
<a href="{{ route($room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show', $room) }}" class="text-sm text-indigo-600 hover:underline"> Back to {{ $room->isWebinar() ? 'webinar' : 'meeting' }}</a>
@php
$backRoute = match (true) {
$room->isWebinar() => route('meet.webinars.show', $room),
$room->isConference() => route('meet.conferences.show', $room),
$room->isSpace() => route('meet.spaces.show', $room),
default => route('meet.rooms.show', $room),
};
$backLabel = match (true) {
$room->isWebinar() => 'webinar',
$room->isConference() => 'conference',
$room->isSpace() => 'room',
default => 'meeting',
};
@endphp
<a href="{{ $backRoute }}" class="text-sm text-indigo-600 hover:underline"> Back to {{ $backLabel }}</a>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
@@ -13,6 +13,10 @@
<h3 class="truncate font-medium text-slate-900">{{ $room->title }}</h3>
@if ($room->isWebinar())
<span class="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">Webinar</span>
@elseif ($room->isConference())
<span class="shrink-0 rounded-full bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-700">Conference</span>
@elseif ($room->isSpace())
<span class="shrink-0 rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">Room</span>
@endif
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColors[$room->status] ?? 'bg-slate-100 text-slate-600' }}">
{{ config('meet.room_statuses')[$room->status] ?? $room->status }}
+9 -2
View File
@@ -46,7 +46,9 @@
data-caption-url="{{ route('meet.room.caption', $session) }}"
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '1' : '0' }}"
data-video-off-on-join="{{ $room->setting('video_off_on_join', false) ? '1' : '0' }}"
data-video-off-on-join="{{ ($isAudioOnly ?? false) || $room->setting('video_off_on_join', false) ? '1' : '0' }}"
data-audio-only="{{ ($isAudioOnly ?? false) ? '1' : '0' }}"
data-is-space="{{ ($isSpace ?? false) ? '1' : '0' }}"
data-watermark="{{ $watermark ? '1' : '0' }}"
data-locked="{{ $session->is_locked ? '1' : '0' }}"
data-recording-active="{{ $activeRecording ? '1' : '0' }}"
@@ -80,6 +82,9 @@
<header class="flex shrink-0 items-center justify-between bg-slate-950 px-4 py-3">
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
@if ($isAudioOnly ?? false)
<p class="text-[10px] font-medium uppercase tracking-wide text-emerald-400/90">Audio room</p>
@endif
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
<p class="min-h-4 truncate text-xs font-medium leading-4"
:class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'"
@@ -165,7 +170,7 @@
</div>
<main class="relative min-h-0 min-w-0 flex-1 p-4">
<div id="video-grid" class="grid h-full gap-2 sm:grid-cols-2 lg:grid-cols-3"></div>
<div id="video-grid" class="grid h-full gap-2 sm:grid-cols-2 lg:grid-cols-3" :class="audioOnly ? 'meet-audio-grid' : ''"></div>
<div id="screen-share" class="absolute inset-4 hidden rounded-xl bg-black"></div>
<div x-show="watermark" class="pointer-events-none absolute right-6 top-6 rounded bg-black/40 px-3 py-1 text-xs text-white/80" x-text="displayName"></div>
@@ -196,6 +201,7 @@
<svg x-show="isMuted" x-cloak 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="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleVideo()" type="button"
x-show="!audioOnly"
class="rounded-full p-3 transition-colors"
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'">
@@ -203,6 +209,7 @@
<svg x-show="isVideoOff" x-cloak 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="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"/><path stroke-linecap="round" stroke-linejoin="round" d="m3 3 18 18"/></svg>
</button>
<button @click="toggleScreenShare()" type="button"
x-show="!audioOnly"
class="rounded-full p-3 transition-colors"
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Share screen">
+8 -2
View File
@@ -1,7 +1,13 @@
<x-app-layout title="Meetings">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold text-slate-900">All meetings</h1>
<a href="{{ route('meet.rooms.create') }}" class="btn-primary">Schedule meeting</a>
<a href="{{ route('meet.rooms.create') }}"
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 shadow-sm transition hover:bg-slate-50 lg:hidden"
title="Schedule meeting"
aria-label="Schedule meeting">
@include('meet.room.partials.meet-icon', ['icon' => 'schedule', 'inverted' => false])
</a>
<a href="{{ route('meet.rooms.create') }}" class="btn-primary hidden lg:inline-flex">Schedule meeting</a>
</div>
<div class="mt-4 space-y-2">
@@ -0,0 +1,59 @@
<x-app-layout title="Create room">
<div class="mx-auto max-w-xl">
<h1 class="text-xl font-semibold text-slate-900">Create a room</h1>
<p class="mt-1 text-sm text-slate-500">Audio-only. Host and assigned speakers talk; everyone else listens and can raise a hand to speak.</p>
<form method="POST" action="{{ route('meet.spaces.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Title</label>
<input type="text" name="title" value="{{ old('title') }}" required placeholder="Weekly leadership check-in" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="3" placeholder="What is this room about?" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('description') }}</textarea>
</div>
@if ($members->isNotEmpty())
<div>
<label class="block text-sm font-medium text-slate-700">Assigned speakers</label>
<p class="mt-0.5 text-xs text-slate-500">Team members who can speak when the room is live. You are always the host.</p>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-slate-200 p-3">
@foreach ($members as $member)
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="speaker_refs[]" value="{{ $member->user_ref }}" @checked(in_array($member->user_ref, old('speaker_refs', []))) class="rounded border-slate-300">
<span>{{ $member->user_ref }}</span>
</label>
@endforeach
</div>
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Invite listeners by email (optional)</label>
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="guest@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Passcode (optional)</label>
<input type="text" name="passcode" value="{{ old('passcode') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-medium text-slate-700">Privacy</legend>
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="invite_only" value="1" @checked(old('invite_only', true)) class="mt-0.5 rounded border-slate-300">
<span>
<span class="font-medium text-slate-800">Private room</span>
<span class="mt-0.5 block text-xs text-slate-500">Only invited people or org members with the link can join.</span>
</span>
</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_listeners" value="1" @checked(old('mute_listeners', true)) class="rounded border-slate-300"> Mute listeners on join (speakers & host can talk)</label>
</fieldset>
<button type="submit" class="btn-primary btn-primary-lg w-full">Create room</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,22 @@
<x-app-layout title="Rooms">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-slate-900">Rooms</h1>
<p class="mt-1 text-sm text-slate-500">Audio-only discussions with a host, assigned speakers, and listeners like X Spaces.</p>
</div>
<a href="{{ route('meet.spaces.create') }}" class="btn-primary">Create room</a>
</div>
<div class="mt-4 space-y-2">
@forelse ($spaces as $room)
@include('meet.partials.room-card', [
'room' => $room,
'detailsRoute' => route('meet.spaces.show', $room),
])
@empty
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">No rooms yet. Create one to host a private discussion.</p>
@endforelse
</div>
<div class="mt-4">{{ $spaces->links() }}</div>
</x-app-layout>
@@ -0,0 +1,76 @@
<x-app-layout title="{{ $room->title }}">
<div class="mx-auto max-w-2xl">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-emerald-600">Room</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
<div class="mt-4 rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">
Host-led audio room. Assigned speakers can talk; everyone else listens and can request to speak.
@if ($room->setting('invite_only'))
This room is private share the join link only with invited people.
@endif
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
<div class="mt-2 flex gap-2">
<input type="text" readonly value="{{ $room->joinUrl() }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
<x-copy-button :text="$room->joinUrl()" class="btn-secondary btn-secondary-sm shrink-0" />
</div>
<div class="btn-group mt-4">
@if ($room->status === 'live' && $room->activeSession())
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-sm">Join room</a>
@elseif ($room->canRestart())
<form method="POST" action="{{ route('meet.spaces.start', $room) }}" class="inline">
@csrf
<button type="submit" class="btn-primary btn-primary-sm">{{ $room->restartLabel() }}</button>
</form>
@endif
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
</div>
@if ($speakers->isNotEmpty())
<div class="mt-5 border-t border-slate-100 pt-4">
<h3 class="text-sm font-medium text-slate-700">Assigned speakers</h3>
<ul class="mt-2 space-y-1 text-sm text-slate-600">
@foreach ($speakers as $speaker)
<li>{{ $speaker->user_ref }}</li>
@endforeach
</ul>
</div>
@endif
@if ($room->passcode)
<p class="mt-3 text-sm text-slate-600">Passcode: <span class="font-mono font-medium">{{ $room->passcode }}</span></p>
@endif
@if ($room->description)
<p class="mt-3 text-sm text-slate-600">{{ $room->description }}</p>
@endif
</div>
@if ($room->sessions->isNotEmpty())
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Past sessions</h2>
<ul class="mt-3 divide-y divide-slate-100 text-sm">
@foreach ($room->sessions as $session)
<li class="flex items-center justify-between py-2">
<span>{{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }}</span>
<span class="text-slate-500">{{ $session->peak_participants }} joined</span>
</li>
@endforeach
</ul>
</div>
@endif
</div>
</x-app-layout>
+5 -1
View File
@@ -13,8 +13,12 @@
$nav = [
['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*'),
['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*') && ! request()->routeIs('meet.spaces.*') && ! request()->routeIs('meet.conferences.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />'],
['name' => 'Rooms', 'route' => route('meet.spaces.index'), 'active' => request()->routeIs('meet.spaces.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z" />'],
['name' => 'Conferences', 'route' => route('meet.conferences.index'), 'active' => request()->routeIs('meet.conferences.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'],
['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z" />'],
['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'),