diff --git a/Cargo.lock b/Cargo.lock index 743943a..19ff500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,6 +2410,28 @@ dependencies = [ "proc-macro2 1.0.59", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log 0.4.18", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "r2d2_sqlite" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99f31323d6161385f385046738df520e0e8694fa74852d35891fc0be08348ddc" +dependencies = [ + "r2d2", + "rusqlite", + "uuid", +] + [[package]] name = "rand" version = "0.8.5" @@ -2885,6 +2907,15 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3246,6 +3277,8 @@ dependencies = [ "dotenv", "gettext", "poise", + "r2d2", + "r2d2_sqlite", "reqwest", "rocket 0.5.0-rc.3", "rocket_contrib", @@ -3830,6 +3863,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "uuid" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d023da39d1fde5a8a3fe1f3e01ca9632ada0a63e9797de55a879d6e2236277be" +dependencies = [ + "getrandom", + "rand", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 09a886a..c068c0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ derive_more = "0.99.17" dotenv = "0.15.0" gettext = "0.4.0" poise = "0.5.5" +r2d2 = "0.8.10" +r2d2_sqlite = "0.22.0" reqwest = "0.11.18" rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] } rocket_contrib = { version = "0.4.11", features = ["templates"] } diff --git a/src/main.rs b/src/main.rs index 9125773..aaec564 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,8 +52,7 @@ async fn main() { return; } let http = http(); - Database::new(false) - .expect("Failed to load database") + load_database() .load_legacy(&http).await .expect("Failed to load legacy submissions"); }, @@ -64,6 +63,10 @@ async fn main() { } } +fn load_database() -> Database { + Database::new(false).expect("Failed to load database") +} + fn http() -> Http { let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); Http::new(&token) @@ -81,11 +84,13 @@ async fn rocket() -> Result, rocket::Error> { rocket::custom(config) .manage(Settings::new(&http).await.unwrap()) .manage(http) + .manage(load_database()) .mount( "/", routes![get_challenge, get_guilds, login, post_login, success, logout, testing], ) .mount("/css", FileServer::from(relative!("styles/css"))) + .mount("/", FileServer::from(relative!("assets")).rank(2)) .mount("/", FileServer::from(relative!("static")).rank(1)) .attach(Template::custom(move |engines| { use tera::Value; diff --git a/src/models/database.rs b/src/models/database.rs index 8553f0b..8b6a494 100644 --- a/src/models/database.rs +++ b/src/models/database.rs @@ -1,18 +1,32 @@ +use std::collections::HashSet; use std::{fs::File, io::Read, collections::HashMap, path::Path}; use poise::serenity_prelude::Http; -use rusqlite::{Connection, params}; +use r2d2::{Pool, PooledConnection}; +use r2d2_sqlite::SqliteConnectionManager; +use r2d2_sqlite::rusqlite::{self, params}; +use derive_more::From; use crate::{utils::get_challenge_number, models::User}; use super::{LegacySubmission, Submission}; pub struct Database { - conn: Connection, + // Must be Arc because Connection contains RefCell, + // which cannot be shared between threads safely + connection_pool: Pool, } const DATABASE_FILENAME: &str = "database.db"; +#[derive(From, Debug)] +pub enum DatabaseError { + Rusqlite(rusqlite::Error), + Pool(r2d2::Error), +} + +type Result = std::result::Result; + impl Database { pub fn file_exists() -> bool { Path::new(DATABASE_FILENAME).exists() @@ -20,12 +34,14 @@ impl Database { pub fn new( testing: bool, - ) -> rusqlite::Result { - let conn = if testing { - Connection::open_in_memory() + ) -> Result { + let connection_manager = if testing { + SqliteConnectionManager::memory() } else { - Connection::open(DATABASE_FILENAME) - }?; + SqliteConnectionManager::file(DATABASE_FILENAME) + }; + let connection_pool = Pool::new(connection_manager)?; + let conn = connection_pool.get()?; conn.execute( "CREATE TABLE IF NOT EXISTS Submission ( id INTEGER PRIMARY KEY, @@ -46,21 +62,26 @@ impl Database { )", params![], )?; - Ok(Self { conn }) + Ok(Self { connection_pool }) } - pub fn has_submitted(&self, user_id: u64) -> rusqlite::Result { - Ok(self.conn + fn conn(&self) -> std::result::Result, r2d2::Error> { + self.connection_pool.get() + } + + pub fn has_submitted(&self, user_id: u64) -> Result { + Ok(self.conn()? .prepare("SELECT 1 FROM User WHERE id = ?1 LIMIT 1")? .query_row(params![user_id], |_row| Ok(())) .is_ok()) } - pub async fn load_legacy(&self, http: &Http) -> rusqlite::Result<()> { + pub async fn load_legacy(&self, http: &Http) -> Result<()> { let latest_challenge = get_challenge_number(); // HashMap of archived users that are no longer sharing a server with 字ちゃん // Their historical usernames and discriminators will be used let mut archived_users = HashMap::new(); + let conn = self.conn()?; for n in 1..=latest_challenge { println!("Loading legacy challenge {n}/{latest_challenge}..."); let mut file = File::open(format!("data/challenges/{n}.json")).unwrap(); @@ -72,7 +93,7 @@ impl Database { .map(|legacy| (legacy, legacy.parse().unwrap())) { let mut already_updated = false; for submission in submissions { - self.conn.execute( + conn.execute( "INSERT INTO Submission(author_id, timestamp, image, challenge) VALUES (?1, ?2, ?3, ?4)", params![ &submission.author_id, @@ -120,7 +141,7 @@ impl Database { } else { match User::fetch(http, submission.author_id).await { Ok(user) => { - self.conn.execute( + conn.execute( "INSERT INTO User(id, name, discriminator, avatar) VALUES (?1, ?2, ?3, ?4)", params![user.id, user.name, user.discriminator, user.avatar] )?; @@ -137,6 +158,51 @@ impl Database { } Ok(()) } + + pub fn get_challenge_user_data(&self, challenge: u32) -> Result<(Vec, HashMap)> { + let submissions = self.get_submissions(challenge)?; + let users = self.get_users({ + let mut user_ids = HashSet::new(); + for submission in &submissions { + user_ids.insert(submission.author_id); + } + user_ids + })?; + Ok((submissions, users)) + } + + pub fn get_submissions(&self, challenge: u32) -> Result> { + Ok(self.conn()? + .prepare("SELECT author_id, timestamp, image FROM Submission WHERE challenge = ?1")? + .query_map(params![challenge], |row| { + Ok(Submission { + author_id: row.get(0)?, + timestamp: row.get(1)?, + image: row.get(2)?, + }) + })? + .collect::, rusqlite::Error>>()?) + } + + fn get_users(&self, users: HashSet) -> Result> { + let conn = self.conn()?; + // Not sure why derive_more::From is unable to convert these errors + users + .iter() + // u64 must be converted to String for templates + .filter_map(|id| -> Option> { + match conn.prepare("SELECT name, discriminator, avatar FROM User WHERE id = ?1") { + Ok(mut statement) => Some(statement.query_row(params![id], |row| Ok((id.to_string(), User { + id: *id, + name: row.get(0)?, + discriminator: row.get(1)?, + avatar: row.get(2)?, + }))).map_err(|error| DatabaseError::Rusqlite(error))), + Err(error) => Some(Err(DatabaseError::Rusqlite(error))), + } + }) + .collect() + } #[allow(dead_code)] pub fn refresh_users(&self) -> rusqlite::Result<()> { diff --git a/src/models/submission/submission.rs b/src/models/submission/submission.rs index 0ce5ccf..1c1a4c2 100644 --- a/src/models/submission/submission.rs +++ b/src/models/submission/submission.rs @@ -1,8 +1,11 @@ use chrono::{Utc, DateTime}; +use serde::Serialize; // Challenge submission // In the legacy site version, one submission held 1 or more images. // Now, 1 submission = 1 image, and the leaderboard count will be labeled as "participations" + +#[derive(Serialize, Debug)] pub struct Submission { pub author_id: u64, // Some fields might be empty for legacy submissions diff --git a/src/models/user/mod.rs b/src/models/user/mod.rs index c0eeef4..073449c 100644 --- a/src/models/user/mod.rs +++ b/src/models/user/mod.rs @@ -18,7 +18,7 @@ pub trait Username { fn username(&self) -> String; } -#[derive(Default, Deserialize)] +#[derive(Default, Deserialize, Debug)] pub struct User { #[serde(deserialize_with = "deserialize_id")] pub id: u64, diff --git a/src/routes/get_challenge.rs b/src/routes/get_challenge.rs index 4d3dea2..b042546 100644 --- a/src/routes/get_challenge.rs +++ b/src/routes/get_challenge.rs @@ -6,7 +6,7 @@ use rocket_dyn_templates::{context, Template}; use crate::{ cookies::LANG_COOKIE, i18n::DEFAULT as DEFAULT_LANG, - models::{Challenge, Settings, SessionUser}, + models::{Challenge, Settings, SessionUser, Database}, utils::AcceptLanguage, }; @@ -15,12 +15,16 @@ pub async fn get_challenge( challenge: u32, cookies: &CookieJar<'_>, settings: &State, + database: &State, accept_language: AcceptLanguage, ) -> Template { + let (submissions, users) = database.get_challenge_user_data(challenge).unwrap(); Template::render( "index", context! { challenge, + submissions, + users, settings: settings.deref(), lang: cookies .get(LANG_COOKIE) diff --git a/styles/sass/style.scss b/styles/sass/style.scss index fd6da3f..a1be5d2 100644 --- a/styles/sass/style.scss +++ b/styles/sass/style.scss @@ -42,10 +42,10 @@ input[type=number] { #content { width: 100%; + height: 100%; + display: flex; + flex-direction: column; overflow: hidden; - & > div { - padding: 1em; - } nav { --bg: #{$fg}; @@ -97,6 +97,58 @@ input[type=number] { border-radius: 100%; } } + + & > div:first-of-type { + display: flex; + height: 100%; + & > :first-child { + min-width: 40em; + padding: 1em; + } + & > * { + overflow: scroll; + } + } +} + +* { + box-sizing: border-box !important; +} + +#submissions { + display: flex; + flex-direction: column; + height: calc(100% - 2em); + background: rgba(0, 0, 0, 0.125); + $gap: 0.5em; + padding: $gap; + & > div { + width: 100%; + column-count: 3; + margin: 0; + + gap: $gap; + + & > figure { + background: rgba(0, 0, 0, 0.125); + padding: $gap; + width: 100%; + box-sizing: border-box; + margin: 0; + margin-bottom: $gap; + break-inside: avoid-column; + text-align: center; + + & > img { + width: 100%; + cursor: pointer; + &:hover { + border: none; + filter: brightness(1.25); + } + } + } + } } .dropdown:hover > .link, nav > .link:hover { diff --git a/templates/index.html.tera b/templates/index.html.tera index ef3d19e..790e832 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -65,54 +65,88 @@
-

Welcome to Tegaki Tuesday #{{ challenge }}!

-
- - - {% for line in content.japanese %} - {% for subline in line %} -

- {%- for word in subline -%} - {%- if word.dictionary -%} - - {%- endif -%} - {% for segment in word.text -%} - {{ segment.kanji }}({{ segment.furigana | safe }}) +

+

Welcome to Tegaki Tuesday #{{ challenge }}!

+
+ {% if content.translation %} +

+ Translation + {% if content.translation.author %}by {{ content.translation.author }}{% endif %} + {% if content.translation.site %} via + {% if content.translation.site.link %} + {{ content.translation.site.name }} + {% else %} + {{ content.translation.site.name }} + {% endif %} + {%- endif -%} +

+ {%- endif -%} + {%- if content.suggester -%} +

This challenge was suggested by {{ content.suggester }} using the -h suggest command.

+ {%- endif -%}
- {% if content.translation %} -

- Translation - {% if content.translation.author %}by {{ content.translation.author }}{% endif %} - {% if content.translation.site %} via - {% if content.translation.site.link %} - {{ content.translation.site.name }} - {% else %} - {{ content.translation.site.name }} - {% endif %} - {%- endif -%} -

- {%- endif -%} - {%- if content.suggester -%} -

This challenge was suggested by {{ content.suggester }} using the -h suggest command.

- {%- endif -%} +
+ +
+ {% for submission in submissions %} + {% set author = users[submission.author_id] %} +
+ {{ author.username }}'s submission +
{{ author.username }}
+
+ {% endfor %} +
+
+ some random shit goes here +
+