Use bottom sheet for More menu and add copy-button feedback.
Deploy Ladill Meet / deploy (push) Successful in 1m13s
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:
@@ -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 = {}) => ({
|
||||
|
||||
@@ -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>
|
||||
@@ -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&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&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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user