Compare commits

...

3 Commits

16 changed files with 416 additions and 2815 deletions

11
Cargo.lock generated
View File

@ -522,7 +522,7 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
name = "changeset" name = "changeset"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"uuid 1.10.0", "uuid 0.8.2",
] ]
[[package]] [[package]]
@ -2357,7 +2357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-targets 0.48.5", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -4815,9 +4815,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.10.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
dependencies = [ dependencies = [
"getrandom", "getrandom",
] ]
@ -4857,12 +4857,15 @@ name = "visions"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"authdb", "authdb",
"futures",
"http 1.1.0", "http 1.1.0",
"mime 0.3.17", "mime 0.3.17",
"mime_guess 2.0.5", "mime_guess 2.0.5",
"serde 1.0.210", "serde 1.0.210",
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-stream",
"uuid 1.11.0",
"warp", "warp",
] ]

View File

@ -14,3 +14,6 @@ tokio = { version = "1", features = [ "full" ] }
warp = { version = "0.3" } warp = { version = "0.3" }
mime_guess = "2.0.5" mime_guess = "2.0.5"
mime = "0.3.17" mime = "0.3.17"
uuid = { version = "1.11.0", features = ["v4"] }
futures = "0.3.31"
tokio-stream = "0.1.16"

View File

@ -1,17 +1,25 @@
use std::{ use std::{
collections::HashMap,
io::Read, io::Read,
path::PathBuf, path::PathBuf,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
}; };
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use uuid::Uuid;
use crate::types::Message;
#[derive(Debug)] #[derive(Debug)]
pub enum AppError { struct WebsocketClient {
JsonError(serde_json::Error), sender: Option<UnboundedSender<Message>>,
} }
#[derive(Clone, Debug)] #[derive(Debug)]
pub struct AppState { pub struct AppState {
pub image_base: PathBuf, pub image_base: PathBuf,
pub clients: HashMap<String, WebsocketClient>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -21,9 +29,40 @@ impl Core {
pub fn new() -> Self { pub fn new() -> Self {
Self(Arc::new(RwLock::new(AppState { Self(Arc::new(RwLock::new(AppState {
image_base: PathBuf::from("/home/savanni/Pictures"), image_base: PathBuf::from("/home/savanni/Pictures"),
clients: HashMap::new(),
}))) })))
} }
pub fn register_client(&self) -> String {
let mut state = self.0.write().unwrap();
let uuid = Uuid::new_v4().simple().to_string();
let client = WebsocketClient { sender: None };
state.clients.insert(uuid.clone(), client);
uuid
}
pub fn unregister_client(&self, client_id: String) {
let mut state = self.0.write().unwrap();
let _ = state.clients.remove(&client_id);
}
pub fn connect_client(&self, client_id: String) -> UnboundedReceiver<Message> {
let mut state = self.0.write().unwrap();
match state.clients.get_mut(&client_id) {
Some(client) => {
let (tx, rx) = unbounded_channel();
client.sender = Some(tx);
rx
}
None => {
unimplemented!();
}
}
}
pub fn get_file(&self, file_name: String) -> Vec<u8> { pub fn get_file(&self, file_name: String) -> Vec<u8> {
let mut full_path = self.0.read().unwrap().image_base.clone(); let mut full_path = self.0.read().unwrap().image_base.clone();
full_path.push(&file_name); full_path.push(&file_name);
@ -55,4 +94,14 @@ impl Core {
}) })
.collect() .collect()
} }
pub fn publish(&self, message: Message) {
let state = self.0.read().unwrap();
state.clients.values().for_each(|client| {
if let Some(ref sender) = client.sender {
let _ = sender.send(message.clone());
}
});
}
} }

View File

@ -1,11 +1,13 @@
use std::{io::Read, path::PathBuf}; use std::{pin::Pin, time::Duration};
use authdb::{AuthDB, AuthToken}; use futures::{SinkExt, StreamExt};
use http::{response::Response, status::StatusCode, Error};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::UnboundedReceiverStream;
use warp::{http::Response, reply::Reply, ws::Message};
use crate::core::Core; use crate::{core::Core, types::PlayArea};
/*
pub async fn handle_auth( pub async fn handle_auth(
auth_ctx: &AuthDB, auth_ctx: &AuthDB,
auth_token: AuthToken, auth_token: AuthToken,
@ -27,18 +29,91 @@ pub async fn handle_auth(
.body("".to_owned()), .body("".to_owned()),
} }
} }
*/
pub async fn handle_playing_field() -> impl Reply {
Response::builder()
.header("application-type", "application/json")
.body(
serde_json::to_string(&PlayArea {
background_image: "tower-in-mist.jpg".to_owned(),
})
.unwrap(),
)
.unwrap()
}
pub async fn handle_file(core: Core, file_name: String) -> impl Reply {
let mimetype = mime_guess::from_path(&file_name).first().unwrap();
let bytes = core.get_file(file_name);
Response::builder()
.header("application-type", mimetype.to_string())
.body(bytes)
.unwrap()
}
pub async fn handle_available_images(core: Core) -> impl Reply {
Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json")
.body(serde_json::to_string(&core.available_images()).unwrap())
.unwrap()
}
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct PlayArea { pub struct RegisterRequest {}
pub background_image: PathBuf,
#[derive(Deserialize, Serialize)]
pub struct RegisterResponse {
url: String,
} }
pub fn handle_playing_field() -> PlayArea { pub async fn handle_register_client(core: Core, request: RegisterRequest) -> impl Reply {
PlayArea { let client_id = core.register_client();
background_image: PathBuf::from("tower-in-mist.jpg"),
} Response::builder()
.header("Access-Control-Allow-Origin", "*")
.header("Content-Type", "application/json")
.body(
serde_json::to_string(&RegisterResponse {
url: format!("ws://127.0.0.1:8001/ws/{}", client_id),
})
.unwrap(),
)
.unwrap()
} }
pub fn handle_file(core: Core, file_name: String) -> Vec<u8> { pub async fn handle_unregister_client(core: Core, client_id: String) -> impl Reply {
core.get_file(file_name) core.unregister_client(client_id);
warp::reply::reply()
}
pub async fn handle_connect_websocket(
core: Core,
ws: warp::ws::Ws,
client_id: String,
) -> impl Reply {
println!("handle_connect_websocket: {}", client_id);
ws.on_upgrade(move |socket| {
let core = core.clone();
async move {
let (mut ws_sender, mut ws_recv) = socket.split();
let mut receiver = core.connect_client(client_id);
tokio::task::spawn(async move {
let _ = ws_sender
.send(Message::text(
serde_json::to_string(&crate::types::Message::Count(0)).unwrap(),
))
.await;
while let Some(msg) = receiver.recv().await {
println!("Relaying message: {:?}", msg);
let _ = ws_sender
.send(Message::text(serde_json::to_string(&msg).unwrap()))
.await;
}
});
}
})
} }

View File

@ -1,5 +1,8 @@
use authdb::AuthError; use authdb::AuthError;
use handlers::{handle_file, handle_playing_field}; use handlers::{
handle_available_images, handle_connect_websocket, handle_file, handle_playing_field,
handle_register_client, handle_unregister_client, RegisterRequest,
};
use std::{ use std::{
convert::Infallible, convert::Infallible,
net::{IpAddr, Ipv4Addr, SocketAddr}, net::{IpAddr, Ipv4Addr, SocketAddr},
@ -16,7 +19,7 @@ mod core;
mod handlers; mod handlers;
// use handlers::handle_auth; // use handlers::handle_auth;
mod routes; mod types;
#[derive(Debug)] #[derive(Debug)]
struct Unauthorized; struct Unauthorized;
@ -95,37 +98,61 @@ async fn handle_rejection(err: warp::Rejection) -> Result<impl Reply, Infallible
pub async fn main() { pub async fn main() {
let core = core::Core::new(); let core = core::Core::new();
let route_playing_field = let route_playing_field = warp::path!("api" / "v1" / "field").then(|| handle_playing_field());
warp::path!("api" / "v1" / "field").map(move || warp::reply::json(&handle_playing_field()));
let route_image = warp::path!("api" / "v1" / "image" / String) let route_image = warp::path!("api" / "v1" / "image" / String)
.and(warp::get()) .and(warp::get())
.then({
let core = core.clone();
move |file_name| handle_file(core.clone(), file_name)
});
let route_available_images = warp::path!("api" / "v1" / "image").and(warp::get()).then({
let core = core.clone();
move || handle_available_images(core.clone())
});
let route_register_client = warp::path!("api" / "v1" / "client")
.and(warp::post())
.then({
let core = core.clone();
move || handle_register_client(core.clone(), RegisterRequest {})
});
let route_unregister_client = warp::path!("api" / "v1" / "client" / String)
.and(warp::delete())
.then({
let core = core.clone();
move |client_id| handle_unregister_client(core.clone(), client_id)
});
let route_websocket = warp::path("ws")
.and(warp::ws())
.and(warp::path::param())
.then({
let core = core.clone();
move |ws, client_id| handle_connect_websocket(core.clone(), ws, client_id)
});
let route_publish = warp::path!("api" / "v1" / "message")
.and(warp::post())
.and(warp::body::json())
.map({ .map({
let core = core.clone(); let core = core.clone();
move |file_name| { move |body| {
let core = core.clone(); println!("route_publish: {:?}", body);
let mimetype = mime_guess::from_path(&file_name).first().unwrap(); core.publish(body);
let bytes = handle_file(core, file_name); warp::reply()
Response::builder()
.header("application-type", mimetype.to_string())
.body(bytes)
.unwrap()
} }
}); });
let route_available_images = warp::path!("api" / "v1" / "image").and(warp::get()).map({ let filter = route_register_client
let core = core.clone(); .or(route_unregister_client)
move || { .or(route_websocket)
let core = core.clone(); .or(route_playing_field)
Response::builder()
.header("Access-Control-Allow-Origin", "*")
.body(serde_json::to_string(&core.available_images()).unwrap())
}
});
let filter = route_playing_field
.or(route_image) .or(route_image)
.or(route_available_images) .or(route_available_images)
.or(route_publish)
.recover(handle_rejection); .recover(handle_rejection);
let server = warp::serve(filter); let server = warp::serve(filter);

View File

@ -1,9 +0,0 @@
use crate::{core::Core, handlers::handle_playing_field};
use warp::{reply::Json, Filter};
pub fn route_playing_field(
_app: Core,
) -> impl Filter<Extract = (Json,), Error = warp::Rejection> + Clone {
warp::path!("api" / "v1" / "field").map(move || warp::reply::json(&handle_playing_field()))
}

View File

@ -0,0 +1,20 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PlayArea {
pub background_image: String,
}
#[derive(Debug)]
pub enum AppError {
JsonError(serde_json::Error),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum Message {
Count(u32),
// PlayArea(PlayArea),
}

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,14 @@
"@types/node": "^16.18.119", "@types/node": "^16.18.119",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router": "^6.28.0",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-use-websocket": "^4.11.1",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },

View File

@ -1,14 +1,34 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import logo from './logo.svg'; import logo from './logo.svg';
import './App.css'; import './App.css';
import { PlayingFieldComponent } from './components/PlayingField/PlayingField'; import { WebsocketPlayingFieldComponent } from './components/PlayingField/PlayingField';
import { Client } from './client'; import { Client } from './client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { GmPlayingFieldComponent } from './components/GmPlayingField/GmPlayingField';
const App = () => { const App = () => {
let client = new Client(); console.log("rendering app");
const client = new Client();
const [websocketUrl, setWebsocketUrl] = useState<string | undefined>(undefined);
useEffect(() => {
client.registerWebsocket().then((url) => setWebsocketUrl(url));
}, [client]);
let router =
createBrowserRouter([
{
path: "/gm",
element: <GmPlayingFieldComponent client={client} />
},
{
path: "/",
element: websocketUrl ? <WebsocketPlayingFieldComponent websocketUrl={websocketUrl} /> : <div> </div>
}
]);
return ( return (
<div className="App"> <div className="App">
<PlayingFieldComponent client={client} /> <RouterProvider router={router} />
</div> </div>
); );
} }

View File

@ -9,6 +9,20 @@ export class Client {
this.base = new URL("http://localhost:8001"); this.base = new URL("http://localhost:8001");
} }
registerWebsocket() {
const url = new URL(this.base);
url.pathname = `api/v1/client`;
return fetch(url, { method: 'POST' }).then((response) => response.json()).then((ws) => ws.url);
}
/*
unregisterWebsocket() {
const url = new URL(this.base);
url.pathname = `api/v1/client`;
return fetch(url, { method: 'POST' }).then((response => response.json()));
}
*/
imageUrl(imageId: string) { imageUrl(imageId: string) {
const url = new URL(this.base); const url = new URL(this.base);
url.pathname = `/api/v1/image/${imageId}`; url.pathname = `/api/v1/image/${imageId}`;

View File

@ -0,0 +1,29 @@
import React, { useEffect, useState } from 'react';
import { Client, PlayingField } from '../../client';
import { ThumbnailComponent } from '../Thumbnail/Thumbnail';
import './GmPlayingField.css';
interface GmPlayingFieldProps {
client: Client
}
export const GmPlayingFieldComponent = ({ client }: GmPlayingFieldProps) => {
const [field, setField] = useState<PlayingField | undefined>(undefined);
useEffect(() => {
client.playingField().then((field) => setField(field));
}, [client]);
const [images, setImages] = useState<string[]>([]);
useEffect(() => {
client.availableImages().then((images) => setImages(images));
}, [client]);
const backgroundUrl = field && client.imageUrl(field.backgroundImage);
return (<div className="playing-field">
<div>
{images.map((imageName) => <ThumbnailComponent client={client} imageId={imageName} />)}
</div>
<div className="playing-field__background"> {backgroundUrl && <img src={backgroundUrl.toString()} alt="playing field" />} </div>
</div>)
}

View File

@ -1,17 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { Client, PlayingField } from '../../client'; import { Client, PlayingField } from '../../client';
import { ThumbnailComponent } from '../Thumbnail'; import { ThumbnailComponent } from '../Thumbnail/Thumbnail';
import './PlayingField.css'; import './PlayingField.css';
import useWebSocket, { ReadyState } from 'react-use-websocket';
interface PlayingFieldProps { /*
client: Client
}
export const PlayingFieldComponent = ({ client }: PlayingFieldProps) => { export const PlayingFieldComponent = ({ client }: PlayingFieldProps) => {
const [socketUrl, setSocketUrl] = useState<string | undefined>(undefined);
const [field, setField] = useState<PlayingField | undefined>(undefined); const [field, setField] = useState<PlayingField | undefined>(undefined);
useEffect(() => {
client.playingField().then((field) => setField(field));
}, [client]);
const [images, setImages] = useState<string[]>([]); const [images, setImages] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@ -27,3 +23,48 @@ export const PlayingFieldComponent = ({ client }: PlayingFieldProps) => {
<div> Right Panel </div> <div> Right Panel </div>
</div>) </div>)
} }
*/
interface WebsocketPlayingFieldProps {
websocketUrl: string;
}
export const WebsocketPlayingFieldComponent = ({ websocketUrl }: WebsocketPlayingFieldProps) => {
const { lastMessage, readyState } = useWebSocket(websocketUrl);
useEffect(() => {
if (lastMessage !== null) {
console.log("last message: ", lastMessage);
}
}, [lastMessage]);
const connectionStatus = {
[ReadyState.CONNECTING]: 'Connecting',
[ReadyState.OPEN]: 'Open',
[ReadyState.CLOSING]: 'Closing',
[ReadyState.CLOSED]: 'Closed',
[ReadyState.UNINSTANTIATED]: 'Uninstantiated',
}[readyState];
return <PlayingFieldComponent backgroundUrl={undefined} connectionStatus={connectionStatus} />;
}
interface PlayingFieldProps {
backgroundUrl: string | undefined;
connectionStatus: string;
}
export const PlayingFieldComponent = ({ backgroundUrl, connectionStatus }: PlayingFieldProps) => {
if (backgroundUrl) {
return (<div className="playing-field">
<div className="playing-field__background"> {backgroundUrl && <img src={backgroundUrl.toString()} alt="playing field" />} </div>
<div> {connectionStatus} </div>
</div>)
} else {
return (<div className="playing-field">
<div> </div>
<div> {connectionStatus} </div>
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Client } from '../client'; import { Client } from '../../client';
import './Thumbnail.css'; import './Thumbnail.css';
interface ThumbnailProps { interface ThumbnailProps {