Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/publish-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ jobs:
context: ./backend
- image: f1replaytiming-frontend
context: ./frontend
build-args: |
NEXT_PUBLIC_API_URL=http://localhost:8000

steps:
- name: Checkout
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

All notable changes to F1 Timing Replay will be documented in this file.

## 1.2.1 - 2026-03-14

### Fixes
- **Connection error screen** - when the frontend cannot reach the backend, a clear error message is now shown instead of the passphrase screen, with the attempted URL and troubleshooting tips
- **Runtime API URL for Docker** - `NEXT_PUBLIC_API_URL` can now be set as a runtime environment variable on the frontend container, so pre-built Docker images work with any backend URL without rebuilding. See the README for details
- Fixed loading overlay staying visible when navigating back to the session picker

### New Features
- **Red flag countdown and skip** - during red flag periods in replay mode, a countdown timer shows how long until the session resumes, with a button to skip ahead to the restart

---

## 1.2.0 - 2026-03-14

### New Features
Expand Down
44 changes: 40 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ services:
image: ghcr.io/adn8naiagent/f1replaytiming-frontend:latest
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000 # Change to your backend URL if not using localhost
depends_on:
- backend

Expand Down Expand Up @@ -90,13 +92,47 @@ Open http://localhost:3000. Select any past session and it will be processed on

### Docker configuration

To enable optional features, edit the environment variables in `docker-compose.yml`:
#### Network & URL configuration

Two environment variables control how the frontend and backend find each other:

| Variable | Set on | Purpose |
|---|---|---|
| `NEXT_PUBLIC_API_URL` | frontend | The URL your **browser** uses to reach the backend |
| `FRONTEND_URL` | backend | The URL your browser uses to reach the frontend (needed for CORS) |

The defaults (`http://localhost:8000` and `http://localhost:3000`) work when accessing the app on the same machine running Docker. If you access from another device, use a reverse proxy, or change ports, update both variables to match.

**Example — accessing from other devices on your network:**
```yaml
backend:
environment:
- FRONTEND_URL=http://192.168.1.50:3000

frontend:
environment:
- NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
```

**Example — behind a reverse proxy (e.g. Cloudflare Tunnel, nginx):**
```yaml
backend:
environment:
- FRONTEND_URL=https://f1.example.com

frontend:
environment:
- NEXT_PUBLIC_API_URL=https://api.f1.example.com
```

In this setup your reverse proxy routes `f1.example.com` to the frontend container (port 3000) and `api.f1.example.com` to the backend container (port 8000).

#### Optional features

- `OPENROUTER_API_KEY` - enables the photo sync feature ([get a key](https://openrouter.ai/))
- `AUTH_ENABLED` / `AUTH_PASSPHRASE` - restricts access with a passphrase

If you change ports, make sure to update:
- `FRONTEND_URL` on the backend to match the URL you access the frontend on (used for CORS)
- `NEXT_PUBLIC_API_URL` on the frontend to match the backend URL (this is baked in at build time, so rebuild with `docker compose up --build` after changing it)
#### Data

Session data is persisted in a Docker volume, so it survives restarts.

Expand Down
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ DATA_DIR=./data
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=f1timingdata
R2_BUCKET_NAME=

# Only needed for pre-compute script (not required at runtime)
FASTF1_CACHE_DIR=.fastf1-cache
Expand Down
7 changes: 7 additions & 0 deletions backend/routers/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ async def auth_login(body: LoginRequest):
if token is None:
raise HTTPException(status_code=401, detail="Invalid passphrase")
return {"token": token}


@router.get("/verify")
async def auth_verify():
"""Validate a cached token. Not in AUTH_SKIP_PATHS, so the auth middleware
will reject invalid tokens with 401 before this handler runs."""
return {"valid": True}
11 changes: 4 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ services:
ports:
- "8000:8000" # Change the left side to use a different host port
environment:
- FRONTEND_URL=http://localhost:3000 # Must match the URL you access the frontend on (for CORS)
- FRONTEND_URL=http://localhost:3000 # The URL your browser uses to access the frontend (for CORS)
- DATA_DIR=/data
# Optional - uncomment to enable features
# - OPENROUTER_API_KEY=your-key-here # enables photo/screenshot sync (manual entry sync works without this)
Expand All @@ -15,14 +15,11 @@ services:
- f1cache:/data/fastf1-cache

frontend:
build:
context: ./frontend
args:
# This URL is baked into the frontend at build time — it's how the browser reaches the backend.
# If you change the backend port above, update this to match (e.g. http://localhost:9000).
NEXT_PUBLIC_API_URL: http://localhost:8000
build: ./frontend
ports:
- "3000:3000" # Change the left side to use a different host port
environment:
- NEXT_PUBLIC_API_URL=http://localhost:8000 # The URL your browser uses to reach the backend
depends_on:
- backend

Expand Down
6 changes: 5 additions & 1 deletion frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build
Expand All @@ -30,11 +30,15 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]
12 changes: 12 additions & 0 deletions frontend/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/sh
# Replace the build-time placeholder with the runtime NEXT_PUBLIC_API_URL.
# If NEXT_PUBLIC_API_URL is not set, default to http://localhost:8000.
RUNTIME_URL="${NEXT_PUBLIC_API_URL:-http://localhost:8000}"
PLACEHOLDER="__NEXT_PUBLIC_API_URL__"

if [ "$RUNTIME_URL" != "$PLACEHOLDER" ]; then
find /app/.next -name "*.js" -exec sed -i "s|$PLACEHOLDER|$RUNTIME_URL|g" {} +
echo "Configured API URL: $RUNTIME_URL"
fi

exec "$@"
96 changes: 82 additions & 14 deletions frontend/src/components/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,75 @@
"use client";

import { useState, useEffect, FormEvent } from "react";
import { apiUrl } from "@/lib/api";
import { getToken, setToken } from "@/lib/auth";
import { useState, useEffect, useCallback, FormEvent } from "react";
import { apiUrl, API_URL } from "@/lib/api";
import { getToken, setToken, clearToken } from "@/lib/auth";

export default function AuthGate({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true);
const [authRequired, setAuthRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [connectionError, setConnectionError] = useState(false);
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);

useEffect(() => {
fetch(apiUrl("/api/auth/status"))
.then((res) => res.json())
const checkAuth = useCallback(() => {
setChecking(true);
setConnectionError(false);

const url = apiUrl("/api/auth/status");
console.log(`[AuthGate] Checking auth status at ${url}`);

fetch(url)
.then((res) => {
if (!res.ok) {
console.error(`[AuthGate] Auth status returned ${res.status}`);
throw new Error(`Backend returned ${res.status}`);
}
return res.json();
})
.then((data) => {
console.log(`[AuthGate] Auth enabled: ${data.auth_enabled}`);
if (!data.auth_enabled) {
setAuthenticated(true);
setChecking(false);
} else {
setAuthRequired(true);
// Check if we have a cached token that still works
const token = getToken();
if (token) {
fetch(apiUrl("/api/health"), {
fetch(apiUrl("/api/auth/verify"), {
headers: { Authorization: `Bearer ${token}` },
}).then((res) => {
if (res.ok) {
console.log("[AuthGate] Cached token is valid");
setAuthenticated(true);
} else {
console.log("[AuthGate] Cached token is invalid, clearing");
clearToken();
}
setChecking(false);
}).catch(() => setChecking(false));
}).catch(() => {
console.error("[AuthGate] Failed to validate cached token");
setChecking(false);
});
} else {
setChecking(false);
}
}
})
.catch(() => {
// Can't reach backend — assume auth is required so we don't bypass it
setAuthRequired(true);
})
.finally(() => {
if (!authRequired) setChecking(false);
.catch((err) => {
console.error(`[AuthGate] Cannot connect to backend at ${API_URL}:`, err.message);
setConnectionError(true);
setChecking(false);
});
}, []);

useEffect(() => {
checkAuth();
}, [checkAuth]);

const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
Expand Down Expand Up @@ -83,6 +107,50 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
);
}

if (connectionError) {
return (
<div className="min-h-screen bg-f1-dark flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img src="/logo.png" alt="F1 Replay" className="w-16 h-16 rounded-lg mx-auto mb-4" />
<h1 className="text-xl font-bold text-white">F1 Replay Timing</h1>
</div>

<div className="bg-f1-card border border-f1-border rounded-xl p-6">
<h2 className="text-sm font-bold text-red-400 mb-3">Cannot connect to backend</h2>
<p className="text-sm text-f1-muted mb-3">
The frontend failed to reach the API server at:
</p>
<code className="block text-xs text-white bg-f1-dark border border-f1-border rounded px-3 py-2 mb-4 break-all">
{API_URL}
</code>
<div className="text-xs text-f1-muted space-y-2">
<p>Common causes:</p>
<ul className="list-disc list-inside space-y-1 ml-1">
<li>The backend container is still starting up</li>
<li>
<code className="text-white">NEXT_PUBLIC_API_URL</code> in your
docker-compose.yml is set to a URL that isn&apos;t reachable from
your browser
</li>
<li>
If behind a reverse proxy, this URL must be the address your browser
uses to reach the backend, not an internal Docker address
</li>
</ul>
</div>
<button
onClick={checkAuth}
className="w-full mt-5 px-4 py-2 bg-f1-red text-white text-sm font-bold rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
</div>
</div>
);
}

if (authenticated) {
return <>{children}</>;
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/SessionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default function SessionPicker() {
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [navigating, setNavigating] = useState(false);
useEffect(() => setNavigating(false), []);
const latestRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);

Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/useReplaySocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export function useReplaySocket(year: number, round: number, sessionType: string
weather: msg.weather,
quali_phase: msg.quali_phase,
rc_messages: msg.rc_messages,
red_flag_end: msg.red_flag_end,
},
}));
break;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getToken, clearToken } from "./auth";

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";

export function apiUrl(path: string): string {
return `${API_URL}${path}`;
Expand Down
2 changes: 1 addition & 1 deletion frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading