Skip to content

Commit 06d71b8

Browse files
authored
Merge pull request #1 from Lawndlwd/feedback
Feedback
2 parents d9f71aa + a93f145 commit 06d71b8

File tree

23 files changed

+712
-186
lines changed

23 files changed

+712
-186
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,10 @@ dist/
66
.env
77
avatars/
88
.claude
9+
AGENTS.md
10+
CLAUDE.md
11+
client/.DS_Store
12+
server/.DS_Store
13+
.DS_Store
14+
15+
docs

client/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Settings from './pages/admin/Settings';
1111
import Game from './pages/play/Game';
1212
import Join from './pages/play/Join';
1313

14+
1415
function RequireAdmin({ children }: { children: React.ReactNode }) {
1516
const { isAdmin, checking } = useAuth();
1617
if (checking)

client/src/context/AppContext.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createContext, type ReactNode, useContext, useEffect, useState } from '
22

33
interface AppContextValue {
44
appName: string;
5+
appSubtitle: string;
56
/** Full display string: "Scaleway by ⚡ Quizz" or "⚡ Quizz" */
67
displayName: string;
78
/** Short brand string: "Scaleway" or "⚡ Quizz" */
@@ -10,26 +11,30 @@ interface AppContextValue {
1011

1112
const AppContext = createContext<AppContextValue>({
1213
appName: '',
14+
appSubtitle: '',
1315
displayName: '⚡ Quizz',
1416
brandName: '⚡ Quizz',
1517
});
1618

17-
function buildDisplay(appName: string): AppContextValue {
19+
function buildDisplay(appName: string, appSubtitle: string): AppContextValue {
1820
const trimmed = appName.trim();
1921
return {
2022
appName: trimmed,
23+
appSubtitle: appSubtitle.trim(),
2124
displayName: trimmed ? `${trimmed} by ⚡ Quizz` : '⚡ Quizz',
2225
brandName: trimmed || '⚡ Quizz',
2326
};
2427
}
2528

2629
export function AppProvider({ children }: { children: ReactNode }) {
27-
const [value, setValue] = useState<AppContextValue>(buildDisplay(''));
30+
const [value, setValue] = useState<AppContextValue>(buildDisplay('', ''));
2831

2932
useEffect(() => {
3033
fetch('/api/public')
3134
.then((r) => r.json())
32-
.then(({ appName }: { appName: string }) => setValue(buildDisplay(appName)))
35+
.then(({ appName, appSubtitle }: { appName: string; appSubtitle: string }) =>
36+
setValue(buildDisplay(appName, appSubtitle ?? '')),
37+
)
3338
.catch(() => {});
3439
}, []);
3540

client/src/pages/admin/GameControl.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
QuestionResults,
1111
Session,
1212
} from '../../types';
13+
import { CountdownScreen } from '../play/components/CountdownScreen';
1314
import { GameEnded } from './components/GameEnded';
1415
import { GameLobby } from './components/GameLobby';
1516
import { GameQuestion } from './components/GameQuestion';
@@ -29,7 +30,7 @@ interface SessionState {
2930
jokersUsed?: { pass: boolean; fiftyFifty: boolean };
3031
}
3132

32-
type Phase = 'lobby' | 'question' | 'results' | 'ended';
33+
type Phase = 'lobby' | 'countdown' | 'question' | 'results' | 'ended';
3334

3435
export default function GameControl() {
3536
const { sessionId } = useParams<{ sessionId: string }>();
@@ -45,6 +46,7 @@ export default function GameControl() {
4546
const [finalBoard, setFinalBoard] = useState<
4647
{ rank: number; username: string; totalScore: number; avatar?: string }[]
4748
>([]);
49+
const [countdownSec, setCountdownSec] = useState(3);
4850
const [timeLeft, setTimeLeft] = useState(0);
4951
const [answeredCount, setAnsweredCount] = useState(0);
5052
const [autoAdvanceLeft, setAutoAdvanceLeft] = useState(0);
@@ -86,7 +88,12 @@ export default function GameControl() {
8688
});
8789

8890
useSocketEvent<Record<string, never>>('game:started', () => {
89-
setPhase('question');
91+
// Phase will be set by game:countdown or game:question
92+
});
93+
94+
useSocketEvent<{ seconds: number }>('game:countdown', (data) => {
95+
setCountdownSec(data.seconds);
96+
setPhase('countdown');
9097
});
9198

9299
useSocketEvent<QuestionPayload>('game:question', (data) => {
@@ -197,6 +204,11 @@ export default function GameControl() {
197204
onCopyLink={() => navigator.clipboard.writeText(shareUrl)}
198205
/>
199206
)}
207+
{phase === 'countdown' && (
208+
<div className="main-content">
209+
<CountdownScreen seconds={countdownSec} />
210+
</div>
211+
)}
200212
{phase === 'question' && question && (
201213
<GameQuestion
202214
question={question}

client/src/pages/admin/Settings.tsx

Lines changed: 85 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ import { Input } from '../../components/Input';
44
import { useAuth } from '../../context/AuthContext';
55
import type { AppConfig } from '../../types';
66

7+
function computeSpeedBonus(position: number, totalPlayers: number, max: number, min: number) {
8+
if (totalPlayers <= 1) return max;
9+
return Math.max(Math.round(max - (max - min) * (position / (totalPlayers - 1))), min);
10+
}
11+
12+
function SpeedBonusPreview({ max, min }: { max: number; min: number }) {
13+
const examples = [5, 10, 20];
14+
return (
15+
<div
16+
style={{
17+
marginTop: 12,
18+
background: 'var(--surface2)',
19+
border: '1px solid var(--border)',
20+
borderRadius: 'var(--radius-sm)',
21+
padding: '12px 14px',
22+
}}
23+
>
24+
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginBottom: 8, fontWeight: 600 }}>
25+
Preview
26+
</p>
27+
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
28+
{examples.map((n) => (
29+
<div key={n} style={{ fontSize: '0.78rem', lineHeight: 1.6 }}>
30+
<span style={{ color: 'var(--text2)' }}>{n} players:</span>
31+
<br />
32+
{Array.from({ length: Math.min(n, 5) }, (_, i) => {
33+
const bonus = computeSpeedBonus(i, n, max, min);
34+
return (
35+
// biome-ignore lint/suspicious/noArrayIndexKey: stable list based on player count
36+
<span key={i} style={{ color: 'var(--accent2)' }}>
37+
{i + 1}st={bonus}
38+
{i < Math.min(n, 5) - 1 ? ', ' : ''}
39+
</span>
40+
);
41+
})}
42+
{n > 5 && (
43+
<span style={{ color: 'var(--text3)' }}>
44+
{' '}
45+
... {n}th={computeSpeedBonus(n - 1, n, max, min)}
46+
</span>
47+
)}
48+
</div>
49+
))}
50+
</div>
51+
</div>
52+
);
53+
}
54+
755
export default function Settings() {
856
const { token } = useAuth();
957
const [cfg, setCfg] = useState<Partial<AppConfig> | null>(null);
@@ -135,7 +183,7 @@ export default function Settings() {
135183

136184
{/* Branding */}
137185
<div className="card mb-6" style={{ maxWidth: 480 }}>
138-
<h2 className="mb-1">✏️ App Name</h2>
186+
<h2 className="mb-1">✏️ Branding</h2>
139187
<p className="text-sm text-muted mb-4">
140188
Shown as{' '}
141189
<strong style={{ color: 'var(--text)' }}>
@@ -148,8 +196,17 @@ export default function Settings() {
148196
placeholder="e.g. Scaleway (leave blank for default)"
149197
value={cfg.appName ?? ''}
150198
onChange={(e) => update('appName', e.target.value)}
199+
/>
200+
<Input
201+
label="Join page subtitle"
202+
placeholder="e.g. Quizz of the day — Cloud Edition"
203+
value={cfg.appSubtitle ?? ''}
204+
onChange={(e) => update('appSubtitle', e.target.value)}
151205
noMargin
152206
/>
207+
<p className="text-xs text-muted mt-2">
208+
Displayed on the player join screen below the logo. Leave blank to hide.
209+
</p>
153210
</div>
154211

155212
<div
@@ -181,15 +238,6 @@ export default function Settings() {
181238
onChange={(e) => update('defaultBaseScore', Number(e.target.value))}
182239
/>
183240

184-
<Input
185-
label="Default Speed Bonus (for players beyond top list)"
186-
type="number"
187-
min={0}
188-
step={5}
189-
value={cfg.defaultSpeedBonus ?? 25}
190-
onChange={(e) => update('defaultSpeedBonus', Number(e.target.value))}
191-
/>
192-
193241
<Input
194242
label="Max Players Per Session"
195243
type="number"
@@ -200,52 +248,34 @@ export default function Settings() {
200248
/>
201249

202250
<div className="form-group">
203-
<p className="form-label">Speed Bonuses (1st correct → 2nd → 3rd → …)</p>
204-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 6 }}>
205-
{(cfg.speedBonuses ?? [200, 150, 100, 50]).map((val, i) => (
206-
<div key={`speed-${i + 1}`} className="flex items-center gap-2">
207-
<span style={{ fontSize: '0.78rem', color: 'var(--text2)', minWidth: 54 }}>
208-
{i + 1}
209-
{i === 0 ? 'st' : i === 1 ? 'nd' : i === 2 ? 'rd' : 'th'} correct
210-
</span>
211-
<Input
212-
type="number"
213-
min={0}
214-
step={10}
215-
value={val}
216-
style={{ maxWidth: 110 }}
217-
onChange={(e) => {
218-
const bonuses = [...(cfg.speedBonuses ?? [])];
219-
bonuses[i] = Number(e.target.value);
220-
update('speedBonuses', bonuses);
221-
}}
222-
/>
223-
{(cfg.speedBonuses ?? []).length > 1 && (
224-
<button
225-
type="button"
226-
className="btn-icon"
227-
style={{ fontSize: '0.8rem' }}
228-
onClick={() => {
229-
const bonuses = (cfg.speedBonuses ?? []).filter((_, idx) => idx !== i);
230-
update('speedBonuses', bonuses);
231-
}}
232-
>
233-
234-
</button>
235-
)}
236-
</div>
237-
))}
238-
</div>
239-
<button
240-
type="button"
241-
className="btn btn-ghost btn-sm"
242-
onClick={() => update('speedBonuses', [...(cfg.speedBonuses ?? []), 0])}
243-
>
244-
+ Add Tier
245-
</button>
246-
<p className="text-xs text-muted mt-2">
247-
Players beyond the last tier use the Default Speed Bonus above.
251+
<p className="form-label" style={{ marginBottom: 4 }}>
252+
Speed Bonus (awarded to correct answerers based on answer speed)
248253
</p>
254+
<p className="text-xs text-muted mb-4">
255+
Scales linearly from max to min based on answer position relative to total players.
256+
The 1st correct answerer gets the max bonus, the last gets the min.
257+
</p>
258+
<div className="form-row">
259+
<Input
260+
label="Max bonus (1st correct)"
261+
type="number"
262+
min={0}
263+
step={10}
264+
value={cfg.speedBonusMax ?? 200}
265+
onChange={(e) => update('speedBonusMax', Number(e.target.value))}
266+
noMargin
267+
/>
268+
<Input
269+
label="Min bonus (last correct)"
270+
type="number"
271+
min={0}
272+
step={5}
273+
value={cfg.speedBonusMin ?? 10}
274+
onChange={(e) => update('speedBonusMin', Number(e.target.value))}
275+
noMargin
276+
/>
277+
</div>
278+
<SpeedBonusPreview max={cfg.speedBonusMax ?? 200} min={cfg.speedBonusMin ?? 10} />
249279
</div>
250280

251281
<div className="form-group">
@@ -264,19 +294,6 @@ export default function Settings() {
264294
noMargin
265295
/>
266296
</div>
267-
268-
<div className="form-group flex items-center gap-3" style={{ marginBottom: 0 }}>
269-
<input
270-
type="checkbox"
271-
id="lateJoin"
272-
style={{ width: 'auto' }}
273-
checked={cfg.allowLateJoin ?? false}
274-
onChange={(e) => update('allowLateJoin', e.target.checked)}
275-
/>
276-
<label htmlFor="lateJoin" style={{ marginBottom: 0 }}>
277-
Allow late join (players can join mid-game)
278-
</label>
279-
</div>
280297
</div>
281298

282299
{/* Streak bonus settings */}

client/src/pages/admin/components/GameEnded.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ interface Props {
1515
onDashboard: () => void;
1616
}
1717

18+
const MEDALS: Record<number, string> = { 1: '🥇', 2: '🥈', 3: '🥉' };
19+
1820
export function GameEnded({ quizTitle, leaderboard, onViewDetails, onDashboard }: Props) {
1921
return (
2022
<div className="main-content">
@@ -29,7 +31,7 @@ export function GameEnded({ quizTitle, leaderboard, onViewDetails, onDashboard }
2931
<ul className="leaderboard">
3032
{leaderboard.map((e) => (
3133
<li key={e.rank} className={`lb-item rank-${Math.min(e.rank, 4)}`}>
32-
<div className="lb-rank">{e.rank}</div>
34+
<div className="lb-rank">{MEDALS[e.rank] ?? e.rank}</div>
3335
<AvatarDisplay avatar={e.avatar} size={30} />
3436
<div className="lb-name">{e.username}</div>
3537
<div className="lb-score">{e.totalScore.toLocaleString()}</div>

0 commit comments

Comments
 (0)