Merge pull request #11 from bensherriff/develop

v0.2.5
This commit is contained in:
Ben Sherriff
2023-10-05 15:43:28 -04:00
committed by GitHub
73 changed files with 6558 additions and 2183 deletions

4
.gitignore vendored
View File

@@ -3,5 +3,5 @@ target/
.idea/ .idea/
**/Cargo.lock **/Cargo.lock
logs/ .next/
app/ node_modules/

View File

@@ -1,6 +1,5 @@
{ {
"rust-analyzer.linkedProjects": [ "rust-analyzer.linkedProjects": [
"./service/Cargo.toml", "./service/Cargo.toml"
"./bot/Cargo.toml",
] ]
} }

View File

@@ -1,8 +0,0 @@
RUST_LOG=warn,bot=info
COMPOSE_PROJECT_NAME=siren
SERVICE_HOST=localhost
SERVICE_PORT=5000
DISCORD_TOKEN=
OPENAI_API_KEY=

View File

@@ -1,42 +0,0 @@
[package]
name = "bot"
version = "0.2.4"
edition = "2021"
authors = ["Ben Sherriff <hello@bensherriff.com>"]
repository = "https://github.com/bensherriff/siren"
readme = "README.md"
license = "GPL-3.0-or-later"
[dependencies]
chrono = { version = "0.4.31", features = ["serde"] }
dotenv = "0.15.0"
serde_json = "1.0.107"
log = "0.4.20"
env_logger = "0.10.0"
service = { path = "../service" }
[dependencies.serenity]
version = "0.11.6"
default-features = false
features = ["client", "gateway", "rustls_backend", "model", "voice", "cache", "framework", "standard_framework"]
[dependencies.songbird]
version = "0.3.2"
features = ["builtin-queue", "yt-dlp"]
[dependencies.tokio]
version = "1.32.0"
features = ["macros", "rt-multi-thread"]
[dependencies.serde]
version = "1.0.188"
features = ["derive"]
[dependencies.reqwest]
version = "0.11.22"
default-features = false
features = ["json", "rustls-tls"]
[dependencies.pyo3]
version = "0.19.2"
features = ["auto-initialize"]

View File

@@ -1,37 +0,0 @@
# Builder
FROM rust:1.72.1-bookworm as builder
WORKDIR /builder
COPY src ./src
COPY Cargo.toml ./
RUN cargo build --release
# Packages
FROM debian:bullseye-slim as packages
WORKDIR /packages
RUN apt-get update && apt-get install -y curl tar xz-utils && \
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux > yt-dlp && \
chmod +x yt-dlp && \
curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz > ffmpeg.tar.xz && \
tar -xJf ffmpeg.tar.xz --wildcards */bin/ffmpeg --transform='s/^.*\///' && rm ffmpeg.tar.xz
# FROM debian:bullseye-slim as libraries
# WORKDIR /libraries
# RUN apt-get update && apt-get install -y unzip && \
# curl -L https://download.pytorch.org/libtorch/cu117/libtorch-cxx11-abi-shared-with-deps-2.0.1%2Bcu117.zip > libtorch.zip && \
# unzip libtorch.zip && rm libtorch.zip
# Runner
FROM debian:bullseye-slim as runtime
WORKDIR /bot
RUN apt-get update && apt-get install -y libopus-dev libpq5 libpq-dev && apt-get auto-remove -y
COPY --from=builder /builder/target/release/bot /usr/local/bin/bot
COPY --from=packages /packages /usr/bin
# COPY --from=libraries /libraries /usr/lib
# ARG LIBTORCH=/usr/lib/libtorch
# ARG LD_LIBRARY_PATH=${LIBTORCH}/lib:${LD_LIBRARY_PATH}
# ADD migrations ./
CMD ["bot"]

View File

@@ -1,20 +0,0 @@
version: '3.8'
services:
bot:
image: siren-bot:${BOT_VERSION:-latest}
container_name: siren-bot
build:
context: .
dockerfile: ./Dockerfile
args:
- VERSION=${BOT_VERSION:-latest}
env_file:
- .env
networks:
- frontend
restart: unless-stopped
networks:
frontend:

View File

@@ -1,5 +1,4 @@
RUST_LOG=warn,service=info RUST_LOG=warn,service=info
COMPOSE_PROJECT_NAME=siren
DATABASE_USER=siren DATABASE_USER=siren
DATABASE_PASSWORD= DATABASE_PASSWORD=
@@ -9,6 +8,7 @@ DATABASE_PORT=5432
SERVICE_HOST=localhost SERVICE_HOST=localhost
SERVICE_PORT=5000 SERVICE_PORT=5000
DATA_DIR_PATH=
DISCORD_TOKEN= DISCORD_TOKEN=
OPENAI_API_KEY= OPENAI_API_KEY=

View File

@@ -1 +1 @@
SIREN_VERSION=0.2.4 SIREN_VERSION=0.2.5

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "service" name = "service"
version = "0.2.4" version = "0.2.5"
edition = "2021" edition = "2021"
authors = ["Ben Sherriff <hello@bensherriff.com>"] authors = ["Ben Sherriff <hello@bensherriff.com>"]
repository = "https://github.com/bensherriff/siren" repository = "https://github.com/bensherriff/siren"
@@ -14,6 +14,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
actix-web = "4.4.0" actix-web = "4.4.0"
actix-rt = "2.9.0" actix-rt = "2.9.0"
actix-cors = "0.6.4"
actix-web-httpauth = "0.8.1" actix-web-httpauth = "0.8.1"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
dotenv = "0.15.0" dotenv = "0.15.0"
@@ -42,3 +43,12 @@ features = ["json", "rustls-tls"]
version = "2.1.2" version = "2.1.2"
default-features = false default-features = false
features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"] features = ["postgres", "32-column-tables", "serde_json", "r2d2", "with-deprecated"]
[dependencies.serenity]
version = "0.11.6"
default-features = false
features = ["client", "gateway", "rustls_backend", "model", "voice", "cache", "framework", "standard_framework"]
[dependencies.songbird]
version = "0.3.2"
features = ["builtin-queue", "yt-dlp"]

View File

@@ -1,12 +1,36 @@
FROM rust:1.72.1-bookworm as builder # =========
# Builder
WORKDIR /service # =========
USER root FROM rust:bookworm as builder
WORKDIR /builder
COPY migrations ./migrations COPY migrations ./migrations
COPY data ./data
COPY src ./src COPY src ./src
COPY Cargo.toml ./ COPY Cargo.toml ./
RUN apt-get update && apt-get install -y cmake
RUN cargo build --release RUN cargo build --release
CMD ["./target/release/service"]
# ==========
# Packages
# ==========
FROM debian:bookworm-slim as packages
WORKDIR /packages
RUN apt-get update && apt-get install -y curl tar xz-utils && \
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux > yt-dlp && \
chmod +x yt-dlp && \
curl -L https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz > ffmpeg.tar.xz && \
tar -xJf ffmpeg.tar.xz --wildcards */bin/ffmpeg --transform='s/^.*\///' && rm ffmpeg.tar.xz
# =========
# Runtime
# =========
FROM rust:bookworm as runtime
WORKDIR /service
USER root
COPY --from=builder /builder/target/release/service /usr/local/bin/service
COPY --from=packages /packages /usr/bin
CMD ["service"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
version: '3.8' version: '3.8'
name: siren
services: services:
service: service:
image: siren-service:${SIREN_VERSION:-latest} image: siren-service:${SIREN_VERSION:-latest}
@@ -14,8 +15,11 @@ services:
environment: environment:
DATABASE_HOST: db DATABASE_HOST: db
DATABASE_PORT: 5432 DATABASE_PORT: 5432
SERVICE_HOST: siren SERVICE_HOST: service
SERVICE_PORT: 5000 SERVICE_PORT: 5000
DATA_DIR_PATH: /data
volumes:
- ${DATA_DIR_PATH}:/data
ports: ports:
- ${SERVICE_PORT:-5000}:5000 - ${SERVICE_PORT:-5000}:5000
depends_on: depends_on:

View File

@@ -5,7 +5,7 @@ use serenity::builder::CreateApplicationCommand;
use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; use serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
use songbird::EventHandler; use songbird::EventHandler;
use crate::commands::audio::{join, leave, add_song, get_songbird, AudioConfigs}; use crate::bot::commands::audio::{join, leave, add_song, get_songbird, AudioConfigs};
use super::{create_response, edit_response}; use super::{create_response, edit_response};

View File

@@ -4,7 +4,6 @@ use std::sync::Arc;
use commands::audio::{create_response, AudioConfig, AudioConfigs}; use commands::audio::{create_response, AudioConfig, AudioConfigs};
use dotenv::dotenv;
use log::{error, warn, info}; use log::{error, warn, info};
use serenity::async_trait; use serenity::async_trait;
use serenity::framework::StandardFramework; use serenity::framework::StandardFramework;
@@ -15,9 +14,9 @@ use serenity::http::Http;
use serenity::prelude::*; use serenity::prelude::*;
use songbird::SerenityInit; use songbird::SerenityInit;
use crate::commands::oai::GPTModel; use crate::bot::commands::oai::GPTModel;
mod commands; pub mod commands;
struct Handler { struct Handler {
// Open AI Config // Open AI Config
@@ -108,11 +107,7 @@ impl EventHandler for Handler {
} }
} }
#[tokio::main] pub async fn run() {
async fn main() {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); let token: String = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let intents: GatewayIntents = GatewayIntents::all(); let intents: GatewayIntents = GatewayIntents::all();
@@ -171,4 +166,4 @@ async fn main() {
if let Err(why) = client.start_autosharded().await { if let Err(why) = client.start_autosharded().await {
error!("An error occurred while running the client: {:?}", why); error!("An error occurred while running the client: {:?}", why);
} }
} }

View File

@@ -51,6 +51,6 @@ pub fn connection() -> Result<DbConnection, ServiceError> {
.map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e))) .map_err(|e| ServiceError::new(500, format!("Failed getting db connection: {}", e)))
} }
pub fn load_data() { pub fn load_data(data_dir_path: &str) {
spells::load_data(); spells::load_data(data_dir_path);
} }

View File

@@ -2,46 +2,57 @@ mod model;
mod routes; mod routes;
mod types; mod types;
use std::{fs::{metadata, File, read_dir}, path::Path, io::BufReader};
use log::{warn, trace};
pub use model::*; pub use model::*;
pub use types::*; pub use types::*;
pub use routes::init_routes; pub use routes::init_routes;
pub fn load_data() { pub fn load_data(data_dir_path: &str) {
let root_path = std::env::current_dir().unwrap(); if Path::new(data_dir_path).exists() {
let files = [ let meta = metadata(data_dir_path).unwrap();
"cantrips.json", "level_1.json", "level_2.json", "level_3.json", "level_4.json", "level_5.json", "level_6.json", "level_7.json", "level_8.json", "level_9.json" if meta.is_dir() {
]; let spells_dir_path = format!("{}/spells", data_dir_path);
let mut spells: Vec<Spell> = vec![]; if Path::new(&spells_dir_path).exists() {
for file in files { let meta = metadata(&spells_dir_path).unwrap();
let mut data_path = std::path::PathBuf::from(&root_path); if meta.is_dir() {
data_path.push(format!("data/spells/{}", file)); for entry in read_dir(&spells_dir_path).unwrap() {
let path = data_path.to_str().unwrap(); let entry = entry.unwrap();
match std::fs::read_to_string(path) { let path = entry.path();
Ok(data) => { if path.is_file() {
log::debug!("Loading spells from {}", path); let file = File::open(path).unwrap();
match serde_json::from_str::<serde_json::Value>(&data) { let reader = BufReader::new(file);
Ok(json) => { let result: Result<Vec<Spell>, serde_json::Error> = serde_json::from_reader(reader);
match serde_json::from_value::<Vec<Spell>>(json) { match result {
Ok(mut new_spells) => spells.append(&mut new_spells), Ok(spells) => {
Err(err) => log::error!("Failed to parse spells data: {}", err) for spell in spells {
let mut filters = QueryFilters::default();
filters.by_name = Some(spell.name.clone());
match QuerySpell::get_all(&filters, 100, 1) {
Ok(spells) => {
if spells.len() > 0 {
trace!("Spell '{}' already exists", spell.name);
continue;
}
},
Err(err) => {
warn!("Error checking if spell '{}' exists: {}", spell.name, err);
continue;
}
};
let spell = InsertSpell::insert(spell.into()).unwrap();
trace!("Inserted spell: {}", spell.name);
}
},
Err(err) => warn!("Error reading spells from file: {}", err)
};
} }
}, }
Err(err) => log::error!("Failed to parse spells data to value: {}", err) }
}; }
},
Err(err) => log::error!("Failed to read from {}: {}", file, err)
};
}
let count = QuerySpell::get_count(&QueryFilters::default()).unwrap();
if count >= spells.len() as i64 {
log::warn!("Spell data is already loaded");
return;
}
for spell in spells {
let spell_name = spell.name.clone();
match InsertSpell::insert(spell.into()) {
Ok(_) => {},
Err(err) => log::error!("Failed to insert '{}' spell: {}", spell_name, err)
} }
} else {
warn!("Data path '{}' does not exist, no data imported", data_dir_path);
} }
} }

View File

@@ -4,9 +4,9 @@ use siren::ServiceError;
use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType}; use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::ConditionType};
use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType}; use super::{SchoolType, CastingTime, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType, Effect};
#[derive(Queryable, QueryableByName)] #[derive(Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = spells)] #[diesel(table_name = spells)]
pub struct QuerySpell { pub struct QuerySpell {
pub id: i32, pub id: i32,
@@ -27,6 +27,7 @@ pub struct QuerySpell {
#[derive(Debug)] #[derive(Debug)]
pub struct QueryFilters { pub struct QueryFilters {
pub by_name: Option<String>, pub by_name: Option<String>,
pub like_name: Option<String>,
pub by_schools: Option<Vec<String>>, pub by_schools: Option<Vec<String>>,
pub by_levels: Option<Vec<i32>>, pub by_levels: Option<Vec<i32>>,
pub by_ritual: Option<bool>, pub by_ritual: Option<bool>,
@@ -43,6 +44,7 @@ impl Default for QueryFilters {
fn default() -> Self { fn default() -> Self {
Self { Self {
by_name: None, by_name: None,
like_name: None,
by_schools: None, by_schools: None,
by_levels: None, by_levels: None,
by_ritual: None, by_ritual: None,
@@ -65,6 +67,9 @@ impl QuerySpell {
let offset = (page - 1) * limit; let offset = (page - 1) * limit;
query = query.offset(offset as i64); query = query.offset(offset as i64);
if let Some(name) = &filters.by_name { if let Some(name) = &filters.by_name {
query = query.filter(spells::name.eq(name));
}
if let Some(name) = &filters.like_name {
query = query.filter(spells::name.ilike(format!("%{}%", name))); query = query.filter(spells::name.ilike(format!("%{}%", name)));
} }
if let Some(schools) = &filters.by_schools { if let Some(schools) = &filters.by_schools {
@@ -191,12 +196,15 @@ impl InsertSpell {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Spell { pub struct Spell {
pub id: Option<i32>,
pub name: String, pub name: String,
pub school: SchoolType, pub school: SchoolType,
pub level: i32, pub level: i32,
pub ritual: bool, pub ritual: bool,
pub casting_time: CastingTime, pub casting_time: CastingTime,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub effect: Option<Effect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub saving_throw: Option<Vec<AbilityType>>, pub saving_throw: Option<Vec<AbilityType>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub attack_type: Option<SpellAttackType>, pub attack_type: Option<SpellAttackType>,
@@ -226,17 +234,19 @@ impl From<QuerySpell> for Spell {
Err(err) => { Err(err) => {
log::error!("Failed to parse spell: {}", err); log::error!("Failed to parse spell: {}", err);
Self { Self {
id: None,
name: "".to_string(), name: "".to_string(),
school: SchoolType::Abjuration, school: SchoolType::Abjuration,
level: 0, level: 0,
ritual: false, ritual: false,
casting_time: CastingTime { amount: 0, casting_type: CastingType::Action }, casting_time: CastingTime { value: 0, casting_type: "".to_string(), note: None },
effect: None,
saving_throw: None, saving_throw: None,
attack_type: None, attack_type: None,
damage_inflict: None, damage_inflict: None,
damage_resist: None, damage_resist: None,
conditions: None, conditions: None,
range: Range { range_type: "".to_string(), amount: None, unit: None }, range: Range { range_type: "".to_string(), value: None, unit: None },
area: None, area: None,
components: Components { verbal: false, somatic: false, material: false, materials_needed: None, materials_cost: None, materials_consumed: None }, components: Components { verbal: false, somatic: false, material: false, materials_needed: None, materials_cost: None, materials_consumed: None },
durations: vec![], durations: vec![],

View File

@@ -10,6 +10,7 @@ use super::{Spell, InsertSpell};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct GetAllParams { struct GetAllParams {
name: Option<String>, name: Option<String>,
like_name: Option<String>,
schools: Option<String>, schools: Option<String>,
levels: Option<String>, levels: Option<String>,
ritual: Option<bool>, ritual: Option<bool>,
@@ -35,6 +36,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
}; };
let mut filters = QueryFilters::default(); let mut filters = QueryFilters::default();
filters.by_name = params.name.clone(); filters.by_name = params.name.clone();
filters.like_name = params.like_name.clone();
filters.by_schools = match &params.schools { filters.by_schools = match &params.schools {
Some(schools) => Some(schools.split(",").map(|s| s.to_string()).collect()), Some(schools) => Some(schools.split(",").map(|s| s.to_string()).collect()),
None => None None => None
@@ -73,7 +75,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
None => None None => None
}; };
// Limit must be between 1 and 100 // Limit must be between 1 and 100
let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(20), 1), 100); let limit = std::cmp::min(std::cmp::max(params.limit.unwrap_or(100), 1), 100);
let total_count = QuerySpell::get_count(&filters).unwrap(); let total_count = QuerySpell::get_count(&filters).unwrap();
let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1); let max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1);
// Page must be between 1 and max_page // Page must be between 1 and max_page
@@ -82,8 +84,11 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
match web::block(move || QuerySpell::get_all(&filters, limit, page)).await.unwrap() { match web::block(move || QuerySpell::get_all(&filters, limit, page)).await.unwrap() {
Ok(spells) => { Ok(spells) => {
let mut response: Vec<Spell> = Vec::new(); let mut response: Vec<Spell> = Vec::new();
for spell in spells { for query_spell in spells {
response.push(Spell::from(spell)); let id = query_spell.id;
let mut spell = Spell::from(query_spell);
spell.id = Some(id);
response.push(spell);
} }
HttpResponse::Ok().json(GetResponse { HttpResponse::Ok().json(GetResponse {
data: response, data: response,
@@ -112,10 +117,15 @@ async fn get_by_id(id: web::Path<String>) -> HttpResponse {
}) })
}; };
match web::block(move || QuerySpell::get_by_id(id)).await.unwrap() { match web::block(move || QuerySpell::get_by_id(id)).await.unwrap() {
Ok(spell) => HttpResponse::Ok().json(GetResponse { Ok(query_spell) => {
data: Spell::from(spell), let id = query_spell.id;
metadata: None let mut spell = Spell::from(query_spell);
}), spell.id = Some(id);
HttpResponse::Ok().json(GetResponse {
data: spell,
metadata: None
})
},
Err(err) => { Err(err) => {
error!("{:?}", err.message); error!("{:?}", err.message);
ResponseError::error_response(&err) ResponseError::error_response(&err)

View File

@@ -56,52 +56,12 @@ impl FromStr for SchoolType {
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct CastingTime { pub struct CastingTime {
pub amount: i32, pub value: i32,
#[serde(rename = "unit")] #[serde(rename = "unit")]
pub casting_type: CastingType pub casting_type: String,
pub note: Option<String>
} }
#[derive(Debug, Serialize, Deserialize)]
pub enum CastingType {
#[serde(rename = "action")]
Action,
#[serde(rename = "bonus")]
BonusAction,
#[serde(rename = "reaction")]
Reaction,
#[serde(rename = "minutes")]
Minutes,
#[serde(rename = "hours")]
Hours
}
// impl CastingType {
// pub fn to_string(&self) -> String {
// match self {
// CastingType::Action => "action".to_string(),
// CastingType::BonusAction => "bonus".to_string(),
// CastingType::Reaction => "reaction".to_string(),
// CastingType::Minutes => "minutes".to_string(),
// CastingType::Hours => "hours".to_string()
// }
// }
// }
// impl FromStr for CastingType {
// type Err = ();
// fn from_str(s: &str) -> Result<Self, Self::Err> {
// match s {
// "action" => Ok(CastingType::Action),
// "bonus" => Ok(CastingType::BonusAction),
// "reaction" => Ok(CastingType::Reaction),
// "minutes" => Ok(CastingType::Minutes),
// "hours" => Ok(CastingType::Hours),
// _ => Err(())
// }
// }
// }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub enum SpellAttackType { pub enum SpellAttackType {
#[serde(rename = "melee")] #[serde(rename = "melee")]
@@ -209,7 +169,7 @@ pub struct Range {
#[serde(rename = "type")] #[serde(rename = "type")]
pub range_type: String, pub range_type: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>, pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String> pub unit: Option<String>
} }
@@ -219,7 +179,7 @@ pub struct Area {
#[serde(rename = "type")] #[serde(rename = "type")]
pub area_type: AreaType, pub area_type: AreaType,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>, pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String> pub unit: Option<String>
} }
@@ -270,7 +230,7 @@ pub struct Duration {
#[serde(rename = "type")] #[serde(rename = "type")]
pub duration_type: DurationType, pub duration_type: DurationType,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>, pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String> pub unit: Option<String>
} }
@@ -303,8 +263,15 @@ pub struct Description {
#[derive(Debug)] #[derive(Debug)]
pub struct Entry { pub struct Entry {
pub entry_type: String, pub text: Option<Vec<String>>,
pub items: Vec<String> pub list: Option<Vec<String>>,
pub table: Option<EntryTable>
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EntryTable {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>
} }
impl<'de> Deserialize<'de> for Entry { impl<'de> Deserialize<'de> for Entry {
@@ -312,36 +279,82 @@ impl<'de> Deserialize<'de> for Entry {
let value = serde_json::Value::deserialize(deserializer)?; let value = serde_json::Value::deserialize(deserializer)?;
match value { match value {
serde_json::Value::String(s) => Ok(Entry { serde_json::Value::String(s) => Ok(Entry {
entry_type: "string".to_string(), text: Some(vec![s]),
items: vec![s] list: None,
table: None,
}), }),
serde_json::Value::Object(o) => { serde_json::Value::Object(o) => {
let entry_type = match o.get("type") { let list = match o.get("list") {
Some(t) => match t.as_str() {
Some(s) => s.to_string(),
None => return Err(serde::de::Error::custom("Invalid entry type"))
},
None => return Err(serde::de::Error::custom("Missing entry type"))
};
let items = match o.get("items") {
Some(i) => match i.as_array() { Some(i) => match i.as_array() {
Some(a) => { Some(a) => {
let mut items = Vec::new(); let mut list = Vec::new();
for item in a { for item in a {
match item.as_str() { match item.as_str() {
Some(s) => items.push(s.to_string()), Some(s) => list.push(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry item")) None => return Err(serde::de::Error::custom("Invalid entry list item"))
} }
} }
items Some(list)
}, },
None => return Err(serde::de::Error::custom("Invalid entry items")) None => return Err(serde::de::Error::custom("Invalid entry list items"))
}, },
None => return Err(serde::de::Error::custom("Missing entry items")) None => None
};
let table = match o.get("table") {
Some(t) => match t.as_object() {
Some(o) => {
let mut headers = Vec::new();
let mut rows = Vec::new();
match o.get("headers") {
Some(c) => match c.as_array() {
Some(a) => {
for item in a {
match item.as_str() {
Some(s) => headers.push(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry table header"))
}
}
},
None => return Err(serde::de::Error::custom("Invalid entry table headers"))
},
None => return Err(serde::de::Error::custom("Missing entry table headers"))
};
match o.get("rows") {
Some(r) => match r.as_array() {
Some(a) => {
for row in a {
match row.as_array() {
Some(a) => {
let mut row = Vec::new();
for item in a {
match item.as_str() {
Some(s) => row.push(s.to_string()),
None => return Err(serde::de::Error::custom("Invalid entry table row item"))
}
}
rows.push(row);
},
None => return Err(serde::de::Error::custom("Invalid entry table row"))
}
}
},
None => return Err(serde::de::Error::custom("Invalid entry table rows"))
},
None => return Err(serde::de::Error::custom("Missing entry table rows"))
};
Some(EntryTable {
headers,
rows
})
},
None => return Err(serde::de::Error::custom("Invalid entry table"))
},
None => None
}; };
Ok(Entry { Ok(Entry {
entry_type, text: None,
items list,
table
}) })
}, },
_ => Err(serde::de::Error::custom("Invalid entry")) _ => Err(serde::de::Error::custom("Invalid entry"))
@@ -351,15 +364,17 @@ impl<'de> Deserialize<'de> for Entry {
impl Serialize for Entry { impl Serialize for Entry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer {
match self.entry_type.as_str() { let mut map = serializer.serialize_map(Some(1))?;
"string" => serializer.serialize_str(&self.items[0]), if let Some(text) = &self.text {
_ => { map.serialize_entry("text", text)?;
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("type", &self.entry_type)?;
map.serialize_entry("items", &self.items)?;
map.end()
}
} }
if let Some(list) = &self.list {
map.serialize_entry("list", list)?;
}
if let Some(table) = &self.table {
map.serialize_entry("table", table)?;
}
map.end()
} }
} }
@@ -374,4 +389,9 @@ pub struct Components {
pub materials_cost: Option<i32>, pub materials_cost: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub materials_consumed: Option<bool> pub materials_consumed: Option<bool>
} }
#[derive(Debug, Serialize, Deserialize)]
pub struct Effect {
pub effect_type: Option<String>
}

View File

@@ -4,11 +4,13 @@ extern crate diesel_migrations;
use std::env; use std::env;
use actix_cors::Cors;
use actix_web::{HttpServer, App}; use actix_web::{HttpServer, App};
use dotenv::dotenv; use dotenv::dotenv;
use log::{error, info}; use log::{error, info, warn};
mod bot;
mod db; mod db;
#[actix_web::main] #[actix_web::main]
@@ -16,15 +18,26 @@ async fn main() -> std::io::Result<()> {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info")); env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
db::init(); db::init();
db::load_data(); match env::var("DATA_DIR_PATH") {
Ok(data_dir_path) => db::load_data(&data_dir_path),
Err(err) => warn!("Unable to load initial database data: {}", err)
};
let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string()); let host = env::var("SERVICE_HOST").unwrap_or("localhost".to_string());
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string()); let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
tokio::spawn(bot::run());
match HttpServer::new(|| { match HttpServer::new(|| {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.max_age(3600);
App::new() App::new()
.configure(db::messages::init_routes) .configure(db::messages::init_routes)
.configure(db::spells::init_routes) .configure(db::spells::init_routes)
.wrap(cors)
}) })
.bind(format!("{}:{}", host, port)) { .bind(format!("{}:{}", host, port)) {
Ok(b) => { Ok(b) => {

5
ui/.env.TEMPLATE Normal file
View File

@@ -0,0 +1,5 @@
SERVICE_HOST=service
SERVICE_PORT=5000
UI_PORT=8080
NODE_ENV=development

17
ui/.eslintrc.json Executable file
View File

@@ -0,0 +1,17 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint/eslint-plugin"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"rules": {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}

1
ui/.nvmrc Normal file
View File

@@ -0,0 +1 @@
18.17.1

8
ui/.prettierrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"printWidth": 120
}

39
ui/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# Base
FROM node:18-alpine AS base
# Dependencies
FROM base as deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Dev
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Runner
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV NEXT_TELEMETRY_DISABLED 1
CMD ["node", "server.js"]

View File

@@ -1,27 +1,26 @@
#!make #!make
SHELL := /bin/bash SHELL := /bin/bash
include .env .PHONY: help build start stop lint
.PHONY: help build test up down exec clean help: ## This info
help: ## Help command
@echo @echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo @echo
build: ## Build the docker image build: ## Install the dependencies and build
docker compose build docker compose build
db: ## Start the docker database up: ## Start the dev instance
docker compose up -d db
up: ## Start the app
docker compose up -d docker compose up -d
down: ## Stop the app down: ## Stop the dev instance
docker compose down docker compose down
clean: lint: ## Run the linter
npm run lint
clean: ## Remove node modules
docker compose down && \ docker compose down && \
docker image rm siren-bot docker image rm siren-ui

26
ui/docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
version: '3'
name: siren
services:
ui:
container_name: siren-ui
env_file:
- .env
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-8080}:3000
build:
context: ./
target: dev
command: "npm run dev"
volumes:
- ./src:/app/src
- ./public:/app/public
- ./styles:/app/styles
networks:
- siren-frontend
restart: unless-stopped
networks:
siren-frontend: {}

5
ui/next-env.d.ts vendored Executable file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

22
ui/next.config.js Executable file
View File

@@ -0,0 +1,22 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
eslint: {
ignoreDuringBuilds: true
},
webpackDevMiddleware: (config) => {
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300
};
return config;
},
publicRuntimeConfig: {
// remove private variables from processEnv
processEnv: Object.fromEntries(Object.entries(process.env).filter(([key]) => key.includes('NEXT_PUBLIC_')))
},
output: 'standalone'
};
module.exports = nextConfig;

5425
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
ui/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "siren-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@mantine/core": "^7.1.2",
"@mantine/hooks": "^7.1.2",
"@mantine/modals": "^7.1.2",
"@mantine/notifications": "^7.1.2",
"axios": "^1.5.1",
"next": "^13.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
"react-leaflet": "^4.2.1",
"recharts": "^2.8.0",
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/node": "20.8.2",
"@types/react": "18.2.24",
"@types/react-dom": "18.2.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.16",
"eslint": "8.50.0",
"eslint-config-next": "13.5.4",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"postcss": "^8.4.31",
"postcss-import": "^15.1.0",
"postcss-preset-mantine": "^1.8.0",
"prettier": "^3.0.3",
"typescript": "5.2.2"
}
}

7
ui/postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-import': {},
autoprefixer: {}
}
};

BIN
ui/public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
ui/public/layers-2x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
ui/public/layers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
ui/public/marker-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
ui/public/marker-shadow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

4
ui/public/vercel.svg Executable file
View File

@@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

25
ui/src/api/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import axios, { AxiosResponse } from 'axios';
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
const servicePort = process.env.SERVICE_PORT || 5000;
export async function getRequest(endpoint: string, params: any): Promise<AxiosResponse<any, any> | undefined> {
const response = await axios
.get(`${serviceHost}:${servicePort}/${endpoint}`, { params })
.catch((error) => console.error(error));
return response || undefined;
}
export async function postRequest(endpoint: string, body: any): Promise<AxiosResponse<any, any> | undefined> {
const response = await axios
.post(`${serviceHost}:${servicePort}/${endpoint}`, { body })
.catch((error) => console.error(error));
return response || undefined;
}
export interface Metadata {
limit: number;
page: number;
pages: number;
total: number;
}

39
ui/src/api/spells.ts Normal file
View File

@@ -0,0 +1,39 @@
import { getRequest } from '.';
import { GetSpellsResponse } from './spells.types';
interface GetSpellsParams {
name?: string;
like_name?: string;
schools?: string[];
levels?: number[];
ritual?: boolean;
concentration?: boolean;
classes?: string[];
damage_inflict?: string[];
damage_resist?: string[];
conditions?: string[];
saving_throw?: string[];
attack_type?: string[];
limit?: number;
page?: number;
}
export async function getSpells(params?: GetSpellsParams): Promise<GetSpellsResponse> {
const response = await getRequest('spells', {
name: params?.name,
like_name: params?.like_name,
schools: params?.schools?.join(','),
levels: params?.levels?.join(','),
ritual: params?.ritual,
concentration: params?.concentration,
classes: params?.classes?.join(','),
damage_inflict: params?.damage_inflict?.join(','),
damage_resist: params?.damage_resist?.join(','),
conditions: params?.conditions?.join(','),
saving_throw: params?.saving_throw?.join(','),
attack_type: params?.attack_type?.join(','),
limit: params?.limit,
page: params?.page
});
return response?.data || { data: [] };
}

View File

@@ -0,0 +1,82 @@
import { Metadata } from '.';
export interface Spell {
id: string;
name: string;
school: string;
level: number;
ritual: boolean;
casting_time: CastingTime;
saving_throw?: string[];
attack_type?: string;
damage_inflict?: string[];
damage_resist?: string[];
conditions?: string[];
range: Range;
area?: Area;
components: Components;
durations: Duration[];
classes: string[];
sources: Source[];
tags?: string[];
description?: Description;
}
export interface CastingTime {
value: number;
unit: string;
}
export interface Range {
type: string;
value?: number;
unit?: string;
}
export interface Area {
type: string;
value?: number;
unit?: string;
}
export interface Components {
verbal: boolean;
somatic: boolean;
material: boolean;
materials_needed?: string;
materials_cost?: number;
materials_consumed?: boolean;
}
export interface Duration {
type: string;
value?: number;
unit?: string;
// concentration: boolean;
}
export interface Source {
source: string;
page?: number;
}
export interface Description {
entries: EntryType[];
}
type EntryType = string | Entry;
export interface Entry {
type: string;
items: string[];
}
export interface GetSpellResponse {
data: Spell;
metadata: Metadata;
}
export interface GetSpellsResponse {
data: Spell[];
metadata: Metadata;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

40
ui/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper';
import Topbar from '@/components/Topbar';
import { Inter } from 'next/font/google';
import { Box, MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import 'styles/globals.css';
import '@mantine/core/styles.css';
import '@mantine/notifications/styles.css';
export const metadata = {
title: 'Siren',
description: ''
};
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en' className='h-full bg-white'>
<head>
<title>Siren</title>
</head>
<body className={`${inter.className} wrapper h-full`}>
<RecoilRootWrapper>
<MantineProvider>
<Notifications />
<ModalsProvider>
<Topbar />
<Box p='xl' pt='sm' className='h-full'>
{children}
</Box>
</ModalsProvider>
</MantineProvider>
</RecoilRootWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

5
ui/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function Page() {
return <></>;
}

View File

@@ -0,0 +1,8 @@
'use client';
import { RecoilRoot } from 'recoil';
import React, { ReactNode } from 'react';
export default function RecoilRootWrapper({ children }: { children: ReactNode }) {
return <RecoilRoot>{children}</RecoilRoot>;
}

View File

@@ -0,0 +1,91 @@
'use client';
import { getSpells } from '@/api/spells';
import { Spell } from '@/api/spells.types';
import SpellModal from '@/components/SpellModal';
import React, { useEffect, useState } from 'react';
import './spells.css';
import { Box, TextInput } from '@mantine/core';
import { AiOutlineVerticalAlignTop } from 'react-icons/ai';
export default function Page() {
const [cantrips, setCantrips] = useState<Spell[]>([]);
const [level1, setLevel1] = useState<Spell[]>([]);
const [level2, setLevel2] = useState<Spell[]>([]);
const [level3, setLevel3] = useState<Spell[]>([]);
const [level4, setLevel4] = useState<Spell[]>([]);
const [level5, setLevel5] = useState<Spell[]>([]);
const [level6, setLevel6] = useState<Spell[]>([]);
const [level7, setLevel7] = useState<Spell[]>([]);
const [level8, setLevel8] = useState<Spell[]>([]);
const [level9, setLevel9] = useState<Spell[]>([]);
const [activeSpell, setActiveSpell] = useState<Spell | undefined>(undefined);
const [isOpen, setIsOpen] = useState(false);
const [searchName, setSearchName] = useState('');
useEffect(() => {
getSpells({ levels: [0] }).then((s) => setCantrips(s.data));
getSpells({ levels: [1] }).then((s) => setLevel1(s.data));
getSpells({ levels: [2] }).then((s) => setLevel2(s.data));
getSpells({ levels: [3] }).then((s) => setLevel3(s.data));
getSpells({ levels: [4] }).then((s) => setLevel4(s.data));
getSpells({ levels: [5] }).then((s) => setLevel5(s.data));
getSpells({ levels: [6] }).then((s) => setLevel6(s.data));
getSpells({ levels: [7] }).then((s) => setLevel7(s.data));
getSpells({ levels: [8] }).then((s) => setLevel8(s.data));
getSpells({ levels: [9] }).then((s) => setLevel9(s.data));
}, []);
return (
<Box style={{ width: '60%', margin: 'auto' }}>
<h1>Spells</h1>
<TextInput
label='Search by name'
placeholder='Acid Splash...'
onChange={(e) => setSearchName(e.target.value)}
style={{ width: '25%' }}
/>
<SpellSection
title='Cantrips'
spells={cantrips.filter((s) => s.name.toLowerCase().includes(searchName.toLowerCase()))}
onClick={(spell) => {
setActiveSpell(spell);
setIsOpen(true);
}}
/>
<hr />
{activeSpell && <SpellModal spell={activeSpell} isOpen={isOpen} onClose={() => setIsOpen(false)} />}
</Box>
);
}
function SpellSection({ title, spells, onClick }: { title: string; spells: Spell[]; onClick: (spell: Spell) => void }) {
const isBrowser = () => typeof window !== 'undefined'; //The approach recommended by Next.js
function scrollToTop() {
if (!isBrowser()) return;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
return (
<Box>
<h2>{title}</h2>
<ul>
{spells.map((spell) => (
<li
key={spell.id}
className='link spell-item'
style={{ width: 'fit-content' }}
onClick={() => onClick(spell)}
>
{spell.name}
</li>
))}
</ul>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'gray' }} onClick={scrollToTop}>
<span style={{ paddingRight: '0.2em' }}>Back to top</span>
<AiOutlineVerticalAlignTop />
</div>
</Box>
);
}

View File

@@ -0,0 +1,7 @@
.spell-item {
padding: 0.2rem;
}
.spell-item:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,153 @@
'use client';
import { Spell } from '@/api/spells.types';
import { levelText, rollDice } from '@/js/spells';
import { capitalize } from '@/js/utils';
import { Grid, Modal } from '@mantine/core';
import { notifications } from '@mantine/notifications';
interface SpellModalProps {
spell: Spell;
isOpen: boolean;
onClose(): void;
}
export default function SpellModal({ spell, isOpen, onClose }: SpellModalProps) {
return (
<Modal opened={isOpen} onClose={onClose} withCloseButton={false} size={'50%'} className='modal'>
<h1 style={{ padding: '0', margin: '0' }}>{spell.name}</h1>
<Grid gutter={1}>
<Grid.Col span={4} style={{ paddingBottom: '1rem' }}>
<span style={{ fontWeight: 'bold' }}>
{capitalize(spell.school)} {levelText(spell)}
</span>
</Grid.Col>
<Grid.Col span={8} style={{ paddingBottom: '1rem' }}>
<div style={{ float: 'right' }}>
<span style={{ float: 'right' }}>
{spell.components.verbal && spell.components.somatic ? 'V, ' : 'V '}
{spell.components.somatic && spell.components.material ? 'S, ' : 'S '}
{spell.components.material && spell.components.materials_needed ? 'M*' : 'M'}
</span>
{spell.components.materials_needed && (
<span style={{ fontSize: '0.8em', color: 'gray' }}>
<br />*{capitalize(spell.components.materials_needed)}
</span>
)}
</div>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Sources:</span>
{spell.sources.map((s) => (
<span style={{ paddingRight: '0.6em' }}>
{s.source}
{s.page ? `.${s.page}` : ''}
</span>
))}
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', marginRight: '1em' }}>Classes:</span>
<span style={{ overflowWrap: 'break-word' }}>
{spell.classes.map((c) => (
<span style={{ paddingRight: '0.6em', display: 'inline-block' }} className='link'>
{capitalize(c)}
</span>
))}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Casting Time:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.casting_time.value} {capitalize(spell.casting_time.unit)}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Range:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.range.type != 'point' && capitalize(spell.range.type)} {spell.range.value}{' '}
{capitalize(spell.range.unit)}
</span>
</Grid.Col>
<Grid.Col span={6}>
<span style={{ fontWeight: 'bold', paddingRight: '1em' }}>Duration:</span>
<span style={{ paddingRight: '0.6em' }}>
{spell.durations.map((d) => (
<span>
{capitalize(d.type)} {d.value} {capitalize(d.unit)}
</span>
))}
</span>
</Grid.Col>
<Grid.Col span={12}>
<SpellDescription spell={spell} />
</Grid.Col>
</Grid>
</Modal>
);
}
function SpellDescription({ spell }: { spell: Spell }) {
function parseText(text: string) {
const regex = /{@(.*?) (.*?)}/g;
const matches = text.matchAll(regex);
const result = [];
let lastIndex = 0;
for (const match of matches) {
const [full, type, name] = match;
result.push(text.slice(lastIndex, match.index));
if (match.index !== undefined) {
result.push(
<span onClick={() => handleLink(type, name)} className='link'>
{name}
</span>
);
lastIndex = match.index + full.length;
}
}
result.push(text.slice(lastIndex));
return result;
}
function handleLink(type: string, name: string) {
if (type == 'spell') {
console.log(`Link to spell: ${name}`);
} else if (type == 'dice' || type == 'damage') {
const rolls = rollDice(name);
notifications.show({
title: `Rolling ${name}`,
message: `${rolls.join(' + ')} = ${rolls.reduce((a, b) => a + b, 0)}`,
color: 'blue',
autoClose: 5000,
withCloseButton: false
});
} else {
console.error(`Unknown link type: ${type}`);
}
}
return (
<>
{spell.description && (
<>
{spell.description.entries.map((e) =>
typeof e === 'string' ? (
<p>{parseText(e)}</p>
) : (
<>
{e.type == 'list' ? (
<ul>
{e.items.map((text) => (
<li>{parseText(text)}</li>
))}
</ul>
) : (
<></>
)}
</>
)
)}
</>
)}
</>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import './topbar.css';
const headerItems = [
{
name: 'Races',
link: '/races'
},
{
name: 'Classes',
link: '/classes'
},
{
name: 'Feats',
link: '/feats'
},
{
name: 'Options & Features',
link: '/options'
},
{
name: 'Backgrounds',
link: '/backgrounds'
},
{
name: 'Items',
link: '/items'
},
{
name: 'Spells',
link: '/spells'
}
];
export default function Topbar() {
const pathName = usePathname();
return (
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
Siren
</Link>
<div className='header-items'>
{headerItems.map((item) => (
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link} key={item.name}>
{item.name}
</Link>
))}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,47 @@
.navbar {
display: flex;
justify-content: space-between;
color: black;
border-bottom: 1px solid #e6e6e6;
}
.navbar .left {
display: flex;
}
.navbar .title {
padding-left: 2em;
padding-right: 2em;
margin: auto;
font-size: x-large;
}
.navbar .left .search {
margin: auto;
}
.navbar .avatar {
padding-right: 2em;
margin-top: auto;
margin-bottom: auto;
}
.header-items {
display: flex;
justify-content: space-between;
}
.header-items .header-item {
padding-left: 2em;
padding-right: 2em;
margin: auto;
border-bottom: 2px solid transparent;
}
.header-items .header-item:hover {
border-bottom: 2px solid #e6e6e6;
}
.header-items .active {
border-bottom: 2px solid #5f5f5f;
}

23
ui/src/js/spells.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Spell } from '@/api/spells.types';
export function levelText(spell: Spell) {
if (spell.level === 0) {
return 'Cantrip';
} else {
return `Level ${spell.level}`;
}
}
export function rollDice(dice: string): number[] {
// eslint-disable-next-line prefer-const
let [count, sides] = dice.split('d');
const rolls = [];
if (isNaN(parseInt(count))) {
count = '1';
}
for (let i = 0; i < parseInt(count); i++) {
rolls.push(Math.floor(Math.random() * parseInt(sides)) + 1);
}
console.log(rolls);
return rolls;
}

5
ui/src/js/theme.ts Normal file
View File

@@ -0,0 +1,5 @@
'use client';
import { createTheme } from '@mantine/core';
export const theme = createTheme({});

6
ui/src/js/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
export function capitalize(str: string | undefined): string {
if (!str || str.length === 0) {
return '';
}
return str.charAt(0).toUpperCase() + str.slice(1);
}

38
ui/styles/globals.css Executable file
View File

@@ -0,0 +1,38 @@
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
.content {
display: flex;
flex-direction: row;
flex: 1;
overflow: hidden;
}
.wrapper > nav {
flex: 0 0 56px;
overflow: hidden;
}
.link {
list-style-type: none;
cursor: pointer;
color: #297bff;
}
.link:hover {
color: #1c59bb;
}

45
ui/tsconfig.json Executable file
View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
"target": "ESNext",
"downlevelIteration": true,
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@api/*": ["src/api"],
"@app/*": ["./src/app/*"],
"@components/*": ["src/components/*"],
"@lib/*": ["src/components/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}