Stage 8 polish: profile validation, finished-game UI, badge + Safari fixes
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 17s

Owner-review follow-up on the Stage 8 branch:
- Friend code is copyable (📋 + toast). The lobby notification badge is fixed —
  it had inherited the hamburger-bar style — into a proper round count dot.
- Safari: min-width:0 on flex text inputs (friend code, profile, chat) so they
  shrink instead of pushing the adjacent button off-screen.
- Profile editing is validated on both the UI and the backend: display-name format
  (letters joined by single space/./_ separators, no leading/trailing/adjacent
  separators, <=32 runes), a UTC-offset timezone picker (account.ResolveZone parses
  ±HH:MM or a legacy IANA name), a 10-minute away grid capped at 12h (wrap-aware),
  and email format; Save is disabled and invalid fields red-bordered until valid.
  Language stays in Settings.
- In a game, an "add to friends" menu item flips to a disabled "request sent"; chat
  send/nudge became ⬆️/🛎️ icon buttons.
- A finished game drops its last-word highlight, hides Check word / Drop game,
  disables zoom, and draws an inert (greyed) footer instead of hiding it.

Tests: account validators (name/away/zone), UI profileValidation, e2e for the
finished-game footer/menu and the copy control. Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +ru, UI_DESIGN) updated for the display-name rule, UTC-offset timezone
and the 12h away window.
This commit is contained in:
Ilia Denisov
2026-06-03 22:12:59 +02:00
parent 2d82c75f0b
commit acbb2d8254
21 changed files with 602 additions and 115 deletions
+12
View File
@@ -583,6 +583,18 @@ Open details: deployment target/host; dashboards; load expectations.
REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}` REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}`
and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow
change (the Go and UI workflows already cover the new code). change (the Go and UI workflows already cover the new code).
- **UI polish (owner review follow-up)**: a copyable friend code (📋 + toast); the
lobby notification badge fixed (it had inherited the hamburger-bar style) and made
a proper count dot; Safari flex inputs given `min-width:0`; **profile-edit
validation on both UI and backend** — display-name format (letters + single
``/`.`/`_`, ≤ 32 runes), a **UTC-offset** timezone picker (`account.ResolveZone`
parses `±HH:MM` or IANA; DST is traded for the simple picker), a 10-minute away grid
capped at **12 h** (wrap-aware), email format — with Save disabled and invalid
fields red-bordered while any field is invalid; language stays in Settings; in a
game, an "add to friends" item flips to a disabled "request sent"; chat send/nudge
became ⬆️/🛎️ icons; a **finished game** drops its last-word highlight, hides Check
word / Drop game, disables zoom, and draws an **inert footer** (greyed rack + tab
bar) instead of hiding it.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
+53 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"regexp"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -16,8 +17,18 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
) )
// maxDisplayName caps a display name's length in runes. // maxDisplayName caps an editable display name's length in runes (the column itself
const maxDisplayName = 64 // is unbounded; auto-provisioned platform names bypass this editor validation).
const maxDisplayName = 32
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading or trailing separator and no two adjacent separators,
// except "<dot|underscore> <space>". So "Name_P. Last" is valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*$`)
// ErrInvalidProfile is returned when a profile update carries an unacceptable // ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name). // field (an unknown language, an invalid timezone, or an over-long display name).
@@ -46,12 +57,15 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage) return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
} }
tz := strings.TrimSpace(p.TimeZone) tz := strings.TrimSpace(p.TimeZone)
if _, err := time.LoadLocation(tz); err != nil { if !validZone(tz) {
return Account{}, fmt.Errorf("%w: time_zone %q: %v", ErrInvalidProfile, p.TimeZone, err) return Account{}, fmt.Errorf("%w: time_zone %q", ErrInvalidProfile, p.TimeZone)
} }
name := strings.TrimSpace(p.DisplayName) name, err := ValidateDisplayName(p.DisplayName)
if utf8.RuneCountInString(name) > maxDisplayName { if err != nil {
return Account{}, fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName) return Account{}, err
}
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
return Account{}, err
} }
stmt := table.Accounts.UPDATE( stmt := table.Accounts.UPDATE(
@@ -74,3 +88,35 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
} }
return modelToAccount(row), nil return modelToAccount(row), nil
} }
// ValidateDisplayName trims surrounding whitespace and checks the editable
// display-name length (<= maxDisplayName runes) and format (displayNameRe),
// returning the cleaned name or ErrInvalidProfile. It is exported so the gateway
// boundary could reuse it; the UI mirrors the same rule.
func ValidateDisplayName(raw string) (string, error) {
name := strings.TrimSpace(raw)
if name == "" {
return "", fmt.Errorf("%w: display name is empty", ErrInvalidProfile)
}
if utf8.RuneCountInString(name) > maxDisplayName {
return "", fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
}
if !displayNameRe.MatchString(name) {
return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile)
}
return name, nil
}
// validateAwayWindow checks that the daily away window's duration, wrapping across
// midnight, does not exceed maxAwayWindow. A zero-length window (start == end) means
// "no away time" and is allowed.
func validateAwayWindow(start, end time.Time) error {
mins := (end.Hour()*60 + end.Minute()) - (start.Hour()*60 + start.Minute())
if mins < 0 {
mins += 24 * 60
}
if time.Duration(mins)*time.Minute > maxAwayWindow {
return fmt.Errorf("%w: away window exceeds %s", ErrInvalidProfile, maxAwayWindow)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package account
import (
"regexp"
"strconv"
"time"
)
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
// ok=false when name is not a well-formed offset within ±14:00.
func parseOffsetZone(name string) (*time.Location, bool) {
m := offsetZoneRe.FindStringSubmatch(name)
if m == nil {
return nil, false
}
h, _ := strconv.Atoi(m[2])
min, _ := strconv.Atoi(m[3])
if h > 14 || min > 59 || (h == 14 && min > 0) {
return nil, false
}
secs := h*3600 + min*60
if m[1] == "-" {
secs = -secs
}
return time.FixedZone(name, secs), true
}
// ResolveZone resolves a stored timezone — a fixed "±HH:MM" offset or an IANA name —
// to a *time.Location, falling back to UTC when it is empty or unrecognised, so a
// bad profile value never breaks the turn-timeout sweeper or the robot's sleep.
func ResolveZone(name string) *time.Location {
if name == "" {
return time.UTC
}
if loc, ok := parseOffsetZone(name); ok {
return loc
}
if loc, err := time.LoadLocation(name); err == nil {
return loc
}
return time.UTC
}
// validZone reports whether name is an acceptable timezone for a profile update —
// either a "±HH:MM" offset or a loadable IANA location.
func validZone(name string) bool {
if _, ok := parseOffsetZone(name); ok {
return true
}
_, err := time.LoadLocation(name)
return err == nil
}
+84
View File
@@ -0,0 +1,84 @@
package account
import (
"strings"
"testing"
"time"
)
func TestValidateDisplayName(t *testing.T) {
cases := map[string]struct {
in string
want string
ok bool
}{
"plain": {"Kaya", "Kaya", true},
"cyrillic": {"Кая", "Кая", true},
"dot underscore mix": {"Name_P. Last", "Name_P. Last", true},
"single dot": {"Mr.Smith", "Mr.Smith", true},
"dot then space": {"Mr. Smith", "Mr. Smith", true},
"trim surrounding": {" Kaya ", "Kaya", true},
"adjacent specials": {"Name P._Last", "", false},
"two spaces": {"Name Last", "", false},
"leading special": {"_Name", "", false},
"trailing special": {"Name.", "", false},
"digit rejected": {"Name2", "", false},
"blank": {" ", "", false},
"too long": {strings.Repeat("a", 33), "", false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got, err := ValidateDisplayName(tc.in)
if tc.ok != (err == nil) || (tc.ok && got != tc.want) {
t.Fatalf("ValidateDisplayName(%q) = (%q, err=%v), want (%q, ok=%v)", tc.in, got, err, tc.want, tc.ok)
}
})
}
}
func TestValidateAwayWindow(t *testing.T) {
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
cases := map[string]struct {
start, end time.Time
ok bool
}{
"8h overnight": {hm(22, 0), hm(6, 0), true},
"12h exact": {hm(0, 0), hm(12, 0), true},
"13h daytime": {hm(8, 0), hm(21, 0), false},
"zero window": {hm(7, 0), hm(7, 0), true},
"13h wrap": {hm(20, 0), hm(9, 0), false},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
if err := validateAwayWindow(tc.start, tc.end); tc.ok != (err == nil) {
t.Fatalf("validateAwayWindow = %v, want ok=%v", err, tc.ok)
}
})
}
}
func TestResolveAndValidZone(t *testing.T) {
offsetOf := func(name string) int {
_, off := time.Date(2024, 1, 1, 12, 0, 0, 0, ResolveZone(name)).Zone()
return off
}
if got := offsetOf("+03:00"); got != 3*3600 {
t.Errorf("+03:00 offset = %d, want 10800", got)
}
if got := offsetOf("-05:30"); got != -(5*3600 + 30*60) {
t.Errorf("-05:30 offset = %d", got)
}
if ResolveZone("nonsense-zone") != time.UTC {
t.Error("unknown zone should resolve to UTC")
}
for _, ok := range []string{"+05:45", "-12:00", "+14:00", "Europe/Moscow", "UTC"} {
if !validZone(ok) {
t.Errorf("validZone(%q) = false, want true", ok)
}
}
for _, bad := range []string{"+15:00", "03:00", "+3:00", "nope", "+05:99"} {
if validZone(bad) {
t.Errorf("validZone(%q) = true, want false", bad)
}
}
}
+6 -10
View File
@@ -5,6 +5,8 @@ import (
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/backend/internal/account"
) )
// effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline // effectiveDeadline is the instant a turn auto-resigns. It is the raw deadline
@@ -57,17 +59,11 @@ func minutesOfDay(t time.Time) int {
return t.Hour()*60 + t.Minute() return t.Hour()*60 + t.Minute()
} }
// loadLocation resolves an IANA timezone name, falling back to UTC when it is // loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset),
// empty or unknown (so a bad profile value never breaks the sweeper). // falling back to UTC when it is empty or unknown (so a bad profile value never
// breaks the sweeper). It defers to account.ResolveZone, the single source of truth.
func loadLocation(name string) *time.Location { func loadLocation(name string) *time.Location {
if name == "" { return account.ResolveZone(name)
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
} }
// SweepTimeouts auto-resigns every active game whose current turn has exceeded // SweepTimeouts auto-resigns every active game whose current turn has exceeded
+2 -2
View File
@@ -84,7 +84,7 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests. // Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t) a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil { if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
t.Fatalf("set toggle: %v", err) t.Fatalf("set toggle: %v", err)
} }
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) { if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
@@ -257,7 +257,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil { if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err) t.Fatalf("post 2: %v", err)
} }
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil { if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
t.Fatalf("set block_chat: %v", err) t.Fatalf("set block_chat: %v", err)
} }
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 { if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
+5 -10
View File
@@ -6,6 +6,7 @@ import (
"math" "math"
"time" "time"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
) )
@@ -136,17 +137,11 @@ func asleep(opponentTZ string, drift time.Duration, now time.Time) bool {
return h >= sleepStartHour && h < sleepEndHour return h >= sleepStartHour && h < sleepEndHour
} }
// loadLocation resolves an IANA timezone name, falling back to UTC when it is // loadLocation resolves a stored timezone (an IANA name or a "±HH:MM" offset),
// empty or unknown (so a bad opponent profile never breaks the driver). // falling back to UTC when it is empty or unknown (so a bad opponent profile never
// breaks the driver). It defers to account.ResolveZone.
func loadLocation(name string) *time.Location { func loadLocation(name string) *time.Location {
if name == "" { return account.ResolveZone(name)
return time.UTC
}
loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC
}
return loc
} }
// selectMove chooses the robot's action given the ranked candidate plays, the // selectMove chooses the robot's action given the ranked candidate plays, the
+9 -5
View File
@@ -298,11 +298,15 @@ requires (there is no DM surface; chat is per-game).
the opponent may nudge **once per hour per game**; it is not allowed on one's own the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform turn. The platform-native delivery is wired with the gateway / platform
side-service (Stage 6 / 8). side-service (Stage 6 / 8).
- **Profile**: `preferred_language` (en/ru), display name, email - **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone** (drives the away window and the (confirm-code binding, see §4), **timezone**, the daily **away window** and the
robot's sleep; user-editable), the daily **away window** and the block toggles — block toggles — all editable through `account.UpdateProfile`, which validates them
all editable through `account.UpdateProfile`. Linked platform accounts and merge (Stage 8): a display name is Unicode letters joined by single ` `/`.`/`_`
are Stage 11. separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
accounts and merge are Stage 11.
## 9. Persistence ## 9. Persistence
+5 -3
View File
@@ -88,9 +88,11 @@ existing friendship). Per-game chat is for quick reactions: messages are short
even disguised. Nudge the player whose turn is awaited at most once per hour (the even disguised. Nudge the player whose turn is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform. nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4)* ### Profile & settings *(Stage 4 / 8)*
Edit language (en/ru), display name, timezone, the daily away window and the block Edit the display name (letters joined by single space / "." / "_" separators, up to
toggles, and bind an email by confirm-code: the backend emails a short code that, 32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind
an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 11. accounts and merge arrive in Stage 11.
+6 -4
View File
@@ -90,10 +90,12 @@ session-токен; backend сопоставляет его с внутренн
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу. push доставляется через платформу.
### Профиль и настройки *(Stage 4)* ### Профиль и настройки *(Stage 4 / 8)*
Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
отсутствия (away) и переключателей блокировок, а также привязка email по «_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
confirm-коду: backend шлёт на почту короткий код, и после ввода email (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на
почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 11. слияние появятся в Stage 11.
+14 -4
View File
@@ -95,13 +95,23 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat - **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat
cards (wins / losses / draws / games / win-rate / best game / best move) — pure cards (wins / losses / draws / games / win-rate / best game / best move) — pure
numbers, no charts. numbers, no charts.
- **Profile editing** (`screens/Profile.svelte`): an inline form (display name, timezone, - **Profile editing** (`screens/Profile.svelte`): an inline form display name, a
the away-window time pickers, block toggles) and an email-binding sub-flow (enter email **UTC-offset** timezone dropdown (defaulting to the browser's offset), the away
→ enter the confirm code). Interface language stays in **Settings** (it writes through window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an
to the account for durable users). email-binding sub-flow (enter email → enter the confirm code on a numeric field).
Invalid fields show a **red border** (no message) and **Save stays disabled** until
every field is valid. Interface language stays in **Settings** (it writes through to
the account for durable users).
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
overflowing in Safari.
- **History / GCG**: the in-game slide-down history gains the running total per move; - **History / GCG**: the in-game slide-down history gains the running total per move;
*Export GCG* shares or downloads the `.gcg` file and appears only once the game is *Export GCG* shares or downloads the `.gcg` file and appears only once the game is
finished. finished.
- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops
*Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert**
(greyed, non-interactive) rather than hidden, so the layout does not jump. Chat
send / nudge are the ⬆️ / 🛎️ icons.
## Caveat ## Caveat
+14 -1
View File
@@ -19,9 +19,10 @@ test('friends: issue a code, accept an incoming request, redeem a code', async (
await loginLobby(page); await loginLobby(page);
await openFriends(page); await openFriends(page);
// Issue a one-time code — it is shown to share. // Issue a one-time code — it is shown to share, with a copy control.
await page.getByRole('button', { name: /Show my code/i }).click(); await page.getByRole('button', { name: /Show my code/i }).click();
await expect(page.getByTestId('friend-code')).toContainText('246813'); await expect(page.getByTestId('friend-code')).toContainText('246813');
await expect(page.getByRole('button', { name: 'Copy' })).toBeVisible();
// The seeded incoming request (Rick) can be accepted; the requests section clears. // The seeded incoming request (Rick) can be accepted; the requests section clears.
await expect(page.getByText('Friend requests')).toBeVisible(); await expect(page.getByText('Friend requests')).toBeVisible();
@@ -73,3 +74,15 @@ test('GCG export is hidden for an active game', async ({ page }) => {
await page.locator('.burger').first().click(); await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0); await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0);
}); });
test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The footer (tab bar) is drawn but its controls are disabled in a finished game.
await expect(page.locator('.tab').first()).toBeDisabled();
// The menu drops Check word and Drop game once the game is over.
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
});
+8 -3
View File
@@ -6,6 +6,7 @@
label: string; label: string;
onclick: () => void; onclick: () => void;
badge?: number; badge?: number;
disabled?: boolean;
} }
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props(); let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
let open = $state(false); let open = $state(false);
@@ -27,7 +28,7 @@
<div class="backdrop" onclick={() => (open = false)}></div> <div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown"> <div class="dropdown">
{#each items as it (it.label)} {#each items as it (it.label)}
<button onclick={() => pick(it.onclick)}> <button onclick={() => pick(it.onclick)} disabled={it.disabled}>
<span>{it.label}</span> <span>{it.label}</span>
{#if it.badge}<span class="idot">{it.badge}</span>{/if} {#if it.badge}<span class="idot">{it.badge}</span>{/if}
</button> </button>
@@ -82,7 +83,7 @@
text-align: center; text-align: center;
font-weight: 700; font-weight: 700;
} }
.burger span { .burger span:not(.dot) {
display: block; display: block;
height: 3px; height: 3px;
background: var(--text); background: var(--text);
@@ -120,7 +121,11 @@
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.dropdown button:hover { .dropdown button:hover:not(:disabled) {
background: var(--surface-2); background: var(--surface-2);
} }
.dropdown button:disabled {
color: var(--text-muted);
opacity: 0.6;
}
</style> </style>
+8 -5
View File
@@ -46,8 +46,8 @@
bind:value={text} bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()} onkeydown={(e) => e.key === 'Enter' && send()}
/> />
<button onclick={send} disabled={busy}>{t('chat.send')}</button> <button class="iconbtn" onclick={send} disabled={busy} aria-label={t('chat.send')}>⬆️</button>
<button class="nudge" onclick={onnudge} disabled={busy}>{t('chat.nudge')}</button> <button class="iconbtn" onclick={onnudge} disabled={busy} aria-label={t('chat.nudge')}>🛎️</button>
</div> </div>
</div> </div>
@@ -95,18 +95,21 @@
} }
.input input { .input input {
flex: 1; flex: 1;
min-width: 0;
padding: 10px; padding: 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
.input button { .iconbtn {
padding: 10px 12px; flex: 0 0 auto;
padding: 8px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-weight: 600; font-size: 1.25rem;
line-height: 1;
} }
</style> </style>
+36 -22
View File
@@ -66,7 +66,7 @@
// Highlight the last word with a dark tile bg; while placing, only the pending tiles // Highlight the last word with a dark tile bg; while placing, only the pending tiles
// are highlighted. It flashes when the opponent just moved and it is now our turn. // are highlighted. It flashes when the opponent just moved and it is now our turn.
const highlight = $derived( const highlight = $derived(
placement.pending.length > 0 || !lastPlay placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
? new Set<string>() ? new Set<string>()
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)), : new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
); );
@@ -378,9 +378,13 @@
} }
} }
let requested = $state(new Set<string>());
const noop = () => {};
async function addFriend(accountId: string) { async function addFriend(accountId: string) {
try { try {
await gateway.friendRequest(accountId); await gateway.friendRequest(accountId);
requested = new Set([...requested, accountId]);
showToast(t('friends.requestSent')); showToast(t('friends.requestSent'));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -391,15 +395,21 @@
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [], view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
); );
// In a finished game the menu drops Check word and Drop game, gains Export GCG, and
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([ const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) }, { label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat }, { label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck }, ...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []), ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest ...(!app.profile?.isGuest
? opponents.map((s) => ({ label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) })) ? opponents.map((s) =>
requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
)
: []), : []),
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }, ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
]); ]);
</script> </script>
@@ -454,7 +464,7 @@
locale={app.locale} locale={app.locale}
{focus} {focus}
oncell={onCell} oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)} ontogglezoom={() => { if (!gameOver) zoomed = !zoomed; }}
/> />
</div> </div>
</div> </div>
@@ -471,28 +481,28 @@
</span> </span>
</div> </div>
{#if !gameOver} <!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
<div class="rack-row"> a finished game shows the final rack greyed out and the controls disabled. -->
<div class="rack-wrap"> <div class="rack-row" class:inert={gameOver}>
<Rack {slots} {variant} {selected} ondown={onRackDown} /> <div class="rack-wrap">
</div> <Rack {slots} {variant} {selected} ondown={onRackDown} />
{#if placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if}
</div> </div>
{/if} {#if !gameOver && placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if}
</div>
{:else} {:else}
<p class="loading">{t('common.loading')}</p> <p class="loading">{t('common.loading')}</p>
{/if} {/if}
{#snippet tabbar()} {#snippet tabbar()}
{#if view && !gameOver} {#if view}
<TabBar> <TabBar>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}> <button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span> <span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
@@ -508,7 +518,7 @@
{/snippet} {/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet} {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm> </HoldConfirm>
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}> <button class="tab" disabled={busy || gameOver || placement.pending.length > 0} onclick={shuffle}>
<span class="sq">🔀</span> <span class="sq">🔀</span>
</button> </button>
</TabBar> </TabBar>
@@ -697,6 +707,10 @@
align-items: stretch; align-items: stretch;
padding: 0 var(--pad) 6px; padding: 0 var(--pad) 6px;
} }
.rack-row.inert {
pointer-events: none;
opacity: 0.55;
}
.rack-wrap { .rack-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+3
View File
@@ -178,6 +178,8 @@ export const en = {
'friends.enterCode': 'Have a code? Add a friend', 'friends.enterCode': 'Have a code? Add a friend',
'friends.codePlaceholder': '6-digit code', 'friends.codePlaceholder': '6-digit code',
'friends.redeem': 'Add', 'friends.redeem': 'Add',
'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.',
'friends.added': 'Added {name}.', 'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players', 'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock', 'friends.unblock': 'Unblock',
@@ -212,6 +214,7 @@ export const en = {
'game.exportGcg': 'Export GCG', 'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.', 'game.gcgActiveOnly': 'Available once the game is finished.',
'game.requestSent': 'Request sent',
'time.minutes': '{n} min', 'time.minutes': '{n} min',
'time.hours': '{n} h', 'time.hours': '{n} h',
+3
View File
@@ -179,6 +179,8 @@ export const ru: Record<MessageKey, string> = {
'friends.enterCode': 'Есть код? Добавить друга', 'friends.enterCode': 'Есть код? Добавить друга',
'friends.codePlaceholder': 'Код из 6 цифр', 'friends.codePlaceholder': 'Код из 6 цифр',
'friends.redeem': 'Добавить', 'friends.redeem': 'Добавить',
'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.',
'friends.added': 'Добавлен(а) {name}.', 'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные', 'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать', 'friends.unblock': 'Разблокировать',
@@ -213,6 +215,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG', 'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.requestSent': 'Запрос отправлен',
'time.minutes': '{n} мин', 'time.minutes': '{n} мин',
'time.hours': '{n} ч', 'time.hours': '{n} ч',
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { awayDurationOk, browserOffset, isOffsetZone, validDisplayName, validEmail } from './profileValidation';
describe('validDisplayName', () => {
it.each([
['Kaya', true],
['Кая', true],
['Name_P. Last', true],
['Mr.Smith', true],
['Mr. Smith', true],
[' Kaya ', true],
['Name P._Last', false],
['Name Last', false],
['_Name', false],
['Name.', false],
['Name2', false],
['', false],
['a'.repeat(33), false],
])('%s -> %s', (name, ok) => {
expect(validDisplayName(name)).toBe(ok);
});
});
describe('validEmail', () => {
it('accepts a normal address', () => expect(validEmail('you@example.com')).toBe(true));
it('rejects a missing domain', () => expect(validEmail('you@')).toBe(false));
it('rejects spaces', () => expect(validEmail('a b@x.com')).toBe(false));
});
describe('awayDurationOk', () => {
it.each([
['22:00', '06:00', true],
['00:00', '12:00', true],
['08:00', '21:00', false],
['07:00', '07:00', true],
['20:00', '09:00', false],
])('%s-%s -> %s', (s, e, ok) => expect(awayDurationOk(s, e)).toBe(ok));
});
describe('timezone helpers', () => {
it('detects offset zones', () => {
expect(isOffsetZone('+03:00')).toBe(true);
expect(isOffsetZone('Europe/Moscow')).toBe(false);
});
it('formats the browser offset as ±HH:MM', () => {
expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Profile-edit validation, mirroring the backend (account/profile.go,
// account/timezone.go) so the form can disable Save and flag fields before a round
// trip. Pure and unit-tested.
/** maxDisplayName caps the editable display name in runes. */
export const maxDisplayName = 32;
/** maxAwayMinutes bounds the daily away window's length (12 h). */
export const maxAwayMinutes = 12 * 60;
// Unicode letters joined by single space / "." / "_" separators, where a "." or "_"
// may be followed by a single space. No leading/trailing separator and no adjacent
// separators except "<dot|underscore> <space>". Same rule as the Go displayNameRe.
const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*$/u;
/** displayNameError returns true when the trimmed name is a valid display name. */
export function validDisplayName(raw: string): boolean {
const name = raw.trim();
return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name);
}
// A pragmatic email check (the backend re-validates with net/mail). Rejects spaces
// and requires a local part, an @, and a dotted domain.
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** validEmail reports whether email is a plausible address. */
export function validEmail(email: string): boolean {
return emailRe.test(email.trim());
}
/** toMinutes parses an "HH:MM" time-of-day into minutes since midnight, or null. */
export function toMinutes(hhmm: string): number | null {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) return null;
return h * 60 + min;
}
/** awayDurationOk reports whether the away window (wrapping midnight) is <= 12 h. */
export function awayDurationOk(start: string, end: string): boolean {
const s = toMinutes(start);
const e = toMinutes(end);
if (s === null || e === null) return false;
let d = e - s;
if (d < 0) d += 24 * 60;
return d <= maxAwayMinutes;
}
/** The real-world set of unique UTC offsets, for the timezone dropdown. */
export const timezoneOffsets: string[] = [
'-12:00', '-11:00', '-10:00', '-09:30', '-09:00', '-08:00', '-07:00', '-06:00',
'-05:00', '-04:00', '-03:30', '-03:00', '-02:00', '-01:00', '+00:00', '+01:00',
'+02:00', '+03:00', '+03:30', '+04:00', '+04:30', '+05:00', '+05:30', '+05:45',
'+06:00', '+06:30', '+07:00', '+08:00', '+08:45', '+09:00', '+09:30', '+10:00',
'+10:30', '+11:00', '+12:00', '+12:45', '+13:00', '+14:00',
];
/** isOffsetZone reports whether a stored timezone is a "±HH:MM" offset. */
export function isOffsetZone(tz: string): boolean {
return /^[+-]\d{2}:\d{2}$/.test(tz);
}
/** browserOffset returns the client's current UTC offset as "±HH:MM". */
export function browserOffset(): string {
const mins = -new Date().getTimezoneOffset(); // getTimezoneOffset is minutes behind UTC
const sign = mins < 0 ? '-' : '+';
const abs = Math.abs(mins);
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
const mm = String(abs % 60).padStart(2, '0');
return `${sign}${hh}:${mm}`;
}
/** Hour options "00".."23" for the away-window pickers. */
export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
/** Minute options on a 10-minute step. */
export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50'];
+36 -1
View File
@@ -67,6 +67,16 @@
function codeTime(unix: number): string { function codeTime(unix: number): string {
return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} }
async function copyCode() {
if (!code) return;
try {
await navigator.clipboard.writeText(code.code);
showToast(t('friends.codeCopied'));
} catch {
// Clipboard may be unavailable (insecure context); leave the code on screen.
}
}
</script> </script>
<Screen title={t('friends.title')} back="/"> <Screen title={t('friends.title')} back="/">
@@ -88,7 +98,10 @@
</div> </div>
{#if code} {#if code}
<div class="code" data-testid="friend-code"> <div class="code" data-testid="friend-code">
<span class="codeval">{code.code}</span> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
<button class="copy" onclick={copyCode} aria-label={t('friends.copy')}>📋</button>
</div>
<span class="codehint"> <span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
@@ -167,6 +180,7 @@
} }
.codein { .codein {
flex: 1; flex: 1;
min-width: 0;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
@@ -184,10 +198,31 @@
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.coderow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.codeval { .codeval {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.3em; letter-spacing: 0.3em;
background: none;
border: none;
color: var(--text);
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.copy {
flex: 0 0 auto;
background: none;
border: none;
font-size: 1.4rem;
padding: 4px;
cursor: pointer;
} }
.codehint { .codehint {
font-size: 0.8rem; font-size: 0.8rem;
+115 -38
View File
@@ -3,35 +3,70 @@
import { app, handleError, logout, showToast } from '../lib/app.svelte'; import { app, handleError, logout, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import type { ProfileUpdate } from '../lib/model'; import {
awayDurationOk,
awayHours,
awayMinutes,
browserOffset,
isOffsetZone,
timezoneOffsets,
validDisplayName,
validEmail,
} from '../lib/profileValidation';
let editing = $state(false); let editing = $state(false);
let form = $state<ProfileUpdate>(blankForm()); let dn = $state('');
let tz = $state('+00:00');
let startH = $state('00');
let startM = $state('00');
let endH = $state('07');
let endM = $state('00');
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let emailInput = $state(''); let emailInput = $state('');
let codeInput = $state(''); let codeInput = $state('');
let emailSent = $state(false); let emailSent = $state(false);
function blankForm(): ProfileUpdate { function defaultTz(): string {
const p = app.profile; const b = browserOffset();
return { return timezoneOffsets.includes(b) ? b : '+00:00';
displayName: p?.displayName ?? '', }
preferredLanguage: p?.preferredLanguage ?? 'en', function splitTime(hhmm: string): [string, string] {
timeZone: p?.timeZone ?? 'UTC', const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
awayStart: p?.awayStart ?? '00:00', if (!m) return ['00', '00'];
awayEnd: p?.awayEnd ?? '07:00', return [m[1], awayMinutes.includes(m[2]) ? m[2] : '00'];
blockChat: p?.blockChat ?? false,
blockFriendRequests: p?.blockFriendRequests ?? false,
};
} }
function startEdit() { function startEdit() {
form = blankForm(); const p = app.profile!;
dn = p.displayName;
tz = isOffsetZone(p.timeZone) && timezoneOffsets.includes(p.timeZone) ? p.timeZone : defaultTz();
[startH, startM] = splitTime(p.awayStart);
[endH, endM] = splitTime(p.awayEnd);
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
editing = true; editing = true;
} }
const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk);
const emailOk = $derived(validEmail(emailInput));
async function save() { async function save() {
if (!formValid) return;
try { try {
app.profile = await gateway.profileUpdate(form); app.profile = await gateway.profileUpdate({
displayName: dn.trim(),
preferredLanguage: app.profile!.preferredLanguage, // language lives in Settings
timeZone: tz,
awayStart,
awayEnd,
blockChat,
blockFriendRequests,
});
editing = false; editing = false;
showToast(t('profile.saved')); showToast(t('profile.saved'));
} catch (e) { } catch (e) {
@@ -40,12 +75,11 @@
} }
async function requestEmail() { async function requestEmail() {
const email = emailInput.trim(); if (!emailOk) return;
if (!email) return;
try { try {
await gateway.emailBindRequest(email); await gateway.emailBindRequest(emailInput.trim());
emailSent = true; emailSent = true;
showToast(t('profile.emailSent', { email })); showToast(t('profile.emailSent', { email: emailInput.trim() }));
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -75,37 +109,45 @@
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}> <form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label> <label>
<span>{t('profile.displayName')}</span> <span>{t('profile.displayName')}</span>
<input bind:value={form.displayName} maxlength="64" /> <input class:invalid={!nameOk} bind:value={dn} maxlength="40" />
</label> </label>
<label> <label>
<span>{t('profile.timezone')}</span> <span>{t('profile.timezone')}</span>
<input bind:value={form.timeZone} /> <select bind:value={tz}>
{#each timezoneOffsets as o (o)}<option value={o}>{o}</option>{/each}
</select>
</label> </label>
<fieldset class="away"> <fieldset class="away" class:invalid={!awayOk}>
<legend>{t('profile.awayWindow')}</legend> <legend>{t('profile.awayWindow')}</legend>
<div class="times"> <div class="times">
<label><span>{t('profile.from')}</span><input type="time" bind:value={form.awayStart} /></label> <span class="tlabel">{t('profile.from')}</span>
<label><span>{t('profile.to')}</span><input type="time" bind:value={form.awayEnd} /></label> <select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<div class="times">
<span class="tlabel">{t('profile.to')}</span>
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div> </div>
<p class="muted">{t('profile.awayHint')}</p> <p class="muted">{t('profile.awayHint')}</p>
</fieldset> </fieldset>
<label class="check"> <label class="check">
<input type="checkbox" bind:checked={form.blockChat} /> <input type="checkbox" bind:checked={blockChat} />
<span>{t('profile.blockChat')}</span> <span>{t('profile.blockChat')}</span>
</label> </label>
<label class="check"> <label class="check">
<input type="checkbox" bind:checked={form.blockFriendRequests} /> <input type="checkbox" bind:checked={blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span> <span>{t('profile.blockFriendRequests')}</span>
</label> </label>
<div class="formacts"> <div class="formacts">
<button type="submit" class="btn">{t('common.save')}</button> <button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button> <button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
</div> </div>
</form> </form>
{:else} {:else}
<dl> <dl>
<dt>{t('profile.language')}</dt>
<dd>{p.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt> <dt>{t('profile.timezone')}</dt>
<dd>{p.timeZone}</dd> <dd>{p.timeZone}</dd>
<dt>{t('profile.awayWindow')}</dt> <dt>{t('profile.awayWindow')}</dt>
@@ -123,12 +165,23 @@
<h3>{t('profile.bindEmail')}</h3> <h3>{t('profile.bindEmail')}</h3>
{#if !emailSent} {#if !emailSent}
<div class="addrow"> <div class="addrow">
<input bind:value={emailInput} placeholder={t('login.emailPlaceholder')} type="email" /> <input
<button class="ghost" onclick={requestEmail}>{t('login.sendCode')}</button> class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk}>{t('login.sendCode')}</button>
</div> </div>
{:else} {:else}
<div class="addrow"> <div class="addrow">
<input bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" /> <input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail}>{t('common.ok')}</button> <button class="btn" onclick={confirmEmail}>{t('common.ok')}</button>
</div> </div>
{/if} {/if}
@@ -183,26 +236,33 @@
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.edit label { .edit > label {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted); color: var(--text-muted);
} }
.edit input:not([type]), .edit input:not([type='checkbox']),
.edit input[type='time'] { .edit select {
min-width: 0;
padding: 9px 11px; padding: 9px 11px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.invalid {
border-color: var(--danger, #c0392b) !important;
}
.away { .away {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 10px 12px; padding: 10px 12px;
margin: 0; margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
} }
.away legend { .away legend {
color: var(--text-muted); color: var(--text-muted);
@@ -211,10 +271,16 @@
} }
.times { .times {
display: flex; display: flex;
gap: 12px; align-items: center;
gap: 8px;
} }
.times label { .tlabel {
flex: 1; min-width: 2.5em;
color: var(--text-muted);
font-size: 0.85rem;
}
.colon {
font-weight: 700;
} }
.check { .check {
flex-direction: row !important; flex-direction: row !important;
@@ -226,6 +292,9 @@
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
.btn:disabled {
opacity: 0.5;
}
.emailbox h3 { .emailbox h3 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 0.95rem; font-size: 0.95rem;
@@ -237,12 +306,17 @@
} }
.addrow input { .addrow input {
flex: 1; flex: 1;
min-width: 0;
padding: 9px 11px; padding: 9px 11px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.addrow input.codein {
letter-spacing: 0.3em;
font-size: 1.1rem;
}
.btn { .btn {
align-self: flex-start; align-self: flex-start;
padding: 9px 14px; padding: 9px 14px;
@@ -258,6 +332,9 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.ghost:disabled {
opacity: 0.5;
}
.logout { .logout {
margin-top: 8px; margin-top: 8px;
align-self: flex-start; align-self: flex-start;