feat(channels): фон комнаты и статичные превью при reduced-motion

- миграция 00014: background_file_id у комнаты (AGENT.md 7.5, 7.7)
- API: POST/DELETE /channels/{id}/background под MANAGE_CHANNEL_BACKGROUND,
  фон отдаётся в списке комнат и рассылается событием CHANNEL_UPDATE
- клиент: фон рисуется за лентой с затемнением, блок «Фон комнаты» в настройках
  комнаты (загрузка, замена, удаление)
- AnimatedImage: при «уменьшить движение» показывает статичный первый кадр,
  нарисованный на canvas, вместо анимации
- тесты: Go (загрузка, права, снятие) и Vitest (лента, настройки, reduced-motion)
This commit is contained in:
2026-09-21 18:08:21 +03:00
parent 91f415e36d
commit 0f0060e8a4
19 changed files with 733 additions and 16 deletions
+27 -13
View File
@@ -27,9 +27,11 @@ type Channel struct {
SlowmodeSeconds int
NSFW bool
Background string
VoiceStatus string
UserLimit int
CreatedAt time.Time
// BackgroundFileID — фон комнаты: файл-картинка (AGENT.md 7.5).
BackgroundFileID *uint64
VoiceStatus string
UserLimit int
CreatedAt time.Time
}
type ChannelOverride struct {
@@ -54,7 +56,7 @@ type CreateChannelParams struct {
}
const channelColumns = `id, guild_id, type, name, description, position, parent_id,
slowmode_seconds, nsfw, background, voice_status, user_limit, created_at`
slowmode_seconds, nsfw, background, background_file_id, voice_status, user_limit, created_at`
func (s *Store) CreateChannel(ctx context.Context, params CreateChannelParams) (*Channel, error) {
if params.ID == 0 {
@@ -95,8 +97,11 @@ type UpdateChannelParams struct {
SlowmodeSeconds *int
NSFW *bool
Background *string
VoiceStatus *string
UserLimit *int
// BackgroundFileID задаёт новый фон, ClearBackground снимает его.
BackgroundFileID *uint64
ClearBackground bool
VoiceStatus *string
UserLimit *int
}
func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChannelParams) (*Channel, error) {
@@ -133,6 +138,13 @@ func (s *Store) UpdateChannel(ctx context.Context, id uint64, params UpdateChann
sets = append(sets, "background = ?")
args = append(args, nullableString(*params.Background))
}
if params.BackgroundFileID != nil {
sets = append(sets, "background_file_id = ?")
args = append(args, int64(*params.BackgroundFileID))
}
if params.ClearBackground {
sets = append(sets, "background_file_id = NULL")
}
if params.VoiceStatus != nil {
sets = append(sets, "voice_status = ?")
args = append(args, *params.VoiceStatus)
@@ -207,16 +219,17 @@ func (s *Store) ListChannelOverrides(ctx context.Context, channelID uint64) ([]C
func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
var (
channel Channel
guildID sql.NullInt64
parentID sql.NullInt64
nsfw int
background sql.NullString
createdAt string
channel Channel
guildID sql.NullInt64
parentID sql.NullInt64
nsfw int
background sql.NullString
backgroundID sql.NullInt64
createdAt string
)
err := scanner.Scan(&channel.ID, &guildID, &channel.Type, &channel.Name, &channel.Description,
&channel.Position, &parentID, &channel.SlowmodeSeconds, &nsfw, &background,
&channel.VoiceStatus, &channel.UserLimit, &createdAt)
&backgroundID, &channel.VoiceStatus, &channel.UserLimit, &createdAt)
if err != nil {
return nil, mapError(err)
}
@@ -226,6 +239,7 @@ func scanChannel(scanner interface{ Scan(...any) error }) (*Channel, error) {
if background.Valid {
channel.Background = background.String
}
channel.BackgroundFileID = optionalID(backgroundID)
channel.CreatedAt = parseTimestamp(createdAt)
return &channel, nil
}