Browse Source

react and stuff

main
Stian Fredrik Aune 2 years ago
parent
commit
fbba57769a
  1. 26
      webui-react/.gitignore
  2. 14
      webui-react/index.html
  3. 30
      webui-react/package.json
  4. BIN
      webui-react/public/2046.png
  5. 1
      webui-react/public/vite.svg
  6. 14
      webui-react/src/App.sass
  7. 34
      webui-react/src/App.tsx
  8. 47
      webui-react/src/actions/devices.ts
  9. 20
      webui-react/src/actions/programs.ts
  10. 26
      webui-react/src/actions/runtime.ts
  11. 63
      webui-react/src/actions/shared.ts
  12. 50
      webui-react/src/actions/workouts.ts
  13. 35
      webui-react/src/contexts/DeviceContext.tsx
  14. 35
      webui-react/src/contexts/ProgramContext.tsx
  15. 213
      webui-react/src/contexts/RuntimeContext.tsx
  16. 94
      webui-react/src/contexts/WorkoutContext.tsx
  17. 50
      webui-react/src/helpers/dates.ts
  18. 3
      webui-react/src/helpers/misc.ts
  19. 36
      webui-react/src/hooks/keyboard.ts
  20. 9
      webui-react/src/main.tsx
  21. 5
      webui-react/src/models/Devices.ts
  22. 50
      webui-react/src/models/Programs.ts
  23. 53
      webui-react/src/models/Shared.ts
  24. 66
      webui-react/src/models/Workouts.ts
  25. 363
      webui-react/src/pages/DevicePage.tsx
  26. 118
      webui-react/src/pages/IndexPage.tsx
  27. 40
      webui-react/src/pages/LoadingPage.tsx
  28. 209
      webui-react/src/pages/PlayPage.tsx
  29. 105
      webui-react/src/pages/WorkoutPage.tsx
  30. 106
      webui-react/src/pages/runtime/ControlsBoi.tsx
  31. 13
      webui-react/src/pages/runtime/MessageBoi.tsx
  32. 56
      webui-react/src/pages/runtime/ProgramBoi.sass
  33. 159
      webui-react/src/pages/runtime/ProgramBoi.tsx
  34. 17
      webui-react/src/pages/runtime/hooks.tsx
  35. 37
      webui-react/src/primitives/Pallette.sass
  36. 34
      webui-react/src/primitives/Shared.sass
  37. 17
      webui-react/src/primitives/Shared.tsx
  38. 134
      webui-react/src/primitives/blob/Blob.sass
  39. 130
      webui-react/src/primitives/blob/Blob.tsx
  40. 43
      webui-react/src/primitives/boi/Boi.sass
  41. 30
      webui-react/src/primitives/boi/Boi.tsx
  42. 37
      webui-react/src/primitives/header/Header.sass
  43. 55
      webui-react/src/primitives/header/Header.tsx
  44. 10
      webui-react/src/primitives/misc/Misc.sass
  45. 8
      webui-react/src/primitives/misc/Misc.tsx
  46. 45
      webui-react/src/primitives/page/Page.sass
  47. 76
      webui-react/src/primitives/page/Page.tsx
  48. 8
      webui-react/src/vite-env.d.ts
  49. 21
      webui-react/tsconfig.json
  50. 9
      webui-react/tsconfig.node.json
  51. 24
      webui-react/vite.config.ts
  52. 977
      webui-react/yarn.lock
  53. 1
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Workout.kt
  54. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Command.kt
  55. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Event.kt
  56. 52
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt
  57. 28
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
  58. 4
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt
  59. 2
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt
  60. 11
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
  61. 10
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt
  62. 37
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Workouts.kt
  63. 2
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt
  64. 2
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketInput.kt
  65. 2
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepository.kt
  66. 45
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepository.kt
  67. 6
      ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt
  68. 13
      ykonsole-mysql/src/main/resources/migrations/tables/workout.xml
  69. 6
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

26
webui-react/.gitignore

@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-webapp
dist-chrome
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
webui-react/index.html

@ -0,0 +1,14 @@
<!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" />
<meta name="theme-color" content="#203764" />
<title>YKonsole</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
webui-react/package.json

@ -0,0 +1,30 @@
{
"name": "webui-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_MODE=webapp vite --host",
"build-webapp": "tsc && VITE_MODE=webapp vite build",
"build-chrome-plugin": "tsc && VITE_MODE=chrome-plugin vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"axios": "^0.27.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router": "^6.3.0",
"react-router-dom": "^6.3.0",
"sass": "^1.53.0"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"typescript": "^4.6.4",
"vite": "^3.0.7"
}
}

BIN
webui-react/public/2046.png

After

Width: 1920  |  Height: 1080  |  Size: 1.7 MiB

1
webui-react/public/vite.svg

@ -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>

14
webui-react/src/App.sass

@ -0,0 +1,14 @@
@import "primitives/Shared"
html, body, #root
margin: 0
width: 100%
height: 100%
background-color: $body-background
color: $body-foreground
font-family: $font-global
font-weight: 300
.App
width: 100%
height: 100%

34
webui-react/src/App.tsx

@ -0,0 +1,34 @@
import {useEffect, useState} from 'react'
import IndexPage from "./pages/IndexPage";
import "./App.sass";
import {ProgramContextProvider} from "./contexts/ProgramContext";
import {BrowserRouter, Route, Routes} from "react-router-dom";
import {DeviceContextProvider} from "./contexts/DeviceContext";
import DevicePage from "./pages/DevicePage";
import {RuntimeContextProvider} from "./contexts/RuntimeContext";
import {WorkoutContextProvider} from "./contexts/WorkoutContext";
import WorkoutPage from "./pages/WorkoutPage";
import PlayPage from "./pages/PlayPage";
function App() {
return (
<BrowserRouter>
<DeviceContextProvider>
<ProgramContextProvider>
<RuntimeContextProvider>
<WorkoutContextProvider>
<Routes>
<Route path="/" element={<IndexPage/>}/>
<Route path="/devices/:id" element={<DevicePage/>}/>
<Route path="/workouts/:id" element={<WorkoutPage/>}/>
<Route path="/play" element={<PlayPage/>}/>
</Routes>
</WorkoutContextProvider>
</RuntimeContextProvider>
</ProgramContextProvider>
</DeviceContextProvider>
</BrowserRouter>
)
}
export default App

47
webui-react/src/actions/devices.ts

@ -0,0 +1,47 @@
import {Device} from "../models/Devices";
import {deleteRequest, getRequest, postRequest, putRequest} from "./shared";
interface DeviceRepository {
fetchAll(): Promise<Device[]>
save(device: Partial<Device>): Promise<boolean>
delete(id: string): Promise<boolean>
}
export default function deviceRepo(): DeviceRepository {
switch (import.meta.env.VITE_MODE) {
case "webapp":
return defaultImpl;
case "chrome-plugin":
default:
throw new Error("Not implemented");
}
}
const defaultImpl: DeviceRepository = {
fetchAll() {
return getRequest("/devices") || null;
},
async save({id, name, connectionString}: Partial<Device>): Promise<boolean> {
try {
if (id) {
await putRequest(`/devices/${id}`, {name, connectionString});
return true;
} else if (name && connectionString) {
await postRequest("/devices", {name, connectionString});
return true;
}
return false;
} catch (e) {
return false;
}
},
async delete(id: string): Promise<boolean> {
try {
await deleteRequest(`/devices/${id}`);
return true;
} catch (e) {
return false;
}
},
};

20
webui-react/src/actions/programs.ts

@ -0,0 +1,20 @@
import {Program} from "../models/Programs";
import {getRequest} from "./shared";
interface ProgramRepository {
fetchAll(): Promise<Program[]>
}
export default function programRepo(): ProgramRepository {
switch (import.meta.env.VITE_MODE) {
case "webapp":
return defaultImpl;
case "chrome-plugin":
default:
throw new Error("Not implemented");
}
}
const defaultImpl: ProgramRepository = {
fetchAll: () => getRequest("/programs") || null,
};

26
webui-react/src/actions/runtime.ts

@ -0,0 +1,26 @@
interface RuntimeRepository {
openWebsocket(): WebSocket
}
export default function runtimeRepo(): RuntimeRepository {
switch (import.meta.env.VITE_MODE) {
case "webapp":
if (window.location.hostname === "localhost" || window.location.hostname === "10.24.10.24") {
return makeRuntimeRepo(`${window.location.hostname}:8080`);
} else {
return makeRuntimeRepo(window.location.hostname);
}
case "chrome-plugin":
return makeRuntimeRepo("127.0.0.1:9999");
default:
throw new Error("Not implemented");
}
}
function makeRuntimeRepo(host: string): RuntimeRepository {
return {
openWebsocket(): WebSocket {
return new WebSocket(`ws://${host}/api/workouts/active`);
},
};
}

63
webui-react/src/actions/shared.ts

@ -0,0 +1,63 @@
import Axios, {AxiosError, AxiosRequestConfig, AxiosResponse} from "axios";
interface Response<T> {
code: number
message: string
data: T
}
export async function getRequest<T>(url: string): Promise<T> {
return sendRequest<T>({
method: "GET",
url: `/api${url}`,
responseType: "json",
});
}
export function postRequest<T>(url: string, data: object = {}): Promise<T> {
return sendRequest<T>({
method: "POST",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function putRequest<T>(url: string, data: object): Promise<T> {
return sendRequest<T>({
method: "PUT",
url: `/api${url}`,
responseType: "json",
data,
});
}
export function deleteRequest<T>(url: string): Promise<T> {
return sendRequest<T>({
method: "DELETE",
url: `/api${url}`,
responseType: "json",
});
}
async function sendRequest<T>(config: AxiosRequestConfig): Promise<T> {
try {
const res = await Axios(config);
return await handleResponse(res);
} catch (e) {
return await handleResponse((e as AxiosError).response as AxiosResponse);
}
}
function handleResponse<T>(response: AxiosResponse): Promise<T> {
const obj: Response<T> = response.data;
if (![200, 201].includes(response.status)) {
if (response.status === 403) {
return Promise.reject(obj.message);
}
}
return Promise.resolve(obj.data);
}

50
webui-react/src/actions/workouts.ts

@ -0,0 +1,50 @@
import {getRequest, postRequest} from "./shared";
import {PastWorkout, WorkoutState} from "../models/Workouts";
interface WorkoutFilter {
daysBack: number
includeTest: boolean
}
export interface CreateWorkoutOptions {
deviceId: string
programId?: string
test?: boolean
}
interface WorkoutRepository {
findById(workoutId: string): Promise<PastWorkout | null>
fetchByFilter(filter: WorkoutFilter): Promise<PastWorkout[]>
fetchStates(workoutId: string): Promise<WorkoutState[]>
createWorkout(options: CreateWorkoutOptions): Promise<boolean>
}
export default function workoutRepo(): WorkoutRepository {
switch (import.meta.env.VITE_MODE) {
case "webapp":
return defaultImpl;
case "chrome-plugin":
default:
throw new Error("Not implemented");
}
}
const defaultImpl: WorkoutRepository = {
findById(workoutId: string): Promise<PastWorkout | null> {
return getRequest<PastWorkout>(`/workouts/${workoutId}`).catch(() => null);
},
fetchByFilter({daysBack, includeTest}: WorkoutFilter): Promise<PastWorkout[]> {
return getRequest(`/workouts?daysBack=${daysBack}&includeTest=${includeTest}`);
},
fetchStates(workoutId: string): Promise<WorkoutState[]> {
return getRequest(`/workouts/${workoutId}/states`);
},
async createWorkout(options: CreateWorkoutOptions) {
try {
await postRequest("/workouts", options);
return true;
} catch (e) {
return false;
}
},
};

35
webui-react/src/contexts/DeviceContext.tsx

@ -0,0 +1,35 @@
import {createContext, useCallback, useEffect, useState} from "react";
import {unimplemented} from "../helpers/misc";
import {Device} from "../models/Devices";
import {WithChildren} from "../primitives/Shared";
import deviceRepo from "../actions/devices";
interface DeviceContextValue {
devices: Device[] | null
refreshDevices(): void
}
const DeviceContext = createContext<DeviceContextValue>({
devices: null,
refreshDevices: unimplemented,
});
export function DeviceContextProvider({children}: WithChildren) {
const [devices, setDevices] = useState<Device[] | null>(null);
const refreshDevices = useCallback(() => {
deviceRepo().fetchAll().then(setDevices);
}, []);
useEffect(() => {
refreshDevices();
}, []);
return (
<DeviceContext.Provider value={{devices, refreshDevices}}>
{children}
</DeviceContext.Provider>
);
}
export default DeviceContext;

35
webui-react/src/contexts/ProgramContext.tsx

@ -0,0 +1,35 @@
import {createContext, useCallback, useEffect, useState} from "react";
import {WithChildren} from "../primitives/Shared";
import {Program} from "../models/Programs";
import {unimplemented} from "../helpers/misc";
import programRepo from "../actions/programs";
interface ProgramContextValue {
programs: Program[] | null
refreshPrograms(): void
}
const ProgramContext = createContext<ProgramContextValue>({
programs: null,
refreshPrograms: unimplemented,
});
export function ProgramContextProvider({children}: WithChildren) {
const [programs, setPrograms] = useState<Program[] | null>(null);
const refreshPrograms = useCallback(() => {
programRepo().fetchAll().then(setPrograms);
}, []);
useEffect(() => {
refreshPrograms();
}, []);
return (
<ProgramContext.Provider value={{programs, refreshPrograms}}>
{children}
</ProgramContext.Provider>
);
}
export default ProgramContext;

213
webui-react/src/contexts/RuntimeContext.tsx

@ -0,0 +1,213 @@
import {CurrentWorkout, WorkoutState, WorkoutStatus} from "../models/Workouts";
import {createContext, useCallback, useEffect, useState} from "react";
import {unimplemented} from "../helpers/misc";
import {WithChildren} from "../primitives/Shared";
import {Values} from "../models/Shared";
import runtimeRepo from "../actions/runtime";
import workoutRepo, {CreateWorkoutOptions} from "../actions/workouts";
interface RuntimeContextValue {
workout: CurrentWorkout | null
states: WorkoutState[]
error: string | null
lastEvent: SocketOutput | null
active: boolean
ready: boolean
ended: boolean
connect(): void
disconnect(): void
start(): void
stop(): void
setLevel(level: number): void
reset(): void
resume(): void
create(options: CreateWorkoutOptions): void
}
const RuntimeContext = createContext<RuntimeContextValue>({
workout: null,
states: [],
error: null,
lastEvent: null,
active: false,
ready: false,
ended: false,
connect: unimplemented,
disconnect: unimplemented,
start: unimplemented,
stop: unimplemented,
setLevel: unimplemented,
reset: unimplemented,
resume: unimplemented,
create: unimplemented,
});
interface SocketInput {
start?: true
stop?: true
connect?: true
disconnect?: true
setValue?: Values
}
function socketInput(obj: SocketInput): string {
return JSON.stringify(obj);
}
export type RuntimeEvent = SocketOutput
interface SocketOutput {
sentAt: string
workout: CurrentWorkout | null
workoutStates: WorkoutState[] | null
event: { name: string } | null
error: { message: string } | null
}
function socketOutput(str: string): SocketOutput {
return JSON.parse(str);
}
export function RuntimeContextProvider({children}: WithChildren): JSX.Element {
const [workout, setWorkout] = useState<CurrentWorkout | null>(null);
const [states, setStates] = useState<WorkoutState[]>([]);
const [error, setError] = useState<string | null>(null);
const [lastEvent, setLastEvent] = useState<SocketOutput | null>(null);
const [active, setActive] = useState<boolean>(false);
const [ready, setReady] = useState<boolean>(false);
const [ended, setEnded] = useState<boolean>(false);
const [socket, setSocket] = useState<WebSocket | null>(null);
const sendCommand = useCallback((input: SocketInput) => {
if (socket && socket.readyState === socket.OPEN && workout) {
socket.send(socketInput(input));
}
}, [socket, workout]);
const connect = useCallback(() => {
sendCommand({connect: true});
}, [sendCommand]);
const disconnect = useCallback(() => {
sendCommand({disconnect: true});
}, [sendCommand]);
const start = useCallback(() => {
sendCommand({start: true});
}, [sendCommand]);
const stop = useCallback(() => {
sendCommand({stop: true});
}, [sendCommand]);
const setLevel = useCallback((level: number) => {
sendCommand({
setValue: {level},
});
}, [sendCommand]);
const reset = useCallback(() => {
setActive(false);
setEnded(false);
setReady(false);
}, []);
const resume = useCallback(() => {
setWorkout(null);
setStates([]);
setError(null);
setReady(false);
setActive(true);
setEnded(false);
setLastEvent(null);
const socket = runtimeRepo().openWebsocket();
socket.onmessage = event => {
const data = socketOutput(event.data);
setLastEvent(data);
setReady(true);
if (data.workout) {
setWorkout(data.workout);
}
if (data.workoutStates && data.workoutStates.length > 0) {
setStates(prev => {
if (prev.length === 0) {
return data.workoutStates!;
} else {
const lastOld = prev[prev.length - 1];
const firstNew = data.workoutStates![0];
if (lastOld.time <= firstNew.time) {
return [
...prev.filter(p => p.time < firstNew.time),
...data.workoutStates!,
];
} else {
return [...prev, ...data.workoutStates!];
}
}
});
}
if (data.event) {
const eventNameMap: Record<string, WorkoutStatus> = {
"Connected": WorkoutStatus.Connected,
"Started": WorkoutStatus.Started,
"Stopped": WorkoutStatus.Stopped,
"Disconnected": WorkoutStatus.Disconnected,
};
const newStatus = eventNameMap[data.event.name];
if (newStatus) {
setWorkout(prev => prev ? ({...prev, status: newStatus}) : prev);
}
}
if (data.error) {
setError(data.error.message);
setReady(true);
}
};
socket.onclose = () => {
setEnded(true);
};
setSocket(socket);
}, [socket]);
const create = useCallback((options: CreateWorkoutOptions) => {
workoutRepo().createWorkout(options).then(success => {
if (success) {
resume();
} else {
setError("Kunne ikke opprette økten!");
setEnded(true);
}
});
}, [resume]);
useEffect(() => {
if (ready && workout?.status === WorkoutStatus.Created) {
connect();
}
}, [ready, workout]);
return (
<RuntimeContext.Provider value={{
workout, states, error,
lastEvent,
active, ready, ended,
connect, disconnect, start, stop, setLevel,
reset, resume, create,
}}>
{children}
</RuntimeContext.Provider>
);
}
export default RuntimeContext;

94
webui-react/src/contexts/WorkoutContext.tsx

@ -0,0 +1,94 @@
import {PastWorkout, WorkoutState} from "../models/Workouts";
import {createContext, useCallback, useContext, useEffect, useState} from "react";
import {unimplemented} from "../helpers/misc";
import {WithChildren} from "../primitives/Shared";
import workoutRepo from "../actions/workouts";
interface WorkoutContextValue {
workouts: PastWorkout[]
loadingWorkouts: boolean
getWorkout(workoutId: string): PastWorkout | null
fetchWorkout(workoutId: string): void
getStates(workoutId: string): WorkoutState[] | null
fetchStates(workoutId: string): void
refreshWorkouts(): void
showMoreWorkouts(): void
}
const WorkoutContext = createContext<WorkoutContextValue>({
workouts: [],
loadingWorkouts: false,
getWorkout: unimplemented,
fetchWorkout: unimplemented,
getStates: unimplemented,
fetchStates: unimplemented,
refreshWorkouts: unimplemented,
showMoreWorkouts: unimplemented,
});
export function WorkoutContextProvider({children}: WithChildren) {
const [workouts, setWorkouts] = useState<PastWorkout[]>([]);
const [cache, setCache] = useState<Record<string, PastWorkout>>({});
const [loadingWorkouts, setLoadingWorkouts] = useState<boolean>(false);
const [days, setDays] = useState(6);
const [ver, setVer] = useState(0);
const [stateMap, setStateMap] = useState<Record<string, WorkoutState[]>>({});
const getWorkout = useCallback((workoutId: string) => {
return cache[workoutId] || workouts.find(w => w.id === workoutId);
}, [cache, workouts]);
const fetchWorkout = useCallback((workoutId: string) => {
workoutRepo().findById(workoutId)
.then(result => {
if (result) {
setCache(prev => ({...prev, [workoutId]: result}));
}
});
}, []);
const getStates = useCallback((workoutId: string) => {
return stateMap[workoutId] || null;
}, [stateMap]);
const fetchStates = useCallback((workoutId: string) => {
workoutRepo().fetchStates(workoutId)
.then(result => setStateMap(prev => ({...prev, [workoutId]: result})));
}, []);
const refreshWorkouts = useCallback(() => {
setVer(prev => prev + 1);
}, []);
const showMoreWorkouts = useCallback(() => {
setDays(prev => prev + (prev > 30 ? 30 : 7));
}, []);
useEffect(() => {
if (ver === 0) {
return;
}
setLoadingWorkouts(true);
workoutRepo().fetchByFilter({daysBack: days, includeTest: false})
.then(setWorkouts)
.finally(() => setLoadingWorkouts(false));
}, [days, ver]);
return (
<WorkoutContext.Provider value={{
workouts, loadingWorkouts,
getWorkout, fetchWorkout,
getStates, fetchStates,
refreshWorkouts, showMoreWorkouts,
}}>
{children}
</WorkoutContext.Provider>
);
}
export default WorkoutContext;

50
webui-react/src/helpers/dates.ts

@ -0,0 +1,50 @@
const oneDay = 1000 * 3600 * 24;
const sevenDays = 7 * oneDay;
export function relativeDateTime(raw: string): string {
const date = new Date(raw);
const now = new Date();
const isThisYear = date.getFullYear() === now.getFullYear();
const isThisMonth = isThisYear && (date.getMonth() === now.getMonth());
const isToday = isThisMonth && (date.getDate() === now.getDate());
if (isToday) {
return formatTime(date);
}
return formatDate(raw.substring(0, 10));
}
export function formatTime(date: string | Date): string {
if (typeof date === "string") {
date = new Date(date);
}
const hour = `${date.getHours()}`.padStart(2, "0");
const minute = `${date.getMinutes()}`.padStart(2, "0");
return `${hour}:${minute}`;
}
export function formatDate(raw: string): string {
const date = new Date(raw);
const now = new Date();
const isLastWeek = date.getTime() > (now.getTime() - sevenDays);
if (isLastWeek) {
return weekDayNames[date.getDay()];
}
const isThisYear = date.getFullYear() === (new Date()).getFullYear();
let dateName = `${date.getDate()}. ${monthNames[date.getMonth()]}`;
if (!isThisYear) {
dateName += ` ${date.getFullYear()}`
}
return dateName;
}
const weekDayNames = ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag", "Søndag"];
const monthNames = ["jan", "feb", "mar", "apr", "mai", "jun", "jul", "aug", "sep", "okt", "nov", "des"];

3
webui-react/src/helpers/misc.ts

@ -0,0 +1,3 @@
export function unimplemented<T>(): T {
throw new Error("unimplemented");
}

36
webui-react/src/hooks/keyboard.ts

@ -0,0 +1,36 @@
import {Dispatch, SetStateAction, useEffect, useMemo, useState} from "react";
export function useKey(keys: string | string[], func: () => void, deps: any[] = []) {
useEffect(() => {
const f = (ev: KeyboardEvent) => {
const fixedKey = Array.isArray(keys) ? keys : [keys];
if (fixedKey.includes(ev.key)) {
func();
}
};
window.addEventListener("keydown", f);
return () => {
window.removeEventListener("keydown", f);
};
}, [keys, func, ...deps]);
}
export function usePlusMinus(length: number): [number, Dispatch<SetStateAction<number>>] {
const [current, setCurrent] = useState(0);
useKey("-", () => setCurrent(prev => prev <= 0 ? length - 1 : prev - 1), [length]);
useKey("+", () => setCurrent(prev => prev >= length - 1 ? 0 : prev + 1), [length]);
useEffect(() => {
if (current < 0) {
setCurrent(0);
} else if (current > length - 1) {
setCurrent(length - 1);
}
}, [length]);
return [current, setCurrent];
}

9
webui-react/src/main.tsx

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

5
webui-react/src/models/Devices.ts

@ -0,0 +1,5 @@
export interface Device {
id: string
name: string
connectionString: string
}

50
webui-react/src/models/Programs.ts

@ -0,0 +1,50 @@
import {Values} from "./Shared";
export interface Program {
id: string
name: string
steps: ProgramStep[]
}
export interface ProgramStep {
index: number
values: Values,
duration?: Values,
}
export function weighting(step: ProgramStep) {
if (step.duration) {
if (step.duration.time) {
return 8 * step.duration.time;
} else if (step.duration.calories) {
return 4 * step.duration.calories;
} else if (step.duration.distance) {
return step.duration.distance;
}
}
return 0;
}
export function subTitleOfProgram(program: Program): string {
let secSum = 0;
let kcalSum = 0;
let mSum = 0;
let hasCustom = false;
for (const step of program.steps) {
if (step.duration?.time) secSum += step.duration.time;
if (step.duration?.calories) kcalSum += step.duration.calories;
if (step.duration?.distance) mSum += step.duration.distance;
hasCustom = hasCustom || (!(step.duration?.time) && !(step.duration?.calories) && !(step.duration?.distance));
}
const parts = [];
if (secSum > 0) parts.push(`${secSum} kcal`);
if (kcalSum > 0) parts.push(`${kcalSum} kcal`);
if (mSum > 0) parts.push(`${mSum} kcal`);
if (hasCustom) parts.push("Custom");
return parts.join(" + ");
}

53
webui-react/src/models/Shared.ts

@ -0,0 +1,53 @@
export enum Size {
Mobile = "mobile",
Tablet = "tablet",
Desktop = "desktop",
Any = "any",
}
export interface ValuesWithTime extends Values {
time: number
}
export interface Values {
time?: number
calories?: number
distance?: number
level?: number
}
export type ColorName = "gray" | "green" | "blue" | "red" | "yellow" | "indigo";
export function diffLinearValues<T extends Values>(a: Values, b: Values): T {
const c: Values = {};
if (a.time) c.time = a.time - (b.time || 0);
if (a.calories) c.calories = a.calories - (b.calories || 0);
if (a.distance) c.distance = a.distance - (b.distance || 0);
if (a.level) c.level = a.level;
return c as T;
}
export function formatValue(raw: number, type: keyof Values): string {
if (type === "time") {
const minutes = Math.floor(raw / 60).toString(10).padStart(2, "0");
const seconds = (raw % 60).toString(10).padStart(2, "0");
return `${minutes}'${seconds}"`;
}
if (type === "calories") {
return `${raw} kcal`;
}
if (type === "distance") {
if (raw >= 100) {
return `${(raw / 1000).toFixed(1)} km`
} else {
return `${raw} m`;
}
}
if (type === "level") {
return `Lvl. ${raw}`;
}
return "";
}

66
webui-react/src/models/Workouts.ts

@ -0,0 +1,66 @@
import {Program} from "./Programs";
import {Device} from "./Devices";
import {ColorName, formatValue, Values, ValuesWithTime} from "./Shared";
interface Workout<D> {
id: string
createdAt: string
device: Device | null
program: Program | null
status: WorkoutStatus
message: string
test: string
}
export type CurrentWorkout = Workout<Device>;
export type PastWorkout = Workout<Device | null>;
export type WorkoutState = ValuesWithTime;
export enum WorkoutStatus {
Created = "Created",
Connected = "Connected",
Started = "Started",
Stopped = "Stopped",
Disconnected = "Disconnected",
}
export function colorOf(workout: CurrentWorkout | PastWorkout): ColorName {
if (workout.message) {
return "red";
}
else return colorByStatus[workout.status];
}
const colorByStatus: Record<WorkoutStatus, ColorName> = {
[WorkoutStatus.Created]: "indigo",
[WorkoutStatus.Connected]: "blue",
[WorkoutStatus.Started]: "green",
[WorkoutStatus.Stopped]: "yellow",
[WorkoutStatus.Disconnected]: "gray",
}
export function firstKey(state: WorkoutState): (keyof WorkoutState) | null {
if (state.time !== undefined) return "time";
if (state.calories !== undefined) return "calories";
if (state.distance !== undefined) return "distance";
if (state.level !== undefined) return "level";
return null;
}
export function stateString(state: Values, type: keyof WorkoutState): string | null {
if (type === "time" && state.time) {
return formatValue(state.time, type);
}
if (type === "calories" && state.calories !== undefined) {
return formatValue(state.calories, type);
}
if (type === "distance" && state.distance !== undefined) {
return formatValue(state.distance, type);
}
if (type === "level" && state.level !== undefined) {
return formatValue(state.level, type);
}
return null;
}

363
webui-react/src/pages/DevicePage.tsx

@ -0,0 +1,363 @@
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import {TitleLine} from "../primitives/misc/Misc";
import {Size} from "../models/Shared";
import Blob, {BlobInput, BlobText} from "../primitives/blob/Blob";
import {Icon} from "../primitives/Shared";
import {
faChain,
faCheck, faChevronDown,
faChevronLeft,
faChevronUp, faCircleInfo, faInfoCircle, faMessage,
faPencilAlt,
faTag,
faTrashCan
} from "@fortawesome/free-solid-svg-icons";
import {useCallback, useContext, useEffect, useMemo, useState} from "react";
import LoadingPage from "./LoadingPage";
import {useNavigate, useParams} from "react-router";
import DeviceContext from "../contexts/DeviceContext";
import {useSearchParams} from "react-router-dom";
import {Device} from "../models/Devices";
import deviceRepo from "../actions/devices";
import RuntimeContext from "../contexts/RuntimeContext";
export default function DevicePage(): JSX.Element {
const {devices} = useContext(DeviceContext);
const navigate = useNavigate();
const {id} = useParams();
const [search] = useSearchParams();
const device = useMemo(() => devices?.find(d => d.id === id) || null, [devices]);
const edit = useMemo(() => search.has("edit") && search.get("edit") === "true", [search, device]);
if (edit && id === "new") {
return <NewDevicePage/>;
} else if (edit && device) {
return <EditDevicePage device={device}/>;
} else if (device !== null) {
return <ViewDevicePage device={device}/>;
} else {
if (devices !== null) {
navigate("/");
}
return <LoadingPage text="Henter enhet"/>;
}
}
function NewDevicePage(): JSX.Element {
const navigate = useNavigate();
const {refreshDevices} = useContext(DeviceContext);
const [name, setName] = useState<string>("");
const [connectionString, setConnectionString] = useState<string>("");
const [wait, setWait] = useState<boolean>();
const onSave = useCallback(async () => {
setWait(true);
if (await deviceRepo().save({name, connectionString})) {
refreshDevices();
navigate("/");
} else {
setWait(false);
}
}, [name, connectionString]);
if (wait) {
return <LoadingPage text="Legger til enhet"/>;
}
return (
<Page>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Ny enhet</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Mobile}>
<PageFlexColumn flex={1}>
<TitleLine>Enhet</TitleLine>
<Blob fillOn={Size.Any}>
<BlobText>Navn</BlobText>
<BlobInput flex={1} type="text" value={name} onChange={setName}/>
</Blob>
<Blob fillOn={Size.Any}>
<BlobText>Adresse</BlobText>
<BlobInput flex={1} type="text" value={connectionString} onChange={setConnectionString}/>
</Blob>
<Blob color="green" onClick={onSave}>
<BlobText>
<Icon value={faCheck}/> Lagre
</BlobText>
</Blob>
</PageFlexColumn>
<PageFlexColumn flex={1}/>
</PageFlexRow>
</PageBody>
</Page>
);
}
interface EditDevicePageProps {
device: Device
}
function EditDevicePage({device}: EditDevicePageProps): JSX.Element {
const navigate = useNavigate();
const {refreshDevices} = useContext(DeviceContext);
const [name, setName] = useState<string>(device.name);
const [connectionString, setConnectionString] = useState<string>(device.connectionString);
const [wait, setWait] = useState<boolean>();
const onSave = useCallback(async () => {
setWait(true);
if (await deviceRepo().save({id: device.id, name, connectionString})) {
refreshDevices();
navigate(`/devices/${device.id}`);
} else {
setWait(false);
}
}, [device, name, connectionString]);
if (wait) {
return <LoadingPage text="Oppdaterer enhet"/>;
}
return (
<Page>
<Header>
<HeaderButton onClick={() => navigate(`/devices/${device.id}`)}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Endre «{device.name}»</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Mobile}>
<PageFlexColumn flex={1}>
<TitleLine>Enhet</TitleLine>
<Blob fillOn={Size.Any}>
<BlobText>Navn</BlobText>
<BlobInput flex={1} type="text" value={name} onChange={setName}/>
</Blob>
<Blob fillOn={Size.Any}>
<BlobText>Adresse</BlobText>
<BlobInput flex={1} type="text" value={connectionString} onChange={setConnectionString}/>
</Blob>
<Blob color="green" onClick={onSave}>
<BlobText>
<Icon value={faCheck}/> Lagre
</BlobText>
</Blob>
</PageFlexColumn>
<PageFlexColumn flex={1}/>
</PageFlexRow>
</PageBody>
</Page>
);
}
interface DevicePageProps {
device: Device
}
function ViewDevicePage({device}: DevicePageProps): JSX.Element {
const navigate = useNavigate();
const {refreshDevices} = useContext(DeviceContext);
const {active, ended, error, create} = useContext(RuntimeContext);
const onDelete = useCallback(() => {
if (!window.confirm("Vil du fjerne denne enheten?")) return;
deviceRepo().delete(device.id).then(() => {
refreshDevices();
navigate("/");
})
}, [device, navigate, refreshDevices]);
return (
<Page>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>{device.name}</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Mobile}>
<PageFlexColumn flex={1}>
<TitleLine>Enhet</TitleLine>
<Blob>
<BlobText>
<Icon value={faTag}/> {device.name}
</BlobText>
</Blob>
<Blob>
<BlobText>
<Icon value={faChain}/> {device.connectionString}
</BlobText>
</Blob>
<Blob color="indigo" onClick={() => navigate(`/devices/${device.id}?edit=true`)}>
<BlobText>
<Icon value={faPencilAlt}/>
</BlobText>
</Blob>
<Blob color="red" onClick={onDelete}>
<BlobText>
<Icon value={faTrashCan}/>
</BlobText>
</Blob>
</PageFlexColumn>
<PageFlexColumn flex={1}>
<TitleLine>Test</TitleLine>
{active && !ended ? <TestSection/> : (
<Blob
color={error ? "red" : (ended ? "green" : "yellow")}
onClick={() => create({deviceId: device.id, test: true})}
>
{ended && error && <BlobText>Prøv igjen</BlobText>}
{ended && !error && <BlobText>Vellykket</BlobText>}
{!ended && <BlobText>Kjør</BlobText>}
</Blob>
)}
</PageFlexColumn>
</PageFlexRow>
</PageBody>
</Page>
);
}
interface Event {
createdAt: string,
type: "up" | "down" | "log",
message: string
}
function currentTime(): string {
return (new Date().toTimeString().substring(0, 8));
}
function TestSection(): JSX.Element {
const {lastEvent, connect, disconnect, start, stop} = useContext(RuntimeContext);
const [events, setEvents] = useState<Event[]>([]);
useEffect(() => {
const timeouts: number[] = [];
if (lastEvent) {
const createdAt = (new Date().toTimeString().substring(0, 8));
if (lastEvent.workout) {
setEvents(prev => [
...prev,
{createdAt, type: "down", message: `Opprettet økt ${lastEvent.workout!.id}`},
{createdAt, type: "log", message: "Vil koble til om 5 sekunder"},
]);
timeouts.push(setTimeout(() => {
setEvents(prev => [
...prev,
{createdAt: currentTime(), type: "up", message: `Kobler til`},
]);
connect();
}, 5000));
}
if (lastEvent.workoutStates && lastEvent.workoutStates.length > 0) {
const last = lastEvent.workoutStates[lastEvent.workoutStates.length - 1];
setEvents(prev => [...prev, {
createdAt, type: "down", message: `Ny tilstand: ${last.time} s, ${last.calories} kcal`,
}])
}
if (lastEvent.event) {
setEvents(prev => [
...prev,
{createdAt, type: "down", message: `Ny hendelse: ${lastEvent.event!.name}`},
]);
if (lastEvent.event.name === "Connected") {
setEvents(prev => [
...prev,
{createdAt, type: "log", message: "Vil starte om 5 sekunder..."},
]);
timeouts.push(setTimeout(() => {
setEvents(prev => [
...prev,
{createdAt: currentTime(), type: "up", message: "Starter"},
]);
start();
}, 5000));
}
if (lastEvent.event.name === "Started") {
setEvents(prev => [
...prev,
{createdAt, type: "log", message: "Vil stoppe om 30 sekunder..."},
]);
timeouts.push(setTimeout(() => {
setEvents(prev => [
...prev,
{createdAt: currentTime(), type: "up", message: "Stopper"},
]);
stop();
}, 30000));
}
if (lastEvent.event.name === "Stopped") {
setEvents(prev => [
...prev,
{createdAt, type: "log", message: "Vil koble fra om 5 sekunder..."},
]);
timeouts.push(setTimeout(() => {
setEvents(prev => [
...prev,
{createdAt: currentTime(), type: "up", message: "Kobler fra"},
]);
disconnect();
}, 5000));
}
if (lastEvent.event.name === "Disconnected") {
setEvents(prev => [
...prev,
{createdAt, type: "log", message: "Frakoblet"},
]);
}
}
} else {
setEvents([]);
}
}, [lastEvent]);
return (
<>
{events.map((e, i) => (
<PageFlexRow key={i}>
<Blob>
<BlobText>{e.createdAt}</BlobText>
</Blob>
<Blob>
<BlobText>
{e.type === "up" && <Icon value={faChevronUp}/>}
{e.type === "down" && <Icon value={faChevronDown}/>}
{e.type === "log" && <Icon value={faMessage}/>}
</BlobText>
</Blob>
<Blob flex={1}>
<BlobText>{e.message}</BlobText>
</Blob>
</PageFlexRow>
))}
<div style={{height: "100px"}}/>
</>
);
}

118
webui-react/src/pages/IndexPage.tsx

@ -0,0 +1,118 @@
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import Header, {HeaderTitle} from "../primitives/header/Header";
import {TitleLine} from "../primitives/misc/Misc";
import {Size} from "../models/Shared";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {Icon} from "../primitives/Shared";
import {faChevronDown, faPlay, faPlus} from "@fortawesome/free-solid-svg-icons";
import {useContext, useEffect, useMemo} from "react";
import ProgramContext from "../contexts/ProgramContext";
import LoadingPage from "./LoadingPage";
import {subTitleOfProgram} from "../models/Programs";
import {useNavigate} from "react-router";
import DeviceContext from "../contexts/DeviceContext";
import WorkoutContext from "../contexts/WorkoutContext";
import {formatDate, formatTime} from "../helpers/dates";
import {colorOf, WorkoutStatus} from "../models/Workouts";
import {faSpinner} from "@fortawesome/free-solid-svg-icons/faSpinner";
import {useKey} from "../hooks/keyboard";
import {Boi} from "../primitives/boi/Boi";
export default function IndexPage(): JSX.Element {
const {devices} = useContext(DeviceContext);
const {programs} = useContext(ProgramContext);
const {workouts, loadingWorkouts, showMoreWorkouts, refreshWorkouts} = useContext(WorkoutContext);
const navigate = useNavigate();
const isRunning = useMemo(() => workouts.some(w => w.status !== WorkoutStatus.Disconnected), [workouts]);
useEffect(() => {
refreshWorkouts();
}, [refreshWorkouts]);
useKey(["/", "*"], () => navigate("/play"), [navigate]);
if (programs === null) {
return <LoadingPage text="Henter programmer"/>
} else if (devices === null) {
return <LoadingPage text="Henter enheter"/>
}
return (
<Page>
<Header>
<HeaderTitle>YKonsole</HeaderTitle>
</Header>
<Boi vertical="bottom" horizontal="left">
<Blob onClick={() => navigate("/play")} color={isRunning ? "yellow" : "green"}>
<BlobText>
<Icon value={faPlay}/> {isRunning ? "Fortsett" : "Start"}
</BlobText>
</Blob>
</Boi>
<PageBody>
<PageFlexRow collapseOn={Size.Tablet}>
<PageFlexColumn flex={1}>
<TitleLine>Siste økter ({workouts.length})</TitleLine>
{workouts.map(w => (
<Blob key={w.id} color={colorOf(w)} onClick={() => navigate(`/workouts/${w.id}`)}>
<BlobText>
<BlobTextLine>{formatDate(w.createdAt)} {formatTime(w.createdAt)}</BlobTextLine>
<BlobTextLine secondary>
{w.program ? w.program.name : (w.device?.name || "Ukjent enhet")}
</BlobTextLine>
</BlobText>
</Blob>
))}
<PageFlexRow>
<Blob onClick={loadingWorkouts ? undefined : () => showMoreWorkouts()}>
<BlobText>
{loadingWorkouts && <Icon value={faSpinner} spin/> }
{!loadingWorkouts && <><Icon value={faChevronDown}/> Vis flere</> }
</BlobText>
</Blob>
</PageFlexRow>
</PageFlexColumn>
<PageFlexColumn flex={1}>
<TitleLine>Programmer ({programs.length})</TitleLine>
{programs.map(p => (
<Blob key={p.id} onClick={() => navigate(`/programs/${p.id}`)}>
<BlobText>
<BlobTextLine>{p.name}</BlobTextLine>
<BlobTextLine secondary>{subTitleOfProgram(p)}</BlobTextLine>
</BlobText>
</Blob>
))}
<Blob color="green" onClick={() => navigate(`/programs/new?edit=true`)}>
<BlobText>
<BlobTextLine>
<Icon value={faPlus}/>
</BlobTextLine>
<BlobTextLine secondary>Legg til</BlobTextLine>
</BlobText>
</Blob>
<TitleLine>Enheter ({devices.length})</TitleLine>
{devices.map(d => (
<Blob key={d.id} onClick={() => navigate(`/devices/${d.id}`)}>
<BlobText>
<BlobTextLine>{d.name}</BlobTextLine>
<BlobTextLine secondary>{d.connectionString}</BlobTextLine>
</BlobText>
</Blob>
))}
<Blob color="green" onClick={() => navigate(`/devices/new?edit=true`)}>
<BlobText>
<BlobTextLine>
<Icon value={faPlus}/>
</BlobTextLine>
<BlobTextLine secondary>Legg til</BlobTextLine>
</BlobText>
</Blob>
</PageFlexColumn>
</PageFlexRow>
</PageBody>
</Page>
);
}

40
webui-react/src/pages/LoadingPage.tsx

@ -0,0 +1,40 @@
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import Header, {HeaderTitle} from "../primitives/header/Header";
import {Icon} from "../primitives/Shared";
import {faSpinner} from "@fortawesome/free-solid-svg-icons/faSpinner";
import {Boi} from "../primitives/boi/Boi";
interface LoadingPageProps {
text?: string
}
function LoadingPage({text}: LoadingPageProps) {
return (
<Page>
<Header>
<HeaderTitle>YKonsole</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow vertical>
<PageFlexRow flex={1}/>
<LoadingSection text={text}/>
<PageFlexRow flex={1}/>
</PageFlexRow>
</PageBody>
</Page>
);
}
interface LoadingSectionProps {
text?: string
}
export function LoadingSection({text}: LoadingSectionProps) {
return (
<Boi vertical="center" horizontal="center">
<Icon value={faSpinner} spin/> {text ? `${text}` : ""}
</Boi>
);
}
export default LoadingPage;

209
webui-react/src/pages/PlayPage.tsx

@ -0,0 +1,209 @@
import Page, {PageFlexRow} from "../primitives/page/Page";
import {useCallback, useContext, useEffect, useMemo, useState} from "react";
import RuntimeContext from "../contexts/RuntimeContext";
import {useNavigate} from "react-router";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import LoadingPage from "./LoadingPage";
import DeviceContext from "../contexts/DeviceContext";
import ProgramContext from "../contexts/ProgramContext";
import {Device} from "../models/Devices";
import {Program, subTitleOfProgram} from "../models/Programs";
import {useKey, usePlusMinus} from "../hooks/keyboard";
import {TitleLine} from "../primitives/misc/Misc";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {Icon} from "../primitives/Shared";
import {faChevronLeft, faClose, faPlay} from "@fortawesome/free-solid-svg-icons";
import {stateString, WorkoutStatus} from "../models/Workouts";
import {Boi} from "../primitives/boi/Boi";
import {useLastState} from "./runtime/hooks";
import {ControlsBoi} from "./runtime/ControlsBoi";
import MessageBoi from "./runtime/MessageBoi";
import ProgramBoi from "./runtime/ProgramBoi";
function PlayPage(): JSX.Element {
const {active, ready, ended, workout, reset, resume} = useContext(RuntimeContext);
const navigate = useNavigate();
useEffect(() => {
if (!active) {
resume();
} else if (active && ended) {
if (workout) {
navigate(`/workouts/${workout.id}`, {replace: true});
reset();
}
}
}, [active, ready, ended, workout, resume]);
if (active && ready && workout === null) {
return <CreatePlayPage/>;
}
if (active && workout !== null) {
return <RunPlayPage/>;
}
return <LoadingPage text="Starter økt"/>;
}
const noProgram: Program = {
id: "",
name: "Uten program",
steps: [{index: 0, values: {}, duration: undefined}],
}
function CreatePlayPage(): JSX.Element {
const {devices} = useContext(DeviceContext);
const {programs} = useContext(ProgramContext);
const {create} = useContext(RuntimeContext);
const programWithFake = useMemo(() => programs ? [noProgram, ...programs] : null, [programs]);
const navigate = useNavigate();
const [device, setDevice] = useState<Device | null>(null);
const [program, setProgram] = useState<Program | null>(null);
const [sel, setSel] = usePlusMinus((device ? (program ? 2 : programWithFake?.length) : devices?.length) || 1);
const confirmSelection = useCallback((idx: number) => {
if (program && device) {
if (idx === 0) {
create({deviceId: device.id, programId: program.id || undefined, test: false})
} else {
navigate("/");
}
} else if (device && programWithFake) {
setProgram(programWithFake[idx] || null);
setSel(0);
} else if (devices !== null) {
setDevice(devices[idx] || null);
setSel(0);
}
}, [create, device, program, devices, programWithFake]);
useKey("Enter", () => {
confirmSelection(sel);
}, [confirmSelection, sel]);
useKey("Escape", () => {
navigate("/");
}, []);
useEffect(() => {
if (devices && devices.length === 0) {
navigate("/");
}
}, [devices]);
if (devices === null || programWithFake === null) {
return <LoadingPage text="Laster inn"/>
}
return (
<Page background={"2046"}>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Ny økt</HeaderTitle>
</Header>
{device === null && (
<Boi vertical="center" horizontal="center" style={{fontSize: undefined}}>
<TitleLine>Velg enhet</TitleLine>
{devices.map((d, i) => (
<Blob key={d.id} onClick={() => confirmSelection(i)} color={sel === i ? "indigo" : "gray"}>
<BlobText>
<BlobTextLine>{d.name}</BlobTextLine>
<BlobTextLine secondary>{d.connectionString}</BlobTextLine>
</BlobText>
</Blob>
))}
</Boi>
)}
{device !== null && program === null && (
<Boi vertical="center" horizontal="center" style={{fontSize: undefined}}>
<TitleLine>Velg program</TitleLine>
{programWithFake.map((p, i) => (
<Blob key={p.id} onClick={() => confirmSelection(i)} color={sel === i ? "indigo" : "gray"}>
<BlobText>
<BlobTextLine>{p.name}</BlobTextLine>
<BlobTextLine secondary>{subTitleOfProgram(p)}</BlobTextLine>
</BlobText>
</Blob>
))}
</Boi>
)}
{device && program && (
<Boi vertical="center" horizontal="center" style={{fontSize: undefined}}>
<TitleLine>Oppsumering</TitleLine>
{device && (
<Blob>
<BlobText>
<BlobTextLine>{device.name}</BlobTextLine>
<BlobTextLine secondary>{device.connectionString}</BlobTextLine>
</BlobText>
</Blob>
)}
{program && (
<Blob>
<BlobText>
<BlobTextLine>{program.name}</BlobTextLine>
<BlobTextLine secondary>{subTitleOfProgram(program)}</BlobTextLine>
</BlobText>
</Blob>
)}
<PageFlexRow>
{device && program && (
<>
<Blob onClick={() => confirmSelection(0)} color={sel === 0 ? "indigo" : "gray"}>
<BlobText>
<Icon value={faPlay}/> Start
</BlobText>
</Blob>
<Blob onClick={() => confirmSelection(1)} color={sel === 1 ? "indigo" : "gray"}>
<BlobText>
<Icon value={faClose}/> Avbryt
</BlobText>
</Blob>
</>
)}
</PageFlexRow>
</Boi>
)}
</Page>
);
}
function RunPlayPage(): JSX.Element {
const {workout} = useContext(RuntimeContext);
const lastState = useLastState();
if (!workout || workout.status === WorkoutStatus.Created) {
return <LoadingPage/>;
}
return (
<Page title="YKonsole" background={"2046"}>
<ControlsBoi/>
{lastState && (
<Boi vertical="center" horizontal="left">
{stateString(lastState, "time")}
<br/>
{stateString(lastState, "calories")}
<br/>
{stateString(lastState, "distance")}
<br/>
{stateString(lastState, "level")}
</Boi>
)}
{workout?.status === WorkoutStatus.Connected && <MessageBoi text="Trykk Enter for å begynne"/>}
{workout?.status === WorkoutStatus.Stopped && <MessageBoi text="Pause"/>}
{workout.program && workout.program.steps.length > 0 && <ProgramBoi/>}
</Page>
);
}
export default PlayPage;

105
webui-react/src/pages/WorkoutPage.tsx

@ -0,0 +1,105 @@
import {useContext, useEffect, useMemo} from "react";
import DeviceContext from "../contexts/DeviceContext";
import {useNavigate, useParams} from "react-router";
import {useSearchParams} from "react-router-dom";
import WorkoutContext from "../contexts/WorkoutContext";
import {PastWorkout, stateString} from "../models/Workouts";
import Page, {PageBody, PageFlexColumn, PageFlexRow} from "../primitives/page/Page";
import LoadingPage, {LoadingSection} from "./LoadingPage";
import Header, {HeaderButton, HeaderTitle} from "../primitives/header/Header";
import {Icon} from "../primitives/Shared";
import {faChevronLeft, faClock, faClockFour, faClockRotateLeft, faTableList} from "@fortawesome/free-solid-svg-icons";
import {Size} from "../models/Shared";
import {TitleLine} from "../primitives/misc/Misc";
import Blob, {BlobText, BlobTextLine} from "../primitives/blob/Blob";
import {subTitleOfProgram} from "../models/Programs";
import {formatDate, formatTime} from "../helpers/dates";
export default function WorkoutPage(): JSX.Element {
const {getWorkout, fetchWorkout, getStates, fetchStates} = useContext(WorkoutContext);
const navigate = useNavigate();
const {id} = useParams();
const workout = useMemo(() => getWorkout(id || "random"), [getWorkout, id]);
const states = useMemo(() => getStates(id || "random"), [getStates, id]);
useEffect(() => {
fetchWorkout(id || "random");
fetchStates(id || "random");
}, [id]);
return (
<Page title={`Økt ${id}`}>
<Header>
<HeaderButton onClick={() => navigate("/")}>
<Icon value={faChevronLeft}/>
</HeaderButton>
<HeaderTitle>Øktdetaljer</HeaderTitle>
</Header>
<PageBody>
<PageFlexRow collapseOn={Size.Tablet}>
<PageFlexColumn flex={1}>
<TitleLine>Økt</TitleLine>
{workout ? (
<>
<Blob>
<BlobText>
<BlobTextLine>
<Icon value={faClockFour}/> {formatTime(workout.createdAt)}
</BlobTextLine>
<BlobTextLine secondary>{formatDate(workout.createdAt)}</BlobTextLine>
</BlobText>
</Blob>
{workout.message && (
<Blob color="red">
<BlobText>
<BlobTextLine>Det oppsto en feil!</BlobTextLine>
<BlobTextLine secondary>{workout.message.trim() || "(Ingen melding)"}</BlobTextLine>
</BlobText>
</Blob>
)}
{workout.device && (
<Blob>
<BlobText>
<BlobTextLine>{workout.device.name}</BlobTextLine>
<BlobTextLine secondary>{workout.device.connectionString}</BlobTextLine>
</BlobText>
</Blob>
)}
{workout.program && (
<Blob>
<BlobText>
<BlobTextLine>{workout.program.name}</BlobTextLine>
<BlobTextLine secondary>{subTitleOfProgram(workout.program)}</BlobTextLine>
</BlobText>
</Blob>
)}
</>
) : <LoadingSection/>}
</PageFlexColumn>
<PageFlexColumn flex={1}>
<TitleLine>Målinger</TitleLine>
{states ? (
states.map(s => (
<PageFlexRow>
<Blob>
<BlobText>{stateString(s, "time")}</BlobText>
</Blob>
<Blob>
<BlobText>{stateString(s, "calories")}</BlobText>
</Blob>
<Blob>
<BlobText>{stateString(s, "distance")}</BlobText>
</Blob>
<Blob>
<BlobText>{stateString(s, "level")}</BlobText>
</Blob>
</PageFlexRow>
))
) : <LoadingSection/>}
</PageFlexColumn>
</PageFlexRow>
</PageBody>
</Page>
);
}

106
webui-react/src/pages/runtime/ControlsBoi.tsx

@ -0,0 +1,106 @@
import {useContext, useEffect, useMemo, useState} from "react";
import RuntimeContext from "../../contexts/RuntimeContext";
import {WorkoutStatus} from "../../models/Workouts";
import {faPause, faPlay} from "@fortawesome/free-solid-svg-icons";
import {useKey, usePlusMinus} from "../../hooks/keyboard";
import {Boi} from "../../primitives/boi/Boi";
import {TitleLine} from "../../primitives/misc/Misc";
import Blob, {BlobText} from "../../primitives/blob/Blob";
import {Icon} from "../../primitives/Shared";
import {useLastState} from "./hooks";
import {IconDefinition} from "@fortawesome/fontawesome-svg-core";
import MessageBoi from "./MessageBoi";
interface Option {
icon?: IconDefinition
text?: string
onClick(): void
}
export function ControlsBoi() {
const {workout, disconnect, start, stop, setLevel} = useContext(RuntimeContext);
const lastState = useLastState();
const [mode, setMode] = useState<"default" | "level">("default");
const options: Option[] = useMemo(() => {
if (!workout) return [];
const isStopped = workout.status === WorkoutStatus.Connected || workout.status === WorkoutStatus.Stopped;
const btnList: Option[] = [];
if (isStopped) {
btnList.push({icon: faPlay, onClick: start})
}
if (workout.status === WorkoutStatus.Started) {
btnList.push({icon: faPause, onClick: stop});
if (!workout.program) {
btnList.push({
text: "Motstand", onClick: () => {
setMode("level");
}
});
}
}
if (isStopped) {
btnList.push({text: "Avslutt", onClick: disconnect});
}
return btnList;
}, [workout]);
const [sel, setSel] = usePlusMinus(mode === "level" ? 32 : options.length);
useKey("Enter", () => {
if (mode === "level") {
setLevel(sel + 1);
setSel(0);
setMode("default");
} else {
if (options[sel]) {
options[sel].onClick();
}
}
setSel(0);
}, [options, sel]);
useKey("Escape", () => {
if (!workout) return;
const isStopped = workout.status === WorkoutStatus.Connected || workout.status === WorkoutStatus.Stopped;
if (isStopped) {
disconnect();
}
}, [workout, disconnect]);
useEffect(() => {
if (lastState?.level && mode === "level") {
setSel(lastState.level - 1);
}
}, [mode]);
if (mode === "level") {
return (
<MessageBoi text={`${sel + 1} +/-`}/>
);
}
return (
<Boi vertical="top" horizontal="right" style={{fontSize: "2vmax"}}>
{options.map((o, i) => (
<Blob key={i} color={sel === i ? "indigo" : "gray"} onClick={o.onClick}>
<BlobText>
{o.icon && <Icon value={o.icon}/>} {o.text}
</BlobText>
</Blob>
))}
</Boi>
);
}

13
webui-react/src/pages/runtime/MessageBoi.tsx

@ -0,0 +1,13 @@
import {Boi} from "../../primitives/boi/Boi";
interface MessageBoiProps {
text: string
}
export default function MessageBoi({text}: MessageBoiProps) {
return (
<Boi vertical="center" horizontal="center" style={{fontSize: "5vmax", fontWeight: "400", paddingTop: "0.125em"}}>
{text}
</Boi>
);
}

56
webui-react/src/pages/runtime/ProgramBoi.sass

@ -0,0 +1,56 @@
@import "../../primitives/Shared"
.HealthBar
display: flex
align-items: flex-end
position: fixed
bottom: 0
width: 100%
.HealthBar-entry
background-color: rgba(0, 0, 0, 0.5)
.HealthBar-entry-text
padding: 2px
font-size: 3vmax
&:first-child
padding: 4px
.ProgressBar
padding: 2px
height: 16px
opacity: 0.5
box-sizing: border-box
display: flex
&:first-child
padding-left: 4px
&:last-child
padding-right: 4px
div
height: 100%
.ProgressBar-bg
background-color: black
.ProgressBar-level-5
background-color: $red-3
.ProgressBar-level-4
background-color: $yellow-3
.ProgressBar-level-3
background-color: $green-3
.ProgressBar-level-2
background-color: $cyan-3
.ProgressBar-level-1
background-color: $blue-3
.ProgressBar-level-0
background-color: $indigo-3

159
webui-react/src/pages/runtime/ProgramBoi.tsx

@ -0,0 +1,159 @@
import "./ProgramBoi.sass";
import {useContext, useEffect, useReducer} from "react";
import RuntimeContext from "../../contexts/RuntimeContext";
import {ProgramStep, weighting} from "../../models/Programs";
import {firstKey, stateString, WorkoutState} from "../../models/Workouts";
import {diffLinearValues, formatValue} from "../../models/Shared";
import {Simulate} from "react-dom/test-utils";
import touchMove = Simulate.touchMove;
interface StepMeta {
actualDuration?: WorkoutState
}
interface ProgressState {
steps: (ProgramStep & StepMeta)[]
currentIndex: number
lastTransition: WorkoutState
lastValue: WorkoutState
toNext: {current: number, max: number}
stopped: boolean
}
interface ProgressChange {
skip?: boolean
workoutState?: WorkoutState
}
function programReducer(state: ProgressState, change: ProgressChange) {
let {steps, currentIndex, lastTransition, lastValue, toNext, stopped} = state;
// Stop working if after program
if (stopped) {
return state;
} else if (currentIndex > steps.length - 1) {
return {...state, stopped: true};
}
// Skip
if (change.skip) {
steps[currentIndex].actualDuration = diffLinearValues(lastValue, lastTransition);
return {
...state,
steps,
lastTransition: lastValue,
currentIndex: currentIndex + 1,
};
}
// Workout state
if (change.workoutState) {
lastValue = change.workoutState;
const step = steps[currentIndex];
if (step.duration) {
if (step.duration.time) {
toNext.current = lastValue.time - lastTransition.time;
toNext.max = step.duration.time;
} else if (step.duration.calories && lastTransition.calories !== undefined && lastValue.calories !== undefined) {
toNext.current = lastValue.calories - lastTransition.calories;
toNext.max = step.duration.calories;
} else if (step.duration.distance && lastTransition.distance !== undefined && lastValue.distance !== undefined) {
toNext.current = lastValue.distance - lastTransition.distance;
toNext.max = step.duration.distance;
}
if (toNext.current >= toNext.max) {
steps[currentIndex].actualDuration = diffLinearValues(lastValue, lastTransition);
currentIndex += 1;
lastTransition = lastValue;
}
}
}
return {steps, currentIndex, lastTransition, lastValue, toNext, stopped};
}
export default function ProgramBoi() {
const {workout, lastEvent} = useContext(RuntimeContext);
const program = workout!.program!;
const [progress, dispatch] = useReducer(programReducer, {
steps: program.steps,
currentIndex: 0,
lastTransition: {time: 0, distance: 0, calories: 0},
lastValue: {time: 0},
toNext: {current: 0, max: 1},
stopped: false,
});
useEffect(() => {
if (lastEvent) {
for (const workoutState of lastEvent?.workoutStates || []) {
dispatch({workoutState});
}
if (lastEvent?.event?.name === "Skipped") {
dispatch({skip: true});
}
}
}, [lastEvent]);
if (progress.steps.some(s => weighting(s) === 0)) {
// TODO: Non-finite mode
return null;
}
return <HealthBarProgress progress={progress}/>;
}
interface ProgressProps {
progress: ProgressState
}
function HealthBarProgress({progress}: ProgressProps) {
const offset = 6 - progress.steps.length;
const steps = [...progress.steps];
steps.reverse();
return (
<div className="HealthBar">
{steps.map((step) => {
const stepIndex = progress.steps.indexOf(step);
const level = progress.steps.indexOf(step) + offset;
const max = Math.max(1, progress.toNext.max);
const key = firstKey(step.duration as WorkoutState);
const duration = step.duration![key!]!;
const durationStr = formatValue(duration - progress.toNext.current, key!);
return (
<div key={step.index} className="HealthBar-entry" style={{flex: weighting(step)}}>
{stepIndex === progress.currentIndex && (
<div className="HealthBar-entry-text">
{durationStr}
</div>
)}
<div className="ProgressBar">
{stepIndex > progress.currentIndex && (
<div className={`ProgressBar-level-${level}`} style={{flex: 1}}/>
)}
{stepIndex === progress.currentIndex && (
<>
<div className={`ProgressBar-level-${level}`} style={{flex: max - progress.toNext.current}}/>
<div className={`ProgressBar-bg`} style={{flex: progress.toNext.current}}/>
</>
)}
{stepIndex < progress.currentIndex && (
<div className={`ProgressBar-bg`} style={{flex: 1}}/>
)}
</div>
</div>
);
})}
</div>
);
}

17
webui-react/src/pages/runtime/hooks.tsx

@ -0,0 +1,17 @@
import {useContext, useEffect, useState} from "react";
import RuntimeContext from "../../contexts/RuntimeContext";
import {WorkoutState} from "../../models/Workouts";
export function useLastState() {
const {lastEvent} = useContext(RuntimeContext);
const [lastState, setLastState] = useState<WorkoutState | null>(null);
useEffect(() => {
if (lastEvent?.workoutStates && lastEvent.workoutStates.length > 0) {
setLastState(lastEvent.workoutStates[lastEvent.workoutStates.length - 1]);
}
}, [lastEvent]);
return lastState;
}

37
webui-react/src/primitives/Pallette.sass

@ -0,0 +1,37 @@
// Raw colors
$green-1: #375623
$green-2: #548235
$green-3: #70ad47
$green-4: #a9d08e
$green-5: #c6e0b4
$green-6: #e2efda
$blue-1: #1f4e78
$blue-2: #2f75b5
$blue-3: #5b9bd5
$blue-4: #9bc2e6
$blue-5: #bdd7ee
$blue-6: #ddebf7
$cyan-1: #1e484f
$cyan-2: #3c848d
$cyan-3: #29b3cc
$cyan-4: #68c8d0
$cyan-5: #86f6fc
$cyan-6: #c7fcff
$yellow-1: #806000
$yellow-2: #bf8f00
$yellow-3: #ffc000
$yellow-4: #ffd966
$yellow-5: #ffe699
$yellow-6: #fff2cc
$red-1: #833c0c
$red-2: #c65911
$red-3: #ed7d31
$red-4: #f4b084
$red-5: #f8cbad
$red-6: #fce4d6
$indigo-1: #203764
$indigo-2: #305496
$indigo-3: #4472c4
$indigo-4: #8ea9db
$indigo-5: #b4c6e7
$indigo-6: #d9e1f2

34
webui-react/src/primitives/Shared.sass

@ -0,0 +1,34 @@
@import "./Pallette"
// Colors
$header-background-gray: #424242
$header-background: $indigo-1
$header-background-focus: $indigo-2
$header-foreground: $indigo-6
$header-foreground-focus: #fff
$body-background: rgb(24, 24, 24)
$body-foreground: #e0e0e0
$body-foreground-dark: $header-background-gray
$title-line: #c0c0c0
$blob-background: #383838
$blob-background-hover: #525252
$blob-background-dark: #262626
$blob-foreground: #e6e6e6
$blob-foreground-clickable: $header-foreground
$blob-foreground-clickable-hover: $header-foreground-focus
// Sizes
$width-internal: 1200px
// Sizes
$mobile-max: 550px
$tablet-max: 850px
$desktop-max: 1050px
// Fonts
$font-global: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif
$font-header: "Bitstream Vera Serif", serif

17
webui-react/src/primitives/Shared.tsx

@ -0,0 +1,17 @@
import React, {CSSProperties, PropsWithChildren} from "react";
import {IconProp} from "@fortawesome/fontawesome-svg-core";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
export type WithChildren = PropsWithChildren<Record<never, never>>;
export type WithStyle = { style?: CSSProperties }
interface IconProps {
value: IconProp
spin?: boolean
flash?: boolean
}
export function Icon({value, spin, flash}: IconProps) {
return <FontAwesomeIcon icon={value} spin={spin} beatFade={flash}/>;
}

134
webui-react/src/primitives/blob/Blob.sass

@ -0,0 +1,134 @@
@import "../Shared.sass"
.Blob
margin-top: 0.5em
border-radius: 0.5em
display: inline-block
cursor: default
margin-left: 0.25em
margin-right: 0.25em
&.Blob-clickable
cursor: pointer
&:hover
color: $blob-foreground-clickable-hover
&.Blob-gray
background-color: $blob-background
color: $blob-foreground
&.Blob-clickable:hover
background-color: $blob-background-hover
&.Blob-green
background-color: $green-1
color: $green-6
&.Blob-clickable:hover
background-color: $green-2
&.Blob-blue
background-color: $blue-1
color: $blue-6
&.Blob-clickable:hover
background-color: $blue-2
&.Blob-cyan
background-color: $cyan-1
color: $cyan-6
&.Blob-clickable:hover
background-color: $cyan-2
&.Blob-yellow
background-color: $yellow-1
color: $yellow-6
&.Blob-clickable:hover
background-color: $yellow-2
&.Blob-red
background-color: $red-1
color: $red-6
&.Blob-clickable:hover
background-color: $red-2
&.Blob-indigo
background-color: $indigo-1
color: $indigo-6
&.Blob-clickable:hover
background-color: $indigo-2
&.Blob-fill-on-any
display: block
@media screen and (max-width: $mobile-max)
&.Blob-fill-on-mobile
display: block
@media screen and (max-width: $tablet-max)
&.Blob-fill-on-tablet
display: block
@media screen and (max-width: $desktop-max)
&.Blob-fill-on-desktop
display: block
.Blob-body
display: flex
flex-direction: row
border-radius: inherit
.BlobText
padding: 0.5em
&.BlobText-centered
text-align: center
&.BlobText-text-secondary
opacity: 0.75
.BlobTextLine
&.BlobTextLine-secondary
opacity: 0.5
&:not(:last-child)
padding-bottom: 0.2em
input.BlobInput
font: inherit
background-color: $blob-background-dark
color: inherit
border: none
padding: 0.5em
border-radius: inherit
&:not(:first-child)
border-bottom-left-radius: 0
border-top-left-radius: 0
&:not(:last-child)
border-bottom-right-radius: 0
border-top-right-radius: 0
div
text-wrap: avoid
.BlobGroup
display: inline-block
.Blob:not(:first-child)
margin-left: 0
border-top-left-radius: 0
border-bottom-left-radius: 0
.Blob:not(:last-child)
margin-right: 0
border-top-right-radius: 0
border-bottom-right-radius: 0

130
webui-react/src/primitives/blob/Blob.tsx

@ -0,0 +1,130 @@
import "./Blob.sass";
import {WithChildren} from "../Shared";
import {CSSProperties, useCallback, useMemo} from "react";
import {Size} from "../../models/Shared";
interface BlobProps extends WithChildren {
onClick?: () => void
disabled?: boolean
fillOn?: Size
flex?: number
color?: "gray" | "green" | "blue" | "red" | "yellow" | "indigo"
}
function Blob({onClick, fillOn, disabled, flex, color, children}: BlobProps) {
const style: CSSProperties = useMemo(() => {
return flex ? {flex} : {};
}, [flex]);
const classNames = useMemo(() => {
const val = ["Blob"];
if (onClick !== undefined && !disabled) {
val.push("Blob-clickable")
}
if (fillOn) {
val.push(`Blob-fill-on-${fillOn}`);
}
val.push(`Blob-${color || "gray"}`)
return val;
}, [onClick, fillOn, disabled, color]);
return (
<div style={style} className={classNames.join(" ")} onClick={disabled ? undefined : onClick}>
<div className="Blob-body">
{children}
</div>
</div>
);
}
interface BlobTextProps extends WithChildren {
centered?: boolean
flex?: number
}
export function BlobText({centered, children, flex}: BlobTextProps) {
const clazz = useMemo(() => {
const classNames = ["BlobText"];
if (centered) {
classNames.push("BlobText-centered");
}
return classNames.join(" ");
}, [centered]);
return (
<div className={clazz} style={{flex}}>
{children}
</div>
);
}
interface BlobTextLineProps extends WithChildren {
secondary?: boolean
flex?: number
}
export function BlobTextLine({secondary, flex, children}: BlobTextLineProps) {
const clazz = useMemo(() => {
const classNames = ["BlobTextLine"];
if (secondary) {
classNames.push("BlobTextLine-secondary");
}
return classNames.join(" ");
}, [secondary]);
return (
<div className={clazz} style={{flex}}>
{children}
</div>
);
}
type BlobInputProps =
| BaseBlobInputProps<"number", number>
| BaseBlobInputProps<"text" | "password" | undefined, string>
interface BaseBlobInputProps<T, V> {
type: T
name?: string
flex?: number
value: V
disabled?: boolean
onChange?: (newValue: V) => void
}
export function BlobInput({type, name, flex, disabled, value, onChange}: BlobInputProps) {
const actualOnChange = useCallback((input: string) => {
if (onChange === undefined) return;
if (type === "number") {
onChange(parseInt(input, 10) || 0);
} else {
onChange(input);
}
}, [onChange]);
return (
<input
disabled={disabled}
className="BlobInput"
name={name}
type={type || "text"}
value={`${value}`}
style={{flex}}
onChange={e => actualOnChange(e.target.value || "")}
/>
);
}
export function BlobGroup({children}: WithChildren) {
return (
<div className="BlobGroup">
{children}
</div>
);
}
export default Blob;

43
webui-react/src/primitives/boi/Boi.sass

@ -0,0 +1,43 @@
@import "../Shared.sass"
.Boi
background-color: rgba(0, 0, 0, 0.33)
z-index: 9999
padding: 0.25vmax 0.75vmax 1.5vmax
box-sizing: border-box
.TitleLine
margin-bottom: 0.25em
&.Boi-v-top
position: fixed
top: 0
&.Boi-v-center
position: fixed
top: 50%
&:not(.Boi-h-center)
transform: translate(0, -50%)
&.Boi-h-center
transform: translate(-50%, -50%)
&.Boi-v-bottom
position: fixed
bottom: 0
&.Boi-h-left
position: fixed
left: 0
&.Boi-h-center
position: fixed
left: 50%
&:not(.Boi-v-center)
transform: translate(-50%, 0)
&.Boi-h-right
position: fixed
right: 0

30
webui-react/src/primitives/boi/Boi.tsx

@ -0,0 +1,30 @@
import "./Boi.sass";
import {WithChildren, WithStyle} from "../Shared";
import {useMemo} from "react";
interface BoiProps extends WithChildren, WithStyle {
vertical: "top" | "center" | "bottom"
horizontal: "left" | "center" | "right"
}
const defaultStyle = {fontSize: "3vmax"};
export function Boi({horizontal, vertical, children, style}: BoiProps) {
const className = useMemo(() => {
const list = [
"Boi",
`Boi-h-${horizontal}`,
`Boi-v-${vertical}`,
];
return list.join(" ");
}, [horizontal, vertical]);
return (
<div className={className}>
<div className="Boi-content" style={{...defaultStyle, ...(style || {})}}>
{children}
</div>
</div>
);
}

37
webui-react/src/primitives/header/Header.sass

@ -0,0 +1,37 @@
@import "../Shared.sass"
.Header
background-color: $header-background
color: $header-foreground
font-family: $font-header
font-size: 200%
.Header-body
display: flex
flex-direction: row
.HeaderTitle
padding: 0.1em 0.2em
font-weight: 400
.HeaderButton
padding: 0.1em 0.2em
.HeaderTitle
flex: 1
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.HeaderTitle-interior
cursor: default
user-select: none
.HeaderButton
min-width: 0.8em
text-align: center
cursor: pointer
&:hover
background-color: $header-background-focus
color: $header-foreground-focus

55
webui-react/src/primitives/header/Header.tsx

@ -0,0 +1,55 @@
import "./Header.sass";
import React, {useEffect} from "react";
import {WithChildren} from "../Shared";
export function HeaderTitle({children}: WithChildren) {
return (
<div className="HeaderTitle">
<span className="HeaderTitle-interior">
{children}
</span>
</div>
)
}
interface HeaderButtonProps extends WithChildren {
title?: string
onClick?: () => void
shortcut?: string
}
export function HeaderButton({title, onClick, shortcut, children}: HeaderButtonProps) {
useEffect(() => {
if (!shortcut || !onClick) {
return;
}
const handler = (e: KeyboardEvent) => {
if (e.key === shortcut) {
onClick();
}
};
window.addEventListener("keypress", handler);
return () => window.removeEventListener("keypress", handler);
}, [shortcut, onClick]);
return (
<div className="HeaderButton" title={title} onClick={onClick}>
{children}
</div>
);
}
function Header({children}: WithChildren) {
return (
<div className="Header">
<div className="Header-body">
{children}
</div>
</div>
);
}
export default Header;

10
webui-react/src/primitives/misc/Misc.sass

@ -0,0 +1,10 @@
@import "../Shared"
.TitleLine
margin-left: 0.125em
margin-right: 0.125em
font-size: 200%
margin-top: 0.5em
padding-bottom: 0.1em
color: $title-line
border-bottom: $blob-background 1px solid

8
webui-react/src/primitives/misc/Misc.tsx

@ -0,0 +1,8 @@
import "./Misc.sass";
import {WithChildren} from "../Shared";
export function TitleLine({children}: WithChildren) {
return (
<div className="TitleLine">{children}</div>
);
}

45
webui-react/src/primitives/page/Page.sass

@ -0,0 +1,45 @@
@import "../Shared.sass"
.Page
width: 100%
height: 100%
display: flex
flex-direction: column
&.Page-bg-2046
background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url("/2046.png") no-repeat center
background-size: cover
.PageBody
flex: 1
width: 100%
max-width: 1200px
height: 100%
margin: 0 auto
.PageBody-padding
height: 2em
.PageFlexRow
display: flex
flex-direction: row
align-items: stretch
&:not(.PageFlexRow-vertical)
width: 100%
&.PageFlexRow-vertical
flex-direction: column
height: 100%
@media screen and (max-width: $mobile-max)
&.PageFlexRow-collapse-on-mobile
display: block
@media screen and (max-width: $tablet-max)
&.PageFlexRow-collapse-on-tablet
display: block
@media screen and (max-width: $desktop-max)
&.PageFlexRow-collapse-on-desktop
display: block

76
webui-react/src/primitives/page/Page.tsx

@ -0,0 +1,76 @@
import "./Page.sass";
import {WithChildren, WithStyle} from "../Shared";
import {useLayoutEffect, useMemo} from "react";
import {Size} from "../../models/Shared";
interface PageProps extends WithChildren {
title?: string
background?: "2046"
}
function Page({title, background, children}: PageProps) {
useLayoutEffect(() => {
document.title = title ? `${title} - Green` : "Green";
}, [title]);
let cls = "Page";
if (background) {
cls += ` Page-bg-${background}`;
}
return (
<div className={cls}>
{children}
</div>
);
}
export function PageBody({children, style}: WithChildren & WithStyle) {
return (
<div className="PageBody" style={style}>
{children}
<div className="PageBody-padding"/>
</div>
);
}
interface PageFlexRowProps extends WithChildren, WithStyle {
collapseOn?: Size
flex?: number
vertical?: boolean
}
export function PageFlexRow({children, collapseOn, flex, vertical, style}: PageFlexRowProps) {
const clazz = useMemo(() => {
const classNames = ["PageFlexRow"];
if (collapseOn) {
classNames.push(`PageFlexRow-collapse-on-${collapseOn}`);
}
if (vertical) {
classNames.push("PageFlexRow-vertical");
}
return classNames.join(" ");
}, [collapseOn, vertical]);
return (
<div className={clazz} style={style ? {...style, flex} : {flex}}>
{children}
</div>
);
}
interface PageFlexColumnProps extends WithChildren {
flex?: number
}
export function PageFlexColumn({flex = 1, children}: PageFlexColumnProps) {
const actualFlex = (flex && flex > 0) ? flex : undefined;
return (
<span style={{flex: actualFlex}}>{children}</span>
);
}
export default Page;

8
webui-react/src/vite-env.d.ts

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MODE: "webapp" | "chrome-plugin"
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

21
webui-react/tsconfig.json

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

9
webui-react/tsconfig.node.json

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

24
webui-react/vite.config.ts

@ -0,0 +1,24 @@
import {defineConfig, loadEnv} from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default ({mode}) => {
//@ts-ignore
process.env = {...process.env, ...loadEnv(mode, process.cwd())}
return defineConfig({
plugins: [react()],
build: {
//@ts-ignore
outDir: process.env.VITE_MODE === "webapp" ? "./dist-webapp" : "./dist-chrome",
},
server: {
proxy: {
"/api": {
target: 'http://localhost:8080',
changeOrigin: true,
},
}
},
});
}

977
webui-react/yarn.lock

@ -0,0 +1,977 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@ampproject/remapping@^2.1.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==
dependencies:
"@jridgewell/gen-mapping" "^0.1.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@babel/code-frame@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
dependencies:
"@babel/highlight" "^7.18.6"
"@babel/compat-data@^7.18.8":
version "7.18.8"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d"
integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ==
"@babel/core@^7.18.10":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8"
integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw==
dependencies:
"@ampproject/remapping" "^2.1.0"
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.10"
"@babel/helper-compilation-targets" "^7.18.9"
"@babel/helper-module-transforms" "^7.18.9"
"@babel/helpers" "^7.18.9"
"@babel/parser" "^7.18.10"
"@babel/template" "^7.18.10"
"@babel/traverse" "^7.18.10"
"@babel/types" "^7.18.10"
convert-source-map "^1.7.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.2.1"
semver "^6.3.0"
"@babel/generator@^7.18.10":
version "7.18.12"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4"
integrity sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg==
dependencies:
"@babel/types" "^7.18.10"
"@jridgewell/gen-mapping" "^0.3.2"
jsesc "^2.5.1"
"@babel/helper-annotate-as-pure@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb"
integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-compilation-targets@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf"
integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg==
dependencies:
"@babel/compat-data" "^7.18.8"
"@babel/helper-validator-option" "^7.18.6"
browserslist "^4.20.2"
semver "^6.3.0"
"@babel/helper-environment-visitor@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
"@babel/helper-function-name@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0"
integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A==
dependencies:
"@babel/template" "^7.18.6"
"@babel/types" "^7.18.9"
"@babel/helper-hoist-variables@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678"
integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-module-imports@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e"
integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-module-transforms@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712"
integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g==
dependencies:
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-module-imports" "^7.18.6"
"@babel/helper-simple-access" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/helper-validator-identifier" "^7.18.6"
"@babel/template" "^7.18.6"
"@babel/traverse" "^7.18.9"
"@babel/types" "^7.18.9"
"@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.9.tgz#4b8aea3b069d8cb8a72cdfe28ddf5ceca695ef2f"
integrity sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w==
"@babel/helper-simple-access@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea"
integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-split-export-declaration@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075"
integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==
dependencies:
"@babel/types" "^7.18.6"
"@babel/helper-string-parser@^7.18.10":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56"
integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==
"@babel/helper-validator-identifier@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
"@babel/helper-validator-option@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8"
integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==
"@babel/helpers@^7.18.9":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9"
integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ==
dependencies:
"@babel/template" "^7.18.6"
"@babel/traverse" "^7.18.9"
"@babel/types" "^7.18.9"
"@babel/highlight@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf"
integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==
dependencies:
"@babel/helper-validator-identifier" "^7.18.6"
chalk "^2.0.0"
js-tokens "^4.0.0"
"@babel/parser@^7.18.10", "@babel/parser@^7.18.11":
version "7.18.11"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.11.tgz#68bb07ab3d380affa9a3f96728df07969645d2d9"
integrity sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ==
"@babel/plugin-syntax-jsx@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0"
integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==
dependencies:
"@babel/helper-plugin-utils" "^7.18.6"
"@babel/plugin-transform-react-jsx-development@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz#dbe5c972811e49c7405b630e4d0d2e1380c0ddc5"
integrity sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==
dependencies:
"@babel/plugin-transform-react-jsx" "^7.18.6"
"@babel/plugin-transform-react-jsx-self@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.18.6.tgz#3849401bab7ae8ffa1e3e5687c94a753fc75bda7"
integrity sha512-A0LQGx4+4Jv7u/tWzoJF7alZwnBDQd6cGLh9P+Ttk4dpiL+J5p7NSNv/9tlEFFJDq3kjxOavWmbm6t0Gk+A3Ig==
dependencies:
"@babel/helper-plugin-utils" "^7.18.6"
"@babel/plugin-transform-react-jsx-source@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.18.6.tgz#06e9ae8a14d2bc19ce6e3c447d842032a50598fc"
integrity sha512-utZmlASneDfdaMh0m/WausbjUjEdGrQJz0vFK93d7wD3xf5wBtX219+q6IlCNZeguIcxS2f/CvLZrlLSvSHQXw==
dependencies:
"@babel/helper-plugin-utils" "^7.18.6"
"@babel/plugin-transform-react-jsx@^7.18.10", "@babel/plugin-transform-react-jsx@^7.18.6":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.18.10.tgz#ea47b2c4197102c196cbd10db9b3bb20daa820f1"
integrity sha512-gCy7Iikrpu3IZjYZolFE4M1Sm+nrh1/6za2Ewj77Z+XirT4TsbJcvOFOyF+fRPwU6AKKK136CZxx6L8AbSFG6A==
dependencies:
"@babel/helper-annotate-as-pure" "^7.18.6"
"@babel/helper-module-imports" "^7.18.6"
"@babel/helper-plugin-utils" "^7.18.9"
"@babel/plugin-syntax-jsx" "^7.18.6"
"@babel/types" "^7.18.10"
"@babel/runtime@^7.7.6":
version "7.18.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.18.10", "@babel/template@^7.18.6":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==
dependencies:
"@babel/code-frame" "^7.18.6"
"@babel/parser" "^7.18.10"
"@babel/types" "^7.18.10"
"@babel/traverse@^7.18.10", "@babel/traverse@^7.18.9":
version "7.18.11"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.11.tgz#3d51f2afbd83ecf9912bcbb5c4d94e3d2ddaa16f"
integrity sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ==
dependencies:
"@babel/code-frame" "^7.18.6"
"@babel/generator" "^7.18.10"
"@babel/helper-environment-visitor" "^7.18.9"
"@babel/helper-function-name" "^7.18.9"
"@babel/helper-hoist-variables" "^7.18.6"
"@babel/helper-split-export-declaration" "^7.18.6"
"@babel/parser" "^7.18.11"
"@babel/types" "^7.18.10"
debug "^4.1.0"
globals "^11.1.0"
"@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9":
version "7.18.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6"
integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ==
dependencies:
"@babel/helper-string-parser" "^7.18.10"
"@babel/helper-validator-identifier" "^7.18.6"
to-fast-properties "^2.0.0"
"@esbuild/linux-loong64@0.14.54":
version "0.14.54"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
"@fortawesome/fontawesome-common-types@6.1.2":
version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.1.2.tgz#c1095b1bbabf19f37f9ff0719db38d92a410bcfe"
integrity sha512-wBaAPGz1Awxg05e0PBRkDRuTsy4B3dpBm+zreTTyd9TH4uUM27cAL4xWyWR0rLJCrRwzVsQ4hF3FvM6rqydKPA==
"@fortawesome/fontawesome-svg-core@^6.1.1":
version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.1.2.tgz#11e2e8583a7dea75d734e4d0e53d91c63fae7511"
integrity sha512-853G/Htp0BOdXnPoeCPTjFrVwyrJHpe8MhjB/DYE9XjwhnNDfuBCd3aKc2YUYbEfHEcBws4UAA0kA9dymZKGjA==
dependencies:
"@fortawesome/fontawesome-common-types" "6.1.2"
"@fortawesome/free-solid-svg-icons@^6.1.1":
version "6.1.2"
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.1.2.tgz#491d668b8a6603698d0ce1ac620f66fd22b74c84"
integrity sha512-lTgZz+cMpzjkHmCwOG3E1ilUZrnINYdqMmrkv30EC3XbRsGlbIOL8H9LaNp5SV4g0pNJDfQ4EdTWWaMvdwyLiQ==
dependencies:
"@fortawesome/fontawesome-common-types" "6.1.2"
"@fortawesome/react-fontawesome@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz#d90dd8a9211830b4e3c08e94b63a0ba7291ddcf4"
integrity sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==
dependencies:
prop-types "^15.8.1"
"@jridgewell/gen-mapping@^0.1.0":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996"
integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==
dependencies:
"@jridgewell/set-array" "^1.0.0"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/gen-mapping@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9"
integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==
dependencies:
"@jridgewell/set-array" "^1.0.1"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.9"
"@jridgewell/resolve-uri@^3.0.3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
"@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
"@jridgewell/trace-mapping@^0.3.9":
version "0.3.15"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774"
integrity sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==
dependencies:
"@jridgewell/resolve-uri" "^3.0.3"
"@jridgewell/sourcemap-codec" "^1.4.10"
"@types/prop-types@*":
version "15.7.5"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
"@types/react-dom@^18.0.6":
version "18.0.6"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1"
integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^18.0.17":
version "18.0.17"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.17.tgz#4583d9c322d67efe4b39a935d223edcc7050ccf4"
integrity sha512-38ETy4tL+rn4uQQi7mB81G7V1g0u2ryquNmsVIOKUAEIDK+3CUjZ6rSRpdvS99dNBnkLFL83qfmtLacGOTIhwQ==
dependencies:
"@types/prop-types" "*"
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/scheduler@*":
version "0.16.2"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
"@vitejs/plugin-react@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-2.0.1.tgz#3197c01d8e4a4eb9fed829c7888c467a43aadd4e"
integrity sha512-uINzNHmjrbunlFtyVkST6lY1ewSfz/XwLufG0PIqvLGnpk2nOIOa/1CACTDNcKi1/RwaCzJLmsXwm1NsUVV/NA==
dependencies:
"@babel/core" "^7.18.10"
"@babel/plugin-transform-react-jsx" "^7.18.10"
"@babel/plugin-transform-react-jsx-development" "^7.18.6"
"@babel/plugin-transform-react-jsx-self" "^7.18.6"
"@babel/plugin-transform-react-jsx-source" "^7.18.6"
magic-string "^0.26.2"
react-refresh "^0.14.0"
ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
dependencies:
color-convert "^1.9.0"
anymatch@~3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^0.27.2:
version "0.27.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
dependencies:
follow-redirects "^1.14.9"
form-data "^4.0.0"
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
dependencies:
fill-range "^7.0.1"
browserslist@^4.20.2:
version "4.21.3"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.3.tgz#5df277694eb3c48bc5c4b05af3e8b7e09c5a6d1a"
integrity sha512-898rgRXLAyRkM1GryrrBHGkqA5hlpkV5MhtZwg9QXeiyLUYs2k00Un05aX5l2/yJIOObYKOpS2JNo8nJDE7fWQ==
dependencies:
caniuse-lite "^1.0.30001370"
electron-to-chromium "^1.4.202"
node-releases "^2.0.6"
update-browserslist-db "^1.0.5"
caniuse-lite@^1.0.30001370:
version "1.0.30001375"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001375.tgz#8e73bc3d1a4c800beb39f3163bf0190d7e5d7672"
integrity sha512-kWIMkNzLYxSvnjy0hL8w1NOaWNr2rn39RTAVyIwcw8juu60bZDWiF1/loOYANzjtJmy6qPgNmn38ro5Pygagdw==
chalk@^2.0.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
dependencies:
ansi-styles "^3.2.1"
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
"chokidar@>=3.0.0 <4.0.0":
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
dependencies:
color-name "1.1.3"
color-name@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
convert-source-map@^1.7.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
dependencies:
safe-buffer "~5.1.1"
csstype@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
debug@^4.1.0:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
electron-to-chromium@^1.4.202:
version "1.4.218"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.218.tgz#d6b817b5454499a92c85888b42dc2ad075e4493a"
integrity sha512-INDylKH//YIf2w67D+IjkfVnGVrZ/D94DAU/FPPm6T4jEPbEDQvo9r2wTj0ncFdtJH8+V8BggZTaN8Rzk5wkgw==
esbuild-android-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
esbuild-android-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
esbuild-darwin-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
esbuild-darwin-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
esbuild-freebsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
esbuild-freebsd-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
esbuild-linux-32@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
esbuild-linux-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
esbuild-linux-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
esbuild-linux-arm@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
esbuild-linux-mips64le@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
esbuild-linux-ppc64le@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
esbuild-linux-riscv64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
esbuild-linux-s390x@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
esbuild-netbsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
esbuild-openbsd-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
esbuild-sunos-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
esbuild-windows-32@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
esbuild-windows-64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
esbuild-windows-arm64@0.14.54:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
esbuild@^0.14.47:
version "0.14.54"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
optionalDependencies:
"@esbuild/linux-loong64" "0.14.54"
esbuild-android-64 "0.14.54"
esbuild-android-arm64 "0.14.54"
esbuild-darwin-64 "0.14.54"
esbuild-darwin-arm64 "0.14.54"
esbuild-freebsd-64 "0.14.54"
esbuild-freebsd-arm64 "0.14.54"
esbuild-linux-32 "0.14.54"
esbuild-linux-64 "0.14.54"
esbuild-linux-arm "0.14.54"
esbuild-linux-arm64 "0.14.54"
esbuild-linux-mips64le "0.14.54"
esbuild-linux-ppc64le "0.14.54"
esbuild-linux-riscv64 "0.14.54"
esbuild-linux-s390x "0.14.54"
esbuild-netbsd-64 "0.14.54"
esbuild-openbsd-64 "0.14.54"
esbuild-sunos-64 "0.14.54"
esbuild-windows-32 "0.14.54"
esbuild-windows-64 "0.14.54"
esbuild-windows-arm64 "0.14.54"
escalade@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
escape-string-regexp@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
dependencies:
to-regex-range "^5.0.1"
follow-redirects@^1.14.9:
version "1.15.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
dependencies:
is-glob "^4.0.1"
globals@^11.1.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
has-flag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==
has@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
dependencies:
function-bind "^1.1.1"
history@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b"
integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==
dependencies:
"@babel/runtime" "^7.7.6"
immutable@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-core-module@^2.9.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
dependencies:
has "^1.0.3"
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
is-glob@^4.0.1, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
dependencies:
is-extglob "^2.1.1"
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
jsesc@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
json5@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
magic-string@^0.26.2:
version "0.26.2"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.26.2.tgz#5331700e4158cd6befda738bb6b0c7b93c0d4432"
integrity sha512-NzzlXpclt5zAbmo6h6jNc8zl2gNRGHvmsZW4IvZhTC4W7k4OlLP+S5YLussa/r3ixNT66KOQfNORlXHSOy/X4A==
dependencies:
sourcemap-codec "^1.4.8"
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
nanoid@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
node-releases@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
picocolors@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
picomatch@^2.0.4, picomatch@^2.2.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
postcss@^8.4.16:
version "8.4.16"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.16.tgz#33a1d675fac39941f5f445db0de4db2b6e01d43c"
integrity sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==
dependencies:
nanoid "^3.3.4"
picocolors "^1.0.0"
source-map-js "^1.0.2"
prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
react-dom@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-refresh@^0.14.0:
version "0.14.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e"
integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==
react-router-dom@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d"
integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw==
dependencies:
history "^5.2.0"
react-router "6.3.0"
react-router@6.3.0, react-router@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557"
integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ==
dependencies:
history "^5.2.0"
react@^18.0.0:
version "18.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
dependencies:
loose-envify "^1.1.0"
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
regenerator-runtime@^0.13.4:
version "0.13.9"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
resolve@^1.22.1:
version "1.22.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
dependencies:
is-core-module "^2.9.0"
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
"rollup@>=2.75.6 <2.77.0 || ~2.77.0":
version "2.77.3"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.77.3.tgz#8f00418d3a2740036e15deb653bed1a90ee0cc12"
integrity sha512-/qxNTG7FbmefJWoeeYJFbHehJ2HNWnjkAFRKzWN/45eNBBF/r8lo992CwcJXEzyVxs5FmfId+vTSTQDb+bxA+g==
optionalDependencies:
fsevents "~2.3.2"
safe-buffer@~5.1.1:
version "5.1.2"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
sass@^1.53.0:
version "1.54.4"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.54.4.tgz#803ff2fef5525f1dd01670c3915b4b68b6cba72d"
integrity sha512-3tmF16yvnBwtlPrNBHw/H907j8MlOX8aTBnlNX1yrKx24RKcJGPyLhFUwkoKBKesR3unP93/2z14Ll8NicwQUA==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
source-map-js ">=0.6.2 <2.0.0"
scheduler@^0.23.0:
version "0.23.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
dependencies:
loose-envify "^1.1.0"
semver@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
sourcemap-codec@^1.4.8:
version "1.4.8"
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
dependencies:
has-flag "^3.0.0"
supports-preserve-symlinks-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
to-fast-properties@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
dependencies:
is-number "^7.0.0"
typescript@^4.6.4:
version "4.7.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235"
integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==
update-browserslist-db@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.5.tgz#be06a5eedd62f107b7c19eb5bcefb194411abf38"
integrity sha512-dteFFpCyvuDdr9S/ff1ISkKt/9YZxKjI9WlRR99c180GaztJtRa/fn18FdxGVKVsnPY7/a/FDN68mcvUmP4U7Q==
dependencies:
escalade "^3.1.1"
picocolors "^1.0.0"
vite@^3.0.7:
version "3.0.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.7.tgz#f1e379857e9c5d652126f8b20d371e1365eb700f"
integrity sha512-dILhvKba1mbP1wCezVQx/qhEK7/+jVn9ciadEcyKMMhZpsuAi/eWZfJRMkmYlkSFG7Qq9NvJbgFq4XOBxugJsA==
dependencies:
esbuild "^0.14.47"
postcss "^8.4.16"
resolve "^1.22.1"
rollup ">=2.75.6 <2.77.0 || ~2.77.0"
optionalDependencies:
fsevents "~2.3.2"

1
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/models/Workout.kt

@ -12,6 +12,7 @@ data class Workout(
val programId: String? = null,
var status: WorkoutStatus = WorkoutStatus.Created,
var message: String = "",
var test: Boolean = false,
) {
fun makeState(values: Collection<Value>) = WorkoutState(
workoutId = id,

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Command.kt

@ -14,3 +14,5 @@ object StopCommand : Command()
data class SetValueCommand(val value: Value) : Command()
object DisconnectCommand : Command()
object SkipCommand : Command()

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/domain/runtime/Event.kt

@ -13,3 +13,5 @@ object Stopped : Event()
object Connected : Event()
object Disconnected : Event()
object Skipped : Event()

52
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/ProgramEnforcer.kt

@ -9,9 +9,35 @@ class ProgramEnforcer(
private val workoutRepo: WorkoutRepository,
) : ReactiveDriver() {
private var lastTransition: WorkoutState = WorkoutState()
private var lastValues: ValuesReceived? = null
private var lastStep: ProgramStep? = null
private var program: Program? = null
private suspend fun runTransition(input: CommandBus, newEvent: ValuesReceived? = null) {
val step = lastStep ?: return
val prog = program ?: return
val event = newEvent ?: lastValues ?: return
val stepIndex = prog.steps.indexOf(step)
if (prog.steps.size > stepIndex + 1) {
// Go to next step and send the change
lastTransition = lastTransition.copy(
time = event.values.find() ?: lastTransition.time,
calories = event.values.find(),
distance = event.values.find(),
level = event.values.find(),
)
lastStep = prog.steps[stepIndex + 1]
lastStep?.values?.forEach { input.emit(SetValueCommand(it)) }
} else {
// The program is done, let's stop it
lastStep = null
program = null
input.emit(StopCommand)
}
}
override suspend fun onEvent(event: Event, input: FlowBus<Command>) {
if (event is Connected) {
// Start program on connecting
@ -29,11 +55,14 @@ class ProgramEnforcer(
}
}
if (event is Skipped) {
runTransition(input)
}
if (event is ValuesReceived && program != null && lastStep != null) {
// If there's a program, look for next transition
val step = lastStep!!
val prog = program!!
val transition = when (step.duration) {
is Time -> (event.values.findInt<Time>()) >= lastTransition.time.toInt() + step.duration.toInt()
@ -43,25 +72,10 @@ class ProgramEnforcer(
}
if (transition) {
val stepIndex = prog.steps.indexOf(step)
if (prog.steps.size > stepIndex + 1) {
// Go to next step and send the change
lastTransition = lastTransition.copy(
time = event.values.find() ?: lastTransition.time,
calories = event.values.find(),
distance = event.values.find(),
level = event.values.find(),
)
lastStep = prog.steps[stepIndex + 1]
lastStep?.values?.forEach { input.emit(SetValueCommand(it)) }
} else {
// The program is done, let's stop it
lastStep = null
program = null
input.emit(StopCommand)
}
runTransition(input, event)
}
lastValues = event
}
if (event is Disconnected) {

28
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt

@ -0,0 +1,28 @@
package net.aiterp.git.ykonsole2.infrastructure.drivers
import kotlinx.coroutines.delay
import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
import kotlin.time.Duration.Companion.seconds
object Skipper : ActiveDriver() {
private var enabled: Boolean = false
override suspend fun onCommand(command: Command, output: FlowBus<Event>) {
if (command is ConnectCommand) {
enabled = true
}
if (command is DisconnectCommand) {
enabled = false
}
if (command is SkipCommand && enabled) {
output.emit(Skipped)
}
}
override suspend fun onTick(output: FlowBus<Event>) {
delay(10.seconds)
}
}

4
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/WorkoutWriter.kt

@ -45,6 +45,10 @@ class WorkoutWriter(
workoutRepo.save(foundWorkout)
}
is Skipped -> {
/* Do nothing */
}
is ErrorOccurred -> {
foundWorkout.message = "Error: ${event.message}"
workoutRepo.save(foundWorkout)

2
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/TestDriver.kt

@ -54,6 +54,8 @@ class TestDriver(private val secondLength: Duration) : ActiveDriver() {
else -> null
}
SkipCommand -> null
StartCommand -> if (connected) Started.also { running = true } else null
StopCommand -> if (connected) Stopped.also { running = false } else null
}

11
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt

@ -20,6 +20,8 @@ class IConsole : ActiveDriver() {
private var running = false
private var connected = false
private var maxLevel = 20
private val queue = Collections.synchronizedSet<Request?>(LinkedHashSet())
private var central: BluetoothCentralManager? = null
@ -91,6 +93,10 @@ class IConsole : ActiveDriver() {
output.emitBlocking(Connected)
}
if (res is MaxLevelResponse) {
maxLevel = res.level
}
if (res is ControlStateResponse) {
if (res.value == 1) {
running = true
@ -159,7 +165,7 @@ class IConsole : ActiveDriver() {
if (value is Level) {
lastLevel = value.toInt()
queue += SetResistanceLevelRequest(value.toInt())
queue += SetResistanceLevelRequest(minOf(lastLevel, maxLevel))
}
}
@ -191,6 +197,7 @@ class IConsole : ActiveDriver() {
is SetValueCommand -> onSetValue(command.value)
StartCommand -> onStart()
StopCommand -> onStop()
SkipCommand -> Unit
}
}
@ -245,6 +252,6 @@ class IConsole : ActiveDriver() {
private val S2_SERVICE = UUID.fromString("49535343-5d82-6099-9348-7aac4d5fbc51")
private val S2_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-026e-3a9b-954c-97daef17e26e")
private val pollDuration: Duration = (500).milliseconds
private val pollDuration: Duration = 500.milliseconds
}
}

10
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Shared.kt

@ -56,25 +56,27 @@ data class ProgramDTO(
data class ProgramStepDTO(
val index: Int? = null,
val values: ValueDTO,
val duration: ValueDTO? = null,
val duration: ValueDTO = ValueDTO(),
) {
fun toProgramStep() = ProgramStep(values.toValues(), duration?.toValueOrNull())
fun toProgramStep() = ProgramStep(values.toValues(), duration.toValueOrNull())
}
data class WorkoutDTO(
val id: String,
val createdAt: Instant,
val device: Device?,
val program: Program?,
val program: ProgramDTO?,
val status: WorkoutStatus,
val message: String,
val test: Boolean,
) {
constructor(workout: Workout, device: Device?, program: Program?) : this(
id = workout.id,
createdAt = workout.createdAt,
device = device,
program = program,
program = program?.let { ProgramDTO.from(it) },
status = workout.status,
message = workout.message,
test = workout.test,
)
}

37
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/Workouts.kt

@ -1,39 +1,52 @@
package net.aiterp.git.ykonsole2.application.routes
import io.ktor.http.*
import io.ktor.http.HttpStatusCode.Companion.BadRequest
import io.ktor.http.HttpStatusCode.Companion.NotFound
import io.ktor.http.HttpStatusCode.Companion.OK
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykDataResponse
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykStatusResponse
import net.aiterp.git.ykonsole2.infrastructure.ktor.ykException
import java.time.LocalDate
import java.time.ZoneId
data class WorkoutInput(val deviceId: String, val programId: String? = null)
data class WorkoutInput(val deviceId: String, val programId: String? = null, val test: Boolean? = false)
fun Route.workouts(
deviceRepo: DeviceRepository,
programRepo: ProgramRepository,
workoutRepo: WorkoutRepository,
) {
val tz = ZoneId.of("Europe/Oslo")
route("/workouts") {
get {
val minTime = call.request.queryParameters["daysBack"]?.toLongOrNull()?.let { daysBack ->
LocalDate.now(tz).minusDays(daysBack).atStartOfDay(tz).toInstant()
}
val includeTest = call.request.queryParameters["includeTest"]?.toBoolean() ?: false
val workouts = workoutRepo.fetchAll()
val devices = deviceRepo.fetchAll()
val programs = programRepo.fetchAll()
val output = workouts.map { workout ->
WorkoutDTO(
workout = workout,
device = devices.firstOrNull { it.id == workout.deviceId },
program = workout.programId?.let { programId ->
programs.firstOrNull { it.id == programId }
}
)
}
val output = workouts.asSequence()
.filter { minTime == null || minTime.isBefore(it.createdAt) }
.filter { includeTest || !it.test }
.sortedByDescending { it.createdAt }
.map { workout ->
WorkoutDTO(
workout = workout,
device = devices.firstOrNull { it.id == workout.deviceId },
program = workout.programId?.let { programId ->
programs.firstOrNull { it.id == programId }
}
)
}.toList()
call.ykDataResponse(output)
}
@ -50,7 +63,7 @@ fun Route.workouts(
programRepo.findById(progId) ?: throw call.ykException(BadRequest, "Invalid program ID")
}
val workout = Workout(deviceId = device.id, programId = program?.id)
val workout = Workout(deviceId = device.id, programId = program?.id, test = input.test ?: false)
workoutRepo.save(workout)
call.ykDataResponse(WorkoutDTO(workout, device, program))

2
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/Connection.kt

@ -48,7 +48,7 @@ fun Route.sockets(
eventBus.collect { event ->
when (event) {
Connected, Started, Stopped, Disconnected -> {
Connected, Started, Stopped, Disconnected, Skipped -> {
sendSerialized(SocketOutput(event = SocketOutput.EventDTO(name = event.name)))
if (event is Disconnected) close()

2
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/routes/ws/SocketInput.kt

@ -9,6 +9,7 @@ data class SocketInput(
val stop: Boolean = false,
val connect: Boolean = false,
val disconnect: Boolean = false,
val skip: Boolean = false,
val setValue: ValueDTO? = null,
) {
fun makeCommands(device: Device) = buildList {
@ -16,6 +17,7 @@ data class SocketInput(
if (stop) add(StopCommand)
if (connect) add(ConnectCommand(device))
if (disconnect) add(DisconnectCommand)
if (skip) add(SkipCommand)
setValue?.toValues()?.forEach { add(SetValueCommand(it)) }
}
}

2
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlProgramRepository.kt

@ -11,7 +11,7 @@ import javax.sql.DataSource
val DataSource.programRepo get() = object : ProgramRepository {
override fun findById(id: String): Program? = withConnection {
val (programId, name) = prepare("SELECT * FROM program WHERE id = ?") {
val (programId, name) = prepare("SELECT * FROM program WHERE id = ? ORDER BY name") {
setString(1, id)
runQuery { if (next()) getString("id") to getString("name") else null }

45
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutRepository.kt

@ -16,9 +16,7 @@ val DataSource.workoutRepo get() = object : WorkoutRepository {
prepare("SELECT * FROM workout WHERE id = ?") {
setString(1, id)
runQuery {
if (next()) makeWorkout() else null
}
runQuery { if (next()) makeWorkout() else null }
}
}
@ -31,39 +29,39 @@ val DataSource.workoutRepo get() = object : WorkoutRepository {
}
override fun findActive() = withConnection {
prepareStatement("SELECT * FROM workout WHERE status != 'Disconnected' ORDER BY created_at DESC").use { ps ->
ps.executeQuery().use { rs ->
if (rs.next()) rs.makeWorkout() else null
}
prepare("SELECT * FROM workout WHERE status != 'Disconnected' ORDER BY created_at DESC") {
runQuery { if (next()) makeWorkout() else null }
}
}
override fun save(workout: Workout) {
withConnection {
prepareStatement(
prepare(
"""
INSERT INTO workout (id, created_at, device_id, program_id, status, message)
VALUES (?, ?, ?, ? , ?, ?)
INSERT INTO workout (id, created_at, device_id, program_id, status, message, is_test)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status),
message = VALUES(message)
message = VALUES(message),
is_test = VALUES(is_test)
""".trimIndent()
).use { ps ->
ps.setString(1, workout.id)
ps.setTimestamp(2, Timestamp.valueOf(workout.createdAt.atZone(ZoneOffset.UTC).toLocalDateTime()))
ps.setString(3, workout.deviceId)
ps.setString(4, workout.programId ?: "")
ps.setString(5, workout.status.name)
ps.setString(6, workout.message)
ps.execute()
) {
setString(1, workout.id)
setTimestamp(2, Timestamp.valueOf(workout.createdAt.atZone(ZoneOffset.UTC).toLocalDateTime()))
setString(3, workout.deviceId)
setString(4, workout.programId ?: "")
setString(5, workout.status.name)
setString(6, workout.message)
setBoolean(7, workout.test)
execute()
}
}
}
override fun delete(workout: Workout) {
connection.use { conn ->
conn.prepareStatement("DELETE FROM workout WHERE id = ?").use { ps ->
ps.setString(1, workout.id)
ps.execute()
withConnection {
prepare("DELETE FROM workout WHERE id = ?") {
setString(1, workout.id)
execute()
}
}
}
@ -75,5 +73,6 @@ val DataSource.workoutRepo get() = object : WorkoutRepository {
programId = getString("program_id").takeIf { it.isNotBlank() },
status = WorkoutStatus.valueOf(getString("status")),
message = getString("message"),
test = getBoolean("is_test"),
)
}

6
ykonsole-mysql/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/repositories/MySqlWorkoutStateRepository.kt

@ -19,8 +19,8 @@ val DataSource.workoutStateRepo get() = object : WorkoutStateRepository {
setString(1, workoutId)
runQuery {
sequence {
while (next()) yield(
buildList {
while (next()) add(
WorkoutState(
workoutId = getString("workout_id"),
time = Time(getInt("ws_seconds")),
@ -29,7 +29,7 @@ val DataSource.workoutStateRepo get() = object : WorkoutStateRepository {
distance = getIntOrNull("ws_meters")?.let { Distance(it) },
),
)
}.toList()
}
}
}
}

13
ykonsole-mysql/src/main/resources/migrations/tables/workout.xml

@ -42,4 +42,17 @@
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="3" author="stian">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="workout" columnName="is_test"/>
</not>
</preConditions>
<addColumn tableName="workout">
<column name="is_test" type="TINYINT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

6
ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

@ -4,6 +4,7 @@ import kotlinx.coroutines.runBlocking
import net.aiterp.git.ykonsole2.application.createServer
import net.aiterp.git.ykonsole2.application.env.strEnv
import net.aiterp.git.ykonsole2.application.services.DriverStarter
import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus
import net.aiterp.git.ykonsole2.domain.runtime.EventBus
import net.aiterp.git.ykonsole2.infrastructure.IConsole
@ -26,6 +27,11 @@ fun main(): Unit = runBlocking {
val commandBus = CommandBus()
val eventBus = EventBus()
workoutRepo.findActive()?.let { active ->
active.status = WorkoutStatus.Disconnected
workoutRepo.save(active)
}
val iConsole = IConsole()
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo)
val testDriver = TestDriver(secondLength = 1.seconds)

Loading…
Cancel
Save