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
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\TemplateService;
use App\Services\Meet\TownHallService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ConferenceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected InvitationService $invitations,
protected CalendarService $calendar,
protected TownHallService $townHalls,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)
->where('organization_id', $organization->id)
->where('type', 'town_hall');
$this->scopeToBranch($request, $query);
$conferences = $query->orderByDesc('scheduled_at')->paginate(20);
return view('meet.conferences.index', compact('conferences', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$templates = Template::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.conferences.create', [
'organization' => $organization,
'templates' => $templates,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'scheduled_at' => ['required', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
'green_room' => ['sometimes', 'boolean'],
'practice_mode' => ['sometimes', 'boolean'],
]);
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
'mute_on_join' => $request->boolean('mute_on_join', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'green_room' => $request->boolean('green_room', true),
'practice_mode' => $request->boolean('practice_mode'),
'stage_mode' => true,
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
'type' => 'town_hall',
'status' => 'scheduled',
]);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if ($room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
abort_unless($room->isConference(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
return view('meet.conferences.show', compact('room'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_unless($room->isConference(), 404);
if ($room->status === 'cancelled') {
return redirect()->route('meet.conferences.show', $room)
->withErrors(['start' => 'Conference was cancelled.']);
}
if ($room->setting('green_room')) {
$session = $this->townHalls->startGreenRoom($room, $request->user());
} else {
$session = $this->sessions->start($room, $request->user());
}
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to join as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
}
+5 -1
View File
@@ -9,6 +9,7 @@ use App\Models\Session;
use App\Services\Meet\InvitationService; use App\Services\Meet\InvitationService;
use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService; use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\WebinarService; use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -22,6 +23,7 @@ class JoinController extends Controller
protected SessionService $sessions, protected SessionService $sessions,
protected SecurityPolicyService $security, protected SecurityPolicyService $security,
protected WebinarService $webinars, protected WebinarService $webinars,
protected SpaceService $spaces,
) {} ) {}
public function show(Request $request, Room $room): View|RedirectResponse public function show(Request $request, Room $room): View|RedirectResponse
@@ -155,7 +157,9 @@ class JoinController extends Controller
$role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest'; $role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest';
if ($room->isWebinar() && $role !== 'host') { if ($room->isSpace()) {
$role = $this->spaces->resolveJoinRole($room, $user, $role);
} elseif ($room->isWebinar() && $role !== 'host') {
$token = $request->session()->get("meet.webinar.{$room->uuid}"); $token = $request->session()->get("meet.webinar.{$room->uuid}");
$reg = $token $reg = $token
? $this->webinars->findByAccessToken($token) ? $this->webinars->findByAccessToken($token)
@@ -12,7 +12,9 @@ use App\Services\Meet\ParticipantPresenter;
use App\Services\Meet\RecordingService; use App\Services\Meet\RecordingService;
use App\Services\Meet\SecurityPolicyService; use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService; use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\TranscriptService; use App\Services\Meet\TranscriptService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -54,19 +56,28 @@ class MeetingRoomController extends Controller
$session->load(['room', 'participants']); $session->load(['room', 'participants']);
$room = $session->room;
$webinars = app(WebinarService::class);
$spaces = app(SpaceService::class);
$isAudioOnly = $room->isAudioOnly();
return view('meet.room.show', [ return view('meet.room.show', [
'session' => $session, 'session' => $session,
'room' => $session->room, 'room' => $room,
'participant' => $participant, 'participant' => $participant,
'mediaToken' => $token, 'mediaToken' => $token,
'mediaUrl' => $this->media->serverUrl(), 'mediaUrl' => $this->media->serverUrl(),
'mediaConfigured' => $this->media->isConfigured(), 'mediaConfigured' => $this->media->isConfigured(),
'messages' => $this->chat->recentMessages($session), 'messages' => $this->chat->recentMessages($session),
'activeRecording' => $session->recordings()->where('status', 'recording')->first(), 'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false), 'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $session->room->isWebinar(), 'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant), 'isAudioOnly' => $isAudioOnly,
'isAttendee' => $participant->isAttendee(), 'isSpace' => $room->isSpace(),
'canPublish' => $room->isSpace()
? $spaces->canPublish($room, $participant)
: $webinars->canPublish($room, $participant),
'isAttendee' => $participant->isAttendee() || $spaces->isListener($room, $participant),
]); ]);
} }
+10 -12
View File
@@ -45,7 +45,7 @@ class RoomController extends Controller
$owner = $this->ownerRef($request); $owner = $this->ownerRef($request);
$query = Room::owned($owner)->where('organization_id', $organization->id) $query = Room::owned($owner)->where('organization_id', $organization->id)
->where('type', '!=', 'webinar'); ->whereNotIn('type', ['webinar', 'town_hall', 'space']);
$this->scopeToBranch($request, $query); $this->scopeToBranch($request, $query);
$rooms = $query->orderByDesc('scheduled_at')->paginate(20); $rooms = $query->orderByDesc('scheduled_at')->paginate(20);
@@ -80,7 +80,7 @@ class RoomController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'], 'description' => ['nullable', 'string', 'max:2000'],
'type' => ['nullable', 'string', 'in:scheduled,town_hall'], 'type' => ['nullable', 'string', 'in:scheduled'],
'scheduled_at' => ['nullable', 'date'], 'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'], 'timezone' => ['nullable', 'timezone'],
@@ -122,16 +122,6 @@ class RoomController extends Controller
if (! empty($validated['recurrence_rule']) && ! $isInstant) { if (! empty($validated['recurrence_rule']) && ! $isInstant) {
$room = $this->recurring->createSeries($request->user(), $organization, $data); $room = $this->recurring->createSeries($request->user(), $organization, $data);
} elseif (($validated['type'] ?? '') === 'town_hall') {
abort_if($isInstant, 422, 'Town halls must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
'type' => 'town_hall',
'status' => 'scheduled',
'settings' => array_merge($settings, [
'green_room' => true,
'practice_mode' => $request->boolean('practice_mode'),
]),
]));
} elseif ($isInstant) { } elseif ($isInstant) {
$room = $this->rooms->createInstant($request->user(), $organization, $data); $room = $this->rooms->createInstant($request->user(), $organization, $data);
} else { } else {
@@ -162,6 +152,14 @@ class RoomController extends Controller
return redirect()->route('meet.webinars.show', $room); return redirect()->route('meet.webinars.show', $room);
} }
if ($room->isConference()) {
return redirect()->route('meet.conferences.show', $room);
}
if ($room->isSpace()) {
return redirect()->route('meet.spaces.show', $room);
}
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
return view('meet.rooms.show', compact('room')); return view('meet.rooms.show', compact('room'));
@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Member;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\SpaceService;
use App\Services\Meet\TemplateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpaceController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected InvitationService $invitations,
protected CalendarService $calendar,
protected SpaceService $spaces,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)
->where('organization_id', $organization->id)
->where('type', 'space');
$this->scopeToBranch($request, $query);
$spaces = $query->orderByDesc('updated_at')->paginate(20);
return view('meet.spaces.index', compact('spaces', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$templates = Template::owned($owner)
->where('organization_id', $organization->id)
->orderBy('name')
->get();
$members = Member::owned($owner)
->where('organization_id', $organization->id)
->where('user_ref', '!=', $owner)
->orderBy('user_ref')
->get();
return view('meet.spaces.create', [
'organization' => $organization,
'templates' => $templates,
'members' => $members,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'speaker_refs' => ['nullable', 'array'],
'speaker_refs.*' => ['string', 'max:64'],
'invite_only' => ['sometimes', 'boolean'],
'mute_listeners' => ['sometimes', 'boolean'],
]);
$speakerRefs = collect($validated['speaker_refs'] ?? [])
->filter()
->unique()
->values()
->all();
$settings = [
'waiting_room' => false,
'join_before_host' => false,
'mute_on_join' => $request->boolean('mute_listeners', true),
'video_off_on_join' => true,
'audio_only' => true,
'space_mode' => true,
'stage_mode' => true,
'invite_only' => $request->boolean('invite_only', true),
'speaker_refs' => $speakerRefs,
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
'type' => 'space',
'status' => empty($validated['scheduled_at']) ? 'scheduled' : 'scheduled',
]);
unset($data['speaker_refs'], $data['invite_only'], $data['mute_listeners']);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if (! empty($room->scheduled_at)) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.spaces.show', $room)->with('success', 'Room created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
abort_unless($room->isSpace(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
$speakerRefs = (array) $room->setting('speaker_refs', []);
$speakers = Member::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->whereIn('user_ref', $speakerRefs)
->get();
return view('meet.spaces.show', compact('room', 'speakers'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_unless($room->isSpace(), 404);
if ($room->status === 'cancelled') {
return redirect()->route('meet.spaces.show', $room)
->withErrors(['start' => 'Room was closed.']);
}
$session = $this->sessions->start($room, $request->user());
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to open room as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
}
+23
View File
@@ -81,6 +81,21 @@ class Room extends Model
return $this->type === 'webinar'; return $this->type === 'webinar';
} }
public function isConference(): bool
{
return $this->type === 'town_hall';
}
public function isSpace(): bool
{
return $this->type === 'space';
}
public function isAudioOnly(): bool
{
return $this->isSpace() || (bool) $this->setting('audio_only', false);
}
public function canRestart(): bool public function canRestart(): bool
{ {
if ($this->status === 'cancelled' || $this->activeSession()) { if ($this->status === 'cancelled' || $this->activeSession()) {
@@ -100,6 +115,14 @@ class Room extends Model
return $restarting ? 'Restart webinar' : 'Start webinar'; return $restarting ? 'Restart webinar' : 'Start webinar';
} }
if ($this->isConference()) {
return $restarting ? 'Restart conference' : 'Start conference';
}
if ($this->isSpace()) {
return $restarting ? 'Reopen room' : 'Open room';
}
return $restarting ? 'Restart meeting' : 'Start meeting'; return $restarting ? 'Restart meeting' : 'Start meeting';
} }
+4 -1
View File
@@ -262,8 +262,11 @@ class SessionService
$isHost = $participant->isHost(); $isHost = $participant->isHost();
$breakoutService = app(BreakoutService::class); $breakoutService = app(BreakoutService::class);
$webinarService = app(WebinarService::class); $webinarService = app(WebinarService::class);
$spaceService = app(\App\Services\Meet\SpaceService::class);
$mediaRoom = $breakoutService->mediaRoomForParticipant($participant); $mediaRoom = $breakoutService->mediaRoomForParticipant($participant);
$canPublish = $webinarService->canPublish($room, $participant) && $participant->canPublishMedia(); $canPublish = ($room->isSpace()
? $spaceService->canPublish($room, $participant)
: $webinarService->canPublish($room, $participant)) && $participant->canPublishMedia();
return $this->media->generateToken( return $this->media->generateToken(
$mediaRoom, $mediaRoom,
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Meet;
use App\Models\Participant;
use App\Models\Room;
use App\Models\User;
class SpaceService
{
/**
* Resolve participant role for a Spaces-style room join.
*/
public function resolveJoinRole(Room $room, ?User $user, string $fallbackRole = 'guest'): string
{
if ($room->type !== 'space') {
return $fallbackRole;
}
if ($user && $user->ownerRef() === $room->host_user_ref) {
return 'host';
}
$speakerRefs = (array) $room->setting('speaker_refs', []);
if ($user && in_array($user->ownerRef(), $speakerRefs, true)) {
return 'panelist';
}
return 'attendee';
}
public function canPublish(Room $room, Participant $participant): bool
{
if ($room->type !== 'space') {
return true;
}
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
}
public function isListener(Room $room, Participant $participant): bool
{
return $room->type === 'space' && $participant->role === 'attendee';
}
public function isAudioOnly(Room $room): bool
{
return $room->isSpace() || (bool) $room->setting('audio_only', false);
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ class WebinarService
public function canPublish(Room $room, Participant $participant): bool public function canPublish(Room $room, Participant $participant): bool
{ {
if ($room->type !== 'webinar') { if (! in_array($room->type, ['webinar', 'town_hall'], true)) {
return true; return true;
} }
+2 -1
View File
@@ -16,7 +16,8 @@ return [
'personal' => 'Personal room', 'personal' => 'Personal room',
'recurring' => 'Recurring meeting', 'recurring' => 'Recurring meeting',
'webinar' => 'Webinar', 'webinar' => 'Webinar',
'town_hall' => 'Town hall', 'town_hall' => 'Conference',
'space' => 'Room',
], ],
'room_statuses' => [ 'room_statuses' => [
+13
View File
@@ -45,6 +45,19 @@ html.meet-room-page {
box-shadow: 0 0 0 2px rgb(52 211 153 / 0.9); 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 { .meet-room-page .meet-speaking-bar {
transform-origin: center bottom; transform-origin: center bottom;
animation: meet-speaking-pulse 0.9s ease-in-out infinite; animation: meet-speaking-pulse 0.9s ease-in-out infinite;
+20 -7
View File
@@ -38,6 +38,8 @@ function meetRoom() {
needsMediaUnlock: false, needsMediaUnlock: false,
waitingCount: 0, waitingCount: 0,
isWebinar: false, isWebinar: false,
audioOnly: false,
isSpace: false,
qaItems: [], qaItems: [],
activePolls: [], activePolls: [],
breakoutBroadcast: '', breakoutBroadcast: '',
@@ -105,8 +107,10 @@ function meetRoom() {
this.isHost = el.dataset.isHost === '1'; this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0'; this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1'; 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.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.isRecording = el.dataset.recordingActive === '1';
this.isLocked = el.dataset.locked === '1'; this.isLocked = el.dataset.locked === '1';
this.watermark = el.dataset.watermark === '1'; this.watermark = el.dataset.watermark === '1';
@@ -275,7 +279,7 @@ function meetRoom() {
if (this.canPublish) { if (this.canPublish) {
await this.enableLocalMedia(room); await this.enableLocalMedia(room);
} else { } else {
this.connectionStatus = 'View-only audience mode'; this.connectionStatus = this.audioOnly ? 'Listening' : 'View-only audience mode';
this.isMuted = true; this.isMuted = true;
this.isVideoOff = true; this.isVideoOff = true;
} }
@@ -315,7 +319,7 @@ function meetRoom() {
async enableLocalMedia(activeRoom, attempts = 3) { async enableLocalMedia(activeRoom, attempts = 3) {
const wantMic = !this.muteOnJoin || this.isHost; const wantMic = !this.muteOnJoin || this.isHost;
const wantCamera = !this.videoOffOnJoin; const wantCamera = !this.videoOffOnJoin && !this.audioOnly;
const audioOptions = { const audioOptions = {
echoCancellation: true, echoCancellation: true,
noiseSuppression: true, noiseSuppression: true,
@@ -651,7 +655,9 @@ function meetRoom() {
if (!tile) { if (!tile) {
tile = document.createElement('div'); tile = document.createElement('div');
tile.dataset.participantTile = identity; 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 = ` tile.innerHTML = `
<div data-tile-stage class="absolute inset-0 flex items-center justify-center"></div> <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"> <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); const hasVideo = this.isParticipantVideoActive(participant);
micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant)); micBadge.classList.toggle('hidden', !this.isParticipantMuted(participant));
camBadge.classList.toggle('hidden', hasVideo); camBadge.classList.toggle('hidden', hasVideo || this.audioOnly);
if (hasVideo) { if (hasVideo) {
avatar.classList.add('hidden'); avatar.classList.add('hidden');
@@ -1032,6 +1038,8 @@ function meetRoom() {
} }
participant.videoTrackPublications.forEach((pub) => { participant.videoTrackPublications.forEach((pub) => {
if (this.audioOnly) return;
if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) { if (pub.track && !pub.isMuted && pub.source !== Track.Source.ScreenShare) {
this.attachTrack(pub.track, pub, participant); this.attachTrack(pub.track, pub, participant);
} }
@@ -1061,6 +1069,11 @@ function meetRoom() {
if (track.kind !== Track.Kind.Video) return; if (track.kind !== Track.Kind.Video) return;
if (this.audioOnly) {
this.updateParticipantTile(participant);
return;
}
const isScreen = publication?.source === Track.Source.ScreenShare const isScreen = publication?.source === Track.Source.ScreenShare
|| track.source === Track.Source.ScreenShare; || track.source === Track.Source.ScreenShare;
@@ -1151,7 +1164,7 @@ function meetRoom() {
}, },
async toggleVideo() { async toggleVideo() {
if (!room) return; if (!room || this.audioOnly) return;
await this.ensureAudioPlayback(); await this.ensureAudioPlayback();
const enabled = room.localParticipant.isCameraEnabled; const enabled = room.localParticipant.isCameraEnabled;
await room.localParticipant.setCameraEnabled(!enabled); await room.localParticipant.setCameraEnabled(!enabled);
@@ -1170,7 +1183,7 @@ function meetRoom() {
}, },
async toggleScreenShare() { async toggleScreenShare() {
if (!room) return; if (!room || this.audioOnly) return;
this.isScreenSharing = !this.isScreenSharing; this.isScreenSharing = !this.isScreenSharing;
await room.localParticipant.setScreenShareEnabled(this.isScreenSharing); await room.localParticipant.setScreenShareEnabled(this.isScreenSharing);
if (!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> <h1 class="text-xl font-semibold text-slate-900">Invitations</h1>
<p class="mt-1 text-sm text-slate-500">{{ $room->title }}</p> <p class="mt-1 text-sm text-slate-500">{{ $room->title }}</p>
</div> </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>
<div class="mt-6 grid gap-6 lg:grid-cols-2"> <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> <h3 class="truncate font-medium text-slate-900">{{ $room->title }}</h3>
@if ($room->isWebinar()) @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> <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 @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' }}"> <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 }} {{ 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-caption-url="{{ route('meet.room.caption', $session) }}"
data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}" data-live-captions="{{ $room->setting('live_captions') ? '1' : '0' }}"
data-mute-on-join="{{ $room->setting('mute_on_join', true) ? '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-watermark="{{ $watermark ? '1' : '0' }}"
data-locked="{{ $session->is_locked ? '1' : '0' }}" data-locked="{{ $session->is_locked ? '1' : '0' }}"
data-recording-active="{{ $activeRecording ? '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"> <header class="flex shrink-0 items-center justify-between bg-slate-950 px-4 py-3">
<div class="min-w-0"> <div class="min-w-0">
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1> <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="text-xs text-slate-400" x-text="connectionStatus"></p>
<p class="min-h-4 truncate text-xs font-medium leading-4" <p class="min-h-4 truncate text-xs font-medium leading-4"
:class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'" :class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'"
@@ -165,7 +170,7 @@
</div> </div>
<main class="relative min-h-0 min-w-0 flex-1 p-4"> <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 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> <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> <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>
<button @click="toggleVideo()" type="button" <button @click="toggleVideo()" type="button"
x-show="!audioOnly"
class="rounded-full p-3 transition-colors" class="rounded-full p-3 transition-colors"
:class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'" :class="isVideoOff ? 'bg-red-600 hover:bg-red-500' : 'bg-slate-700 hover:bg-slate-600'"
:title="isVideoOff ? 'Turn camera on' : 'Turn camera off'"> :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> <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>
<button @click="toggleScreenShare()" type="button" <button @click="toggleScreenShare()" type="button"
x-show="!audioOnly"
class="rounded-full p-3 transition-colors" class="rounded-full p-3 transition-colors"
:class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'" :class="isScreenSharing ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="Share screen"> title="Share screen">
+8 -2
View File
@@ -1,7 +1,13 @@
<x-app-layout title="Meetings"> <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> <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>
<div class="mt-4 space-y-2"> <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 = [ $nav = [
['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'), ['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" />'], '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" />'], '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.*'), ['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" />'], '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.*'), ['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'),
+14
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Meet\AdminController; use App\Http\Controllers\Meet\AdminController;
use App\Http\Controllers\Meet\AiController; use App\Http\Controllers\Meet\AiController;
use App\Http\Controllers\Meet\CalendarController; use App\Http\Controllers\Meet\CalendarController;
use App\Http\Controllers\Meet\ConferenceController;
use App\Http\Controllers\Meet\ChannelController; use App\Http\Controllers\Meet\ChannelController;
use App\Http\Controllers\Meet\ContactGroupController; use App\Http\Controllers\Meet\ContactGroupController;
use App\Http\Controllers\Meet\DashboardController; use App\Http\Controllers\Meet\DashboardController;
@@ -18,6 +19,7 @@ use App\Http\Controllers\Meet\OnboardingController;
use App\Http\Controllers\Meet\RecordingController; use App\Http\Controllers\Meet\RecordingController;
use App\Http\Controllers\Meet\ReportController; use App\Http\Controllers\Meet\ReportController;
use App\Http\Controllers\Meet\RoomController; use App\Http\Controllers\Meet\RoomController;
use App\Http\Controllers\Meet\SpaceController;
use App\Http\Controllers\Meet\SettingsController; use App\Http\Controllers\Meet\SettingsController;
use App\Http\Controllers\Meet\SummaryController; use App\Http\Controllers\Meet\SummaryController;
use App\Http\Controllers\Meet\TemplateController; use App\Http\Controllers\Meet\TemplateController;
@@ -131,6 +133,18 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/webinars/{room}', [WebinarController::class, 'show'])->name('meet.webinars.show'); Route::get('/webinars/{room}', [WebinarController::class, 'show'])->name('meet.webinars.show');
Route::post('/webinars/{room}/start', [WebinarController::class, 'start'])->name('meet.webinars.start'); Route::post('/webinars/{room}/start', [WebinarController::class, 'start'])->name('meet.webinars.start');
Route::get('/conferences', [ConferenceController::class, 'index'])->name('meet.conferences.index');
Route::get('/conferences/create', [ConferenceController::class, 'create'])->name('meet.conferences.create');
Route::post('/conferences', [ConferenceController::class, 'store'])->name('meet.conferences.store');
Route::get('/conferences/{room}', [ConferenceController::class, 'show'])->name('meet.conferences.show');
Route::post('/conferences/{room}/start', [ConferenceController::class, 'start'])->name('meet.conferences.start');
Route::get('/rooms', [SpaceController::class, 'index'])->name('meet.spaces.index');
Route::get('/rooms/create', [SpaceController::class, 'create'])->name('meet.spaces.create');
Route::post('/rooms', [SpaceController::class, 'store'])->name('meet.spaces.store');
Route::get('/rooms/{room}', [SpaceController::class, 'show'])->name('meet.spaces.show');
Route::post('/rooms/{room}/start', [SpaceController::class, 'start'])->name('meet.spaces.start');
Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations'); Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations');
Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve'); Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve');
Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject'); Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject');
+17 -3
View File
@@ -598,12 +598,11 @@ class MeetWebTest extends TestCase
]); ]);
} }
public function test_host_can_create_town_hall_room(): void public function test_host_can_create_conference(): void
{ {
$this->actingAs($this->user) $this->actingAs($this->user)
->post('/meetings', [ ->post('/conferences', [
'title' => 'All hands', 'title' => 'All hands',
'type' => 'town_hall',
'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'),
'duration_minutes' => 90, 'duration_minutes' => 90,
]) ])
@@ -615,6 +614,21 @@ class MeetWebTest extends TestCase
]); ]);
} }
public function test_host_can_create_space_room(): void
{
$this->actingAs($this->user)
->post('/rooms', [
'title' => 'Leadership space',
'invite_only' => true,
])
->assertRedirect();
$this->assertDatabaseHas('meet_rooms', [
'title' => 'Leadership space',
'type' => 'space',
]);
}
protected function createRoom(array $overrides = []): Room protected function createRoom(array $overrides = []): Room
{ {
return Room::create(array_merge([ return Room::create(array_merge([