blogsite initial commit

This commit is contained in:
Emilis 2025-04-05 14:14:57 +01:00
commit a88e887f33
No known key found for this signature in database
50 changed files with 6407 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/blog/dist/
/target/
.vscode
build-and-send.fish

2532
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
Cargo.toml Normal file
View File

@ -0,0 +1,3 @@
[workspace]
resolver = "3"
members = ["blog", "blog-macros", "scratchpad", "blog-server"]

12
blog-macros/Cargo.toml Normal file
View File

@ -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"] }

238
blog-macros/src/lib.rs Normal file
View File

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

17
blog-server/Cargo.toml Normal file
View File

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

View File

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

49
blog-server/src/game.rs Normal file
View File

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

282
blog-server/src/main.rs Normal file
View File

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

1572
blog/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
blog/Cargo.toml Normal file
View File

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

BIN
blog/img/badges/bsky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

BIN
blog/img/badges/bunny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
blog/img/badges/flash.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

BIN
blog/img/badges/protect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

BIN
blog/img/badges/puff.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
blog/img/badges/riir.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
blog/img/badges/ublock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
blog/img/badges/wiki.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
blog/img/puffidle.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

17
blog/index.html Normal file
View File

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

264
blog/index.scss Normal file
View File

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

66
blog/src/app.rs Normal file
View File

@ -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} /> },
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

42
blog/src/main.rs Normal file
View File

@ -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();
}

62
blog/src/nav.rs Normal file
View File

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

25
blog/src/pages/home.rs Normal file
View File

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

View File

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

30
blog/src/pages/post.rs Normal file
View File

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

View File

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

41
blog/src/post_list.rs Normal file
View File

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

View File

@ -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>
}
},
};

View File

@ -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>
}
},
};

View File

@ -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>
}
},
};

View File

@ -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>
}
},
};

8
scratchpad/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "scratchpad"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = { version = "2" }
log = { version = "0.4" }

85
scratchpad/src/game.rs Normal file
View File

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

145
scratchpad/src/lib.rs Normal file
View File

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