Add Conferences and audio-only Rooms, plus mobile schedule icon.
Deploy Ladill Meet / deploy (push) Successful in 1m29s
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:
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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.*'),
|
||||
|
||||
Reference in New Issue
Block a user