Compare commits

...

4 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
10 changed files with 88 additions and 7 deletions

View File

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

View File

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

View File

@@ -1 +1 @@
0.0.38 0.0.39

View File

@@ -91,7 +91,17 @@ class ConferenceCreateIn(BaseModel):
raise ValueError("closed_conference_requires_password") raise ValueError("closed_conference_requires_password")
if self.recurrence is not None and not self.is_pinned: if self.recurrence is not None and not self.is_pinned:
raise ValueError("recurrence_requires_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") raise ValueError("scheduled_at_in_the_past")
return self return self

View File

@@ -212,6 +212,23 @@ async def test_create_scheduled_in_the_past_returns_422(
assert response.status_code == 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} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.38} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.39}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул

View File

@@ -75,7 +75,16 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
const [title, setTitle] = useState(conference?.title ?? '') const [title, setTitle] = useState(conference?.title ?? '')
const [dateStr, setDateStr] = useState(initialSlot.date) const [dateStr, setDateStr] = useState(initialSlot.date)
const [timeStr, setTimeStr] = useState(initialSlot.time) 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 [isClosed, setIsClosed] = useState(conference?.is_closed ?? false)
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -306,8 +315,9 @@ export function ConferenceFormCard({ conference, onSaved, onDeleted, onCancelEdi
type="number" type="number"
min={5} min={5}
step={5} step={5}
value={durationMinutes} value={durationMinutesInput}
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)} onChange={(e) => setDurationMinutesInput(e.target.value)}
onFocus={(e) => e.target.select()}
/> />
</div> </div>

View File

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

View File

@@ -133,6 +133,10 @@ export function RegisterPage() {
<span className="tab is-active" role="tab" aria-selected="true"> <span className="tab is-active" role="tab" aria-selected="true">
Регистрация Регистрация
</span> </span>
<Link to="/join" className="tab" role="tab" aria-selected="false">
<span className="label-full">Зайти как гость</span>
<span className="label-short">Гость</span>
</Link>
</div> </div>
<h2 className="screen-title">Создать аккаунт</h2> <h2 className="screen-title">Создать аккаунт</h2>
<form onSubmit={handleSubmit} noValidate> <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); } .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 { 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); } .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; } .input-wrap { position: relative; display: flex; align-items: center; }
@@ -289,4 +294,13 @@
.stat-glass { flex-basis: 100%; } .stat-glass { flex-basis: 100%; }
.form-panel { padding: var(--space-6) var(--space-4) var(--space-10); } .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; }
} }