feat: initial daily-timer implementation

This commit is contained in:
Mikhail Kiselev
2026-02-08 05:17:37 +03:00
parent 537f72eb51
commit ef23291bdd
37 changed files with 7779 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
<script>
import { createEventDispatcher } from 'svelte'
import { NextSpeaker, SkipSpeaker, PauseMeeting, ResumeMeeting, StopMeeting } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
$: isPaused = timerState?.paused || false
$: hasQueue = (timerState?.remainingQueue?.length || 0) > 0
async function handleNext() {
await NextSpeaker()
}
async function handleSkip() {
await SkipSpeaker()
}
async function handlePauseResume() {
if (isPaused) {
await ResumeMeeting()
} else {
await PauseMeeting()
}
}
async function handleStop() {
await StopMeeting()
dispatch('stop')
}
</script>
<div class="controls">
<button class="btn primary" on:click={handleNext}>
{hasQueue ? $t('controls.next') : $t('controls.stop')}
</button>
{#if hasQueue}
<button class="btn secondary" on:click={handleSkip}>
{$t('controls.skip')}
</button>
{/if}
<button class="btn secondary pause-btn" on:click={handlePauseResume}>
{isPaused ? '▶' : '⏸'}
</button>
<button class="btn danger" on:click={handleStop}>
</button>
</div>
<style>
.controls {
display: flex;
gap: 8px;
justify-content: center;
padding: 12px;
background: #232f3e;
border-radius: 12px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
height: 44px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn.primary {
flex: 2;
background: #4a90d9;
color: white;
}
.btn.primary:hover {
background: #3a7bc8;
}
.btn.secondary {
flex: 1;
background: #3d4f61;
color: #e0e0e0;
}
.btn.secondary:hover {
background: #4d5f71;
}
.btn.pause-btn {
flex: 0;
min-width: 44px;
font-size: 18px;
}
.btn.danger {
flex: 0;
min-width: 44px;
font-size: 18px;
background: #7f1d1d;
color: #fca5a5;
}
.btn.danger:hover {
background: #991b1b;
}
</style>

View File

@@ -0,0 +1,572 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { GetSessions, GetStatistics, ExportData, ExportCSV, DeleteSession, DeleteAllSessions } from '../../wailsjs/go/app/App'
import { t, locale } from '../lib/i18n'
let sessions = []
let stats = null
let loading = true
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
let dateTo = new Date().toISOString().split('T')[0]
let exporting = false
let showDeleteAllConfirm = false
let deletingSessionId = null
function handleKeydown(e) {
if (e.key === 'Escape') {
if (deletingSessionId !== null) deletingSessionId = null
if (showDeleteAllConfirm) showDeleteAllConfirm = false
}
}
onMount(async () => {
window.addEventListener('keydown', handleKeydown)
await loadData()
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
async function loadData() {
loading = true
try {
sessions = await GetSessions(50, 0)
stats = await GetStatistics(dateFrom, dateTo)
} catch (e) {
console.error('Failed to load history:', e)
}
loading = false
}
async function handleDeleteSession(id) {
deletingSessionId = id
}
async function confirmDeleteSession() {
if (!deletingSessionId) return
try {
await DeleteSession(deletingSessionId)
await loadData()
} catch (e) {
console.error('Failed to delete session:', e)
}
deletingSessionId = null
}
async function handleDeleteAll() {
showDeleteAllConfirm = true
}
async function confirmDeleteAll() {
try {
await DeleteAllSessions()
await loadData()
} catch (e) {
console.error('Failed to delete all sessions:', e)
}
showDeleteAllConfirm = false
}
async function handleExportJSON() {
exporting = true
try {
const path = await ExportData(dateFrom, dateTo)
if (path) {
alert('Exported to: ' + path)
}
} catch (e) {
alert('Export failed: ' + e)
}
exporting = false
}
async function handleExportCSV() {
exporting = true
try {
const path = await ExportCSV(dateFrom, dateTo)
if (path) {
alert('Exported to: ' + path)
}
} catch (e) {
alert('Export failed: ' + e)
}
exporting = false
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function formatDate(dateStr) {
const loc = $locale === 'ru' ? 'ru-RU' : 'en-US'
return new Date(dateStr).toLocaleDateString(loc, {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<div class="history">
<div class="filters">
<div class="date-range">
<input type="date" bind:value={dateFrom} on:change={loadData} />
<span></span>
<input type="date" bind:value={dateTo} on:change={loadData} />
</div>
<div class="export-buttons">
<button on:click={handleExportJSON} disabled={exporting}>{$t('history.exportJSON')}</button>
<button on:click={handleExportCSV} disabled={exporting}>{$t('history.exportCSV')}</button>
{#if sessions.length > 0}
<button class="delete-all-btn" on:click={handleDeleteAll}>{$t('history.deleteAll')}</button>
{/if}
</div>
</div>
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else}
{#if stats}
<section class="stats-overview">
<h2>{$t('participants.stats')}</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.totalSessions}</div>
<div class="stat-label">{$t('participants.totalMeetings')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{formatTime(Math.round(stats.averageMeetingTime))}</div>
<div class="stat-label">{$t('history.avgTime')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.overtimePercentage.toFixed(0)}%</div>
<div class="stat-label">{$t('history.overtimeRate')}</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.averageAttendance.toFixed(1)}</div>
<div class="stat-label">{$t('history.avgAttendance')}</div>
</div>
</div>
{#if stats.participantBreakdown?.length > 0}
<h3>{$t('history.participantBreakdown')}</h3>
<div class="breakdown-table">
<div class="breakdown-header">
<span>{$t('history.name')}</span>
<span>{$t('history.sessions')}</span>
<span>{$t('history.avgTime')}</span>
<span>{$t('history.overtime')}</span>
<span>{$t('history.attendance')}</span>
</div>
{#each stats.participantBreakdown as p}
<div class="breakdown-row">
<span>{p.name}</span>
<span>{p.sessionsAttended}</span>
<span>{formatTime(Math.round(p.averageSpeakingTime))}</span>
<span class:overtime={p.overtimeCount > 0}>{p.overtimeCount}</span>
<span>{p.attendanceRate.toFixed(0)}%</span>
</div>
{/each}
</div>
{/if}
</section>
{/if}
<section class="sessions-list">
<h2>{$t('history.recentSessions')}</h2>
{#if sessions.length === 0}
<p class="empty">{$t('history.noSessions')}</p>
{:else}
{#each sessions as session}
<div class="session-card" class:overtime={session.totalDuration > 900}>
<div class="session-header">
<span class="session-date">{formatDate(session.startedAt)}</span>
<span class="session-duration">{formatTime(session.totalDuration)}</span>
{#if session.totalDuration > 900}
<span class="overtime-badge">OVERTIME</span>
{/if}
<button class="delete-session-btn" on:click={() => handleDeleteSession(session.id)} title={$t('history.deleteSession')}>🗑️</button>
</div>
{#if session.participantLogs?.length > 0}
<div class="session-participants">
{#each session.participantLogs as log}
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
<span class="log-order">#{log.order}</span>
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
<span class="log-duration">{formatTime(log.duration)}</span>
{#if log.overtime}
<span class="overtime-icon">⚠️</span>
{/if}
{#if log.skipped}
<span class="skipped-icon">⏭️</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</section>
{/if}
</div>
<!-- Delete Session Confirmation Modal -->
{#if deletingSessionId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={() => deletingSessionId = null}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal" on:click|stopPropagation>
<h3>{$t('history.confirmDeleteTitle')}</h3>
<p>{$t('history.confirmDeleteSession')}</p>
<div class="modal-buttons">
<button class="cancel-btn" on:click={() => deletingSessionId = null}>{$t('common.cancel')}</button>
<button class="confirm-btn" on:click={confirmDeleteSession}>{$t('common.delete')}</button>
</div>
</div>
</div>
{/if}
<!-- Delete All Confirmation Modal -->
{#if showDeleteAllConfirm}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal-overlay" on:click={() => showDeleteAllConfirm = false}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal" on:click|stopPropagation>
<h3>{$t('history.confirmDeleteAllTitle')}</h3>
<p>{$t('history.confirmDeleteAll')}</p>
<div class="modal-buttons">
<button class="cancel-btn" on:click={() => showDeleteAllConfirm = false}>{$t('common.cancel')}</button>
<button class="confirm-btn danger" on:click={confirmDeleteAll}>{$t('history.deleteAll')}</button>
</div>
</div>
</div>
{/if}
<style>
.history {
max-width: 800px;
margin: 0 auto;
}
.filters {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.date-range input {
padding: 6px 8px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 13px;
max-width: 130px;
}
.date-range span {
color: #6b7280;
}
.export-buttons {
display: flex;
gap: 8px;
}
.export-buttons button {
padding: 8px 16px;
background: #3d4f61;
color: #e0e0e0;
border: none;
border-radius: 8px;
cursor: pointer;
}
.export-buttons button:hover {
background: #4d5f71;
}
.export-buttons button:disabled {
opacity: 0.5;
}
section {
background: #232f3e;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
h2 {
margin: 0 0 16px 0;
color: #e0e0e0;
}
h3 {
margin: 24px 0 12px 0;
color: #9ca3af;
font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
background: #1b2636;
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 22px;
font-weight: 700;
color: #4a90d9;
margin-bottom: 2px;
}
.stat-label {
font-size: 10px;
color: #6b7280;
text-transform: uppercase;
}
.breakdown-table {
background: #1b2636;
border-radius: 8px;
overflow: hidden;
}
.breakdown-header,
.breakdown-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
padding: 8px 10px;
font-size: 12px;
}
.breakdown-header {
background: #3d4f61;
font-size: 12px;
text-transform: uppercase;
color: #9ca3af;
}
.breakdown-row {
border-bottom: 1px solid #3d4f61;
}
.breakdown-row:last-child {
border-bottom: none;
}
.breakdown-row .overtime {
color: #ef4444;
}
.session-card {
background: #1b2636;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.session-card.overtime {
border-left: 4px solid #ef4444;
}
.session-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.session-date {
flex: 1;
color: #e0e0e0;
}
.session-duration {
font-family: 'SF Mono', 'Menlo', monospace;
color: #9ca3af;
}
.overtime-badge {
background: #ef4444;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.session-participants {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.participant-log {
display: flex;
align-items: center;
gap: 8px;
background: #232f3e;
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
}
.participant-log.log-overtime {
border: 1px solid #ef4444;
}
.participant-log.skipped {
opacity: 0.6;
}
.log-order {
color: #6b7280;
font-size: 12px;
}
.log-name {
color: #e0e0e0;
}
.log-duration {
color: #9ca3af;
font-family: 'SF Mono', 'Menlo', monospace;
}
.loading, .empty {
text-align: center;
padding: 48px;
color: #6b7280;
}
.delete-all-btn {
background: #dc2626 !important;
border-color: #991b1b !important;
}
.delete-all-btn:hover {
background: #b91c1c !important;
}
.delete-session-btn {
margin-left: auto;
background: transparent;
border: none;
cursor: pointer;
font-size: 16px;
opacity: 0.5;
transition: opacity 0.2s;
padding: 4px 8px;
}
.delete-session-btn:hover {
opacity: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
}
.modal h3 {
margin: 0 0 12px 0;
color: #e0e0e0;
font-size: 18px;
}
.modal p {
margin: 0 0 20px 0;
color: #9ca3af;
font-size: 14px;
}
.modal-buttons {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-buttons button {
padding: 8px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.cancel-btn {
background: #374151;
border: 1px solid #4b5563;
color: #e0e0e0;
}
.cancel-btn:hover {
background: #4b5563;
}
.confirm-btn {
background: #dc2626;
border: 1px solid #991b1b;
color: white;
}
.confirm-btn:hover {
background: #b91c1c;
}
.confirm-btn.danger {
background: #991b1b;
}
.confirm-btn.danger:hover {
background: #7f1d1d;
}
</style>

View File

@@ -0,0 +1,166 @@
<script>
import { createEventDispatcher } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
const dispatch = createEventDispatcher()
$: allSpeakers = timerState?.allSpeakers || []
$: currentSpeakerId = timerState?.currentSpeakerId || 0
function handleSkip(speakerId) {
dispatch('skip', { speakerId })
}
</script>
<div class="participant-list">
<h3>{$t('timer.participants')}</h3>
{#if allSpeakers.length > 0}
<ul>
{#each allSpeakers as speaker}
<li class="speaker-item {speaker.status}">
<span class="order">{speaker.order}</span>
<span class="name">{speaker.name}</span>
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
</button>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="empty"></p>
{/if}
</div>
<style>
.participant-list {
background: #232f3e;
border-radius: 12px;
padding: 12px;
flex: 1;
overflow: auto;
}
h3 {
margin: 0 0 8px 0;
color: #9ca3af;
font-size: 12px;
text-transform: uppercase;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.speaker-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: #1b2636;
border-radius: 8px;
margin-bottom: 6px;
transition: background-color 0.3s;
}
.speaker-item:last-child {
margin-bottom: 0;
}
.speaker-item.speaking {
background: #166534;
}
.speaker-item.done {
background: #1e3a5f;
}
.speaker-item.pending {
background: #1b2636;
}
.speaker-item.skipped {
background: repeating-linear-gradient(
45deg,
#3d2f1f,
#3d2f1f 5px,
#2d2318 5px,
#2d2318 10px
);
}
.order {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: #3d4f61;
border-radius: 50%;
font-size: 12px;
color: #9ca3af;
margin-right: 10px;
flex-shrink: 0;
}
.speaker-item.speaking .order {
background: #22c55e;
color: white;
}
.speaker-item.done .order {
background: #3b82f6;
color: white;
}
.name {
flex: 1;
font-size: 14px;
color: #e0e0e0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.skip-btn {
margin-left: 8px;
padding: 4px 8px;
background: #6b7280;
border: none;
border-radius: 6px;
color: white;
font-size: 14px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s, transform 0.1s;
}
.skip-btn:hover {
opacity: 1;
background: #9ca3af;
}
.skip-btn:active {
transform: scale(0.95);
}
.empty {
color: #6b7280;
text-align: center;
padding: 24px;
}
</style>

View File

@@ -0,0 +1,372 @@
<script>
import { onMount, createEventDispatcher } from 'svelte'
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App'
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime'
import { t, locale, setLocale } from '../lib/i18n'
const dispatch = createEventDispatcher()
let settings = null
let meeting = null
let loading = true
let saving = false
let meetingLimitMin = 15
let defaultTimeMin = 2
let windowWidth = 800
let windowFullHeight = true
let audioContext = null
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
return audioContext
}
function playBeep(frequency, duration, type = 'sine') {
try {
const ctx = getAudioContext()
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.frequency.value = frequency
oscillator.type = type
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + duration)
} catch (e) {
console.error('Failed to play sound:', e)
alert('Sound error: ' + e.message)
}
}
function testSound(name) {
switch (name) {
case 'warning':
playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200)
break
case 'timeup':
playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500)
break
case 'meeting_end':
playBeep(523, 0.2)
setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(784, 0.4), 400)
break
}
}
onMount(async () => {
await loadData()
})
async function loadData() {
loading = true
try {
console.log('Loading settings...')
settings = await GetSettings()
console.log('Settings loaded:', settings)
meeting = await GetMeeting()
console.log('Meeting loaded:', meeting)
if (meeting) {
meetingLimitMin = Math.floor(meeting.timeLimit / 60)
}
if (settings) {
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
windowWidth = settings.windowWidth || 800
windowFullHeight = settings.windowFullHeight !== false
}
dispatch('loaded', settings)
} catch (e) {
console.error('Failed to load settings:', e)
}
loading = false
}
async function saveSettings() {
saving = true
try {
settings.defaultParticipantTime = defaultTimeMin * 60
meeting.timeLimit = meetingLimitMin * 60
settings.windowWidth = Math.max(480, windowWidth)
settings.windowFullHeight = windowFullHeight
await UpdateSettings(settings)
await UpdateMeeting(meeting.name, meeting.timeLimit)
// Apply window size immediately
if (windowFullHeight) {
try {
const screens = await ScreenGetAll()
if (screens && screens.length > 0) {
const primaryScreen = screens[0]
const height = primaryScreen.size?.height || 800
WindowSetSize(settings.windowWidth, height - 80)
}
} catch (e) {
console.error('Failed to get screen size:', e)
}
} else {
WindowSetSize(settings.windowWidth, 600)
}
dispatch('loaded', settings)
} catch (e) {
console.error('Failed to save settings:', e)
alert('Failed to save settings: ' + e)
}
saving = false
}
</script>
<div class="settings">
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else if !meeting || !settings}
<div class="error">Failed to load settings. Please restart the app.</div>
{:else}
<section>
<h2>{$t('settings.language')}</h2>
<div class="field">
<div class="language-switcher">
<button class:active={$locale === 'ru'} on:click={() => setLocale('ru')}>🇷🇺 Русский</button>
<button class:active={$locale === 'en'} on:click={() => setLocale('en')}>🇺🇸 English</button>
</div>
</div>
</section>
<section>
<h2>{$t('settings.title')}</h2>
<div class="field">
<label for="meetingName">{$t('setup.title')}</label>
<input type="text" id="meetingName" bind:value={meeting.name} />
</div>
<div class="field">
<label for="meetingLimit">{$t('setup.totalTime')} ({$t('setup.minutes')})</label>
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
</div>
</section>
<section>
<h2>{$t('setup.speakerTime')}</h2>
<div class="field">
<label for="defaultTime">{$t('settings.defaultSpeakerTime')} ({$t('setup.minutes')})</label>
<input type="number" id="defaultTime" bind:value={defaultTimeMin} min="1" max="10" />
</div>
<div class="field">
<label for="warningThreshold">{$t('settings.warningTime')} ({$t('settings.seconds')})</label>
<input type="number" id="warningThreshold" bind:value={settings.warningThreshold} min="5" max="120" />
</div>
</section>
<section>
<h2>{$t('settings.sound')}</h2>
<div class="field checkbox">
<input type="checkbox" id="soundEnabled" bind:checked={settings.soundEnabled} />
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
</div>
<div class="sound-test-buttons">
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
<button type="button" class="test-btn" on:click={() => testSound('timeup')}> Test Time Up</button>
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
</div>
</section>
<section>
<h2>{$t('settings.windowWidth')}</h2>
<div class="field checkbox">
<input type="checkbox" id="windowFullHeight" bind:checked={windowFullHeight} />
<label for="windowFullHeight">{$t('settings.windowFullHeight')}</label>
</div>
<div class="field">
<label for="windowWidth">{$t('settings.windowWidthHint')}</label>
<input type="number" id="windowWidth" bind:value={windowWidth} min="480" max="1920" step="10" />
</div>
</section>
<button class="save-btn" on:click={saveSettings} disabled={saving}>
{saving ? $t('common.loading') : $t('settings.save')}
</button>
{/if}
</div>
<style>
.settings {
max-width: 100%;
margin: 0 auto;
}
.error {
background: #7f1d1d;
color: #fca5a5;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.loading {
text-align: center;
color: #9ca3af;
padding: 40px;
}
section {
background: #232f3e;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
h2 {
margin: 0 0 12px 0;
color: #e0e0e0;
font-size: 16px;
}
.field {
margin-bottom: 16px;
}
.field:last-child {
margin-bottom: 0;
}
label {
display: block;
color: #9ca3af;
font-size: 14px;
margin-bottom: 6px;
}
input[type="text"],
input[type="number"] {
width: 100%;
padding: 12px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 16px;
box-sizing: border-box;
color-scheme: dark;
}
.field.checkbox {
display: flex;
align-items: center;
gap: 12px;
}
.field.checkbox input {
width: 20px;
height: 20px;
}
.field.checkbox label {
margin: 0;
color: #e0e0e0;
}
.save-btn {
width: 100%;
padding: 16px;
background: #4a90d9;
color: white;
border: none;
border-radius: 12px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
}
.save-btn:hover {
background: #3a7bc8;
}
.language-switcher {
display: flex;
gap: 8px;
}
.language-switcher button {
flex: 1;
padding: 12px 16px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.language-switcher button:hover {
border-color: #4a90d9;
color: #e0e0e0;
}
.language-switcher button.active {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
text-align: center;
padding: 48px;
color: #6b7280;
}
.sound-test-buttons {
display: flex;
gap: 8px;
margin-top: 12px;
}
.test-btn {
flex: 1;
padding: 10px 12px;
border: 2px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #9ca3af;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.test-btn:hover {
border-color: #4a90d9;
background: #2a3a4e;
color: #e0e0e0;
}
.test-btn:active {
transform: scale(0.97);
}
</style>

View File

@@ -0,0 +1,826 @@
<script>
import { onMount, createEventDispatcher } from 'svelte'
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
import { t } from '../lib/i18n'
const dispatch = createEventDispatcher()
let participants = []
let meeting = null
let selectedOrder = []
let attendance = {}
let loading = true
let newName = ''
let newTimeLimitMin = 2
// Edit mode
let editingId = null
let editName = ''
let editTimeLimitMin = 2
// Meeting name editing
let editingMeetingName = false
let meetingNameInput = ''
// Meeting time editing
let editingMeetingTime = false
let meetingTimeInput = 60
onMount(async () => {
await loadData()
})
async function loadData() {
loading = true
try {
participants = await GetParticipants()
meeting = await GetMeeting()
selectedOrder = participants.map(p => p.id)
attendance = {}
participants.forEach(p => {
attendance[p.id] = true
})
} catch (e) {
console.error('Failed to load data:', e)
}
loading = false
}
async function handleAddParticipant() {
if (!newName.trim()) return
try {
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60)
newName = ''
await loadData()
} catch (e) {
console.error('Failed to add participant:', e)
}
}
async function handleRemove(id) {
if (!confirm('Remove participant?')) return
try {
await DeleteParticipant(id)
await loadData()
} catch (e) {
console.error('Failed to remove participant:', e)
}
}
function startEdit(p) {
editingId = p.id
editName = p.name
editTimeLimitMin = Math.floor(p.timeLimit / 60)
}
function cancelEdit() {
editingId = null
editName = ''
editTimeLimitMin = 2
}
async function saveEdit() {
if (!editName.trim() || editingId === null) return
try {
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60)
editingId = null
await loadData()
} catch (e) {
console.error('Failed to update participant:', e)
}
}
function toggleAttendance(id) {
attendance[id] = !attendance[id]
attendance = attendance
}
// Drag and drop state
let draggedId = null
let dragOverId = null
function handleDragStart(e, id) {
draggedId = id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id.toString())
e.target.classList.add('dragging')
}
function handleDragEnd(e) {
e.target.classList.remove('dragging')
draggedId = null
dragOverId = null
}
function handleDragOver(e, id) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
dragOverId = id
}
function handleDragLeave(e) {
dragOverId = null
}
async function handleDrop(e, targetId) {
e.preventDefault()
if (draggedId === null || draggedId === targetId) {
dragOverId = null
return
}
const fromIndex = selectedOrder.indexOf(draggedId)
const toIndex = selectedOrder.indexOf(targetId)
if (fromIndex !== -1 && toIndex !== -1) {
selectedOrder.splice(fromIndex, 1)
selectedOrder.splice(toIndex, 0, draggedId)
selectedOrder = selectedOrder
try {
await ReorderParticipants(selectedOrder)
} catch (err) {
console.error('Failed to save order:', err)
}
}
dragOverId = null
}
async function handleStart() {
const presentIds = selectedOrder.filter(id => attendance[id])
if (presentIds.length === 0) {
alert($t('setup.noParticipants'))
return
}
try {
await StartMeeting(presentIds, attendance)
dispatch('started')
} catch (e) {
console.error('Failed to start meeting:', e)
alert('Failed to start meeting: ' + e)
}
}
function getParticipant(id) {
return participants.find(p => p.id === id)
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
function startEditMeetingName() {
meetingNameInput = meeting?.name || ''
editingMeetingName = true
}
function cancelEditMeetingName() {
editingMeetingName = false
meetingNameInput = ''
}
async function saveMeetingName() {
if (!meetingNameInput.trim()) return
try {
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600)
meeting = await GetMeeting()
editingMeetingName = false
} catch (e) {
console.error('Failed to update meeting name:', e)
}
}
function startEditMeetingTime() {
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60)
editingMeetingTime = true
}
function cancelEditMeetingTime() {
editingMeetingTime = false
}
async function saveMeetingTime() {
if (meetingTimeInput < 1) return
try {
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60)
meeting = await GetMeeting()
editingMeetingTime = false
} catch (e) {
console.error('Failed to update meeting time:', e)
}
}
function handleGlobalKeydown(e) {
if (e.key === 'Escape') {
if (editingId !== null) cancelEdit()
if (editingMeetingName) cancelEditMeetingName()
if (editingMeetingTime) cancelEditMeetingTime()
}
}
</script>
<svelte:window on:keydown={handleGlobalKeydown} />
<div class="setup">
<div class="header">
{#if editingMeetingName}
<div class="meeting-name-edit">
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
bind:value={meetingNameInput}
on:keydown={(e) => {
if (e.key === 'Enter') saveMeetingName()
if (e.key === 'Escape') cancelEditMeetingName()
}}
autofocus
/>
<button class="save-btn" on:click={saveMeetingName}>✓</button>
<button class="cancel-btn" on:click={cancelEditMeetingName}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<h1 on:click={startEditMeetingName} class="editable-title">
{meeting?.name || 'Daily Standup'}
<span class="edit-icon"></span>
</h1>
{/if}
{#if editingMeetingTime}
<div class="meeting-time-edit">
<!-- svelte-ignore a11y-autofocus -->
<input
type="number"
bind:value={meetingTimeInput}
min="1"
max="480"
on:keydown={(e) => {
if (e.key === 'Enter') saveMeetingTime()
if (e.key === 'Escape') cancelEditMeetingTime()
}}
autofocus
/>
<span class="time-suffix">{$t('setup.minutes')}</span>
<button class="save-btn" on:click={saveMeetingTime}>✓</button>
<button class="cancel-btn" on:click={cancelEditMeetingTime}>✗</button>
</div>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<p on:click={startEditMeetingTime} class="editable-time">
{$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)}
<span class="edit-icon"></span>
</p>
{/if}
</div>
<div class="add-participant">
<input
type="text"
bind:value={newName}
placeholder={$t('setup.namePlaceholder')}
on:keydown={(e) => e.key === 'Enter' && handleAddParticipant()}
/>
<input
type="number"
bind:value={newTimeLimitMin}
min="1"
max="10"
title="{$t('setup.speakerTime')} ({$t('setup.minutes')})"
/>
<span class="time-suffix">{$t('setup.minutes')}</span>
<button on:click={handleAddParticipant}>{$t('participants.add')}</button>
</div>
{#if loading}
<div class="loading">{$t('common.loading')}</div>
{:else if participants.length === 0}
<div class="empty">
<p>{$t('setup.noParticipants')}</p>
</div>
{:else}
<div class="participant-order">
<h3>{$t('timer.queue')}</h3>
<p class="hint">{$t('setup.dragHint')}</p>
<ul>
{#each selectedOrder as id, i}
{@const p = getParticipant(id)}
{#if p}
<li
class:absent={!attendance[id]}
class:drag-over={dragOverId === id}
draggable="true"
on:dragstart={(e) => handleDragStart(e, id)}
on:dragend={handleDragEnd}
on:dragover={(e) => handleDragOver(e, id)}
on:dragleave={handleDragLeave}
on:drop={(e) => handleDrop(e, id)}
>
<span class="drag-handle"></span>
<span class="order-num">{i + 1}</span>
<button
class="attendance-toggle"
class:present={attendance[id]}
on:click={() => toggleAttendance(id)}
>
{attendance[id] ? '✓' : '✗'}
</button>
<span class="name">{p.name}</span>
<span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span>
<button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}"></button>
<button class="remove" on:click={() => handleRemove(id)}>×</button>
</li>
{/if}
{/each}
</ul>
</div>
{#if editingId !== null}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal-overlay" on:click={cancelEdit}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="edit-modal" on:click|stopPropagation>
<h3>{$t('participants.edit')}</h3>
<div class="edit-field">
<label for="editName">{$t('participants.name')}</label>
<input id="editName" type="text" bind:value={editName} on:keydown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}} />
</div>
<div class="edit-field">
<label for="editTime">{$t('setup.speakerTime')} ({$t('setup.minutes')})</label>
<input id="editTime" type="number" bind:value={editTimeLimitMin} min="1" max="10" on:keydown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}} />
</div>
<div class="edit-actions">
<button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button>
<button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button>
</div>
</div>
</div>
{/if}
<div class="summary">
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span>
<span>{formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
</div>
<button class="start-btn" on:click={handleStart}>
{$t('setup.startMeeting')}
</button>
{/if}
</div>
<style>
.setup {
max-width: 600px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.header h1 {
margin: 0;
color: #e0e0e0;
display: block;
}
.header h1.editable-title {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.header h1.editable-title:hover {
color: #4a90d9;
}
.header h1 .edit-icon {
font-size: 16px;
opacity: 0.5;
}
.header h1.editable-title:hover .edit-icon {
opacity: 1;
}
.meeting-name-edit {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
}
.meeting-name-edit input {
padding: 8px 12px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 18px;
font-weight: 600;
text-align: center;
min-width: 200px;
}
.meeting-name-edit .save-btn {
padding: 8px 12px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.meeting-name-edit .cancel-btn {
padding: 8px 12px;
background: #991b1b;
color: #fca5a5;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.header p {
color: #9ca3af;
margin: 8px 0 0 0;
}
.header p.editable-time {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.header p.editable-time:hover {
color: #4a90d9;
}
.header p .edit-icon {
font-size: 12px;
opacity: 0.5;
}
.header p.editable-time:hover .edit-icon {
opacity: 1;
}
.meeting-time-edit {
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
margin-top: 8px;
}
.meeting-time-edit input {
padding: 6px 8px;
border: 1px solid #4a90d9;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
text-align: center;
width: 60px;
color-scheme: dark;
}
.meeting-time-edit .time-suffix {
color: #9ca3af;
font-size: 14px;
}
.meeting-time-edit .save-btn {
padding: 6px 10px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.meeting-time-edit .cancel-btn {
padding: 6px 10px;
background: #991b1b;
color: #fca5a5;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.add-participant {
display: flex;
gap: 6px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.add-participant input[type="text"] {
flex: 1;
min-width: 120px;
padding: 10px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
}
.add-participant input[type="number"] {
width: 50px;
padding: 10px 6px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
text-align: center;
}
.time-suffix {
color: #9ca3af;
font-size: 12px;
display: flex;
align-items: center;
}
.add-participant button {
padding: 10px 16px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.add-participant button:hover {
background: #3a7bc8;
}
.participant-order h3 {
margin: 0 0 4px 0;
color: #e0e0e0;
}
.hint {
color: #6b7280;
font-size: 14px;
margin: 0 0 12px 0;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: #232f3e;
border-radius: 8px;
margin-bottom: 6px;
transition: opacity 0.2s;
flex-wrap: wrap;
}
li.absent {
opacity: 0.5;
}
li.drag-over {
border: 2px dashed #4a90d9;
background: #2a3a4e;
}
li:global(.dragging) {
opacity: 0.5;
background: #1b2636;
}
.drag-handle {
cursor: grab;
color: #6b7280;
font-size: 16px;
padding: 4px;
user-select: none;
}
.drag-handle:active {
cursor: grabbing;
}
.order-num {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: #4a90d9;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
color: white;
}
.attendance-toggle {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
background: #7f1d1d;
color: #fca5a5;
}
.attendance-toggle.present {
background: #166534;
color: #4ade80;
}
.name {
flex: 1;
color: #e0e0e0;
font-size: 14px;
min-width: 80px;
word-break: break-word;
}
.time-limit {
font-family: 'SF Mono', 'Menlo', monospace;
color: #6b7280;
font-size: 12px;
}
.edit {
padding: 4px 8px;
background: transparent;
border: none;
color: #6b7280;
font-size: 14px;
cursor: pointer;
}
.edit:hover {
color: #4a90d9;
}
.remove {
padding: 4px 8px;
background: transparent;
border: none;
color: #6b7280;
font-size: 20px;
cursor: pointer;
}
.remove:hover {
color: #ef4444;
}
/* Edit Modal */
.edit-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.edit-modal {
background: #232f3e;
border-radius: 12px;
padding: 20px;
width: 90%;
max-width: 320px;
}
.edit-modal h3 {
margin: 0 0 16px 0;
color: #e0e0e0;
font-size: 18px;
}
.edit-field {
margin-bottom: 12px;
}
.edit-field label {
display: block;
color: #9ca3af;
font-size: 12px;
margin-bottom: 4px;
}
.edit-field input {
width: 100%;
padding: 10px;
border: 1px solid #3d4f61;
border-radius: 8px;
background: #1b2636;
color: #e0e0e0;
font-size: 14px;
box-sizing: border-box;
color-scheme: dark;
}
.edit-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.edit-actions .cancel-btn {
flex: 1;
padding: 10px;
background: #3d4f61;
color: #e0e0e0;
border: none;
border-radius: 8px;
cursor: pointer;
}
.edit-actions .cancel-btn:hover {
background: #4d5f71;
}
.edit-actions .save-btn {
flex: 1;
padding: 10px;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.edit-actions .save-btn:hover {
background: #3a7bc8;
}
.summary {
display: flex;
justify-content: space-between;
padding: 16px;
background: #232f3e;
border-radius: 8px;
margin-bottom: 16px;
color: #9ca3af;
}
.start-btn {
width: 100%;
padding: 20px;
background: #166534;
color: #4ade80;
border: none;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.start-btn:hover {
background: #15803d;
}
.loading, .empty {
text-align: center;
padding: 48px;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,464 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { t } from '../lib/i18n'
export let timerState
let currentTime = ''
let clockInterval
function updateClock() {
const now = new Date()
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
onMount(() => {
updateClock()
clockInterval = setInterval(updateClock, 1000)
})
onDestroy(() => {
if (clockInterval) clearInterval(clockInterval)
})
$: speakerTime = formatTime(timerState?.speakerElapsed || 0)
$: speakerLimit = formatTime(timerState?.speakerLimit || 0)
$: meetingTime = formatTime(timerState?.meetingElapsed || 0)
$: meetingLimit = formatTime(timerState?.meetingLimit || 0)
$: speakerProgress = timerState?.speakerLimit > 0
? Math.min((timerState.speakerElapsed / timerState.speakerLimit) * 100, 100)
: 0
$: warningZoneStart = timerState?.speakerLimit > 0 && timerState?.warningSeconds > 0
? Math.max(0, 100 - (timerState.warningSeconds / timerState.speakerLimit) * 100)
: 100
$: meetingProgress = timerState?.meetingLimit > 0
? Math.min((timerState.meetingElapsed / timerState.meetingLimit) * 100, 100)
: 0
// Yellow zone: time allocated for all speakers (as % of meeting limit)
$: speakersZoneEnd = timerState?.meetingLimit > 0 && timerState?.totalSpeakersTime > 0
? Math.min((timerState.totalSpeakersTime / timerState.meetingLimit) * 100, 100)
: 0
// Red zone: last 10% of meeting time
$: meetingDangerZoneStart = 90
function formatTime(seconds) {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
function getTimerClass(state) {
if (!state) return ''
if (state.speakerOvertime) return 'overtime'
if (state.warning) return 'warning'
return ''
}
</script>
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
<div class="header-row">
<div class="current-clock">{currentTime}</div>
<div class="help-icon">
?
<div class="help-tooltip">
<div class="tooltip-title">Hotkeys</div>
<div class="tooltip-row"><span class="key">⌘N</span> Next speaker</div>
<div class="tooltip-row"><span class="key">⌘S</span> Skip speaker</div>
<div class="tooltip-row"><span class="key">Space</span> Pause/Resume</div>
<div class="tooltip-row"><span class="key">⌘Q</span> Stop meeting</div>
</div>
</div>
</div>
<div class="speaker-name">
{#if timerState?.currentSpeaker}
Сейчас говорит: {timerState.currentSpeaker}
{:else}
{$t('timer.noSpeaker')}
{/if}
</div>
<div class="speaker-section">
<div class="timer-display {getTimerClass(timerState)}">
<span class="time">{speakerTime}</span>
<span class="separator">/</span>
<span class="limit">{speakerLimit}</span>
</div>
<div class="progress-bar">
<div class="warning-zone" style="left: {warningZoneStart}%"></div>
<div
class="progress-fill {getTimerClass(timerState)}"
style="width: {speakerProgress}%"
></div>
</div>
{#if timerState?.speakerOvertime}
<div class="overtime-badge"></div>
{:else if timerState?.warning}
<div class="warning-badge">⚠️</div>
{/if}
</div>
<div class="meeting-section">
<div class="meeting-label">{$t('timer.totalTime')}</div>
<div class="meeting-time" class:overtime={timerState?.meetingOvertime}>
{meetingTime} / {meetingLimit}
</div>
<div class="progress-bar small meeting-progress">
<div class="buffer-zone" style="left: {speakersZoneEnd}%; width: {Math.max(0, meetingDangerZoneStart - speakersZoneEnd)}%"></div>
<div class="danger-zone" style="left: {meetingDangerZoneStart}%"></div>
<div
class="progress-fill {timerState?.meetingOvertime ? 'overtime' : ''}"
style="width: {meetingProgress}%"
></div>
</div>
</div>
<div class="status-bar">
<span>Speaker {timerState?.speakingOrder || 0} of {timerState?.totalSpeakers || 0}</span>
{#if timerState?.paused}
<span class="paused-badge">PAUSED</span>
{/if}
</div>
</div>
<style>
.timer-container {
position: relative;
background: #232f3e;
border-radius: 12px;
padding: 16px;
text-align: center;
transition: box-shadow 0.3s, background-color 0.5s;
}
.timer-container.warning {
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5), 0 0 40px rgba(251, 191, 36, 0.3);
animation: warningPulse 1.5s ease-in-out infinite;
}
.timer-container.overtime {
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6), 0 0 50px rgba(239, 68, 68, 0.4);
animation: overtimePulse 0.8s ease-in-out infinite;
}
.timer-container.warning::before,
.timer-container.overtime::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
border-radius: 12px 12px 0 0;
}
.timer-container.warning::before {
background: linear-gradient(90deg, transparent, #fbbf24, transparent);
animation: stripeSweep 2s linear infinite;
}
.timer-container.overtime::before {
background: linear-gradient(90deg, transparent, #ef4444, transparent);
animation: stripeSweep 1s linear infinite;
}
@keyframes warningPulse {
0%, 100% {
background-color: #232f3e;
box-shadow: 0 0 20px rgba(251, 191, 36, 0.4), 0 0 40px rgba(251, 191, 36, 0.2);
}
50% {
background-color: #2d3a28;
box-shadow: 0 0 30px rgba(251, 191, 36, 0.6), 0 0 60px rgba(251, 191, 36, 0.4);
}
}
@keyframes overtimePulse {
0%, 100% {
background-color: #232f3e;
box-shadow: 0 0 25px rgba(239, 68, 68, 0.5), 0 0 50px rgba(239, 68, 68, 0.3);
}
50% {
background-color: #3a2828;
box-shadow: 0 0 35px rgba(239, 68, 68, 0.8), 0 0 70px rgba(239, 68, 68, 0.5);
}
}
@keyframes stripeSweep {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.current-clock {
font-size: 14px;
color: #8899a6;
font-family: 'SF Mono', 'Menlo', monospace;
}
.speaker-name {
font-size: 16px;
font-weight: 600;
color: #e0e0e0;
text-align: center;
margin-bottom: 16px;
}
.help-icon {
position: relative;
width: 20px;
height: 20px;
min-width: 20px;
border-radius: 50%;
background: #3d4f61;
color: #8899a6;
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
cursor: help;
}
.help-tooltip {
display: none;
position: absolute;
top: 28px;
right: 0;
background: #1b2636;
border: 1px solid #3d4f61;
border-radius: 8px;
padding: 12px;
min-width: 160px;
z-index: 100;
text-align: left;
}
.help-icon:hover .help-tooltip {
display: block;
}
.tooltip-title {
font-size: 12px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #3d4f61;
}
.tooltip-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #9ca3af;
margin-bottom: 4px;
}
.tooltip-row:last-child {
margin-bottom: 0;
}
.tooltip-row .key {
background: #3d4f61;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 10px;
color: #e0e0e0;
}
.speaker-section {
margin-bottom: 24px;
}
.timer-display {
font-size: 48px;
font-weight: 700;
font-family: 'SF Mono', 'Menlo', monospace;
margin-bottom: 12px;
transition: color 0.3s;
}
.timer-display .time {
color: #4ade80;
}
.timer-display.warning .time {
color: #fbbf24;
}
.timer-display.overtime .time {
color: #ef4444;
animation: pulse 1s ease-in-out infinite;
}
.timer-display .separator {
color: #6b7280;
margin: 0 4px;
font-size: 32px;
}
.timer-display .limit {
color: #6b7280;
font-size: 32px;
}
.progress-bar {
position: relative;
height: 8px;
background: #3d4f61;
border-radius: 4px;
overflow: hidden;
}
.warning-zone {
position: absolute;
top: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(239, 68, 68, 0.3),
rgba(239, 68, 68, 0.3) 3px,
rgba(127, 29, 29, 0.3) 3px,
rgba(127, 29, 29, 0.3) 6px
);
border-radius: 0 4px 4px 0;
}
.progress-bar.small {
height: 4px;
}
.meeting-progress {
position: relative;
}
.buffer-zone {
position: absolute;
top: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(251, 191, 36, 0.3),
rgba(251, 191, 36, 0.3) 2px,
rgba(180, 130, 20, 0.3) 2px,
rgba(180, 130, 20, 0.3) 4px
);
}
.danger-zone {
position: absolute;
top: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
rgba(239, 68, 68, 0.4),
rgba(239, 68, 68, 0.4) 2px,
rgba(127, 29, 29, 0.4) 2px,
rgba(127, 29, 29, 0.4) 4px
);
border-radius: 0 4px 4px 0;
}
.progress-fill {
height: 100%;
background: #4ade80;
transition: width 0.1s linear, background 0.3s;
border-radius: 4px;
}
.progress-fill.warning {
background: #fbbf24;
}
.progress-fill.overtime {
background: #ef4444;
}
.overtime-badge {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
background: #ef4444;
color: white;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
animation: pulse 1s ease-in-out infinite;
}
.warning-badge {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
background: #fbbf24;
color: #1b2636;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
}
.meeting-section {
padding: 16px;
background: #1b2636;
border-radius: 8px;
margin-bottom: 16px;
}
.meeting-label {
font-size: 12px;
color: #9ca3af;
text-transform: uppercase;
margin-bottom: 4px;
}
.meeting-time {
font-size: 24px;
font-family: 'SF Mono', 'Menlo', monospace;
color: #e0e0e0;
margin-bottom: 8px;
}
.meeting-time.overtime {
color: #ef4444;
}
.status-bar {
display: flex;
justify-content: center;
gap: 16px;
color: #9ca3af;
font-size: 14px;
}
.paused-badge {
background: #6b7280;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
</style>