This commit is contained in:
Benjamin Sherriff
2023-10-05 09:07:53 -04:00
parent ac17be838a
commit 1b41849115
54 changed files with 6473 additions and 129 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,5 +1,4 @@
RUST_LOG=warn,bot=info
COMPOSE_PROJECT_NAME=siren
SERVICE_HOST=localhost
SERVICE_PORT=5000

View File

@@ -1,5 +1,6 @@
version: '3.8'
name: siren
services:
bot:
image: siren-bot:${BOT_VERSION:-latest}

View File

@@ -1,5 +1,4 @@
RUST_LOG=warn,service=info
COMPOSE_PROJECT_NAME=siren
DATABASE_USER=siren
DATABASE_PASSWORD=

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "service"
version = "0.2.4"
version = "0.2.5"
edition = "2021"
authors = ["Ben Sherriff <hello@bensherriff.com>"]
repository = "https://github.com/bensherriff/siren"
@@ -14,6 +14,7 @@ path = "src/lib.rs"
[dependencies]
actix-web = "4.4.0"
actix-rt = "2.9.0"
actix-cors = "0.6.4"
actix-web-httpauth = "0.8.1"
chrono = { version = "0.4.31", features = ["serde"] }
dotenv = "0.15.0"

View File

@@ -5,12 +5,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"saving_throw": [
@@ -48,7 +48,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -67,7 +67,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -87,7 +87,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -95,7 +95,7 @@
},
"area": {
"type": "sphere",
"amount": 5,
"value": 5,
"unit": "feet"
},
"damage_inflict": [
@@ -111,7 +111,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -133,12 +133,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 120,
"value": 120,
"unit": "feet"
},
"damage_inflict": [
@@ -153,7 +153,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -176,17 +176,17 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"area": {
"type": "cube",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -200,7 +200,7 @@
},
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "hour"
}
],
@@ -231,17 +231,17 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"area": {
"type": "cube",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -252,7 +252,7 @@
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -275,12 +275,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 120,
"value": 120,
"unit": "feet"
},
"components": {
@@ -292,7 +292,7 @@
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -303,7 +303,7 @@
],
"description": {
"entries": [
"You create up to four torch-amountd lights within range, making them appear as torches, lanterns, or glowing orbs that hover in the air for the duration. You can also combine the four lights into one glowing vaguely humanoid form of Medium amount. Whichever form you choose, each light sheds dim light in a 10-foot radius.",
"You create up to four torch-valued lights within range, making them appear as torches, lanterns, or glowing orbs that hover in the air for the duration. You can also combine the four lights into one glowing vaguely humanoid form of Medium value. Whichever form you choose, each light sheds dim light in a 10-foot radius.",
"As a bonus action on your turn, you can move the lights up to 60 feet to a new spot within range. A light must be within 20 feet of another light created by this spell, and a light winks out if it exceeds the spell's range."
]
}
@@ -314,12 +314,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -358,12 +358,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 120,
"value": 120,
"unit": "feet"
},
"damage_inflict": [
@@ -398,7 +398,7 @@
"level": 0,
"ritual": true,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -412,7 +412,7 @@
"durations": [
{
"type": "timed",
"amount": 8,
"value": 8,
"unit": "hours"
}
],
@@ -434,12 +434,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 120,
"value": 120,
"unit": "feet"
},
"damage_inflict": [
@@ -474,7 +474,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -484,12 +484,12 @@
"verbal": false,
"somatic": true,
"material": true,
"materials_needed": "a small amount of makeup applied to the face as this spell is cast"
"materials_needed": "a small value of makeup applied to the face as this spell is cast"
},
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -510,12 +510,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"saving_throw": [
@@ -552,7 +552,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -560,7 +560,7 @@
},
"area": {
"type": "sphere",
"amount": 5,
"value": 5,
"unit": "feet"
},
"damage_inflict": [
@@ -596,7 +596,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -610,7 +610,7 @@
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -631,12 +631,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -674,12 +674,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"saving_throw": [
@@ -716,7 +716,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -724,7 +724,7 @@
},
"area": {
"type": "sphere",
"amount": 20,
"value": 20,
"unit": "feet"
},
"saving_throw": [
@@ -739,7 +739,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "hour"
}
],
@@ -761,12 +761,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "self",
"amount": 15,
"value": 15,
"unit": "feet"
},
"saving_throw": [
@@ -803,12 +803,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -819,7 +819,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -842,7 +842,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "bonus"
},
"range": {
@@ -860,7 +860,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -881,7 +881,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "minutes"
},
"range": {
@@ -916,12 +916,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 120,
"value": 120,
"unit": "feet"
},
"components": {
@@ -933,7 +933,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -955,12 +955,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"saving_throw": [
@@ -996,12 +996,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -1013,7 +1013,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -1037,17 +1037,17 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"area": {
"type": "cube",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -1061,7 +1061,7 @@
},
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "hour"
}
],
@@ -1090,12 +1090,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 10,
"value": 10,
"unit": "feet"
},
"saving_throw": [
@@ -1134,11 +1134,11 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"range": {
"type": "point",
"amount": 10,
"value": 10,
"unit": "feet"
},
"components": {
@@ -1149,7 +1149,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "hour"
}
],
@@ -1183,7 +1183,7 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"range": {
"type": "self"
@@ -1220,7 +1220,7 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"range": {
"type": "self"
@@ -1237,7 +1237,7 @@
"durations": [
{
"type": "timed",
"amount": 10,
"value": 10,
"unit": "minutes"
}
],
@@ -1261,11 +1261,11 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"damage_inflict": [
@@ -1301,7 +1301,7 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"range": {
"type": "touch"
@@ -1315,7 +1315,7 @@
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -1336,12 +1336,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"saving_throw": [
@@ -1378,7 +1378,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1389,7 +1389,7 @@
],
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -1419,17 +1419,17 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"area": {
"type": "cube",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -1443,7 +1443,7 @@
},
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "hour"
}
],
@@ -1473,7 +1473,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "bonus"
},
"range": {
@@ -1492,7 +1492,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -1513,7 +1513,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1551,7 +1551,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
@@ -1584,7 +1584,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1595,7 +1595,7 @@
],
"range": {
"type": "self",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -1625,12 +1625,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -1641,7 +1641,7 @@
"durations": [
{
"type": "timed",
"amount": 1,
"value": 1,
"unit": "minutes"
}
],
@@ -1674,7 +1674,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1683,7 +1683,7 @@
"attack_type": "melee",
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -1715,7 +1715,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1726,7 +1726,7 @@
],
"range": {
"type": "point",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {
@@ -1756,7 +1756,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1767,7 +1767,7 @@
],
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"components": {
@@ -1797,12 +1797,12 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"range": {
"type": "point",
"amount": 30,
"value": 30,
"unit": "feet"
},
"components": {
@@ -1813,7 +1813,7 @@
"durations": [
{
"type": "concentration",
"amount": 1,
"value": 1,
"unit": "round"
}
],
@@ -1834,7 +1834,7 @@
"level": 0,
"ritual": false,
"casting_time": {
"amount": 1,
"value": 1,
"unit": "action"
},
"damage_inflict": [
@@ -1845,7 +1845,7 @@
],
"range": {
"type": "point",
"amount": 60,
"value": 60,
"unit": "feet"
},
"components": {
@@ -1877,7 +1877,7 @@
"ritual": false,
"casting_time": {
"unit": "action",
"amount": 1
"value": 1
},
"damage_inflict": [
"radiant"
@@ -1887,7 +1887,7 @@
],
"range": {
"type": "point",
"amount": 5,
"value": 5,
"unit": "feet"
},
"components": {

View File

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

View File

@@ -6,7 +6,7 @@ use crate::db::{schema::spells::{self}, classes::AbilityType, conditions::Condit
use super::{SchoolType, CastingTime, CastingType, SpellAttackType, SpellDamageType, Range, Area, Components, Duration, Source, Description, DurationType};
#[derive(Queryable, QueryableByName)]
#[derive(Queryable, QueryableByName, Serialize, Deserialize)]
#[diesel(table_name = spells)]
pub struct QuerySpell {
pub id: i32,
@@ -191,6 +191,7 @@ impl InsertSpell {
#[derive(Debug, Serialize, Deserialize)]
pub struct Spell {
pub id: Option<i32>,
pub name: String,
pub school: SchoolType,
pub level: i32,
@@ -226,17 +227,18 @@ impl From<QuerySpell> for Spell {
Err(err) => {
log::error!("Failed to parse spell: {}", err);
Self {
id: None,
name: "".to_string(),
school: SchoolType::Abjuration,
level: 0,
ritual: false,
casting_time: CastingTime { amount: 0, casting_type: CastingType::Action },
casting_time: CastingTime { value: 0, casting_type: CastingType::Action },
saving_throw: None,
attack_type: None,
damage_inflict: None,
damage_resist: 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,
components: Components { verbal: false, somatic: false, material: false, materials_needed: None, materials_cost: None, materials_consumed: None },
durations: vec![],

View File

@@ -73,7 +73,7 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
None => None
};
// 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 max_page = std::cmp::max((total_count as f64 / limit as f64).ceil() as i32, 1);
// Page must be between 1 and max_page
@@ -82,8 +82,11 @@ async fn get_all(req: HttpRequest) -> HttpResponse {
match web::block(move || QuerySpell::get_all(&filters, limit, page)).await.unwrap() {
Ok(spells) => {
let mut response: Vec<Spell> = Vec::new();
for spell in spells {
response.push(Spell::from(spell));
for query_spell in spells {
let id = query_spell.id;
let mut spell = Spell::from(query_spell);
spell.id = Some(id);
response.push(spell);
}
HttpResponse::Ok().json(GetResponse {
data: response,
@@ -112,10 +115,15 @@ async fn get_by_id(id: web::Path<String>) -> HttpResponse {
})
};
match web::block(move || QuerySpell::get_by_id(id)).await.unwrap() {
Ok(spell) => HttpResponse::Ok().json(GetResponse {
data: Spell::from(spell),
metadata: None
}),
Ok(query_spell) => {
let id = query_spell.id;
let mut spell = Spell::from(query_spell);
spell.id = Some(id);
HttpResponse::Ok().json(GetResponse {
data: spell,
metadata: None
})
},
Err(err) => {
error!("{:?}", err.message);
ResponseError::error_response(&err)

View File

@@ -56,7 +56,7 @@ impl FromStr for SchoolType {
#[derive(Debug, Serialize, Deserialize)]
pub struct CastingTime {
pub amount: i32,
pub value: i32,
#[serde(rename = "unit")]
pub casting_type: CastingType
}
@@ -209,7 +209,7 @@ pub struct Range {
#[serde(rename = "type")]
pub range_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>,
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>
}
@@ -219,7 +219,7 @@ pub struct Area {
#[serde(rename = "type")]
pub area_type: AreaType,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>,
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>
}
@@ -270,7 +270,7 @@ pub struct Duration {
#[serde(rename = "type")]
pub duration_type: DurationType,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>,
pub value: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>
}

View File

@@ -4,6 +4,7 @@ extern crate diesel_migrations;
use std::env;
use actix_cors::Cors;
use actix_web::{HttpServer, App};
use dotenv::dotenv;
@@ -22,9 +23,15 @@ async fn main() -> std::io::Result<()> {
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
match HttpServer::new(|| {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.max_age(3600);
App::new()
.configure(db::messages::init_routes)
.configure(db::spells::init_routes)
.wrap(cors)
})
.bind(format!("{}:{}", host, port)) {
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"]

26
ui/Makefile Normal file
View File

@@ -0,0 +1,26 @@
#!make
SHELL := /bin/bash
.PHONY: help build start stop lint
help: ## This info
@echo
@cat Makefile | grep -E '^[a-zA-Z\/_-]+:.*?## .*$$' | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
build: ## Install the dependencies and build
docker compose build
up: ## Start the dev instance
docker compose up -d
down: ## Stop the dev instance
docker compose down
lint: ## Run the linter
npm run lint
clean: ## Remove node modules
docker compose down && \
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;
}

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

@@ -0,0 +1,37 @@
import { getRequest } from '.';
import { GetSpellsResponse } from './spells.types';
interface GetSpellsParams {
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,
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 <></>;
}

0
ui/src/app/bot/page.tsx Normal file
View 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,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"
]
}