feat: initial daily-timer implementation
This commit is contained in:
27
frontend/index.html
Normal file
27
frontend/index.html
Normal 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
17
frontend/jsconfig.json
Normal 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
1299
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
frontend/package.json
Normal file
16
frontend/package.json
Normal 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
1
frontend/package.json.md5
Executable file
@@ -0,0 +1 @@
|
||||
719a41f9088f999bf7b87d245f1e5231
|
||||
10
frontend/public/sounds/README.md
Normal file
10
frontend/public/sounds/README.md
Normal 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
466
frontend/src/App.svelte
Normal file
@@ -0,0 +1,466 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import Timer from './components/Timer.svelte'
|
||||
import ParticipantList from './components/ParticipantList.svelte'
|
||||
import Controls from './components/Controls.svelte'
|
||||
import Settings from './components/Settings.svelte'
|
||||
import History from './components/History.svelte'
|
||||
import Setup from './components/Setup.svelte'
|
||||
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
|
||||
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
|
||||
import { t, initLocale } from './lib/i18n'
|
||||
|
||||
let currentView = 'main'
|
||||
let timerState = null
|
||||
let meetingActive = false
|
||||
let settings = null
|
||||
let participants = []
|
||||
let currentTime = ''
|
||||
let clockInterval
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 1000)
|
||||
initLocale()
|
||||
await loadSettings()
|
||||
await loadParticipants()
|
||||
EventsOn('timer:tick', handleTimerEvent)
|
||||
EventsOn('timer:speaker_warning', handleWarning)
|
||||
EventsOn('timer:speaker_timeup', handleTimeUp)
|
||||
EventsOn('timer:meeting_warning', handleMeetingWarning)
|
||||
EventsOn('timer:meeting_ended', handleMeetingEnded)
|
||||
EventsOn('timer:speaker_changed', handleSpeakerChanged)
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await GetSettings()
|
||||
const width = settings?.windowWidth >= 480 ? settings.windowWidth : 800
|
||||
|
||||
if (settings?.windowFullHeight) {
|
||||
// Get screen dimensions and use full height
|
||||
try {
|
||||
const screens = await ScreenGetAll()
|
||||
if (screens && screens.length > 0) {
|
||||
const primaryScreen = screens[0]
|
||||
const height = primaryScreen.size?.height || 800
|
||||
// Leave some space for dock/taskbar (approx 80px)
|
||||
WindowSetSize(width, height - 80)
|
||||
} else {
|
||||
WindowSetSize(width, 800)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get screen size:', e)
|
||||
WindowSetSize(width, 800)
|
||||
}
|
||||
} else {
|
||||
WindowSetSize(width, 600)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
EventsOff('timer:tick')
|
||||
EventsOff('timer:speaker_warning')
|
||||
EventsOff('timer:speaker_timeup')
|
||||
EventsOff('timer:meeting_warning')
|
||||
EventsOff('timer:meeting_ended')
|
||||
EventsOff('timer:speaker_changed')
|
||||
})
|
||||
|
||||
function handleTimerEvent(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleWarning(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('warning')
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeUp(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('timeup')
|
||||
}
|
||||
}
|
||||
|
||||
function handleMeetingWarning(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleMeetingEnded(state) {
|
||||
timerState = state
|
||||
meetingActive = false
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('meeting_end')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSpeakerChanged(state) {
|
||||
timerState = state
|
||||
}
|
||||
|
||||
let audioContext = null
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||
}
|
||||
return audioContext
|
||||
}
|
||||
|
||||
function playBeep(frequency, duration, type = 'sine') {
|
||||
try {
|
||||
const ctx = getAudioContext()
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gainNode = ctx.createGain()
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
oscillator.frequency.value = frequency
|
||||
oscillator.type = type
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
|
||||
|
||||
oscillator.start(ctx.currentTime)
|
||||
oscillator.stop(ctx.currentTime + duration)
|
||||
} catch (e) {
|
||||
console.error('Failed to play sound:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(name) {
|
||||
switch (name) {
|
||||
case 'warning':
|
||||
// Two short warning beeps
|
||||
playBeep(880, 0.15)
|
||||
setTimeout(() => playBeep(880, 0.15), 200)
|
||||
break
|
||||
case 'timeup':
|
||||
// Descending tone sequence
|
||||
playBeep(1200, 0.2)
|
||||
setTimeout(() => playBeep(900, 0.2), 250)
|
||||
setTimeout(() => playBeep(600, 0.3), 500)
|
||||
break
|
||||
case 'meeting_end':
|
||||
// Final chime - three notes
|
||||
playBeep(523, 0.2) // C5
|
||||
setTimeout(() => playBeep(659, 0.2), 200) // E5
|
||||
setTimeout(() => playBeep(784, 0.4), 400) // G5
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleMeetingStarted() {
|
||||
meetingActive = true
|
||||
currentView = 'main'
|
||||
}
|
||||
|
||||
function handleSettingsLoaded(s) {
|
||||
settings = s
|
||||
}
|
||||
|
||||
async function handleSkipFromList(event) {
|
||||
const { speakerId } = event.detail
|
||||
try {
|
||||
await RemoveFromQueue(speakerId)
|
||||
} catch (e) {
|
||||
console.error('Failed to remove speaker from queue:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await GetParticipants() || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load participants:', e)
|
||||
participants = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuickStart() {
|
||||
if (participants.length === 0) return
|
||||
|
||||
const ids = participants.map(p => p.id)
|
||||
const attendance = {}
|
||||
participants.forEach(p => { attendance[p.id] = true })
|
||||
|
||||
try {
|
||||
await StartMeeting(ids, attendance)
|
||||
meetingActive = true
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
alert('Ошибка запуска планёрки: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload participants when switching to main view
|
||||
$: if (currentView === 'main' && !meetingActive) {
|
||||
loadParticipants()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="titlebar"></div>
|
||||
|
||||
<main>
|
||||
<nav class="nav" class:hidden={meetingActive}>
|
||||
<button
|
||||
class:active={currentView === 'main'}
|
||||
on:click={() => currentView = 'main'}
|
||||
>
|
||||
{$t('nav.timer')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'setup'}
|
||||
on:click={() => currentView = 'setup'}
|
||||
>
|
||||
{$t('nav.setup')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'history'}
|
||||
on:click={() => currentView = 'history'}
|
||||
>
|
||||
{$t('nav.history')}
|
||||
</button>
|
||||
<button
|
||||
class:active={currentView === 'settings'}
|
||||
on:click={() => currentView = 'settings'}
|
||||
>
|
||||
{$t('nav.settings')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{#if currentView === 'main'}
|
||||
{#if meetingActive && timerState}
|
||||
<div class="timer-view">
|
||||
<Timer {timerState} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
||||
<Controls {timerState} on:stop={() => meetingActive = false} />
|
||||
</div>
|
||||
{:else if participants.length > 0}
|
||||
<div class="ready-to-start">
|
||||
<div class="current-clock">{currentTime}</div>
|
||||
<h2>{$t('timer.readyToStart')}</h2>
|
||||
<p>{$t('timer.registeredParticipants')}: {participants.length}</p>
|
||||
<button class="start-btn big" on:click={handleQuickStart}>
|
||||
{$t('setup.startMeeting')}
|
||||
</button>
|
||||
<button class="secondary-btn" on:click={() => currentView = 'setup'}>
|
||||
{$t('timer.editParticipants')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-meeting">
|
||||
<div class="current-clock">{currentTime}</div>
|
||||
<h2>{$t('timer.noParticipants')}</h2>
|
||||
<p>{$t('timer.goToParticipants')}</p>
|
||||
<button class="start-btn" on:click={() => currentView = 'setup'}>
|
||||
{$t('nav.setup')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if currentView === 'setup'}
|
||||
<Setup on:started={handleMeetingStarted} />
|
||||
{:else if currentView === 'history'}
|
||||
<History />
|
||||
{:else if currentView === 'settings'}
|
||||
<Settings on:loaded={handleSettingsLoaded} />
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding-top: 28px; /* macOS titlebar */
|
||||
}
|
||||
|
||||
/* Draggable titlebar area */
|
||||
.titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
--wails-draggable: drag;
|
||||
-webkit-app-region: drag;
|
||||
background: transparent;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: #232f3e;
|
||||
border-bottom: 1px solid #3d4f61;
|
||||
--wails-draggable: drag;
|
||||
-webkit-app-region: drag;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav button {
|
||||
-webkit-app-region: no-drag;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav button:hover {
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.nav button.active {
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.timer-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-meeting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.no-meeting h2 {
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.no-meeting p {
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-meeting .start-btn {
|
||||
padding: 16px 32px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.no-meeting .start-btn:hover {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.ready-to-start {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ready-to-start h2 {
|
||||
color: #e0e0e0;
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ready-to-start p {
|
||||
color: #9ca3af;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ready-to-start .start-btn.big {
|
||||
padding: 20px 36px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ready-to-start .start-btn.big:hover {
|
||||
background: #15803d;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.ready-to-start .secondary-btn {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ready-to-start .secondary-btn:hover {
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.current-clock {
|
||||
font-size: 32px;
|
||||
color: #4a90d9;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/components/Controls.svelte
Normal file
118
frontend/src/components/Controls.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { NextSpeaker, SkipSpeaker, PauseMeeting, ResumeMeeting, StopMeeting } from '../../wailsjs/go/app/App'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
export let timerState
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: isPaused = timerState?.paused || false
|
||||
$: hasQueue = (timerState?.remainingQueue?.length || 0) > 0
|
||||
|
||||
async function handleNext() {
|
||||
await NextSpeaker()
|
||||
}
|
||||
|
||||
async function handleSkip() {
|
||||
await SkipSpeaker()
|
||||
}
|
||||
|
||||
async function handlePauseResume() {
|
||||
if (isPaused) {
|
||||
await ResumeMeeting()
|
||||
} else {
|
||||
await PauseMeeting()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
await StopMeeting()
|
||||
dispatch('stop')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn primary" on:click={handleNext}>
|
||||
{hasQueue ? $t('controls.next') : $t('controls.stop')}
|
||||
</button>
|
||||
|
||||
{#if hasQueue}
|
||||
<button class="btn secondary" on:click={handleSkip}>
|
||||
{$t('controls.skip')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button class="btn secondary pause-btn" on:click={handlePauseResume}>
|
||||
{isPaused ? '▶' : '⏸'}
|
||||
</button>
|
||||
|
||||
<button class="btn danger" on:click={handleStop}>
|
||||
⏹
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
flex: 2;
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: #3a7bc8;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
flex: 1;
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: #4d5f71;
|
||||
}
|
||||
|
||||
.btn.pause-btn {
|
||||
flex: 0;
|
||||
min-width: 44px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
flex: 0;
|
||||
min-width: 44px;
|
||||
font-size: 18px;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.btn.danger:hover {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
</style>
|
||||
572
frontend/src/components/History.svelte
Normal file
572
frontend/src/components/History.svelte
Normal file
@@ -0,0 +1,572 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { GetSessions, GetStatistics, ExportData, ExportCSV, DeleteSession, DeleteAllSessions } from '../../wailsjs/go/app/App'
|
||||
import { t, locale } from '../lib/i18n'
|
||||
|
||||
let sessions = []
|
||||
let stats = null
|
||||
let loading = true
|
||||
let dateFrom = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
let dateTo = new Date().toISOString().split('T')[0]
|
||||
let exporting = false
|
||||
let showDeleteAllConfirm = false
|
||||
let deletingSessionId = null
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (deletingSessionId !== null) deletingSessionId = null
|
||||
if (showDeleteAllConfirm) showDeleteAllConfirm = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
await loadData()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
try {
|
||||
sessions = await GetSessions(50, 0)
|
||||
stats = await GetStatistics(dateFrom, dateTo)
|
||||
} catch (e) {
|
||||
console.error('Failed to load history:', e)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function handleDeleteSession(id) {
|
||||
deletingSessionId = id
|
||||
}
|
||||
|
||||
async function confirmDeleteSession() {
|
||||
if (!deletingSessionId) return
|
||||
try {
|
||||
await DeleteSession(deletingSessionId)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete session:', e)
|
||||
}
|
||||
deletingSessionId = null
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
showDeleteAllConfirm = true
|
||||
}
|
||||
|
||||
async function confirmDeleteAll() {
|
||||
try {
|
||||
await DeleteAllSessions()
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete all sessions:', e)
|
||||
}
|
||||
showDeleteAllConfirm = false
|
||||
}
|
||||
|
||||
async function handleExportJSON() {
|
||||
exporting = true
|
||||
try {
|
||||
const path = await ExportData(dateFrom, dateTo)
|
||||
if (path) {
|
||||
alert('Exported to: ' + path)
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Export failed: ' + e)
|
||||
}
|
||||
exporting = false
|
||||
}
|
||||
|
||||
async function handleExportCSV() {
|
||||
exporting = true
|
||||
try {
|
||||
const path = await ExportCSV(dateFrom, dateTo)
|
||||
if (path) {
|
||||
alert('Exported to: ' + path)
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Export failed: ' + e)
|
||||
}
|
||||
exporting = false
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
const loc = $locale === 'ru' ? 'ru-RU' : 'en-US'
|
||||
return new Date(dateStr).toLocaleDateString(loc, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="history">
|
||||
<div class="filters">
|
||||
<div class="date-range">
|
||||
<input type="date" bind:value={dateFrom} on:change={loadData} />
|
||||
<span>—</span>
|
||||
<input type="date" bind:value={dateTo} on:change={loadData} />
|
||||
</div>
|
||||
|
||||
<div class="export-buttons">
|
||||
<button on:click={handleExportJSON} disabled={exporting}>{$t('history.exportJSON')}</button>
|
||||
<button on:click={handleExportCSV} disabled={exporting}>{$t('history.exportCSV')}</button>
|
||||
{#if sessions.length > 0}
|
||||
<button class="delete-all-btn" on:click={handleDeleteAll}>{$t('history.deleteAll')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">{$t('common.loading')}</div>
|
||||
{:else}
|
||||
{#if stats}
|
||||
<section class="stats-overview">
|
||||
<h2>{$t('participants.stats')}</h2>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.totalSessions}</div>
|
||||
<div class="stat-label">{$t('participants.totalMeetings')}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{formatTime(Math.round(stats.averageMeetingTime))}</div>
|
||||
<div class="stat-label">{$t('history.avgTime')}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.overtimePercentage.toFixed(0)}%</div>
|
||||
<div class="stat-label">{$t('history.overtimeRate')}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{stats.averageAttendance.toFixed(1)}</div>
|
||||
<div class="stat-label">{$t('history.avgAttendance')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stats.participantBreakdown?.length > 0}
|
||||
<h3>{$t('history.participantBreakdown')}</h3>
|
||||
<div class="breakdown-table">
|
||||
<div class="breakdown-header">
|
||||
<span>{$t('history.name')}</span>
|
||||
<span>{$t('history.sessions')}</span>
|
||||
<span>{$t('history.avgTime')}</span>
|
||||
<span>{$t('history.overtime')}</span>
|
||||
<span>{$t('history.attendance')}</span>
|
||||
</div>
|
||||
{#each stats.participantBreakdown as p}
|
||||
<div class="breakdown-row">
|
||||
<span>{p.name}</span>
|
||||
<span>{p.sessionsAttended}</span>
|
||||
<span>{formatTime(Math.round(p.averageSpeakingTime))}</span>
|
||||
<span class:overtime={p.overtimeCount > 0}>{p.overtimeCount}</span>
|
||||
<span>{p.attendanceRate.toFixed(0)}%</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="sessions-list">
|
||||
<h2>{$t('history.recentSessions')}</h2>
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="empty">{$t('history.noSessions')}</p>
|
||||
{:else}
|
||||
{#each sessions as session}
|
||||
<div class="session-card" class:overtime={session.totalDuration > 900}>
|
||||
<div class="session-header">
|
||||
<span class="session-date">{formatDate(session.startedAt)}</span>
|
||||
<span class="session-duration">{formatTime(session.totalDuration)}</span>
|
||||
{#if session.totalDuration > 900}
|
||||
<span class="overtime-badge">OVERTIME</span>
|
||||
{/if}
|
||||
<button class="delete-session-btn" on:click={() => handleDeleteSession(session.id)} title={$t('history.deleteSession')}>🗑️</button>
|
||||
</div>
|
||||
|
||||
{#if session.participantLogs?.length > 0}
|
||||
<div class="session-participants">
|
||||
{#each session.participantLogs as log}
|
||||
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
|
||||
<span class="log-order">#{log.order}</span>
|
||||
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
|
||||
<span class="log-duration">{formatTime(log.duration)}</span>
|
||||
{#if log.overtime}
|
||||
<span class="overtime-icon">⚠️</span>
|
||||
{/if}
|
||||
{#if log.skipped}
|
||||
<span class="skipped-icon">⏭️</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete Session Confirmation Modal -->
|
||||
{#if deletingSessionId !== null}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="modal-overlay" on:click={() => deletingSessionId = null}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<h3>{$t('history.confirmDeleteTitle')}</h3>
|
||||
<p>{$t('history.confirmDeleteSession')}</p>
|
||||
<div class="modal-buttons">
|
||||
<button class="cancel-btn" on:click={() => deletingSessionId = null}>{$t('common.cancel')}</button>
|
||||
<button class="confirm-btn" on:click={confirmDeleteSession}>{$t('common.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete All Confirmation Modal -->
|
||||
{#if showDeleteAllConfirm}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="modal-overlay" on:click={() => showDeleteAllConfirm = false}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<h3>{$t('history.confirmDeleteAllTitle')}</h3>
|
||||
<p>{$t('history.confirmDeleteAll')}</p>
|
||||
<div class="modal-buttons">
|
||||
<button class="cancel-btn" on:click={() => showDeleteAllConfirm = false}>{$t('common.cancel')}</button>
|
||||
<button class="confirm-btn danger" on:click={confirmDeleteAll}>{$t('history.deleteAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.history {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.date-range input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 13px;
|
||||
max-width: 130px;
|
||||
}
|
||||
|
||||
.date-range span {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-buttons button {
|
||||
padding: 8px 16px;
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-buttons button:hover {
|
||||
background: #4d5f71;
|
||||
}
|
||||
|
||||
.export-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
section {
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 24px 0 12px 0;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #1b2636;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #4a90d9;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.breakdown-table {
|
||||
background: #1b2636;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.breakdown-header,
|
||||
.breakdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breakdown-header {
|
||||
background: #3d4f61;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
border-bottom: 1px solid #3d4f61;
|
||||
}
|
||||
|
||||
.breakdown-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.breakdown-row .overtime {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: #1b2636;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-card.overtime {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.session-date {
|
||||
flex: 1;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.session-duration {
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.overtime-badge {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-participants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.participant-log {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #232f3e;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.participant-log.log-overtime {
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.participant-log.skipped {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.log-order {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-name {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.log-duration {
|
||||
color: #9ca3af;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.delete-all-btn {
|
||||
background: #dc2626 !important;
|
||||
border-color: #991b1b !important;
|
||||
}
|
||||
|
||||
.delete-all-btn:hover {
|
||||
background: #b91c1c !important;
|
||||
}
|
||||
|
||||
.delete-session-btn {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.delete-session-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #1b2636;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 0 0 20px 0;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #374151;
|
||||
border: 1px solid #4b5563;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #dc2626;
|
||||
border: 1px solid #991b1b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.confirm-btn.danger {
|
||||
background: #991b1b;
|
||||
}
|
||||
|
||||
.confirm-btn.danger:hover {
|
||||
background: #7f1d1d;
|
||||
}
|
||||
</style>
|
||||
166
frontend/src/components/ParticipantList.svelte
Normal file
166
frontend/src/components/ParticipantList.svelte
Normal file
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
export let timerState
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: allSpeakers = timerState?.allSpeakers || []
|
||||
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
||||
|
||||
function handleSkip(speakerId) {
|
||||
dispatch('skip', { speakerId })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="participant-list">
|
||||
<h3>{$t('timer.participants')}</h3>
|
||||
|
||||
{#if allSpeakers.length > 0}
|
||||
<ul>
|
||||
{#each allSpeakers as speaker}
|
||||
<li class="speaker-item {speaker.status}">
|
||||
<span class="order">{speaker.order}</span>
|
||||
<span class="name">{speaker.name}</span>
|
||||
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
|
||||
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
⏭
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">—</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.participant-list {
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.speaker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: #1b2636;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.speaker-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.speaker-item.speaking {
|
||||
background: #166534;
|
||||
}
|
||||
|
||||
.speaker-item.done {
|
||||
background: #1e3a5f;
|
||||
}
|
||||
|
||||
.speaker-item.pending {
|
||||
background: #1b2636;
|
||||
}
|
||||
|
||||
.speaker-item.skipped {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
#3d2f1f,
|
||||
#3d2f1f 5px,
|
||||
#2d2318 5px,
|
||||
#2d2318 10px
|
||||
);
|
||||
}
|
||||
|
||||
.order {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #3d4f61;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.speaker-item.speaking .order {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.speaker-item.done .order {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #6b7280;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
}
|
||||
|
||||
.skip-btn:hover {
|
||||
opacity: 1;
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
.skip-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
372
frontend/src/components/Settings.svelte
Normal file
372
frontend/src/components/Settings.svelte
Normal file
@@ -0,0 +1,372 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime'
|
||||
import { t, locale, setLocale } from '../lib/i18n'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let settings = null
|
||||
let meeting = null
|
||||
let loading = true
|
||||
let saving = false
|
||||
let meetingLimitMin = 15
|
||||
let defaultTimeMin = 2
|
||||
let windowWidth = 800
|
||||
let windowFullHeight = true
|
||||
let audioContext = null
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||
}
|
||||
return audioContext
|
||||
}
|
||||
|
||||
function playBeep(frequency, duration, type = 'sine') {
|
||||
try {
|
||||
const ctx = getAudioContext()
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gainNode = ctx.createGain()
|
||||
|
||||
oscillator.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
oscillator.frequency.value = frequency
|
||||
oscillator.type = type
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
|
||||
|
||||
oscillator.start(ctx.currentTime)
|
||||
oscillator.stop(ctx.currentTime + duration)
|
||||
} catch (e) {
|
||||
console.error('Failed to play sound:', e)
|
||||
alert('Sound error: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function testSound(name) {
|
||||
switch (name) {
|
||||
case 'warning':
|
||||
playBeep(880, 0.15)
|
||||
setTimeout(() => playBeep(880, 0.15), 200)
|
||||
break
|
||||
case 'timeup':
|
||||
playBeep(1200, 0.2)
|
||||
setTimeout(() => playBeep(900, 0.2), 250)
|
||||
setTimeout(() => playBeep(600, 0.3), 500)
|
||||
break
|
||||
case 'meeting_end':
|
||||
playBeep(523, 0.2)
|
||||
setTimeout(() => playBeep(659, 0.2), 200)
|
||||
setTimeout(() => playBeep(784, 0.4), 400)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
try {
|
||||
console.log('Loading settings...')
|
||||
settings = await GetSettings()
|
||||
console.log('Settings loaded:', settings)
|
||||
meeting = await GetMeeting()
|
||||
console.log('Meeting loaded:', meeting)
|
||||
|
||||
if (meeting) {
|
||||
meetingLimitMin = Math.floor(meeting.timeLimit / 60)
|
||||
}
|
||||
if (settings) {
|
||||
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
|
||||
windowWidth = settings.windowWidth || 800
|
||||
windowFullHeight = settings.windowFullHeight !== false
|
||||
}
|
||||
|
||||
dispatch('loaded', settings)
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving = true
|
||||
try {
|
||||
settings.defaultParticipantTime = defaultTimeMin * 60
|
||||
meeting.timeLimit = meetingLimitMin * 60
|
||||
settings.windowWidth = Math.max(480, windowWidth)
|
||||
settings.windowFullHeight = windowFullHeight
|
||||
|
||||
await UpdateSettings(settings)
|
||||
await UpdateMeeting(meeting.name, meeting.timeLimit)
|
||||
|
||||
// Apply window size immediately
|
||||
if (windowFullHeight) {
|
||||
try {
|
||||
const screens = await ScreenGetAll()
|
||||
if (screens && screens.length > 0) {
|
||||
const primaryScreen = screens[0]
|
||||
const height = primaryScreen.size?.height || 800
|
||||
WindowSetSize(settings.windowWidth, height - 80)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get screen size:', e)
|
||||
}
|
||||
} else {
|
||||
WindowSetSize(settings.windowWidth, 600)
|
||||
}
|
||||
|
||||
dispatch('loaded', settings)
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e)
|
||||
alert('Failed to save settings: ' + e)
|
||||
}
|
||||
saving = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="settings">
|
||||
{#if loading}
|
||||
<div class="loading">{$t('common.loading')}</div>
|
||||
{:else if !meeting || !settings}
|
||||
<div class="error">Failed to load settings. Please restart the app.</div>
|
||||
{:else}
|
||||
<section>
|
||||
<h2>{$t('settings.language')}</h2>
|
||||
|
||||
<div class="field">
|
||||
<div class="language-switcher">
|
||||
<button class:active={$locale === 'ru'} on:click={() => setLocale('ru')}>🇷🇺 Русский</button>
|
||||
<button class:active={$locale === 'en'} on:click={() => setLocale('en')}>🇺🇸 English</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{$t('settings.title')}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="meetingName">{$t('setup.title')}</label>
|
||||
<input type="text" id="meetingName" bind:value={meeting.name} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="meetingLimit">{$t('setup.totalTime')} ({$t('setup.minutes')})</label>
|
||||
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{$t('setup.speakerTime')}</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="defaultTime">{$t('settings.defaultSpeakerTime')} ({$t('setup.minutes')})</label>
|
||||
<input type="number" id="defaultTime" bind:value={defaultTimeMin} min="1" max="10" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="warningThreshold">{$t('settings.warningTime')} ({$t('settings.seconds')})</label>
|
||||
<input type="number" id="warningThreshold" bind:value={settings.warningThreshold} min="5" max="120" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{$t('settings.sound')}</h2>
|
||||
|
||||
<div class="field checkbox">
|
||||
<input type="checkbox" id="soundEnabled" bind:checked={settings.soundEnabled} />
|
||||
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
|
||||
</div>
|
||||
|
||||
<div class="sound-test-buttons">
|
||||
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
|
||||
<button type="button" class="test-btn" on:click={() => testSound('timeup')}>⏰ Test Time Up</button>
|
||||
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{$t('settings.windowWidth')}</h2>
|
||||
|
||||
<div class="field checkbox">
|
||||
<input type="checkbox" id="windowFullHeight" bind:checked={windowFullHeight} />
|
||||
<label for="windowFullHeight">{$t('settings.windowFullHeight')}</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="windowWidth">{$t('settings.windowWidthHint')}</label>
|
||||
<input type="number" id="windowWidth" bind:value={windowWidth} min="480" max="1920" step="10" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="save-btn" on:click={saveSettings} disabled={saving}>
|
||||
{saving ? $t('common.loading') : $t('settings.save')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
section {
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.field.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field.checkbox input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.field.checkbox label {
|
||||
margin: 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.save-btn:hover {
|
||||
background: #3a7bc8;
|
||||
}
|
||||
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.language-switcher button {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-switcher button:hover {
|
||||
border-color: #4a90d9;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.language-switcher button.active {
|
||||
border-color: #4a90d9;
|
||||
background: #2a3a4e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sound-test-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 2px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.test-btn:hover {
|
||||
border-color: #4a90d9;
|
||||
background: #2a3a4e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.test-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
||||
826
frontend/src/components/Setup.svelte
Normal file
826
frontend/src/components/Setup.svelte
Normal file
@@ -0,0 +1,826 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let participants = []
|
||||
let meeting = null
|
||||
let selectedOrder = []
|
||||
let attendance = {}
|
||||
let loading = true
|
||||
let newName = ''
|
||||
let newTimeLimitMin = 2
|
||||
|
||||
// Edit mode
|
||||
let editingId = null
|
||||
let editName = ''
|
||||
let editTimeLimitMin = 2
|
||||
|
||||
// Meeting name editing
|
||||
let editingMeetingName = false
|
||||
let meetingNameInput = ''
|
||||
|
||||
// Meeting time editing
|
||||
let editingMeetingTime = false
|
||||
let meetingTimeInput = 60
|
||||
|
||||
onMount(async () => {
|
||||
await loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
try {
|
||||
participants = await GetParticipants()
|
||||
meeting = await GetMeeting()
|
||||
|
||||
selectedOrder = participants.map(p => p.id)
|
||||
attendance = {}
|
||||
participants.forEach(p => {
|
||||
attendance[p.id] = true
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function handleAddParticipant() {
|
||||
if (!newName.trim()) return
|
||||
|
||||
try {
|
||||
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60)
|
||||
newName = ''
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to add participant:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(id) {
|
||||
if (!confirm('Remove participant?')) return
|
||||
|
||||
try {
|
||||
await DeleteParticipant(id)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to remove participant:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(p) {
|
||||
editingId = p.id
|
||||
editName = p.name
|
||||
editTimeLimitMin = Math.floor(p.timeLimit / 60)
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null
|
||||
editName = ''
|
||||
editTimeLimitMin = 2
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editName.trim() || editingId === null) return
|
||||
|
||||
try {
|
||||
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60)
|
||||
editingId = null
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to update participant:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAttendance(id) {
|
||||
attendance[id] = !attendance[id]
|
||||
attendance = attendance
|
||||
}
|
||||
|
||||
// Drag and drop state
|
||||
let draggedId = null
|
||||
let dragOverId = null
|
||||
|
||||
function handleDragStart(e, id) {
|
||||
draggedId = id
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', id.toString())
|
||||
e.target.classList.add('dragging')
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
e.target.classList.remove('dragging')
|
||||
draggedId = null
|
||||
dragOverId = null
|
||||
}
|
||||
|
||||
function handleDragOver(e, id) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
dragOverId = id
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
dragOverId = null
|
||||
}
|
||||
|
||||
async function handleDrop(e, targetId) {
|
||||
e.preventDefault()
|
||||
if (draggedId === null || draggedId === targetId) {
|
||||
dragOverId = null
|
||||
return
|
||||
}
|
||||
|
||||
const fromIndex = selectedOrder.indexOf(draggedId)
|
||||
const toIndex = selectedOrder.indexOf(targetId)
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
selectedOrder.splice(fromIndex, 1)
|
||||
selectedOrder.splice(toIndex, 0, draggedId)
|
||||
selectedOrder = selectedOrder
|
||||
|
||||
try {
|
||||
await ReorderParticipants(selectedOrder)
|
||||
} catch (err) {
|
||||
console.error('Failed to save order:', err)
|
||||
}
|
||||
}
|
||||
|
||||
dragOverId = null
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const presentIds = selectedOrder.filter(id => attendance[id])
|
||||
if (presentIds.length === 0) {
|
||||
alert($t('setup.noParticipants'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StartMeeting(presentIds, attendance)
|
||||
dispatch('started')
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
alert('Failed to start meeting: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
function getParticipant(id) {
|
||||
return participants.find(p => p.id === id)
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function startEditMeetingName() {
|
||||
meetingNameInput = meeting?.name || ''
|
||||
editingMeetingName = true
|
||||
}
|
||||
|
||||
function cancelEditMeetingName() {
|
||||
editingMeetingName = false
|
||||
meetingNameInput = ''
|
||||
}
|
||||
|
||||
async function saveMeetingName() {
|
||||
if (!meetingNameInput.trim()) return
|
||||
try {
|
||||
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600)
|
||||
meeting = await GetMeeting()
|
||||
editingMeetingName = false
|
||||
} catch (e) {
|
||||
console.error('Failed to update meeting name:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startEditMeetingTime() {
|
||||
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60)
|
||||
editingMeetingTime = true
|
||||
}
|
||||
|
||||
function cancelEditMeetingTime() {
|
||||
editingMeetingTime = false
|
||||
}
|
||||
|
||||
async function saveMeetingTime() {
|
||||
if (meetingTimeInput < 1) return
|
||||
try {
|
||||
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60)
|
||||
meeting = await GetMeeting()
|
||||
editingMeetingTime = false
|
||||
} catch (e) {
|
||||
console.error('Failed to update meeting time:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingId !== null) cancelEdit()
|
||||
if (editingMeetingName) cancelEditMeetingName()
|
||||
if (editingMeetingTime) cancelEditMeetingTime()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleGlobalKeydown} />
|
||||
|
||||
<div class="setup">
|
||||
<div class="header">
|
||||
{#if editingMeetingName}
|
||||
<div class="meeting-name-edit">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="text"
|
||||
bind:value={meetingNameInput}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveMeetingName()
|
||||
if (e.key === 'Escape') cancelEditMeetingName()
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
<button class="save-btn" on:click={saveMeetingName}>✓</button>
|
||||
<button class="cancel-btn" on:click={cancelEditMeetingName}>✗</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<h1 on:click={startEditMeetingName} class="editable-title">
|
||||
{meeting?.name || 'Daily Standup'}
|
||||
<span class="edit-icon">✎</span>
|
||||
</h1>
|
||||
{/if}
|
||||
{#if editingMeetingTime}
|
||||
<div class="meeting-time-edit">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="number"
|
||||
bind:value={meetingTimeInput}
|
||||
min="1"
|
||||
max="480"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveMeetingTime()
|
||||
if (e.key === 'Escape') cancelEditMeetingTime()
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
<span class="time-suffix">{$t('setup.minutes')}</span>
|
||||
<button class="save-btn" on:click={saveMeetingTime}>✓</button>
|
||||
<button class="cancel-btn" on:click={cancelEditMeetingTime}>✗</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<p on:click={startEditMeetingTime} class="editable-time">
|
||||
{$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)}
|
||||
<span class="edit-icon">✎</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="add-participant">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newName}
|
||||
placeholder={$t('setup.namePlaceholder')}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleAddParticipant()}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={newTimeLimitMin}
|
||||
min="1"
|
||||
max="10"
|
||||
title="{$t('setup.speakerTime')} ({$t('setup.minutes')})"
|
||||
/>
|
||||
<span class="time-suffix">{$t('setup.minutes')}</span>
|
||||
<button on:click={handleAddParticipant}>{$t('participants.add')}</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">{$t('common.loading')}</div>
|
||||
{:else if participants.length === 0}
|
||||
<div class="empty">
|
||||
<p>{$t('setup.noParticipants')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="participant-order">
|
||||
<h3>{$t('timer.queue')}</h3>
|
||||
<p class="hint">☰ {$t('setup.dragHint')}</p>
|
||||
|
||||
<ul>
|
||||
{#each selectedOrder as id, i}
|
||||
{@const p = getParticipant(id)}
|
||||
{#if p}
|
||||
<li
|
||||
class:absent={!attendance[id]}
|
||||
class:drag-over={dragOverId === id}
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, id)}
|
||||
on:dragend={handleDragEnd}
|
||||
on:dragover={(e) => handleDragOver(e, id)}
|
||||
on:dragleave={handleDragLeave}
|
||||
on:drop={(e) => handleDrop(e, id)}
|
||||
>
|
||||
<span class="drag-handle">☰</span>
|
||||
|
||||
<span class="order-num">{i + 1}</span>
|
||||
|
||||
<button
|
||||
class="attendance-toggle"
|
||||
class:present={attendance[id]}
|
||||
on:click={() => toggleAttendance(id)}
|
||||
>
|
||||
{attendance[id] ? '✓' : '✗'}
|
||||
</button>
|
||||
|
||||
<span class="name">{p.name}</span>
|
||||
<span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span>
|
||||
|
||||
<button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}">✎</button>
|
||||
<button class="remove" on:click={() => handleRemove(id)}>×</button>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{#if editingId !== null}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="edit-modal-overlay" on:click={cancelEdit}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="edit-modal" on:click|stopPropagation>
|
||||
<h3>{$t('participants.edit')}</h3>
|
||||
<div class="edit-field">
|
||||
<label for="editName">{$t('participants.name')}</label>
|
||||
<input id="editName" type="text" bind:value={editName} on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveEdit()
|
||||
if (e.key === 'Escape') cancelEdit()
|
||||
}} />
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label for="editTime">{$t('setup.speakerTime')} ({$t('setup.minutes')})</label>
|
||||
<input id="editTime" type="number" bind:value={editTimeLimitMin} min="1" max="10" on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveEdit()
|
||||
if (e.key === 'Escape') cancelEdit()
|
||||
}} />
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="cancel-btn" on:click={cancelEdit}>{$t('common.cancel')}</button>
|
||||
<button class="save-btn" on:click={saveEdit}>{$t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="summary">
|
||||
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span>
|
||||
<span>≈ {formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
|
||||
</div>
|
||||
|
||||
<button class="start-btn" on:click={handleStart}>
|
||||
{$t('setup.startMeeting')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #e0e0e0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header h1.editable-title {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header h1.editable-title:hover {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
.header h1 .edit-icon {
|
||||
font-size: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.header h1.editable-title:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meeting-name-edit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meeting-name-edit input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #4a90d9;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.meeting-name-edit .save-btn {
|
||||
padding: 8px 12px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-name-edit .cancel-btn {
|
||||
padding: 8px 12px;
|
||||
background: #991b1b;
|
||||
color: #fca5a5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #9ca3af;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.header p.editable-time {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header p.editable-time:hover {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
.header p .edit-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.header p.editable-time:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meeting-time-edit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.meeting-time-edit input {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #4a90d9;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
width: 60px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.meeting-time-edit .time-suffix {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meeting-time-edit .save-btn {
|
||||
padding: 6px 10px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-time-edit .cancel-btn {
|
||||
padding: 6px 10px;
|
||||
background: #991b1b;
|
||||
color: #fca5a5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-participant input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 10px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.add-participant input[type="number"] {
|
||||
width: 50px;
|
||||
padding: 10px 6px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-suffix {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-participant button {
|
||||
padding: 10px 16px;
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-participant button:hover {
|
||||
background: #3a7bc8;
|
||||
}
|
||||
|
||||
.participant-order h3 {
|
||||
margin: 0 0 4px 0;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: #232f3e;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
transition: opacity 0.2s;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
li.absent {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
li.drag-over {
|
||||
border: 2px dashed #4a90d9;
|
||||
background: #2a3a4e;
|
||||
}
|
||||
|
||||
li:global(.dragging) {
|
||||
opacity: 0.5;
|
||||
background: #1b2636;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6b7280;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.order-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #4a90d9;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.attendance-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.attendance-toggle.present {
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
min-width: 80px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.edit {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit:hover {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
.remove {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Edit Modal */
|
||||
.edit-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 90%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.edit-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.edit-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.edit-field label {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.edit-field input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.edit-actions .cancel-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background: #3d4f61;
|
||||
color: #e0e0e0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-actions .cancel-btn:hover {
|
||||
background: #4d5f71;
|
||||
}
|
||||
|
||||
.edit-actions .save-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background: #4a90d9;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-actions .save-btn:hover {
|
||||
background: #3a7bc8;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: #232f3e;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: #15803d;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
464
frontend/src/components/Timer.svelte
Normal file
464
frontend/src/components/Timer.svelte
Normal file
@@ -0,0 +1,464 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
export let timerState
|
||||
|
||||
let currentTime = ''
|
||||
let clockInterval
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateClock()
|
||||
clockInterval = setInterval(updateClock, 1000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (clockInterval) clearInterval(clockInterval)
|
||||
})
|
||||
|
||||
$: speakerTime = formatTime(timerState?.speakerElapsed || 0)
|
||||
$: speakerLimit = formatTime(timerState?.speakerLimit || 0)
|
||||
$: meetingTime = formatTime(timerState?.meetingElapsed || 0)
|
||||
$: meetingLimit = formatTime(timerState?.meetingLimit || 0)
|
||||
|
||||
$: speakerProgress = timerState?.speakerLimit > 0
|
||||
? Math.min((timerState.speakerElapsed / timerState.speakerLimit) * 100, 100)
|
||||
: 0
|
||||
|
||||
$: warningZoneStart = timerState?.speakerLimit > 0 && timerState?.warningSeconds > 0
|
||||
? Math.max(0, 100 - (timerState.warningSeconds / timerState.speakerLimit) * 100)
|
||||
: 100
|
||||
|
||||
$: meetingProgress = timerState?.meetingLimit > 0
|
||||
? Math.min((timerState.meetingElapsed / timerState.meetingLimit) * 100, 100)
|
||||
: 0
|
||||
|
||||
// Yellow zone: time allocated for all speakers (as % of meeting limit)
|
||||
$: speakersZoneEnd = timerState?.meetingLimit > 0 && timerState?.totalSpeakersTime > 0
|
||||
? Math.min((timerState.totalSpeakersTime / timerState.meetingLimit) * 100, 100)
|
||||
: 0
|
||||
|
||||
// Red zone: last 10% of meeting time
|
||||
$: meetingDangerZoneStart = 90
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getTimerClass(state) {
|
||||
if (!state) return ''
|
||||
if (state.speakerOvertime) return 'overtime'
|
||||
if (state.warning) return 'warning'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="timer-container" class:warning={timerState?.warning} class:overtime={timerState?.speakerOvertime}>
|
||||
<div class="header-row">
|
||||
<div class="current-clock">{currentTime}</div>
|
||||
<div class="help-icon">
|
||||
?
|
||||
<div class="help-tooltip">
|
||||
<div class="tooltip-title">Hotkeys</div>
|
||||
<div class="tooltip-row"><span class="key">⌘N</span> Next speaker</div>
|
||||
<div class="tooltip-row"><span class="key">⌘S</span> Skip speaker</div>
|
||||
<div class="tooltip-row"><span class="key">Space</span> Pause/Resume</div>
|
||||
<div class="tooltip-row"><span class="key">⌘Q</span> Stop meeting</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speaker-name">
|
||||
{#if timerState?.currentSpeaker}
|
||||
Сейчас говорит: {timerState.currentSpeaker}
|
||||
{:else}
|
||||
{$t('timer.noSpeaker')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="speaker-section">
|
||||
|
||||
<div class="timer-display {getTimerClass(timerState)}">
|
||||
<span class="time">{speakerTime}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="limit">{speakerLimit}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="warning-zone" style="left: {warningZoneStart}%"></div>
|
||||
<div
|
||||
class="progress-fill {getTimerClass(timerState)}"
|
||||
style="width: {speakerProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{#if timerState?.speakerOvertime}
|
||||
<div class="overtime-badge">⏰</div>
|
||||
{:else if timerState?.warning}
|
||||
<div class="warning-badge">⚠️</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="meeting-section">
|
||||
<div class="meeting-label">{$t('timer.totalTime')}</div>
|
||||
<div class="meeting-time" class:overtime={timerState?.meetingOvertime}>
|
||||
{meetingTime} / {meetingLimit}
|
||||
</div>
|
||||
<div class="progress-bar small meeting-progress">
|
||||
<div class="buffer-zone" style="left: {speakersZoneEnd}%; width: {Math.max(0, meetingDangerZoneStart - speakersZoneEnd)}%"></div>
|
||||
<div class="danger-zone" style="left: {meetingDangerZoneStart}%"></div>
|
||||
<div
|
||||
class="progress-fill {timerState?.meetingOvertime ? 'overtime' : ''}"
|
||||
style="width: {meetingProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Speaker {timerState?.speakingOrder || 0} of {timerState?.totalSpeakers || 0}</span>
|
||||
{#if timerState?.paused}
|
||||
<span class="paused-badge">PAUSED</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timer-container {
|
||||
position: relative;
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: box-shadow 0.3s, background-color 0.5s;
|
||||
}
|
||||
|
||||
.timer-container.warning {
|
||||
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5), 0 0 40px rgba(251, 191, 36, 0.3);
|
||||
animation: warningPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timer-container.overtime {
|
||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6), 0 0 50px rgba(239, 68, 68, 0.4);
|
||||
animation: overtimePulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timer-container.warning::before,
|
||||
.timer-container.overtime::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.timer-container.warning::before {
|
||||
background: linear-gradient(90deg, transparent, #fbbf24, transparent);
|
||||
animation: stripeSweep 2s linear infinite;
|
||||
}
|
||||
|
||||
.timer-container.overtime::before {
|
||||
background: linear-gradient(90deg, transparent, #ef4444, transparent);
|
||||
animation: stripeSweep 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes warningPulse {
|
||||
0%, 100% {
|
||||
background-color: #232f3e;
|
||||
box-shadow: 0 0 20px rgba(251, 191, 36, 0.4), 0 0 40px rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
50% {
|
||||
background-color: #2d3a28;
|
||||
box-shadow: 0 0 30px rgba(251, 191, 36, 0.6), 0 0 60px rgba(251, 191, 36, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overtimePulse {
|
||||
0%, 100% {
|
||||
background-color: #232f3e;
|
||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.5), 0 0 50px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
50% {
|
||||
background-color: #3a2828;
|
||||
box-shadow: 0 0 35px rgba(239, 68, 68, 0.8), 0 0 70px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stripeSweep {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.current-clock {
|
||||
font-size: 14px;
|
||||
color: #8899a6;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.speaker-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.help-icon {
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
border-radius: 50%;
|
||||
background: #3d4f61;
|
||||
color: #8899a6;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.help-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 0;
|
||||
background: #1b2636;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-width: 160px;
|
||||
z-index: 100;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-icon:hover .help-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #3d4f61;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip-row .key {
|
||||
background: #3d4f61;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 10px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.speaker-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
margin-bottom: 12px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.timer-display .time {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.timer-display.warning .time {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.timer-display.overtime .time {
|
||||
color: #ef4444;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timer-display .separator {
|
||||
color: #6b7280;
|
||||
margin: 0 4px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.timer-display .limit {
|
||||
color: #6b7280;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: #3d4f61;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.warning-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(239, 68, 68, 0.3),
|
||||
rgba(239, 68, 68, 0.3) 3px,
|
||||
rgba(127, 29, 29, 0.3) 3px,
|
||||
rgba(127, 29, 29, 0.3) 6px
|
||||
);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.progress-bar.small {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.meeting-progress {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buffer-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(251, 191, 36, 0.3),
|
||||
rgba(251, 191, 36, 0.3) 2px,
|
||||
rgba(180, 130, 20, 0.3) 2px,
|
||||
rgba(180, 130, 20, 0.3) 4px
|
||||
);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(239, 68, 68, 0.4),
|
||||
rgba(239, 68, 68, 0.4) 2px,
|
||||
rgba(127, 29, 29, 0.4) 2px,
|
||||
rgba(127, 29, 29, 0.4) 4px
|
||||
);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4ade80;
|
||||
transition: width 0.1s linear, background 0.3s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-fill.warning {
|
||||
background: #fbbf24;
|
||||
}
|
||||
|
||||
.progress-fill.overtime {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.overtime-badge {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
padding: 6px 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.warning-badge {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
padding: 6px 16px;
|
||||
background: #fbbf24;
|
||||
color: #1b2636;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.meeting-section {
|
||||
padding: 16px;
|
||||
background: #1b2636;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meeting-label {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meeting-time {
|
||||
font-size: 24px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.meeting-time.overtime {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.paused-badge {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
</style>
|
||||
318
frontend/src/lib/i18n.js
Normal file
318
frontend/src/lib/i18n.js
Normal file
@@ -0,0 +1,318 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
|
||||
export const locale = writable('ru');
|
||||
|
||||
export const translations = {
|
||||
ru: {
|
||||
// Navigation
|
||||
nav: {
|
||||
timer: 'Таймер',
|
||||
setup: 'Участники',
|
||||
history: 'История',
|
||||
settings: 'Настройки',
|
||||
},
|
||||
|
||||
// Setup page
|
||||
setup: {
|
||||
title: 'Название собрания',
|
||||
participants: 'Участники',
|
||||
addParticipant: 'Добавить участника',
|
||||
namePlaceholder: 'Имя участника',
|
||||
noParticipants: 'Добавьте участников для начала собрания',
|
||||
selectAll: 'Выбрать всех',
|
||||
deselectAll: 'Снять выбор',
|
||||
startMeeting: 'Начать собрание',
|
||||
speakerTime: 'Время на спикера',
|
||||
totalTime: 'Общее время',
|
||||
minutes: 'мин',
|
||||
unlimited: 'Без ограничения',
|
||||
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
timer: {
|
||||
currentSpeaker: 'Текущий спикер',
|
||||
speakerTime: 'Время спикера',
|
||||
totalTime: 'Общее время',
|
||||
remaining: 'Осталось',
|
||||
queue: 'Очередь',
|
||||
participants: 'Участники',
|
||||
finished: 'Выступили',
|
||||
noSpeaker: 'Нет спикера',
|
||||
noActiveMeeting: 'Собрание не запущено',
|
||||
goToParticipants: 'Перейдите в раздел Участники, чтобы добавить участников',
|
||||
readyToStart: 'Всё готово к началу',
|
||||
editParticipants: 'Редактировать участников',
|
||||
noParticipants: 'Участники не добавлены',
|
||||
registeredParticipants: 'Зарегистрированные участники',
|
||||
},
|
||||
|
||||
// Controls
|
||||
controls: {
|
||||
next: 'Следующий',
|
||||
skip: 'Пропустить',
|
||||
pause: 'Пауза',
|
||||
resume: 'Продолжить',
|
||||
stop: 'Завершить',
|
||||
},
|
||||
|
||||
// History page
|
||||
history: {
|
||||
title: 'История собраний',
|
||||
noHistory: 'История пуста',
|
||||
date: 'Дата',
|
||||
duration: 'Длительность',
|
||||
participants: 'Участники',
|
||||
avgTime: 'Среднее время',
|
||||
export: 'Экспорт',
|
||||
exportJSON: 'Экспорт JSON',
|
||||
exportCSV: 'Экспорт CSV',
|
||||
delete: 'Удалить',
|
||||
deleteAll: 'Удалить историю',
|
||||
deleteSession: 'Удалить запись',
|
||||
confirmDelete: 'Удалить эту запись?',
|
||||
confirmDeleteTitle: 'Подтверждение удаления',
|
||||
confirmDeleteSession: 'Вы уверены, что хотите удалить эту запись? Действие необратимо.',
|
||||
confirmDeleteAllTitle: 'Удалить всю историю?',
|
||||
confirmDeleteAll: 'Вы уверены, что хотите удалить ВСЮ историю собраний? Это действие необратимо!',
|
||||
overtimeRate: 'Процент превышения',
|
||||
avgAttendance: 'Средняя явка',
|
||||
recentSessions: 'Последние собрания',
|
||||
noSessions: 'Собраний пока нет',
|
||||
participantBreakdown: 'Статистика по участникам',
|
||||
name: 'Имя',
|
||||
sessions: 'Собрания',
|
||||
overtime: 'Превышение',
|
||||
attendance: 'Явка',
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Настройки собрания',
|
||||
language: 'Язык',
|
||||
sound: 'Звуковые уведомления',
|
||||
soundEnabled: 'Включены',
|
||||
soundDisabled: 'Выключены',
|
||||
warningTime: 'Предупреждение за',
|
||||
seconds: 'сек',
|
||||
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
||||
defaultTotalTime: 'Общее время собрания (мин)',
|
||||
theme: 'Тема оформления',
|
||||
themeDark: 'Тёмная',
|
||||
themeLight: 'Светлая',
|
||||
save: 'Сохранить',
|
||||
saved: 'Сохранено!',
|
||||
windowWidth: 'Настройка окна',
|
||||
windowWidthHint: 'Ширина окна (мин. 480 пикселей)',
|
||||
windowFullHeight: 'Окно на всю высоту экрана',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Управление участниками',
|
||||
add: 'Добавить',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
name: 'Имя',
|
||||
stats: 'Статистика',
|
||||
avgSpeakTime: 'Среднее время выступления',
|
||||
totalMeetings: 'Всего собраний',
|
||||
confirmDelete: 'Удалить участника?',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
close: 'Закрыть',
|
||||
delete: 'Удалить',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
loading: 'Загрузка...',
|
||||
error: 'Ошибка',
|
||||
},
|
||||
|
||||
// Time formats
|
||||
time: {
|
||||
hours: 'ч',
|
||||
minutes: 'мин',
|
||||
seconds: 'сек',
|
||||
},
|
||||
},
|
||||
|
||||
en: {
|
||||
// Navigation
|
||||
nav: {
|
||||
timer: 'Timer',
|
||||
setup: 'Participants',
|
||||
history: 'History',
|
||||
settings: 'Settings',
|
||||
},
|
||||
|
||||
// Setup page
|
||||
setup: {
|
||||
title: 'New Meeting',
|
||||
participants: 'Participants',
|
||||
addParticipant: 'Add Participant',
|
||||
namePlaceholder: 'Participant name',
|
||||
noParticipants: 'Add participants to start a meeting',
|
||||
selectAll: 'Select All',
|
||||
deselectAll: 'Deselect All',
|
||||
startMeeting: 'Start Meeting',
|
||||
speakerTime: 'Speaker Time',
|
||||
totalTime: 'Total Time',
|
||||
minutes: 'min',
|
||||
unlimited: 'Unlimited',
|
||||
dragHint: 'drag to reorder, ✓/✗ attendance',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
timer: {
|
||||
currentSpeaker: 'Current Speaker',
|
||||
speakerTime: 'Speaker Time',
|
||||
totalTime: 'Total Time',
|
||||
remaining: 'Remaining',
|
||||
queue: 'Queue',
|
||||
participants: 'Participants',
|
||||
finished: 'Finished',
|
||||
noSpeaker: 'No speaker',
|
||||
noActiveMeeting: 'No active meeting',
|
||||
goToParticipants: 'Go to Participants to add participants',
|
||||
readyToStart: 'Ready to start',
|
||||
editParticipants: 'Edit participants',
|
||||
noParticipants: 'No participants added',
|
||||
registeredParticipants: 'Registered participants',
|
||||
},
|
||||
|
||||
// Controls
|
||||
controls: {
|
||||
next: 'Next',
|
||||
skip: 'Skip',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
stop: 'Stop',
|
||||
},
|
||||
|
||||
// History page
|
||||
history: {
|
||||
title: 'Meeting History',
|
||||
noHistory: 'No history yet',
|
||||
date: 'Date',
|
||||
duration: 'Duration',
|
||||
participants: 'Participants',
|
||||
avgTime: 'Avg Time',
|
||||
export: 'Export',
|
||||
exportJSON: 'Export JSON',
|
||||
exportCSV: 'Export CSV',
|
||||
delete: 'Delete',
|
||||
deleteAll: 'Delete History',
|
||||
deleteSession: 'Delete session',
|
||||
confirmDelete: 'Delete this record?',
|
||||
confirmDeleteTitle: 'Confirm Deletion',
|
||||
confirmDeleteSession: 'Are you sure you want to delete this session? This action cannot be undone.',
|
||||
confirmDeleteAllTitle: 'Delete All History?',
|
||||
confirmDeleteAll: 'Are you sure you want to delete ALL meeting history? This action cannot be undone!',
|
||||
overtimeRate: 'Overtime Rate',
|
||||
avgAttendance: 'Avg. Attendance',
|
||||
recentSessions: 'Recent Sessions',
|
||||
noSessions: 'No sessions yet',
|
||||
participantBreakdown: 'Participant Breakdown',
|
||||
name: 'Name',
|
||||
sessions: 'Sessions',
|
||||
overtime: 'Overtime',
|
||||
attendance: 'Attendance',
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
title: 'Meeting Settings',
|
||||
language: 'Language',
|
||||
sound: 'Sound Notifications',
|
||||
soundEnabled: 'Enabled',
|
||||
soundDisabled: 'Disabled',
|
||||
warningTime: 'Warning before',
|
||||
seconds: 'sec',
|
||||
defaultSpeakerTime: 'Default Speaker Time',
|
||||
defaultTotalTime: 'Total meeting time (min)',
|
||||
theme: 'Theme',
|
||||
themeDark: 'Dark',
|
||||
themeLight: 'Light',
|
||||
save: 'Save',
|
||||
saved: 'Saved!',
|
||||
windowWidth: 'Window Settings',
|
||||
windowWidthHint: 'Window width (min. 480 pixels)',
|
||||
windowFullHeight: 'Full screen height window',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Manage Participants',
|
||||
add: 'Add',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
name: 'Name',
|
||||
stats: 'Statistics',
|
||||
avgSpeakTime: 'Avg Speaking Time',
|
||||
totalMeetings: 'Total Meetings',
|
||||
confirmDelete: 'Delete participant?',
|
||||
},
|
||||
|
||||
// Common
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
loading: 'Loading...',
|
||||
error: 'Error',
|
||||
},
|
||||
|
||||
// Time formats
|
||||
time: {
|
||||
hours: 'h',
|
||||
minutes: 'min',
|
||||
seconds: 'sec',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const t = derived(locale, ($locale) => {
|
||||
return (key) => {
|
||||
const keys = key.split('.');
|
||||
let value = translations[$locale];
|
||||
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
console.warn(`Translation missing: ${key} for locale ${$locale}`);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
});
|
||||
|
||||
export function setLocale(lang) {
|
||||
if (translations[lang]) {
|
||||
locale.set(lang);
|
||||
localStorage.setItem('daily-timer-locale', lang);
|
||||
}
|
||||
}
|
||||
|
||||
export function initLocale() {
|
||||
const saved = localStorage.getItem('daily-timer-locale');
|
||||
if (saved && translations[saved]) {
|
||||
locale.set(saved);
|
||||
} else {
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (translations[browserLang]) {
|
||||
locale.set(browserLang);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
});
|
||||
|
||||
export default app;
|
||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal 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
53
frontend/wailsjs/go/app/App.d.ts
vendored
Executable 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
103
frontend/wailsjs/go/app/App.js
Executable 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
434
frontend/wailsjs/go/models.ts
Executable 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
frontend/wailsjs/runtime/package.json
Normal file
24
frontend/wailsjs/runtime/package.json
Normal 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
249
frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal 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
|
||||
242
frontend/wailsjs/runtime/runtime.js
Normal file
242
frontend/wailsjs/runtime/runtime.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user