feat(cosmetics): оформление профиля — галерея, роли и личные стили

- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
  и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
  (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
  больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
  GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
  настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
  ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
2026-09-21 01:28:48 +03:00
parent 2e038a1d3f
commit 48e30a1897
25 changed files with 3542 additions and 41 deletions
+67 -7
View File
@@ -41,6 +41,11 @@ type rolePayload struct {
IsDefault bool `json:"is_default"`
Hoist bool `json:"hoist"`
Mentionable bool `json:"mentionable"`
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
type memberPayload struct {
@@ -55,6 +60,8 @@ type memberPayload struct {
JoinedAt string `json:"joined_at"`
TimeoutUntil string `json:"timeout_until,omitempty"`
RoleIDs []string `json:"role_ids"`
// Вычисленное оформление участника на этом сервере (AGENT.md 7.2).
Cosmetics *cosmeticsPayload `json:"cosmetics,omitempty"`
}
type guildDetailPayload struct {
@@ -99,7 +106,7 @@ func channelFromStore(channel store.Channel, resolved *permissions.Resolved) cha
}
func roleFromStore(role store.Role) rolePayload {
return rolePayload{
payload := rolePayload{
ID: formatSnowflake(role.ID),
Name: role.Name,
Color: role.Color,
@@ -108,7 +115,18 @@ func roleFromStore(role store.Role) rolePayload {
IsDefault: role.IsDefault,
Hoist: role.Hoist,
Mentionable: role.Mentionable,
NickColor: role.NickColor,
}
if role.CosmeticFrameID != nil {
payload.CosmeticFrameID = formatSnowflake(*role.CosmeticFrameID)
}
if role.CosmeticBadgeID != nil {
payload.CosmeticBadgeID = formatSnowflake(*role.CosmeticBadgeID)
}
if role.NickEffect != "" && role.NickEffect != string(store.NickEffectNone) {
payload.NickEffect = role.NickEffect
}
return payload
}
type guildOutput struct {
@@ -743,13 +761,20 @@ func (s *Server) registerMemberRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление считается поэлементно для всего сервера (AGENT.md 7.2).
cosmetics, err := s.store.EffectiveCosmeticsForGuild(ctx, guildID)
if err != nil {
return nil, humaError(err)
}
output := &memberListOutput{}
output.Body.Members = make([]memberPayload, 0, len(profiles))
for _, profile := range profiles {
if len(output.Body.Members) >= input.Limit {
break
}
output.Body.Members = append(output.Body.Members, memberFromProfile(profile))
payload := memberFromProfile(profile)
payload.Cosmetics = cosmeticsFromEffective(cosmetics[profile.UserID])
output.Body.Members = append(output.Body.Members, payload)
}
return output, nil
})
@@ -1098,11 +1123,15 @@ func (s *Server) registerRoleRoutes(api huma.API) {
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
Permissions string `json:"permissions,omitempty"`
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Hoist bool `json:"hoist,omitempty"`
Mentionable bool `json:"mentionable,omitempty"`
Name string `json:"name" minLength:"1" maxLength:"64"`
Permissions string `json:"permissions,omitempty"`
Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"`
Hoist bool `json:"hoist,omitempty"`
Mentionable bool `json:"mentionable,omitempty"`
CosmeticFrameID string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect string `json:"nick_effect,omitempty"`
}
},
) (*roleOutput, error) {
@@ -1136,6 +1165,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4) сохраняем тем же обработчиком правки.
if role, err = s.applyRoleCosmetics(ctx, guildID, role, roleCosmeticsInput{
FrameID: input.Body.CosmeticFrameID,
BadgeID: input.Body.CosmeticBadgeID,
NickColor: input.Body.NickColor,
NickEffect: input.Body.NickEffect,
FrameSet: input.Body.CosmeticFrameID != "",
BadgeSet: input.Body.CosmeticBadgeID != "",
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != "",
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "")
s.dispatchGuildEvent(guildID, "ROLE_CREATE", roleFromStore(*role))
@@ -1161,6 +1203,11 @@ func (s *Server) registerRoleRoutes(api huma.API) {
Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"`
Hoist *bool `json:"hoist,omitempty"`
Mentionable *bool `json:"mentionable,omitempty"`
// Оформление роли (AGENT.md 7.4): пустая строка снимает элемент.
CosmeticFrameID *string `json:"cosmetic_frame_id,omitempty"`
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect *string `json:"nick_effect,omitempty"`
}
},
) (*roleOutput, error) {
@@ -1208,6 +1255,19 @@ func (s *Server) registerRoleRoutes(api huma.API) {
if err != nil {
return nil, humaError(err)
}
// Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника.
if updated, err = s.applyRoleCosmetics(ctx, guildID, updated, roleCosmeticsInput{
FrameID: optionalString(input.Body.CosmeticFrameID),
BadgeID: optionalString(input.Body.CosmeticBadgeID),
NickColor: input.Body.NickColor,
NickEffect: optionalString(input.Body.NickEffect),
FrameSet: input.Body.CosmeticFrameID != nil,
BadgeSet: input.Body.CosmeticBadgeID != nil,
ColorSet: input.Body.NickColor != nil,
EffectSet: input.Body.NickEffect != nil,
}); err != nil {
return nil, err
}
s.invalidateGuild(guildID)
s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "")
s.dispatchGuildEvent(guildID, "ROLE_UPDATE", roleFromStore(*updated))