blogsite initial commit
|
@ -0,0 +1,4 @@
|
|||
/blog/dist/
|
||||
/target/
|
||||
.vscode
|
||||
build-and-send.fish
|
|
@ -0,0 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["blog", "blog-macros", "scratchpad", "blog-server"]
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "blog-macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "extra-traits"] }
|
|
@ -0,0 +1,238 @@
|
|||
use core::error::Error;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Read},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use proc_macro2::Span;
|
||||
use quote::{ToTokens, quote};
|
||||
use syn::{parse::Parse, parse_macro_input};
|
||||
|
||||
struct IncludePath {
|
||||
dir_path: PathBuf,
|
||||
modules: Vec<syn::Ident>,
|
||||
}
|
||||
|
||||
fn display_err(span: Span, err: impl Error) -> syn::Error {
|
||||
syn::Error::new(span, err.to_string().as_str())
|
||||
}
|
||||
|
||||
fn read_modules(span: Span, path: &Path) -> syn::Result<Vec<syn::Ident>> {
|
||||
let read_dir = std::fs::read_dir(path).map_err(|err| display_err(span, err))?;
|
||||
|
||||
Ok(read_dir
|
||||
.map(|file| file.map_err(|err| display_err(span, err)))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|file| file.file_type().map(|ft| ft.is_file()).unwrap_or_default())
|
||||
.filter_map(|file| {
|
||||
file.file_name()
|
||||
.to_string_lossy()
|
||||
.strip_suffix(".rs")
|
||||
.map(|f| f.to_string())
|
||||
})
|
||||
.map(|module_name| syn::Ident::new(&module_name, span))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
impl Parse for IncludePath {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let directory = input.parse::<syn::LitStr>()?;
|
||||
|
||||
let dir_path = std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(directory.value())
|
||||
.to_path_buf();
|
||||
|
||||
let modules = read_modules(directory.span(), &dir_path)?;
|
||||
|
||||
Ok(Self { modules, dir_path })
|
||||
}
|
||||
}
|
||||
impl IncludePath {
|
||||
fn posts_tokens(&self) -> proc_macro2::TokenStream {
|
||||
let mod_decl = self.modules.iter().map(|module| {
|
||||
let path = self.dir_path.join(format!("{module}.rs"));
|
||||
let path = path.to_str().unwrap();
|
||||
quote! {
|
||||
mod #module {
|
||||
include!(#path);
|
||||
}}
|
||||
});
|
||||
let posts = self.modules.iter().map(|module| {
|
||||
quote! {
|
||||
#module::POST
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
#(#mod_decl)*
|
||||
|
||||
pub const POSTS: &[Post] = &[#(#posts),*];
|
||||
}
|
||||
}
|
||||
fn normal_tokens(&self) -> proc_macro2::TokenStream {
|
||||
let mod_decl = self.modules.iter().map(|module| {
|
||||
quote! {mod #module;}
|
||||
});
|
||||
let pub_use = self.modules.iter().map(|module| {
|
||||
quote! {
|
||||
#module::*
|
||||
}
|
||||
});
|
||||
|
||||
quote! {
|
||||
#(#mod_decl)*
|
||||
pub use {#(#pub_use),*};
|
||||
}
|
||||
}
|
||||
}
|
||||
enum IncludeOutput {
|
||||
Normal(IncludePath),
|
||||
Posts(IncludePath),
|
||||
}
|
||||
|
||||
impl ToTokens for IncludeOutput {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
tokens.extend(match self {
|
||||
IncludeOutput::Normal(include_path) => include_path.normal_tokens(),
|
||||
IncludeOutput::Posts(include_path) => include_path.posts_tokens(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn posts(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let pubuse = IncludeOutput::Posts(parse_macro_input!(input as IncludePath));
|
||||
quote! {#pubuse}.into()
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn include_path(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let incl_path = IncludeOutput::Normal(parse_macro_input!(input as IncludePath));
|
||||
quote! {#incl_path}.into()
|
||||
}
|
||||
|
||||
struct FileWithPath {
|
||||
// bytes: Vec<u8>,
|
||||
include: proc_macro2::TokenStream,
|
||||
rel_path: String,
|
||||
}
|
||||
impl FileWithPath {
|
||||
fn from_path(path: impl AsRef<Path>, origin_path: impl AsRef<Path>) -> Result<Self, io::Error> {
|
||||
let abs_path = path
|
||||
.as_ref()
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|t| t.to_string()))
|
||||
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))?;
|
||||
let include = quote! {
|
||||
include_bytes!(#abs_path)
|
||||
};
|
||||
let origin_abs = origin_path.as_ref().canonicalize().and_then(|p| {
|
||||
p.to_str()
|
||||
.ok_or(io::Error::new(io::ErrorKind::InvalidData, "invalid path"))
|
||||
.map(|t| t.to_string())
|
||||
})?;
|
||||
let rel_path = abs_path.replace(&origin_abs, "");
|
||||
|
||||
Ok(Self { include, rel_path })
|
||||
}
|
||||
}
|
||||
|
||||
fn find_matching_files_recursive<P, F, D>(
|
||||
p: P,
|
||||
include_in_rerun: F,
|
||||
directory_filter: D,
|
||||
out: &mut Vec<FileWithPath>,
|
||||
origin_path: &PathBuf,
|
||||
) -> Result<(), io::Error>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
F: Fn(&str) -> bool + Copy,
|
||||
D: Fn(&str) -> bool + Copy,
|
||||
{
|
||||
for item in std::fs::read_dir(p.as_ref())? {
|
||||
let item = item?;
|
||||
if item.file_type()?.is_dir() {
|
||||
if directory_filter(item.file_name().to_str().ok_or(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"file has no name",
|
||||
))?) {
|
||||
find_matching_files_recursive(
|
||||
item.path(),
|
||||
include_in_rerun,
|
||||
directory_filter,
|
||||
out,
|
||||
origin_path,
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(file_name) = item.file_name().to_str() {
|
||||
if include_in_rerun(file_name) {
|
||||
out.push(FileWithPath::from_path(item.path(), origin_path)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct IncludeDist {
|
||||
name: syn::Ident,
|
||||
files: Vec<FileWithPath>,
|
||||
}
|
||||
|
||||
impl IncludeDist {
|
||||
fn read_dist_path(
|
||||
name: syn::Ident,
|
||||
path_span: Span,
|
||||
path: &Path,
|
||||
origin_path: &PathBuf,
|
||||
) -> syn::Result<Self> {
|
||||
let mut files = Vec::new();
|
||||
find_matching_files_recursive(path, |_| true, |_| true, &mut files, origin_path)
|
||||
.map_err(|err| syn::Error::new(path_span, err.to_string()))?;
|
||||
Ok(Self { name, files })
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for IncludeDist {
|
||||
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let name = input.parse::<syn::Ident>()?;
|
||||
input.parse::<syn::Token![,]>()?;
|
||||
let directory = input.parse::<syn::LitStr>()?;
|
||||
|
||||
let dir_path = std::env::current_dir()
|
||||
.unwrap()
|
||||
.join(directory.value())
|
||||
.to_path_buf();
|
||||
|
||||
Self::read_dist_path(name, directory.span(), &dir_path, &dir_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToTokens for IncludeDist {
|
||||
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||
// std::collections::HashMap
|
||||
let array_contents = self.files.iter().map(|file| {
|
||||
let FileWithPath { include, rel_path } = file;
|
||||
|
||||
quote! {
|
||||
(#rel_path, #include)
|
||||
}
|
||||
});
|
||||
let name = &self.name;
|
||||
tokens.extend(quote! {
|
||||
pub const #name: &[(&'static str, &'static [u8])] = &[#(#array_contents),*];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn include_dist(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
|
||||
let incl_dist = parse_macro_input!(input as IncludeDist);
|
||||
quote! {#incl_dist}.into()
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "blog-server"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tokio = { version = "1.44", features = ["full"] }
|
||||
log = { version = "0.4" }
|
||||
pretty_env_logger = { version = "0.5" }
|
||||
futures = "0.3.31"
|
||||
anyhow = { version = "1" }
|
||||
scratchpad = { path = "../scratchpad" }
|
||||
# tower-http = { version = "0.6", features = ["fs"] }
|
||||
# tower-service = "0.3.3"
|
||||
blog-macros = { path = "../blog-macros" }
|
||||
mime-sniffer = { version = "0.1" }
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=blog
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=blog
|
||||
Group=blog
|
||||
|
||||
WorkingDirectory=/home/blog
|
||||
Environment=RUST_LOG=info
|
||||
Environment=PORT=3024
|
||||
ExecStart=/home/blog/blog-server
|
||||
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -0,0 +1,49 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use scratchpad::game::{GameError, GameState, TileType};
|
||||
use tokio::sync::broadcast::Sender;
|
||||
|
||||
pub struct GameStateSender {
|
||||
game_state: GameState,
|
||||
sender: Option<Sender<Arc<[TileType]>>>,
|
||||
}
|
||||
|
||||
impl GameStateSender {
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
game_state: GameState::blank(),
|
||||
sender: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sender(&mut self, sender: Sender<Arc<[TileType]>>) {
|
||||
self.sender.replace(sender);
|
||||
}
|
||||
|
||||
pub fn get_board_values(&self) -> scratchpad::game::BoardValues {
|
||||
self.game_state.get_board_values()
|
||||
}
|
||||
|
||||
pub fn replace_game_state(&mut self, bytes: &[u8]) -> Result<(), GameError> {
|
||||
self.game_state.replace_game_state(bytes)
|
||||
}
|
||||
|
||||
pub async fn set_tile(&mut self, row: u32, col: u32, value: TileType) -> Result<(), GameError> {
|
||||
self.game_state.set_tile(row, col, value)?;
|
||||
let sender = match self.sender.as_ref() {
|
||||
Some(s) => s,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let new_state = self
|
||||
.game_state
|
||||
.get_board_values()
|
||||
.iter()
|
||||
.flat_map(|tiles| tiles.iter())
|
||||
.copied()
|
||||
.collect::<Arc<_>>();
|
||||
if let Err(err) = sender.send(new_state) {
|
||||
log::error!("sending from set_tile: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,282 @@
|
|||
mod game;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Bytes,
|
||||
extract::{
|
||||
ConnectInfo, State,
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
},
|
||||
http::{Request, header},
|
||||
response::IntoResponse,
|
||||
routing::{any, get},
|
||||
};
|
||||
use core::{net::SocketAddr, str::FromStr};
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use game::GameStateSender;
|
||||
use scratchpad::{ServerMessage, game::TileType};
|
||||
use std::{env, io::ErrorKind, sync::Arc};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::{
|
||||
Mutex,
|
||||
broadcast::{Receiver, error::RecvError},
|
||||
},
|
||||
};
|
||||
|
||||
static GAME_STATE: Mutex<GameStateSender> = Mutex::const_new(GameStateSender::new());
|
||||
|
||||
struct AppState {
|
||||
recv: Receiver<Arc<[TileType]>>,
|
||||
}
|
||||
impl Clone for AppState {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
recv: self.recv.resubscribe(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_PORT: u16 = 8080;
|
||||
const DEFAULT_HOST: &str = "127.0.0.1";
|
||||
const GAME_STATE_SAVE_FILE_NAME: &str = "/tmp/scratchpad.state";
|
||||
|
||||
async fn game_saver(mut recv: Receiver<Arc<[TileType]>>) {
|
||||
loop {
|
||||
let board = match recv.recv().await {
|
||||
Ok(board) => board,
|
||||
Err(RecvError::Closed) => {
|
||||
panic!("save channel closed")
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("game_saver: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut save_file = match tokio::fs::File::create(GAME_STATE_SAVE_FILE_NAME).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => {
|
||||
log::error!("opening save file: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let board_bytes = board.iter().map(|t| *t as u8).collect::<Box<_>>();
|
||||
if let Err(err) = save_file.write_all(&board_bytes).await {
|
||||
core::mem::drop(save_file);
|
||||
log::error!("writing save file: {err}");
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = save_file.flush().await {
|
||||
core::mem::drop(save_file);
|
||||
log::error!("flushing save file: {err}");
|
||||
continue;
|
||||
}
|
||||
core::mem::drop(save_file);
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_game_save_or_give_up() {
|
||||
let mut game_save = Vec::new();
|
||||
let save_ref = game_save.as_mut();
|
||||
|
||||
let save_slice = match tokio::fs::File::open(GAME_STATE_SAVE_FILE_NAME)
|
||||
.and_then(|mut f| async move { f.read_to_end(save_ref).await })
|
||||
.await
|
||||
{
|
||||
Ok(read) => &game_save[..read],
|
||||
Err(err) => {
|
||||
log::error!("loading game save: {err}; using blank game state");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = GAME_STATE.lock().await.replace_game_state(save_slice) {
|
||||
log::error!("restoring game save: {err}; using blank game state")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
pretty_env_logger::init();
|
||||
|
||||
let (sender, recv) = tokio::sync::broadcast::channel::<Arc<[TileType]>>(512);
|
||||
restore_game_save_or_give_up().await;
|
||||
GAME_STATE.lock().await.set_sender(sender);
|
||||
let recv_saver = recv.resubscribe();
|
||||
let saver_handle = tokio::spawn(async move { game_saver(recv_saver).await });
|
||||
|
||||
let host = env::var("HOST").unwrap_or(DEFAULT_HOST.to_string());
|
||||
let port = env::var("PORT")
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
.map(|port_str| {
|
||||
port_str
|
||||
.parse::<u16>()
|
||||
.unwrap_or_else(|err| panic!("parse PORT={port_str} failed: {err}"))
|
||||
})
|
||||
.unwrap_or(DEFAULT_PORT);
|
||||
let listen_addr =
|
||||
SocketAddr::from_str(format!("{host}:{port}").as_str()).expect("invalid host/port");
|
||||
|
||||
let state = AppState { recv };
|
||||
|
||||
let app = Router::new()
|
||||
.route("/scratchpad", any(ws_handler))
|
||||
.with_state(state)
|
||||
.fallback(get(handle_http_static));
|
||||
let listener = tokio::net::TcpListener::bind(listen_addr).await.unwrap();
|
||||
log::info!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
saver_handle.await.unwrap();
|
||||
}
|
||||
|
||||
async fn handle_http_static(req: Request<axum::body::Body>) -> impl IntoResponse {
|
||||
use mime_sniffer::MimeTypeSniffer;
|
||||
const INDEX_FILE: &[u8] = include_bytes!("../../blog/dist/index.html");
|
||||
let path = req.uri().path();
|
||||
log::info!("path: {path}");
|
||||
|
||||
blog_macros::include_dist!(DIST_FILES, "blog/dist");
|
||||
|
||||
let file = if let Some(file) = DIST_FILES.iter().find_map(|(file_path, file)| {
|
||||
if *file_path == path {
|
||||
Some(*file)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
file
|
||||
} else {
|
||||
return (
|
||||
[(header::CONTENT_TYPE, "text/html".to_string())],
|
||||
INDEX_FILE,
|
||||
);
|
||||
};
|
||||
|
||||
let mime = if path.ends_with(".js") {
|
||||
"text/javascript".to_string()
|
||||
} else if path.ends_with(".css") {
|
||||
"text/css".to_string()
|
||||
} else if path.ends_with(".wasm") {
|
||||
"application/wasm".to_string()
|
||||
} else {
|
||||
file.sniff_mime_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
};
|
||||
|
||||
([(header::CONTENT_TYPE, mime)], file)
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
log::info!("{addr} connected.");
|
||||
// finalize the upgrade process by returning upgrade callback.
|
||||
// we can customize the callback by sending additional info such as address.
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, addr, state.clone().recv))
|
||||
}
|
||||
|
||||
async fn on_recv(
|
||||
socket: &mut WebSocket,
|
||||
msg: Option<Result<Message, axum::Error>>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let msg = match msg {
|
||||
Some(Ok(msg)) => msg,
|
||||
Some(Err(err)) => return Err(err.into()),
|
||||
|
||||
None => {
|
||||
log::info!("no message");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let bytes = match msg {
|
||||
Message::Binary(bytes) => bytes,
|
||||
Message::Text(_) => return Err(anyhow::anyhow!("text")),
|
||||
Message::Ping(_) => return Err(anyhow::anyhow!("ping")),
|
||||
Message::Pong(_) => return Err(anyhow::anyhow!("pong")),
|
||||
Message::Close(close_frame) => {
|
||||
return Err(anyhow::anyhow!("sent close frame: {close_frame:?}"));
|
||||
}
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return Err(anyhow::anyhow!("got an empty bytes message"));
|
||||
}
|
||||
let bytes = bytes.iter().as_slice();
|
||||
let msg_type = scratchpad::ClientMessageType::try_from(bytes[0])?;
|
||||
log::info!("got message type: {msg_type:?}");
|
||||
let message = msg_type.try_parse_message(&bytes[1..])?;
|
||||
match message {
|
||||
scratchpad::ClientMessage::GetBoard => {
|
||||
let response: Vec<u8> =
|
||||
ServerMessage::StateUpdate(GAME_STATE.lock().await.get_board_values()).into();
|
||||
socket
|
||||
.send(Message::Binary(Bytes::from_owner(response)))
|
||||
.await?;
|
||||
}
|
||||
scratchpad::ClientMessage::SendMove(send_move) => {
|
||||
let new_board_state_message: Vec<u8> = ServerMessage::StateUpdate({
|
||||
let mut state = GAME_STATE.lock().await;
|
||||
state
|
||||
.set_tile(send_move.row, send_move.col, send_move.value)
|
||||
.await?;
|
||||
state.get_board_values()
|
||||
})
|
||||
.into();
|
||||
socket
|
||||
.send(Message::Binary(Bytes::from_owner(new_board_state_message)))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_state_update(
|
||||
socket: &mut WebSocket,
|
||||
state: Result<Arc<[bool]>, RecvError>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let state = match state {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
log::error!("recv error: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mut out_bytes = Vec::new();
|
||||
out_bytes.push(scratchpad::ServerMessageType::StateUpdate as u8);
|
||||
out_bytes.extend(state.iter().map(|t| *t as u8));
|
||||
socket
|
||||
.send(Message::Binary(Bytes::from_owner(out_bytes)))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
who: SocketAddr,
|
||||
mut recv: Receiver<Arc<[TileType]>>,
|
||||
) {
|
||||
loop {
|
||||
if let Err(err) = tokio::select! {
|
||||
msg = socket.recv() => {
|
||||
log::info!("socket.recv");
|
||||
on_recv(&mut socket, msg).await
|
||||
},
|
||||
r = recv.recv() => {
|
||||
log::info!("state update");
|
||||
on_state_update(&mut socket, r).await
|
||||
}
|
||||
} {
|
||||
log::error!("[{who}] {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "blog"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[dependencies]
|
||||
web-sys = { version = "0.3", features = [
|
||||
"HtmlTableCellElement",
|
||||
"Event",
|
||||
"EventTarget",
|
||||
"HtmlImageElement",
|
||||
"HtmlDivElement",
|
||||
] }
|
||||
log = "0.4"
|
||||
rand = { version = "0.8", features = ["small_rng"] }
|
||||
yew = { version = "0.21", features = ["csr"] }
|
||||
yew-router = "0.18.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
gloo = "0.11"
|
||||
wasm-logger = "0.2"
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
once_cell = "1"
|
||||
chrono = "0.4.40"
|
||||
blog-macros = { path = "../blog-macros" }
|
||||
scratchpad = { path = "../scratchpad" }
|
||||
futures = "0.3.31"
|
||||
wasm-bindgen-futures = "0.4.50"
|
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 858 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 37 KiB |
After Width: | Height: | Size: 726 B |
After Width: | Height: | Size: 902 B |
After Width: | Height: | Size: 759 B |
After Width: | Height: | Size: 84 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 89 KiB |
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>website</title>
|
||||
<link rel="icon" href="/img/puffidle.webp" />
|
||||
<link data-trunk rel="sass" href="index.scss" />
|
||||
<link data-trunk rel="copy-dir" href="img">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,264 @@
|
|||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(133, 0, 153, 1) 0%, rgba(57, 0, 153, 1) 100%);
|
||||
min-height: 100vw;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
main {
|
||||
color: #fff6d5;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
// padding-bottom: 80px;
|
||||
// min-height: 50vh;
|
||||
// background:
|
||||
// linear-gradient(145deg, rgba(133, 0, 153, 1) 0%, rgba(57, 0, 153, 1) 100%);
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 20em;
|
||||
}
|
||||
|
||||
.burger {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
h1.navbar-item {
|
||||
font-size: 32px;
|
||||
align-self: flex-start;
|
||||
backdrop-filter: blur(10px);
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.navbar-start .navbar-item {
|
||||
background-color: #fff6d5;
|
||||
margin-left: 20px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
$link_color: #432054;
|
||||
$link_hover_color: hsl(280, 55%, 61%);
|
||||
$link_bg_color: #fff6d5;
|
||||
$shadow_color: hsl(280, 55%, 61%);
|
||||
$shadow_color_2: hsl(300, 55%, 61%);
|
||||
$link_filter: drop-shadow(5px 5px 0 $shadow_color) drop-shadow(5px 5px 0 $shadow_color_2);
|
||||
$link_select_filter: invert(100%);
|
||||
|
||||
.navbar-item:hover {
|
||||
filter: $link_select_filter;
|
||||
}
|
||||
|
||||
.navbar-menu {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.navbar a,
|
||||
.link-container a,
|
||||
.pagination-item a {
|
||||
text-decoration: none;
|
||||
color: $link_color;
|
||||
}
|
||||
|
||||
.out-of-order {
|
||||
filter: $link_select_filter;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
font-family: 'Cute Font';
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
gap: 30px;
|
||||
filter: $link_filter;
|
||||
}
|
||||
|
||||
.navbar-icon img {
|
||||
width: 32px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
font-size: 12px;
|
||||
width: 100vw;
|
||||
user-select: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.footer .content {
|
||||
filter: drop-shadow(0px 0px 6px #000000);
|
||||
}
|
||||
|
||||
.footer-background {
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
z-index: -1;
|
||||
--s: 34px;
|
||||
--c1: #1c2130;
|
||||
--c2: hsl(291, 50%, 56%);
|
||||
// --c2: #b3e099;
|
||||
|
||||
--_c: 5%, #0000 75%, var(--c1) 0;
|
||||
--_g: /var(--s) var(--s) conic-gradient(at var(--_c));
|
||||
--_l: /var(--s) var(--s) conic-gradient(at 50% var(--_c));
|
||||
background:
|
||||
0 calc(7*var(--s)/10) var(--_l),
|
||||
calc(var(--s)/2) calc(var(--s)/5) var(--_l),
|
||||
calc(var(--s)/5) 0 var(--_g),
|
||||
calc(7*var(--s)/10) calc(var(--s)/2) var(--_g),
|
||||
conic-gradient(at 90% 90%, var(--c1) 75%, var(--c2) 0) 0 0/calc(var(--s)/2) calc(var(--s)/2);
|
||||
|
||||
}
|
||||
|
||||
.badge-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
filter: drop-shadow(5px 5px 0px #000000);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
filter: brightness(120%) drop-shadow(5px 5px 0px hsl(0, 0%, 30%));
|
||||
transform: scale(1.1) skew(-10deg, 10deg);
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
font-family: 'Cute Font';
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 30px;
|
||||
filter: $link_filter;
|
||||
}
|
||||
|
||||
.pagination-list {
|
||||
list-style: none;
|
||||
|
||||
font-family: 'Cute Font';
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 30px;
|
||||
filter: $link_filter;
|
||||
}
|
||||
|
||||
|
||||
.link-container {
|
||||
background-color: #fff6d5;
|
||||
width: 100%;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.link-container:hover {
|
||||
filter: $link_select_filter;
|
||||
}
|
||||
|
||||
.home-body {
|
||||
display: flex;
|
||||
columns: 2;
|
||||
flex-wrap: wrap;
|
||||
width: 80%;
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.home-column {
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
}
|
||||
|
||||
.post-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-wrap: wrap;
|
||||
width: 80vw;
|
||||
margin-left: 10vw;
|
||||
margin-right: 10vw;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.post-body {
|
||||
background-color: $link_bg_color;
|
||||
font-family: 'Cute Font';
|
||||
filter: $link_filter;
|
||||
color: $link_color
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.pagination-list {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
flex-direction: row;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.pagination-list .pagination-item {
|
||||
background-color: #fff6d5;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.pagination-item:hover {
|
||||
filter: $link_select_filter;
|
||||
}
|
||||
|
||||
.scratchpad-tile {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #fff6d5;
|
||||
}
|
||||
|
||||
.scratchpad-active-tile {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
.scratchpad-grid {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(32, 1fr);
|
||||
grid-template-rows: repeat(32, 1fr);
|
||||
|
||||
filter: $link_filter;
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use yew::prelude::*;
|
||||
use yew_router::{
|
||||
history::{AnyHistory, History, MemoryHistory},
|
||||
BrowserRouter, Router, Switch,
|
||||
};
|
||||
|
||||
use crate::{components::Footer, nav::Nav, pages::*, Route};
|
||||
|
||||
#[function_component]
|
||||
pub fn App() -> Html {
|
||||
html! {
|
||||
<BrowserRouter>
|
||||
<Nav />
|
||||
|
||||
<main>
|
||||
<Switch<Route> render={switch} />
|
||||
</main>
|
||||
<Footer />
|
||||
</BrowserRouter>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq, Eq, Debug)]
|
||||
pub struct ServerAppProps {
|
||||
pub url: AttrValue,
|
||||
pub queries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ServerApp(props: &ServerAppProps) -> Html {
|
||||
let history = AnyHistory::from(MemoryHistory::new());
|
||||
history
|
||||
.push_with_query(&*props.url, &props.queries)
|
||||
.unwrap();
|
||||
|
||||
html! {
|
||||
<Router history={history}>
|
||||
<Nav />
|
||||
|
||||
<main>
|
||||
<Switch<Route> render={switch} />
|
||||
</main>
|
||||
<Footer />
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
fn switch(routes: Route) -> Html {
|
||||
if let Some(title) = routes.default_title() {
|
||||
gloo::utils::document().set_title(title);
|
||||
}
|
||||
match routes {
|
||||
Route::Home => {
|
||||
html! { <Home /> }
|
||||
}
|
||||
Route::NotFound => {
|
||||
html! { <PageNotFound /> }
|
||||
}
|
||||
Route::PostIndex => {
|
||||
html! { <PostIndex /> }
|
||||
}
|
||||
Route::PostPage { id } => html! { <PostPage id={id} /> },
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
use web_sys::{wasm_bindgen::JsCast, HtmlImageElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
pub const BADGE_INFO_LIST: &[BadgeInfo] = &[
|
||||
BadgeInfo::new("/img/badges/puff.webp", Some("/"), "jigglypuff.club"),
|
||||
BadgeInfo::new(
|
||||
"/img/badges/bsky.png",
|
||||
Some("https://bsky.app/profile/jigglypuff.club"),
|
||||
"Bluesky",
|
||||
),
|
||||
BadgeInfo::with_special(
|
||||
"/img/badges/pronouns.png",
|
||||
None,
|
||||
"Pronouns: he/him",
|
||||
Special::OnClickSwitch("/img/badges/pronouns-animated.webp"),
|
||||
),
|
||||
BadgeInfo::new(
|
||||
"/img/badges/ublock.png",
|
||||
Some("https://github.com/gorhill/uBlock"),
|
||||
"Use uBlock Origin! (or ad-blockers in general)",
|
||||
),
|
||||
BadgeInfo::new(
|
||||
"/img/badges/consent-o-matic.png",
|
||||
Some("https://consentomatic.au.dk/"),
|
||||
"Use consent-o-matic! (or similar plugins)",
|
||||
),
|
||||
BadgeInfo::new(
|
||||
"/img/badges/wiki.png",
|
||||
Some("https://donate.wikimedia.org/"),
|
||||
"Donate to Wikipedia",
|
||||
),
|
||||
BadgeInfo::with_special(
|
||||
"/img/badges/riir-animated.webp",
|
||||
None,
|
||||
"Rewrite it in Rust!",
|
||||
Special::OnClickSwitch("/img/badges/riir.png"),
|
||||
),
|
||||
BadgeInfo::with_special(
|
||||
"/img/badges/bunny.png",
|
||||
None,
|
||||
"Best viewed on Bunny Browser!",
|
||||
Special::OnClickSwitch("/img/badges/bunny-animated.webp"),
|
||||
),
|
||||
BadgeInfo::with_special(
|
||||
"/img/badges/protect.png",
|
||||
Some("https://transkidsdeservebetter.org/support"),
|
||||
"Protect Trans Kids!",
|
||||
Special::OnHoverSwitch("/img/badges/protect-animated.webp"),
|
||||
),
|
||||
];
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Special {
|
||||
OnClickSwitch(&'static str),
|
||||
OnHoverSwitch(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Properties)]
|
||||
pub struct BadgeInfo {
|
||||
pub src: &'static str,
|
||||
pub url: Option<&'static str>,
|
||||
pub name: &'static str,
|
||||
pub special: Option<Special>,
|
||||
}
|
||||
|
||||
impl BadgeInfo {
|
||||
pub const fn new(src: &'static str, url: Option<&'static str>, name: &'static str) -> Self {
|
||||
Self {
|
||||
src,
|
||||
url,
|
||||
name,
|
||||
special: None,
|
||||
}
|
||||
}
|
||||
pub const fn with_special(
|
||||
src: &'static str,
|
||||
url: Option<&'static str>,
|
||||
name: &'static str,
|
||||
special: Special,
|
||||
) -> Self {
|
||||
Self {
|
||||
src,
|
||||
url,
|
||||
name,
|
||||
special: Some(special),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_badge_html(&self) -> Html {
|
||||
html! {
|
||||
<Badge src={self.src} url={self.url} name={self.name} special={self.special}/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn switch_badge(event: MouseEvent) {
|
||||
log::warn!("switch_badge");
|
||||
if let Some(target) = event
|
||||
.target()
|
||||
.and_then(|t| t.dyn_into::<HtmlImageElement>().ok())
|
||||
{
|
||||
let new_src = match target.get_attribute("swap") {
|
||||
Some(new_src) => new_src,
|
||||
None => return,
|
||||
};
|
||||
let old_src = target.src();
|
||||
target.set_src(&new_src);
|
||||
target.set_attribute("swap", &old_src).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Badge(info: &BadgeInfo) -> Html {
|
||||
let img = match info.special.as_ref() {
|
||||
Some(Special::OnClickSwitch(on_click_switch)) => {
|
||||
html! {
|
||||
<img class="badge" draggable="false" swap={*on_click_switch} onclick={switch_badge} src={info.src} alt={info.name} title={info.name}/>
|
||||
}
|
||||
}
|
||||
Some(Special::OnHoverSwitch(on_hover_switch)) => {
|
||||
html! {
|
||||
<img draggable="false" class="badge" swap={*on_hover_switch} onmouseover={switch_badge} src={info.src} alt={info.name} title={info.name}/>
|
||||
}
|
||||
}
|
||||
None => html! {
|
||||
<img class="badge" draggable="false" src={info.src} alt={info.name} title={info.name}/>
|
||||
},
|
||||
};
|
||||
if let Some(url) = info.url.as_ref() {
|
||||
html! {
|
||||
<a href={*url} target="_blank" rel="noopener noreferrer">
|
||||
{img}
|
||||
</a>
|
||||
}
|
||||
} else {
|
||||
img
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
use super::{BadgeInfo, BADGE_INFO_LIST};
|
||||
|
||||
#[function_component]
|
||||
pub fn Footer() -> Html {
|
||||
let badges = BADGE_INFO_LIST.iter().map(BadgeInfo::create_badge_html);
|
||||
html! {
|
||||
<footer class="footer">
|
||||
<div class="footer-background">
|
||||
<div class="badge-list">
|
||||
{for badges}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
use yew::{function_component, prelude::*};
|
||||
|
||||
use crate::components::PostLinks;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Properties)]
|
||||
pub struct LatestProps {
|
||||
#[prop_or(5)]
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn LatestPosts(props: &LatestProps) -> Html {
|
||||
let mut post_list = crate::post_list::POSTS.iter().collect::<Vec<_>>();
|
||||
post_list.sort_by_key(|post| post.timestamp);
|
||||
let posts = post_list
|
||||
.into_iter()
|
||||
.take(props.count as _)
|
||||
.collect::<Box<_>>();
|
||||
|
||||
html! {
|
||||
<div class="latest-posts">
|
||||
<h3>{"latest posts"}</h3>
|
||||
<PostLinks posts={posts} />
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
use std::ops::Range;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
const ELLIPSIS: &str = "\u{02026}";
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
|
||||
pub struct PageQuery {
|
||||
pub page: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Properties)]
|
||||
pub struct Props {
|
||||
pub page: u32,
|
||||
pub total_pages: u32,
|
||||
pub route_to_page: Route,
|
||||
}
|
||||
|
||||
// #[function_component]
|
||||
// pub fn RelNavButtons(props: &Props) -> Html {
|
||||
// let Props {
|
||||
// page,
|
||||
// total_pages,
|
||||
// route_to_page: to,
|
||||
// } = props.clone();
|
||||
|
||||
// let prev = if page == 1 {
|
||||
// html!()
|
||||
// } else {
|
||||
// html! {
|
||||
// <div class="link-container">
|
||||
// <Link<Route, PageQuery>
|
||||
// classes={classes!("pagination-previous")}
|
||||
// disabled={page==1}
|
||||
// query={Some(PageQuery{page: page - 1})}
|
||||
// to={to.clone()}
|
||||
// >
|
||||
// { "Previous" }
|
||||
// </Link<Route, PageQuery>>
|
||||
// </div>
|
||||
// }
|
||||
// };
|
||||
|
||||
// let next = if page >= total_pages {
|
||||
// html! {}
|
||||
// } else {
|
||||
// html! {
|
||||
// <div class="link-container">
|
||||
// <Link<Route, PageQuery>
|
||||
// classes={classes!("pagination-next")}
|
||||
// disabled={page==total_pages}
|
||||
// query={Some(PageQuery{page: page + 1})}
|
||||
// {to}
|
||||
// >
|
||||
// { "Next" }
|
||||
// </Link<Route, PageQuery>>
|
||||
// </div>
|
||||
// }
|
||||
// };
|
||||
|
||||
// html! {
|
||||
// <div class="link-list">
|
||||
// {prev}
|
||||
// {next}
|
||||
// </div>
|
||||
// }
|
||||
// }
|
||||
|
||||
#[derive(Properties, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RenderLinksProps {
|
||||
range: Range<u32>,
|
||||
len: usize,
|
||||
max_links: usize,
|
||||
props: Props,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn RenderLinks(props: &RenderLinksProps) -> Html {
|
||||
let RenderLinksProps {
|
||||
range,
|
||||
len,
|
||||
max_links,
|
||||
props,
|
||||
} = props.clone();
|
||||
|
||||
let mut range = range;
|
||||
|
||||
if len > max_links {
|
||||
let last_link =
|
||||
html! {<RenderLink to_page={range.next_back().unwrap()} props={props.clone()} />};
|
||||
// remove 1 for the ellipsis and 1 for the last link
|
||||
let links = range
|
||||
.take(max_links - 2)
|
||||
.map(|page| html! {<RenderLink to_page={page} props={props.clone()} />});
|
||||
html! {
|
||||
<>
|
||||
{ for links }
|
||||
<li><span class="pagination-ellipsis">{ ELLIPSIS }</span></li>
|
||||
{ last_link }
|
||||
</>
|
||||
}
|
||||
} else {
|
||||
html! { for range.map(|page| html! {<RenderLink to_page={page} props={props.clone()} />}) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RenderLinkProps {
|
||||
to_page: u32,
|
||||
props: Props,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn RenderLink(props: &RenderLinkProps) -> Html {
|
||||
let RenderLinkProps { to_page, props } = props.clone();
|
||||
|
||||
let Props {
|
||||
page,
|
||||
route_to_page,
|
||||
..
|
||||
} = props;
|
||||
|
||||
let is_current_class = if to_page == page { "is-current" } else { "" };
|
||||
let pagination_classes = if to_page == page {
|
||||
"pagination-item out-of-order"
|
||||
} else {
|
||||
"pagination-item"
|
||||
};
|
||||
|
||||
html! {
|
||||
<li class={pagination_classes}>
|
||||
<Link<Route, PageQuery>
|
||||
classes={classes!("pagination-link", is_current_class)}
|
||||
to={route_to_page}
|
||||
query={Some(PageQuery{page: to_page})}
|
||||
>
|
||||
{ to_page }
|
||||
</Link<Route, PageQuery>>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Links(props: &Props) -> Html {
|
||||
const LINKS_PER_SIDE: usize = 3;
|
||||
|
||||
let Props {
|
||||
page, total_pages, ..
|
||||
} = *props;
|
||||
|
||||
let pages_prev = page.checked_sub(1).unwrap_or_default() as usize;
|
||||
let pages_next = (total_pages - page) as usize;
|
||||
|
||||
let links_left = LINKS_PER_SIDE.min(pages_prev)
|
||||
// if there are less than `LINKS_PER_SIDE` to the right, we add some more on the left.
|
||||
+ LINKS_PER_SIDE.checked_sub(pages_next).unwrap_or_default();
|
||||
let links_right = 2 * LINKS_PER_SIDE - links_left;
|
||||
|
||||
html! {
|
||||
<>
|
||||
<RenderLinks range={ 1..page } len={pages_prev} max_links={links_left} props={props.clone()} />
|
||||
<RenderLink to_page={page} props={props.clone()} />
|
||||
<RenderLinks range={ page + 1..total_pages + 1 } len={pages_next} max_links={links_right} props={props.clone()} />
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn Pagination(props: &Props) -> Html {
|
||||
let Props {
|
||||
page,
|
||||
total_pages,
|
||||
route_to_page: to,
|
||||
} = props.clone();
|
||||
let prev = if page > 1 {
|
||||
html! {
|
||||
<div class="link-container">
|
||||
<Link<Route, PageQuery>
|
||||
classes={classes!("pagination-previous")}
|
||||
disabled={page==1}
|
||||
query={if page == 1 {
|
||||
None
|
||||
} else {
|
||||
Some(PageQuery{page: page - 1})
|
||||
}}
|
||||
to={to.clone()}
|
||||
>
|
||||
{ "Previous" }
|
||||
</Link<Route, PageQuery>>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="link-container">
|
||||
<p style="display: inline; color: #5b2063; user-select: none;">{"Previous"}</p>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
let next = if page < total_pages {
|
||||
html! {
|
||||
<div class="link-container">
|
||||
<Link<Route, PageQuery>
|
||||
classes={classes!("pagination-next")}
|
||||
disabled={page >= total_pages}
|
||||
query={if page >= total_pages {
|
||||
None
|
||||
} else {
|
||||
Some(PageQuery{page: page + 1})
|
||||
}}
|
||||
{to}
|
||||
>
|
||||
{ "Next" }
|
||||
</Link<Route, PageQuery>>
|
||||
</div>
|
||||
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<div class="link-container">
|
||||
<p style="display: inline; color: #5b2063; user-select: none;">{"Next"}</p>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
html! {
|
||||
<nav class="pagination is-right" role="navigation" aria-label="pagination">
|
||||
<div class="link-list">
|
||||
{prev}
|
||||
</div>
|
||||
<div class="pagination-list">
|
||||
<Links ..{props.clone()} />
|
||||
</div>
|
||||
<div class="link-list">
|
||||
{next}
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
use crate::post_list::Post;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Properties)]
|
||||
pub struct PostLinksProps {
|
||||
pub posts: Box<[&'static Post]>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PostLinks(props: &PostLinksProps) -> Html {
|
||||
let posts = props.posts.iter().map(|post| {
|
||||
let route_id = post.route_id();
|
||||
|
||||
html! {
|
||||
<div class="link-container">
|
||||
<a href={format!("/posts/{route_id}")}>{post.title}</a>
|
||||
</div>
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div class="link-list posts-list">
|
||||
{ for posts }
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
use std::rc::Rc;
|
||||
|
||||
use gloo::timers::callback::Interval;
|
||||
use instant::Instant;
|
||||
use yew::prelude::*;
|
||||
|
||||
const RESOLUTION: u32 = 500;
|
||||
const MIN_INTERVAL_MS: u32 = 50;
|
||||
|
||||
pub enum ValueAction {
|
||||
Tick,
|
||||
Props(Props),
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct ValueState {
|
||||
start: Instant,
|
||||
|
||||
value: f64,
|
||||
|
||||
props: Props,
|
||||
}
|
||||
|
||||
impl Reducible for ValueState {
|
||||
type Action = ValueAction;
|
||||
|
||||
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||
match action {
|
||||
Self::Action::Props(props) => Self {
|
||||
start: self.start,
|
||||
value: self.value,
|
||||
props,
|
||||
}
|
||||
.into(),
|
||||
|
||||
Self::Action::Tick => {
|
||||
let elapsed = self.start.elapsed().as_millis() as u32;
|
||||
let value = elapsed as f64 / self.props.duration_ms as f64;
|
||||
|
||||
let mut start = self.start;
|
||||
|
||||
if elapsed > self.props.duration_ms {
|
||||
self.props.on_complete.emit(());
|
||||
start = Instant::now();
|
||||
} else {
|
||||
self.props.on_progress.emit(self.value);
|
||||
}
|
||||
|
||||
Self {
|
||||
start,
|
||||
value,
|
||||
props: self.props.clone(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub duration_ms: u32,
|
||||
pub on_complete: Callback<()>,
|
||||
#[prop_or_default]
|
||||
pub on_progress: Callback<f64>,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ProgressDelay(props: &Props) -> Html {
|
||||
let Props { duration_ms, .. } = props.clone();
|
||||
|
||||
let value = {
|
||||
let props = props.clone();
|
||||
use_reducer(move || ValueState {
|
||||
start: Instant::now(),
|
||||
value: 0.0,
|
||||
|
||||
props,
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let value = value.clone();
|
||||
use_effect_with((), move |_| {
|
||||
let interval = (duration_ms / RESOLUTION).min(MIN_INTERVAL_MS);
|
||||
let interval = Interval::new(interval, move || value.dispatch(ValueAction::Tick));
|
||||
|
||||
|| {
|
||||
let _interval = interval;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let value = value.clone();
|
||||
use_effect_with(props.clone(), move |props| {
|
||||
value.dispatch(ValueAction::Props(props.clone()));
|
||||
|| {}
|
||||
});
|
||||
}
|
||||
|
||||
let value = &value.value;
|
||||
|
||||
html! {
|
||||
<progress class="progress is-primary" value={value.to_string()} max=1.0>
|
||||
{ format!("{:.0}%", 100.0 * value) }
|
||||
</progress>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
use core::ops::Deref;
|
||||
use std::{rc::Rc, sync::Arc};
|
||||
|
||||
use futures::{lock::Mutex, SinkExt, StreamExt};
|
||||
use gloo::net::websocket::Message;
|
||||
use scratchpad::{
|
||||
game::{self, BoardValues},
|
||||
ClientMessage, SendMove, ServerMessage,
|
||||
};
|
||||
use web_sys::{wasm_bindgen::JsCast, Element, HtmlDivElement, HtmlElement, HtmlTableCellElement};
|
||||
use yew::prelude::*;
|
||||
|
||||
const IS_ON_CLASS: &str = "scratchpad-active-tile";
|
||||
|
||||
fn update_scratchpad(new_state: BoardValues) {
|
||||
let tiles_html = gloo::utils::document().get_elements_by_class_name("scratchpad-tile");
|
||||
let mut tiles = Vec::with_capacity(tiles_html.length() as _);
|
||||
for idx in 0..tiles_html.length() {
|
||||
let tile = tiles_html
|
||||
.get_with_index(idx)
|
||||
.unwrap()
|
||||
.dyn_into::<HtmlDivElement>()
|
||||
.unwrap();
|
||||
let row: u32 = tile.get_attribute("row").unwrap().parse().unwrap();
|
||||
let col: u32 = tile.get_attribute("col").unwrap().parse().unwrap();
|
||||
tiles.push((row, col, tile));
|
||||
}
|
||||
|
||||
tiles.sort_by_key(|(row, col, _)| row * game::COLS as u32 + col);
|
||||
|
||||
for (element, new_value) in tiles
|
||||
.into_iter()
|
||||
.map(|t| t.2)
|
||||
.zip(new_state.into_iter().flat_map(|row| row.into_iter()))
|
||||
{
|
||||
let currently = element.class_name().contains(IS_ON_CLASS);
|
||||
match (currently, new_value) {
|
||||
(true, false) => {
|
||||
element.set_class_name(element.class_name().replace(IS_ON_CLASS, "").as_str())
|
||||
}
|
||||
(false, true) => {
|
||||
element.set_class_name(format!("{} {IS_ON_CLASS}", element.class_name()).as_str())
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn ScratchPad() -> Html {
|
||||
// let host = gloo::utils::window()
|
||||
// .location()
|
||||
// .host()
|
||||
// .expect("No host found in current window");
|
||||
// let ws_url = format!("ws://{host}/scratchpad").replace(":8080", ":3031");
|
||||
let ws_url = String::from("wss://jigglypuff.club/scratchpad");
|
||||
|
||||
let send = {
|
||||
let ws = gloo::net::websocket::futures::WebSocket::open(&ws_url).unwrap();
|
||||
let (send, mut recv) = ws.split();
|
||||
let send = Rc::new(Mutex::new(send));
|
||||
yew::platform::spawn_local(async move {
|
||||
loop {
|
||||
let msg = match recv.next().await {
|
||||
Some(Ok(msg)) => msg,
|
||||
Some(Err(err)) => {
|
||||
log::error!("scratchpad socket error: {err}");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
log::error!("scratchpad socket closed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let bytes = match msg {
|
||||
Message::Text(_) => {
|
||||
log::error!("did not expect text message; discarding.");
|
||||
continue;
|
||||
}
|
||||
Message::Bytes(bytes) => bytes,
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
log::warn!("empty message; discarding.");
|
||||
continue;
|
||||
}
|
||||
|
||||
let msg_type: scratchpad::ServerMessageType = match bytes[0].try_into() {
|
||||
Ok(t) => t,
|
||||
Err(err) => {
|
||||
log::error!("invalid message: {err} ({:#04X})", bytes[0]);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let ServerMessage::StateUpdate(msg) = match msg_type.try_parse_message(&bytes[1..])
|
||||
{
|
||||
Ok(msg) => msg,
|
||||
Err(err) => {
|
||||
log::error!("{err}");
|
||||
continue; // im tired idc
|
||||
}
|
||||
};
|
||||
update_scratchpad(msg);
|
||||
}
|
||||
});
|
||||
|
||||
let send_cl = send.clone();
|
||||
yew::platform::spawn_local(async move {
|
||||
let board_req: Vec<u8> = ClientMessage::GetBoard.into();
|
||||
if let Err(err) = send_cl.lock().await.send(Message::Bytes(board_req)).await {
|
||||
log::error!("sending req: {err}");
|
||||
}
|
||||
});
|
||||
send
|
||||
};
|
||||
|
||||
let grid_tile_click = || {
|
||||
let send_click = send.clone();
|
||||
// let state = ws_state.clone();
|
||||
move |event: MouseEvent| {
|
||||
// let (x, y) = state.deref();
|
||||
let target = event
|
||||
.target()
|
||||
.and_then(|t| t.dyn_into::<Element>().ok())
|
||||
.expect("grid_tile_click click target is not an element??");
|
||||
|
||||
let row: u32 = target
|
||||
.get_attribute("row")
|
||||
.expect("no row attribute on grid_tile_click")
|
||||
.parse()
|
||||
.expect("row is not u32");
|
||||
|
||||
let col: u32 = target
|
||||
.get_attribute("col")
|
||||
.expect("no col attribute on grid_tile_click")
|
||||
.parse()
|
||||
.expect("col is not u32");
|
||||
|
||||
let value = match target.class_name().contains("scratchpad-active-tile") {
|
||||
true => {
|
||||
target.set_class_name(target.class_name().replace(IS_ON_CLASS, "").as_str());
|
||||
true
|
||||
}
|
||||
false => {
|
||||
target
|
||||
.set_class_name(format!("{} {IS_ON_CLASS}", target.class_name()).as_str());
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// flip cause it's setting now
|
||||
let value = !value;
|
||||
let send_click_clone = send_click.clone();
|
||||
yew::platform::spawn_local(async move {
|
||||
let bytes: Vec<u8> = ClientMessage::SendMove(SendMove { row, col, value }).into();
|
||||
if let Err(err) = send_click_clone
|
||||
.lock()
|
||||
.await
|
||||
.send(Message::Bytes(bytes))
|
||||
.await
|
||||
{
|
||||
log::error!("Sending tile update: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let rows = (0u32..scratchpad::game::ROWS as u32)
|
||||
.map(|row| (row, (0u32..scratchpad::game::COLS as u32)))
|
||||
.map(|(row_idx, cols)| {
|
||||
let columns = cols.map(move |col_idx| {
|
||||
html! {
|
||||
<div class="scratchpad-tile" row={row_idx.to_string()} col={col_idx.to_string()} onclick={grid_tile_click()}>
|
||||
// <span />
|
||||
</div>
|
||||
}
|
||||
});
|
||||
html! {
|
||||
<>
|
||||
{for columns}
|
||||
</>
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div>
|
||||
<div class="scratchpad-grid">
|
||||
{for rows}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
mod app;
|
||||
mod nav;
|
||||
mod post_list;
|
||||
mod components {
|
||||
blog_macros::include_path!("blog/src/components");
|
||||
}
|
||||
mod pages {
|
||||
blog_macros::include_path!("blog/src/pages");
|
||||
}
|
||||
|
||||
pub use app::App;
|
||||
|
||||
use yew_router::Routable;
|
||||
|
||||
#[derive(Routable, PartialEq, Eq, Clone, Debug)]
|
||||
pub enum Route {
|
||||
#[at("/posts/:id")]
|
||||
PostPage { id: String },
|
||||
#[at("/posts")]
|
||||
PostIndex,
|
||||
#[at("/")]
|
||||
Home,
|
||||
#[not_found]
|
||||
#[at("/404")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn default_title(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Route::PostIndex => Some("Jigglypuff.club - Posts"),
|
||||
Route::Home => Some("Jigglypuff.club"),
|
||||
Route::NotFound => Some("Jigglypuff.club - Not Found"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
wasm_logger::init(wasm_logger::Config::new(log::Level::Trace));
|
||||
yew::Renderer::<App>::new().render();
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
use crate::Route;
|
||||
|
||||
#[function_component]
|
||||
pub fn Nav() -> Html {
|
||||
let navbar_active = use_state_eq(|| false);
|
||||
|
||||
let toggle_navbar = {
|
||||
let navbar_active = navbar_active.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
navbar_active.set(!*navbar_active);
|
||||
})
|
||||
};
|
||||
|
||||
let active_class = if !*navbar_active { "is-active" } else { "" };
|
||||
|
||||
html! {
|
||||
<nav class="navbar is-primary" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<div class="navbar-icon">
|
||||
<img class="puff" src="/img/puffidle.webp" alt="jigglypuff idle animation melee"/>
|
||||
</div>
|
||||
|
||||
<button class={classes!("navbar-burger", "burger", active_class)}
|
||||
aria-label="menu" aria-expanded="false"
|
||||
onclick={toggle_navbar}
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class={classes!("navbar-menu", active_class)}>
|
||||
<div class="navbar-start">
|
||||
<Link<Route> classes={classes!("navbar-item")} to={Route::Home}>
|
||||
{ "Home" }
|
||||
</Link<Route>>
|
||||
<Link<Route> classes={classes!("navbar-item")} to={Route::PostIndex}>
|
||||
{ "Posts" }
|
||||
</Link<Route>>
|
||||
// <Link<Route> classes={classes!("navbar-item")} to={Route::Posts}>
|
||||
// { "Posts" }
|
||||
// </Link<Route>>
|
||||
|
||||
// <div class="navbar-item has-dropdown is-hoverable">
|
||||
// <div class="navbar-link">
|
||||
// { "More" }
|
||||
// </div>
|
||||
// <div class="navbar-dropdown">
|
||||
// <Link<Route> classes={classes!("navbar-item")} to={Route::Authors}>
|
||||
// { "Meet the authors" }
|
||||
// </Link<Route>>
|
||||
// </div>
|
||||
// </div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
use crate::components::{LatestPosts, ScratchPad};
|
||||
|
||||
#[function_component]
|
||||
pub fn Home() -> Html {
|
||||
gloo::utils::document().set_title("jigglypuff.club");
|
||||
|
||||
html! {
|
||||
<>
|
||||
<div class="tile is-ancestor is-vertical">
|
||||
<h1>{"welcome to jigglypuff.club"}</h1>
|
||||
</div>
|
||||
<div class="home-body">
|
||||
<div class="home-column">
|
||||
<LatestPosts />
|
||||
</div>
|
||||
<div class="home-column">
|
||||
<h2>{"\"guest\" \"book\""}</h2>
|
||||
<ScratchPad />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
#[function_component]
|
||||
pub fn PageNotFound() -> Html {
|
||||
html! {
|
||||
<section class="hero is-danger is-bold is-large">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h1 class="title">
|
||||
{ "Page not found" }
|
||||
</h1>
|
||||
<h2 class="subtitle">
|
||||
{ "Page page does not seem to exist" }
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
use yew::prelude::*;
|
||||
|
||||
use crate::pages::PageNotFound;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Properties)]
|
||||
pub struct Props {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[function_component]
|
||||
pub fn PostPage(props: &Props) -> Html {
|
||||
let post = match crate::post_list::POSTS
|
||||
.iter()
|
||||
.find(|post| post.route_id() == props.id)
|
||||
{
|
||||
Some(post) => post,
|
||||
None => return html! {<PageNotFound />},
|
||||
};
|
||||
|
||||
gloo::utils::document().set_title(post.title);
|
||||
|
||||
html! {
|
||||
<div class="post-container">
|
||||
<div class="post-body">
|
||||
<h1 class="title">{ post.title }</h1>
|
||||
{(post.content)()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
use yew::prelude::*;
|
||||
use yew_router::hooks;
|
||||
|
||||
use crate::components::{PageQuery, Pagination, PostLinks};
|
||||
|
||||
const ITEMS_PER_PAGE: usize = 10;
|
||||
|
||||
#[function_component]
|
||||
pub fn PostIndex() -> Html {
|
||||
let location = hooks::use_location().unwrap();
|
||||
let current_page: usize = location.query::<PageQuery>().map(|it| it.page).unwrap_or(1) as _;
|
||||
let posts_count = crate::post_list::POSTS.len();
|
||||
let total_pages = if posts_count % ITEMS_PER_PAGE == 0 {
|
||||
posts_count / ITEMS_PER_PAGE
|
||||
} else {
|
||||
1 + (posts_count / ITEMS_PER_PAGE)
|
||||
};
|
||||
|
||||
let posts = crate::post_list::POSTS
|
||||
.iter()
|
||||
.skip((current_page - 1) * ITEMS_PER_PAGE)
|
||||
.take(ITEMS_PER_PAGE)
|
||||
.collect::<Box<_>>();
|
||||
|
||||
html! {
|
||||
<div class="section container">
|
||||
<h1 class="title">{ "Posts" }</h1>
|
||||
<PostLinks posts={posts}/>
|
||||
<Pagination
|
||||
page={current_page as u32}
|
||||
total_pages={total_pages as u32}
|
||||
route_to_page={crate::Route::PostIndex}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
blog_macros::posts!("blog/src/post_list");
|
||||
|
||||
use core::time::Duration;
|
||||
|
||||
use chrono::Datelike;
|
||||
|
||||
const VALID_CHARS_FOR_ROUTE_ID: &str =
|
||||
"01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Post {
|
||||
pub title: &'static str,
|
||||
pub timestamp: u64,
|
||||
pub content: fn() -> yew::Html,
|
||||
}
|
||||
|
||||
impl PartialEq for Post {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.title == other.title && self.timestamp == other.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub fn route_id(&self) -> String {
|
||||
let post_time = chrono::DateTime::UNIX_EPOCH + Duration::from_secs(self.timestamp);
|
||||
|
||||
let mut route_id = self
|
||||
.title
|
||||
.replace(' ', "_")
|
||||
.matches(
|
||||
VALID_CHARS_FOR_ROUTE_ID
|
||||
.chars()
|
||||
.collect::<Vec<char>>()
|
||||
.as_slice(),
|
||||
)
|
||||
.collect::<String>();
|
||||
route_id.truncate(30);
|
||||
|
||||
format!("{}{:0>2}_{route_id}", post_time.year(), post_time.month())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
use super::Post;
|
||||
use yew::html;
|
||||
|
||||
pub const POST: Post = Post {
|
||||
title: "wacky test post",
|
||||
timestamp: 1743710397,
|
||||
content: || {
|
||||
html! {
|
||||
<div>
|
||||
<p>{"me"}</p><br />
|
||||
<p>{"I me (me)"}</p>
|
||||
<p>{"aaa"}</p>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
use super::Post;
|
||||
use yew::html;
|
||||
|
||||
pub const POST: Post = Post {
|
||||
title: "wowwee post 2! now a new post!",
|
||||
timestamp: 1743710398,
|
||||
content: || {
|
||||
html! {
|
||||
<div>
|
||||
<p>{"hello world"}</p><br />
|
||||
<p>{"this is an \"example11!!{}\""}</p>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
use super::Post;
|
||||
use yew::html;
|
||||
|
||||
pub const POST: Post = Post {
|
||||
title: "wowowowow33333333!",
|
||||
timestamp: 1743710399,
|
||||
content: || {
|
||||
html! {
|
||||
<div>
|
||||
<p>{"hello world"}</p><br />
|
||||
<p>{"this is an \"example113!!{}\""}</p>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
use super::Post;
|
||||
use yew::html;
|
||||
|
||||
pub const POST: Post = Post {
|
||||
title: "omgff hgIOW DOI U MAKE 4 PSOT!",
|
||||
timestamp: 1743710400,
|
||||
content: || {
|
||||
html! {
|
||||
<div>
|
||||
<p>{"fdsgorld"}</p><br />
|
||||
<p>{"this i22222!!{}\""}</p>
|
||||
</div>
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "scratchpad"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
thiserror = { version = "2" }
|
||||
log = { version = "0.4" }
|
|
@ -0,0 +1,85 @@
|
|||
use thiserror::Error;
|
||||
|
||||
pub const ROWS: usize = 32;
|
||||
pub const COLS: usize = 32;
|
||||
pub type TileType = bool;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Error)]
|
||||
pub enum GameError {
|
||||
#[error("coordinates out of range")]
|
||||
CoordinatesOutOfRange,
|
||||
#[error("board values message has invalid length")]
|
||||
BoardValuesMessageLengthInvalid,
|
||||
#[error("board values contains illegal value")]
|
||||
BoardValuesIllegalValue,
|
||||
}
|
||||
|
||||
pub type BoardValues = [[TileType; COLS]; ROWS];
|
||||
|
||||
pub struct GameState {
|
||||
board: Board,
|
||||
}
|
||||
|
||||
struct Row([TileType; COLS]);
|
||||
struct Board {
|
||||
rows: [Row; ROWS],
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
pub const fn blank() -> Self {
|
||||
Self {
|
||||
board: Board {
|
||||
rows: unsafe {
|
||||
core::mem::transmute::<[u8; core::mem::size_of::<[Row; COLS]>()], [Row; COLS]>(
|
||||
[0u8; core::mem::size_of::<[Row; COLS]>()],
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_board_values(&self) -> BoardValues {
|
||||
let mut values = [[TileType::default(); COLS]; ROWS];
|
||||
values
|
||||
.iter_mut()
|
||||
.map(|row| row.iter_mut())
|
||||
.zip(self.board.rows.iter().map(|row| row.0.iter()))
|
||||
.for_each(|(dst, src)| dst.zip(src).for_each(|(dst, src)| *dst = *src));
|
||||
values
|
||||
}
|
||||
|
||||
pub fn replace_game_state(&mut self, bytes: &[u8]) -> Result<(), GameError> {
|
||||
let board_values = board_values_from_bytes(bytes)?;
|
||||
self.board
|
||||
.rows
|
||||
.iter_mut()
|
||||
.map(|row| row.0.iter_mut())
|
||||
.zip(board_values.iter().map(|row| row.iter()))
|
||||
.for_each(|(dst, src)| dst.zip(src).for_each(|(dst, src)| *dst = *src));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_tile(&mut self, row: u32, col: u32, value: TileType) -> Result<(), GameError> {
|
||||
if row >= ROWS as u32 || col >= COLS as u32 {
|
||||
return Err(GameError::CoordinatesOutOfRange);
|
||||
}
|
||||
self.board.rows[row as usize].0[col as usize] = value;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn board_values_from_bytes(bytes: &[u8]) -> Result<BoardValues, GameError> {
|
||||
if bytes.len() != (ROWS * COLS) {
|
||||
return Err(GameError::BoardValuesMessageLengthInvalid);
|
||||
}
|
||||
if bytes.iter().any(|b| *b > 1) {
|
||||
return Err(GameError::BoardValuesIllegalValue);
|
||||
}
|
||||
let mut out = [[TileType::default(); COLS]; ROWS];
|
||||
out.iter_mut()
|
||||
.flat_map(|tiles| tiles.iter_mut())
|
||||
.zip(bytes.iter())
|
||||
.for_each(|(out, inp)| *out = *inp == 1);
|
||||
|
||||
Ok(out)
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
use game::{GameError, TileType};
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod game;
|
||||
|
||||
#[derive(Debug, Error, Clone, Copy)]
|
||||
pub enum MessageError {
|
||||
#[error("invalid message type")]
|
||||
InvalidMessageType,
|
||||
#[error("message too short")]
|
||||
MessageTooShort,
|
||||
#[error("invalid tile value")]
|
||||
InvalidTileValue,
|
||||
#[error("{0}")]
|
||||
GameError(#[from] GameError),
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ClientMessageType {
|
||||
GetBoard = 0x10,
|
||||
SendMove = 0x20,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ClientMessageType {
|
||||
type Error = MessageError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
Ok(match value {
|
||||
0x10 => ClientMessageType::GetBoard,
|
||||
0x20 => ClientMessageType::SendMove,
|
||||
_ => return Err(MessageError::InvalidMessageType),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SendMove {
|
||||
pub row: u32,
|
||||
pub col: u32,
|
||||
pub value: TileType,
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for SendMove {
|
||||
type Error = MessageError;
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
if value.len() < 9 {
|
||||
return Err(MessageError::InvalidMessageType);
|
||||
}
|
||||
let row = u32::from_be_bytes(value[..4].try_into().unwrap());
|
||||
let col = u32::from_be_bytes(value[4..8].try_into().unwrap());
|
||||
let value = match value[8] {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(MessageError::InvalidTileValue),
|
||||
};
|
||||
Ok(Self { row, col, value })
|
||||
}
|
||||
}
|
||||
|
||||
impl SendMove {
|
||||
fn write_to_vec(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.row.to_be_bytes());
|
||||
out.extend_from_slice(&self.col.to_be_bytes());
|
||||
out.push(self.value as _);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientMessage {
|
||||
GetBoard,
|
||||
SendMove(SendMove),
|
||||
}
|
||||
|
||||
impl From<ClientMessage> for Vec<u8> {
|
||||
fn from(value: ClientMessage) -> Self {
|
||||
let mut out = Vec::new();
|
||||
match value {
|
||||
ClientMessage::GetBoard => out.push(ClientMessageType::GetBoard as u8),
|
||||
ClientMessage::SendMove(send_move) => {
|
||||
out.push(ClientMessageType::SendMove as u8);
|
||||
send_move.write_to_vec(&mut out);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientMessageType {
|
||||
pub fn try_parse_message(&self, bytes: &[u8]) -> Result<ClientMessage, MessageError> {
|
||||
match self {
|
||||
ClientMessageType::GetBoard => Ok(ClientMessage::GetBoard),
|
||||
ClientMessageType::SendMove => Ok(ClientMessage::SendMove(bytes.try_into()?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ServerMessageType {
|
||||
StateUpdate = 0x30,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ServerMessageType {
|
||||
type Error = MessageError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
Ok(match value {
|
||||
0x30 => ServerMessageType::StateUpdate,
|
||||
_ => return Err(MessageError::InvalidMessageType),
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum ServerMessage {
|
||||
StateUpdate(game::BoardValues),
|
||||
}
|
||||
|
||||
impl ServerMessageType {
|
||||
pub fn try_parse_message(&self, bytes: &[u8]) -> Result<ServerMessage, MessageError> {
|
||||
Ok(match self {
|
||||
ServerMessageType::StateUpdate => {
|
||||
ServerMessage::StateUpdate(game::board_values_from_bytes(bytes)?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerMessage> for Vec<u8> {
|
||||
fn from(value: ServerMessage) -> Self {
|
||||
match value {
|
||||
ServerMessage::StateUpdate(state) => [ServerMessageType::StateUpdate as u8]
|
||||
.into_iter()
|
||||
.chain(
|
||||
state
|
||||
.into_iter()
|
||||
.flat_map(|row| row.into_iter())
|
||||
.map(|tile| tile as u8),
|
||||
)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|