Compare commits

..

6 Commits

Author SHA1 Message Date
d7a01e0087 release: версия 0.0.39
Some checks are pending
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
2026-08-11 20:52:36 +03:00
9fb1f1f7ee fix(calendar): починить ввод в поле «Длительность»
Числовое состояние (`Number(value) || 0`) не давало очистить поле —
удаление тут же превращалось обратно в 0, и следующая цифра визуально
дописывалась к нему. Состояние переведено на строку (пустое значение
теперь возможно, приводится к числу перед отправкой), плюс выделение
содержимого по фокусу — так первая же цифра заменяет старое значение,
а не приписывается к нему.
2026-08-11 20:50:08 +03:00
e2254819c7 fix(conferences): разрешить дату в прошлом для закреплённых конференций
Закреплённая конференция — постоянная комната-ссылка, а не разовое
событие: прошедшая дата в scheduled_at для неё осмысленна (например,
серия «каждый день с такого-то числа», уже наступившего). Запрет
прошлого остаётся в силе только для обычных конференций.
2026-08-11 20:49:50 +03:00
e71bf0623f feat(auth): добавить вкладку «Зайти как гость» на экраны входа/регистрации
Незалогиненный пользователь может сразу перейти на /join, не выбирая
между входом и регистрацией. На узких экранах (375/360px) подпись
сокращается до «Гость», чтобы вкладка осталась в одну строку.
2026-08-11 20:49:33 +03:00
b12d32dfba release: версия 0.0.38
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-11 02:04:00 +03:00
359f9fd1f5 fix(room): починить чернеющее своё видео в Safari при выключении фона
Safari гасил собственный обходной путь для чёрного кадра — охрана от
мерцания в livekit-client сравнивает srcObject с тем же объектом
MediaStream, а detach/attach внутри библиотеки промахивался мимо
обработанного трека и мутировал поток на месте. Переприцепляем
элементы вручную после stopProcessor(), чтобы пересоздать MediaStream
и дать защите сработать штатно.
2026-08-11 02:03:24 +03:00
11 changed files with 115 additions and 7 deletions

View File

@@ -133,7 +133,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.37
VIDCONF_VERSION=0.0.39
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

View File

@@ -3,6 +3,39 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.39] — 2026-08-11
### Добавлено
- Вкладка «Зайти как гость» на экранах входа и регистрации — ведёт на
`/join`, чтобы незалогиненный пользователь мог подключиться по номеру
конференции, не выбирая между входом и регистрацией. На узких экранах
(375/360px) подпись сокращается до «Гость» (та же схема коротких
подписей, что у «Мини-окно»/«Мини» в тулбаре комнаты).
### Исправлено
- Дата и время в прошлом были запрещены сервером для ЛЮБОЙ конференции
при создании, включая закреплённые — хотя закреплённая конференция не
разовое событие, а постоянная комната-ссылка, и прошедшая дата для неё
осмысленна (например, серия «каждый день с такого-то числа», уже
наступившего). Запрет прошлого теперь действует только для обычных
конференций.
- Поле «Длительность, мин» в форме конференции: числовое состояние
(`Number(value) || 0`) не давало очистить поле — стирание тут же
превращалось обратно в 0, и следующая цифра визуально дописывалась
к нему вместо замены. Состояние переведено на строку, плюс выделение
содержимого по фокусу.
## [0.0.38] — 2026-08-11
### Исправлено
- Safari: при выключении виртуального фона в комнате чернело собственное
видео (остальные участники видели картинку нормально). Причина —
внутренняя охрана livekit-client от мерцания сравнивала `srcObject` с
тем же объектом `MediaStream` и из-за этого не давала сработать
собственному обходному пути библиотеки для чёрного кадра в Safari.
Теперь после остановки процессора фона элементы принудительно
переприцепляются (`detach`/`attach`), пересоздавая `MediaStream`.
## [0.0.37] — 2026-08-10
Закрытие технического долга. Без изменений в продуктовой логике, кроме

View File

@@ -1 +1 @@
0.0.37
0.0.39

View File

@@ -91,7 +91,17 @@ class ConferenceCreateIn(BaseModel):
raise ValueError("closed_conference_requires_password")
if self.recurrence is not None and not self.is_pinned:
raise ValueError("recurrence_requires_pinned")
if self.scheduled_at is not None and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE:
# Запрет прошлого — только для обычных (не закреплённых) конференций.
# Закреплённая конференция — не разовое событие с конкретной датой, а
# постоянная комната-ссылка (см. комментарий у `is_pinned` в
# `models/conference.py`); `scheduled_at` для неё — не более чем
# подсказка в календаре, и прошедшая дата (например, «встречаемся тут
# каждый день с такого-то числа», уже наступившего) осмысленна.
if (
not self.is_pinned
and self.scheduled_at is not None
and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE
):
raise ValueError("scheduled_at_in_the_past")
return self

View File

@@ -212,6 +212,23 @@ async def test_create_scheduled_in_the_past_returns_422(
assert response.status_code == 422
async def test_create_pinned_scheduled_in_the_past_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Закреплённая конференция — не разовое событие, прошлая дата для неё осмысленна."""
user = await _make_user(db_session)
await db_session.commit()
past = datetime.now(UTC) - timedelta(hours=1)
response = await client.post(
"/api/v1/conferences",
json={"title": "Pinned past", "scheduled_at": _iso(past), "is_pinned": True},
headers=_auth_headers(user),
)
assert response.status_code == 201
assert response.json()["is_pinned"] is True
# --- «Мои конференции» ---------------------------------------------------------

View File

@@ -94,7 +94,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.37}
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.39}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул

View File

@@ -75,7 +75,16 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
const [title, setTitle] = useState(conference?.title ?? '')
const [dateStr, setDateStr] = useState(initialSlot.date)
const [timeStr, setTimeStr] = useState(initialSlot.time)
const [durationMinutes, setDurationMinutes] = useState(conference?.duration_minutes ?? 30)
// Строкой, а не числом: контролируемый number-инпут с числовым состоянием
// не даёт очистить поле (`Number('') || 0` тут же возвращает 0, значение
// "0" вспыхивает обратно) — строка хранит именно то, что ввёл пользователь,
// включая пустоту. Вторая половина жалобы («ввод дописывается после 0», а
// не заменяет его) — не про хранение, а про то, что при фокусе курсор
// просто вставал рядом с текущим значением; чинится `onFocus={select()}`
// у самого `<input>` ниже. `durationMinutes` — производное число для
// валидации и отправки.
const [durationMinutesInput, setDurationMinutesInput] = useState(String(conference?.duration_minutes ?? 30))
const durationMinutes = Number(durationMinutesInput) || 0
const [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
const [password, setPassword] = useState('')
@@ -306,8 +315,9 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
type="number"
min={5}
step={5}
value={durationMinutes}
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
value={durationMinutesInput}
onChange={(e) => setDurationMinutesInput(e.target.value)}
onFocus={(e) => e.target.select()}
/>
</div>

View File

@@ -103,6 +103,22 @@ export function useVirtualBackground(
if (!track || !desiredUrl) {
if (track && processorRef.current) {
await track.stopProcessor()
// Safari-обход: `stopProcessor` внутри livekit-client меняет трек
// в элементе «на месте», переиспользуя тот же объект `MediaStream`
// (внутренний `detach` промахивается мимо обработанного трека,
// который реально висел в элементе). Из-за этого собственная
// защита библиотеки от мерцания (`srcObject !== mediaStream` в
// `attachToElement`) не даёт сработать её же обходному пути для
// чёрного кадра в Safari. Пересоздаём `MediaStream` вручную —
// detach обнулит `srcObject`, attach создаст новый объект, защита
// пропустит штатный `play()`. Элемент остаётся в своём документе,
// никуда не переезжает. Копия массива обязательна: `detach`
// правит `attachedElements` по ходу перебора. Уберётся, если
// библиотека когда-нибудь починит эту защиту.
for (const el of [...track.attachedElements]) {
track.detach(el)
track.attach(el)
}
await processorRef.current.destroy()
processorRef.current = null
}

View File

@@ -70,6 +70,10 @@ export function LoginPage() {
<Link to="/register" className="tab" role="tab" aria-selected="false">
Регистрация
</Link>
<Link to="/join" className="tab" role="tab" aria-selected="false">
<span className="label-full">Зайти как гость</span>
<span className="label-short">Гость</span>
</Link>
</div>
<form

View File

@@ -133,6 +133,10 @@ export function RegisterPage() {
<span className="tab is-active" role="tab" aria-selected="true">
Регистрация
</span>
<Link to="/join" className="tab" role="tab" aria-selected="false">
<span className="label-full">Зайти как гость</span>
<span className="label-short">Гость</span>
</Link>
</div>
<h2 className="screen-title">Создать аккаунт</h2>
<form onSubmit={handleSubmit} noValidate>

View File

@@ -104,6 +104,11 @@
}
.tab.is-active { background: var(--color-surface); color: var(--color-ink-700); font-weight: 600; box-shadow: var(--shadow-sm); }
/* Короткая подпись вкладки «Зайти как гость» — на узком экране (см. @media
ниже) длинный вариант переносится на 2 строки и делает вкладку выше
соседних; та же схема, что у «Мини-окно»/«Мини» в RoomToolbar.tsx/room.css. */
.tab .label-short { display: none; }
.field { margin-bottom: var(--space-5); }
.field label { display: block; font: var(--text-body); font-weight: 600; color: var(--color-ink-900); margin-bottom: var(--space-2); }
.input-wrap { position: relative; display: flex; align-items: center; }
@@ -289,4 +294,13 @@
.stat-glass { flex-basis: 100%; }
.form-panel { padding: var(--space-6) var(--space-4) var(--space-10); }
/* Три вкладки (Вход/Регистрация/Зайти как гость) на ширину 375/360px в
исходный `width: fit-content` не помещаются — растягиваем группу на
всю ширину блока и даём каждой вкладке одинаковую долю вместо
ужимания по контенту, иначе текст «Регистрация» переносится. */
.tabs { width: 100%; }
.tab { flex: 1; justify-content: center; padding: 8px 6px; font-size: 13px; }
.tab .label-full { display: none; }
.tab .label-short { display: inline; }
}