- Add askama.toml for template configuration (ui/ directory) - Add Suite app documentation with flow diagrams (SVG) - App launcher, chat flow, drive flow, tasks flow - Individual app docs: chat, drive, tasks, mail, etc. - Add HTML templates for Suite apps - Base template with header and app launcher - Auth login page - Chat, Drive, Mail, Meet, Tasks templates - Partial templates for messages, sessions, notifications - Add Extensions type to AppState for type-erased storage - Add mTLS module for service-to-service authentication - Update web handlers to use new template paths (suite/) - Fix auth module to avoid axum-extra TypedHeader dependency
1071 lines
32 KiB
HTML
1071 lines
32 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Meet - General Bots{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="meet-container" id="meet-app">
|
|
<!-- Pre-join Screen -->
|
|
<div class="prejoin-screen" id="prejoin-screen">
|
|
<div class="prejoin-card">
|
|
<div class="prejoin-header">
|
|
<h1>Join Meeting</h1>
|
|
<p>Configure your audio and video before joining</p>
|
|
</div>
|
|
|
|
<div class="preview-container">
|
|
<video id="local-preview" autoplay muted playsinline></video></video>
|
|
<div class="preview-placeholder" id="preview-placeholder">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="12" cy="7" r="4"/>
|
|
</svg>
|
|
<span>Camera is off</span>
|
|
</div>
|
|
<div class="preview-controls">
|
|
<button class="preview-btn" id="toggle-camera" title="Toggle camera">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M23 7l-7</span> 5 7 5V7z"/>
|
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
|
</svg>
|
|
</button>
|
|
<button class="preview-btn" id="toggle-mic" title="Toggle microphone">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="device-settings">
|
|
<div class="device-select">
|
|
<label>Camera</label>
|
|
<select id="camera-select">
|
|
<option value="">Select camera...</option>
|
|
</select>
|
|
</div>
|
|
<div class="device-select">
|
|
<label>Microphone</label>
|
|
<select id="mic-select">
|
|
<option value="">Select microphone...</option>
|
|
</select>
|
|
</div>
|
|
<div class="device-select">
|
|
<label>Speaker</label>
|
|
<select id="speaker-select">
|
|
<option value="">Select speaker...</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="prejoin-actions">
|
|
<input type="text"
|
|
id="display-name"
|
|
placeholder="Your name"
|
|
value="{{ user_name|default("") }}"
|
|
class="name-input">
|
|
<button class="join-btn" id="join-btn"
|
|
hx-post="/api/meet/join"
|
|
hx-vals='js:{room: document.getElementById("room-id").value, name: document.getElementById("display-name").value}'
|
|
hx-target="#meet-app"
|
|
hx-swap="innerHTML">
|
|
Join Meeting
|
|
</button>
|
|
</div>
|
|
|
|
<input type="hidden" id="room-id" value="{{ room_id|default("") }}">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Meeting Room (hidden initially) -->
|
|
<div class="meeting-room" id="meeting-room" style="display: none;">
|
|
<!-- Meeting Header -->
|
|
<header class="meet-header">
|
|
<div class="meet-info">
|
|
<h2 id="meeting-title">{{ room_name|default("Meeting Room") }}</h2>
|
|
<span class="meeting-id" id="meeting-id-display">{{ room_id|default("") }}</span>
|
|
<span class="meeting-timer" id="meeting-timer">00:00:00</span>
|
|
</div>
|
|
<div class="meet-header-actions">
|
|
<button class="header-action-btn" id="record-btn" title="Record meeting">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<circle cx="12" cy="12" r="4" fill="currentColor"/>
|
|
</svg>
|
|
<span>Record</span>
|
|
</button>
|
|
<button class="header-action-btn active" id="transcribe-btn" title="Toggle transcription">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
</svg>
|
|
<span>Transcribe</span>
|
|
</button>
|
|
<button class="header-action-btn" id="participants-btn" title="Show participants">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
<span class="badge" id="participant-count">1</span>
|
|
</button>
|
|
<button class="header-action-btn" id="chat-btn" title="Toggle chat">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
<span class="badge hidden" id="unread-count">0</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main Meeting Area -->
|
|
<main class="meet-main">
|
|
<!-- Video Grid -->
|
|
<div class="video-grid" id="video-grid">
|
|
<!-- Local Video -->
|
|
<div class="video-tile local" id="local-video-tile">
|
|
<video id="local-video" autoplay muted playsinline></video>
|
|
<div class="video-overlay">
|
|
<span class="participant-name">You</span>
|
|
<div class="video-indicators">
|
|
<span class="indicator mic" id="local-mic-indicator">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="speaking-indicator"></div>
|
|
</div>
|
|
|
|
<!-- Remote participants added dynamically -->
|
|
<div id="remote-videos"></div>
|
|
</div>
|
|
|
|
<!-- Sidebar Panels -->
|
|
<aside class="meet-sidebar" id="meet-sidebar" style="display: none;">
|
|
<!-- Participants Panel -->
|
|
<div class="sidebar-panel" id="participants-panel" style="display: none;">
|
|
<div class="panel-header">
|
|
<h3>Participants</h3>
|
|
<button class="close-btn" onclick="togglePanel('participants')">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="panel-content">
|
|
<div class="participants-list" id="participants-list">
|
|
<!-- Participants added dynamically -->
|
|
</div>
|
|
<div class="panel-actions">
|
|
<button class="action-btn" id="invite-btn"
|
|
hx-get="/api/meet/invite-modal"
|
|
hx-target="#modal-container">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="8.5" cy="7" r="4"/>
|
|
<line x1="20" y1="8" x2="20" y2="14"/>
|
|
<line x1="23" y1="11" x2="17" y2="11"/>
|
|
</svg>
|
|
Invite
|
|
</button>
|
|
<button class="action-btn" id="mute-all-btn"
|
|
hx-post="/api/meet/mute-all"
|
|
hx-swap="none">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
|
|
</svg>
|
|
Mute All
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chat Panel -->
|
|
<div class="sidebar-panel" id="chat-panel" style="display: none;">
|
|
<div class="panel-header">
|
|
<h3>Chat</h3>
|
|
<button class="close-btn" onclick="togglePanel('chat')">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="panel-content">
|
|
<div class="chat-messages" id="chat-messages"
|
|
hx-ext="ws"
|
|
ws-connect="/ws/meet/{{ room_id }}/chat">
|
|
<!-- Chat messages added dynamically -->
|
|
</div>
|
|
<form class="chat-input-form" ws-send>
|
|
<input type="text"
|
|
name="message"
|
|
placeholder="Type a message..."
|
|
autocomplete="off">
|
|
<button type="submit">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</main>
|
|
|
|
<!-- Meeting Controls -->
|
|
<footer class="meet-controls">
|
|
<div class="controls-left">
|
|
<span class="meeting-time" id="meeting-time-footer">00:00:00</span>
|
|
</div>
|
|
|
|
<div class="controls-center">
|
|
<button class="control-btn" id="mic-btn" title="Toggle microphone (M)">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
</svg>
|
|
<span class="control-label">Mic</span>
|
|
</button>
|
|
|
|
<button class="control-btn" id="camera-btn" title="Toggle camera (V)">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M23 7l-7 5 7 5V7z"/>
|
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
|
|
</svg>
|
|
<span class="control-label">Camera</span>
|
|
</button>
|
|
|
|
<button class="control-btn" id="share-btn" title="Share screen (S)">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
|
<line x1="12" y1="17" x2="12" y2="21"/>
|
|
</svg>
|
|
<span class="control-label">Share</span>
|
|
</button>
|
|
|
|
<button class="control-btn" id="reactions-btn" title="Reactions">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
|
|
<line x1="9" y1="9" x2="9.01" y2="9"/>
|
|
<line x1="15" y1="9" x2="15.01" y2="9"/>
|
|
</svg>
|
|
<span class="control-label">React</span>
|
|
</button>
|
|
|
|
<button class="control-btn danger" id="leave-btn" title="Leave meeting"
|
|
hx-post="/api/meet/leave"
|
|
hx-swap="none">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3"/>
|
|
<line x1="23" y1="1" x2="17" y2="7"/>
|
|
<line x1="17" y1="1" x2="23" y2="7"/>
|
|
</svg>
|
|
<span class="control-label">Leave</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="controls-right">
|
|
<button class="control-btn-sm" id="settings-btn" title="Settings">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="control-btn-sm" id="fullscreen-btn" title="Fullscreen (F)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<!-- Modal Container -->
|
|
<div id="modal-container"></div>
|
|
</div>
|
|
|
|
<style>
|
|
.meet-container {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg);
|
|
}
|
|
|
|
/* Pre-join Screen */
|
|
.prejoin-screen {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.prejoin-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 2rem;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
}
|
|
|
|
.prejoin-header {
|
|
text-align: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.prejoin-header h1 {
|
|
font-size: 1.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.prejoin-header p {
|
|
color: var(--text-secondary);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.preview-container {
|
|
position: relative;
|
|
aspect-ratio: 16/9;
|
|
background: #000;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.preview-container video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.preview-placeholder {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.preview-controls {
|
|
position: absolute;
|
|
bottom: 1rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.preview-btn {
|
|
padding: 0.75rem;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
border: none;
|
|
border-radius: 50%;
|
|
color: white;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.preview-btn:hover {
|
|
background: rgba(0, 0, 0, 0.8);
|
|
}
|
|
|
|
.preview-btn.off {
|
|
background: var(--error);
|
|
}
|
|
|
|
.device-settings {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.device-select label {
|
|
display: block;
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.device-select select {
|
|
width: 100%;
|
|
padding: 0.625rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.prejoin-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.name-input {
|
|
flex: 1;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.join-btn {
|
|
padding: 0.75rem 1.5rem;
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.join-btn:hover {
|
|
background: var(--primary-hover);
|
|
}
|
|
|
|
/* Meeting Room */
|
|
.meeting-room {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.meet-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.meet-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.meet-info h2 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.meeting-id {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
padding: 0.25rem 0.5rem;
|
|
background: var(--bg);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.meeting-timer {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.meet-header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.header-action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
font-size: 0.8125rem;
|
|
cursor: pointer;
|
|
transition: background 0.2s, color 0.2s;
|
|
}
|
|
|
|
.header-action-btn:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text);
|
|
}
|
|
|
|
.header-action-btn.active {
|
|
background: var(--primary-light);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.badge {
|
|
padding: 0.125rem 0.375rem;
|
|
background: var(--primary);
|
|
color: white;
|
|
border-radius: 10px;
|
|
font-size: 0.6875rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge.hidden {
|
|
display: none;
|
|
}
|
|
|
|
/* Video Grid */
|
|
.meet-main {
|
|
flex: 1;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.video-grid {
|
|
flex: 1;
|
|
display: grid;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem;
|
|
background: #000;
|
|
}
|
|
|
|
.video-grid:has(.video-tile:nth-child(1):last-child) {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.video-grid:has(.video-tile:nth-child(2)) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
|
|
.video-grid:has(.video-tile:nth-child(3)),
|
|
.video-grid:has(.video-tile:nth-child(4)) {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
grid-template-rows: repeat(2, 1fr);
|
|
}
|
|
|
|
.video-tile {
|
|
position: relative;
|
|
background: #1a1a1a;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.video-tile video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.video-tile.local video {
|
|
transform: scaleX(-1);
|
|
}
|
|
|
|
.video-overlay {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
padding: 0.75rem;
|
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.participant-name {
|
|
font-size: 0.875rem;
|
|
color: white;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.video-indicators {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.indicator {
|
|
padding: 0.375rem;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
border-radius: 4px;
|
|
color: white;
|
|
}
|
|
|
|
.indicator.muted {
|
|
color: var(--error);
|
|
}
|
|
|
|
.speaking-indicator {
|
|
position: absolute;
|
|
inset: 0;
|
|
border: 3px solid var(--success);
|
|
border-radius: 8px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.video-tile.speaking .speaking-indicator {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.meet-sidebar {
|
|
width: 320px;
|
|
background: var(--surface);
|
|
border-left: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.sidebar-panel {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.panel-header h3 {
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.close-btn {
|
|
padding: 0.375rem;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text);
|
|
}
|
|
|
|
.panel-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.participants-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.participant-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.participant-item:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.participant-avatar {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: var(--primary);
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.participant-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.participant-info .name {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.participant-info .role {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.panel-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.action-btn {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 0.625rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-size: 0.8125rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
/* Chat */
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.chat-message {
|
|
max-width: 85%;
|
|
}
|
|
|
|
.chat-message.own {
|
|
align-self: flex-end;
|
|
}
|
|
|
|
.chat-message .sender {
|
|
font-size: 0.75rem;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.chat-message .content {
|
|
padding: 0.625rem 0.875rem;
|
|
background: var(--bg);
|
|
border-radius: 12px;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.chat-message.own .content {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.chat-input-form {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.chat-input-form input {
|
|
flex: 1;
|
|
padding: 0.625rem 0.875rem;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
color: var(--text);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.chat-input-form button {
|
|
padding: 0.625rem;
|
|
background: var(--primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: white;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Controls */
|
|
.meet-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem;
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.controls-left,
|
|
.controls-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
min-width: 150px;
|
|
}
|
|
|
|
.controls-right {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.meeting-time {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.controls-center {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.control-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.75rem 1.25rem;
|
|
background: var(--bg);
|
|
border: none;
|
|
border-radius: 12px;
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.control-btn.off {
|
|
background: var(--error);
|
|
color: white;
|
|
}
|
|
|
|
.control-btn.danger {
|
|
background: var(--error);
|
|
color: white;
|
|
}
|
|
|
|
.control-btn.danger:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.control-label {
|
|
font-size: 0.6875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.control-btn-sm {
|
|
padding: 0.625rem;
|
|
background: var(--bg);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.control-btn-sm:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.meet-header-actions span:not(.badge) {
|
|
display: none;
|
|
}
|
|
|
|
.meet-sidebar {
|
|
position: fixed;
|
|
right: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
z-index: 100;
|
|
transform: translateX(100%);
|
|
transition: transform 0.3s;
|
|
}
|
|
|
|
.meet-sidebar.open {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
.control-label {
|
|
display: none;
|
|
}
|
|
|
|
.control-btn {
|
|
padding: 0.75rem;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// Meeting state
|
|
const meetState = {
|
|
isMuted: false,
|
|
isCameraOff: false,
|
|
isSharing: false,
|
|
isRecording: false,
|
|
startTime: null,
|
|
timerInterval: null
|
|
};
|
|
|
|
// Pre-join device setup
|
|
async function initDevices() {
|
|
try {
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
|
|
const cameraSelect = document.getElementById('camera-select');
|
|
const micSelect = document.getElementById('mic-select');
|
|
const speakerSelect = document.getElementById('speaker-select');
|
|
|
|
devices.forEach(device => {
|
|
const option = document.createElement('option');
|
|
option.value = device.deviceId;
|
|
option.text = device.label || `${device.kind} ${device.deviceId.slice(0, 8)}`;
|
|
|
|
if (device.kind === 'videoinput') {
|
|
cameraSelect.appendChild(option);
|
|
} else if (device.kind === 'audioinput') {
|
|
micSelect.appendChild(option);
|
|
} else if (device.kind === 'audiooutput') {
|
|
speakerSelect.appendChild(option);
|
|
}
|
|
});
|
|
|
|
// Start preview
|
|
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
document.getElementById('local-preview').srcObject = stream;
|
|
document.getElementById('preview-placeholder').style.display = 'none';
|
|
} catch (err) {
|
|
console.error('Error accessing devices:', err);
|
|
}
|
|
}
|
|
|
|
// Toggle panel
|
|
function togglePanel(panel) {
|
|
const sidebar = document.getElementById('meet-sidebar');
|
|
const participantsPanel = document.getElementById('participants-panel');
|
|
const chatPanel = document.getElementById('chat-panel');
|
|
|
|
if (panel === 'participants') {
|
|
if (participantsPanel.style.display === 'none') {
|
|
participantsPanel.style.display = 'flex';
|
|
chatPanel.style.display = 'none';
|
|
sidebar.style.display = 'flex';
|
|
} else {
|
|
participantsPanel.style.display = 'none';
|
|
sidebar.style.display = 'none';
|
|
}
|
|
} else if (panel === 'chat') {
|
|
if (chatPanel.style.display === 'none') {
|
|
chatPanel.style.display = 'flex';
|
|
participantsPanel.style.display = 'none';
|
|
sidebar.style.display = 'flex';
|
|
} else {
|
|
chatPanel.style.display = 'none';
|
|
sidebar.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Timer
|
|
function startTimer() {
|
|
meetState.startTime = Date.now();
|
|
meetState.timerInterval = setInterval(updateTimer, 1000);
|
|
}
|
|
|
|
function updateTimer() {
|
|
const elapsed = Date.now() - meetState.startTime;
|
|
const hours = Math.floor(elapsed / 3600000);
|
|
const minutes = Math.floor((elapsed % 3600000) / 60000);
|
|
const seconds = Math.floor((elapsed % 60000) / 1000);
|
|
|
|
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
document.getElementById('meeting-timer').textContent = timeStr;
|
|
document.getElementById('meeting-time-footer').textContent = timeStr;
|
|
}
|
|
|
|
// Control buttons
|
|
document.getElementById('mic-btn')?.addEventListener('click', function() {
|
|
meetState.isMuted = !meetState.isMuted;
|
|
this.classList.toggle('off', meetState.isMuted);
|
|
});
|
|
|
|
document.getElementById('camera-btn')?.addEventListener('click', function() {
|
|
meetState.isCameraOff = !meetState.isCameraOff;
|
|
this.classList.toggle('off', meetState.isCameraOff);
|
|
});
|
|
|
|
document.getElementById('share-btn')?.addEventListener('click', async function() {
|
|
if (!meetState.isSharing) {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
|
meetState.isSharing = true;
|
|
this.classList.add('active');
|
|
} catch (err) {
|
|
console.error('Screen share error:', err);
|
|
}
|
|
} else {
|
|
meetState.isSharing = false;
|
|
this.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
document.getElementById('participants-btn')?.addEventListener('click', () => togglePanel('participants'));
|
|
document.getElementById('chat-btn')?.addEventListener('click', () => togglePanel('chat'));
|
|
|
|
document.getElementById('fullscreen-btn')?.addEventListener('click', () => {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case 'm':
|
|
document.getElementById('mic-btn')?.click();
|
|
break;
|
|
case 'v':
|
|
document.getElementById('camera-btn')?.click();
|
|
break;
|
|
case 's':
|
|
document.getElementById('share-btn')?.click();
|
|
break;
|
|
case 'f':
|
|
document.getElementById('fullscreen-btn')?.click();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Initialize on load
|
|
document.addEventListener('DOMContentLoaded', initDevices);
|
|
</script>
|
|
{% endblock %}
|