Move the assertNever utility to a utilities file
This commit is contained in:
parent
dc8cb834e0
commit
08462388ea
15
Cargo.lock
generated
15
Cargo.lock
generated
@ -4394,6 +4394,20 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697"
|
||||
dependencies = [
|
||||
"bitflags 2.6.0",
|
||||
"bytes",
|
||||
"http 1.2.0",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@ -4688,6 +4702,7 @@ dependencies = [
|
||||
"thiserror 2.0.3",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"typeshare",
|
||||
"urlencoding",
|
||||
"uuid 1.11.0",
|
||||
|
@ -24,6 +24,7 @@ serde_json = { version = "*" }
|
||||
thiserror = { version = "2.0.3" }
|
||||
tokio = { version = "1", features = [ "full" ] }
|
||||
tokio-stream = { version = "0.1.16" }
|
||||
tower-http = { version = "0.6.2", features = ["cors"] }
|
||||
typeshare = { version = "1.0.4" }
|
||||
urlencoding = { version = "2.1.3" }
|
||||
uuid = { version = "1.11.0", features = ["v4"] }
|
||||
|
@ -2,9 +2,11 @@ use std::fmt;
|
||||
|
||||
use rusqlite::types::{FromSql, FromSqlResult, ValueRef};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct UserId(String);
|
||||
|
||||
impl UserId {
|
||||
@ -47,6 +49,7 @@ impl fmt::Display for UserId {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
@ -87,6 +90,7 @@ impl fmt::Display for SessionId {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct GameId(String);
|
||||
|
||||
impl GameId {
|
||||
@ -121,6 +125,7 @@ impl FromSql for GameId {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||
#[typeshare]
|
||||
pub struct CharacterId(String);
|
||||
|
||||
impl CharacterId {
|
||||
|
@ -16,7 +16,7 @@ use crate::{
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
|
||||
pub struct HealthCheck {
|
||||
pub ok: bool,
|
||||
pub admin_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn wrap_handler<F, A, Fut>(f: F) -> (StatusCode, Json<Option<A>>)
|
||||
@ -46,10 +46,10 @@ where
|
||||
pub async fn healthcheck(core: Core) -> Vec<u8> {
|
||||
match core.status().await {
|
||||
ResultExt::Ok(s) => serde_json::to_vec(&HealthCheck {
|
||||
ok: s.admin_enabled,
|
||||
admin_enabled: s.admin_enabled,
|
||||
})
|
||||
.unwrap(),
|
||||
ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { ok: false }).unwrap(),
|
||||
ResultExt::Err(_) => serde_json::to_vec(&HealthCheck { admin_enabled: false }).unwrap(),
|
||||
ResultExt::Fatal(err) => panic!("{}", err),
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{header::CONTENT_TYPE, HeaderMap, Method, StatusCode},
|
||||
routing::{get, post, put},
|
||||
Json, Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use crate::{
|
||||
core::Core,
|
||||
@ -21,60 +22,67 @@ pub fn routes(core: Core) -> Router {
|
||||
get({
|
||||
let core = core.clone();
|
||||
move || healthcheck(core)
|
||||
}),
|
||||
})
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_methods([Method::GET])
|
||||
.allow_origin(Any),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/auth",
|
||||
post({
|
||||
let core = core.clone();
|
||||
move |req: Json<AuthRequest>| wrap_handler(|| check_password(core, req))
|
||||
}).layer(
|
||||
CorsLayer::new().allow_methods([Method::POST]).allow_headers([CONTENT_TYPE]).allow_origin(Any),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
// By default, just get the self user.
|
||||
"/api/v1/user",
|
||||
get({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None))
|
||||
})
|
||||
.put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<CreateUserRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| create_user(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/password",
|
||||
put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<SetPasswordRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| set_password(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/:user_id",
|
||||
get({
|
||||
let core = core.clone();
|
||||
move |user_id: Path<UserId>, headers: HeaderMap| {
|
||||
let Path(user_id) = user_id;
|
||||
wrap_handler(|| get_user(core, headers, Some(user_id)))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/games",
|
||||
put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<CreateGameRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| create_game(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
// By default, just get the self user.
|
||||
"/api/v1/user",
|
||||
get({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap| wrap_handler(|| get_user(core, headers, None))
|
||||
})
|
||||
.put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<CreateUserRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| create_user(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/password",
|
||||
put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<SetPasswordRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| set_password(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/:user_id",
|
||||
get({
|
||||
let core = core.clone();
|
||||
move |user_id: Path<UserId>, headers: HeaderMap| {
|
||||
let Path(user_id) = user_id;
|
||||
wrap_handler(|| get_user(core, headers, Some(user_id)))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/games",
|
||||
put({
|
||||
let core = core.clone();
|
||||
move |headers: HeaderMap, req: Json<CreateGameRequest>| {
|
||||
let Json(req) = req;
|
||||
wrap_handler(|| create_game(core, headers, req))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -46,9 +46,9 @@ const App = ({ client }: AppProps) => {
|
||||
console.log("rendering app");
|
||||
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
client.registerWebsocket().then((url) => setWebsocketUrl(url))
|
||||
}, [client]);
|
||||
// useEffect(() => {
|
||||
// client.registerWebsocket().then((url) => setWebsocketUrl(url))
|
||||
// }, [client]);
|
||||
|
||||
let router =
|
||||
createBrowserRouter([
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { SessionId } from "visions-types";
|
||||
|
||||
export type PlayingField = {
|
||||
backgroundImage: string;
|
||||
}
|
||||
@ -64,15 +66,15 @@ export class Client {
|
||||
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify(password) });
|
||||
}
|
||||
|
||||
async auth(username: string, password: string) {
|
||||
async auth(username: string, password: string): Promise<SessionId | undefined> {
|
||||
const url = new URL(this.base);
|
||||
url.pathname = `api/v1/auth`
|
||||
return fetch(url, { method: 'PUT', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) });
|
||||
url.pathname = `/api/v1/auth`
|
||||
return fetch(url, { method: 'POST', headers: [['Content-Type', 'application/json']], body: JSON.stringify({ 'username': username, 'password': password }) });
|
||||
}
|
||||
|
||||
async status() {
|
||||
async health() {
|
||||
const url = new URL(this.base);
|
||||
url.pathname = `/api/v1/status`;
|
||||
url.pathname = `/api/v1/health`;
|
||||
return fetch(url).then((response) => response.json());
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, { useContext } from 'react';
|
||||
import './Tabletop.css';
|
||||
import { RGB } from 'visions-types';
|
||||
import { Rgb } from 'visions-types';
|
||||
|
||||
interface TabletopElementProps {
|
||||
backgroundColor: RGB;
|
||||
backgroundColor: Rgb;
|
||||
backgroundUrl: URL | undefined;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { assertNever } from '.';
|
||||
import { assertNever } from '../../utils';
|
||||
import './Charsheet.css';
|
||||
import { DriveGuage } from './DriveGuage/DriveGuage';
|
||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { assertNever } from '.';
|
||||
import { SimpleGuage } from '../../components/Guages/SimpleGuage';
|
||||
import { Charsheet, Nerve, Cunning, Intuition } from './types';
|
||||
import './CharsheetPanel.css';
|
||||
import classNames from 'classnames';
|
||||
import { assertNever } from '../../utils';
|
||||
|
||||
interface CharsheetPanelProps {
|
||||
sheet: Charsheet;
|
||||
|
@ -1,9 +1,5 @@
|
||||
import { CharsheetElement } from './Charsheet';
|
||||
import { CharsheetPanelElement } from './CharsheetPanel';
|
||||
|
||||
export function assertNever(value: never) {
|
||||
throw new Error("Unexpected value: " + value);
|
||||
}
|
||||
|
||||
export default { CharsheetElement, CharsheetPanelElement };
|
||||
|
||||
|
@ -1,26 +1,53 @@
|
||||
import React, { createContext, PropsWithChildren, useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { Status, Tabletop } from "visions-types";
|
||||
import { Client } from "../../client";
|
||||
import { assertNever } from "../../plugins/Candela";
|
||||
import { assertNever } from "../../utils";
|
||||
|
||||
type AuthState = { type: "NoAdmin" } | { type: "Unauthed" } | { type: "Authed", userid: string };
|
||||
|
||||
type AppState = {
|
||||
auth: AuthState;
|
||||
tabletop: Tabletop;
|
||||
type LoadingState = { type: "Loading" }
|
||||
|
||||
type ReadyState = {
|
||||
type: "Ready",
|
||||
auth: AuthState,
|
||||
tabletop: Tabletop,
|
||||
}
|
||||
|
||||
type AppState = LoadingState | ReadyState
|
||||
|
||||
type Action = { type: "SetAuthState", content: AuthState };
|
||||
|
||||
/*
|
||||
const initialState = (): AppState => (
|
||||
{
|
||||
auth: { type: "NoAdmin" },
|
||||
tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined }
|
||||
}
|
||||
);
|
||||
*/
|
||||
const initialState = (): AppState => ({ type: "Loading" })
|
||||
|
||||
const loadingReducer = (state: LoadingState, action: Action): AppState => {
|
||||
return { type: "Ready", auth: action.content, tabletop: { backgroundColor: { red: 0, green: 0, blue: 0 }, backgroundImage: undefined } }
|
||||
}
|
||||
|
||||
const readyReducer = (state: ReadyState, action: Action): AppState => {
|
||||
return { ...state, auth: action.content }
|
||||
}
|
||||
|
||||
const stateReducer = (state: AppState, action: Action): AppState => {
|
||||
return { ...state, auth: action.content }
|
||||
switch (state.type) {
|
||||
case "Loading": {
|
||||
return loadingReducer(state, action);
|
||||
}
|
||||
case "Ready": {
|
||||
return readyReducer(state, action);
|
||||
}
|
||||
default: {
|
||||
assertNever(state);
|
||||
return { type: "Loading" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StateManager {
|
||||
@ -35,7 +62,7 @@ class StateManager {
|
||||
async status() {
|
||||
if (!this.client || !this.dispatch) return;
|
||||
|
||||
const { admin_enabled } = await this.client.status();
|
||||
const { admin_enabled } = await this.client.health();
|
||||
if (!admin_enabled) {
|
||||
this.dispatch({ type: "SetAuthState", content: { type: "NoAdmin" } });
|
||||
} else {
|
||||
@ -54,9 +81,9 @@ class StateManager {
|
||||
if (!this.client || !this.dispatch) return;
|
||||
|
||||
let resp = await this.client.auth(username, password);
|
||||
let userid = await resp.json();
|
||||
console.log("userid retrieved", userid);
|
||||
this.dispatch({ type: "SetAuthState", content: { type: "Authed", userid } });
|
||||
let sessionid = await resp.json();
|
||||
console.log("sessionid retrieved", sessionid);
|
||||
this.dispatch({ type: "SetAuthState", content: { type: "Authed", sessionid } });
|
||||
}
|
||||
}
|
||||
|
||||
|
4
visions/ui/src/utils.ts
Normal file
4
visions/ui/src/utils.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function assertNever(value: never) {
|
||||
throw new Error("Unexpected value: " + value);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren, useContext, useState } from 'react';
|
||||
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
||||
import { assertNever } from '../../plugins/Candela';
|
||||
import { assertNever } from '../../utils';
|
||||
import './Authentication.css';
|
||||
|
||||
interface AuthenticationProps {
|
||||
@ -17,35 +17,48 @@ export const Authentication = ({ onAdminPassword, onAuth, children }: PropsWithC
|
||||
let [pwField, setPwField] = useState<string>("");
|
||||
let [state, _] = useContext(StateContext);
|
||||
|
||||
switch (state.auth.type) {
|
||||
case "NoAdmin": {
|
||||
return <div className="auth">
|
||||
<div className="card">
|
||||
<h1> Welcome to your new Visions VTT Instance </h1>
|
||||
<p> Set your admin password: </p>
|
||||
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
|
||||
</div>
|
||||
</div>;
|
||||
switch (state.type) {
|
||||
case "Loading": {
|
||||
return <div>Loading</div>
|
||||
}
|
||||
case "Unauthed": {
|
||||
return <div className="auth card">
|
||||
<div className="card">
|
||||
<h1> Welcome to Visions VTT </h1>
|
||||
<div className="auth__input-line">
|
||||
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
|
||||
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
case "Authed": {
|
||||
return <div> {children} </div>;
|
||||
case "Ready": {
|
||||
switch (state.auth.type) {
|
||||
case "NoAdmin": {
|
||||
return <div className="auth">
|
||||
<div className="card">
|
||||
<h1> Welcome to your new Visions VTT Instance </h1>
|
||||
<p> Set your admin password: </p>
|
||||
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||
<input type="submit" value="Submit" onClick={() => onAdminPassword(pwField)} />
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
case "Unauthed": {
|
||||
return <div className="auth card">
|
||||
<div className="card">
|
||||
<h1> Welcome to Visions VTT </h1>
|
||||
<div className="auth__input-line">
|
||||
<input type="text" placeholder="Username" onChange={(evt) => setUserField(evt.target.value)} />
|
||||
<input type="password" placeholder="Password" onChange={(evt) => setPwField(evt.target.value)} />
|
||||
<input type="submit" value="Sign in" onClick={() => onAuth(userField, pwField)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
case "Authed": {
|
||||
return <div> {children} </div>;
|
||||
}
|
||||
default: {
|
||||
assertNever(state.auth);
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
default: {
|
||||
assertNever(state.auth);
|
||||
return <div></div>;
|
||||
assertNever(state);
|
||||
return <div></div>
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||
import { ThumbnailElement } from '../../components/Thumbnail/Thumbnail';
|
||||
import { WebsocketContext } from '../../components/WebsocketProvider';
|
||||
import './GmView.css';
|
||||
import { assertNever } from '../../utils';
|
||||
|
||||
interface GmViewProps {
|
||||
client: Client
|
||||
@ -18,12 +19,21 @@ export const GmView = ({ client }: GmViewProps) => {
|
||||
client.availableImages().then((images) => setImages(images));
|
||||
}, [client]);
|
||||
|
||||
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
||||
return (<div className="gm-view">
|
||||
<div>
|
||||
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
||||
</div>
|
||||
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
||||
</div>)
|
||||
switch (state.type) {
|
||||
case "Loading": return <div></div>;
|
||||
case "Ready": {
|
||||
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
||||
return (<div className="gm-view">
|
||||
<div>
|
||||
{images.map((imageName) => <ThumbnailElement id={imageName} url={client.imageUrl(imageName)} onclick={() => { client.setBackgroundImage(imageName); }} />)}
|
||||
</div>
|
||||
<TabletopElement backgroundColor={state.tabletop.backgroundColor} backgroundUrl={backgroundUrl} />
|
||||
</div>)
|
||||
}
|
||||
default: {
|
||||
assertNever(state);
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { Client } from '../../client';
|
||||
import { TabletopElement } from '../../components/Tabletop/Tabletop';
|
||||
import Candela from '../../plugins/Candela';
|
||||
import { StateContext } from '../../providers/StateProvider/StateProvider';
|
||||
import { assertNever } from '../../utils';
|
||||
|
||||
const TEST_CHARSHEET_UUID = "12df9c09-1f2f-4147-8eda-a97bd2a7a803";
|
||||
|
||||
@ -26,14 +27,23 @@ export const PlayerView = ({ client }: PlayerViewProps) => {
|
||||
[client, setCharsheet]
|
||||
);
|
||||
|
||||
const backgroundColor = state.tabletop.backgroundColor;
|
||||
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
|
||||
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
||||
switch (state.type) {
|
||||
case "Loading": return <div></div>;
|
||||
case "Ready": {
|
||||
const backgroundColor = state.tabletop.backgroundColor;
|
||||
const tabletopColorStyle = `rgb(${backgroundColor.red}, ${backgroundColor.green}, ${backgroundColor.blue})`;
|
||||
const backgroundUrl = state.tabletop.backgroundImage ? client.imageUrl(state.tabletop.backgroundImage) : undefined;
|
||||
|
||||
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
||||
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
|
||||
<div className="player-view__right-panel">
|
||||
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
|
||||
</div>)
|
||||
return (<div className="player-view" style={{ backgroundColor: tabletopColorStyle }}>
|
||||
<div className="player-view__middle-panel"> <TabletopElement backgroundColor={backgroundColor} backgroundUrl={backgroundUrl} /> </div>
|
||||
<div className="player-view__right-panel">
|
||||
{charsheet ? <Candela.CharsheetPanelElement sheet={charsheet} /> : <div> </div>}</div>
|
||||
</div>)
|
||||
}
|
||||
default: {
|
||||
assertNever(state);
|
||||
return <div></div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user