Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a01e0087 | |||
| 9fb1f1f7ee | |||
| e2254819c7 | |||
| e71bf0623f |
@@ -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) + мониторинг,
|
||||||
|
|||||||
22
CHANGELOG.md
22
CHANGELOG.md
@@ -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
|
||||||
|
|
||||||
### Исправлено
|
### Исправлено
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# --- «Мои конференции» ---------------------------------------------------------
|
# --- «Мои конференции» ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user