Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f153d7f32 | ||
|
|
9c6a2dbf96 | ||
|
|
93c91161ba | ||
|
|
1620e12115 | ||
|
|
545a18cf59 | ||
|
|
7e376f8211 | ||
|
|
c2a17185fd | ||
|
|
b2454f3e9e | ||
|
|
422ff362c3 | ||
|
|
41c3fd4934 | ||
|
|
6783ed8b0a | ||
|
|
fe6a41226c |
7
Makefile
7
Makefile
@@ -81,7 +81,8 @@ release-all: lint
|
||||
@ls -lh dist/*.zip
|
||||
|
||||
# Upload release to Gitea (requires GITEA_TOKEN env var)
|
||||
release-upload:
|
||||
# Depends on 'release' to ensure dist/ files are up-to-date
|
||||
release-upload: release
|
||||
@if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi
|
||||
@echo "Creating release $(VERSION) on Gitea..."
|
||||
@RELEASE_ID=$$(curl -s -X POST \
|
||||
@@ -101,8 +102,8 @@ release-upload:
|
||||
done
|
||||
@echo "Done!"
|
||||
|
||||
# Full release cycle: build + upload
|
||||
release-publish: release release-upload
|
||||
# Full release cycle: build + upload (release-upload already depends on release)
|
||||
release-publish: release-upload
|
||||
|
||||
# Help
|
||||
help:
|
||||
|
||||
13
README.md
13
README.md
@@ -15,6 +15,7 @@
|
||||
- 💾 **Экспорт** - экспорт данных в JSON или CSV
|
||||
- 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения
|
||||
- 🌐 **Локализация** - русский и английский интерфейс
|
||||
- 🔗 **Jira интеграция** - автоматическое открытие Jira-фильтра в браузере при ходе участника
|
||||
- 🔄 **Автообновление** - проверка и установка обновлений из приложения
|
||||
|
||||
## Скриншоты
|
||||
@@ -96,8 +97,9 @@ xattr -cr "Daily Timer.app"
|
||||
|
||||
1. **Добавить участников** - ввести имена и установить лимиты времени
|
||||
2. **Установить общий лимит** - настроить длительность митинга (по умолчанию: 60 минут)
|
||||
3. **Упорядочить** - перетащить для изменения порядка выступлений
|
||||
4. **Отметить присутствие** - переключить статус присутствия/отсутствия
|
||||
3. **Настроить Jira URL** - указать базовый URL доски Jira (опционально), для каждого участника задать quickFilter ID
|
||||
4. **Упорядочить** - перетащить для изменения порядка выступлений
|
||||
5. **Отметить присутствие** - переключить статус присутствия/отсутствия
|
||||
|
||||
### Во время митинга
|
||||
|
||||
@@ -105,8 +107,9 @@ xattr -cr "Daily Timer.app"
|
||||
2. Таймер показывает текущего спикера с обратным отсчётом
|
||||
3. Нажать **Следующий** для перехода (или ⌘N)
|
||||
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
|
||||
5. Использовать **Пауза/Продолжить** для прерываний
|
||||
6. Нажать **Стоп** для досрочного завершения
|
||||
5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
|
||||
6. Использовать **Пауза/Продолжить** для прерываний
|
||||
7. Нажать **Стоп** для досрочного завершения
|
||||
|
||||
### Горячие клавиши
|
||||
|
||||
@@ -185,7 +188,7 @@ GITEA_TOKEN=<token> make release-publish
|
||||
|
||||
## Планы
|
||||
|
||||
- [ ] Drag-and-drop для порядка участников
|
||||
- [x] Drag-and-drop для порядка участников
|
||||
- [ ] Интеграция с Telegram (отправка сводки митинга)
|
||||
- [ ] Интеграция с календарём (авто-расписание)
|
||||
- [ ] Шаблоны команд
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
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 { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } from '../wailsjs/go/app/App'
|
||||
import { t, initLocale } from './lib/i18n'
|
||||
import { attendance } from './lib/stores'
|
||||
|
||||
let currentView = 'main'
|
||||
let timerState = null
|
||||
@@ -192,22 +193,37 @@
|
||||
currentView = 'main'
|
||||
}
|
||||
|
||||
function handleSettingsLoaded(s) {
|
||||
settings = s
|
||||
function handleSettingsLoaded(event) {
|
||||
settings = event.detail
|
||||
}
|
||||
|
||||
async function handleSkipFromList(event) {
|
||||
const { speakerId } = event.detail
|
||||
try {
|
||||
await RemoveFromQueue(speakerId)
|
||||
// If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
|
||||
if (timerState?.currentSpeakerId === speakerId) {
|
||||
await SkipSpeaker()
|
||||
} else {
|
||||
await RemoveFromQueue(speakerId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to remove speaker from queue:', e)
|
||||
console.error('Failed to skip speaker:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchSpeaker(event) {
|
||||
const { speakerId } = event.detail
|
||||
try {
|
||||
await SwitchToSpeaker(speakerId)
|
||||
} catch (e) {
|
||||
console.error('Failed to switch to speaker:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParticipants() {
|
||||
try {
|
||||
participants = await GetParticipants() || []
|
||||
attendance.init(participants)
|
||||
} catch (e) {
|
||||
console.error('Failed to load participants:', e)
|
||||
participants = []
|
||||
@@ -217,12 +233,16 @@
|
||||
async function handleQuickStart() {
|
||||
if (participants.length === 0) return
|
||||
|
||||
const ids = participants.map(p => p.id)
|
||||
const attendance = {}
|
||||
participants.forEach(p => { attendance[p.id] = true })
|
||||
const att = attendance.get()
|
||||
const presentIds = participants.filter(p => att[p.id]).map(p => p.id)
|
||||
|
||||
if (presentIds.length === 0) {
|
||||
alert($t('setup.noParticipants'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StartMeeting(ids, attendance)
|
||||
await StartMeeting(presentIds, att)
|
||||
meetingActive = true
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
@@ -266,12 +286,12 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<div class="content" class:no-nav={meetingActive}>
|
||||
{#if currentView === 'main'}
|
||||
{#if meetingActive && timerState}
|
||||
<div class="timer-view">
|
||||
<Timer {timerState} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
|
||||
<Controls {timerState} on:stop={() => meetingActive = false} />
|
||||
</div>
|
||||
{:else if participants.length > 0}
|
||||
@@ -333,6 +353,11 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
@@ -373,10 +398,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
top: 84px; /* 32px titlebar + 52px nav height */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.content.no-nav {
|
||||
top: 32px; /* Only titlebar when nav is hidden */
|
||||
}
|
||||
|
||||
.timer-view {
|
||||
|
||||
@@ -30,8 +30,33 @@
|
||||
await StopMeeting()
|
||||
dispatch('stop')
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
// ⌘N - Next speaker
|
||||
if (e.metaKey && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault()
|
||||
handleNext()
|
||||
}
|
||||
// ⌘S - Skip speaker
|
||||
if (e.metaKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
handleSkip()
|
||||
}
|
||||
// Space - Pause/Resume
|
||||
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
e.preventDefault()
|
||||
handlePauseResume()
|
||||
}
|
||||
// ⌘Q - Stop meeting
|
||||
if (e.metaKey && e.key.toLowerCase() === 'q') {
|
||||
e.preventDefault()
|
||||
handleStop()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn primary" on:click={handleNext}>
|
||||
{hasQueue ? $t('controls.next') : $t('controls.stop')}
|
||||
|
||||
@@ -205,7 +205,15 @@
|
||||
<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>
|
||||
<span class="log-duration">
|
||||
<span class:overtime={log.duration > (log.participant?.timeLimit || 0)}>
|
||||
{formatTime(log.duration)}
|
||||
</span>
|
||||
{#if log.participant?.timeLimit}
|
||||
<span class="time-sep">/</span>
|
||||
<span class="time-limit">{formatTime(log.participant.timeLimit)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if log.overtime}
|
||||
<span class="overtime-icon">⚠️</span>
|
||||
{/if}
|
||||
@@ -463,6 +471,22 @@
|
||||
.log-duration {
|
||||
color: #9ca3af;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-duration .overtime {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.log-duration .time-sep {
|
||||
color: #6b7280;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.log-duration .time-limit {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.loading, .empty {
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, tick } from 'svelte'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
export let timerState
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let listEl
|
||||
let lastSpeakingOrder = 0
|
||||
|
||||
$: allSpeakers = timerState?.allSpeakers || []
|
||||
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
||||
$: currentElapsed = timerState?.speakerElapsed || 0
|
||||
$: speakingOrder = timerState?.speakingOrder || 0
|
||||
|
||||
// Auto-scroll when speaker changes
|
||||
$: if (speakingOrder !== lastSpeakingOrder && speakingOrder > 0) {
|
||||
lastSpeakingOrder = speakingOrder
|
||||
scrollToCurrentSpeaker()
|
||||
}
|
||||
|
||||
async function scrollToCurrentSpeaker() {
|
||||
await tick() // Wait for DOM update
|
||||
if (!listEl) return
|
||||
|
||||
// Find the index of the current speaking participant
|
||||
const speakingIndex = allSpeakers.findIndex(s => s.status === 'speaking')
|
||||
if (speakingIndex < 0) return
|
||||
|
||||
// Scroll to show previous speaker at top (or current if first)
|
||||
const targetIndex = Math.max(0, speakingIndex - 1)
|
||||
const items = listEl.querySelectorAll('li')
|
||||
if (items[targetIndex]) {
|
||||
items[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkip(speakerId) {
|
||||
dispatch('skip', { speakerId })
|
||||
}
|
||||
|
||||
function handleSwitch(speakerId) {
|
||||
dispatch('switch', { speakerId })
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
@@ -25,9 +55,14 @@
|
||||
<h3>{$t('timer.participants')}</h3>
|
||||
|
||||
{#if allSpeakers.length > 0}
|
||||
<ul>
|
||||
<ul bind:this={listEl}>
|
||||
{#each allSpeakers as speaker}
|
||||
<li class="speaker-item {speaker.status}">
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
class="speaker-item {speaker.status}"
|
||||
class:clickable={speaker.status !== 'speaking'}
|
||||
on:click={() => speaker.status !== 'speaking' && handleSwitch(speaker.id)}
|
||||
>
|
||||
<span class="order">{speaker.order}</span>
|
||||
<span class="name">{speaker.name}</span>
|
||||
<span class="time-display">
|
||||
@@ -47,8 +82,8 @@
|
||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
|
||||
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
⏭
|
||||
</button>
|
||||
{/if}
|
||||
@@ -108,6 +143,18 @@
|
||||
background: #1b2636;
|
||||
}
|
||||
|
||||
.speaker-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.speaker-item.clickable:hover {
|
||||
background: #2d3f52;
|
||||
}
|
||||
|
||||
.speaker-item.done.clickable:hover {
|
||||
background: #2a4a6f;
|
||||
}
|
||||
|
||||
.speaker-item.skipped {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
@@ -181,12 +228,12 @@
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
@@ -199,6 +246,7 @@
|
||||
|
||||
.time-sep {
|
||||
color: #6b7280;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
let saving = false
|
||||
let meetingLimitMin = 15
|
||||
let defaultTimeMin = 2
|
||||
let defaultJiraUrl = ''
|
||||
let editingDefaultJiraUrl = false
|
||||
let defaultJiraUrlInput = ''
|
||||
let windowWidth = 800
|
||||
let windowFullHeight = true
|
||||
let audioContext = null
|
||||
@@ -236,6 +239,7 @@
|
||||
}
|
||||
if (settings) {
|
||||
defaultTimeMin = Math.floor(settings.defaultParticipantTime / 60)
|
||||
defaultJiraUrl = settings.defaultJiraUrl || 'https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN'
|
||||
windowWidth = settings.windowWidth || 800
|
||||
windowFullHeight = settings.windowFullHeight !== false
|
||||
}
|
||||
@@ -251,6 +255,7 @@
|
||||
saving = true
|
||||
try {
|
||||
settings.defaultParticipantTime = defaultTimeMin * 60
|
||||
settings.defaultJiraUrl = defaultJiraUrl.trim()
|
||||
meeting.timeLimit = meetingLimitMin * 60
|
||||
settings.windowWidth = Math.max(480, windowWidth)
|
||||
settings.windowFullHeight = windowFullHeight
|
||||
@@ -312,6 +317,29 @@
|
||||
<label for="meetingLimit">{$t('settings.defaultTotalTime')}</label>
|
||||
<input type="number" id="meetingLimit" bind:value={meetingLimitMin} min="1" max="120" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="defaultJiraUrl">{$t('settings.defaultJiraUrl')}</label>
|
||||
{#if editingDefaultJiraUrl}
|
||||
<div class="jira-edit-inline">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input type="url" bind:value={defaultJiraUrlInput} autofocus
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') { defaultJiraUrl = defaultJiraUrlInput; editingDefaultJiraUrl = false }
|
||||
if (e.key === 'Escape') editingDefaultJiraUrl = false
|
||||
}}
|
||||
/>
|
||||
<button class="jira-inline-save" on:click={() => { defaultJiraUrl = defaultJiraUrlInput; editingDefaultJiraUrl = false }}>✓</button>
|
||||
<button class="jira-inline-cancel" on:click={() => editingDefaultJiraUrl = false}>✗</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="jira-display" on:click={() => { defaultJiraUrlInput = defaultJiraUrl; editingDefaultJiraUrl = true }}>
|
||||
<span class="jira-url-text">{defaultJiraUrl || '—'}</span>
|
||||
<span class="jira-edit-icon">✎</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -526,7 +554,8 @@
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
input[type="number"],
|
||||
input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #3d4f61;
|
||||
@@ -609,12 +638,6 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sound-test-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
@@ -836,4 +859,79 @@
|
||||
background: #3b7dc9;
|
||||
border-color: #3b7dc9;
|
||||
}
|
||||
|
||||
.jira-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.jira-display:hover {
|
||||
border-color: #4a90d9;
|
||||
}
|
||||
|
||||
.jira-url-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jira-edit-icon {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
color: #9ca3af;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.jira-display:hover .jira-edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.jira-edit-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.jira-edit-inline input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 1px solid #4a90d9;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jira-inline-save,
|
||||
.jira-inline-cancel {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 42px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jira-inline-save {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.jira-inline-cancel {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting, GetSettings } from '../../wailsjs/go/app/App'
|
||||
import { t } from '../lib/i18n'
|
||||
import { attendance } from '../lib/stores'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let participants = []
|
||||
let meeting = null
|
||||
let selectedOrder = []
|
||||
let attendance = {}
|
||||
let loading = true
|
||||
let newName = ''
|
||||
let newTimeLimitMin = 2
|
||||
@@ -17,15 +17,55 @@
|
||||
let editingId = null
|
||||
let editName = ''
|
||||
let editTimeLimitMin = 2
|
||||
let editJiraFilter = ''
|
||||
|
||||
// Quick jira filter edit
|
||||
let quickFilterEditId = null
|
||||
let quickFilterInput = ''
|
||||
|
||||
// Meeting name editing
|
||||
let editingMeetingName = false
|
||||
let meetingNameInput = ''
|
||||
let originalMeetingName = ''
|
||||
|
||||
// Meeting time editing
|
||||
let editingMeetingTime = false
|
||||
let meetingTimeInput = 60
|
||||
let originalMeetingTime = 60
|
||||
|
||||
// Meeting Jira URL editing
|
||||
let editingMeetingJiraUrl = false
|
||||
let meetingJiraUrlInput = ''
|
||||
let originalMeetingJiraUrl = ''
|
||||
let defaultJiraUrl = ''
|
||||
|
||||
// Confirmation dialog when switching between editable fields
|
||||
let confirmDialog = null
|
||||
|
||||
function isDirty() {
|
||||
if (editingMeetingName) return meetingNameInput !== originalMeetingName
|
||||
if (editingMeetingTime) return meetingTimeInput !== originalMeetingTime
|
||||
if (editingMeetingJiraUrl) return meetingJiraUrlInput !== originalMeetingJiraUrl
|
||||
return false
|
||||
}
|
||||
|
||||
function getActiveEdit() {
|
||||
if (editingMeetingName) return { save: saveMeetingName, cancel: cancelEditMeetingName }
|
||||
if (editingMeetingTime) return { save: saveMeetingTime, cancel: cancelEditMeetingTime }
|
||||
if (editingMeetingJiraUrl) return { save: saveMeetingJiraUrl, cancel: cancelEditMeetingJiraUrl }
|
||||
return null
|
||||
}
|
||||
|
||||
async function guardEdit(startFn) {
|
||||
const active = getActiveEdit()
|
||||
if (!active) { startFn(); return }
|
||||
if (!isDirty()) { active.cancel(); startFn(); return }
|
||||
confirmDialog = {
|
||||
save: async () => { confirmDialog = null; await active.save(); startFn() },
|
||||
discard: () => { confirmDialog = null; active.cancel(); startFn() },
|
||||
cancel: () => { confirmDialog = null }
|
||||
}
|
||||
}
|
||||
onMount(async () => {
|
||||
await loadData()
|
||||
})
|
||||
@@ -35,12 +75,11 @@
|
||||
try {
|
||||
participants = await GetParticipants()
|
||||
meeting = await GetMeeting()
|
||||
const appSettings = await GetSettings()
|
||||
defaultJiraUrl = appSettings?.defaultJiraUrl || 'https://jira.ncloudtech.ru/secure/RapidBoard.jspa?rapidView=1431&projectKey=RNDIN'
|
||||
|
||||
selectedOrder = participants.map(p => p.id)
|
||||
attendance = {}
|
||||
participants.forEach(p => {
|
||||
attendance[p.id] = true
|
||||
})
|
||||
attendance.init(participants)
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
}
|
||||
@@ -51,7 +90,7 @@
|
||||
if (!newName.trim()) return
|
||||
|
||||
try {
|
||||
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60)
|
||||
await AddParticipant(newName.trim(), '', newTimeLimitMin * 60, '')
|
||||
newName = ''
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
@@ -74,19 +113,43 @@
|
||||
editingId = p.id
|
||||
editName = p.name
|
||||
editTimeLimitMin = Math.floor(p.timeLimit / 60)
|
||||
editJiraFilter = p.jiraFilter || ''
|
||||
}
|
||||
|
||||
function startQuickFilterEdit(p) {
|
||||
quickFilterEditId = p.id
|
||||
quickFilterInput = p.jiraFilter || ''
|
||||
}
|
||||
|
||||
function cancelQuickFilterEdit() {
|
||||
quickFilterEditId = null
|
||||
quickFilterInput = ''
|
||||
}
|
||||
|
||||
async function saveQuickFilterEdit() {
|
||||
const p = participants.find(x => x.id === quickFilterEditId)
|
||||
if (!p) return
|
||||
try {
|
||||
await UpdateParticipant(p.id, p.name, '', p.timeLimit, quickFilterInput.trim())
|
||||
quickFilterEditId = null
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
console.error('Failed to update jira filter:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null
|
||||
editName = ''
|
||||
editTimeLimitMin = 2
|
||||
editJiraFilter = ''
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editName.trim() || editingId === null) return
|
||||
|
||||
try {
|
||||
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60)
|
||||
await UpdateParticipant(editingId, editName.trim(), '', editTimeLimitMin * 60, editJiraFilter.trim())
|
||||
editingId = null
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
@@ -95,8 +158,7 @@
|
||||
}
|
||||
|
||||
function toggleAttendance(id) {
|
||||
attendance[id] = !attendance[id]
|
||||
attendance = attendance
|
||||
attendance.toggle(id)
|
||||
}
|
||||
|
||||
// Drag and drop state
|
||||
@@ -152,14 +214,15 @@
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
const presentIds = selectedOrder.filter(id => attendance[id])
|
||||
const att = attendance.get()
|
||||
const presentIds = selectedOrder.filter(id => att[id])
|
||||
if (presentIds.length === 0) {
|
||||
alert($t('setup.noParticipants'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StartMeeting(presentIds, attendance)
|
||||
await StartMeeting(presentIds, att)
|
||||
dispatch('started')
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
@@ -178,7 +241,8 @@
|
||||
}
|
||||
|
||||
function startEditMeetingName() {
|
||||
meetingNameInput = meeting?.name || ''
|
||||
originalMeetingName = meeting?.name || ''
|
||||
meetingNameInput = originalMeetingName
|
||||
editingMeetingName = true
|
||||
}
|
||||
|
||||
@@ -190,7 +254,7 @@
|
||||
async function saveMeetingName() {
|
||||
if (!meetingNameInput.trim()) return
|
||||
try {
|
||||
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600)
|
||||
await UpdateMeeting(meetingNameInput.trim(), meeting?.timeLimit || 3600, meeting?.jiraUrl || '')
|
||||
meeting = await GetMeeting()
|
||||
editingMeetingName = false
|
||||
} catch (e) {
|
||||
@@ -199,7 +263,8 @@
|
||||
}
|
||||
|
||||
function startEditMeetingTime() {
|
||||
meetingTimeInput = Math.floor((meeting?.timeLimit || 3600) / 60)
|
||||
originalMeetingTime = Math.floor((meeting?.timeLimit || 3600) / 60)
|
||||
meetingTimeInput = originalMeetingTime
|
||||
editingMeetingTime = true
|
||||
}
|
||||
|
||||
@@ -210,7 +275,7 @@
|
||||
async function saveMeetingTime() {
|
||||
if (meetingTimeInput < 1) return
|
||||
try {
|
||||
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60)
|
||||
await UpdateMeeting(meeting?.name || 'Daily Standup', meetingTimeInput * 60, meeting?.jiraUrl || '')
|
||||
meeting = await GetMeeting()
|
||||
editingMeetingTime = false
|
||||
} catch (e) {
|
||||
@@ -218,11 +283,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_JIRA_URL = defaultJiraUrl
|
||||
|
||||
function startEditMeetingJiraUrl() {
|
||||
originalMeetingJiraUrl = meeting?.jiraUrl || defaultJiraUrl
|
||||
meetingJiraUrlInput = originalMeetingJiraUrl
|
||||
editingMeetingJiraUrl = true
|
||||
}
|
||||
|
||||
function cancelEditMeetingJiraUrl() {
|
||||
editingMeetingJiraUrl = false
|
||||
meetingJiraUrlInput = ''
|
||||
}
|
||||
|
||||
async function saveMeetingJiraUrl() {
|
||||
try {
|
||||
await UpdateMeeting(meeting?.name || 'Daily Standup', meeting?.timeLimit || 3600, meetingJiraUrlInput.trim())
|
||||
meeting = await GetMeeting()
|
||||
editingMeetingJiraUrl = false
|
||||
} catch (e) {
|
||||
console.error('Failed to update meeting jira url:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingId !== null) cancelEdit()
|
||||
if (editingMeetingName) cancelEditMeetingName()
|
||||
if (editingMeetingTime) cancelEditMeetingTime()
|
||||
if (editingMeetingJiraUrl) cancelEditMeetingJiraUrl()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -248,11 +337,12 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<h1 on:click={startEditMeetingName} class="editable-title">
|
||||
<h1 on:click={() => guardEdit(startEditMeetingName)} class="editable-title">
|
||||
{meeting?.name || 'Daily Standup'}
|
||||
<span class="edit-icon">✎</span>
|
||||
</h1>
|
||||
{/if}
|
||||
<div class="meeting-info-block">
|
||||
{#if editingMeetingTime}
|
||||
<div class="meeting-time-edit">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
@@ -273,13 +363,51 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<p on:click={startEditMeetingTime} class="editable-time">
|
||||
<p on:click={() => guardEdit(startEditMeetingTime)} class="editable-time">
|
||||
{$t('setup.totalTime')}: {formatTime(meeting?.timeLimit || 900)}
|
||||
<span class="edit-icon">✎</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if editingMeetingJiraUrl}
|
||||
<div class="meeting-jira-edit">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
type="url"
|
||||
bind:value={meetingJiraUrlInput}
|
||||
placeholder={$t('setup.jiraUrlPlaceholder')}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveMeetingJiraUrl()
|
||||
if (e.key === 'Escape') cancelEditMeetingJiraUrl()
|
||||
}}
|
||||
autofocus
|
||||
/>
|
||||
<button class="save-btn" on:click={saveMeetingJiraUrl}>✓</button>
|
||||
<button class="cancel-btn" on:click={cancelEditMeetingJiraUrl}>✗</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<p on:click={() => guardEdit(startEditMeetingJiraUrl)} class="editable-jira">
|
||||
{$t('setup.jiraUrl')}: {#if meeting?.jiraUrl}<span class="jira-url-value">{meeting.jiraUrl}</span>{:else}<span class="jira-default-label">(Default)</span>{/if}
|
||||
<span class="edit-icon">✎</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if confirmDialog}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="confirm-overlay" on:click={confirmDialog.cancel}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="confirm-dialog" on:click|stopPropagation>
|
||||
<p class="confirm-text">Сохранить изменения?</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-discard" on:click={confirmDialog.discard}>Отменить</button>
|
||||
<button class="confirm-save" on:click={confirmDialog.save}>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="add-participant">
|
||||
<input
|
||||
type="text"
|
||||
@@ -314,7 +442,7 @@
|
||||
{@const p = getParticipant(id)}
|
||||
{#if p}
|
||||
<li
|
||||
class:absent={!attendance[id]}
|
||||
class:absent={!$attendance[id]}
|
||||
class:drag-over={dragOverId === id}
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, id)}
|
||||
@@ -329,14 +457,23 @@
|
||||
|
||||
<button
|
||||
class="attendance-toggle"
|
||||
class:present={attendance[id]}
|
||||
class:present={$attendance[id]}
|
||||
on:click={() => toggleAttendance(id)}
|
||||
>
|
||||
{attendance[id] ? '✓' : '✗'}
|
||||
{$attendance[id] ? '✓' : '✗'}
|
||||
</button>
|
||||
|
||||
<span class="name">{p.name}</span>
|
||||
<span class="time-limit">{Math.floor(p.timeLimit / 60)} {$t('setup.minutes')}</span>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<span
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="url-indicator"
|
||||
class:url-indicator--empty={!p.jiraFilter}
|
||||
title={p.jiraFilter ? `${meeting?.jiraUrl}&quickFilter=${p.jiraFilter}` : $t('participants.jiraFilterEmpty')}
|
||||
on:click|stopPropagation={() => startQuickFilterEdit(p)}
|
||||
>🔗</span>
|
||||
|
||||
<button class="edit" on:click={() => startEdit(p)} title="{$t('participants.edit')}">✎</button>
|
||||
<button class="remove" on:click={() => handleRemove(id)}>×</button>
|
||||
@@ -366,6 +503,13 @@
|
||||
if (e.key === 'Escape') cancelEdit()
|
||||
}} />
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label for="editJiraFilter">{$t('participants.jiraFilter')}</label>
|
||||
<input id="editJiraFilter" type="text" bind:value={editJiraFilter} placeholder={$t('participants.jiraFilterPlaceholder')} 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>
|
||||
@@ -374,9 +518,33 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if quickFilterEditId !== null}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="edit-modal-overlay" on:click={cancelQuickFilterEdit}>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
|
||||
<div class="edit-modal quick-filter-modal" on:click|stopPropagation>
|
||||
<h3>quickFilter ID</h3>
|
||||
<div class="edit-field">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input id="quickFilterInput" type="text" bind:value={quickFilterInput}
|
||||
placeholder={$t('participants.jiraFilterPlaceholder')}
|
||||
autofocus
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') saveQuickFilterEdit()
|
||||
if (e.key === 'Escape') cancelQuickFilterEdit()
|
||||
}} />
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button class="cancel-btn" on:click={cancelQuickFilterEdit}>{$t('common.cancel')}</button>
|
||||
<button class="save-btn" on:click={saveQuickFilterEdit}>{$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>
|
||||
<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}>
|
||||
@@ -393,11 +561,12 @@
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
color: #e0e0e0;
|
||||
display: block;
|
||||
}
|
||||
@@ -469,9 +638,11 @@
|
||||
|
||||
.header p.editable-time {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header p.editable-time:hover {
|
||||
@@ -532,6 +703,89 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header p.editable-jira {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.header p.editable-jira:hover {
|
||||
color: #4a90d9;
|
||||
}
|
||||
|
||||
.header p.editable-jira:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.meeting-info-block {
|
||||
margin: 12px 0 16px;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.jira-url-value {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.jira-default-label {
|
||||
font-size: 11px;
|
||||
color: #4b5563;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meeting-jira-edit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.meeting-jira-edit input {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid #4a90d9;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
width: 280px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.meeting-jira-edit .save-btn {
|
||||
padding: 5px 10px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meeting-jira-edit .cancel-btn {
|
||||
padding: 5px 10px;
|
||||
background: #991b1b;
|
||||
color: #fca5a5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-participant {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -680,6 +934,28 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.url-indicator {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
filter: sepia(1) saturate(3) hue-rotate(90deg);
|
||||
}
|
||||
|
||||
.url-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.url-indicator--empty {
|
||||
opacity: 0.2;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.url-indicator--empty:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edit {
|
||||
padding: 4px 8px;
|
||||
background: transparent;
|
||||
@@ -720,6 +996,61 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 120px;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: #1e2d3d;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
min-width: 240px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-discard {
|
||||
padding: 6px 14px;
|
||||
background: #374151;
|
||||
color: #9ca3af;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm-save {
|
||||
padding: 6px 14px;
|
||||
background: #166534;
|
||||
color: #4ade80;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-modal {
|
||||
background: #232f3e;
|
||||
border-radius: 12px;
|
||||
@@ -728,6 +1059,10 @@
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.edit-modal.quick-filter-modal {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.edit-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #e0e0e0;
|
||||
|
||||
@@ -23,10 +23,12 @@ export const translations = {
|
||||
deselectAll: 'Снять выбор',
|
||||
startMeeting: 'Начать собрание',
|
||||
speakerTime: 'Время на спикера',
|
||||
totalTime: 'Общее время',
|
||||
totalTime: 'Общее время собрания',
|
||||
minutes: 'мин',
|
||||
unlimited: 'Без ограничения',
|
||||
dragHint: 'перетащите для изменения порядка, ✓/✗ присутствие',
|
||||
jiraUrl: 'Jira Kanban URL',
|
||||
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
@@ -103,6 +105,7 @@ export const translations = {
|
||||
seconds: 'сек',
|
||||
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
||||
defaultTotalTime: 'Общее время собрания (мин)',
|
||||
defaultJiraUrl: 'Jira URL Панели Kanban',
|
||||
theme: 'Тема оформления',
|
||||
themeDark: 'Тёмная',
|
||||
themeLight: 'Светлая',
|
||||
@@ -139,6 +142,9 @@ export const translations = {
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
name: 'Имя',
|
||||
jiraFilter: 'Jira Kanban URL',
|
||||
jiraFilterPlaceholder: 'quickFilter ID (напр. 12345)',
|
||||
jiraFilterEmpty: 'Jira фильтр не задан — нажмите для настройки',
|
||||
stats: 'Статистика',
|
||||
avgSpeakTime: 'Среднее время выступления',
|
||||
totalMeetings: 'Всего собраний',
|
||||
@@ -196,10 +202,12 @@ export const translations = {
|
||||
deselectAll: 'Deselect All',
|
||||
startMeeting: 'Start Meeting',
|
||||
speakerTime: 'Speaker Time',
|
||||
totalTime: 'Total Time',
|
||||
totalTime: 'Total Meeting Time',
|
||||
minutes: 'min',
|
||||
unlimited: 'Unlimited',
|
||||
dragHint: 'drag to reorder, ✓/✗ attendance',
|
||||
jiraUrl: 'Jira Kanban URL',
|
||||
jiraUrlPlaceholder: 'https://jira.ncloudtech.ru/...',
|
||||
},
|
||||
|
||||
// Timer page
|
||||
@@ -276,6 +284,7 @@ export const translations = {
|
||||
seconds: 'sec',
|
||||
defaultSpeakerTime: 'Default Speaker Time',
|
||||
defaultTotalTime: 'Total meeting time (min)',
|
||||
defaultJiraUrl: 'Jira Kanban Board URL',
|
||||
theme: 'Theme',
|
||||
themeDark: 'Dark',
|
||||
themeLight: 'Light',
|
||||
@@ -312,6 +321,9 @@ export const translations = {
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
name: 'Name',
|
||||
jiraFilter: 'Jira Kanban URL',
|
||||
jiraFilterPlaceholder: 'quickFilter ID (e.g. 12345)',
|
||||
jiraFilterEmpty: 'Jira filter not set — click to configure',
|
||||
stats: 'Statistics',
|
||||
avgSpeakTime: 'Avg Speaking Time',
|
||||
totalMeetings: 'Total Meetings',
|
||||
|
||||
56
frontend/src/lib/stores.js
Normal file
56
frontend/src/lib/stores.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
|
||||
function createAttendanceStore() {
|
||||
const { subscribe, set, update } = writable({});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize attendance for all participants (default: true)
|
||||
init(participants) {
|
||||
const current = get({ subscribe });
|
||||
const newAttendance = {};
|
||||
|
||||
for (const p of participants) {
|
||||
// Keep existing value or default to true
|
||||
newAttendance[p.id] = current[p.id] !== undefined ? current[p.id] : true;
|
||||
}
|
||||
|
||||
set(newAttendance);
|
||||
},
|
||||
|
||||
// Toggle attendance for a participant
|
||||
toggle(id) {
|
||||
update((att) => {
|
||||
att[id] = !att[id];
|
||||
return { ...att };
|
||||
});
|
||||
},
|
||||
|
||||
// Set attendance for a participant
|
||||
set(id, present) {
|
||||
update((att) => {
|
||||
att[id] = present;
|
||||
return { ...att };
|
||||
});
|
||||
},
|
||||
|
||||
// Get current attendance object
|
||||
get() {
|
||||
return get({ subscribe });
|
||||
},
|
||||
|
||||
// Reset all to true
|
||||
resetAll() {
|
||||
update((att) => {
|
||||
const reset = {};
|
||||
for (const id in att) {
|
||||
reset[id] = true;
|
||||
}
|
||||
return reset;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const attendance = createAttendanceStore();
|
||||
10
frontend/wailsjs/go/app/App.d.ts
vendored
10
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -3,7 +3,7 @@
|
||||
import {models} from '../models';
|
||||
import {updater} from '../models';
|
||||
|
||||
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>;
|
||||
export function AddParticipant(arg1:string,arg2:string,arg3:number,arg4:string):Promise<models.Participant>;
|
||||
|
||||
export function CheckForUpdates():Promise<updater.UpdateInfo>;
|
||||
|
||||
@@ -53,6 +53,8 @@ export function RestartApp():Promise<void>;
|
||||
|
||||
export function ResumeMeeting():Promise<void>;
|
||||
|
||||
export function SaveWindowPosition():Promise<void>;
|
||||
|
||||
export function SelectCustomSound(arg1:string):Promise<string>;
|
||||
|
||||
export function SkipSpeaker():Promise<void>;
|
||||
@@ -61,8 +63,10 @@ export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Pr
|
||||
|
||||
export function StopMeeting():Promise<void>;
|
||||
|
||||
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
|
||||
export function SwitchToSpeaker(arg1:number):Promise<void>;
|
||||
|
||||
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
|
||||
export function UpdateMeeting(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||
|
||||
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number,arg5:string):Promise<void>;
|
||||
|
||||
export function UpdateSettings(arg1:models.Settings):Promise<void>;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// 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 AddParticipant(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
@@ -102,6 +102,10 @@ export function ResumeMeeting() {
|
||||
return window['go']['app']['App']['ResumeMeeting']();
|
||||
}
|
||||
|
||||
export function SaveWindowPosition() {
|
||||
return window['go']['app']['App']['SaveWindowPosition']();
|
||||
}
|
||||
|
||||
export function SelectCustomSound(arg1) {
|
||||
return window['go']['app']['App']['SelectCustomSound'](arg1);
|
||||
}
|
||||
@@ -118,12 +122,16 @@ export function StopMeeting() {
|
||||
return window['go']['app']['App']['StopMeeting']();
|
||||
}
|
||||
|
||||
export function UpdateMeeting(arg1, arg2) {
|
||||
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
|
||||
export function SwitchToSpeaker(arg1) {
|
||||
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateParticipant(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4);
|
||||
export function UpdateMeeting(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function UpdateParticipant(arg1, arg2, arg3, arg4, arg5) {
|
||||
return window['go']['app']['App']['UpdateParticipant'](arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
|
||||
export function UpdateSettings(arg1) {
|
||||
|
||||
@@ -112,6 +112,7 @@ export namespace models {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
jiraFilter?: string;
|
||||
timeLimit: number;
|
||||
order: number;
|
||||
active: boolean;
|
||||
@@ -129,6 +130,7 @@ export namespace models {
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.email = source["email"];
|
||||
this.jiraFilter = source["jiraFilter"];
|
||||
this.timeLimit = source["timeLimit"];
|
||||
this.order = source["order"];
|
||||
this.active = source["active"];
|
||||
@@ -253,6 +255,7 @@ export namespace models {
|
||||
export class Meeting {
|
||||
id: number;
|
||||
name: string;
|
||||
jiraUrl?: string;
|
||||
timeLimit: number;
|
||||
sessions?: MeetingSession[];
|
||||
// Go type: time
|
||||
@@ -268,6 +271,7 @@ export namespace models {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.jiraUrl = source["jiraUrl"];
|
||||
this.timeLimit = source["timeLimit"];
|
||||
this.sessions = this.convertValues(source["sessions"], MeetingSession);
|
||||
this.createdAt = this.convertValues(source["createdAt"], null);
|
||||
@@ -327,6 +331,9 @@ export namespace models {
|
||||
theme: string;
|
||||
windowWidth: number;
|
||||
windowFullHeight: boolean;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
defaultJiraUrl: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Settings(source);
|
||||
@@ -345,6 +352,9 @@ export namespace models {
|
||||
this.theme = source["theme"];
|
||||
this.windowWidth = source["windowWidth"];
|
||||
this.windowFullHeight = source["windowFullHeight"];
|
||||
this.windowX = source["windowX"];
|
||||
this.windowY = source["windowY"];
|
||||
this.defaultJiraUrl = source["defaultJiraUrl"];
|
||||
}
|
||||
}
|
||||
export class SpeakerInfo {
|
||||
|
||||
@@ -18,12 +18,13 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
store *storage.Storage
|
||||
timer *timer.Timer
|
||||
session *models.MeetingSession
|
||||
currentLogs map[uint]*models.ParticipantLog
|
||||
updater *updater.Updater
|
||||
ctx context.Context
|
||||
store *storage.Storage
|
||||
timer *timer.Timer
|
||||
session *models.MeetingSession
|
||||
currentLogs map[uint]*models.ParticipantLog
|
||||
participantURLs map[uint]string
|
||||
updater *updater.Updater
|
||||
}
|
||||
|
||||
func New(store *storage.Storage) *App {
|
||||
@@ -39,10 +40,19 @@ func (a *App) Startup(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (a *App) OnDomReady(ctx context.Context) {
|
||||
// Restore saved window position
|
||||
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
|
||||
if settings.WindowX >= 0 && settings.WindowY >= 0 {
|
||||
runtime.WindowSetPosition(ctx, settings.WindowX, settings.WindowY)
|
||||
}
|
||||
}
|
||||
runtime.WindowShow(ctx)
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
// Save window position before closing
|
||||
a.saveWindowPosition()
|
||||
|
||||
if a.timer != nil {
|
||||
a.timer.Close()
|
||||
}
|
||||
@@ -51,22 +61,42 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) saveWindowPosition() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
x, y := runtime.WindowGetPosition(a.ctx)
|
||||
if x >= 0 && y >= 0 {
|
||||
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
|
||||
settings.WindowX = x
|
||||
settings.WindowY = y
|
||||
_ = a.store.UpdateSettings(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SaveWindowPosition saves current window position (can be called from frontend)
|
||||
func (a *App) SaveWindowPosition() {
|
||||
a.saveWindowPosition()
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (a *App) AddParticipant(name string, email string, timeLimit int, jiraFilter string) (*models.Participant, error) {
|
||||
participants, _ := a.store.GetAllParticipants()
|
||||
order := len(participants)
|
||||
|
||||
p := &models.Participant{
|
||||
Name: name,
|
||||
Email: email,
|
||||
TimeLimit: timeLimit,
|
||||
Order: order,
|
||||
Active: true,
|
||||
Name: name,
|
||||
Email: email,
|
||||
JiraFilter: jiraFilter,
|
||||
TimeLimit: timeLimit,
|
||||
Order: order,
|
||||
Active: true,
|
||||
}
|
||||
|
||||
if err := a.store.CreateParticipant(p); err != nil {
|
||||
@@ -75,12 +105,13 @@ func (a *App) AddParticipant(name string, email string, timeLimit int) (*models.
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int) error {
|
||||
func (a *App) UpdateParticipant(id uint, name string, email string, timeLimit int, jiraFilter string) error {
|
||||
p := &models.Participant{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Email: email,
|
||||
TimeLimit: timeLimit,
|
||||
ID: id,
|
||||
Name: name,
|
||||
Email: email,
|
||||
JiraFilter: jiraFilter,
|
||||
TimeLimit: timeLimit,
|
||||
}
|
||||
return a.store.UpdateParticipant(p)
|
||||
}
|
||||
@@ -99,13 +130,14 @@ func (a *App) GetMeeting() (*models.Meeting, error) {
|
||||
return a.store.GetMeeting()
|
||||
}
|
||||
|
||||
func (a *App) UpdateMeeting(name string, timeLimit int) error {
|
||||
func (a *App) UpdateMeeting(name string, timeLimit int, jiraURL string) error {
|
||||
meeting, err := a.store.GetMeeting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meeting.Name = name
|
||||
meeting.TimeLimit = timeLimit
|
||||
meeting.JiraURL = jiraURL
|
||||
return a.store.UpdateMeeting(meeting)
|
||||
}
|
||||
|
||||
@@ -153,6 +185,17 @@ func (a *App) StartMeeting(participantOrder []uint, attendance map[uint]bool) er
|
||||
}
|
||||
}
|
||||
|
||||
a.participantURLs = make(map[uint]string)
|
||||
if meeting.JiraURL != "" {
|
||||
for _, p := range participants {
|
||||
url := meeting.JiraURL
|
||||
if p.JiraFilter != "" {
|
||||
url = meeting.JiraURL + "&quickFilter=" + p.JiraFilter
|
||||
}
|
||||
a.participantURLs[p.ID] = url
|
||||
}
|
||||
}
|
||||
|
||||
a.timer = timer.New(meeting.TimeLimit, warningThreshold)
|
||||
a.timer.SetQueue(queue)
|
||||
a.currentLogs = make(map[uint]*models.ParticipantLog)
|
||||
@@ -177,15 +220,42 @@ func (a *App) handleTimerEvents() {
|
||||
runtime.EventsEmit(a.ctx, "timer:meeting_timeup", event.State)
|
||||
case timer.EventSpeakerChanged:
|
||||
a.saveSpeakerLog(event.State)
|
||||
if url, ok := a.participantURLs[event.State.CurrentSpeakerID]; ok && url != "" {
|
||||
runtime.BrowserOpenURL(a.ctx, url)
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
||||
case timer.EventMeetingEnded:
|
||||
a.saveSpeakerLog(event.State)
|
||||
a.finalizeSpeakerLogs(event.State)
|
||||
a.endMeetingSession(event.State)
|
||||
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) finalizeSpeakerLogs(state models.TimerState) {
|
||||
if a.session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only finalize existing logs, don't create new ones
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) saveSpeakerLog(state models.TimerState) {
|
||||
if a.session == nil {
|
||||
return
|
||||
@@ -247,6 +317,12 @@ func (a *App) RemoveFromQueue(speakerID uint) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) SwitchToSpeaker(speakerID uint) {
|
||||
if a.timer != nil {
|
||||
a.timer.SwitchToSpeaker(speakerID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) PauseMeeting() {
|
||||
if a.timer != nil {
|
||||
a.timer.Pause()
|
||||
|
||||
@@ -5,19 +5,21 @@ import (
|
||||
)
|
||||
|
||||
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" tsType:"string"`
|
||||
UpdatedAt time.Time `json:"updatedAt" tsType:"string"`
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Email string `json:"email,omitempty"`
|
||||
JiraFilter string `json:"jiraFilter,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" tsType:"string"`
|
||||
UpdatedAt time.Time `json:"updatedAt" tsType:"string"`
|
||||
}
|
||||
|
||||
type Meeting struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null;default:Daily Standup"`
|
||||
JiraURL string `json:"jiraUrl,omitempty"`
|
||||
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" tsType:"string"`
|
||||
@@ -67,6 +69,9 @@ type Settings struct {
|
||||
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
|
||||
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
||||
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
|
||||
WindowX int `json:"windowX" gorm:"default:-1"` // -1 = not set (center)
|
||||
WindowY int `json:"windowY" gorm:"default:-1"` // -1 = not set (center)
|
||||
DefaultJiraUrl string `json:"defaultJiraUrl" gorm:"default:''"`
|
||||
}
|
||||
|
||||
@@ -117,9 +117,10 @@ func (s *Storage) CreateParticipant(p *models.Participant) 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,
|
||||
"name": p.Name,
|
||||
"email": p.Email,
|
||||
"jira_filter": p.JiraFilter,
|
||||
"time_limit": p.TimeLimit,
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -105,15 +105,20 @@ func (t *Timer) Start() {
|
||||
t.speakerWarned = false
|
||||
t.meetingWarned = false
|
||||
|
||||
if len(t.queue) > 0 {
|
||||
t.startNextSpeaker(now)
|
||||
hasSpeakers := len(t.queue) > 0
|
||||
if hasSpeakers {
|
||||
t.startNextSpeaker(now, 0)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
if hasSpeakers {
|
||||
t.emit(EventSpeakerChanged)
|
||||
}
|
||||
|
||||
go t.tick()
|
||||
}
|
||||
|
||||
func (t *Timer) startNextSpeaker(now time.Time) {
|
||||
func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
|
||||
if len(t.queue) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -137,8 +142,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
|
||||
t.currentSpeakerID = speaker.ID
|
||||
t.currentSpeaker = speaker.Name
|
||||
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
||||
t.speakerStartTime = now
|
||||
t.speakerElapsed = 0
|
||||
t.speakerStartTime = now.Add(-offset)
|
||||
t.speakerElapsed = offset
|
||||
t.speakingOrder++
|
||||
t.speakerWarned = false
|
||||
t.speakerTimeUpEmitted = false
|
||||
@@ -193,7 +198,7 @@ func (t *Timer) NextSpeaker() {
|
||||
|
||||
var eventType EventType
|
||||
if len(t.queue) > 0 {
|
||||
t.startNextSpeaker(now)
|
||||
t.startNextSpeaker(now, 0)
|
||||
eventType = EventSpeakerChanged
|
||||
} else {
|
||||
t.running = false
|
||||
@@ -227,11 +232,14 @@ func (t *Timer) SkipSpeaker() {
|
||||
|
||||
now := time.Now()
|
||||
if len(t.queue) > 1 {
|
||||
t.startNextSpeaker(now)
|
||||
t.startNextSpeaker(now, 0)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
} else {
|
||||
// Only skipped speaker left - they need to speak now
|
||||
t.startNextSpeaker(now, 0)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +256,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from queue
|
||||
// Find speaker info before removing
|
||||
var speakerInfo models.QueuedSpeaker
|
||||
for _, s := range t.queue {
|
||||
if s.ID == speakerID {
|
||||
speakerInfo = s
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from current position in queue
|
||||
for i, s := range t.queue {
|
||||
if s.ID == speakerID {
|
||||
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
||||
@@ -256,11 +273,124 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add to end of queue so they can speak later
|
||||
if speakerInfo.ID != 0 {
|
||||
t.queue = append(t.queue, speakerInfo)
|
||||
}
|
||||
|
||||
// Mark as skipped in allSpeakers and move to end
|
||||
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
||||
t.moveSpeakerToEnd(speakerID)
|
||||
}
|
||||
|
||||
// SwitchToSpeaker moves the specified speaker to front of queue and starts them
|
||||
// If speaker is already done, resumes their timer from accumulated time
|
||||
func (t *Timer) SwitchToSpeaker(speakerID uint) {
|
||||
t.mu.Lock()
|
||||
|
||||
if !t.running {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// First, find speaker in allSpeakers to get their info and status
|
||||
var speakerInfo *models.SpeakerInfo
|
||||
var speakerInfoIdx int
|
||||
for i := range t.allSpeakers {
|
||||
if t.allSpeakers[i].ID == speakerID {
|
||||
speakerInfo = &t.allSpeakers[i]
|
||||
speakerInfoIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if speakerInfo == nil {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Don't switch to currently speaking speaker
|
||||
if speakerInfo.Status == models.SpeakerStatusSpeaking {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate offset for resuming (0 for pending/skipped, timeSpent for done)
|
||||
var offset time.Duration
|
||||
if speakerInfo.Status == models.SpeakerStatusDone {
|
||||
offset = time.Duration(speakerInfo.TimeSpent) * time.Second
|
||||
}
|
||||
|
||||
// Save current speaker time
|
||||
now := time.Now()
|
||||
if t.currentSpeakerID != 0 {
|
||||
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
||||
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
|
||||
t.allSpeakers[i].TimeSpent = timeSpent
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find speaker in queue (pending/skipped) or create new entry (done)
|
||||
foundIdx := -1
|
||||
for i, s := range t.queue {
|
||||
if s.ID == speakerID {
|
||||
foundIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create QueuedSpeaker from SpeakerInfo
|
||||
queuedSpeaker := models.QueuedSpeaker{
|
||||
ID: speakerInfo.ID,
|
||||
Name: speakerInfo.Name,
|
||||
TimeLimit: speakerInfo.TimeLimit,
|
||||
Order: speakerInfo.Order,
|
||||
}
|
||||
|
||||
if foundIdx >= 0 {
|
||||
// Remove from current position in queue
|
||||
t.queue = append(t.queue[:foundIdx], t.queue[foundIdx+1:]...)
|
||||
}
|
||||
// Insert at front of queue
|
||||
t.queue = append([]models.QueuedSpeaker{queuedSpeaker}, t.queue...)
|
||||
|
||||
// Move the selected speaker in allSpeakers to position after last done/speaking
|
||||
insertPos := 0
|
||||
for i, s := range t.allSpeakers {
|
||||
if s.Status == models.SpeakerStatusDone || s.Status == models.SpeakerStatusSpeaking {
|
||||
insertPos = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
if speakerInfoIdx >= 0 && speakerInfoIdx != insertPos {
|
||||
// Save speaker info before removing
|
||||
savedInfo := *speakerInfo
|
||||
// Remove from current position
|
||||
t.allSpeakers = append(t.allSpeakers[:speakerInfoIdx], t.allSpeakers[speakerInfoIdx+1:]...)
|
||||
// Adjust insert position if needed
|
||||
if speakerInfoIdx < insertPos {
|
||||
insertPos--
|
||||
}
|
||||
// Insert at new position
|
||||
t.allSpeakers = append(t.allSpeakers[:insertPos], append([]models.SpeakerInfo{savedInfo}, t.allSpeakers[insertPos:]...)...)
|
||||
// Update order numbers
|
||||
for i := range t.allSpeakers {
|
||||
t.allSpeakers[i].Order = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Start this speaker with offset (0 for new speakers, accumulated time for done)
|
||||
t.startNextSpeaker(now, offset)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
}
|
||||
|
||||
func (t *Timer) Pause() {
|
||||
t.mu.Lock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user