Use bottom sheet for More menu and add copy-button feedback.
Deploy Ladill Meet / deploy (push) Successful in 1m13s

Shared copy-button component shows Copied state on meeting details; mobile More is icon-only and opens the same panel pattern as chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 14:56:46 +00:00
co-authored by Cursor
parent 322f96b367
commit 8b43b01dfa
5 changed files with 176 additions and 100 deletions
+2
View File
@@ -1,7 +1,9 @@
import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';
import { registerLadillClipboard } from './ladill-clipboard';
Alpine.plugin(collapse);
registerLadillClipboard(Alpine);
// In-app notification bell + dropdown.
Alpine.data('notificationDropdown', (config = {}) => ({
+57
View File
@@ -0,0 +1,57 @@
const COPY_FEEDBACK_MS = 2000;
export async function writeClipboardText(text) {
const value = String(text ?? '');
if (!value) {
return false;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {
// fall through to legacy copy
}
try {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
export function registerLadillClipboard(Alpine) {
Alpine.data('copyButton', (text = '') => ({
copied: false,
text: text ?? '',
resetTimer: null,
async copy() {
const ok = await writeClipboardText(this.text);
if (!ok) {
return;
}
this.copied = true;
if (this.resetTimer) {
clearTimeout(this.resetTimer);
}
this.resetTimer = setTimeout(() => {
this.copied = false;
}, COPY_FEEDBACK_MS);
},
}));
}
@@ -0,0 +1,29 @@
@props([
'text' => '',
'label' => 'Copy',
'copiedLabel' => 'Copied!',
'icon' => false,
'copiedClass' => '',
])
<button
type="button"
x-data="copyButton(@js($text))"
@click="copy()"
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
:class="copied ? @js($copiedClass) : ''"
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
>
@if ($icon)
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
@elseif ($slot->isNotEmpty())
{{ $slot }}
@else
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
@endif
</button>
+87 -99
View File
@@ -205,11 +205,10 @@
@include('meet.room.partials.meet-icon', ['icon' => 'screen-share'])
</button>
<button @click="toggleFeatures()" type="button"
class="inline-flex items-center gap-1.5 rounded-full px-3.5 py-3 text-xs font-medium transition-colors sm:hidden"
class="rounded-full p-3 transition-colors sm:hidden"
:class="featuresOpen ? 'bg-indigo-600 hover:bg-indigo-500' : 'bg-slate-700 hover:bg-slate-600'"
title="More options">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
More
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"/></svg>
</button>
</div>
@@ -254,102 +253,6 @@
</div>
</div>
</div>
<div x-show="featuresOpen" x-cloak @click.outside="featuresOpen = false"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="translate-y-2 opacity-0"
x-transition:enter-end="translate-y-0 opacity-100"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="translate-y-0 opacity-100"
x-transition:leave-end="translate-y-2 opacity-0"
class="absolute bottom-full left-1/2 z-40 mb-3 w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-2xl bg-slate-900/98 p-1 shadow-2xl backdrop-blur-md">
<div class="flex items-center justify-between px-3 py-3">
<div>
<p class="text-sm font-semibold text-white">More options</p>
<p class="text-xs text-slate-400">Meeting tools and extras</p>
</div>
<button @click="featuresOpen = false" type="button" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-white" title="Close">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="space-y-1 p-2">
<div class="space-y-1 sm:hidden">
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
@click="raiseHand(); featuresOpen = false" />
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send a quick reaction to everyone"
@click="sendReaction('thumbs'); featuresOpen = false" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the meeting chat"
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<button @click="openUploadPicker()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
@include('meet.room.partials.meet-icon', ['icon' => 'upload-file', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium">Share files</span>
<span class="block text-xs text-slate-400">Upload a file for everyone in the call</span>
</span>
</button>
@if ($participant->isHost())
<button @click="toggleRecordingFromMenu()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-red-500/15">
<span class="h-3 w-3 rounded-full" :class="isRecording ? 'bg-red-500 animate-pulse' : 'bg-red-400'"></span>
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isRecording ? 'Stop recording' : 'Start recording'"></span>
<span class="block text-xs text-slate-400">Save this meeting to the cloud</span>
</span>
</button>
<button @click="toggleLockFromMenu()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
@include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isLocked ? 'Unlock meeting' : 'Lock meeting'"></span>
<span class="block text-xs text-slate-400">Control who can join</span>
</span>
</button>
<x-meet.room.menu-item icon="start-breakout" title="Start breakouts" subtitle="Split participants into smaller rooms"
@click="createBreakouts()" />
<x-meet.room.menu-item icon="close-breakout" title="Close breakouts" subtitle="Bring everyone back to the main room"
@click="closeBreakouts()" />
@endif
</div>
@if ($isWebinar ?? false)
<div class="px-3 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Q&amp;A</p>
<ul class="mt-2 max-h-36 space-y-2 overflow-y-auto">
<template x-for="item in qaItems" :key="item.uuid">
<li class="rounded-lg bg-slate-800/80 px-3 py-2 text-sm">
<span class="font-medium text-sky-300" x-text="item.asker_name"></span>
<span class="text-slate-300" x-text="': ' + item.question"></span>
</li>
</template>
<li x-show="qaItems.length === 0" class="text-xs text-slate-500">No questions yet.</li>
</ul>
<form @submit.prevent="submitQuestion" class="mt-3 flex gap-2">
<input type="text" x-model="qaInput" placeholder="Ask a question…"
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium hover:bg-indigo-500">Send</button>
</form>
</div>
@endif
<p x-show="breakoutBroadcast" class="mx-2 mb-2 rounded-xl bg-amber-950/40 px-3 py-3 text-sm text-amber-100" x-text="breakoutBroadcast"></p>
</div>
</footer>
<div x-show="breakoutModalOpen" x-cloak
@@ -485,6 +388,91 @@
<p x-show="inCallParticipants().length === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500">No participants yet.</p>
</div>
</x-meet.room.panel>
<x-meet.room.panel show="featuresOpen" title="More options">
<x-slot:subtitle>
<p>Meeting tools and extras</p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div class="space-y-1">
<div class="space-y-1 sm:hidden">
<x-meet.room.menu-item icon="raise-hand" title="Raise hand" subtitle="Let the host know you have a question"
@click="raiseHand(); featuresOpen = false" />
<x-meet.room.menu-item icon="applause" title="Applause" subtitle="Send a quick reaction to everyone"
@click="sendReaction('thumbs'); featuresOpen = false" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the meeting chat"
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
</div>
<button @click="openUploadPicker()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
@include('meet.room.partials.meet-icon', ['icon' => 'upload-file', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium">Share files</span>
<span class="block text-xs text-slate-400">Upload a file for everyone in the call</span>
</span>
</button>
@if ($participant->isHost())
<button @click="toggleRecordingFromMenu()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-red-500/15">
<span class="h-3 w-3 rounded-full" :class="isRecording ? 'bg-red-500 animate-pulse' : 'bg-red-400'"></span>
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isRecording ? 'Stop recording' : 'Start recording'"></span>
<span class="block text-xs text-slate-400">Save this meeting to the cloud</span>
</span>
</button>
<button @click="toggleLockFromMenu()" type="button"
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-slate-200 transition-colors hover:bg-slate-800">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800/80">
@include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isLocked ? 'Unlock meeting' : 'Lock meeting'"></span>
<span class="block text-xs text-slate-400">Control who can join</span>
</span>
</button>
<x-meet.room.menu-item icon="start-breakout" title="Start breakouts" subtitle="Split participants into smaller rooms"
@click="createBreakouts()" />
<x-meet.room.menu-item icon="close-breakout" title="Close breakouts" subtitle="Bring everyone back to the main room"
@click="closeBreakouts()" />
@endif
</div>
@if ($isWebinar ?? false)
<div class="mt-4 border-t border-slate-800 pt-4">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Q&amp;A</p>
<ul class="mt-2 max-h-36 space-y-2 overflow-y-auto">
<template x-for="item in qaItems" :key="item.uuid">
<li class="rounded-lg bg-slate-800/80 px-3 py-2 text-sm">
<span class="font-medium text-sky-300" x-text="item.asker_name"></span>
<span class="text-slate-300" x-text="': ' + item.question"></span>
</li>
</template>
<li x-show="qaItems.length === 0" class="text-xs text-slate-500">No questions yet.</li>
</ul>
<form @submit.prevent="submitQuestion" class="mt-3 flex gap-2">
<input type="text" x-model="qaInput" placeholder="Ask a question…"
class="flex-1 rounded-lg border-0 bg-slate-950 px-3 py-2 text-sm text-white placeholder:text-slate-500 ring-1 ring-slate-700/60">
<button type="submit" class="rounded-lg bg-indigo-600 px-3 py-2 text-xs font-medium hover:bg-indigo-500">Send</button>
</form>
</div>
@endif
<p x-show="breakoutBroadcast" class="mt-3 rounded-xl bg-amber-950/40 px-3 py-3 text-sm text-amber-100" x-text="breakoutBroadcast"></p>
</div>
</x-meet.room.panel>
</div>
</body>
</html>
+1 -1
View File
@@ -23,7 +23,7 @@
<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">
<button type="button" onclick="navigator.clipboard.writeText('{{ $room->joinUrl() }}')" class="btn-secondary btn-secondary-sm shrink-0">Copy</button>
<x-copy-button :text="$room->joinUrl()" class="btn-secondary btn-secondary-sm shrink-0" />
</div>
<div class="btn-group mt-4">