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>
|
||||
Reference in New Issue
Block a user