Skip to content

Commit 2060e9e

Browse files
authored
Merge pull request #7 from GilbN/claude/issue-5-20260409-2200
feat: add solo mode
2 parents 279fbaf + cc7c54a commit 2060e9e

12 files changed

Lines changed: 117 additions & 39 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
VITE_HTTPS=true
2+
VITE_BASE_PATH=/
3+
VITE_WS_SERVER_URL=/ws
4+
WS_PORT=8080
5+
WS_ALLOWED_ORIGINS=https://localhost:5173,http://localhost:5173

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ yarn-debug.log*
66
yarn-error.log*
77
pnpm-debug.log*
88
lerna-debug.log*
9+
.env
910

1011
node_modules
1112
dist

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ For internet-facing deployments, restrict WebSocket origins:
3535

3636
```bash
3737
docker run -d -p 80:80 \
38-
-e ALLOWED_ORIGINS=https://timer.example.com \
38+
-e WS_ALLOWED_ORIGINS=https://timer.example.com \
3939
ghcr.io/gilbn/opk-timer:latest
4040
```
4141

@@ -51,7 +51,7 @@ services:
5151
- "80:80"
5252
restart: unless-stopped
5353
environment:
54-
ALLOWED_ORIGINS: "*"
54+
WS_ALLOWED_ORIGINS: "*"
5555
```
5656
5757
## Tech stack
@@ -76,8 +76,8 @@ services:
7676

7777
| Variable | Default | Description |
7878
|----------|---------|-------------|
79-
| `PORT` | `8080` | Port the WebSocket relay server listens on |
80-
| `ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed origins for WebSocket connections |
79+
| `WS_PORT` | `8080` | Port the WebSocket relay server listens on |
80+
| `WS_ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed origins for WebSocket connections |
8181

8282
## Getting started
8383

server/index.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { WebSocketServer, WebSocket } from 'ws'
22

3-
const PORT = process.env.PORT || 8080
4-
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS || '*'
3+
const WS_PORT = process.env.WS_PORT || 8080
4+
const WS_ALLOWED_ORIGINS = process.env.WS_ALLOWED_ORIGINS || '*'
55
const HEARTBEAT_INTERVAL = 30_000
66
const HEARTBEAT_TIMEOUT = 10_000
77
const HOST_GRACE_PERIOD = 60_000 * 30 // 10 minutes for host to reconnect
@@ -245,10 +245,10 @@ function startHeartbeat(wss) {
245245
// --- Server setup ---
246246

247247
const wss = new WebSocketServer({
248-
port: PORT,
248+
port: WS_PORT,
249249
verifyClient: ({ origin }, cb) => {
250-
if (ALLOWED_ORIGINS === '*' || !origin) return cb(true)
251-
const allowed = ALLOWED_ORIGINS.split(',').map(o => o.trim())
250+
if (WS_ALLOWED_ORIGINS === '*' || !origin) return cb(true)
251+
const allowed = WS_ALLOWED_ORIGINS.split(',').map(o => o.trim())
252252
cb(allowed.includes(origin), 403, 'Forbidden')
253253
},
254254
})
@@ -285,4 +285,4 @@ wss.on('connection', (ws) => {
285285

286286
startHeartbeat(wss)
287287

288-
console.log(`opk-timer relay server listening on ws://localhost:${PORT}`)
288+
console.log(`opk-timer relay server listening on ws://localhost:${WS_PORT}`)

server/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"private": true,
44
"type": "module",
55
"scripts": {
6-
"start": "node index.js",
7-
"dev": "node --watch index.js"
6+
"start": "node --env-file-if-exists=../.env index.js",
7+
"dev": "node --env-file-if-exists=../.env --watch index.js"
88
},
99
"dependencies": {
1010
"ws": "^8.18.0"

src/App.svelte

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,27 @@
2727
2828
async function restoreSession() {
2929
const savedRoom = loadRoomState()
30-
if (!savedRoom?.code) return
30+
if (!savedRoom?.code && !savedRoom?.isSolo) return
3131
3232
try {
33-
if (savedRoom.isHost) {
33+
if (savedRoom.isSolo) {
34+
// Solo mode: restore without any WebSocket
35+
roomState.set({ code: null, isHost: true, isSolo: true, connectedPeers: [] })
36+
const savedTimer = loadTimerState()
37+
if (savedTimer?.programId || savedRoom.programId) {
38+
const scheduler = new TimerScheduler()
39+
window.__opkScheduler = scheduler
40+
if (savedTimer?.programId) {
41+
scheduler.loadProgram(savedTimer.programId)
42+
scheduler.restoreState(savedTimer)
43+
} else {
44+
scheduler.loadProgram(savedRoom.programId)
45+
}
46+
currentView.set('timer')
47+
} else {
48+
currentView.set('lobby')
49+
}
50+
} else if (savedRoom.isHost) {
3451
// Host reload: reclaim the same room code
3552
const host = new SocketHost()
3653
await host.createRoom(savedRoom.code)

src/lib/i18n.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const translations = {
1010
shareToInstall: 'Del',
1111
createRoom: 'Opprett rom',
1212
joinRoom: 'Bli med i rom',
13+
soloMode: 'Solo',
1314
stopwatch: 'Stoppeklokke',
1415
roomCode: 'Romkode',
1516
enterCode: 'Skriv inn kode',
@@ -128,6 +129,7 @@ const translations = {
128129
shareToInstall: 'Share',
129130
createRoom: 'Create Room',
130131
joinRoom: 'Join Room',
132+
soloMode: 'Solo',
131133
stopwatch: 'Stopwatch',
132134
roomCode: 'Room Code',
133135
enterCode: 'Enter code',

src/lib/stores.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const roomState = writable({
88
code: null,
99
isHost: false,
1010
isSpectator: false,
11+
isSolo: false,
1112
connectedPeers: [],
1213
})
1314

src/views/HomeView.svelte

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@
180180
currentView.set('stopwatch')
181181
}
182182
183+
function startSolo() {
184+
unlockAudio()
185+
roomState.set({ code: null, isHost: true, isSolo: true, connectedPeers: [] })
186+
saveRoomState({ isSolo: true, isHost: true })
187+
currentView.set('lobby')
188+
}
189+
183190
function timeAgo(ts) {
184191
const diff = Date.now() - ts
185192
const min = Math.floor(diff / 60000)
@@ -258,14 +265,23 @@
258265
</div>
259266
</div>
260267
261-
<button class="btn-ghost stopwatch-btn" onclick={openStopwatch}>
262-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
263-
<circle cx="12" cy="13" r="8"/>
264-
<polyline points="12 9 12 13 14.5 15"/>
265-
<path d="M9 3h6M12 3v2"/>
266-
</svg>
267-
{$t('stopwatch')}
268-
</button>
268+
<div class="ghost-row">
269+
<button class="btn-ghost" onclick={startSolo}>
270+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
271+
<circle cx="12" cy="8" r="4"/>
272+
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
273+
</svg>
274+
{$t('soloMode')}
275+
</button>
276+
<button class="btn-ghost" onclick={openStopwatch}>
277+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
278+
<circle cx="12" cy="13" r="8"/>
279+
<polyline points="12 9 12 13 14.5 15"/>
280+
<path d="M9 3h6M12 3v2"/>
281+
</svg>
282+
{$t('stopwatch')}
283+
</button>
284+
</div>
269285
270286
<button class="btn-ghost display-btn" onclick={() => { showDisplayForm = !showDisplayForm; error = '' }}>
271287
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -533,6 +549,15 @@
533549
border-color: rgba(255,255,255,0.15);
534550
}
535551
552+
.ghost-row {
553+
display: flex;
554+
gap: 0.4rem;
555+
}
556+
557+
.ghost-row .btn-ghost {
558+
flex: 1;
559+
}
560+
536561
/* ── Recent rooms ── */
537562
.recent-rooms {
538563
display: flex;

src/views/LobbyView.svelte

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
2727
scheduler.loadProgram(programId)
2828
29-
saveRoomState({ code: $roomState.code, isHost: true, programId })
29+
saveRoomState({ code: $roomState.code, isHost: true, isSolo: $roomState.isSolo, programId })
3030
currentView.set('timer')
3131
}
3232
@@ -37,7 +37,7 @@
3737
3838
function disconnect() {
3939
if (!confirm(get(t)('confirmDisconnect'))) return
40-
if ($roomState.code) {
40+
if ($roomState.code && !$roomState.isSolo) {
4141
addRoomToHistory({ code: $roomState.code, isHost: true })
4242
}
4343
if (window.__opkHost) {
@@ -51,11 +51,13 @@
5151

5252
<div class="view lobby-view">
5353
<div class="top-bar">
54-
<RoomCode code={$roomState.code} />
55-
<div class="top-bar-right">
54+
{#if $roomState.isSolo}
55+
<div class="solo-badge">{$t('soloMode')}</div>
56+
{:else}
57+
<RoomCode code={$roomState.code} />
5658
<ConnectionStatus status="connected" />
57-
<SettingsMenu />
58-
</div>
59+
{/if}
60+
<SettingsMenu />
5961
</div>
6062

6163
{#if $roomState.connectedPeers.length > 0}
@@ -90,10 +92,17 @@
9092
gap: 0.5rem;
9193
}
9294
93-
.top-bar-right {
94-
display: flex;
95-
align-items: center;
96-
gap: 0.5rem;
95+
.solo-badge {
96+
font-family: var(--font-mono);
97+
font-size: 1rem;
98+
font-weight: 700;
99+
letter-spacing: 0.18em;
100+
color: var(--accent);
101+
padding: 0.35rem 0.7rem;
102+
background: var(--bg-surface);
103+
border: 1px solid rgba(0, 230, 118, 0.2);
104+
border-radius: var(--radius);
105+
text-transform: uppercase;
97106
}
98107
99108
.peer-pill {

0 commit comments

Comments
 (0)