Add participant creation functionality to event API, including backend service and database integration. Update frontend to support participant registration and enhance UI for displaying registered players.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 01:21:00 +03:00
parent d85d5ffdc0
commit 59c0bdb345
9 changed files with 217 additions and 6 deletions

View File

@@ -71,6 +71,7 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
api.Put("/api/events/{eventID}", s.updateEvent) api.Put("/api/events/{eventID}", s.updateEvent)
api.Get("/api/events/{eventID}/tournament", s.getEventTournament) api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
api.Get("/api/events/{eventID}/rsvps", s.rsvps) api.Get("/api/events/{eventID}/rsvps", s.rsvps)
api.Post("/api/events/{eventID}/participants", s.createParticipant)
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP) api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
api.Post("/api/events/{eventID}/balance", s.balance) api.Post("/api/events/{eventID}/balance", s.balance)
api.Put("/api/events/{eventID}/teams", s.selectBalance) api.Put("/api/events/{eventID}/teams", s.selectBalance)
@@ -277,6 +278,19 @@ func (s *Server) rsvps(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, 200) respond(w, out, err, 200)
} }
func (s *Server) createParticipant(w http.ResponseWriter, r *http.Request) {
var in struct {
DisplayName string `json:"displayName"`
Ratings domain.Ratings `json:"ratings"`
Status domain.RSVPStatus `json:"status"`
}
if !decode(w, r, &in) {
return
}
player, rsvp, err := s.service.CreateParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.DisplayName, in.Ratings, in.Status)
respond(w, map[string]any{"player": player, "rsvp": rsvp}, err, http.StatusCreated)
}
func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) { func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) {
var in struct { var in struct {
Status domain.RSVPStatus `json:"status"` Status domain.RSVPStatus `json:"status"`

View File

@@ -122,7 +122,7 @@ func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Playe
} }
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) { func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
rows, err := s.pool.Query(ctx, `SELECT id,account_id,display_name,tank_rating,damage_rating,support_rating, rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`) preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -147,6 +147,40 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain.RSVP) (domain.Player, domain.RSVP, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.Player{}, domain.RSVP{}, err
}
defer tx.Rollback(ctx)
var preferredRoles, preferredPlayers []byte
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`,
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
if err == nil {
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
}
if err == nil {
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
}
if err == nil {
err = tx.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at)
VALUES($1,$2,$3,$4,$5,$6)
RETURNING event_id,player_id,status,actor_account_id,source,updated_at`,
r.EventID, r.PlayerID, r.Status, r.ActorAccountID, r.Source, r.UpdatedAt).
Scan(&r.EventID, &r.PlayerID, &r.Status, &r.ActorAccountID, &r.Source, &r.UpdatedAt)
}
if err == nil {
err = tx.Commit(ctx)
}
if err != nil {
return domain.Player{}, domain.RSVP{}, mapError(err)
}
return p, r, nil
}
func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) { func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) {
err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at) err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)

View File

@@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings"
"time" "time"
"mixmaker/backend/internal/domain" "mixmaker/backend/internal/domain"
@@ -18,6 +19,7 @@ type Store interface {
DeleteSession(context.Context, string) error DeleteSession(context.Context, string) error
UpdatePlayer(context.Context, domain.Player) (domain.Player, error) UpdatePlayer(context.Context, domain.Player) (domain.Player, error)
ListPlayers(context.Context) ([]domain.Player, error) ListPlayers(context.Context) ([]domain.Player, error)
CreateParticipant(context.Context, domain.Player, domain.RSVP) (domain.Player, domain.RSVP, error)
CreateEvent(context.Context, domain.Event) (domain.Event, error) CreateEvent(context.Context, domain.Event) (domain.Event, error)
GetEvent(context.Context, string) (domain.Event, error) GetEvent(context.Context, string) (domain.Event, error)
UpdateEvent(context.Context, domain.Event) (domain.Event, error) UpdateEvent(context.Context, domain.Event) (domain.Event, error)
@@ -174,6 +176,47 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
return out, err return out, err
} }
func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) {
if !actor.IsAdmin() {
return domain.Player{}, domain.RSVP{}, domain.ErrForbidden
}
displayName = strings.TrimSpace(displayName)
if displayName == "" {
return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: display name is required", domain.ErrInvalid)
}
if err := ratings.Validate(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
if _, err := s.Store.GetEvent(ctx, eventID); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
now := s.Now()
player := domain.Player{
ID: NewID(),
DisplayName: displayName,
Ratings: ratings,
CreatedAt: now,
UpdatedAt: now,
}
rsvp := domain.RSVP{
EventID: eventID,
PlayerID: player.ID,
ActorAccountID: actor.ID,
Status: status,
Source: domain.SourceAdmin,
UpdatedAt: now,
}
if err := rsvp.Validate(); err != nil {
return domain.Player{}, domain.RSVP{}, err
}
player, rsvp, err := s.Store.CreateParticipant(ctx, player, rsvp)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp})
s.Bus.Publish("event:"+eventID, rsvp)
}
return player, rsvp, err
}
func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) { func (s *Service) Balance(ctx context.Context, actor domain.Account, eventID string) ([]domain.BalanceCandidate, error) {
if !actor.IsAdmin() { if !actor.IsAdmin() {
return nil, domain.ErrForbidden return nil, domain.ErrForbidden

View File

@@ -0,0 +1,9 @@
-- +mixmaker Up
ALTER TABLE players
ALTER COLUMN account_id DROP NOT NULL;
-- +mixmaker Down
DELETE FROM rsvps WHERE player_id IN (SELECT id FROM players WHERE account_id IS NULL);
DELETE FROM players WHERE account_id IS NULL;
ALTER TABLE players
ALTER COLUMN account_id SET NOT NULL;

View File

@@ -11,7 +11,7 @@ import {
} from '@tanstack/react-router' } from '@tanstack/react-router'
import { import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent, api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type PlayerRole, type RsvpStatus, type Series, type Session, type Team, type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team,
} from './api/client' } from './api/client'
import { import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile, demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
@@ -144,10 +144,12 @@ function EventPage() {
const event = eventQuery.data const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
const statusOrder: Record<RsvpStatus, number> = { going: 0, maybe: 1, not_going: 2 }
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout"> return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section> <section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside> <aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div></div> </div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
} }
function ProfilePage() { function ProfilePage() {
@@ -234,7 +236,38 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event:
function Participants({ eventId }: { eventId: string }) { function Participants({ eventId }: { eventId: string }) {
const client = useQueryClient() const client = useQueryClient()
const language = useLanguage() const language = useLanguage()
const options = useMemo(() => rankOptions(language), [language])
const [displayName, setDisplayName] = useState('')
const [status, setStatus] = useState<RsvpStatus>('going')
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 13, damage: 13, support: 13 })
const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined }) const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const create = useMutation({
mutationFn: () => {
if (!demoMode) return api.createParticipant(eventId, displayName.trim(), ratings, status)
const now = new Date().toISOString()
const id = `guest-${Date.now()}`
return Promise.resolve<Registration>({
id: `${eventId}:${id}`,
player: {
id,
displayName: displayName.trim(),
ratings: { ...ratings, updatedAt: now },
preferredRoles: [],
preferredPlayerIds: [],
},
status,
updatedAt: now,
changedBy: { id: demoSession.account.id, displayName: demoSession.account.displayName, source: 'admin' },
})
},
onSuccess: (registration) => {
client.setQueryData<Registration[]>(['registrations', eventId], (current) => [...(current ?? []), registration])
setDisplayName('')
setRatings({ tank: 13, damage: 13, support: 13 })
setStatus('going')
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
const override = useMutation({ const override = useMutation({
mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status), mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status),
onSuccess: () => { onSuccess: () => {
@@ -245,7 +278,7 @@ function Participants({ eventId }: { eventId: string }) {
if (rows.isLoading) return <LoadingState label="Loading participants…" /> if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} /> if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName])) const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section> return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
} }
function BalanceView({ eventId }: { eventId: string }) { function BalanceView({ eventId }: { eventId: string }) {

View File

@@ -442,6 +442,23 @@ export const api = {
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session))) return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session)))
}, },
createParticipant: async (
eventId: string,
displayName: string,
ratings: Pick<Ratings, PlayerRole>,
status: RsvpStatus,
) => {
const result = await request<{ player: RawPlayer; rsvp: RawRSVP }>(`/events/${eventId}/participants`, {
method: 'POST',
body: JSON.stringify({
displayName,
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
status: statusToRaw(status),
}),
})
const player = adaptPlayer(result.player)
return adaptRegistration(result.rsvp, new Map([[player.id, player]]))
},
setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => { setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => {
const [result, players, session] = await Promise.all([ const [result, players, session] = await Promise.all([
request<RawRSVP>(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }), request<RawRSVP>(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }),

View File

@@ -57,6 +57,9 @@ const translations: Record<string, string> = {
'RSVP for': 'Статус участия для', 'RSVP for': 'Статус участия для',
'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что', 'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что',
'Attendance': 'Участие', 'Attendance': 'Участие',
'Registered players': 'Зарегистрированные игроки',
'Everyone can see who responded and their current status.': 'Все могут видеть, кто ответил и какой статус выбрал.',
'No responses yet.': 'Ответов пока нет.',
'No': 'Нет', 'No': 'Нет',
'Manage participants': 'Управлять участниками', 'Manage participants': 'Управлять участниками',
'Loading profile…': 'Загрузка профиля…', 'Loading profile…': 'Загрузка профиля…',
@@ -113,6 +116,14 @@ const translations: Record<string, string> = {
'Loading participants…': 'Загрузка участников…', 'Loading participants…': 'Загрузка участников…',
'Participant responses': 'Ответы участников', 'Participant responses': 'Ответы участников',
'Admin overrides are attributed and audited.': 'Изменения администратора подписываются и аудируются.', 'Admin overrides are attributed and audited.': 'Изменения администратора подписываются и аудируются.',
'Add participant': 'Добавить участника',
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
'Nickname': 'Ник',
'Tank rank': 'Ранг танка',
'Damage rank': 'Ранг урона',
'Support rank': 'Ранг поддержки',
'Status': 'Статус',
'Adding…': 'Добавление…',
'Not going': 'Не идёт', 'Not going': 'Не идёт',
'Preferred with': 'Предпочитает играть с', 'Preferred with': 'Предпочитает играть с',
'No preferred roles': 'Нет предпочитаемых ролей', 'No preferred roles': 'Нет предпочитаемых ролей',

View File

@@ -124,6 +124,11 @@ main { min-height: calc(100vh - 72px); }
.attendance-stats strong { margin-left: auto; color: #fff; } .attendance-stats strong { margin-left: auto; color: #fff; }
.attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; } .attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; }
.attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); } .attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); }
.public-roster { margin-top: 18px; padding: 24px 28px; }
.public-roster .section-title { margin-top: 0; }
.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; }
.public-roster-player { min-height: 52px; display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.public-roster-player strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; } .profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; } .large-avatar { width: 70px; height: 70px; font-size: 21px; }
@@ -158,6 +163,14 @@ main { min-height: calc(100vh - 72px); }
.settings-card, .admin-summary, .table-card { padding: 24px; } .settings-card, .admin-summary, .table-card { padding: 24px; }
.settings-card .section-title, .table-card .section-title { margin-top: 0; } .settings-card .section-title, .table-card .section-title { margin-top: 0; }
.field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; } .field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; }
.guest-participant-form { margin-bottom: 24px; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); }
.guest-participant-form > div:first-child { margin-bottom: 14px; }
.guest-participant-form h2 { font-size: 16px; }
.guest-participant-form p { margin-top: 4px; color: var(--muted); font-size: 10px; }
.guest-participant-fields { display: grid; grid-template-columns: minmax(150px,1.4fr) repeat(4,minmax(120px,1fr)) auto; align-items: end; gap: 10px; }
.guest-participant-fields label { display: flex; flex-direction: column; gap: 6px; color: #b6bcc4; font-size: 10px; font-weight: 700; }
.guest-participant-fields input, .guest-participant-fields select { width: 100%; min-height: 40px; }
.guest-participant-fields .button { min-height: 40px; white-space: nowrap; }
.settings-card label { display: flex; flex-direction: column; gap: 8px; color: #b6bcc4; font-size: 11px; font-weight: 700; margin-bottom: 16px; } .settings-card label { display: flex; flex-direction: column; gap: 8px; color: #b6bcc4; font-size: 11px; font-weight: 700; margin-bottom: 16px; }
input, select, textarea { color: #e9ebed; background: #0e1115; border: 1px solid #303640; border-radius: 6px; padding: 11px 12px; } input, select, textarea { color: #e9ebed; background: #0e1115; border: 1px solid #303640; border-radius: 6px; padding: 11px 12px; }
textarea { min-height: 85px; resize: vertical; } textarea { min-height: 85px; resize: vertical; }
@@ -247,10 +260,10 @@ textarea { min-height: 85px; resize: vertical; }
.bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); } .bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); }
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; } .featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; } .event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; } .detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; } .profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.teammate-grid { grid-template-columns: repeat(2,1fr); } .teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; } .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; } .candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; } .scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; } .turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }

View File

@@ -195,6 +195,43 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/EventRegistration" $ref: "#/components/schemas/EventRegistration"
/api/events/{eventId}/participants:
post:
tags: [Events]
operationId: createGuestParticipant
parameters:
- $ref: "#/components/parameters/EventId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [displayName, ratings, status]
properties:
displayName:
type: string
minLength: 1
ratings:
$ref: "#/components/schemas/Ratings"
status:
type: string
enum: [Going, Maybe, NotGoing]
responses:
"201":
description: Guest participant created and registered
content:
application/json:
schema:
type: object
required: [player, rsvp]
properties:
player:
$ref: "#/components/schemas/Player"
rsvp:
$ref: "#/components/schemas/EventRegistration"
"403":
$ref: "#/components/responses/Forbidden"
/api/events/{eventId}/rsvps/{playerId}: /api/events/{eventId}/rsvps/{playerId}:
put: put:
tags: [Events] tags: [Events]