feat: initial daily-timer implementation

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

27
frontend/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daily Timer</title>
<style>
html,
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1b2636;
color: #e0e0e0;
overflow: hidden;
height: 100%;
}
#app {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

17
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}

1299
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
frontend/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "daily-timer-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"svelte": "^4.2.18",
"vite": "^5.4.2"
}
}

1
frontend/package.json.md5 Executable file
View File

@@ -0,0 +1 @@
719a41f9088f999bf7b87d245f1e5231

View File

@@ -0,0 +1,10 @@
# Sound Files
Place custom MP3 sound files here:
- `warning.mp3` - Plays when speaker time is running low (30 seconds by default)
- `timeup.mp3` - Plays when speaker time expires
- `meeting_end.mp3` - Plays when meeting ends
The application uses the Web Audio API to play sounds from the frontend.
Default sounds are embedded, but you can override them with custom files.

466
frontend/src/App.svelte Normal file
View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

318
frontend/src/lib/i18n.js Normal file
View 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
View File

@@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app'),
});
export default app;

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

53
frontend/wailsjs/go/app/App.d.ts vendored Executable file
View File

@@ -0,0 +1,53 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>;
export function DeleteAllSessions():Promise<void>;
export function DeleteParticipant(arg1:number):Promise<void>;
export function DeleteSession(arg1:number):Promise<void>;
export function ExportCSV(arg1:string,arg2:string):Promise<string>;
export function ExportData(arg1:string,arg2:string):Promise<string>;
export function GetMeeting():Promise<models.Meeting>;
export function GetParticipants():Promise<Array<models.Participant>>;
export function GetSession(arg1:number):Promise<models.MeetingSession>;
export function GetSessions(arg1:number,arg2:number):Promise<Array<models.MeetingSession>>;
export function GetSettings():Promise<models.Settings>;
export function GetSoundsDir():Promise<string>;
export function GetStatistics(arg1:string,arg2:string):Promise<models.AggregatedStats>;
export function GetTimerState():Promise<models.TimerState>;
export function NextSpeaker():Promise<void>;
export function PauseMeeting():Promise<void>;
export function RemoveFromQueue(arg1:number):Promise<void>;
export function ReorderParticipants(arg1:Array<number>):Promise<void>;
export function ResumeMeeting():Promise<void>;
export function SkipSpeaker():Promise<void>;
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Promise<void>;
export function StopMeeting():Promise<void>;
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
export function UpdateSettings(arg1:models.Settings):Promise<void>;

103
frontend/wailsjs/go/app/App.js Executable file
View File

@@ -0,0 +1,103 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddParticipant(arg1, arg2, arg3) {
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3);
}
export function DeleteAllSessions() {
return window['go']['app']['App']['DeleteAllSessions']();
}
export function DeleteParticipant(arg1) {
return window['go']['app']['App']['DeleteParticipant'](arg1);
}
export function DeleteSession(arg1) {
return window['go']['app']['App']['DeleteSession'](arg1);
}
export function ExportCSV(arg1, arg2) {
return window['go']['app']['App']['ExportCSV'](arg1, arg2);
}
export function ExportData(arg1, arg2) {
return window['go']['app']['App']['ExportData'](arg1, arg2);
}
export function GetMeeting() {
return window['go']['app']['App']['GetMeeting']();
}
export function GetParticipants() {
return window['go']['app']['App']['GetParticipants']();
}
export function GetSession(arg1) {
return window['go']['app']['App']['GetSession'](arg1);
}
export function GetSessions(arg1, arg2) {
return window['go']['app']['App']['GetSessions'](arg1, arg2);
}
export function GetSettings() {
return window['go']['app']['App']['GetSettings']();
}
export function GetSoundsDir() {
return window['go']['app']['App']['GetSoundsDir']();
}
export function GetStatistics(arg1, arg2) {
return window['go']['app']['App']['GetStatistics'](arg1, arg2);
}
export function GetTimerState() {
return window['go']['app']['App']['GetTimerState']();
}
export function NextSpeaker() {
return window['go']['app']['App']['NextSpeaker']();
}
export function PauseMeeting() {
return window['go']['app']['App']['PauseMeeting']();
}
export function RemoveFromQueue(arg1) {
return window['go']['app']['App']['RemoveFromQueue'](arg1);
}
export function ReorderParticipants(arg1) {
return window['go']['app']['App']['ReorderParticipants'](arg1);
}
export function ResumeMeeting() {
return window['go']['app']['App']['ResumeMeeting']();
}
export function SkipSpeaker() {
return window['go']['app']['App']['SkipSpeaker']();
}
export function StartMeeting(arg1, arg2) {
return window['go']['app']['App']['StartMeeting'](arg1, arg2);
}
export function StopMeeting() {
return window['go']['app']['App']['StopMeeting']();
}
export function UpdateMeeting(arg1, arg2) {
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
}
export function UpdateParticipant(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4);
}
export function UpdateSettings(arg1) {
return window['go']['app']['App']['UpdateSettings'](arg1);
}

434
frontend/wailsjs/go/models.ts Executable file
View File

@@ -0,0 +1,434 @@
export namespace models {
export class ParticipantBreakdown {
participantId: number;
name: string;
sessionsAttended: number;
totalSpeakingTime: number;
averageSpeakingTime: number;
overtimeCount: number;
skipCount: number;
attendanceRate: number;
static createFrom(source: any = {}) {
return new ParticipantBreakdown(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.participantId = source["participantId"];
this.name = source["name"];
this.sessionsAttended = source["sessionsAttended"];
this.totalSpeakingTime = source["totalSpeakingTime"];
this.averageSpeakingTime = source["averageSpeakingTime"];
this.overtimeCount = source["overtimeCount"];
this.skipCount = source["skipCount"];
this.attendanceRate = source["attendanceRate"];
}
}
export class AggregatedStats {
totalSessions: number;
totalMeetingTime: number;
averageMeetingTime: number;
overtimeSessions: number;
overtimePercentage: number;
averageAttendance: number;
participantBreakdown: ParticipantBreakdown[];
static createFrom(source: any = {}) {
return new AggregatedStats(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.totalSessions = source["totalSessions"];
this.totalMeetingTime = source["totalMeetingTime"];
this.averageMeetingTime = source["averageMeetingTime"];
this.overtimeSessions = source["overtimeSessions"];
this.overtimePercentage = source["overtimePercentage"];
this.averageAttendance = source["averageAttendance"];
this.participantBreakdown = this.convertValues(source["participantBreakdown"], ParticipantBreakdown);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SessionAttendance {
id: number;
sessionId: number;
participantId: number;
participant?: Participant;
present: boolean;
joinedLate: boolean;
static createFrom(source: any = {}) {
return new SessionAttendance(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.sessionId = source["sessionId"];
this.participantId = source["participantId"];
this.participant = this.convertValues(source["participant"], Participant);
this.present = source["present"];
this.joinedLate = source["joinedLate"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Participant {
id: number;
name: string;
email?: string;
timeLimit: number;
order: number;
active: boolean;
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
static createFrom(source: any = {}) {
return new Participant(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.email = source["email"];
this.timeLimit = source["timeLimit"];
this.order = source["order"];
this.active = source["active"];
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ParticipantLog {
id: number;
sessionId: number;
participantId: number;
participant?: Participant;
// Go type: time
startedAt: any;
// Go type: time
endedAt?: any;
duration: number;
skipped: boolean;
overtime: boolean;
order: number;
static createFrom(source: any = {}) {
return new ParticipantLog(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.sessionId = source["sessionId"];
this.participantId = source["participantId"];
this.participant = this.convertValues(source["participant"], Participant);
this.startedAt = this.convertValues(source["startedAt"], null);
this.endedAt = this.convertValues(source["endedAt"], null);
this.duration = source["duration"];
this.skipped = source["skipped"];
this.overtime = source["overtime"];
this.order = source["order"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class MeetingSession {
id: number;
meetingId: number;
// Go type: time
startedAt: any;
// Go type: time
endedAt?: any;
totalDuration: number;
completed: boolean;
participantLogs?: ParticipantLog[];
attendance?: SessionAttendance[];
static createFrom(source: any = {}) {
return new MeetingSession(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.meetingId = source["meetingId"];
this.startedAt = this.convertValues(source["startedAt"], null);
this.endedAt = this.convertValues(source["endedAt"], null);
this.totalDuration = source["totalDuration"];
this.completed = source["completed"];
this.participantLogs = this.convertValues(source["participantLogs"], ParticipantLog);
this.attendance = this.convertValues(source["attendance"], SessionAttendance);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Meeting {
id: number;
name: string;
timeLimit: number;
sessions?: MeetingSession[];
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
static createFrom(source: any = {}) {
return new Meeting(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.timeLimit = source["timeLimit"];
this.sessions = this.convertValues(source["sessions"], MeetingSession);
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class QueuedSpeaker {
id: number;
name: string;
timeLimit: number;
order: number;
static createFrom(source: any = {}) {
return new QueuedSpeaker(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.timeLimit = source["timeLimit"];
this.order = source["order"];
}
}
export class Settings {
id: number;
defaultParticipantTime: number;
defaultMeetingTime: number;
soundEnabled: boolean;
soundWarning: string;
soundTimeUp: string;
soundMeetingEnd: string;
warningThreshold: number;
theme: string;
windowWidth: number;
windowFullHeight: boolean;
static createFrom(source: any = {}) {
return new Settings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.defaultParticipantTime = source["defaultParticipantTime"];
this.defaultMeetingTime = source["defaultMeetingTime"];
this.soundEnabled = source["soundEnabled"];
this.soundWarning = source["soundWarning"];
this.soundTimeUp = source["soundTimeUp"];
this.soundMeetingEnd = source["soundMeetingEnd"];
this.warningThreshold = source["warningThreshold"];
this.theme = source["theme"];
this.windowWidth = source["windowWidth"];
this.windowFullHeight = source["windowFullHeight"];
}
}
export class SpeakerInfo {
id: number;
name: string;
timeLimit: number;
order: number;
status: string;
static createFrom(source: any = {}) {
return new SpeakerInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.timeLimit = source["timeLimit"];
this.order = source["order"];
this.status = source["status"];
}
}
export class TimerState {
running: boolean;
paused: boolean;
currentSpeakerId: number;
currentSpeaker: string;
speakerElapsed: number;
speakerLimit: number;
meetingElapsed: number;
meetingLimit: number;
speakerOvertime: boolean;
meetingOvertime: boolean;
warning: boolean;
warningSeconds: number;
totalSpeakersTime: number;
speakingOrder: number;
totalSpeakers: number;
remainingQueue: QueuedSpeaker[];
allSpeakers: SpeakerInfo[];
static createFrom(source: any = {}) {
return new TimerState(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.running = source["running"];
this.paused = source["paused"];
this.currentSpeakerId = source["currentSpeakerId"];
this.currentSpeaker = source["currentSpeaker"];
this.speakerElapsed = source["speakerElapsed"];
this.speakerLimit = source["speakerLimit"];
this.meetingElapsed = source["meetingElapsed"];
this.meetingLimit = source["meetingLimit"];
this.speakerOvertime = source["speakerOvertime"];
this.meetingOvertime = source["meetingOvertime"];
this.warning = source["warning"];
this.warningSeconds = source["warningSeconds"];
this.totalSpeakersTime = source["totalSpeakersTime"];
this.speakingOrder = source["speakingOrder"];
this.totalSpeakers = source["totalSpeakers"];
this.remainingQueue = this.convertValues(source["remainingQueue"], QueuedSpeaker);
this.allSpeakers = this.convertValues(source["allSpeakers"], SpeakerInfo);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

249
frontend/wailsjs/runtime/runtime.d.ts vendored Normal file
View File

@@ -0,0 +1,249 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void

View File

@@ -0,0 +1,242 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}