Initial Commit

This commit is contained in:
Nik Afiq 2025-11-04 20:53:28 +09:00
commit 3ebe2cbf47
24 changed files with 4494 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
Dockerfile
docker-compose.yml
*.log
**/*.log
modules
dist
tmp
.DS_Store

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
.DS_Store
*/node_modules

197
README.md Normal file
View File

@ -0,0 +1,197 @@
# Watch Party (Frontend + Backend)
Watch Party is a small full-stack app:
- **Frontend:** Vite + React + TypeScript, served by **Nginx**
- **Backend:** Go (Gin) API + PostgreSQL
- **Orchestration:** One **docker-compose** that keeps FE/BE split on the same internal network
- **Routing:** Your Debian Nginx is the public entrypoint and reverse-proxies to the Mac mini (where this stack runs)
```
router → Debian nginx (TLS) → Mac mini :3000 → [web nginx ↔ api ↔ db]
```
---
## Repository Layout
```
.
├── docker-compose.yml # single compose for web + api + db + migrate
├── .env # stack configuration (see template below)
├── backend/ # Go API + migrations
│ ├── cmd/
│ │ ├── migrate/ # one-off migration binary
│ │ └── server/ # API server (Gin)
│ ├── db/migration/ # SQL migrations (0001_init.sql, etc.)
│ ├── Dockerfile # builds server + migrate
│ └── go.mod / go.sum
└── frontend/ # Vite + React + TS
├── Dockerfile # Vite build → Nginx runtime (envsubst for proxy)
├── ops/ # container runtime bits
│ ├── entrypoint.sh
│ ├── nginx.conf # (optional/manual)
│ └── nginx.conf.template # uses BACKEND_ORIGIN
├── src/, public/, vite.config.ts, package*.json, tsconfig*.json
```
---
## Services (Compose)
- **web**: Nginx serving the built SPA, and proxying `/api/*` to `api:8082` (Docker DNS)
- **api**: Go server (Gin), listens on `:8082`, health at `/healthz`
- **migrate**: one-off job that runs the `migrate` binary, applies SQL from `backend/db/migration`
- **db**: PostgreSQL 16 (internal only)
Only **web** is published to your LAN (port `3000` by default). `api` and `db` are internal to the compose network.
---
## .env (template)
```dotenv
##################
## Frontend env ##
##################
WEB_PORT=3000
PUBLIC_BASE_PATH=/watch-party/
BACKEND_ORIGIN=http://api:8082
#################
## Backend env ##
#################
# Postgres (container)
POSTGRES_DB=watchparty
POSTGRES_USER=admin
POSTGRES_PASSWORD=change-me
TZ=Asia/Tokyo
COMPOSE_PLATFORM=linux/arm64/v8
# Backend server
ADDR=:8082
GIN_MODE=release
PGHOST=db
POSTGRES_PORT=5432
PGSSLMODE=disable
```
> **Why mixed names?** The current Go code builds its DSN from `PGHOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, and `PGSSLMODE`.
---
## Run (Production-like)
```bash
docker compose build
docker compose up -d
```
Open: `http://<mac-mini-LAN-IP>:3000/watch-party/`
Health checks:
- Web (nginx): `curl http://<mac-mini-LAN-IP>:3000/`
- API through web proxy: `curl http://<mac-mini-LAN-IP>:3000/api/healthz`
- API direct (inside network only): `curl http://api:8082/healthz` (from another container)
Logs:
```bash
docker compose logs -f migrate # should start, apply SQL, and exit 0
docker compose logs -f api
docker compose logs -f web
docker compose logs -f db
```
---
## Debian Nginx (public reverse proxy)
Terminate TLS on Debian and forward to the Mac mini:
```nginx
# Serve the SPA under /watch-party/
location /watch-party/ {
proxy_pass http://<mac-mini-LAN-IP>:3000/watch-party/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
# Proxy API via the web container (keeps same-origin from browser POV)
location /api/ {
proxy_pass http://<mac-mini-LAN-IP>:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
```
Security headers & HSTS on Debian are recommended.
---
## Local Development (hot reload)
You can dev each side outside Docker:
- **Frontend**
```bash
cd frontend
npm ci
npm run dev # http://localhost:5173
```
Configure your FE to call the API (e.g., via a local `.env` or vite config).
- **Backend**
```bash
cd backend
go run ./cmd/server
```
Make sure Postgres is reachable (either from the compose `db` or your local).
For a dev-only compose with Vite HMR, create an override (not included here).
---
## Notes & Conventions
- **Only `web` is exposed** to the LAN. `api` and `db` are `expose`d internally.
- The frontend **does not** use `host.docker.internal`; it proxies to `api:8082` by Docker DNS.
- Backend healthcheck probes `http://localhost:8082/healthz` and the server binds `ADDR=:8082`.
- Migrations live in `backend/db/migration/` and are applied by the **migrate** container at startup.
- Vite base path is controlled by `PUBLIC_BASE_PATH` (e.g., `/watch-party/`).
---
## Quick Test Endpoints
```bash
# Latest health:
curl http://<mac-mini-LAN-IP>:3000/api/healthz
# Current show (if table seeded and current_ep=true):
curl http://<mac-mini-LAN-IP>:3000/api/current
```
---
## Troubleshooting
- **Migrate “hangs” / API starts instead**
Ensure the backend image uses `CMD` (not `ENTRYPOINT`) and compose sets:
```yaml
migrate:
command: ["/app/migrate"]
api:
command: ["/app/server"]
```
- **404 for deep links** under `/watch-party/`
Confirm SPA fallback is active in `frontend/ops/nginx.conf.template` and Vite `base` matches `PUBLIC_BASE_PATH`.
- **DB not reachable**
Check `.env` values match the mixed DSN env names. `PGHOST=db`, `POSTGRES_PORT=5432`, etc.
- **CORS issues**
There shouldnt be any—browser hits `web`, which proxies to `api`, so its same-origin.
---
## License
MIT

101
docker-compose.yml Normal file
View File

@ -0,0 +1,101 @@
version: "3.9"
name: watch-party
services:
# Frontend (Vite built → nginx). Only public-facing service on LAN.
web:
build:
context: ./frontend
dockerfile: Dockerfile
args:
PUBLIC_BASE_PATH: ${PUBLIC_BASE_PATH}
image: watchparty-frontend:prod
container_name: watchparty-frontend
environment:
BACKEND_ORIGIN: ${BACKEND_ORIGIN}
ports:
- "${WEB_PORT:-3000}:80"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
networks: [internal]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
# Backend DB (internal only)
db:
image: postgres:16-alpine
platform: ${COMPOSE_PLATFORM}
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
ports:
- "${POSTGRES_PORT:-5432}:5432" ####### TEMPORARY EXPOSE #########
volumes:
- pgdata:/var/lib/postgresql/data
command: >
postgres
-c listen_addresses='*'
-c shared_buffers=64MB
-c max_connections=50
-c log_min_duration_statement=500ms
-c log_destination=stderr
-c logging_collector=on
-c timezone='${TZ:-Asia/Tokyo}'
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks: [internal]
# One-off migration job (idempotent)
migrate:
build:
context: ./backend
dockerfile: Dockerfile
image: watchparty-backend:latest
entrypoint: ["/app/migrate"]
env_file:
- ./.env
depends_on:
db:
condition: service_healthy
restart: "no"
networks: [internal]
# API server (internal port only; reached via web → proxy)
api:
image: watchparty-backend:latest
env_file:
- ./.env
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
expose:
- "8082"
restart: unless-stopped
networks: [internal]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/healthz"]
interval: 10s
timeout: 5s
retries: 10
ports:
- "${APP_PORT:-8082}:8082" ####### TEMPORARY EXPOSE #########
networks:
internal:
driver: bridge
volumes:
pgdata:

23
frontend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# ---- build ----
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# NEW: base path for Vite (e.g. /watch-party/)
ARG PUBLIC_BASE_PATH=/
ENV PUBLIC_BASE_PATH=${PUBLIC_BASE_PATH}
RUN npm run build
# ---- runtime (nginx) ----
FROM nginx:1.27-alpine
RUN apk add --no-cache gettext
COPY --from=build /app/dist /usr/share/nginx/html
COPY ops/nginx.conf.template /etc/nginx/conf.d/default.conf.template
COPY ops/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV BACKEND_ORIGIN=http://localhost:8082
EXPOSE 80
ENTRYPOINT ["/bin/sh","/entrypoint.sh"]

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tokyo-clock</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,7 @@
#!/bin/sh
set -e
: "${BACKEND_ORIGIN:=http://localhost:8082}"
envsubst '$BACKEND_ORIGIN' \
< /etc/nginx/conf.d/default.conf.template \
> /etc/nginx/conf.d/default.conf
exec nginx -g 'daemon off;'

12
frontend/ops/nginx.conf Normal file
View File

@ -0,0 +1,12 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback (deep links work)
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# (Optional) redirect bare root to /watch-party/
# Helps avoid loading "/" by mistake (your log shows that happened once)
location = / { return 302 /watch-party/; }
# Redirect no-trailing-slash to trailing slash
location = /watch-party { return 302 /watch-party/; }
# Serve the SPA under /watch-party/ using alias
# /watch-party/... -> /usr/share/nginx/html/...
location /watch-party/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
}
# Proxy API unchanged (your React app should request /api/current)
location /api/ {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_pass ${BACKEND_ORIGIN}/;
}
}

3215
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "tokyo-clock",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@types/node": "^20.19.17",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.44.0",
"vite": "npm:rolldown-vite@7.1.12"
},
"overrides": {
"vite": "npm:rolldown-vite@7.1.12"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

21
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,21 @@
import "./index.css";
// import DigitalClock from "./components/DigitalClock";
import Timer from "./components/Timer";
export default function App() {
return (
<div className="app">
<main className="card">
<Timer />
{/* <DigitalClock /> */}
<div className="footer">
Built by{" "}
<a href="https://x.com/nik4nao" target="_blank" rel="noopener noreferrer">
@nik4nao
</a>{" "}
contact for inquiries or requirements.
</div>
</main>
</div>
);
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from "react";
const TIMEZONE = "Asia/Tokyo"; // JST, UTC+09:00, no DST
const LOCALE = "en-GB"; // ensures HH:MM:SS 24h digits
// One formatter for time
const timeFmt = new Intl.DateTimeFormat(LOCALE, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: TIMEZONE,
});
// And one for date + weekday (e.g., Mon, 2025-09-29)
const dateFmt = new Intl.DateTimeFormat(LOCALE, {
weekday: "short",
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: TIMEZONE,
});
/**
* Returns "HH:MM:SS" and a human friendly date line in Tokyo time.
* Uses formatToParts to avoid weird locale punctuation.
*/
function getTokyoNow() {
const parts = timeFmt.formatToParts(new Date());
const map: Record<string, string> = {};
for (const p of parts) map[p.type] = p.value;
const hh = map.hour ?? "00";
const mm = map.minute ?? "00";
const ss = map.second ?? "00";
return {
time: `${hh}:${mm}:${ss}`,
date: dateFmt.format(new Date()),
};
}
/**
* A setTimeout-based ticker aligned to the *next* second boundary
* to minimize drift over time.
*/
export default function DigitalClock() {
const [{ time, date }, setState] = useState(getTokyoNow());
const timerRef = useRef<number | null>(null);
useEffect(() => {
const schedule = () => {
// Update now
setState(getTokyoNow());
// Compute delay to next exact second
const now = Date.now();
const msToNextSecond = 1000 - (now % 1000);
timerRef.current = window.setTimeout(schedule, msToNextSecond + 5);
};
// Start immediately
schedule();
// Pause updates when tab is hidden (save battery)
const onVisibility = () => {
if (document.hidden) {
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = null;
} else {
schedule();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
if (timerRef.current) window.clearTimeout(timerRef.current);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
return (
<div>
<div></div>
<div className="clock" aria-live="polite" aria-label="Tokyo time">
{time}
</div>
<div className="date">
{date} · JST (UTC+09:00)
</div>
<div className="footer">
Built by{" "}
<a href="https://x.com/nik4nao" target="_blank" rel="noopener noreferrer">
@nik4nao
</a>{" "}
contact for inquiries or requirements.
</div>
</div>
);
}

View File

@ -0,0 +1,312 @@
import { useEffect, useMemo, useRef, useState } from "react";
// ===== Config & fallbacks =====
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
const API_URL = "/api/current";
const FALLBACK_START_HOUR = 19;
const FALLBACK_START_MINUTE = 25;
const FALLBACK_END_SECONDS = 300;
// ==============================
type ApiSchedule = {
ep_num: number;
ep_title: string;
season_name: string;
start_time: string; // "HH:MM" or "HH:MM:SS"
playback_length: string; // "MM:SS" or "HH:MM:SS"
};
function formatHMS(total: number) {
const t = Math.max(0, Math.floor(total));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const HH = String(h).padStart(2, "0");
const MM = String(m).padStart(2, "0");
const SS = String(s).padStart(2, "0");
return h > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}`;
}
function parseStartTime(s: string): { hour: number; minute: number; second: number } | null {
const m = /^(\d{1,2}):([0-5]\d)(?::([0-5]\d))?$/.exec(s.trim());
if (!m) return null;
const hour = Number(m[1]);
const minute = Number(m[2]);
const second = m[3] ? Number(m[3]) : 0;
if (hour < 0 || hour > 23) return null;
return { hour, minute, second };
}
function parseDurationToSeconds(s: string): number {
const parts = s.trim().split(":").map(Number);
if (parts.some((n) => Number.isNaN(n) || n < 0)) return FALLBACK_END_SECONDS;
if (parts.length === 2) {
const [mm, ss] = parts;
return mm * 60 + ss;
} else if (parts.length === 3) {
const [hh, mm, ss] = parts;
return hh * 3600 + mm * 60 + ss;
}
return FALLBACK_END_SECONDS;
}
function getJstYMD(now = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
let y = 0, m = 0, d = 0;
for (const p of parts) {
if (p.type === "year") y = Number(p.value);
if (p.type === "month") m = Number(p.value);
if (p.type === "day") d = Number(p.value);
}
return { y, m, d };
}
/** Convert JST wall-time to UTC ms. JST = UTC+9 */
function jstToUtcMs(y: number, m: number, d: number, hh: number, mm: number, ss = 0) {
return Date.UTC(y, m - 1, d, hh - 9, mm, ss, 0);
}
/** Compact "current time in JST" readout used under the digits */
function NowJst() {
const [time, setTime] = useState(() =>
new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date())
);
useEffect(() => {
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
let t: number | null = null;
const tick = () => {
setTime(fmt.format(new Date()));
const msToNext = 1000 - (Date.now() % 1000);
t = window.setTimeout(tick, msToNext + 5);
};
tick();
return () => { if (t) clearTimeout(t); };
}, []);
return (
<div className="timer-now">
<span className="muted"></span>
<span className="now-digits">{time}</span>
</div>
);
}
async function loadSchedule(signal?: AbortSignal) {
const res = await fetch(API_URL, { cache: "no-store", signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as ApiSchedule;
}
export default function Timer() {
const [loaded, setLoaded] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// meta
const [meta, setMeta] = useState<{ ep_num: number; ep_title: string; season_name: string } | null>(null);
// timing
const [startUtcMs, setStartUtcMs] = useState<number>(() => {
const { y, m, d } = getJstYMD();
return jstToUtcMs(y, m, d, FALLBACK_START_HOUR, FALLBACK_START_MINUTE, 0);
});
const [endSeconds, setEndSeconds] = useState<number>(FALLBACK_END_SECONDS);
const [elapsed, setElapsed] = useState(0);
const [phase, setPhase] = useState<"waiting" | "running" | "ended">("waiting");
const [untilStart, setUntilStart] = useState(0);
const timerRef = useRef<number | null>(null);
// fetch schedule
useEffect(() => {
const ac = new AbortController();
loadSchedule(ac.signal)
.then((data) => {
const parsedStart = parseStartTime(data.start_time);
const end = parseDurationToSeconds(data.playback_length);
const { y, m, d } = getJstYMD();
const startMs = parsedStart
? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second)
: startUtcMs; // keep fallback
setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name });
setStartUtcMs(startMs);
setEndSeconds(end);
setLoaded(true);
setErrorMsg(null);
})
.catch(() => {
setLoaded(true);
setErrorMsg("Failed to load schedule; using defaults.");
});
return () => ac.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // mount only
// tick per second
useEffect(() => {
const tick = () => {
const nowMs = Date.now();
if (nowMs < startUtcMs) {
setPhase("waiting");
setUntilStart(Math.ceil((startUtcMs - nowMs) / 1000));
setElapsed(0);
} else {
const secs = Math.floor((nowMs - startUtcMs) / 1000);
const clamped = Math.min(secs, endSeconds);
setElapsed(clamped);
setPhase(clamped >= endSeconds ? "ended" : "running");
}
const msToNext = 1000 - (nowMs % 1000);
timerRef.current = window.setTimeout(tick, msToNext + 5);
};
tick();
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [startUtcMs, endSeconds]);
// status labels
const startLabel = useMemo(() => {
const fmt = new Intl.DateTimeFormat("en-GB", {
timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit", hour12: false,
});
return fmt.format(new Date(startUtcMs));
}, [startUtcMs]);
const progress = endSeconds > 0 ? Math.min(100, Math.round((elapsed / endSeconds) * 100)) : 0;
// nicety: update tab title while running
useEffect(() => {
const base = meta ? `Ep ${meta.ep_num}` : "Timer";
if (phase === "running") {
document.title = `LIVE ${formatHMS(elapsed)} / ${formatHMS(endSeconds)}${base}`;
} else if (phase === "waiting") {
document.title = `Starts ${startLabel}${base}`;
} else {
document.title = `Ended — ${base}`;
}
}, [phase, elapsed, endSeconds, startLabel, meta]);
useEffect(() => {
if (phase !== "ended") return;
let stopped = false;
let ac: AbortController | null = null;
const refetch = async () => {
ac?.abort(); // cancel any in-flight
ac = new AbortController();
try {
const data = await loadSchedule(ac.signal);
// Recompute start/end from fresh data
const parsedStart = parseStartTime(data.start_time);
const end = parseDurationToSeconds(data.playback_length);
const { y, m, d } = getJstYMD();
const nextStartMs = parsedStart
? jstToUtcMs(y, m, d, parsedStart.hour, parsedStart.minute, parsedStart.second)
: startUtcMs;
setMeta({ ep_num: data.ep_num, ep_title: data.ep_title, season_name: data.season_name });
setStartUtcMs(nextStartMs);
setEndSeconds(end);
setErrorMsg(null);
setLoaded(true);
// If the new schedule has moved us out of "ended", the ticking effect will handle it.
} catch (e) {
// keep current UI; try again next tick
}
};
// immediate fetch once the timer ends
refetch();
// then poll every 60s
const id = window.setInterval(() => {
if (!stopped) refetch();
}, 60_000);
return () => {
stopped = true;
if (id) window.clearInterval(id);
ac?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase]); // runs when phase flips to "ended"
const showPrestartCountdown = phase === "waiting" && untilStart > 0 && untilStart <= 10;
const hasHours = endSeconds >= 3600 || elapsed >= 3600;
return (
<div className="timer-block">
{/* Meta */}
{meta && (
<div className="timer-meta">
<div className="timer-season">{meta.season_name}</div>
<div className="timer-episode"> {meta.ep_num} {meta.ep_title}</div>
</div>
)}
{/* Status & now row */}
<div className="timer-toprow">
<span className={`pill ${phase}`}>
{phase === "running" && "LIVE"}
{phase === "waiting" && `Starts ${startLabel} JST`}
{phase === "ended" && "Ended"}
</span>
<NowJst />
</div>
{/* Hero digits */}
<div className={`timer-hero ${hasHours ? "has-hours" : ""}`} role="timer" aria-live="off">
{loaded ? (
<>
<span className="hero-number elapsed">{formatHMS(elapsed)}</span>
<span className="hero-sep">{"\u00A0/\u00A0"}</span>
<span className="hero-number total">{formatHMS(endSeconds)}</span>
</>
) : (
<>
<span className="hero-number">--:--</span>
<span className="hero-sep">{"\u00A0/\u00A0"}</span>
<span className="hero-number">--:--</span>
</>
)}
</div>
{/* Progress */}
<div className="progress" aria-label="Playback progress">
<div className={`progress-fill ${phase}`} style={{ width: `${progress}%` }} />
</div>
{/* Sub status line */}
{loaded && phase === "waiting" && (
<div className="timer-status"> {formatHMS(untilStart)}</div>
)}
{loaded && phase === "ended" && (
<div className="timer-status"> {startLabel} JST.</div>
)}
{errorMsg && <div className="timer-status">{errorMsg}</div>}
{showPrestartCountdown && (
<div className="countdown-overlay" role="status" aria-live="assertive">
<div className="countdown-wrap">
<div className="countdown-label"></div>
<div className="countdown-number">{String(untilStart).padStart(2, "0")}</div>
</div>
</div>
)}
</div>
);
}

260
frontend/src/index.css Normal file
View File

@ -0,0 +1,260 @@
:root {
--bg: #0b0f14;
--card: #0f1520;
--text: #e6eef8;
--subtle: #9fb3c8;
--accent: #79c0ff;
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
background: radial-gradient(1200px 800px at 20% -10%, #12202f 0%, #0b0f14 40%),
radial-gradient(900px 600px at 80% 110%, #1a2740 0%, #0b0f14 40%);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
.app {
min-height: 100%;
display: grid;
place-items: center;
padding: 24px;
}
.card {
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 28px 32px;
box-shadow: 0 10px 30px rgba(0,0,0,0.25);
max-width: 720px;
width: 100%;
text-align: center;
container-type: inline-size;
position: relative;
isolation: isolate;
}
.h1 {
margin: 0 0 8px 0;
font-size: clamp(20px, 2.4vw, 28px);
font-weight: 600;
color: var(--text);
}
.subtle {
margin: 0 0 24px 0;
color: var(--subtle);
font-size: 14px;
}
.clock {
font-variant-numeric: tabular-nums;
letter-spacing: 1px; /* slightly lighter spacing for smaller size */
font-weight: 700;
font-size: clamp(20px, 5vw, 36px); /* smaller now */
line-height: 1.1;
}
.date {
margin-top: 12px;
color: var(--accent);
font-weight: 600;
font-size: clamp(14px, 2.5vw, 18px);
}
.footer {
margin-top: 20px;
color: var(--subtle);
font-size: 12px;
}
kbd {
padding: 2px 6px;
border-radius: 6px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.timer {
margin-top: 8px;
font-variant-numeric: tabular-nums;
letter-spacing: 2px;
font-weight: 700;
font-size: clamp(40px, 10vw, 90px);
line-height: 1.05;
white-space: nowrap;
}
.timer-block {
display: grid;
gap: 16px;
justify-items: center;
text-align: center;
width: 100%;
}
.timer-meta {
display: grid;
gap: 4px;
}
.timer-status {
margin-top: 23px;
color: var(--subtle);
font-size: 12px;
}
.timer-sep {
white-space: nowrap;
}
.timer-season {
color: var(--subtle);
font-weight: 600;
font-size: clamp(12px, 2vw, 14px);
}
.timer-episode {
color: var(--text);
font-weight: 700;
font-size: clamp(14px, 3vw, 18px);
}
.timer-toprow {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.pill {
padding: 4px 10px;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
border: 1px solid rgba(255,255,255,0.15);
}
.pill.running { background: rgba(0,255,128,0.12); }
.pill.waiting { background: rgba(255,255,0,0.10); }
.pill.ended { background: rgba(255,0,0,0.10); }
.timer-now {
display: inline-flex;
gap: 8px;
align-items: baseline;
}
.timer-now .muted { color: var(--subtle); font-size: 12px; }
.timer-now .now-digits {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: clamp(14px, 2.5vw, 18px);
}
.timer-hero {
width: 100%;
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.15em;
white-space: nowrap;
overflow: hidden;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
font-weight: 800;
font-size: clamp(24px, 13cqw, 88px);
line-height: 1.05;
}
.timer-hero .hero-sep { white-space: nowrap; }
.hero-sep { white-space: nowrap; font-size: 0.8em; }
.hero-number.elapsed { font-size: 1em; }
.hero-number.total { font-size: 0.66em; opacity: 0.9; }
.timer-hero.has-hours {
letter-spacing: 0.25px;
font-size: clamp(22px, 10.5cqw, 80px);
}
.timer-hero.has-hours .hero-number.total { font-size: 0.6em; }
.timer-hero.has-hours .hero-sep { font-size: 0.72em; }
/* Progress bar */
.progress {
position: relative;
width: min(100%, 640px);
height: 8px;
margin-inline: auto;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width 200ms linear;
}
.progress-fill.waiting { width: 0%; }
.progress-fill.ended { width: 100%; }
.timer-status {
color: var(--subtle);
font-size: 12px;
margin-top: -4px;
}
.countdown-overlay {
position: absolute;
inset: 0;
z-index: 999;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(2px);
pointer-events: none;
}
.countdown-wrap {
text-align: center;
color: var(--text);
text-shadow: 0 6px 24px rgba(0,0,0,0.45);
animation: pop 180ms ease-out;
}
.countdown-number {
font-variant-numeric: tabular-nums;
font-weight: 900;
letter-spacing: 2px;
line-height: 0.95;
text-shadow: 0 8px 28px rgba(0,0,0,0.55);
font-size: clamp(48px, 26cqw, 220px);
}
.countdown-label {
margin-bottom: 8px;
font-size: clamp(12px, 2.2cqw, 22px);
font-weight: 700;
color: var(--subtle);
}
.card:has(.countdown-overlay) .timer-meta,
.card:has(.countdown-overlay) .timer-toprow,
.card:has(.countdown-overlay) .timer-hero,
.card:has(.countdown-overlay) .progress,
.card:has(.countdown-overlay) .timer-status {
filter: blur(1px) brightness(0.85);
}
.footer a { text-decoration: underline; }
@keyframes pop {
from { transform: scale(0.98); opacity: 0.0; }
to { transform: scale(1); opacity: 1.0; }
}
@media (prefers-reduced-motion: reduce) {
.progress-fill { transition: none; }
.countdown-wrap {animation: none;}
}
@container (max-width: 420px) {
.timer-hero { font-size: clamp(22px, 11cqw, 72px); letter-spacing: 0.25px; }
.hero-number.total, .hero-sep { font-size: 0.62em; }
}
@container (max-width: 360px) {
.timer-hero { font-size: clamp(20px, 10cqw, 64px); letter-spacing: 0; }
}
@supports not (container-type: inline-size) {
.timer-hero { font-size: clamp(28px, 8vw, 96px); }
}

9
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": [],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

18
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const base = process.env.PUBLIC_BASE_PATH || "/";
export default defineConfig({
base,
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://localhost:8082",
changeOrigin: true,
rewrite: p => p.replace(/^\/api/, ""),
},
},
},
});