feat: initial daily-timer implementation
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Daily Timer build outputs
|
||||||
|
build/bin/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go coverage
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Local development
|
||||||
|
.env.local
|
||||||
21
.golangci.yml
Normal file
21
.golangci.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# golangci-lint configuration
|
||||||
|
# https://golangci-lint.run/usage/configuration/
|
||||||
|
|
||||||
|
version: '2'
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- staticcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- unused
|
||||||
|
- misspell
|
||||||
|
- unconvert
|
||||||
|
- goconst
|
||||||
|
- gocyclo
|
||||||
|
- dupl
|
||||||
74
Makefile
Normal file
74
Makefile
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.PHONY: dev build clean install frontend
|
||||||
|
|
||||||
|
# Development
|
||||||
|
dev:
|
||||||
|
wails dev
|
||||||
|
|
||||||
|
# Build for macOS
|
||||||
|
build:
|
||||||
|
wails build -clean
|
||||||
|
|
||||||
|
# Build for macOS (universal binary)
|
||||||
|
build-universal:
|
||||||
|
wails build -clean -platform darwin/universal
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
frontend:
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# Generate Go bindings
|
||||||
|
generate:
|
||||||
|
wails generate module
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
rm -rf build/bin
|
||||||
|
rm -rf frontend/dist
|
||||||
|
rm -rf frontend/node_modules
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
cd frontend && npm run format 2>/dev/null || true
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
deps:
|
||||||
|
go mod download
|
||||||
|
go mod tidy
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# Initialize project (first time setup)
|
||||||
|
init: deps frontend
|
||||||
|
@echo "Project initialized. Run 'make dev' to start development."
|
||||||
|
|
||||||
|
# Help
|
||||||
|
help:
|
||||||
|
@echo "Daily Timer - Makefile Commands"
|
||||||
|
@echo ""
|
||||||
|
@echo "Development:"
|
||||||
|
@echo " make dev - Start development server with hot reload"
|
||||||
|
@echo " make build - Build production binary for macOS"
|
||||||
|
@echo " make build-universal - Build universal binary (Intel + Apple Silicon)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Setup:"
|
||||||
|
@echo " make init - Initialize project (install all dependencies)"
|
||||||
|
@echo " make deps - Install Go and frontend dependencies"
|
||||||
|
@echo " make frontend - Install frontend dependencies only"
|
||||||
|
@echo ""
|
||||||
|
@echo "Maintenance:"
|
||||||
|
@echo " make clean - Remove build artifacts"
|
||||||
|
@echo " make fmt - Format Go and frontend code"
|
||||||
|
@echo " make lint - Run linters"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo ""
|
||||||
|
@echo "Misc:"
|
||||||
|
@echo " make generate - Regenerate Wails bindings"
|
||||||
|
@echo " make help - Show this help"
|
||||||
160
README.md
160
README.md
@@ -0,0 +1,160 @@
|
|||||||
|
# Daily Timer
|
||||||
|
|
||||||
|
A desktop application for managing daily standup meetings with time tracking, participant management, and statistics.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ⏱️ **Timer Display** - Large, visible timer for current speaker with progress bar
|
||||||
|
- 👥 **Participant Management** - Predefined list with configurable time limits per person
|
||||||
|
- 🔄 **Speaking Queue** - Visual queue with ability to reorder, skip, or move speakers
|
||||||
|
- ⏭️ **Skip Function** - Move unprepared participants to end of queue
|
||||||
|
- ✅ **Attendance Tracking** - Mark participants as present/absent before meeting
|
||||||
|
- ⚠️ **Time Warnings** - Visual and audio alerts when time is running out
|
||||||
|
- 📊 **Statistics** - Track meeting duration, overtime, and individual performance
|
||||||
|
- 📈 **History** - View past meetings with detailed breakdowns
|
||||||
|
- 💾 **Export** - Export data in JSON or CSV format
|
||||||
|
- 🔊 **Sound Notifications** - Configurable audio alerts
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_(Coming soon)_
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- macOS 10.15+ (Catalina or later)
|
||||||
|
- Go 1.21+
|
||||||
|
- Node.js 18+
|
||||||
|
- Wails CLI v2
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Install Wails CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clone and Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-username/daily-timer.git
|
||||||
|
cd daily-timer
|
||||||
|
|
||||||
|
# Initialize project
|
||||||
|
make init
|
||||||
|
|
||||||
|
# Development mode
|
||||||
|
make dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
The built application will be in `build/bin/`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Setup Meeting
|
||||||
|
|
||||||
|
1. **Add Participants** - Enter names and set individual time limits
|
||||||
|
2. **Set Meeting Limit** - Configure total meeting duration (default: 15 minutes)
|
||||||
|
3. **Arrange Order** - Drag to reorder speaking sequence
|
||||||
|
4. **Mark Attendance** - Toggle present/absent status
|
||||||
|
|
||||||
|
### During Meeting
|
||||||
|
|
||||||
|
1. Click **Start Meeting** to begin
|
||||||
|
2. Timer shows current speaker with countdown
|
||||||
|
3. Click **Next Speaker** to advance (or press ⌘N)
|
||||||
|
4. Click **Skip** to move current speaker to end of queue
|
||||||
|
5. Use **Pause/Resume** for interruptions
|
||||||
|
6. Click **Stop** to end meeting early
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ----- | ------------ |
|
||||||
|
| ⌘N | Next Speaker |
|
||||||
|
| ⌘S | Skip Speaker |
|
||||||
|
| Space | Pause/Resume |
|
||||||
|
| ⌘Q | Stop Meeting |
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
|
||||||
|
- View historical meeting data in the **History** tab
|
||||||
|
- Filter by date range
|
||||||
|
- Export to JSON or CSV for external analysis
|
||||||
|
- Track participant overtime frequency and average speaking time
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are stored in `~/Library/Application Support/DailyTimer/`:
|
||||||
|
|
||||||
|
- `daily-timer.db` - SQLite database with all data
|
||||||
|
- `sounds/` - Custom sound files (optional)
|
||||||
|
|
||||||
|
### Custom Sounds
|
||||||
|
|
||||||
|
Place custom MP3 files in the sounds directory:
|
||||||
|
|
||||||
|
- `warning.mp3` - Plays when time is running low
|
||||||
|
- `timeup.mp3` - Plays when speaker time expires
|
||||||
|
- `meeting_end.mp3` - Plays when meeting ends
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
daily-timer/
|
||||||
|
├── main.go # Wails application entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── app/ # Application logic and Wails bindings
|
||||||
|
│ ├── models/ # Data models
|
||||||
|
│ ├── storage/ # SQLite database layer
|
||||||
|
│ └── timer/ # Timer logic with events
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.svelte # Main application
|
||||||
|
│ │ └── components/ # UI components
|
||||||
|
│ └── wailsjs/ # Generated Go bindings
|
||||||
|
├── wails.json # Wails configuration
|
||||||
|
├── Makefile # Build commands
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev # Start development with hot reload
|
||||||
|
make build # Build production binary
|
||||||
|
make test # Run tests
|
||||||
|
make fmt # Format code
|
||||||
|
make lint # Run linters
|
||||||
|
make clean # Clean build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Drag-and-drop participant reordering
|
||||||
|
- [ ] Telegram integration (send meeting summary)
|
||||||
|
- [ ] Calendar integration (auto-schedule)
|
||||||
|
- [ ] Team templates
|
||||||
|
- [ ] Cloud sync
|
||||||
|
- [ ] Windows/Linux support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Run tests and linting
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and feature requests, please use GitHub Issues.
|
||||||
|
|||||||
BIN
build/appicon.png
Normal file
BIN
build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
68
build/darwin/Info.dev.plist
Normal file
68
build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>{{.Info.ProductName}}</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>{{.OutputFilename}}</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.wails.{{.Name}}</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>{{.Info.Comments}}</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>{{.Info.ProductVersion}}</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>iconfile</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.13.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
{{if .Info.FileAssociations}}
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.FileAssociations}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Ext}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>{{.Name}}</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
<key>CFBundleTypeIconFile</key>
|
||||||
|
<string>{{.IconName}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
{{if .Info.Protocols}}
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
{{range .Info.Protocols}}
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.wails.{{.Scheme}}</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>{{.Scheme}}</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>{{.Role}}</string>
|
||||||
|
</dict>
|
||||||
|
{{end}}
|
||||||
|
</array>
|
||||||
|
{{end}}
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
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);
|
||||||
|
}
|
||||||
42
go.mod
Normal file
42
go.mod
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
module daily-timer
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0
|
||||||
|
gorm.io/driver/sqlite v1.5.6
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||||
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/u v1.1.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/samber/lo v1.49.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
|
golang.org/x/net v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
golang.org/x/text v0.22.0 // indirect
|
||||||
|
)
|
||||||
91
go.sum
Normal file
91
go.sum
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||||
|
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
|
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||||
|
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||||
|
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||||
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
|
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||||
|
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||||
|
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||||
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||||
|
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||||
|
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||||
|
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||||
|
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||||
|
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||||
|
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||||
|
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
|
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||||
|
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
481
internal/app/app.go
Normal file
481
internal/app/app.go
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daily-timer/internal/models"
|
||||||
|
"daily-timer/internal/storage"
|
||||||
|
"daily-timer/internal/timer"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
|
store *storage.Storage
|
||||||
|
timer *timer.Timer
|
||||||
|
session *models.MeetingSession
|
||||||
|
currentLogs map[uint]*models.ParticipantLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(store *storage.Storage) *App {
|
||||||
|
return &App{
|
||||||
|
store: store,
|
||||||
|
currentLogs: make(map[uint]*models.ParticipantLog),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) OnDomReady(ctx context.Context) {
|
||||||
|
runtime.WindowShow(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.Close()
|
||||||
|
}
|
||||||
|
if a.store != nil {
|
||||||
|
_ = a.store.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
|
||||||
|
func (a *App) GetParticipants() ([]models.Participant, error) {
|
||||||
|
return a.store.GetParticipants()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.Participant, error) {
|
||||||
|
participants, _ := a.store.GetAllParticipants()
|
||||||
|
order := len(participants)
|
||||||
|
|
||||||
|
p := &models.Participant{
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
TimeLimit: timeLimit,
|
||||||
|
Order: order,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.store.CreateParticipant(p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int) error {
|
||||||
|
p := &models.Participant{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
TimeLimit: timeLimit,
|
||||||
|
}
|
||||||
|
return a.store.UpdateParticipant(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteParticipant(id uint) error {
|
||||||
|
return a.store.DeleteParticipant(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ReorderParticipants(ids []uint) error {
|
||||||
|
return a.store.ReorderParticipants(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meeting
|
||||||
|
|
||||||
|
func (a *App) GetMeeting() (*models.Meeting, error) {
|
||||||
|
return a.store.GetMeeting()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UpdateMeeting(name string, timeLimit int) error {
|
||||||
|
meeting, err := a.store.GetMeeting()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
meeting.Name = name
|
||||||
|
meeting.TimeLimit = timeLimit
|
||||||
|
return a.store.UpdateMeeting(meeting)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer Controls
|
||||||
|
|
||||||
|
func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) error {
|
||||||
|
meeting, err := a.store.GetMeeting()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, _ := a.store.GetSettings()
|
||||||
|
warningThreshold := 30
|
||||||
|
if settings != nil {
|
||||||
|
warningThreshold = settings.WarningThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := a.store.CreateSession(meeting.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.session = session
|
||||||
|
|
||||||
|
for participantID, present := range attendance {
|
||||||
|
_ = a.store.SetAttendance(session.ID, participantID, present, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
participants, _ := a.store.GetParticipants()
|
||||||
|
participantMap := make(map[uint]models.Participant)
|
||||||
|
for _, p := range participants {
|
||||||
|
participantMap[p.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
queue := make([]models.QueuedSpeaker, 0, len(participantOrder))
|
||||||
|
for i, id := range participantOrder {
|
||||||
|
if p, ok := participantMap[id]; ok {
|
||||||
|
if present, ok := attendance[id]; ok && present {
|
||||||
|
queue = append(queue, models.QueuedSpeaker{
|
||||||
|
ID: p.ID,
|
||||||
|
Name: p.Name,
|
||||||
|
TimeLimit: p.TimeLimit,
|
||||||
|
Order: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.timer = timer.New(meeting.TimeLimit, warningThreshold)
|
||||||
|
a.timer.SetQueue(queue)
|
||||||
|
a.currentLogs = make(map[uint]*models.ParticipantLog)
|
||||||
|
go a.handleTimerEvents()
|
||||||
|
a.timer.Start()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleTimerEvents() {
|
||||||
|
for event := range a.timer.Events() {
|
||||||
|
switch event.Type {
|
||||||
|
case timer.EventTick:
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:tick", event.State)
|
||||||
|
case timer.EventSpeakerWarning:
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:speaker_warning", event.State)
|
||||||
|
case timer.EventSpeakerTimeUp:
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:speaker_timeup", event.State)
|
||||||
|
case timer.EventMeetingWarning:
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:meeting_warning", event.State)
|
||||||
|
case timer.EventMeetingTimeUp:
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
|
||||||
|
case timer.EventSpeakerChanged:
|
||||||
|
a.saveSpeakerLog(event.State)
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
||||||
|
case timer.EventMeetingEnded:
|
||||||
|
a.saveSpeakerLog(event.State)
|
||||||
|
a.endMeetingSession(event.State)
|
||||||
|
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) saveSpeakerLog(state models.TimerState) {
|
||||||
|
if a.session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, log := range a.currentLogs {
|
||||||
|
if log.EndedAt == nil {
|
||||||
|
now := time.Now()
|
||||||
|
log.EndedAt = &now
|
||||||
|
log.Duration = int(now.Sub(log.StartedAt).Seconds())
|
||||||
|
|
||||||
|
participants, _ := a.store.GetParticipants()
|
||||||
|
for _, p := range participants {
|
||||||
|
if p.ID == id {
|
||||||
|
log.Overtime = log.Duration > p.TimeLimit
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = a.store.UpdateParticipantLog(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.CurrentSpeakerID > 0 {
|
||||||
|
log := &models.ParticipantLog{
|
||||||
|
SessionID: a.session.ID,
|
||||||
|
ParticipantID: state.CurrentSpeakerID,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Order: state.SpeakingOrder,
|
||||||
|
}
|
||||||
|
_ = a.store.CreateParticipantLog(log)
|
||||||
|
a.currentLogs[state.CurrentSpeakerID] = log
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) endMeetingSession(state models.TimerState) {
|
||||||
|
if a.session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.store.EndSession(a.session.ID, state.MeetingElapsed)
|
||||||
|
a.session = nil
|
||||||
|
a.currentLogs = make(map[uint]*models.ParticipantLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) NextSpeaker() {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.NextSpeaker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SkipSpeaker() {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.SkipSpeaker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RemoveFromQueue(speakerID uint) {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.RemoveFromQueue(speakerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PauseMeeting() {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.Pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ResumeMeeting() {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.Resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) StopMeeting() {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetTimerState() *models.TimerState {
|
||||||
|
if a.timer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state := a.timer.GetState()
|
||||||
|
return &state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
|
||||||
|
func (a *App) GetSettings() (*models.Settings, error) {
|
||||||
|
return a.store.GetSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) UpdateSettings(settings *models.Settings) error {
|
||||||
|
return a.store.UpdateSettings(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History & Statistics
|
||||||
|
|
||||||
|
func (a *App) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
|
||||||
|
return a.store.GetSessions(limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetSession(id uint) (*models.MeetingSession, error) {
|
||||||
|
return a.store.GetSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteSession(id uint) error {
|
||||||
|
return a.store.DeleteSession(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteAllSessions() error {
|
||||||
|
return a.store.DeleteAllSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) GetStatistics(fromStr, toStr string) (*models.AggregatedStats, error) {
|
||||||
|
from, err := time.Parse("2006-01-02", fromStr)
|
||||||
|
if err != nil {
|
||||||
|
from = time.Now().AddDate(0, -1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
to, err := time.Parse("2006-01-02", toStr)
|
||||||
|
if err != nil {
|
||||||
|
to = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.store.GetAggregatedStats(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportData(fromStr, toStr string) (string, error) {
|
||||||
|
from, _ := time.Parse("2006-01-02", fromStr)
|
||||||
|
to, _ := time.Parse("2006-01-02", toStr)
|
||||||
|
|
||||||
|
if from.IsZero() {
|
||||||
|
from = time.Now().AddDate(-1, 0, 0)
|
||||||
|
}
|
||||||
|
if to.IsZero() {
|
||||||
|
to = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
participants, err := a.store.GetAllParticipants()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := a.store.GetSessions(1000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
meeting, _ := a.store.GetMeeting()
|
||||||
|
sessionStats := make([]models.SessionStats, 0, len(sessions))
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.StartedAt.Before(from) || s.StartedAt.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := models.SessionStats{
|
||||||
|
SessionID: s.ID,
|
||||||
|
Date: s.StartedAt.Format("2006-01-02 15:04"),
|
||||||
|
TotalDuration: s.TotalDuration,
|
||||||
|
MeetingLimit: meeting.TimeLimit,
|
||||||
|
Overtime: s.TotalDuration > meeting.TimeLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, log := range s.ParticipantLogs {
|
||||||
|
stats.ParticipantStats = append(stats.ParticipantStats, models.ParticipantStats{
|
||||||
|
ParticipantID: log.ParticipantID,
|
||||||
|
Name: log.Participant.Name,
|
||||||
|
Duration: log.Duration,
|
||||||
|
TimeLimit: log.Participant.TimeLimit,
|
||||||
|
Overtime: log.Overtime,
|
||||||
|
Skipped: log.Skipped,
|
||||||
|
SpeakingOrder: log.Order,
|
||||||
|
})
|
||||||
|
if log.Overtime {
|
||||||
|
stats.OvertimeCount++
|
||||||
|
}
|
||||||
|
if log.Skipped {
|
||||||
|
stats.SkippedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, att := range s.Attendance {
|
||||||
|
if att.Present {
|
||||||
|
stats.PresentCount++
|
||||||
|
} else {
|
||||||
|
stats.AbsentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.ParticipantCount = stats.PresentCount + stats.AbsentCount
|
||||||
|
|
||||||
|
sessionStats = append(sessionStats, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggStats, _ := a.store.GetAggregatedStats(from, to)
|
||||||
|
|
||||||
|
exportData := models.ExportData{
|
||||||
|
ExportedAt: time.Now().Format(time.RFC3339),
|
||||||
|
Participants: participants,
|
||||||
|
Sessions: sessionStats,
|
||||||
|
Statistics: *aggStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(exportData, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("daily-timer-export-%s.json", time.Now().Format("2006-01-02"))
|
||||||
|
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: filename,
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{DisplayName: "JSON Files", Pattern: "*.json"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if savePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(savePath, data, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return savePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ExportCSV(fromStr, toStr string) (string, error) {
|
||||||
|
from, _ := time.Parse("2006-01-02", fromStr)
|
||||||
|
to, _ := time.Parse("2006-01-02", toStr)
|
||||||
|
|
||||||
|
if from.IsZero() {
|
||||||
|
from = time.Now().AddDate(-1, 0, 0)
|
||||||
|
}
|
||||||
|
if to.IsZero() {
|
||||||
|
to = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := a.store.GetSessions(1000, 0)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
csv := "Date,Participant,Duration (s),Time Limit (s),Overtime,Skipped,Speaking Order\n"
|
||||||
|
|
||||||
|
for _, s := range sessions {
|
||||||
|
if s.StartedAt.Before(from) || s.StartedAt.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
date := s.StartedAt.Format("2006-01-02")
|
||||||
|
|
||||||
|
for _, log := range s.ParticipantLogs {
|
||||||
|
csv += fmt.Sprintf("%s,%s,%d,%d,%t,%t,%d\n",
|
||||||
|
date,
|
||||||
|
log.Participant.Name,
|
||||||
|
log.Duration,
|
||||||
|
log.Participant.TimeLimit,
|
||||||
|
log.Overtime,
|
||||||
|
log.Skipped,
|
||||||
|
log.Order,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("daily-timer-export-%s.csv", time.Now().Format("2006-01-02"))
|
||||||
|
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||||
|
DefaultFilename: filename,
|
||||||
|
Filters: []runtime.FileFilter{
|
||||||
|
{DisplayName: "CSV Files", Pattern: "*.csv"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if savePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(savePath, []byte(csv), 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return savePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sound
|
||||||
|
|
||||||
|
func (a *App) GetSoundsDir() string {
|
||||||
|
configDir, _ := os.UserConfigDir()
|
||||||
|
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
|
||||||
|
_ = os.MkdirAll(soundsDir, 0755)
|
||||||
|
return soundsDir
|
||||||
|
}
|
||||||
72
internal/models/models.go
Normal file
72
internal/models/models.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Participant struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"not null"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
TimeLimit int `json:"timeLimit" gorm:"default:120"` // seconds
|
||||||
|
Order int `json:"order" gorm:"default:0"`
|
||||||
|
Active bool `json:"active" gorm:"default:true"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meeting struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"not null;default:Daily Standup"`
|
||||||
|
TimeLimit int `json:"timeLimit" gorm:"default:3600"` // total meeting limit in seconds (1 hour)
|
||||||
|
Sessions []MeetingSession `json:"sessions,omitempty" gorm:"foreignKey:MeetingID"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetingSession struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
MeetingID uint `json:"meetingId" gorm:"not null"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
|
TotalDuration int `json:"totalDuration"` // seconds
|
||||||
|
Completed bool `json:"completed" gorm:"default:false"`
|
||||||
|
ParticipantLogs []ParticipantLog `json:"participantLogs,omitempty" gorm:"foreignKey:SessionID"`
|
||||||
|
Attendance []SessionAttendance `json:"attendance,omitempty" gorm:"foreignKey:SessionID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantLog struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
SessionID uint `json:"sessionId" gorm:"not null"`
|
||||||
|
ParticipantID uint `json:"participantId" gorm:"not null"`
|
||||||
|
Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
|
||||||
|
StartedAt time.Time `json:"startedAt"`
|
||||||
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
|
Duration int `json:"duration"` // seconds
|
||||||
|
Skipped bool `json:"skipped" gorm:"default:false"`
|
||||||
|
Overtime bool `json:"overtime" gorm:"default:false"`
|
||||||
|
Order int `json:"order"` // speaking order in session
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionAttendance struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
SessionID uint `json:"sessionId" gorm:"not null"`
|
||||||
|
ParticipantID uint `json:"participantId" gorm:"not null"`
|
||||||
|
Participant Participant `json:"participant,omitempty" gorm:"foreignKey:ParticipantID"`
|
||||||
|
Present bool `json:"present" gorm:"default:true"`
|
||||||
|
JoinedLate bool `json:"joinedLate" gorm:"default:false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
DefaultParticipantTime int `json:"defaultParticipantTime" gorm:"default:120"` // seconds
|
||||||
|
DefaultMeetingTime int `json:"defaultMeetingTime" gorm:"default:900"` // seconds
|
||||||
|
SoundEnabled bool `json:"soundEnabled" gorm:"default:true"`
|
||||||
|
SoundWarning string `json:"soundWarning" gorm:"default:warning.mp3"`
|
||||||
|
SoundTimeUp string `json:"soundTimeUp" gorm:"default:timeup.mp3"`
|
||||||
|
SoundMeetingEnd string `json:"soundMeetingEnd" gorm:"default:meeting_end.mp3"`
|
||||||
|
WarningThreshold int `json:"warningThreshold" gorm:"default:30"` // seconds before time up
|
||||||
|
Theme string `json:"theme" gorm:"default:dark"`
|
||||||
|
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
||||||
|
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
|
||||||
|
}
|
||||||
98
internal/models/types.go
Normal file
98
internal/models/types.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type SpeakerStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpeakerStatusPending SpeakerStatus = "pending"
|
||||||
|
SpeakerStatusSpeaking SpeakerStatus = "speaking"
|
||||||
|
SpeakerStatusDone SpeakerStatus = "done"
|
||||||
|
SpeakerStatusSkipped SpeakerStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimerState struct {
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
CurrentSpeakerID uint `json:"currentSpeakerId"`
|
||||||
|
CurrentSpeaker string `json:"currentSpeaker"`
|
||||||
|
SpeakerElapsed int `json:"speakerElapsed"` // seconds
|
||||||
|
SpeakerLimit int `json:"speakerLimit"` // seconds
|
||||||
|
MeetingElapsed int `json:"meetingElapsed"` // seconds
|
||||||
|
MeetingLimit int `json:"meetingLimit"` // seconds
|
||||||
|
SpeakerOvertime bool `json:"speakerOvertime"`
|
||||||
|
MeetingOvertime bool `json:"meetingOvertime"`
|
||||||
|
Warning bool `json:"warning"`
|
||||||
|
WarningSeconds int `json:"warningSeconds"` // seconds before end for warning
|
||||||
|
TotalSpeakersTime int `json:"totalSpeakersTime"` // sum of all speaker limits
|
||||||
|
SpeakingOrder int `json:"speakingOrder"`
|
||||||
|
TotalSpeakers int `json:"totalSpeakers"`
|
||||||
|
RemainingQueue []QueuedSpeaker `json:"remainingQueue"`
|
||||||
|
AllSpeakers []SpeakerInfo `json:"allSpeakers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeakerInfo struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TimeLimit int `json:"timeLimit"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
Status SpeakerStatus `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedSpeaker struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TimeLimit int `json:"timeLimit"`
|
||||||
|
Order int `json:"order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStats struct {
|
||||||
|
SessionID uint `json:"sessionId"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
TotalDuration int `json:"totalDuration"`
|
||||||
|
MeetingLimit int `json:"meetingLimit"`
|
||||||
|
Overtime bool `json:"overtime"`
|
||||||
|
ParticipantCount int `json:"participantCount"`
|
||||||
|
PresentCount int `json:"presentCount"`
|
||||||
|
AbsentCount int `json:"absentCount"`
|
||||||
|
OvertimeCount int `json:"overtimeCount"`
|
||||||
|
SkippedCount int `json:"skippedCount"`
|
||||||
|
ParticipantStats []ParticipantStats `json:"participantStats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantStats struct {
|
||||||
|
ParticipantID uint `json:"participantId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
TimeLimit int `json:"timeLimit"`
|
||||||
|
Overtime bool `json:"overtime"`
|
||||||
|
Skipped bool `json:"skipped"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
SpeakingOrder int `json:"speakingOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AggregatedStats struct {
|
||||||
|
TotalSessions int `json:"totalSessions"`
|
||||||
|
TotalMeetingTime int `json:"totalMeetingTime"`
|
||||||
|
AverageMeetingTime float64 `json:"averageMeetingTime"`
|
||||||
|
OvertimeSessions int `json:"overtimeSessions"`
|
||||||
|
OvertimePercentage float64 `json:"overtimePercentage"`
|
||||||
|
AverageAttendance float64 `json:"averageAttendance"`
|
||||||
|
ParticipantBreakdown []ParticipantBreakdown `json:"participantBreakdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantBreakdown struct {
|
||||||
|
ParticipantID uint `json:"participantId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SessionsAttended int `json:"sessionsAttended"`
|
||||||
|
TotalSpeakingTime int `json:"totalSpeakingTime"`
|
||||||
|
AverageSpeakingTime float64 `json:"averageSpeakingTime"`
|
||||||
|
OvertimeCount int `json:"overtimeCount"`
|
||||||
|
SkipCount int `json:"skipCount"`
|
||||||
|
AttendanceRate float64 `json:"attendanceRate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportData struct {
|
||||||
|
ExportedAt string `json:"exportedAt"`
|
||||||
|
Participants []Participant `json:"participants"`
|
||||||
|
Sessions []SessionStats `json:"sessions"`
|
||||||
|
Statistics AggregatedStats `json:"statistics"`
|
||||||
|
}
|
||||||
331
internal/storage/storage.go
Normal file
331
internal/storage/storage.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daily-timer/internal/models"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() (*Storage, error) {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
configDir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Join(configDir, "DailyTimer")
|
||||||
|
if err := os.MkdirAll(appDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(appDir, "daily-timer.db")
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&models.Participant{},
|
||||||
|
&models.Meeting{},
|
||||||
|
&models.MeetingSession{},
|
||||||
|
&models.ParticipantLog{},
|
||||||
|
&models.SessionAttendance{},
|
||||||
|
&models.Settings{},
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Storage{db: db}
|
||||||
|
if err := s.ensureDefaults(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ensureDefaults() error {
|
||||||
|
var settings models.Settings
|
||||||
|
result := s.db.First(&settings)
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
settings = models.Settings{
|
||||||
|
DefaultParticipantTime: 120,
|
||||||
|
DefaultMeetingTime: 3600,
|
||||||
|
SoundEnabled: true,
|
||||||
|
SoundWarning: "warning",
|
||||||
|
SoundTimeUp: "timeup",
|
||||||
|
SoundMeetingEnd: "meeting_end",
|
||||||
|
WarningThreshold: 30,
|
||||||
|
Theme: "dark",
|
||||||
|
WindowWidth: 800,
|
||||||
|
WindowFullHeight: true,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&settings).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create default settings: %w", err)
|
||||||
|
}
|
||||||
|
} else if settings.DefaultMeetingTime == 900 {
|
||||||
|
// Migrate old default value to new default (60 min instead of 15)
|
||||||
|
s.db.Model(&settings).Update("default_meeting_time", 3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
var meeting models.Meeting
|
||||||
|
result = s.db.First(&meeting)
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
meeting = models.Meeting{
|
||||||
|
Name: "Daily Standup",
|
||||||
|
TimeLimit: 3600,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&meeting).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create default meeting: %w", err)
|
||||||
|
}
|
||||||
|
} else if meeting.TimeLimit == 900 {
|
||||||
|
// Migrate old default value to new default (60 min instead of 15)
|
||||||
|
s.db.Model(&meeting).Update("time_limit", 3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
func (s *Storage) GetParticipants() ([]models.Participant, error) {
|
||||||
|
var participants []models.Participant
|
||||||
|
err := s.db.Where("active = ?", true).Order("\"order\" ASC").Find(&participants).Error
|
||||||
|
return participants, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetAllParticipants() ([]models.Participant, error) {
|
||||||
|
var participants []models.Participant
|
||||||
|
err := s.db.Order("\"order\" ASC").Find(&participants).Error
|
||||||
|
return participants, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateParticipant(p *models.Participant) error {
|
||||||
|
return s.db.Create(p).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateParticipant(p *models.Participant) error {
|
||||||
|
return s.db.Model(&models.Participant{}).Where("id = ?", p.ID).Updates(map[string]interface{}{
|
||||||
|
"name": p.Name,
|
||||||
|
"email": p.Email,
|
||||||
|
"time_limit": p.TimeLimit,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteParticipant(id uint) error {
|
||||||
|
return s.db.Model(&models.Participant{}).Where("id = ?", id).Update("active", false).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ReorderParticipants(ids []uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for i, id := range ids {
|
||||||
|
if err := tx.Model(&models.Participant{}).Where("id = ?", id).Update("\"order\"", i).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meetings
|
||||||
|
func (s *Storage) GetMeeting() (*models.Meeting, error) {
|
||||||
|
var meeting models.Meeting
|
||||||
|
err := s.db.First(&meeting).Error
|
||||||
|
return &meeting, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateMeeting(m *models.Meeting) error {
|
||||||
|
return s.db.Save(m).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
func (s *Storage) CreateSession(meetingID uint) (*models.MeetingSession, error) {
|
||||||
|
session := &models.MeetingSession{
|
||||||
|
MeetingID: meetingID,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.Create(session).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) EndSession(sessionID uint, totalDuration int) error {
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.Model(&models.MeetingSession{}).Where("id = ?", sessionID).Updates(map[string]interface{}{
|
||||||
|
"ended_at": now,
|
||||||
|
"total_duration": totalDuration,
|
||||||
|
"completed": true,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetSession(id uint) (*models.MeetingSession, error) {
|
||||||
|
var session models.MeetingSession
|
||||||
|
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||||
|
Preload("Attendance").Preload("Attendance.Participant").
|
||||||
|
First(&session, id).Error
|
||||||
|
return &session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) GetSessions(limit, offset int) ([]models.MeetingSession, error) {
|
||||||
|
var sessions []models.MeetingSession
|
||||||
|
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||||
|
Preload("Attendance").Preload("Attendance.Participant").
|
||||||
|
Order("started_at DESC").Limit(limit).Offset(offset).Find(&sessions).Error
|
||||||
|
return sessions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteSession(id uint) error {
|
||||||
|
// Delete related logs first
|
||||||
|
if err := s.db.Where("session_id = ?", id).Delete(&models.ParticipantLog{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete related attendance
|
||||||
|
if err := s.db.Where("session_id = ?", id).Delete(&models.SessionAttendance{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete session
|
||||||
|
return s.db.Delete(&models.MeetingSession{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteAllSessions() error {
|
||||||
|
// Delete all logs
|
||||||
|
if err := s.db.Exec("DELETE FROM participant_logs").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete all attendance
|
||||||
|
if err := s.db.Exec("DELETE FROM session_attendances").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete all sessions
|
||||||
|
return s.db.Exec("DELETE FROM meeting_sessions").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant logs
|
||||||
|
func (s *Storage) CreateParticipantLog(log *models.ParticipantLog) error {
|
||||||
|
return s.db.Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateParticipantLog(log *models.ParticipantLog) error {
|
||||||
|
return s.db.Save(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance
|
||||||
|
func (s *Storage) SetAttendance(sessionID, participantID uint, present, late bool) error {
|
||||||
|
attendance := models.SessionAttendance{
|
||||||
|
SessionID: sessionID,
|
||||||
|
ParticipantID: participantID,
|
||||||
|
Present: present,
|
||||||
|
JoinedLate: late,
|
||||||
|
}
|
||||||
|
return s.db.Create(&attendance).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
func (s *Storage) GetSettings() (*models.Settings, error) {
|
||||||
|
var settings models.Settings
|
||||||
|
err := s.db.First(&settings).Error
|
||||||
|
return &settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateSettings(settings *models.Settings) error {
|
||||||
|
return s.db.Save(settings).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
func (s *Storage) GetAggregatedStats(from, to time.Time) (*models.AggregatedStats, error) {
|
||||||
|
var sessions []models.MeetingSession
|
||||||
|
err := s.db.Preload("ParticipantLogs").Preload("ParticipantLogs.Participant").
|
||||||
|
Preload("Attendance").
|
||||||
|
Where("started_at BETWEEN ? AND ?", from, to).
|
||||||
|
Where("completed = ?", true).
|
||||||
|
Find(&sessions).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sessions) == 0 {
|
||||||
|
return &models.AggregatedStats{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
meeting, _ := s.GetMeeting()
|
||||||
|
|
||||||
|
totalTime := 0
|
||||||
|
overtimeSessions := 0
|
||||||
|
participantData := make(map[uint]*models.ParticipantBreakdown)
|
||||||
|
|
||||||
|
for _, session := range sessions {
|
||||||
|
totalTime += session.TotalDuration
|
||||||
|
if meeting != nil && session.TotalDuration > meeting.TimeLimit {
|
||||||
|
overtimeSessions++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, log := range session.ParticipantLogs {
|
||||||
|
if _, ok := participantData[log.ParticipantID]; !ok {
|
||||||
|
participantData[log.ParticipantID] = &models.ParticipantBreakdown{
|
||||||
|
ParticipantID: log.ParticipantID,
|
||||||
|
Name: log.Participant.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pd := participantData[log.ParticipantID]
|
||||||
|
pd.SessionsAttended++
|
||||||
|
pd.TotalSpeakingTime += log.Duration
|
||||||
|
if log.Overtime {
|
||||||
|
pd.OvertimeCount++
|
||||||
|
}
|
||||||
|
if log.Skipped {
|
||||||
|
pd.SkipCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
participants, _ := s.GetAllParticipants()
|
||||||
|
totalSessions := len(sessions)
|
||||||
|
|
||||||
|
breakdowns := make([]models.ParticipantBreakdown, 0, len(participantData))
|
||||||
|
for _, pd := range participantData {
|
||||||
|
if pd.SessionsAttended > 0 {
|
||||||
|
pd.AverageSpeakingTime = float64(pd.TotalSpeakingTime) / float64(pd.SessionsAttended)
|
||||||
|
}
|
||||||
|
pd.AttendanceRate = float64(pd.SessionsAttended) / float64(totalSessions) * 100
|
||||||
|
breakdowns = append(breakdowns, *pd)
|
||||||
|
}
|
||||||
|
|
||||||
|
avgAttendance := 0.0
|
||||||
|
if len(participants) > 0 && totalSessions > 0 {
|
||||||
|
totalAttended := 0
|
||||||
|
for _, pd := range participantData {
|
||||||
|
totalAttended += pd.SessionsAttended
|
||||||
|
}
|
||||||
|
avgAttendance = float64(totalAttended) / float64(totalSessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.AggregatedStats{
|
||||||
|
TotalSessions: totalSessions,
|
||||||
|
TotalMeetingTime: totalTime,
|
||||||
|
AverageMeetingTime: float64(totalTime) / float64(totalSessions),
|
||||||
|
OvertimeSessions: overtimeSessions,
|
||||||
|
OvertimePercentage: float64(overtimeSessions) / float64(totalSessions) * 100,
|
||||||
|
AverageAttendance: avgAttendance,
|
||||||
|
ParticipantBreakdown: breakdowns,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Close() error {
|
||||||
|
sqlDB, err := s.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
428
internal/timer/timer.go
Normal file
428
internal/timer/timer.go
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
package timer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"daily-timer/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventTick EventType = "tick"
|
||||||
|
EventSpeakerWarning EventType = "speaker_warning"
|
||||||
|
EventSpeakerTimeUp EventType = "speaker_timeup"
|
||||||
|
EventMeetingWarning EventType = "meeting_warning"
|
||||||
|
EventMeetingTimeUp EventType = "meeting_timeup"
|
||||||
|
EventSpeakerChanged EventType = "speaker_changed"
|
||||||
|
EventMeetingEnded EventType = "meeting_ended"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
State models.TimerState `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timer struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
running bool
|
||||||
|
paused bool
|
||||||
|
|
||||||
|
meetingStartTime time.Time
|
||||||
|
meetingElapsed time.Duration
|
||||||
|
meetingLimit time.Duration
|
||||||
|
|
||||||
|
speakerStartTime time.Time
|
||||||
|
speakerElapsed time.Duration
|
||||||
|
speakerLimit time.Duration
|
||||||
|
|
||||||
|
currentSpeakerID uint
|
||||||
|
currentSpeaker string
|
||||||
|
speakingOrder int
|
||||||
|
queue []models.QueuedSpeaker
|
||||||
|
allSpeakers []models.SpeakerInfo
|
||||||
|
|
||||||
|
warningThreshold time.Duration
|
||||||
|
speakerWarned bool
|
||||||
|
speakerTimeUpEmitted bool
|
||||||
|
meetingWarned bool
|
||||||
|
|
||||||
|
eventCh chan Event
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
pausedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(meetingLimitSec, warningThresholdSec int) *Timer {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Timer{
|
||||||
|
meetingLimit: time.Duration(meetingLimitSec) * time.Second,
|
||||||
|
warningThreshold: time.Duration(warningThresholdSec) * time.Second,
|
||||||
|
eventCh: make(chan Event, 100),
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Events() <-chan Event {
|
||||||
|
return t.eventCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) SetQueue(speakers []models.QueuedSpeaker) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.queue = speakers
|
||||||
|
|
||||||
|
// Initialize allSpeakers with pending status
|
||||||
|
t.allSpeakers = make([]models.SpeakerInfo, len(speakers))
|
||||||
|
for i, s := range speakers {
|
||||||
|
t.allSpeakers[i] = models.SpeakerInfo{
|
||||||
|
ID: s.ID,
|
||||||
|
Name: s.Name,
|
||||||
|
TimeLimit: s.TimeLimit,
|
||||||
|
Order: i + 1,
|
||||||
|
Status: models.SpeakerStatusPending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Start() {
|
||||||
|
t.mu.Lock()
|
||||||
|
if t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
t.running = true
|
||||||
|
t.paused = false
|
||||||
|
t.meetingStartTime = now
|
||||||
|
t.meetingElapsed = 0
|
||||||
|
t.speakingOrder = 0
|
||||||
|
t.speakerWarned = false
|
||||||
|
t.meetingWarned = false
|
||||||
|
|
||||||
|
if len(t.queue) > 0 {
|
||||||
|
t.startNextSpeaker(now)
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
go t.tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) startNextSpeaker(now time.Time) {
|
||||||
|
if len(t.queue) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark previous speaker as done (only if they were speaking, not skipped)
|
||||||
|
if t.currentSpeakerID != 0 {
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
||||||
|
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
|
||||||
|
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speaker := t.queue[0]
|
||||||
|
t.queue = t.queue[1:]
|
||||||
|
t.currentSpeakerID = speaker.ID
|
||||||
|
t.currentSpeaker = speaker.Name
|
||||||
|
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
||||||
|
t.speakerStartTime = now
|
||||||
|
t.speakerElapsed = 0
|
||||||
|
t.speakingOrder++
|
||||||
|
t.speakerWarned = false
|
||||||
|
t.speakerTimeUpEmitted = false
|
||||||
|
|
||||||
|
// Mark current speaker as speaking
|
||||||
|
t.updateSpeakerStatus(speaker.ID, models.SpeakerStatusSpeaking)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) updateSpeakerStatus(id uint, status models.SpeakerStatus) {
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
if t.allSpeakers[i].ID == id {
|
||||||
|
t.allSpeakers[i].Status = status
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) moveSpeakerToEnd(id uint) {
|
||||||
|
var speaker models.SpeakerInfo
|
||||||
|
idx := -1
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
if t.allSpeakers[i].ID == id {
|
||||||
|
speaker = t.allSpeakers[i]
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx >= 0 {
|
||||||
|
// Remove from current position
|
||||||
|
t.allSpeakers = append(t.allSpeakers[:idx], t.allSpeakers[idx+1:]...)
|
||||||
|
// Add to end
|
||||||
|
t.allSpeakers = append(t.allSpeakers, speaker)
|
||||||
|
// Update order numbers
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
t.allSpeakers[i].Order = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) NextSpeaker() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if !t.paused {
|
||||||
|
t.speakerElapsed = now.Sub(t.speakerStartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventType EventType
|
||||||
|
if len(t.queue) > 0 {
|
||||||
|
t.startNextSpeaker(now)
|
||||||
|
eventType = EventSpeakerChanged
|
||||||
|
} else {
|
||||||
|
t.running = false
|
||||||
|
t.paused = false
|
||||||
|
eventType = EventMeetingEnded
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(eventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) SkipSpeaker() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running || t.currentSpeakerID == 0 {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark current speaker as skipped in allSpeakers
|
||||||
|
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusSkipped)
|
||||||
|
|
||||||
|
skipped := models.QueuedSpeaker{
|
||||||
|
ID: t.currentSpeakerID,
|
||||||
|
Name: t.currentSpeaker,
|
||||||
|
TimeLimit: int(t.speakerLimit.Seconds()),
|
||||||
|
}
|
||||||
|
t.queue = append(t.queue, skipped)
|
||||||
|
|
||||||
|
// Move skipped speaker to end of allSpeakers list
|
||||||
|
t.moveSpeakerToEnd(t.currentSpeakerID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if len(t.queue) > 1 {
|
||||||
|
t.startNextSpeaker(now)
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventSpeakerChanged)
|
||||||
|
} else {
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) RemoveFromQueue(speakerID uint) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
if !t.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't remove current speaker - use SkipSpeaker for that
|
||||||
|
if speakerID == t.currentSpeakerID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue
|
||||||
|
for i, s := range t.queue {
|
||||||
|
if s.ID == speakerID {
|
||||||
|
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as skipped in allSpeakers and move to end
|
||||||
|
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
||||||
|
t.moveSpeakerToEnd(speakerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Pause() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running || t.paused {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
t.paused = true
|
||||||
|
t.pausedAt = now
|
||||||
|
t.speakerElapsed = now.Sub(t.speakerStartTime)
|
||||||
|
t.meetingElapsed = now.Sub(t.meetingStartTime)
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Resume() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running || !t.paused {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseDuration := time.Since(t.pausedAt)
|
||||||
|
t.speakerStartTime = t.speakerStartTime.Add(pauseDuration)
|
||||||
|
t.meetingStartTime = t.meetingStartTime.Add(pauseDuration)
|
||||||
|
t.paused = false
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Stop() {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mark current speaker as done before stopping
|
||||||
|
if t.currentSpeakerID != 0 {
|
||||||
|
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone)
|
||||||
|
}
|
||||||
|
t.running = false
|
||||||
|
t.paused = false
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventMeetingEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) GetState() models.TimerState {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return t.buildState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) buildState() models.TimerState {
|
||||||
|
speakerElapsed := t.speakerElapsed
|
||||||
|
meetingElapsed := t.meetingElapsed
|
||||||
|
|
||||||
|
if t.running && !t.paused {
|
||||||
|
now := time.Now()
|
||||||
|
speakerElapsed = now.Sub(t.speakerStartTime)
|
||||||
|
meetingElapsed = now.Sub(t.meetingStartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
speakerOvertime := speakerElapsed > t.speakerLimit
|
||||||
|
meetingOvertime := meetingElapsed > t.meetingLimit
|
||||||
|
warning := !speakerOvertime && (t.speakerLimit-speakerElapsed) <= t.warningThreshold
|
||||||
|
|
||||||
|
// Copy allSpeakers to avoid data race and calculate total speakers time
|
||||||
|
allSpeakers := make([]models.SpeakerInfo, len(t.allSpeakers))
|
||||||
|
copy(allSpeakers, t.allSpeakers)
|
||||||
|
|
||||||
|
totalSpeakersTime := 0
|
||||||
|
for _, s := range t.allSpeakers {
|
||||||
|
totalSpeakersTime += s.TimeLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.TimerState{
|
||||||
|
Running: t.running,
|
||||||
|
Paused: t.paused,
|
||||||
|
CurrentSpeakerID: t.currentSpeakerID,
|
||||||
|
CurrentSpeaker: t.currentSpeaker,
|
||||||
|
SpeakerElapsed: int(speakerElapsed.Seconds()),
|
||||||
|
SpeakerLimit: int(t.speakerLimit.Seconds()),
|
||||||
|
MeetingElapsed: int(meetingElapsed.Seconds()),
|
||||||
|
MeetingLimit: int(t.meetingLimit.Seconds()),
|
||||||
|
SpeakerOvertime: speakerOvertime,
|
||||||
|
MeetingOvertime: meetingOvertime,
|
||||||
|
Warning: warning,
|
||||||
|
WarningSeconds: int(t.warningThreshold.Seconds()),
|
||||||
|
TotalSpeakersTime: totalSpeakersTime,
|
||||||
|
SpeakingOrder: t.speakingOrder,
|
||||||
|
TotalSpeakers: len(t.allSpeakers),
|
||||||
|
RemainingQueue: t.queue,
|
||||||
|
AllSpeakers: allSpeakers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) tick() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-t.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
t.mu.Lock()
|
||||||
|
if !t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.paused {
|
||||||
|
t.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
speakerElapsed := now.Sub(t.speakerStartTime)
|
||||||
|
meetingElapsed := now.Sub(t.meetingStartTime)
|
||||||
|
|
||||||
|
remaining := t.speakerLimit - speakerElapsed
|
||||||
|
if !t.speakerWarned && remaining <= t.warningThreshold && remaining > 0 {
|
||||||
|
t.speakerWarned = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventSpeakerWarning)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !t.speakerTimeUpEmitted && speakerElapsed >= t.speakerLimit {
|
||||||
|
t.speakerTimeUpEmitted = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventSpeakerTimeUp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
meetingRemaining := t.meetingLimit - meetingElapsed
|
||||||
|
if !t.meetingWarned && meetingRemaining <= t.warningThreshold && meetingRemaining > 0 {
|
||||||
|
t.meetingWarned = true
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventMeetingWarning)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventTick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) emit(eventType EventType) {
|
||||||
|
t.mu.RLock()
|
||||||
|
state := t.buildState()
|
||||||
|
t.mu.RUnlock()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case t.eventCh <- Event{Type: eventType, State: state}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Timer) Close() {
|
||||||
|
t.cancel()
|
||||||
|
close(t.eventCh)
|
||||||
|
}
|
||||||
61
main.go
Normal file
61
main.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||||
|
|
||||||
|
"daily-timer/internal/app"
|
||||||
|
"daily-timer/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:frontend/dist
|
||||||
|
var assets embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
store, err := storage.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize storage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
application := app.New(store)
|
||||||
|
|
||||||
|
if err := wails.Run(&options.App{
|
||||||
|
Title: "Daily Timer",
|
||||||
|
Width: 480,
|
||||||
|
Height: 900,
|
||||||
|
MinWidth: 480,
|
||||||
|
MinHeight: 400,
|
||||||
|
MaxWidth: 480,
|
||||||
|
StartHidden: true,
|
||||||
|
AssetServer: &assetserver.Options{
|
||||||
|
Assets: assets,
|
||||||
|
},
|
||||||
|
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||||
|
OnStartup: application.Startup,
|
||||||
|
OnDomReady: application.OnDomReady,
|
||||||
|
OnShutdown: application.Shutdown,
|
||||||
|
Bind: []interface{}{
|
||||||
|
application,
|
||||||
|
},
|
||||||
|
Mac: &mac.Options{
|
||||||
|
TitleBar: &mac.TitleBar{
|
||||||
|
TitlebarAppearsTransparent: true,
|
||||||
|
HideTitle: true,
|
||||||
|
HideTitleBar: false,
|
||||||
|
FullSizeContent: true,
|
||||||
|
UseToolbar: false,
|
||||||
|
},
|
||||||
|
About: &mac.AboutInfo{
|
||||||
|
Title: "Daily Timer",
|
||||||
|
Message: "Meeting timer with participant tracking\n\n© 2026 Movida.Biz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
log.Fatalf("error running application: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
wails.json
Normal file
20
wails.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||||
|
"name": "Daily Timer",
|
||||||
|
"outputfilename": "daily-timer",
|
||||||
|
"frontend:install": "npm install",
|
||||||
|
"frontend:build": "npm run build",
|
||||||
|
"frontend:dev:watcher": "npm run dev",
|
||||||
|
"frontend:dev:serverUrl": "auto",
|
||||||
|
"author": {
|
||||||
|
"name": "Movida.Biz",
|
||||||
|
"email": "admin@movida.biz"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"companyName": "Movida.Biz",
|
||||||
|
"productName": "Daily Timer",
|
||||||
|
"productVersion": "1.0.0",
|
||||||
|
"comments": "Meeting timer with participant time tracking",
|
||||||
|
"copyright": "Copyright © 2026 Movida.Biz"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user