Compare commits

...

11 commits

Author SHA1 Message Date
7109b3435d UI refresh 2025-03-28 18:30:01 -07:00
6ff3db09c5 Fix waiting function 2025-03-28 15:03:54 -07:00
0040dcf6df Wait before refreshing users not after 2025-03-28 13:43:36 -07:00
b2a9ec9d45 Clean up warnings 2025-03-28 13:42:41 -07:00
d03095dbc3 cargo clippy --fix 2025-03-28 13:39:07 -07:00
c202e4e250 Periodically refresh user list every hour 2025-03-28 13:37:41 -07:00
5e848ffe76 Rudimentary avatar updater 2025-03-27 23:27:10 -07:00
6f0a4d1316 Update default avatar generation 2025-03-27 12:15:03 -07:00
24512469e9 cargo clippy --fix 2025-03-27 11:40:18 -07:00
2689f9ac0d Update dependencies 2025-03-27 10:42:36 -07:00
add2885c26 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/e7f38be3775bab9659575f192ece011c033655f0?narHash=sha256-vYGY9bnqEeIncNarDZYhm6KdLKgXMS%2BHA2mTRaWEc80%3D' (2023-08-30)
  → 'github:NixOS/nixpkgs/698214a32beb4f4c8e3942372c694f40848b360d?narHash=sha256-hw63HnwnqU3ZQfsMclLhMvOezpM7RSB0dMAtD5/sOiw%3D' (2025-03-25)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/40e851593ef4f9f8cd0b69c8cae7b722b9953a23?narHash=sha256-K9npbs4S6%2Br51vpiElJi%2B0vwbAeftCAcOGbot/PCBnQ%3D' (2023-08-31)
  → 'github:oxalica/rust-overlay/b4d2dee9d16e7725b71969f28862ded3a94a7934?narHash=sha256-yPlxN0r3pQjUIwyX/qeWSTdpHjWy/AfmM0PK1bYkO18%3D' (2025-03-27)
• Removed input 'rust-overlay/flake-utils'
• Removed input 'rust-overlay/flake-utils/systems'
• Updated input 'rust-overlay/nixpkgs':
    'github:NixOS/nixpkgs/96ba1c52e54e74c3197f4d43026b3f3d92e83ff9?narHash=sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII%2BF%2Bx2hklDOQPB50%3D' (2023-04-13)
  → 'github:NixOS/nixpkgs/4bc9c909d9ac828a039f288cf872d16d38185db8?narHash=sha256-nIYdTAiKIGnFNugbomgBJR%2BXv5F1ZQU%2BHfaBqJKroC0%3D' (2025-01-08)
2025-03-27 09:54:38 -07:00
26 changed files with 2101 additions and 1603 deletions

3274
Cargo.lock generated

File diff suppressed because it is too large Load diff

52
flake.lock generated
View file

@ -1,30 +1,12 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1693377291,
"narHash": "sha256-vYGY9bnqEeIncNarDZYhm6KdLKgXMS+HA2mTRaWEc80=",
"lastModified": 1742889210,
"narHash": "sha256-hw63HnwnqU3ZQfsMclLhMvOezpM7RSB0dMAtD5/sOiw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7f38be3775bab9659575f192ece011c033655f0",
"rev": "698214a32beb4f4c8e3942372c694f40848b360d",
"type": "github"
},
"original": {
@ -36,11 +18,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"lastModified": 1736320768,
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github"
},
"original": {
@ -58,15 +40,14 @@
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1693447852,
"narHash": "sha256-K9npbs4S6+r51vpiElJi+0vwbAeftCAcOGbot/PCBnQ=",
"lastModified": 1743042789,
"narHash": "sha256-yPlxN0r3pQjUIwyX/qeWSTdpHjWy/AfmM0PK1bYkO18=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "40e851593ef4f9f8cd0b69c8cae7b722b9953a23",
"rev": "b4d2dee9d16e7725b71969f28862ded3a94a7934",
"type": "github"
},
"original": {
@ -74,21 +55,6 @@
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View file

@ -6,24 +6,24 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
accept-language = "2.0.0"
chrono = { version = "0.4.28", features = ["serde"] }
clap = "4.4.2"
comrak = "0.18.0"
derive_more = "0.99.17"
dotenv = "0.15.0"
gettext = "0.4.0"
gh-emoji = "1.0.7"
poise = "0.5.5"
r2d2 = "0.8.10"
r2d2_sqlite = "0.22.0"
regex = "1.9.4"
reqwest = "0.11.20"
rocket = { version = "=0.5.0-rc.3", features = ["secrets", "json"] }
rocket_contrib = { version = "0.4.11", features = ["templates"] }
rocket_dyn_templates = { version = "0.1.0-rc.3", features = ["tera"] }
rusqlite = { version = "0.29.0", features = ["chrono"] }
serde = "1.0.188"
serde_json = "1.0.105"
serde_yaml = "0.9.25"
tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] }
accept-language = "3.1"
chrono = { version = "0.4", features = ["serde"] }
clap = "4.5"
comrak = "0.37"
derive_more = { version = "2.0", features = ["full"] }
dotenv = "0.15"
gettext = "0.4"
gh-emoji = "1.0"
lazy_static = "1.5.0"
poise = "0.5" # TODO: update to 0.6
r2d2 = "0.8"
r2d2_sqlite = "0.27"
regex = "1.11"
reqwest = "0.12"
rocket = { version = "0.5", features = ["secrets", "json"] }
rocket_dyn_templates = { version = "0.2", features = ["tera"] }
rusqlite = { version = "0.34", features = ["chrono"] }
serde = "1.0"
serde_json = "1.0"
serde_yaml = "0.9.34" # deprecated
tokio = { version = "1.44", features = ["macros", "rt-multi-thread"] }

View file

@ -11,6 +11,7 @@ use std::{
use gettext::Catalog;
#[derive(From, Debug)]
#[allow(dead_code)]
pub enum LoadCatalogsError {
Io(std::io::Error),
Parse(gettext::Error),

View file

@ -4,11 +4,12 @@ extern crate rocket;
use clap::{Parser, Subcommand};
use poise::serenity_prelude::Http;
use rocket::{
fs::{relative, FileServer},
Ignite, Rocket,
fs::{relative, FileServer}, Ignite, Rocket
};
use rocket_dyn_templates::{tera, Template};
use std::{collections::HashMap, env};
use tokio::time::sleep;
use std::{collections::HashMap, env, time::Duration};
use lazy_static::lazy_static;
mod models;
use models::{Database, Settings};
@ -61,6 +62,15 @@ async fn main() {
}
},
None => {
tokio::task::spawn(async {
const INTERVAL: Duration = Duration::from_millis(60 * 60 * 1000); // every hour
loop {
sleep(INTERVAL).await;
if let Err(err) = DATABASE.refresh_users(&HTTP).await {
error!("{:?}", err);
}
}
});
rocket().await.expect("Failed to launch rocket");
}
}
@ -75,9 +85,12 @@ fn http() -> Http {
Http::new(&token)
}
async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
let http = http();
lazy_static! {
static ref DATABASE: Database = load_database();
static ref HTTP: Http = http();
}
async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
let config = rocket::Config::figment().merge(("port", 1313)).merge((
"secret_key",
env::var("SECRET")
@ -85,9 +98,8 @@ async fn rocket() -> Result<Rocket<Ignite>, rocket::Error> {
));
rocket::custom(config)
.manage(Settings::new(&http).await.unwrap())
.manage(http)
.manage(load_database())
.manage(Settings::new(&HTTP).await.unwrap())
// .manage(http)
.mount(
"/",
routes![

View file

@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::{collections::HashMap, fs::File, io::Read, path::Path};
use derive_more::From;
use poise::serenity_prelude::{Http, SerenityError};
use poise::serenity_prelude::{Http, SerenityError, StatusCode};
use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::rusqlite::{self, params};
use r2d2_sqlite::SqliteConnectionManager;
@ -20,13 +20,15 @@ pub struct Database {
const DATABASE_FILENAME: &str = "database.db";
#[derive(From, Debug)]
#[allow(dead_code)]
pub enum DatabaseError {
Rusqlite(rusqlite::Error),
Pool(r2d2::Error),
}
#[derive(From, Debug)]
pub enum LoadLegacyError {
#[allow(dead_code)]
pub enum GenericError {
Database(DatabaseError),
Serenity(SerenityError),
}
@ -82,7 +84,7 @@ impl Database {
.is_ok())
}
pub async fn load_legacy(&self, http: &Http) -> std::result::Result<(), LoadLegacyError> {
pub async fn load_legacy(&self, http: &Http) -> std::result::Result<(), GenericError> {
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
@ -124,12 +126,7 @@ impl Database {
},
);
}
Ok(user) => {
conn.execute(
"INSERT INTO User(id, name, discriminator, avatar, deleted) VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, user.name, user.discriminator, user.avatar, user.deleted]
).map_err(DatabaseError::Rusqlite)?;
}
Ok(user) => self.insert_user(&user)?,
Err(error) if error.to_string().eq("Unknown User") => {
// This will also be called in the case of an invalid user ID
println!("Failed to fetch user {id}, adding to archive");
@ -143,7 +140,7 @@ impl Database {
},
);
}
Err(error) => return Err(LoadLegacyError::Serenity(error)),
Err(error) => return Err(GenericError::Serenity(error)),
},
};
}
@ -265,11 +262,53 @@ impl Database {
.collect()
}
fn get_all_users(&self) -> Result<Vec<User>> {
let conn = self.conn()?;
let mut stmt = conn.prepare("SELECT id, name, discriminator, avatar, deleted FROM User")?;
let result: Vec<Result<User>> = stmt.query_map([], |row| Ok(User {
id: row.get(0)?,
name: row.get(1)?,
discriminator: row.get(2)?,
avatar: row.get(3)?,
deleted: row.get(4)?,
}))?.map(|result| match result {
Ok(user) => Ok(user),
Err(err) => Err(DatabaseError::Rusqlite(err)),
}).collect();
result.into_iter().collect()
}
pub fn insert_user(&self, user: &User) -> Result<()> {
self.conn()?.execute(
"INSERT OR REPLACE INTO User(id, name, discriminator, avatar, deleted) VALUES (?1, ?2, ?3, ?4, ?5)",
params![user.id, user.name, user.discriminator, user.avatar, user.deleted]
)?;
Ok(())
}
#[allow(dead_code)]
pub fn refresh_users(&self) -> rusqlite::Result<()> {
pub async fn refresh_users(&self, http: &Http) -> std::result::Result<(), GenericError> {
// Periodically refresh all changable user data (name, discriminator, avatar)
// Ideally this should run periodically.
todo!()
let all_users = self.get_all_users()?;
for (i, user) in all_users.iter().enumerate() {
print!("User {i}/{}, ", all_users.len());
let updated = match User::fetch(http, user.id).await {
Ok(user) => user,
Err(SerenityError::Http(error)) if error.status_code().eq(&Some(StatusCode::NOT_FOUND)) => {
println!("not found");
continue;
},
Err(error) => panic!("{error}"),
};
if user.eq(&updated) {
println!("no change");
continue;
}
println!("updated");
self.insert_user(&updated)?;
}
Ok(())
}
#[allow(dead_code, unused_variables)]

View file

@ -1,5 +1,5 @@
mod user;
pub use user::{SessionUser, User, Username};
pub use user::{SessionUser, User};
mod challenge;
pub use challenge::Challenge;

View file

@ -26,6 +26,7 @@ impl Settings {
}
#[derive(From, Debug)]
#[allow(dead_code)]
pub enum GetSettingsError {
Io(std::io::Error),
Deserialize(serde_yaml::Error),

View file

@ -21,6 +21,7 @@ where
}
#[derive(From, Debug)]
#[allow(dead_code)]
pub enum LegacySubmissionParseError {
BadAuthorId(std::num::ParseIntError),
}
@ -45,7 +46,7 @@ impl LegacySubmission {
.next()?
// Get last number
.split('-')
.last()?
.next_back()?
.parse()
.ok()
// Check if discriminator or timestamp, then convert

View file

@ -19,7 +19,7 @@ pub trait Username {
fn username(&self) -> String;
}
#[derive(Default, Deserialize, Debug)]
#[derive(Default, Deserialize, Debug, PartialEq, Eq)]
pub struct User {
#[serde(deserialize_with = "deserialize_id")]
pub id: u64,
@ -92,11 +92,16 @@ impl User {
}
),
// Archived user or user with no avatar, calculate default avatar
// https://www.reddit.com/r/discordapp/comments/au6v4e/comment/eh61dm6/
// https://docs.rs/serenity/0.11.5/serenity/model/user/struct.User.html#method.default_avatar_url
None => format!(
"https://cdn.discordapp.com/embed/avatars/{}.png",
self.discriminator % 5
// https://docs.rs/serenity/0.12.4/src/serenity/model/user.rs.html#805
if self.discriminator == 0 {
// New avatar system
((self.id >> 22) % 6) as u16
} else {
// Old avatar system
self.discriminator % 5
}
),
}
}
@ -142,12 +147,12 @@ impl SessionUser {
}
pub fn purge(cookies: &CookieJar<'_>) {
cookies.remove_private(Cookie::named(TOKEN_COOKIE));
cookies.remove_private(Cookie::named(USER_ID_COOKIE));
cookies.remove_private(Cookie::named(USER_NAME_COOKIE));
cookies.remove_private(Cookie::named(USER_DISCRIMINATOR_COOKIE));
cookies.remove_private(Cookie::named(USER_AVATAR_COOKIE));
cookies.remove(Cookie::named(TOKEN_EXPIRE_COOKIE));
cookies.remove_private(Cookie::from(TOKEN_COOKIE));
cookies.remove_private(Cookie::from(USER_ID_COOKIE));
cookies.remove_private(Cookie::from(USER_NAME_COOKIE));
cookies.remove_private(Cookie::from(USER_DISCRIMINATOR_COOKIE));
cookies.remove_private(Cookie::from(USER_AVATAR_COOKIE));
cookies.remove(Cookie::from(TOKEN_EXPIRE_COOKIE));
}
fn from_cookies(cookies: &CookieJar<'_>) -> Option<Self> {
@ -169,14 +174,14 @@ impl SessionUser {
.get(TOKEN_EXPIRE_COOKIE)
.map(|expire| expire.value().parse::<i64>())
.and_then(Result::ok)
.map_or(true, |timestamp| Utc::now().timestamp() >= timestamp)
.is_none_or(|timestamp| Utc::now().timestamp() >= timestamp)
{
cookies.remove_private(Cookie::named(TOKEN_COOKIE));
cookies.remove_private(Cookie::named(USER_ID_COOKIE));
cookies.remove_private(Cookie::named(USER_NAME_COOKIE));
cookies.remove_private(Cookie::named(USER_DISCRIMINATOR_COOKIE));
cookies.remove_private(Cookie::named(USER_AVATAR_COOKIE));
cookies.remove(Cookie::named(TOKEN_EXPIRE_COOKIE));
cookies.remove_private(Cookie::from(TOKEN_COOKIE));
cookies.remove_private(Cookie::from(USER_ID_COOKIE));
cookies.remove_private(Cookie::from(USER_NAME_COOKIE));
cookies.remove_private(Cookie::from(USER_DISCRIMINATOR_COOKIE));
cookies.remove_private(Cookie::from(USER_AVATAR_COOKIE));
cookies.remove(Cookie::from(TOKEN_EXPIRE_COOKIE));
return Ok(None);
}
Ok(Some(user))
@ -184,6 +189,7 @@ impl SessionUser {
}
#[derive(From, Debug)]
#[allow(dead_code)]
pub enum GetUserError {
Reqwest(reqwest::Error),
Deserialize(serde_json::Error),

View file

@ -6,8 +6,8 @@ use rocket_dyn_templates::{context, Template};
use crate::{
cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG,
models::{Challenge, Database, SessionUser, Settings},
utils::AcceptLanguage,
models::{Challenge, SessionUser, Settings},
utils::AcceptLanguage, DATABASE,
};
#[get("/<challenge>")]
@ -15,10 +15,9 @@ pub async fn get_challenge(
challenge: u32,
cookies: &CookieJar<'_>,
settings: &State<Settings>,
database: &State<Database>,
accept_language: AcceptLanguage,
) -> Template {
let (submissions, users) = database.get_challenge_user_data(challenge).unwrap();
let (submissions, users) = DATABASE.get_challenge_user_data(challenge).unwrap();
Template::render(
"challenge",
context! {

View file

@ -1,7 +1,7 @@
use poise::serenity_prelude::Http;
use rocket::response::stream::{Event, EventStream};
use rocket::{http::CookieJar, State};
use crate::HTTP;
use crate::{cookies::user::USER_ID_COOKIE, models::Settings};
// TODO: Incrementally send guilds
@ -9,7 +9,6 @@ use crate::{cookies::user::USER_ID_COOKIE, models::Settings};
pub async fn get_guilds<'a>(
cookies: &'a CookieJar<'_>,
settings: &'a State<Settings>,
http: &'a State<Http>,
) -> EventStream![Event + 'a] {
// EventStream![] is shorthand for EventStream[Event],
// but we need to pass in a lifetime parameter.
@ -28,7 +27,7 @@ pub async fn get_guilds<'a>(
if guild.hidden {
continue;
}
yield Event::data(format!("{},{}", guild.id, http.get_member(guild.id, user_id).await.is_ok()));
yield Event::data(format!("{},{}", guild.id, HTTP.get_member(guild.id, user_id).await.is_ok()));
}
}
}

View file

@ -9,8 +9,8 @@ use rocket_dyn_templates::{context, Template};
use crate::{
cookies::LANG_COOKIE,
i18n::DEFAULT as DEFAULT_LANG,
models::{Database, DatabaseError, SessionUser, Settings},
utils::AcceptLanguage,
models::{DatabaseError, SessionUser, Settings},
utils::AcceptLanguage, DATABASE,
};
#[get("/users/<user>")]
@ -18,10 +18,9 @@ pub async fn get_user(
user: String,
cookies: &CookieJar<'_>,
settings: &State<Settings>,
database: &State<Database>,
accept_language: AcceptLanguage,
) -> Result<Template, Status> {
let profile_user = match database.get_user_by_name(&user) {
let profile_user = match DATABASE.get_user_by_name(&user) {
Ok(profile_user) => profile_user,
Err(DatabaseError::Rusqlite(rusqlite::Error::QueryReturnedNoRows)) => {
return Err(Status::NotFound)
@ -35,7 +34,7 @@ pub async fn get_user(
"user",
context! {
profile_user,
submissions: database.get_submissions_by_user_name(&user).unwrap(),
submissions: DATABASE.get_submissions_by_user_name(&user).unwrap(),
settings: settings.deref(),
lang: cookies
.get(LANG_COOKIE)

View file

@ -29,7 +29,7 @@ pub async fn post_login(login: Form<Login<'_>>, cookies: &CookieJar<'_>) -> Redi
.timestamp()
.to_string(),
));
cookies.remove(Cookie::named(WELCOMED_COOKIE));
cookies.remove(Cookie::from(WELCOMED_COOKIE));
}
Redirect::to("/")
}

View file

@ -1,20 +1,19 @@
use std::ops::Deref;
use poise::serenity_prelude::Http;
use rocket::{http::CookieJar, State};
use rocket::http::CookieJar;
use crate::models::SessionUser;
use crate::{models::SessionUser, HTTP};
#[get("/testing")]
pub async fn testing(cookies: &CookieJar<'_>, http: &State<Http>) -> String {
pub async fn testing(cookies: &CookieJar<'_>) -> String {
// Get logged in user's join date in 字ちゃん server
format!(
"{:?}",
http.get_guild(814700630958276649)
HTTP.get_guild(814700630958276649)
.await
.expect("Failed to get testing guild")
.member(
http.deref(),
HTTP.deref(),
SessionUser::get(cookies)
.await
.expect("Failed to get logged in user data")

View file

@ -11,6 +11,10 @@ pub fn furigana_to_html(text: &str) -> String {
// so { needs to be escaped as \{
// Curly brace literals \{ need to have their backslash escaped as \\{
// TODO: Modify so <span lang="ja"> only wraps continuous sections of furigana
if text.trim().is_empty() {
// Don't return empty <span lang="ja"> if text is empty
return String::new();
}
let re = Regex::new(r"\[([^\]]*)\]\{([^\\}]*)}").unwrap();
format!(
"<span lang=\"ja\">{}</span>",

View file

@ -353,6 +353,7 @@ const KYUJITAI: &[(char, char)] = &[
pub trait Kyujitai {
fn to_kyujitai(&self) -> String;
#[allow(dead_code)]
fn to_shinjitai(&self) -> String;
}

View file

@ -1,5 +1,5 @@
@use "sass:color"
$bg: #FFFDF3
$bg0: #{color.adjust($bg, $lightness: -5%, $hue: -7deg, $saturation: -50%)}
$fg: #011627
$bg: #fefdfa
$bg0: #fefefa
$fg: #1a1110

View file

@ -1,6 +1,38 @@
#challenge
padding: 1em
@use "_theme" as *
$wave: 10px
$wave_scale: 2
#challenge
box-shadow: 0px 0px 10px 5px rgba(0, 0, 0, 0.125)
z-index: 1
background: #3f313a
iframe
background: black
width: 100%
aspect-ratio: 16/9
margin: 0
#challenge-description, footer
padding: 1em
#challenge-content
padding: 1em
padding-top: calc(1em - $wave)
padding-bottom: calc(1em - $wave)
font-size: 1.25em
$shadow: #1a1110
$shadow-size: 1px
text-shadow: calc(-1 * $shadow-size) calc(-1 * $shadow-size) 0 $shadow, calc(-1 * $shadow-size) 0px 0 $shadow, calc(-1 * $shadow-size) $shadow-size 0 $shadow, 0px $shadow-size 0 $shadow, $shadow-size $shadow-size 0 $shadow, $shadow-size 0px 0 $shadow, $shadow-size calc(-1 * $shadow-size) 0 $shadow, 0px calc(-1 * $shadow-size) 0 $shadow;
background: #23191e
p
margin: 0
a:hover
text-decoration: none
border-bottom: 3px solid
a
&.noun
color: #a6e3a1
@ -18,4 +50,22 @@ a
color: #f9e2af
&.phrase
color: #fab387
color: #fab387
.wave
--size: #{$wave}
--R: calc(var(--size)*1.28)
mask: radial-gradient(var(--R) at 50% calc(1.8*var(--size)),#000 99%,#0000 101%) calc(50% - 2*var(--size)) 0/calc(4*var(--size)) 100%, radial-gradient(var(--R) at 50% calc(-.8*var(--size)),#0000 99%,#000 101%) 50% var(--size)/calc(4*var(--size)) 100% repeat-x
background: #23191e
$height: calc($wave * 2)
height: $height
flex-shrink: 0
&.wave-bottom
transform: scaleY(-1) scaleX($wave_scale)
&.wave-top
transform: scaleX($wave_scale) translateX($wave * $wave_scale)
margin-top: -$height
pointer-events: none

View file

@ -12,10 +12,13 @@ body
height: 100vh
box-sizing: border-box
padding: 2em
background: $bg0
color: var(--fg)
background: #2b2028
color: var(--bg)
font-size: 1.25em
b, strong
color: white
input[type=number]
background: var(--bg)
color: var(--fg)
@ -27,6 +30,7 @@ input[type=number]
appearance: textfield
a
color: #9fe2bf
text-decoration: none
&:hover
@ -38,5 +42,30 @@ a
h1
text-align: center
&:first-child
margin-top: 0
:first-child
margin-top: 0
margin-bottom: 0
.avatar
border-radius: 100%
display: inline
height: 2em
vertical-align: middle
background: #5e595d
button
border: none
color: inherit
font: inherit
background: #3f313a
border: 3px solid #1a1110
border-radius: 0.25em
box-shadow: 0px 2px #1a1110
cursor: pointer
&:active
box-shadow: none
transform: translateY(2px)
background: transparent
color: #3f313a

View file

@ -5,8 +5,10 @@ nav
--fg: #{$bg}
background: var(--bg)
color: var(--fg)
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.125)
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.5)
display: flex
align-items: center
z-index: 2
.dropdown
position: relative
@ -51,5 +53,8 @@ nav
cursor: pointer
.icon
height: 1.5em
vertical-align: middle
height: 1em
vertical-align: middle
nav .icon
height: 1.5em

View file

@ -10,7 +10,6 @@
background: var(--bg)
box-shadow: 0 0 16px rgba(0, 0, 0, 0.125)
border: none
border-radius: 4px
#content
width: 100%
@ -19,7 +18,7 @@
grid-template-areas: "nav nav" "challenge submissions"
grid-template-columns: 30em auto
grid-template-rows: min-content auto
overflow: hidden
border: 4px solid $fg
#nav
grid-area: nav
@ -31,10 +30,10 @@
grid-area: submissions
#challenge, #submissions
overflow: scroll
overflow-x: hidden;
overflow-y: scroll
display: flex
flex-direction: column
gap: 1em
footer
margin-top: auto

View file

@ -1,6 +1,8 @@
@use "_theme" as *
#submissions
background: #3f313a
grid-area: submissions
background: rgba(0, 0, 0, 0.125)
padding: 1em
& > div
@ -13,7 +15,9 @@
gap: $gap
& > figure
background: rgba(0, 0, 0, 0.125)
background: #5c4f57
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.125)
border-radius: 0.5em
padding: $gap
width: 100%
box-sizing: border-box
@ -23,6 +27,7 @@
text-align: center
& > img
border-radius: 0.5em
width: 100%
cursor: pointer

View file

@ -16,7 +16,7 @@
<div id="content">
<nav id="nav">{% block nav %}{% include "nav" %}{% endblock %}</nav>
<div id="challenge">
<div>{% block content %}{% endblock %}</div>
{% block content %}{% endblock %}
<footer>
{% block content_copyright %}
Copyright {% include "copyright-years" %} Tegaki Tuesday. All rights reserved. 字ちゃん mascot art by <a href="https://twitter.com/bellumela" target="_blank">@bellumela</a>.
@ -55,7 +55,11 @@
{% if profile_user %}
<a href="/{{ submission.challenge }}">#{{ submission.challenge }}</a>
{% else %}
{% if not author.deleted %}<a href="/users/{{ author.name }}" target="_blank">{% endif %}{{ author.username }}{% if author.deleted %} (deleted account){% else %}</a>{% endif %}
{% if not author.deleted %}
<a href="/users/{{ author.name }}" target="_blank">
{% endif %}
<img src="{{ author.avatar }}" alt="{{ author.username }}'s avatar" class="avatar"> {{ author.username }}
{% if author.deleted %} (deleted account){% else %}</a>{% endif %}
{% endif %}
</figcaption>
</figure>

View file

@ -1,7 +1,11 @@
{% extends "base" %}
{% block content %}
<h1>Welcome to Tegaki Tuesday #{{ challenge }}!</h1>
{% if content.youtube %}
<iframe width="560" height="315" src="https://www.youtube.com/embed/{{ content.youtube }}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
{% endif %}
<div class="wave wave-top"></div>
<div id="challenge-content">
{% if content.japanese %}
<div lang="ja">
<script>
@ -15,7 +19,19 @@
kyujitai = !kyujitai;
}
</script>
<a href="https://en.wikipedia.org/wiki/Ky%C5%ABjitai" target="_blank">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 0 1-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 0 1-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 0 1-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584ZM12 18a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</a>
<button onclick="kyujitaiToggle(); this.innerHTML = kyujitai ? '新字体' : '旧字体'">旧字体</button>
<button>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3" />
</svg>
</button>
<br>
<br>
{% for line in content.japanese %}
{% for subline in line %}
<p>
@ -35,6 +51,9 @@
{% endfor %}
</div>
{% endif %}
</div>
<div class="wave wave-bottom"></div>
<div id="challenge-description">
{{ content.text | safe }}
{% if content.translation %}
<p>
@ -52,4 +71,5 @@
{%- if content.suggester -%}
<p><em>This challenge was suggested by <strong>{{ content.suggester }}</strong> using the <code>-h suggest</code> command.</code></em></p>
{%- endif -%}
</div>
{% endblock %}

View file

@ -2,7 +2,8 @@
{% block content %}
<div style="text-align: center">
<img src="{{ profile_user.avatar }}" alt="{{ profile_user.username }}" style="border-radius: 100%">
<br>
<img src="{{ profile_user.avatar }}" alt="{{ profile_user.username }}" class="avatar" style="font-size: 6em">
<h1><a href="https://discord.com/users/{{ profile_user.id }}">{{ profile_user.username }}</a>'s submissions</h1>
{{ submissions | length }} images
</div>