feat: initial daily-timer implementation
This commit is contained in:
466
frontend/src/App.svelte
Normal file
466
frontend/src/App.svelte
Normal file
@@ -0,0 +1,466 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import Timer from './components/Timer.svelte'
|
||||
import ParticipantList from './components/ParticipantList.svelte'
|
||||
import Controls from './components/Controls.svelte'
|
||||
import Settings from './components/Settings.svelte'
|
||||
import History from './components/History.svelte'
|
||||
import Setup from './components/Setup.svelte'
|
||||
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
|
||||
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
|
||||
import { t, initLocale } from './lib/i18n'
|
||||
|
||||
let currentView = 'main'
|
||||
let timerState = null
|
||||
let meetingActive = false
|
||||
let settings = null
|
||||
let participants = []
|
||||
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(async () => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 1000)
|
||||
initLocale()
|
||||
await loadSettings()
|
||||
await loadParticipants()
|
||||
EventsOn('timer:tick', handleTimerEvent)
|
||||
EventsOn('timer:speaker_warning', handleWarning)
|
||||
EventsOn('timer:speaker_timeup', handleTimeUp)
|
||||
EventsOn('timer:meeting_warning', handleMeetingWarning)
|
||||
EventsOn('timer:meeting_ended', handleMeetingEnded)
|
||||
EventsOn('timer:speaker_changed', handleSpeakerChanged)
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await GetSettings()
|
||||
const width = settings?.windowWidth >= 480 ? settings.windowWidth : 800
|
||||
|
||||
if (settings?.windowFullHeight) {
|
||||
// Get screen dimensions and use full height
|
||||
try {
|
||||
const screens = await ScreenGetAll()
|
||||
if (screens && screens.length > 0) {
|
||||
const primaryScreen = screens[0]
|
||||
const height = primaryScreen.size?.height || 800
|
||||
// Leave some space for dock/taskbar (approx 80px)
|
||||
WindowSetSize(width, height - 80)
|
||||
} else {
|
||||
WindowSetSize(width, 800)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get screen size:', e)
|
||||
WindowSetSize(width, 800)
|
||||
}
|
||||
} else {
|
||||
WindowSetSize(width, 600)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
EventsOff('timer:tick')
|
||||
EventsOff('timer:speaker_warning')
|
||||
EventsOff('timer:speaker_timeup')
|
||||
EventsOff('timer:meeting_warning')
|
||||
EventsOff('timer:meeting_ended')
|
||||
EventsOff('timer:speaker_changed')
|
||||
})
|
||||
|
||||
function handleTimerEvent(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleWarning(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('warning')
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeUp(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('timeup')
|
||||
}
|
||||
}
|
||||
|
||||
function handleMeetingWarning(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleMeetingEnded(state) {
|
||||
timerState = state
|
||||
meetingActive = false
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('meeting_end')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpeakerChanged(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(name) {
|
||||
switch (name) {
|
||||
case 'warning':
|
||||
// Two short warning beeps
|
||||
playBeep(880, 0.15)
|
||||
setTimeout(() => playBeep(880, 0.15), 200)
|
||||
break
|
||||
case 'timeup':
|
||||
// Descending tone sequence
|
||||
playBeep(1200, 0.2)
|
||||
setTimeout(() => playBeep(900, 0.2), 250)
|
||||
setTimeout(() => playBeep(600, 0.3), 500)
|
||||
break
|
||||
case 'meeting_end':
|
||||
// Final chime - three notes
|
||||
playBeep(523, 0.2) // C5
|
||||
setTimeout(() => playBeep(659, 0.2), 200) // E5
|
||||
setTimeout(() => playBeep(784, 0.4), 400) // G5
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleMeetingStarted() {
|
||||
meetingActive = true
|
||||
currentView = 'main'
|
||||
}
|
||||
|
||||
function handleSettingsLoaded(s) {
|
||||
settings = s
|
||||
}
|
||||
|
||||
async function handleSkipFromList(event) {
|
||||
const { speakerId } = event.detail
|
||||
try {
|
||||
await RemoveFromQueue(speakerId)
|
||||
} catch (e) {
|
||||
console.error('Failed to remove speaker from queue:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await GetParticipants() || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load participants:', e)
|
||||
participants = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickStart() {
|
||||
if (participants.length === 0) return
|
||||
|
||||
const ids = participants.map(p => p.id)
|
||||
const attendance = {}
|
||||
participants.forEach(p => { attendance[p.id] = true })
|
||||
|
||||
try {
|
||||
await StartMeeting(ids, attendance)
|
||||
meetingActive = true
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
alert('Ошибка запуска планёрки: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload participants when switching to main view
|
||||
$: if (currentView === 'main' && !meetingActive) {
|
||||
loadParticipants()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="titlebar"></div>
|
||||
|
||||
<main>
|
||||
<nav class="nav" class:hidden={meetingActive}>
|
||||
<button
|
||||
class:active={currentView === 'main'}
|
||||
on:click={() => currentView = 'main'}
|
||||
>
|
||||
{$t('nav.timer')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'setup'}
|
||||
on:click={() => currentView = 'setup'}
|
||||
>
|
||||
{$t('nav.setup')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'history'}
|
||||
on:click={() => currentView = 'history'}
|
||||
>
|
||||
{$t('nav.history')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'settings'}
|
||||
on:click={() => currentView = 'settings'}
|
||||
>
|
||||
{$t('nav.settings')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{#if currentView === 'main'}
|
||||
{#if meetingActive && timerState}
|
||||
<div class="timer-view">
|
||||
<Timer {timerState} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
||||
<Controls {timerState} on:stop={() => meetingActive = false} />
|
||||
</div>
|
||||
{:else if participants.length > 0}
|
||||
<div class="ready-to-start">
|
||||
<div class="current-clock">{currentTime}</div>
|
||||
<h2>{$t('timer.readyToStart')}</h2>
|
||||
<p>{$t('timer.registeredParticipants')}: {participants.length}</p>
|
||||
<button class="start-btn big" on:click={handleQuickStart}>
|
||||
{$t('setup.startMeeting')}
|
||||
</button>
|
||||
<button class="secondary-btn" on:click={() => currentView = 'setup'}>
|
||||
{$t('timer.editParticipants')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-meeting">
|
||||
<div class="current-clock">{currentTime}</div>
|
||||
<h2>{$t('timer.noParticipants')}</h2>
|
||||
<p>{$t('timer.goToParticipants')}</p>
|
||||
<button class="start-btn" on:click={() => currentView = 'setup'}>
|
||||
{$t('nav.setup')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if currentView === 'setup'}
|
||||
<Setup on:started={handleMeetingStarted} />
|
||||
{:else if currentView === 'history'}
|
||||
<History />
|
||||
{:else if currentView === 'settings'}
|
||||
<Settings on:loaded={handleSettingsLoaded} />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding-top: 28px; /* macOS titlebar */
|
||||
}
|
||||
|
||||
/* Draggable titlebar area */
|
||||
.titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
--wails-draggable: drag;
|
||||
-webkit-app-region: drag;
|
||||
background: transparent;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #232f3e;
|
||||
border-bottom: 1px solid #3d4f61;
|
||||
--wails-draggable: drag;
|
||||
-webkit-app-region: drag;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
-webkit-app-region: no-drag;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav button:hover {
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav button.active {
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.timer-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-meeting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.no-meeting h2 {
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.no-meeting p {
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-meeting .start-btn {
|
||||
padding: 16px 32px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.no-meeting .start-btn:hover {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.ready-to-start {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ready-to-start h2 {
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ready-to-start p {
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ready-to-start .start-btn.big {
|
||||
padding: 20px 36px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ready-to-start .start-btn.big:hover {
|
||||
background: #15803d;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.ready-to-start .secondary-btn {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ready-to-start .secondary-btn:hover {
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.current-clock {
|
||||
font-size: 32px;
|
||||
color: #4a90d9;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/components/Controls.svelte
Normal file
118
frontend/src/components/Controls.svelte
Normal 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>
|
||||
572
frontend/src/components/History.svelte
Normal file
572
frontend/src/components/History.svelte
Normal 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>
|
||||
166
frontend/src/components/ParticipantList.svelte
Normal file
166
frontend/src/components/ParticipantList.svelte
Normal 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>
|
||||
372
frontend/src/components/Settings.svelte
Normal file
372
frontend/src/components/Settings.svelte
Normal 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>
|
||||
826
frontend/src/components/Setup.svelte
Normal file
826
frontend/src/components/Setup.svelte
Normal 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>
|
||||
464
frontend/src/components/Timer.svelte
Normal file
464
frontend/src/components/Timer.svelte
Normal 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>
|
||||
318
frontend/src/lib/i18n.js
Normal file
318
frontend/src/lib/i18n.js
Normal file
@@ -0,0 +1,318 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
export const locale = writable('ru');
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
// Navigation
|
||||
nav: {
|
||||
timer: 'Таймер',
|
||||
setup: 'Участники',
|
||||
history: 'История',
|
||||
settings: 'Настройки',
|
||||
},
|
||||
|
||||
// Setup page
|
||||
setup: {
|
||||
title: 'Название собрания',
|
||||
participants: 'Участники',
|
||||
addParticipant: 'Добавить участника',
|
||||
namePlaceholder: 'Имя участника',
|
||||
noParticipants: 'Добавьте участников для начала собрания',
|
||||
selectAll: 'Выбрать всех',
|
||||
deselectAll: 'Снять выбор',
|
||||
startMeeting: 'Начать собрание',
|
||||
speakerTime: 'Время на спикера',
|
||||
totalTime: 'Общее время',
|
||||
minutes: 'мин',
|
||||
unlimited: 'Без ограничения',
|
||||
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
timer: {
|
||||
currentSpeaker: 'Текущий спикер',
|
||||
speakerTime: 'Время спикера',
|
||||
totalTime: 'Общее время',
|
||||
remaining: 'Осталось',
|
||||
queue: 'Очередь',
|
||||
participants: 'Участники',
|
||||
finished: 'Выступили',
|
||||
noSpeaker: 'Нет спикера',
|
||||
noActiveMeeting: 'Собрание не запущено',
|
||||
goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
|
||||
readyToStart: 'Всё готово к началу',
|
||||
editParticipants: 'Редактировать участников',
|
||||
noParticipants: 'Участники не добавлены',
|
||||
registeredParticipants: 'Зарегистрированные участники',
|
||||
},
|
||||
|
||||
// Controls
|
||||
controls: {
|
||||
next: 'Следующий',
|
||||
skip: 'Пропустить',
|
||||
pause: 'Пауза',
|
||||
resume: 'Продолжить',
|
||||
stop: 'Завершить',
|
||||
},
|
||||
|
||||
// History page
|
||||
history: {
|
||||
title: 'История собраний',
|
||||
noHistory: 'История пуста',
|
||||
date: 'Дата',
|
||||
duration: 'Длительность',
|
||||
participants: 'Участники',
|
||||
avgTime: 'Среднее время',
|
||||
export: 'Экспорт',
|
||||
exportJSON: 'Экспорт JSON',
|
||||
exportCSV: 'Экспорт CSV',
|
||||
delete: 'Удалить',
|
||||
deleteAll: 'Удалить историю',
|
||||
deleteSession: 'Удалить запись',
|
||||
confirmDelete: 'Удалить эту запись?',
|
||||
confirmDeleteTitle: 'Подтверждение удаления',
|
||||
confirmDeleteSession: 'Вы уверены, что хотите удалить эту запись? Действие необратимо.',
|
||||
confirmDeleteAllTitle: 'Удалить всю историю?',
|
||||
confirmDeleteAll: 'Вы уверены, что хотите удалить ВСЮ историю собраний? Это действие необратимо!',
|
||||
overtimeRate: 'Процент превышения',
|
||||
avgAttendance: 'Средняя явка',
|
||||
recentSessions: 'Последние собрания',
|
||||
noSessions: 'Собраний пока нет',
|
||||
participantBreakdown: 'Статистика по участникам',
|
||||
name: 'Имя',
|
||||
sessions: 'Собрания',
|
||||
overtime: 'Превышение',
|
||||
attendance: 'Явка',
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Настройки собрания',
|
||||
language: 'Язык',
|
||||
sound: 'Звуковые уведомления',
|
||||
soundEnabled: 'Включены',
|
||||
soundDisabled: 'Выключены',
|
||||
warningTime: 'Предупреждение за',
|
||||
seconds: 'сек',
|
||||
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
||||
defaultTotalTime: 'Общее время собрания (мин)',
|
||||
theme: 'Тема оформления',
|
||||
themeDark: 'Тёмная',
|
||||
themeLight: 'Светлая',
|
||||
save: 'Сохранить',
|
||||
saved: 'Сохранено!',
|
||||
windowWidth: 'Настройка окна',
|
||||
windowWidthHint: 'Ширина окна (мин. 480 пикселей)',
|
||||
windowFullHeight: 'Окно на всю высоту экрана',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Управление участниками',
|
||||
add: 'Добавить',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
name: 'Имя',
|
||||
stats: 'Статистика',
|
||||
avgSpeakTime: 'Среднее время выступления',
|
||||
totalMeetings: 'Всего собраний',
|
||||
confirmDelete: 'Удалить участника?',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
close: 'Закрыть',
|
||||
delete: 'Удалить',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
loading: 'Загрузка...',
|
||||
error: 'Ошибка',
|
||||
},
|
||||
|
||||
// Time formats
|
||||
time: {
|
||||
hours: 'ч',
|
||||
minutes: 'мин',
|
||||
seconds: 'сек',
|
||||
},
|
||||
},
|
||||
|
||||
en: {
|
||||
// Navigation
|
||||
nav: {
|
||||
timer: 'Timer',
|
||||
setup: 'Participants',
|
||||
history: 'History',
|
||||
settings: 'Settings',
|
||||
},
|
||||
|
||||
// Setup page
|
||||
setup: {
|
||||
title: 'New Meeting',
|
||||
participants: 'Participants',
|
||||
addParticipant: 'Add Participant',
|
||||
namePlaceholder: 'Participant name',
|
||||
noParticipants: 'Add participants to start a meeting',
|
||||
selectAll: 'Select All',
|
||||
deselectAll: 'Deselect All',
|
||||
startMeeting: 'Start Meeting',
|
||||
speakerTime: 'Speaker Time',
|
||||
totalTime: 'Total Time',
|
||||
minutes: 'min',
|
||||
unlimited: 'Unlimited',
|
||||
dragHint: 'drag to reorder, ✓/✗ attendance',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
timer: {
|
||||
currentSpeaker: 'Current Speaker',
|
||||
speakerTime: 'Speaker Time',
|
||||
totalTime: 'Total Time',
|
||||
remaining: 'Remaining',
|
||||
queue: 'Queue',
|
||||
participants: 'Participants',
|
||||
finished: 'Finished',
|
||||
noSpeaker: 'No speaker',
|
||||
noActiveMeeting: 'No active meeting',
|
||||
goToParticipants: 'Go to Participants to add participants',
|
||||
readyToStart: 'Ready to start',
|
||||
editParticipants: 'Edit participants',
|
||||
noParticipants: 'No participants added',
|
||||
registeredParticipants: 'Registered participants',
|
||||
},
|
||||
|
||||
// Controls
|
||||
controls: {
|
||||
next: 'Next',
|
||||
skip: 'Skip',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
// History page
|
||||
history: {
|
||||
title: 'Meeting History',
|
||||
noHistory: 'No history yet',
|
||||
date: 'Date',
|
||||
duration: 'Duration',
|
||||
participants: 'Participants',
|
||||
avgTime: 'Avg Time',
|
||||
export: 'Export',
|
||||
exportJSON: 'Export JSON',
|
||||
exportCSV: 'Export CSV',
|
||||
delete: 'Delete',
|
||||
deleteAll: 'Delete History',
|
||||
deleteSession: 'Delete session',
|
||||
confirmDelete: 'Delete this record?',
|
||||
confirmDeleteTitle: 'Confirm Deletion',
|
||||
confirmDeleteSession: 'Are you sure you want to delete this session? This action cannot be undone.',
|
||||
confirmDeleteAllTitle: 'Delete All History?',
|
||||
confirmDeleteAll: 'Are you sure you want to delete ALL meeting history? This action cannot be undone!',
|
||||
overtimeRate: 'Overtime Rate',
|
||||
avgAttendance: 'Avg. Attendance',
|
||||
recentSessions: 'Recent Sessions',
|
||||
noSessions: 'No sessions yet',
|
||||
participantBreakdown: 'Participant Breakdown',
|
||||
name: 'Name',
|
||||
sessions: 'Sessions',
|
||||
overtime: 'Overtime',
|
||||
attendance: 'Attendance',
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Meeting Settings',
|
||||
language: 'Language',
|
||||
sound: 'Sound Notifications',
|
||||
soundEnabled: 'Enabled',
|
||||
soundDisabled: 'Disabled',
|
||||
warningTime: 'Warning before',
|
||||
seconds: 'sec',
|
||||
defaultSpeakerTime: 'Default Speaker Time',
|
||||
defaultTotalTime: 'Total meeting time (min)',
|
||||
theme: 'Theme',
|
||||
themeDark: 'Dark',
|
||||
themeLight: 'Light',
|
||||
save: 'Save',
|
||||
saved: 'Saved!',
|
||||
windowWidth: 'Window Settings',
|
||||
windowWidthHint: 'Window width (min. 480 pixels)',
|
||||
windowFullHeight: 'Full screen height window',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Manage Participants',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
name: 'Name',
|
||||
stats: 'Statistics',
|
||||
avgSpeakTime: 'Avg Speaking Time',
|
||||
totalMeetings: 'Total Meetings',
|
||||
confirmDelete: 'Delete participant?',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
},
|
||||
|
||||
// Time formats
|
||||
time: {
|
||||
hours: 'h',
|
||||
minutes: 'min',
|
||||
seconds: 'sec',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const t = derived(locale, ($locale) => {
|
||||
return (key) => {
|
||||
const keys = key.split('.');
|
||||
let value = translations[$locale];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
console.warn(`Translation missing: ${key} for locale ${$locale}`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
});
|
||||
|
||||
export function setLocale(lang) {
|
||||
if (translations[lang]) {
|
||||
locale.set(lang);
|
||||
localStorage.setItem('daily-timer-locale', lang);
|
||||
}
|
||||
}
|
||||
|
||||
export function initLocale() {
|
||||
const saved = localStorage.getItem('daily-timer-locale');
|
||||
if (saved && translations[saved]) {
|
||||
locale.set(saved);
|
||||
} else {
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (translations[browserLang]) {
|
||||
locale.set(browserLang);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user