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(),
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||