Initial Commit
This commit is contained in:
commit
3ebe2cbf47
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.env
|
||||
.DS_Store
|
||||
*/node_modules
|
||||
197
README.md
Normal file
197
README.md
Normal 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 shouldn’t be any—browser hits `web`, which proxies to `api`, so it’s same-origin.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
101
docker-compose.yml
Normal file
101
docker-compose.yml
Normal 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
23
frontend/Dockerfile
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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>
|
||||
7
frontend/ops/entrypoint.sh
Normal file
7
frontend/ops/entrypoint.sh
Normal 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
12
frontend/ops/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
32
frontend/ops/nginx.conf.template
Normal file
32
frontend/ops/nginx.conf.template
Normal 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
3215
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
42
frontend/src/App.css
Normal 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
21
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
98
frontend/src/components/DigitalClock.tsx
Normal file
98
frontend/src/components/DigitalClock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
frontend/src/components/Timer.tsx
Normal file
312
frontend/src/components/Timer.tsx
Normal 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
260
frontend/src/index.css
Normal 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
9
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
18
frontend/vite.config.ts
Normal 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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user