시간 제한 없음은 정말 무제한, 대기실 설정은 바꾸면 바로 적용, 모르는 사람 기다리기(빠른 시작·공개 방) 제거

- 오목에서 [시간 제한 없음]을 골라도 30초가 흐르던 문제: 대기실 설정이 [설정 저장]을 눌러야
  서버에 반영되어, 누르지 않고 [게임 시작]하면 기본값(한 수 30초)으로 시작했다. 이제 설정은
  바꾸는 즉시 서버에 보낸다(저장 버튼 없음, 휴대폰 시트는 [닫기]만). 빠르게 여러 번 눌러도
  먼저 온 방 정보가 새 값을 되돌리지 않게 lib/option-sync.ts로 맞춘다.
- 시간 제한 없음(오목·체스·장기·오델로·바둑·쿼리도): 무응답 한도(idleLimitSec, 5~10분 뒤 시간패)와
  그 카운트다운을 없앴다. 체스는 첫 수 제한도 피셔일 때만. 연결이 끊긴 사람의 접속 유예는 그대로.
- 홈의 [지금 열린 방] 탭, 게임 소개의 [아무나와 빠른 시작]·기다리는 방 목록, 대기실의 방 공개
  설정을 뺐다(방은 모두 친구만). 서버 API는 남겨 두고 화면에서만 부르지 않는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 20:42:46 +09:00
parent 1e1408302a
commit 19c163f4d2
41 changed files with 215 additions and 264 deletions

View File

@@ -70,16 +70,16 @@ describe('Room timers', () => {
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
room.start('A');
room.clearTimer();
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
// No time control: no turn deadline. Black disconnects → only the 60s grace applies.
room.disconnect(a);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
// Reconnect before the grace period ends → deadline goes back to the idle limit.
// Reconnect before the grace period ends → no deadline again.
clock.advance(30_000);
room.join(conn('A'), 'player');
room.clearTimer();
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
expect(room.effectiveDeadline()).toBeNull();
});
test('restore postpones overdue deadlines by 30s and keeps game state', () => {

View File

@@ -589,8 +589,6 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: BadukView;
clock = (
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 700, fontSize: '1.15rem', color: active && ms <= 10_000 ? 'var(--danger)' : undefined }}>{mmss(ms)}</span>
);
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={c.idleLimitSec * 1000} mine={mine} />;
}
// Byoyomi countdown beeps for my own clock (last 5 seconds of a period).

View File

@@ -620,7 +620,7 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView;
const now = useServerNow(100);
const tc = v.timeControl;
let clock: React.ReactNode = null;
if (active && !v.started[color]) {
if (tc.kind === 'fischer' && active && !v.started[color]) {
clock = (
<span className="row" style={{ gap: 6 }}>
<span className="small muted">첫 수</span>
@@ -647,8 +647,6 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: ChessView;
{fmtClock(ms)}
</span>
);
} else if (active) {
clock = <TurnTimer deadline={deadline} totalMs={v.idleLimitSec * 1000} mine={id === me} />;
}
const caps = [...v.captured[color]].sort((a, b) => 'qrbnp'.indexOf(a) - 'qrbnp'.indexOf(b));
const adv = color === 'white' ? v.materialDiff : -v.materialDiff;
@@ -688,7 +686,7 @@ function statusText(v: ChessView, _me: string | null, players: Record<string, Pu
if (mine && v.draw.pendingBy && v.draw.pendingBy !== mine) return '상대가 무승부를 제안했어요';
if (mine === v.turn) {
if (v.checkSquare) return '체크! 킹을 지켜야 해요';
if (!v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`;
if (v.timeControl.kind === 'fischer' && !v.started[mine]) return `내 차례예요 (${COLOR_KO[mine]}). 첫 수를 ${v.firstMoveSec}초 안에 둬 주세요`;
if (v.claimable) return '내 차례예요. 무승부를 주장할 수 있어요';
return `내 차례예요 (${COLOR_KO[mine]}). 움직일 기물을 눌러 주세요`;
}
@@ -797,16 +795,12 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
</div>
</div>
)}
{o.timeControl.kind === 'none' && (
{o.timeControl.kind === 'fischer' && (
<div className="stack" style={{ gap: 6 }}>
<strong>응답이 없으면 시간패</strong>
<Seg value={o.idleLimitSec} options={[[300, '5분'], [600, '10분']]} onChange={(idleLimitSec) => set({ idleLimitSec })} />
<strong>첫 수 제한 (넘기면 대국 취소)</strong>
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
</div>
)}
<div className="stack" style={{ gap: 6 }}>
<strong>첫 수 제한 (넘기면 대국 취소)</strong>
<Seg value={o.firstMoveSec} options={[[15, '15초'], [30, '30초'], [60, '60초']]} onChange={(firstMoveSec) => set({ firstMoveSec })} />
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>3회 반복 · 50수 무승부</strong>
<Seg value={o.drawMode} options={[['auto', '자동으로 무승부'], ['fide', 'FIDE (주장해야 무승부)']]} onChange={(drawMode) => set({ drawMode })} />

View File

@@ -671,7 +671,7 @@ function Clock({ view: v, side, deadline, mine }: { view: JanggiView; side: Side
return v.setup.waitingFor.includes(side) ? <TurnTimer deadline={deadline} totalMs={v.rules.setupTimeSec * 1000} mine={mine} /> : null;
}
const active = v.phase === 'playing' && v.turn === side;
if (tc.kind === 'none') return active ? <TurnTimer deadline={deadline} totalMs={v.rules.idleLimitSec * 1000} mine={mine} /> : null;
if (tc.kind === 'none') return null;
const periodMs = tc.periodSec * 1000;
const elapsed = active ? Math.max(0, now - v.clock.turnStartedAt) : 0;
const main = v.clock.mainMs[side] - elapsed;

View File

@@ -298,9 +298,8 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: OmokView;
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
} else if (active && tc.kind === 'perMove') {
clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={id === me} />;
}
return (
<div

View File

@@ -414,9 +414,8 @@ function PlayerBar({ view: v, color, players, deadline, me }: { view: OthelloVie
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={id === me} />;
} else if (active && tc.kind === 'perMove') {
clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={id === me} />;
}
const score = v.result ? v.result.displayScore[color] : v.discs[color];
return (

View File

@@ -571,9 +571,8 @@ function PlayerRow({ v, x, players, deadline, me }: { v: QuoridorView; x: Quorid
{Math.floor(s / 60)}:{String(s % 60).padStart(2, '0')}
</span>
);
} else if (active) {
const total = tc.kind === 'perMove' ? tc.perMoveSec * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 300_000;
clock = <TurnTimer deadline={deadline} totalMs={total} mine={x.playerId === me} size={40} />;
} else if (active && tc.kind === 'perMove') {
clock = <TurnTimer deadline={deadline} totalMs={tc.perMoveSec * 1000} mine={x.playerId === me} size={40} />;
}
const state =
x.status === 'finished'

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test';
import { OptionSync, optionsKey, withLocks } from './option-sync';
const base = { ruleSet: 'korean', timeControl: { kind: 'perMove', perMoveSec: 30 }, undo: '3' };
const a = { ...base, ruleSet: 'free' };
const ab = { ...a, timeControl: { kind: 'none', perMoveSec: 30 } };
describe('OptionSync', () => {
test('in sync: every room update is followed', () => {
const s = new OptionSync();
expect(s.received(base)).toBe(true);
expect(s.received(a)).toBe(true);
});
test('two quick clicks: a stale update and the first echo do not undo the second click', () => {
const s = new OptionSync();
s.sent(base, a);
s.sent(a, ab);
expect(s.received(base)).toBe(false); // someone joined before our first change reached the server
expect(s.received(a)).toBe(false); // echo of the first click
expect(s.received(a)).toBe(false); // another update between the two echoes
expect(s.received(ab)).toBe(true); // caught up
expect(s.received(base)).toBe(true); // later server change (e.g. game switch) is followed again
});
test('a server-side change we did not send (admin lock) is followed', () => {
const s = new OptionSync();
s.sent(base, a);
expect(s.received({ ...base, undo: 'off' })).toBe(true);
});
test('key order does not matter', () => {
expect(optionsKey({ x: 1, y: { b: 2, a: 1 } })).toBe(optionsKey({ y: { a: 1, b: 2 }, x: 1 }));
});
});
test('withLocks keeps locked paths from the current options', () => {
const current = { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 }, undo: '3' };
const next = { ruleSet: 'korean', timeControl: { kind: 'none', perMoveSec: 45 }, undo: '1' };
expect(withLocks(next, current, ['ruleSet', 'timeControl.kind'])).toEqual({ ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 }, undo: '1' });
expect(withLocks(next, current, [])).toBe(next);
});

View File

@@ -0,0 +1,55 @@
/**
* Lobby settings are sent on every change (no save button). This keeps the host's screen from jumping back
* when a room update that was already on its way (e.g. someone joined) still carries older options.
*/
/** JSON with sorted object keys, so equal options compare equal whatever order their keys were built in. */
export function optionsKey(v: unknown): string {
return JSON.stringify(v, (_k, x: unknown) =>
x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x,
);
}
/** Copies the locked option paths (e.g. `timeControl.kind`) from the room's current options, as the server does. */
export function withLocks<T>(next: T, current: T, locked: string[]): T {
if (!locked.length) return next;
const out = structuredClone(next) as Record<string, unknown>;
for (const path of locked) {
const keys = path.split('.');
let src: unknown = current;
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
if (src === undefined) continue;
let dst = out;
for (const k of keys.slice(0, -1)) {
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
dst = dst[k] as Record<string, unknown>;
}
dst[keys[keys.length - 1]!] = structuredClone(src);
}
return out as T;
}
/**
* While changes are on their way: [server options before the first change, each sent options...]; empty when in sync.
*/
export class OptionSync {
private history: string[] = [];
/** Call before sending `sent`; `server` is the room's options as last received. */
sent(server: unknown, sent: unknown): void {
if (!this.history.length) this.history = [optionsKey(server)];
this.history.push(optionsKey(sent));
}
/** A room update arrived. True: show the server's options. False: it is older than our latest change, keep ours. */
received(server: unknown): boolean {
const i = this.history.lastIndexOf(optionsKey(server));
if (i >= 0 && i < this.history.length - 1) {
this.history = this.history.slice(i);
return false;
}
// Caught up with our last change, or changed on the server (admin, game switch): follow the server.
this.history = [];
return true;
}
}

View File

@@ -78,14 +78,10 @@ export const api = {
myGames: (before?: string | null) =>
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
createRoom: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId }),
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
publicRooms: (gameId: string) =>
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
};
export { call as apiCall };

View File

@@ -1,7 +1,7 @@
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
import { useEffect, useState } from 'react';
/** 게임 소개 + 방 만들기 / 컴퓨터와 연습 / 규칙 (docs/06 §2, §10). */
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite';
import { botsAllowed, catalogById } from '@bg/shared/lite';
import { Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api';
@@ -15,18 +15,9 @@ export default function GamePage() {
const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId);
const nav = useNavigate();
const me = useSession((s) => s.me);
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!g) return;
const load = () => api.publicRooms(gameId).then((r) => setRooms(r.rooms)).catch(() => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [gameId]); // eslint-disable-line react-hooks/exhaustive-deps
if (!g) return <main className="page">없는 게임이에요.</main>;
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const run = (p: () => Promise<{ code: string }>) =>
@@ -61,9 +52,6 @@ export default function GamePage() {
<button className="btn btn-primary btn-block" style={{ minHeight: 64 }} disabled={busy} onClick={() => run(() => api.createRoom(g.id))}>
+ 친구랑 하기 (방 만들기)
</button>
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
⚡ 아무나와 빠른 시작
</button>
{botsAllowed(g.id) && (
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
🤖 컴퓨터와 연습하기
@@ -73,23 +61,6 @@ export default function GamePage() {
📖 규칙 보기
</Link>
</div>
<section className="stack">
<h2>기다리는 방</h2>
{rooms.length === 0 && <p className="muted">지금 열린 공개 방이 없어요. [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between' }}>
<div>
<strong>{r.host}님의 방</strong>
<div className="muted small">
{r.seated}/{r.max}명 · 코드 {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</section>
{needName && (
<Sheet onClose={() => setNeedName(null)} label="이름 정하기">
<h2>먼저 이름을 정해 주세요</h2>

View File

@@ -1,12 +1,11 @@
/** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + [게임 고르기 | 지금 열린 방] 탭. */
import { useEffect, useState } from 'react';
/** 홈 (docs/07-ui-ux.md §3.3): 큰 버튼 3개(방 만들기·코드로 참여·연습) + 게임 고르기. */
import { useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
import { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { GameGrid } from '../components/GameGrid';
import { InstallCard } from '../components/InstallCard';
import { gameName } from '../components/GameNotice';
import { api } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
@@ -19,18 +18,9 @@ export function Home() {
const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [tab, setTab] = useState<'games' | 'rooms'>('games');
const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id));
useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string, practice = false) =>
withName(async () => {
@@ -80,41 +70,10 @@ export function Home() {
)}
</section>
<section className="stack" style={{ gap: 16 }}>
<div className="home-tabs" role="tablist" aria-label="홈 보기">
<button role="tab" id="tab-games" aria-controls="panel-games" aria-selected={tab === 'games'} onClick={() => setTab('games')}>
🎮 게임 고르기
</button>
<button role="tab" id="tab-rooms" aria-controls="panel-rooms" aria-selected={tab === 'rooms'} onClick={() => setTab('rooms')}>
🚪 지금 열린 방{rooms.length > 0 && <span className="badge">{rooms.length}</span>}
</button>
</div>
{tab === 'games' ? (
<div role="tabpanel" id="panel-games" aria-labelledby="tab-games" className="stack" style={{ gap: 16 }}>
<GameGrid games={available} />
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</div>
) : (
<div role="tabpanel" id="panel-rooms" aria-labelledby="tab-rooms" className="stack" style={{ gap: 12, maxWidth: 720 }}>
{rooms.length === 0 && <p className="muted card">지금 기다리는 공개 방이 없어요. 게임을 골라 [빠른 시작]을 누르면 새 방이 열려요.</p>}
{rooms.map((r) => (
<div key={r.code} className="card row" style={{ justifyContent: 'space-between', flexWrap: 'nowrap' }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
{GAME_ICON[r.gameId] ?? '🎲'}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<strong>{gameName(r.gameId)}</strong> <span className="muted small">· {r.host}님의 방</span>
<div className="muted small">
{r.seated}/{r.max}명 · {formatRoomCode(r.code)}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => withName(() => nav(`/r/${r.code}`))}>
참여
</button>
</div>
))}
</div>
)}
<section className="stack" style={{ gap: 16 }} aria-labelledby="home-games">
<h2 id="home-games">🎮 게임 고르기</h2>
<GameGrid games={available} />
{UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</section>
<InstallCard />

View File

@@ -9,6 +9,7 @@ import { QrModal } from '../components/QrModal';
import { api, type RoomPreview } from '../net/api';
import { socket, useRoom } from '../net/socket';
import { useSession } from '../store/session';
import { OptionSync, withLocks } from '../lib/option-sync';
import { loadGameUI } from '../games/registry';
import type { GameUI } from '../games/types';
import { sounds } from '../lib/feedback';
@@ -390,7 +391,6 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</button>
)}
</div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{me.kind === 'guest' && !practice && <GuestWarning />}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
@@ -497,22 +497,22 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
);
}
/** Host settings: game options + room options. Inline on PC, in a sheet on narrow screens. */
/**
* Host settings: game options + room options. Inline on PC, in a sheet on narrow screens.
* Every change is sent right away, so [게임 시작] always uses what is on screen.
*/
function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI; inline?: boolean; onDone?: () => void }) {
const [options, setOptions] = useState(room.options);
const [visibility, setVisibility] = useState(room.visibility);
const [allowSpectators, setAllowSpectators] = useState(room.allowSpectators);
const [chatEnabled, setChatEnabled] = useState(room.chatEnabled);
// Follow server-side changes (e.g. admin locks) when not editing.
useEffect(() => setOptions(room.options), [room.options]);
const dirty =
JSON.stringify(options) !== JSON.stringify(room.options) ||
visibility !== room.visibility ||
allowSpectators !== room.allowSpectators ||
chatEnabled !== room.chatEnabled;
const save = () => {
socket.send({ t: 'config', options, visibility, allowSpectators, chatEnabled });
onDone?.();
const sync = useRef(new OptionSync());
useEffect(() => {
if (sync.current.received(room.options)) setOptions(room.options);
}, [room.options]);
const changeOptions = (next: typeof options) => {
// Admin-locked rules keep the room's value, exactly as the server will (so the echo matches).
const out = withLocks(next, room.options, room.lockedOptions);
sync.current.sent(room.options, out);
setOptions(out);
socket.send({ t: 'config', options: out });
};
const seg = (value: boolean, set: (v: boolean) => void, on: string, off: string) => (
<div className="segmented">
@@ -527,36 +527,24 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
return (
<div className="stack" style={{ gap: 14 }}>
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
<ui.Options value={options} onChange={setOptions} />
<p className="muted small">바꾸면 바로 적용돼요.</p>
<ui.Options value={options} onChange={changeOptions} />
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong>
{room.seats.some((s) => s && isBotId(s.user.id)) ? (
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
) : (
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(2, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}>
<strong>관전</strong>
{seg(allowSpectators, setAllowSpectators, '허용', '안 됨')}
{seg(room.allowSpectators, (allowSpectators) => socket.send({ t: 'config', allowSpectators }), '허용', '안 됨')}
</div>
<div className="stack" style={{ gap: 6 }}>
<strong>채팅</strong>
{seg(chatEnabled, setChatEnabled, '켜기', '끄기')}
{seg(room.chatEnabled, (chatEnabled) => socket.send({ t: 'config', chatEnabled }), '켜기', '끄기')}
</div>
</div>
<div className="row">
<button className="btn btn-primary" style={{ flex: 2 }} disabled={inline && !dirty} onClick={save}>
{inline ? (dirty ? '설정 저장' : '저장됨') : '저장'}
{!inline && (
<button className="btn btn-primary btn-block" onClick={onDone}>
닫기
</button>
{!inline && (
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onDone}>
취소
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -1182,32 +1182,6 @@ button.header-user {
padding: 16px;
}
}
.home-tabs {
display: flex;
gap: 4px;
border-bottom: 2px solid var(--border);
}
.home-tabs button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--tap);
padding: 0 18px;
margin-bottom: -2px;
border: 0;
border-bottom: 3px solid transparent;
background: transparent;
color: var(--text-sub);
font: inherit;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
}
.home-tabs button[aria-selected='true'] {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Login page (docs/07 §3.2): big Discord button, guest below */
.btn-discord {
min-height: 68px;