Works
This commit is contained in:
+34
-33
@@ -1,35 +1,36 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": false
|
"useIgnoreFile": false
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/src/**/*",
|
"**/src/**/*",
|
||||||
"**/.vscode/**/*",
|
"**/.vscode/**/*",
|
||||||
"**/index.html",
|
"**/index.html",
|
||||||
"**/vite.config.ts",
|
"**/vite.config.ts",
|
||||||
"!**/src/routeTree.gen.ts",
|
"!**/src/routeTree.gen.ts",
|
||||||
"!**/src/styles.css"
|
"!**/src/styles.css"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab"
|
"indentStyle": "space",
|
||||||
},
|
"indentWidth": 2
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
},
|
||||||
"linter": {
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"enabled": true,
|
"linter": {
|
||||||
"rules": {
|
"enabled": true,
|
||||||
"recommended": true
|
"rules": {
|
||||||
}
|
"recommended": true
|
||||||
},
|
}
|
||||||
"javascript": {
|
},
|
||||||
"formatter": {
|
"javascript": {
|
||||||
"quoteStyle": "double"
|
"formatter": {
|
||||||
}
|
"quoteStyle": "double"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<title>By Other Means Control Timer</title>
|
<title>By Other Means Control Timer</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-900 text-slate-100">
|
<body class="bg-slate-900 text-slate-100">
|
||||||
<div id="app"></div>
|
<div id="app" class="container mx-auto"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+49
-86
@@ -33,6 +33,21 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/code-frame": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
|
"js-tokens": "^4.0.0",
|
||||||
|
"picocolors": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
@@ -49,6 +64,7 @@
|
|||||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.7",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.29.7",
|
"@babel/generator": "^7.29.7",
|
||||||
@@ -74,21 +90,6 @@
|
|||||||
"url": "https://opencollective.com/babel"
|
"url": "https://opencollective.com/babel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core/node_modules/@babel/code-frame": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
|
||||||
"js-tokens": "^4.0.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
@@ -250,21 +251,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template/node_modules/@babel/code-frame": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
|
||||||
"js-tokens": "^4.0.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
@@ -284,21 +270,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse/node_modules/@babel/code-frame": {
|
|
||||||
"version": "7.29.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
|
||||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-validator-identifier": "^7.29.7",
|
|
||||||
"js-tokens": "^4.0.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
@@ -476,6 +447,39 @@
|
|||||||
"node": ">=14.21.3"
|
"node": ">=14.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "2.0.0-alpha.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
|
||||||
|
"integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "2.0.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "2.0.0-alpha.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
|
||||||
|
"integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1494,47 +1498,6 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emnapi/core": "^1.7.1",
|
|
||||||
"@emnapi/runtime": "^1.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
|
||||||
"version": "0.10.2",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||||
|
import {
|
||||||
|
clearSelectedPhase,
|
||||||
|
setSelectedPhase,
|
||||||
|
} from "../store/slices/phaseSlice";
|
||||||
|
import type { PhaseType } from "../utils/types";
|
||||||
|
|
||||||
|
interface PropType {
|
||||||
|
phase: PhaseType;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Phase: FC<PropType> = ({ phase, current }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const selected = useAppSelector((state) => state.phase.selected);
|
||||||
|
const thisSelected = selected === phase.name;
|
||||||
|
|
||||||
|
const phaseClick = () => {
|
||||||
|
if (thisSelected) {
|
||||||
|
dispatch(clearSelectedPhase());
|
||||||
|
} else {
|
||||||
|
dispatch(setSelectedPhase(phase.name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`overflow-hidden relative w-96 p-2 ${phase.color} border border-2 shadow-md rounded-md ${phase.borderColor}`}
|
||||||
|
onClick={phaseClick}
|
||||||
|
>
|
||||||
|
<div className="text-xl px-8">{phase.name}</div>
|
||||||
|
<div className="text-sm px-8">
|
||||||
|
{phase.minuteStart}-{phase.minuteEnd}
|
||||||
|
</div>
|
||||||
|
{thisSelected && <div className="text-md px-8">{phase.description}</div>}
|
||||||
|
{current && (
|
||||||
|
<>
|
||||||
|
<div className="rotate-45 bg-white h-16 w-16 absolute -top-10 -left-12"></div>
|
||||||
|
<div className="-rotate-45 bg-white h-16 w-16 absolute -bottom-10 -left-12"></div>
|
||||||
|
<div className="bg-white w-16 absolute bottom-0 top-0 -left-14"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Phase;
|
||||||
+8
-8
@@ -11,14 +11,14 @@ const router = getRouter();
|
|||||||
|
|
||||||
const rootElement = document.getElementById("app");
|
const rootElement = document.getElementById("app");
|
||||||
if (!rootElement)
|
if (!rootElement)
|
||||||
throw new Error("Could not find #app element to mount the app");
|
throw new Error("Could not find #app element to mount the app");
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate loading={null} persistor={persistor}>
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
+67
-55
@@ -1,70 +1,82 @@
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import {
|
import { type FC, type MouseEventHandler, useEffect, useState } from "react";
|
||||||
type EventHandler,
|
import Phase from "../components/Phase";
|
||||||
type FC,
|
|
||||||
type MouseEventHandler,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
import { useAppDispatch, useAppSelector } from "../store/hooks";
|
||||||
import { clearStartTime, setStartTime } from "../store/slices/timeSlice";
|
import { clearStartTime, setStartTime } from "../store/slices/timeSlice";
|
||||||
import { ROUND_LENGTH_SECONDS } from "../utils/constants";
|
import { PHASES, ROUND_LENGTH_SECONDS } from "../utils/constants";
|
||||||
import { formatElapsed } from "../utils/functions";
|
import {
|
||||||
|
formatElapsed,
|
||||||
|
getCurrentPhaseIndex,
|
||||||
|
getElapsedSeconds,
|
||||||
|
} from "../utils/functions";
|
||||||
|
|
||||||
const Welcome: FC = () => {
|
const Welcome: FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const startTime = useAppSelector((state) => state.time.startTime);
|
const startTime = useAppSelector((state) => state.time.startTime);
|
||||||
const [now, setNow] = useState(() => DateTime.now());
|
const [now, setNow] = useState(() => DateTime.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!startTime) return;
|
if (!startTime) return;
|
||||||
const interval = setInterval(() => setNow(DateTime.now()), 1000);
|
const interval = setInterval(() => setNow(DateTime.now()), 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [startTime]);
|
}, [startTime]);
|
||||||
|
|
||||||
const elapsedSeconds = startTime
|
const elapsedSeconds = getElapsedSeconds(startTime, now);
|
||||||
? now.diff(DateTime.fromISO(startTime), "seconds").seconds
|
const isRunning = startTime !== null;
|
||||||
: 0;
|
const isOvertime = elapsedSeconds > ROUND_LENGTH_SECONDS;
|
||||||
const isRunning = startTime !== null;
|
|
||||||
const isOvertime = elapsedSeconds > 60;
|
|
||||||
|
|
||||||
const nextRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
|
const currentPhaseIndex = getCurrentPhaseIndex(
|
||||||
dispatch(clearStartTime());
|
PHASES,
|
||||||
};
|
elapsedSeconds,
|
||||||
|
isRunning,
|
||||||
|
);
|
||||||
|
|
||||||
const startRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
|
const nextRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
|
||||||
dispatch(setStartTime(DateTime.now().toISO() ?? ""));
|
dispatch(clearStartTime());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const startRoundClick: MouseEventHandler<HTMLButtonElement> = (_e) => {
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-8 p-8">
|
dispatch(setStartTime(DateTime.now().toISO() ?? ""));
|
||||||
<h1 className="text-4xl font-bold">By Other Means Control Timer</h1>
|
};
|
||||||
|
|
||||||
<div
|
return (
|
||||||
className={`font-mono text-8xl tabular-nums ${isOvertime ? "text-red-500 animate-bounce" : "text-slate-100"}`}
|
<div className="flex min-h-screen flex-col items-center gap-8 p-8">
|
||||||
>
|
<h1 className="text-4xl font-bold">By Other Means Control Timer</h1>
|
||||||
{formatElapsed(elapsedSeconds)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isRunning ? (
|
<div
|
||||||
<button
|
className={`font-mono text-8xl tabular-nums ${isOvertime ? "text-red-500 animate-bounce" : "text-slate-100"}`}
|
||||||
type="button"
|
>
|
||||||
onClick={nextRoundClick}
|
{formatElapsed(elapsedSeconds)}
|
||||||
className="rounded-lg bg-slate-700 px-6 py-3 text-lg font-semibold hover:bg-slate-600"
|
</div>
|
||||||
>
|
|
||||||
Prepare Next Round
|
{isRunning ? (
|
||||||
</button>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<button
|
onClick={nextRoundClick}
|
||||||
type="button"
|
className="rounded-lg bg-slate-700 px-6 py-3 text-lg font-semibold hover:bg-slate-600"
|
||||||
onClick={startRoundClick}
|
>
|
||||||
className="rounded-lg bg-emerald-600 px-6 py-3 text-lg font-semibold hover:bg-emerald-500"
|
Prepare Next Round
|
||||||
>
|
</button>
|
||||||
Start Round
|
) : (
|
||||||
</button>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</div>
|
onClick={startRoundClick}
|
||||||
);
|
className="rounded-lg bg-emerald-600 px-6 py-3 text-lg font-semibold hover:bg-emerald-500"
|
||||||
|
>
|
||||||
|
Start Round
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{PHASES.map((phase, index) => {
|
||||||
|
return (
|
||||||
|
<Phase
|
||||||
|
key={phase.name}
|
||||||
|
phase={phase}
|
||||||
|
current={index === currentPhaseIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Welcome;
|
export default Welcome;
|
||||||
|
|||||||
+10
-10
@@ -2,18 +2,18 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
|||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof getRouter>;
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-19
@@ -3,26 +3,26 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
|||||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
{import.meta.env.VITE_TANSTACK_DEVTOOLS === "true" && (
|
{import.meta.env.VITE_TANSTACK_DEVTOOLS === "true" && (
|
||||||
<TanStackDevtools
|
<TanStackDevtools
|
||||||
config={{
|
config={{
|
||||||
position: "bottom-right",
|
position: "bottom-right",
|
||||||
}}
|
}}
|
||||||
plugins={[
|
plugins={[
|
||||||
{
|
{
|
||||||
name: "Tanstack Router",
|
name: "Tanstack Router",
|
||||||
render: <TanStackRouterDevtoolsPanel />,
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { createFileRoute } from "@tanstack/react-router";
|
|||||||
import Welcome from "../pages/Welcome";
|
import Welcome from "../pages/Welcome";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <Welcome />;
|
return <Welcome />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
|
interface StateType {
|
||||||
|
selected: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: StateType = {
|
||||||
|
selected: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseSlice = createSlice({
|
||||||
|
name: "phase",
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSelectedPhase: (state, action: PayloadAction<string>) => {
|
||||||
|
state.selected = action.payload;
|
||||||
|
},
|
||||||
|
clearSelectedPhase: (state) => {
|
||||||
|
state.selected = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setSelectedPhase, clearSelectedPhase } = phaseSlice.actions;
|
||||||
|
export default phaseSlice.reducer;
|
||||||
@@ -1,24 +1,24 @@
|
|||||||
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
interface StateType {
|
interface StateType {
|
||||||
startTime: string | null;
|
startTime: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: StateType = {
|
const initialState: StateType = {
|
||||||
startTime: null,
|
startTime: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeSlice = createSlice({
|
const timeSlice = createSlice({
|
||||||
name: "time",
|
name: "time",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setStartTime: (state, action: PayloadAction<string>) => {
|
setStartTime: (state, action: PayloadAction<string>) => {
|
||||||
state.startTime = action.payload;
|
state.startTime = action.payload;
|
||||||
},
|
},
|
||||||
clearStartTime: (state) => {
|
clearStartTime: (state) => {
|
||||||
state.startTime = null;
|
state.startTime = null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setStartTime, clearStartTime } = timeSlice.actions;
|
export const { setStartTime, clearStartTime } = timeSlice.actions;
|
||||||
|
|||||||
+14
-55
@@ -1,75 +1,34 @@
|
|||||||
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
import { combineReducers, configureStore } from "@reduxjs/toolkit";
|
||||||
import { persistReducer, persistStore } from "redux-persist";
|
import { persistReducer, persistStore } from "redux-persist";
|
||||||
|
import storage from "redux-persist/es/storage";
|
||||||
|
import phaseReducer from "./slices/phaseSlice";
|
||||||
import timeReducer from "./slices/timeSlice";
|
import timeReducer from "./slices/timeSlice";
|
||||||
|
|
||||||
const noopStorage = {
|
|
||||||
getItem() {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
},
|
|
||||||
setItem() {
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
removeItem() {
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// redux-persist's bundled storage engine can throw or come back malformed in
|
|
||||||
// some browsers (private mode, locked-down storage), so this is a minimal
|
|
||||||
// storage engine that never touches `redux-persist/lib/storage`.
|
|
||||||
function createLocalStorageEngine() {
|
|
||||||
if (typeof window === "undefined") return noopStorage;
|
|
||||||
try {
|
|
||||||
const testKey = "__by_other_means_storage_test__";
|
|
||||||
window.localStorage.setItem(testKey, testKey);
|
|
||||||
window.localStorage.removeItem(testKey);
|
|
||||||
} catch {
|
|
||||||
return noopStorage;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
getItem(key: string) {
|
|
||||||
return Promise.resolve(window.localStorage.getItem(key));
|
|
||||||
},
|
|
||||||
setItem(key: string, value: string) {
|
|
||||||
window.localStorage.setItem(key, value);
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
removeItem(key: string) {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure which slices to persist
|
|
||||||
const persistConfig = {
|
const persistConfig = {
|
||||||
key: "root",
|
key: "root",
|
||||||
storage: createLocalStorageEngine(),
|
storage: storage,
|
||||||
whitelist: ["time"],
|
whitelist: ["time", "phase"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine all reducers
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
time: timeReducer,
|
time: timeReducer,
|
||||||
|
phase: phaseReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create persisted reducer
|
|
||||||
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
const persistedReducer = persistReducer(persistConfig, rootReducer);
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: persistedReducer,
|
reducer: persistedReducer,
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: {
|
serializableCheck: {
|
||||||
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
|
ignoredActions: ["persist/PERSIST", "persist/REHYDRATE"],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const persistor = persistStore(store);
|
export const persistor = persistStore(store);
|
||||||
|
|
||||||
// Infer the `RootState`, `AppDispatch`, and `AppStore` types from the store itself
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
|
||||||
export type AppDispatch = typeof store.dispatch;
|
export type AppDispatch = typeof store.dispatch;
|
||||||
export type AppStore = typeof store;
|
export type AppStore = typeof store;
|
||||||
|
|||||||
@@ -1,2 +1,42 @@
|
|||||||
|
import type { PhaseType } from "./types";
|
||||||
|
|
||||||
const ROUND_LENGTH_MINUTES = 40;
|
const ROUND_LENGTH_MINUTES = 40;
|
||||||
export const ROUND_LENGTH_SECONDS = ROUND_LENGTH_MINUTES * 60;
|
export const ROUND_LENGTH_SECONDS = ROUND_LENGTH_MINUTES * 60;
|
||||||
|
|
||||||
|
export const PHASES: PhaseType[] = [
|
||||||
|
{
|
||||||
|
minuteStart: 0,
|
||||||
|
minuteEnd: 15,
|
||||||
|
name: "Negotiation Phase",
|
||||||
|
description:
|
||||||
|
"Attend formal negotiations or Imperial Senate. Draft, formalise and ratify treaties. Apart from the first turn, orders from the previous turn will also be resolved during this phase.",
|
||||||
|
color: "bg-red-800",
|
||||||
|
borderColor: "border-red-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minuteStart: 15,
|
||||||
|
minuteEnd: 20,
|
||||||
|
name: "Plenary Phase",
|
||||||
|
description:
|
||||||
|
"Chairs announce any ratified treaties. Facilitators announce any major map events. Teams collect income.",
|
||||||
|
color: "bg-yellow-600",
|
||||||
|
borderColor: "border-yellow-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minuteStart: 20,
|
||||||
|
minuteEnd: 30,
|
||||||
|
name: "Discussion Phase",
|
||||||
|
description: "Freely circulate, discuss any topics as desired.",
|
||||||
|
color: "bg-green-800",
|
||||||
|
borderColor: "border-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minuteStart: 30,
|
||||||
|
minuteEnd: 40,
|
||||||
|
name: "Orders Phase",
|
||||||
|
description:
|
||||||
|
"Issue orders, plan for the following turn. Remain at team tables during the Orders phase.",
|
||||||
|
color: "bg-sky-800",
|
||||||
|
borderColor: "border-sky-600",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
+28
-4
@@ -1,6 +1,30 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { PhaseType } from "./types";
|
||||||
|
|
||||||
export function formatElapsed(seconds: number): string {
|
export function formatElapsed(seconds: number): string {
|
||||||
const clamped = Math.max(0, Math.floor(seconds));
|
const clamped = Math.max(0, Math.floor(seconds));
|
||||||
const minutes = Math.floor(clamped / 60);
|
const minutes = Math.floor(clamped / 60);
|
||||||
const secs = clamped % 60;
|
const secs = clamped % 60;
|
||||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getElapsedSeconds(
|
||||||
|
startTime: string | null,
|
||||||
|
now: DateTime,
|
||||||
|
): number {
|
||||||
|
if (!startTime) return 0;
|
||||||
|
return now.diff(DateTime.fromISO(startTime), "seconds").seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPhaseIndex(
|
||||||
|
phases: PhaseType[],
|
||||||
|
elapsedSeconds: number,
|
||||||
|
isRunning: boolean,
|
||||||
|
): number {
|
||||||
|
if (!isRunning) return -1;
|
||||||
|
const elapsedMinutes = elapsedSeconds / 60;
|
||||||
|
for (let i = 0; i < phases.length; i++) {
|
||||||
|
if (elapsedMinutes < phases[i].minuteEnd) return i;
|
||||||
|
}
|
||||||
|
return phases.length - 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface PhaseType {
|
||||||
|
minuteStart: number;
|
||||||
|
minuteEnd: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
borderColor: string;
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_TANSTACK_DEVTOOLS: string;
|
readonly VITE_TANSTACK_DEVTOOLS: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-7
@@ -5,13 +5,16 @@ import viteReact from "@vitejs/plugin-react";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
resolve: { tsconfigPaths: true },
|
||||||
plugins: [
|
plugins: [
|
||||||
devtools(),
|
devtools(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
tanstackRouter({ target: "react", autoCodeSplitting: true }),
|
tanstackRouter({ target: "react", autoCodeSplitting: true }),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
],
|
],
|
||||||
|
server: {
|
||||||
|
port: 8000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
Reference in New Issue
Block a user