Compare commits
28 commits
30ba15fd9a
...
561cb49fbf
Author | SHA1 | Date | |
---|---|---|---|
561cb49fbf | |||
a1907591c6 | |||
6c0f35bf01 | |||
492215d17c | |||
f87230596d | |||
7f53fb3d27 | |||
8a6dc9e339 | |||
56146a7d99 | |||
7ecabbe72f | |||
fb9d068bd0 | |||
b34bfee629 | |||
16dfeca43c | |||
cb5340da17 | |||
31728966c5 | |||
1fe57e2bb6 | |||
aae088ddb9 | |||
3080602727 | |||
afd50c1f0e | |||
ace93534ad | |||
d369118da3 | |||
4e21a8ee92 | |||
4ecf26f4b2 | |||
15cbfeb0d6 | |||
779fc69405 | |||
a08636b3ee | |||
6efeb0498c | |||
defee22098 | |||
439c0a4c31 |
59 changed files with 22187 additions and 233 deletions
4
.envrc
Normal file
4
.envrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
strict_env
|
||||||
|
|
||||||
|
export HOUHOU_SRC=$PWD
|
||||||
|
export DATABASE_URL=$HOUHOU_SRC/src-tauri/SrsDatabase.sqlite
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -22,3 +22,6 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
houhou.db
|
||||||
|
src/data/kanadata.json
|
2
.prettierignore
Normal file
2
.prettierignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
src-tauri
|
7
.prettierrc.json5
Normal file
7
.prettierrc.json5
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
useTabs: false,
|
||||||
|
tabWidth: 2,
|
||||||
|
singleQuote: false,
|
||||||
|
trailingComma: "all",
|
||||||
|
printWidth: 100,
|
||||||
|
}
|
1
.tokeignore
Normal file
1
.tokeignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
package-lock.json
|
2
CREDITS.md
Normal file
2
CREDITS.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
- [Houhou SRS](http://houhou-srs.com) ([CCPL](https://github.com/Doublevil/Houhou-SRS/blob/master/LICENSE.md))
|
||||||
|
- [KRADFILE](https://www.edrdg.org/krad/kradinf.html) ([EDRDG](http://www.edrdg.org/edrdg/licence.html))
|
5140
package-lock.json
generated
5140
package-lock.json
generated
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "houhou",
|
"name": "houhou",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
@ -10,19 +10,37 @@
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@chakra-ui/icons": "^2.0.19",
|
||||||
|
"@chakra-ui/react": "^2.7.0",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
"@tauri-apps/api": "^1.3.0",
|
"@tauri-apps/api": "^1.3.0",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"flowbite": "^1.6.5",
|
||||||
|
"framer-motion": "^10.12.16",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router": "^6.11.2",
|
"react-router": "^6.11.2",
|
||||||
"react-router-dom": "^6.11.2"
|
"react-router-dom": "^6.11.2",
|
||||||
|
"react-timeago": "^7.1.0",
|
||||||
|
"swr": "^2.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^1.3.1",
|
"@tauri-apps/cli": "^1.3.1",
|
||||||
|
"@types/lodash-es": "^4.17.7",
|
||||||
"@types/node": "^18.7.10",
|
"@types/node": "^18.7.10",
|
||||||
"@types/react": "^18.0.15",
|
"@types/react": "^18.0.15",
|
||||||
"@types/react-dom": "^18.0.6",
|
"@types/react-dom": "^18.0.6",
|
||||||
|
"@types/react-timeago": "^4.1.3",
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
"@vitejs/plugin-react": "^3.0.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
|
"postcss": "^8.4.24",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
"sass": "^1.62.1",
|
"sass": "^1.62.1",
|
||||||
|
"tailwindcss": "^3.3.2",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"vite": "^4.2.1"
|
"vite": "^4.2.1"
|
||||||
}
|
}
|
||||||
|
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
4
rustfmt.toml
Normal file
4
rustfmt.toml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
max_width = 80
|
||||||
|
tab_spaces = 2
|
||||||
|
wrap_comments = true
|
||||||
|
fn_single_line = true
|
4
src-tauri/.gitignore
vendored
4
src-tauri/.gitignore
vendored
|
@ -2,3 +2,7 @@
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
|
*-shm
|
||||||
|
*-wal
|
||||||
|
|
||||||
|
SrsDatabase.sqlite
|
1249
src-tauri/Cargo.lock
generated
1249
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,22 +1,28 @@
|
||||||
[package]
|
[package]
|
||||||
name = "houhou"
|
name = "houhou"
|
||||||
version = "0.0.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
[workspace]
|
||||||
|
members = ["database-maker"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.3", features = [] }
|
tauri-build = { version = "1.3", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "1.3", features = ["shell-open"] }
|
tauri = { version = "1.3", features = ["notification-all", "shell-open", "system-tray"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
dirs = "5.0.1"
|
dirs = "5.0.1"
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
clap = { version = "4.3.2", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite"] }
|
||||||
|
tokio = { version = "1.28.2", features = ["full"] }
|
||||||
|
derivative = "2.2.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# this feature is used for production builds or when `devPath` points to the filesystem
|
# this feature is used for production builds or when `devPath` points to the filesystem
|
||||||
|
|
BIN
src-tauri/KanjiDatabase.sqlite
Normal file
BIN
src-tauri/KanjiDatabase.sqlite
Normal file
Binary file not shown.
6462
src-tauri/data/kradfile
Normal file
6462
src-tauri/data/kradfile
Normal file
File diff suppressed because it is too large
Load diff
6455
src-tauri/data/kradfile.utf8
Normal file
6455
src-tauri/data/kradfile.utf8
Normal file
File diff suppressed because it is too large
Load diff
10
src-tauri/database-maker/Cargo.toml
Normal file
10
src-tauri/database-maker/Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "database-maker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.71"
|
||||||
|
clap = { version = "4.3.2", features = ["derive"] }
|
||||||
|
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite"] }
|
||||||
|
tokio = { version = "1.28.2", features = ["full"] }
|
44
src-tauri/database-maker/src/kradfile.rs
Normal file
44
src-tauri/database-maker/src/kradfile.rs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tokio::{
|
||||||
|
fs::File,
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEPARATOR: char = ':';
|
||||||
|
|
||||||
|
pub async fn process_kradfile(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let file = File::open(path.as_ref()).await?;
|
||||||
|
|
||||||
|
let file_reader = BufReader::new(file);
|
||||||
|
let mut lines = file_reader.lines();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let line = match lines.next_line().await? {
|
||||||
|
Some(v) => v,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip comments
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = line.split(SEPARATOR).collect::<Vec<_>>();
|
||||||
|
let (kanji, radicals) = match &parts[..] {
|
||||||
|
&[kanji, radicals] => {
|
||||||
|
let kanji = kanji.trim();
|
||||||
|
let radicals = radicals.trim().split_whitespace().collect::<Vec<_>>();
|
||||||
|
(kanji, radicals)
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
35
src-tauri/database-maker/src/main.rs
Normal file
35
src-tauri/database-maker/src/main.rs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
pub mod kradfile;
|
||||||
|
|
||||||
|
use std::{path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
struct Opt {
|
||||||
|
in_dir: PathBuf,
|
||||||
|
out_file: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let opt = Opt::parse();
|
||||||
|
|
||||||
|
// Open sqlite db
|
||||||
|
let uri = format!("sqlite:{}", opt.out_file.display());
|
||||||
|
println!("Opening {}...", uri);
|
||||||
|
let options = SqliteConnectOptions::from_str(&uri)?.create_if_missing(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Migrate that shit
|
||||||
|
sqlx::migrate!().run(&pool).await?;
|
||||||
|
|
||||||
|
// Kradfile
|
||||||
|
kradfile::process_kradfile(&pool, opt.in_dir.join("kradfile.utf8")).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
19
src-tauri/migrations/20230608093603_initial.sql
Normal file
19
src-tauri/migrations/20230608093603_initial.sql
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS [SrsEntrySet] (
|
||||||
|
[ID] integer NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
[CreationDate] bigint NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
[NextAnswerDate] bigint,
|
||||||
|
[Meanings] [nvarchar(300)] NOT NULL,
|
||||||
|
[Readings] [nvarchar(100)] NOT NULL,
|
||||||
|
[CurrentGrade] smallint NOT NULL DEFAULT 0,
|
||||||
|
[FailureCount] integer NOT NULL DEFAULT 0,
|
||||||
|
[SuccessCount] integer NOT NULL DEFAULT 0,
|
||||||
|
[AssociatedVocab] [nvarchar(100)],
|
||||||
|
[AssociatedKanji] [nvarchar(10)],
|
||||||
|
[MeaningNote] [nvarchar(1000)],
|
||||||
|
[ReadingNote] [nvarchar(1000)],
|
||||||
|
[SuspensionDate] bigint,
|
||||||
|
[Tags] [nvarchar(300)],
|
||||||
|
[LastUpdateDate] BIGINT,
|
||||||
|
[IsDeleted] BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
[ServerId] integer
|
||||||
|
);
|
212
src-tauri/src/kanji.rs
Normal file
212
src-tauri/src/kanji.rs
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use sqlx::{sqlite::SqliteRow, Encode, Row, SqlitePool, Type};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
srs::SrsDb,
|
||||||
|
utils::{EpochMs, Ticks},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct KanjiDb(pub SqlitePool);
|
||||||
|
|
||||||
|
#[derive(Debug, Derivative, Serialize, Deserialize)]
|
||||||
|
#[derivative(Default)]
|
||||||
|
pub struct GetKanjiOptions {
|
||||||
|
#[serde(default)]
|
||||||
|
character: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "default_skip")]
|
||||||
|
#[derivative(Default(value = "0"))]
|
||||||
|
skip: u32,
|
||||||
|
|
||||||
|
#[serde(default = "default_how_many")]
|
||||||
|
#[derivative(Default(value = "40"))]
|
||||||
|
how_many: u32,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
include_srs_info: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_skip() -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn default_how_many() -> u32 {
|
||||||
|
40
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Kanji {
|
||||||
|
character: String,
|
||||||
|
most_used_rank: u32,
|
||||||
|
meanings: Vec<KanjiMeaning>,
|
||||||
|
srs_info: Option<KanjiSrsInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KanjiMeaning {
|
||||||
|
id: u32,
|
||||||
|
meaning: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GetKanjiResult {
|
||||||
|
count: u32,
|
||||||
|
kanji: Vec<Kanji>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KanjiSrsInfo {
|
||||||
|
id: u32,
|
||||||
|
current_grade: u32,
|
||||||
|
next_answer_date: EpochMs,
|
||||||
|
associated_kanji: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_kanji(
|
||||||
|
kanji_db: State<'_, KanjiDb>,
|
||||||
|
srs_db: State<'_, SrsDb>,
|
||||||
|
options: Option<GetKanjiOptions>,
|
||||||
|
) -> Result<GetKanjiResult, String> {
|
||||||
|
let opts = options.unwrap_or_default();
|
||||||
|
|
||||||
|
let looking_for_character_clause = match opts.character {
|
||||||
|
None => String::new(),
|
||||||
|
Some(_) => format!("AND KanjiSet.Character = ?"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_string = format!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
Character,
|
||||||
|
KanjiMeaningSet.Meaning,
|
||||||
|
MostUsedRank,
|
||||||
|
KanjiMeaningSet.ID as KanjiMeaningID
|
||||||
|
FROM (
|
||||||
|
SELECT *
|
||||||
|
FROM KanjiSet
|
||||||
|
WHERE MostUsedRank IS NOT NULL
|
||||||
|
{looking_for_character_clause}
|
||||||
|
ORDER BY MostUsedRank LIMIT ?, ?
|
||||||
|
) as Kanji
|
||||||
|
JOIN KanjiMeaningSet ON Kanji.ID = KanjiMeaningSet.Kanji_ID
|
||||||
|
ORDER BY MostUsedRank, KanjiMeaningSet.ID
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&query_string);
|
||||||
|
|
||||||
|
// Do all the binds
|
||||||
|
|
||||||
|
if let Some(character) = &opts.character {
|
||||||
|
query = query.bind(character.clone());
|
||||||
|
}
|
||||||
|
query = query.bind(opts.skip);
|
||||||
|
query = query.bind(opts.how_many);
|
||||||
|
|
||||||
|
let result = query
|
||||||
|
.fetch_all(&kanji_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
// Look for SRS info
|
||||||
|
let mut srs_info_map = HashMap::new();
|
||||||
|
if opts.include_srs_info {
|
||||||
|
let looking_for_character_clause = match opts.character {
|
||||||
|
None => String::new(),
|
||||||
|
Some(_) => format!("AND AssociatedKanji = ?"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let query_string = format!(
|
||||||
|
r#"
|
||||||
|
SELECT ID, NextAnswerDate, AssociatedKanji, CurrentGrade FROM SrsEntrySet
|
||||||
|
WHERE 1=1
|
||||||
|
{}
|
||||||
|
"#,
|
||||||
|
looking_for_character_clause
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut query = sqlx::query(&query_string);
|
||||||
|
|
||||||
|
if let Some(character) = &opts.character {
|
||||||
|
query = query.bind(character.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = query
|
||||||
|
.fetch_all(&srs_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
for row in result {
|
||||||
|
let associated_kanji: String = match row.get("AssociatedKanji") {
|
||||||
|
Some(v) => v,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = row.get("ID");
|
||||||
|
let current_grade = row.get("CurrentGrade");
|
||||||
|
let next_answer_date: i64 = row.get("NextAnswerDate");
|
||||||
|
let next_answer_date = Ticks(next_answer_date).epoch_ms();
|
||||||
|
|
||||||
|
srs_info_map.insert(
|
||||||
|
associated_kanji.clone(),
|
||||||
|
KanjiSrsInfo {
|
||||||
|
id,
|
||||||
|
current_grade,
|
||||||
|
next_answer_date,
|
||||||
|
associated_kanji,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Put it all together
|
||||||
|
let kanji = {
|
||||||
|
let mut new_vec: Vec<Kanji> = Vec::with_capacity(result.len());
|
||||||
|
let mut last_character: Option<String> = None;
|
||||||
|
|
||||||
|
for row in result {
|
||||||
|
let character: String = row.get("Character");
|
||||||
|
let most_used_rank = row.get("MostUsedRank");
|
||||||
|
|
||||||
|
let meaning = KanjiMeaning {
|
||||||
|
id: row.get("KanjiMeaningID"),
|
||||||
|
meaning: row.get("Meaning"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let same_as = match last_character {
|
||||||
|
Some(ref last_character) if character == *last_character => {
|
||||||
|
Some(new_vec.last_mut().unwrap())
|
||||||
|
}
|
||||||
|
Some(_) => None,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
last_character = Some(character.clone());
|
||||||
|
|
||||||
|
if let Some(kanji) = same_as {
|
||||||
|
kanji.meanings.push(meaning);
|
||||||
|
} else {
|
||||||
|
let srs_info = srs_info_map.remove(&character);
|
||||||
|
|
||||||
|
new_vec.push(Kanji {
|
||||||
|
character,
|
||||||
|
most_used_rank,
|
||||||
|
meanings: vec![meaning],
|
||||||
|
srs_info,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new_vec
|
||||||
|
};
|
||||||
|
|
||||||
|
let count = sqlx::query("SELECT COUNT(*) FROM KanjiSet")
|
||||||
|
.fetch_one(&kanji_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let count = count.try_get(0).map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
Ok(GetKanjiResult { kanji, count })
|
||||||
|
}
|
|
@ -1,15 +1,90 @@
|
||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
#[macro_use]
|
||||||
#[tauri::command]
|
extern crate derivative;
|
||||||
fn greet(name: &str) -> String {
|
#[macro_use]
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
extern crate serde;
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
pub mod kanji;
|
||||||
|
pub mod srs;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
|
use std::process;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use tauri::{
|
||||||
|
CustomMenuItem, SystemTray, SystemTrayEvent, SystemTrayMenu,
|
||||||
|
SystemTrayMenuItem, WindowEvent,
|
||||||
|
};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
use crate::kanji::KanjiDb;
|
||||||
|
use crate::srs::SrsDb;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let app_dir = dirs::config_dir().unwrap().join("houhou");
|
||||||
|
fs::create_dir_all(&app_dir).await?;
|
||||||
|
|
||||||
|
// Open kanji db
|
||||||
|
let kanji_db_options =
|
||||||
|
SqliteConnectOptions::from_str("./KanjiDatabase.sqlite")?.read_only(true);
|
||||||
|
let kanji_pool = SqlitePoolOptions::new()
|
||||||
|
.connect_with(kanji_db_options)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Open SRS db
|
||||||
|
let srs_db_path = app_dir.join("srs_db.sqlite");
|
||||||
|
let srs_db_options =
|
||||||
|
SqliteConnectOptions::from_str(&srs_db_path.display().to_string())?
|
||||||
|
.create_if_missing(true);
|
||||||
|
println!("Opening srs database at {} ...", srs_db_path.display());
|
||||||
|
let srs_pool = SqlitePoolOptions::new()
|
||||||
|
.connect_with(srs_db_options)
|
||||||
|
.await?;
|
||||||
|
println!("Running migrations...");
|
||||||
|
sqlx::migrate!().run(&srs_pool).await?;
|
||||||
|
|
||||||
|
// System tray
|
||||||
|
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
|
||||||
|
let tray_menu = SystemTrayMenu::new().add_item(quit);
|
||||||
|
let tray = SystemTray::new()
|
||||||
|
.with_tooltip("Houhou")
|
||||||
|
.with_menu(tray_menu);
|
||||||
|
|
||||||
|
// Build tauri
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.manage(KanjiDb(kanji_pool))
|
||||||
|
.manage(SrsDb(srs_pool))
|
||||||
|
.system_tray(tray)
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
srs::get_srs_stats,
|
||||||
|
srs::add_srs_item,
|
||||||
|
srs::generate_review_batch,
|
||||||
|
srs::update_srs_item,
|
||||||
|
kanji::get_kanji,
|
||||||
|
])
|
||||||
|
.on_window_event(|event| match event.event() {
|
||||||
|
WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
event.window().hide().unwrap();
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_system_tray_event(|app, event| match event {
|
||||||
|
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
|
||||||
|
"quit" => {
|
||||||
|
process::exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.context("error while running tauri application")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
255
src-tauri/src/srs.rs
Normal file
255
src-tauri/src/srs.rs
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
kanji::KanjiDb,
|
||||||
|
utils::{Ticks, TICK_MULTIPLIER},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SrsDb(pub SqlitePool);
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SrsStats {
|
||||||
|
reviews_available: u32,
|
||||||
|
|
||||||
|
reviews_today: u32,
|
||||||
|
total_items: u32,
|
||||||
|
total_reviews: u32,
|
||||||
|
|
||||||
|
/// Used to calculate average success
|
||||||
|
num_success: u32,
|
||||||
|
num_failure: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_srs_stats(db: State<'_, SrsDb>) -> Result<SrsStats, String> {
|
||||||
|
// counts query
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_items,
|
||||||
|
SUM(SuccessCount) AS num_success,
|
||||||
|
SUM(FailureCount) AS num_failure
|
||||||
|
FROM SrsEntrySet
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(&db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
// reviews query
|
||||||
|
let utc_now = Ticks::now().map_err(|err| err.to_string())?;
|
||||||
|
let utc_tomorrow = utc_now + Duration::from_secs(60 * 60 * 24);
|
||||||
|
let row2 = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*) AS reviews
|
||||||
|
FROM SrsEntrySet
|
||||||
|
WHERE
|
||||||
|
SuspensionDate IS NULL
|
||||||
|
AND NextAnswerDate <= ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT COUNT(*) AS reviews
|
||||||
|
FROM SrsEntrySet
|
||||||
|
WHERE
|
||||||
|
SuspensionDate IS NULL
|
||||||
|
AND NextAnswerDate <= ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&utc_now)
|
||||||
|
.bind(&utc_tomorrow)
|
||||||
|
.fetch_all(&db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
Ok(SrsStats {
|
||||||
|
reviews_available: row2[0].get("reviews"),
|
||||||
|
reviews_today: row2[1].get("reviews"),
|
||||||
|
total_items: row.try_get("total_items").unwrap_or(0),
|
||||||
|
total_reviews: 0,
|
||||||
|
num_success: row.try_get("num_success").unwrap_or(0),
|
||||||
|
num_failure: row.try_get("num_failure").unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Derivative, Serialize, Deserialize)]
|
||||||
|
#[derivative(Default)]
|
||||||
|
pub struct AddSrsItemOptions {
|
||||||
|
character: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_srs_item(
|
||||||
|
kanji_db: State<'_, KanjiDb>,
|
||||||
|
srs_db: State<'_, SrsDb>,
|
||||||
|
options: AddSrsItemOptions,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Fetch meanings
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT Meaning
|
||||||
|
FROM KanjiMeaningSet
|
||||||
|
JOIN KanjiSet ON KanjiMeaningSet.Kanji_ID = KanjiSet.ID
|
||||||
|
WHERE KanjiSet.Character = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&options.character)
|
||||||
|
.fetch_all(&kanji_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let meanings = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| row.get::<String, _>("Meaning").to_owned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let meanings = meanings.join(",");
|
||||||
|
println!("meanings: {:?}", meanings);
|
||||||
|
|
||||||
|
// Fetch readings
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT OnYomi, KunYomi, Nanori FROM KanjiSet WHERE Character = ?",
|
||||||
|
)
|
||||||
|
.bind(&options.character)
|
||||||
|
.fetch_one(&kanji_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let onyomi_reading: String = row.get("OnYomi");
|
||||||
|
let kunyomi_reading: String = row.get("KunYomi");
|
||||||
|
let nanori_reading: String = row.get("Nanori");
|
||||||
|
let readings = onyomi_reading
|
||||||
|
.split(",")
|
||||||
|
.chain(kunyomi_reading.split(","))
|
||||||
|
.chain(nanori_reading.split(","))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let readings = readings.join(",");
|
||||||
|
println!("readings: {:?}", readings);
|
||||||
|
|
||||||
|
let query_string = format!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO SrsEntrySet
|
||||||
|
(CreationDate, AssociatedKanji, NextAnswerDate,
|
||||||
|
Meanings, Readings)
|
||||||
|
VALUES
|
||||||
|
(?, ?, ?, ?, ?)
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let utc_now = Ticks::now().map_err(|err| err.to_string())?;
|
||||||
|
let query = sqlx::query(&query_string)
|
||||||
|
.bind(&utc_now)
|
||||||
|
.bind(&options.character)
|
||||||
|
.bind(&utc_now)
|
||||||
|
.bind(&meanings)
|
||||||
|
.bind(&readings);
|
||||||
|
|
||||||
|
query
|
||||||
|
.execute(&srs_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Derivative, Serialize, Deserialize)]
|
||||||
|
#[derivative(Default)]
|
||||||
|
pub struct GenerateReviewBatchOptions {
|
||||||
|
#[serde(default = "default_batch_size")]
|
||||||
|
#[derivative(Default(value = "10"))]
|
||||||
|
batch_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_batch_size() -> u32 {
|
||||||
|
10
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SrsEntry {
|
||||||
|
id: u32,
|
||||||
|
current_grade: u32,
|
||||||
|
meanings: Vec<String>,
|
||||||
|
readings: Vec<String>,
|
||||||
|
associated_kanji: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_review_batch(
|
||||||
|
srs_db: State<'_, SrsDb>,
|
||||||
|
options: Option<GenerateReviewBatchOptions>,
|
||||||
|
) -> Result<Vec<SrsEntry>, String> {
|
||||||
|
let opts = options.unwrap_or_default();
|
||||||
|
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM SrsEntrySet
|
||||||
|
WHERE AssociatedKanji IS NOT NULL
|
||||||
|
AND CurrentGrade < 8
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(opts.batch_size)
|
||||||
|
.fetch_all(&srs_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let result = result
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let id = row.get("ID");
|
||||||
|
|
||||||
|
let meanings: String = row.get("Meanings");
|
||||||
|
let meanings = meanings.split(",").map(|s| s.to_owned()).collect();
|
||||||
|
|
||||||
|
let readings: String = row.get("Readings");
|
||||||
|
let readings = readings.split(",").map(|s| s.to_owned()).collect();
|
||||||
|
|
||||||
|
SrsEntry {
|
||||||
|
id,
|
||||||
|
current_grade: row.get("CurrentGrade"),
|
||||||
|
meanings,
|
||||||
|
readings,
|
||||||
|
associated_kanji: row.get("AssociatedKanji"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_srs_item(
|
||||||
|
srs_db: State<'_, SrsDb>,
|
||||||
|
item_id: u32,
|
||||||
|
delay: i64,
|
||||||
|
new_grade: u32,
|
||||||
|
correct: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let (success, failure) = match correct {
|
||||||
|
true => (1, 0),
|
||||||
|
false => (0, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kanji.Interface/ViewModels/Partial/Srs/SrsReviewViewModel.cs:600
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE SrsEntrySet
|
||||||
|
SET
|
||||||
|
SuccessCount = SuccessCount + ?,
|
||||||
|
FailureCount = FailureCount + ?,
|
||||||
|
NextAnswerDate = NextAnswerDate + ?,
|
||||||
|
CurrentGrade = ?
|
||||||
|
WHERE ID = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(success)
|
||||||
|
.bind(failure)
|
||||||
|
.bind(delay * TICK_MULTIPLIER)
|
||||||
|
.bind(new_grade)
|
||||||
|
.bind(item_id)
|
||||||
|
.execute(&srs_db.0)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
68
src-tauri/src/utils.rs
Normal file
68
src-tauri/src/utils.rs
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
use std::{
|
||||||
|
ops::Add,
|
||||||
|
time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use sqlx::{
|
||||||
|
database::{HasArguments, HasValueRef},
|
||||||
|
encode::IsNull,
|
||||||
|
error::BoxDynError,
|
||||||
|
Decode, Encode, Sqlite, Type,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const TICK_MULTIPLIER: i64 = 1_000_000_000;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct Ticks(pub i64);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct EpochMs(pub u64);
|
||||||
|
|
||||||
|
impl Ticks {
|
||||||
|
pub fn now() -> Result<Self, SystemTimeError> {
|
||||||
|
Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn epoch_ms(&self) -> EpochMs {
|
||||||
|
let millis = Duration::from_nanos(self.0 as u64).as_millis() as u64;
|
||||||
|
EpochMs(millis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<Duration> for Ticks {
|
||||||
|
fn into(self) -> Duration {
|
||||||
|
Duration::from_nanos(self.0 as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Duration> for Ticks {
|
||||||
|
fn from(value: Duration) -> Self {
|
||||||
|
Ticks(value.as_secs() as i64 * TICK_MULTIPLIER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'q> Encode<'q, Sqlite> for Ticks {
|
||||||
|
fn encode_by_ref(
|
||||||
|
&self,
|
||||||
|
buf: &mut <Sqlite as HasArguments<'q>>::ArgumentBuffer,
|
||||||
|
) -> IsNull {
|
||||||
|
self.0.encode_by_ref(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Type<Sqlite> for Ticks {
|
||||||
|
fn type_info() -> <Sqlite as sqlx::Database>::TypeInfo {
|
||||||
|
i64::type_info()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Add<Duration> for Ticks {
|
||||||
|
type Output = Ticks;
|
||||||
|
|
||||||
|
fn add(self, rhs: Duration) -> Self::Output {
|
||||||
|
let rhs_ticks = Ticks::from(rhs);
|
||||||
|
Ticks(self.0 + rhs_ticks.0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,7 +8,7 @@
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "houhou",
|
"productName": "houhou",
|
||||||
"version": "0.0.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": {
|
"allowlist": {
|
||||||
|
@ -16,12 +16,15 @@
|
||||||
"shell": {
|
"shell": {
|
||||||
"all": false,
|
"all": false,
|
||||||
"open": true
|
"open": true
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"all": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"identifier": "com.tauri.dev",
|
"identifier": "io.mzhang.houhou",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
@ -33,6 +36,10 @@
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
},
|
},
|
||||||
|
"systemTray": {
|
||||||
|
"iconPath": "icons/icon.ico",
|
||||||
|
"iconAsTemplate": true
|
||||||
|
},
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
|
|
|
@ -1,3 +1,14 @@
|
||||||
|
$navLinkAccentColor: #09c;
|
||||||
|
$navLinkColor: hsl(203, 91%, 91%);
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -6,6 +17,38 @@
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
li {
|
li {
|
||||||
padding-left: 0;
|
flex-basis: 0;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
min-height: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 4px solid transparent;
|
||||||
|
transition: background-color 0.1s ease-out, border-top-color 0.1s ease-out;
|
||||||
|
|
||||||
|
// Make the top bar seem less web-ish
|
||||||
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
|
||||||
|
&:not(.link-active):hover {
|
||||||
|
border-top-color: #f4f4f4;
|
||||||
|
background: linear-gradient(#f4f4f4, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-active {
|
||||||
|
border-top-color: $navLinkAccentColor;
|
||||||
|
// background-color: $navLinkColor;
|
||||||
|
background: linear-gradient($navLinkColor, transparent);
|
||||||
|
}
|
||||||
|
|
124
src/App.tsx
124
src/App.tsx
|
@ -1,27 +1,117 @@
|
||||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
|
||||||
import styles from "./App.module.scss"
|
import classNames from "classnames";
|
||||||
import KanjiPane from "./panes/KanjiPane";
|
import { ChakraProvider, Flex } from "@chakra-ui/react";
|
||||||
import HomePane from "./panes/HomePane";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import { Outlet, Route, createRoutesFromElements, matchPath, useLocation } from "react-router";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
|
||||||
export default function App() {
|
import styles from "./App.module.scss";
|
||||||
const router = createBrowserRouter(routes);
|
|
||||||
|
function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Flex className={styles.main} direction="column" alignSelf="start">
|
||||||
<ul className={styles.header}>
|
<ul className={styles.header}>
|
||||||
<li><a href="/">Home</a></li>
|
{navLinks.map((navLink: NavLink) => {
|
||||||
<li><a href="/srs">Srs</a></li>
|
const active = (
|
||||||
<li><a href="/kanji">Kanji</a></li>
|
navLink.subPaths ? navLink.subPaths : [{ key: navLink.key, path: navLink.path }]
|
||||||
<li><a href="/vocab">Vocab</a></li>
|
).some((item) => matchPath({ path: item.path }, location.pathname));
|
||||||
<li><a href="/settings">Settings</a></li>
|
const mainPath = navLink.subPaths ? navLink.subPaths[0].path : navLink.path;
|
||||||
|
const className = classNames(styles.link, active && styles["link-active"]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={`navLink-${navLink.key}`}>
|
||||||
|
<Link to={mainPath} className={className}>
|
||||||
|
{navLink.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<RouterProvider router={router} />
|
<div className={styles.body}>
|
||||||
</>
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = [
|
export default function App() {
|
||||||
{ path: "/", element: <HomePane /> },
|
const router = createBrowserRouter(
|
||||||
{ path: "/kanji", element: <KanjiPane /> },
|
createRoutesFromElements(
|
||||||
|
<Route path="/" element={<Layout />}>
|
||||||
|
{navLinks.flatMap((route, idx) => {
|
||||||
|
if (route.subPaths) {
|
||||||
|
return route.subPaths.map((subRoute, idx2) => {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={`route-${route.key}-${subRoute.key}`}
|
||||||
|
index={idx + idx2 == 0}
|
||||||
|
path={subRoute.path}
|
||||||
|
lazy={() => import(`./panes/${subRoute.element ?? route.element}.tsx`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Route
|
||||||
|
key={`route-${route.key}`}
|
||||||
|
index={idx == 0}
|
||||||
|
path={route.path}
|
||||||
|
lazy={() => import(`./panes/${route.element}.tsx`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Route>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StrictMode>
|
||||||
|
<ChakraProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ChakraProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavLinkPath =
|
||||||
|
| {
|
||||||
|
path: string;
|
||||||
|
subPaths?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
path?: undefined;
|
||||||
|
subPaths: { key: string; path: string; element?: string }[];
|
||||||
|
};
|
||||||
|
type NavLink = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
element: string;
|
||||||
|
} & NavLinkPath;
|
||||||
|
|
||||||
|
const navLinks: NavLink[] = [
|
||||||
|
// { key: "home", path: "/", title: "Home", element: <HomePane /> },
|
||||||
|
{
|
||||||
|
key: "srs",
|
||||||
|
title: "SRS",
|
||||||
|
element: "SrsPane",
|
||||||
|
subPaths: [
|
||||||
|
{ key: "index", path: "/" },
|
||||||
|
{ key: "review", path: "/srs/review", element: "SrsReviewPane" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "kanji",
|
||||||
|
title: "Kanji",
|
||||||
|
element: "KanjiPane",
|
||||||
|
subPaths: [
|
||||||
|
{ key: "index", path: "/kanji" },
|
||||||
|
{ key: "selected", path: "/kanji/:selectedKanji" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: "vocab", path: "/vocab", title: "Vocab", element: "VocabPane" },
|
||||||
|
{ key: "settings", path: "/settings", title: "Settings", element: "SettingsPane" },
|
||||||
];
|
];
|
10
src/components/DashboardReviewStats.module.scss
Normal file
10
src/components/DashboardReviewStats.module.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
.reviews-stats {
|
||||||
|
background-color: gray;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviews-available {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
}
|
82
src/components/DashboardReviewStats.tsx
Normal file
82
src/components/DashboardReviewStats.tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Button, Grid, GridItem, Stat, StatLabel, StatNumber, Tooltip } from "@chakra-ui/react";
|
||||||
|
import { ArrowRightIcon } from "@chakra-ui/icons";
|
||||||
|
import styles from "./DashboardReviewStats.module.scss";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import ConditionalWrapper from "./utils/ConditionalWrapper";
|
||||||
|
|
||||||
|
interface SrsStats {
|
||||||
|
reviews_available: number;
|
||||||
|
|
||||||
|
reviews_today: number;
|
||||||
|
total_items: number;
|
||||||
|
total_reviews: number;
|
||||||
|
|
||||||
|
/// Used to calculate average success
|
||||||
|
num_success: number;
|
||||||
|
num_failure: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stat {
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardReviewStats() {
|
||||||
|
const {
|
||||||
|
data: srsStats,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
} = useSWR(["get_srs_stats"], ([command]) => invoke<SrsStats>(command));
|
||||||
|
|
||||||
|
if (!srsStats)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{JSON.stringify([srsStats, error, isLoading])}
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const averageSuccess = srsStats.num_success / (srsStats.num_success + srsStats.num_failure || 1);
|
||||||
|
const averageSuccessStr = `${Math.round(averageSuccess * 10000) / 100}%`;
|
||||||
|
|
||||||
|
const canReview = srsStats.reviews_available == 0;
|
||||||
|
|
||||||
|
const generateStat = (stat: Stat) => {
|
||||||
|
return (
|
||||||
|
<GridItem>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>{stat.label}</StatLabel>
|
||||||
|
<StatNumber>{stat.value}</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
</GridItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid templateColumns="2fr 1fr 1fr" templateRows="1fr 1fr" gap={4}>
|
||||||
|
<GridItem rowSpan={2} className={styles["reviews-available"]}>
|
||||||
|
<Stat>
|
||||||
|
<StatLabel>reviews available</StatLabel>
|
||||||
|
<StatNumber>{srsStats.reviews_available}</StatNumber>
|
||||||
|
</Stat>
|
||||||
|
|
||||||
|
<ConditionalWrapper
|
||||||
|
condition={canReview}
|
||||||
|
wrapper={(children) => <Tooltip label="Add items to start reviewing">{children}</Tooltip>}
|
||||||
|
elseWrapper={(children) => <Link to="/srs/review">{children}</Link>}
|
||||||
|
>
|
||||||
|
<Button isDisabled={canReview} colorScheme="blue">
|
||||||
|
Start reviewing <ArrowRightIcon marginLeft={3} />
|
||||||
|
</Button>
|
||||||
|
</ConditionalWrapper>
|
||||||
|
</GridItem>
|
||||||
|
|
||||||
|
{generateStat({ label: "reviews available", value: srsStats.reviews_available })}
|
||||||
|
{generateStat({ label: "reviews today", value: srsStats.reviews_today })}
|
||||||
|
{generateStat({ label: "total items", value: srsStats.total_items })}
|
||||||
|
{generateStat({ label: "average success", value: averageSuccessStr })}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
11
src/components/KanjiDisplay.module.scss
Normal file
11
src/components/KanjiDisplay.module.scss
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
$kanjiDisplaySize: 80px;
|
||||||
|
|
||||||
|
.display {
|
||||||
|
border: 1px solid rgb(87, 87, 210);
|
||||||
|
width: $kanjiDisplaySize;
|
||||||
|
height: $kanjiDisplaySize;
|
||||||
|
line-height: $kanjiDisplaySize;
|
||||||
|
font-size: $kanjiDisplaySize * 0.8;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
80
src/components/KanjiDisplay.tsx
Normal file
80
src/components/KanjiDisplay.tsx
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
|
import { GetKanjiResult } from "../panes/KanjiPane";
|
||||||
|
import TimeAgo from "react-timeago";
|
||||||
|
import styles from "./KanjiDisplay.module.scss";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { Button } from "@chakra-ui/button";
|
||||||
|
import { AddIcon } from "@chakra-ui/icons";
|
||||||
|
import SelectOnClick from "./utils/SelectOnClick";
|
||||||
|
import { Alert, AlertIcon } from "@chakra-ui/alert";
|
||||||
|
import { isValid } from "date-fns";
|
||||||
|
|
||||||
|
interface KanjiDisplayProps {
|
||||||
|
kanjiCharacter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
|
||||||
|
const {
|
||||||
|
data: kanjiResult,
|
||||||
|
error,
|
||||||
|
isLoading,
|
||||||
|
mutate,
|
||||||
|
} = useSWR(["get_kanji", kanjiCharacter], ([command, character]) =>
|
||||||
|
invoke<GetKanjiResult>(command, { options: { character, include_srs_info: true } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!kanjiResult || !kanjiResult.kanji)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{JSON.stringify([kanjiResult, error, isLoading])}
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const kanji = kanjiResult.kanji[0];
|
||||||
|
|
||||||
|
const addSrsItem = async () => {
|
||||||
|
await invoke("add_srs_item", {
|
||||||
|
options: {
|
||||||
|
character: kanji.character,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
let srsPart = (
|
||||||
|
<Button onClick={addSrsItem} colorScheme="green">
|
||||||
|
<AddIcon /> Add to SRS
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
if (kanji.srs_info) {
|
||||||
|
const nextAnswerDate = new Date(kanji.srs_info.next_answer_date);
|
||||||
|
srsPart = (
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
<p>You are learning this item!</p>
|
||||||
|
|
||||||
|
{isValid(nextAnswerDate) && (
|
||||||
|
<p>
|
||||||
|
(Due: <TimeAgo date={nextAnswerDate} />)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<details>
|
||||||
|
<summary>Debug</summary>
|
||||||
|
<pre>{JSON.stringify(kanji, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<SelectOnClick className={styles.display}>{kanji.character}</SelectOnClick>
|
||||||
|
|
||||||
|
{kanji.meanings.map((m) => m.meaning).join(", ")}
|
||||||
|
|
||||||
|
<div>{srsPart}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
70
src/components/KanjiList.module.scss
Normal file
70
src/components/KanjiList.module.scss
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
$kanjiCharacterSize: 28px;
|
||||||
|
|
||||||
|
.kanji-list {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanji-link {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
transition: background-color 0.1s ease-out, border-left-color 0.1s ease-out;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
|
||||||
|
&:not(.kanji-link-active):hover {
|
||||||
|
border-top-color: transparent;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanji-link-character {
|
||||||
|
font-size: $kanjiCharacterSize;
|
||||||
|
line-height: $kanjiCharacterSize;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanji-list-scroll {
|
||||||
|
direction: rtl;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanji-list-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanji-link-active {
|
||||||
|
border-left-color: #09c;
|
||||||
|
background-color: hsl(203, 91%, 91%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
102
src/components/KanjiList.tsx
Normal file
102
src/components/KanjiList.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { GetKanjiResult } from "../panes/KanjiPane";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Badge, Grid, GridItem } from "@chakra-ui/layout";
|
||||||
|
import styles from "./KanjiList.module.scss";
|
||||||
|
import { Kanji } from "../lib/kanji";
|
||||||
|
import { Input, Spinner } from "@chakra-ui/react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import SearchBar from "./SearchBar";
|
||||||
|
import LevelBadge from "./utils/LevelBadge";
|
||||||
|
|
||||||
|
export interface KanjiListProps {
|
||||||
|
kanjiList: Kanji[];
|
||||||
|
totalCount: number;
|
||||||
|
selectedCharacter?: string;
|
||||||
|
loadMoreKanji: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KanjiList({
|
||||||
|
kanjiList,
|
||||||
|
totalCount,
|
||||||
|
selectedCharacter,
|
||||||
|
loadMoreKanji,
|
||||||
|
}: KanjiListProps) {
|
||||||
|
// Set up intersection observer
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
const [loadingCanary, setLoadingCanary] = useState<HTMLDivElement | null>(null);
|
||||||
|
const loadingCanaryRef = useCallback(
|
||||||
|
(element: HTMLDivElement) => {
|
||||||
|
if (element) setLoadingCanary(element);
|
||||||
|
},
|
||||||
|
[setLoadingCanary],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadingCanary && !isLoadingMore) {
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
loadMoreKanji();
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(loadingCanary);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.unobserve(loadingCanary);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [loadingCanary, isLoadingMore]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}, [kanjiList]);
|
||||||
|
|
||||||
|
const renderKanjiItem = (kanji: Kanji, active: boolean) => {
|
||||||
|
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
|
||||||
|
if (kanji.srs_info) console.log("kanji", kanji);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}>
|
||||||
|
<Grid templateRows="repeat(2, 1fr)" templateColumns="auto 1fr" columnGap={4}>
|
||||||
|
<GridItem rowSpan={2} className={styles["kanji-link-character"]}>
|
||||||
|
{kanji.character}
|
||||||
|
</GridItem>
|
||||||
|
<GridItem>{kanji.meanings[0].meaning}</GridItem>
|
||||||
|
<GridItem className={styles.badges}>
|
||||||
|
<LevelBadge grade={kanji.srs_info?.current_grade} />
|
||||||
|
<Badge>#{kanji.most_used_rank} common</Badge>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles["search-container"]}>
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small className={styles["result-count"]}>
|
||||||
|
Displaying {kanjiList.length} of {totalCount} results.
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div className={styles["kanji-list-scroll"]}>
|
||||||
|
<div className={styles["kanji-list-inner"]}>
|
||||||
|
{kanjiList.map((kanji) => {
|
||||||
|
const active = kanji.character == selectedCharacter;
|
||||||
|
return renderKanjiItem(kanji, active);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className={styles.loading} ref={loadingCanaryRef}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,3 +0,0 @@
|
||||||
export default function KanjiSearch() {
|
|
||||||
|
|
||||||
}
|
|
23
src/components/SearchBar.tsx
Normal file
23
src/components/SearchBar.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { SearchIcon } from "@chakra-ui/icons";
|
||||||
|
import { Input, InputGroup, InputRightElement, Spinner } from "@chakra-ui/react";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
export default function SearchBar() {
|
||||||
|
const [status, setStatus] = useState("idle");
|
||||||
|
|
||||||
|
const onSubmit = (evt: FormEvent) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setStatus("loading");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<InputGroup>
|
||||||
|
<Input autoFocus placeholder="Search..." />
|
||||||
|
<InputRightElement>
|
||||||
|
{{ idle: <SearchIcon />, loading: <Spinner /> }[status]}
|
||||||
|
</InputRightElement>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
21
src/components/utils/ConditionalWrapper.tsx
Normal file
21
src/components/utils/ConditionalWrapper.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
export interface ConditionalWrapperProps<S, T> {
|
||||||
|
condition: boolean;
|
||||||
|
wrapper?: (_: S) => T;
|
||||||
|
elseWrapper?: (_: S) => T;
|
||||||
|
children: S;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConditionalWrapper<S, T>({
|
||||||
|
condition,
|
||||||
|
wrapper,
|
||||||
|
children,
|
||||||
|
elseWrapper,
|
||||||
|
}: ConditionalWrapperProps<S, T>) {
|
||||||
|
return condition
|
||||||
|
? wrapper
|
||||||
|
? wrapper(children)
|
||||||
|
: children
|
||||||
|
: elseWrapper
|
||||||
|
? elseWrapper(children)
|
||||||
|
: children;
|
||||||
|
}
|
42
src/components/utils/ConfirmQuitModal.tsx
Normal file
42
src/components/utils/ConfirmQuitModal.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export interface ConfirmQuitModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmQuitModal({ isOpen, onClose }: ConfirmQuitModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Confirm Quit</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
Are you sure you want to go back? Your current progress into this batch will not be saved.
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Link to="/">
|
||||||
|
<Button colorScheme="red" mr={3}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
33
src/components/utils/LevelBadge.tsx
Normal file
33
src/components/utils/LevelBadge.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { Badge } from "@chakra-ui/react";
|
||||||
|
import { srsLevels } from "../../lib/srs";
|
||||||
|
|
||||||
|
export interface LevelBadgeProps {
|
||||||
|
grade?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LevelBadge({ grade }: LevelBadgeProps) {
|
||||||
|
if (grade == undefined) return null;
|
||||||
|
|
||||||
|
const levelInfo = srsLevels.get(grade);
|
||||||
|
if (!levelInfo) return null;
|
||||||
|
|
||||||
|
const { color, name } = levelInfo;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge backgroundColor={color} color="white">
|
||||||
|
{name}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeMap = new Map<number, [string | JSX.Element, string]>([
|
||||||
|
[8, [<>★</>, "green"]],
|
||||||
|
[7, ["A2", "blue"]],
|
||||||
|
[6, ["A1", "blue"]],
|
||||||
|
[5, ["B2", "yellow"]],
|
||||||
|
[4, ["B1", "yellow"]],
|
||||||
|
[3, ["C2", "orange"]],
|
||||||
|
[2, ["C1", "orange"]],
|
||||||
|
[1, ["D2", "red"]],
|
||||||
|
[0, ["D1", "red"]],
|
||||||
|
]);
|
30
src/components/utils/SelectOnClick.tsx
Normal file
30
src/components/utils/SelectOnClick.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { HTMLAttributes, PropsWithChildren, useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export default function SelectOnClick({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<HTMLAttributes<HTMLDivElement>>) {
|
||||||
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
if (element) setContainer(element);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(container);
|
||||||
|
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel) return;
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}, [container]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} ref={containerRef} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
887
src/data/kanadata.json
Normal file
887
src/data/kanadata.json
Normal file
|
@ -0,0 +1,887 @@
|
||||||
|
{
|
||||||
|
"kanaToHiragana": [
|
||||||
|
["ッ", "っ"],
|
||||||
|
["チ", "ち"],
|
||||||
|
["シ", "し"],
|
||||||
|
["ツ", "つ"],
|
||||||
|
["ヅ", "づ"],
|
||||||
|
["ヂ", "ぢ"],
|
||||||
|
["ヮ", "ゎ"],
|
||||||
|
["ャ", "ゃ"],
|
||||||
|
["ィ", "ぃ"],
|
||||||
|
["ュ", "ゅ"],
|
||||||
|
["ェ", "ぇ"],
|
||||||
|
["ョ", "ょ"],
|
||||||
|
["カ", "か"],
|
||||||
|
["キ", "き"],
|
||||||
|
["ク", "く"],
|
||||||
|
["ケ", "け"],
|
||||||
|
["コ", "こ"],
|
||||||
|
["サ", "さ"],
|
||||||
|
["ス", "す"],
|
||||||
|
["セ", "せ"],
|
||||||
|
["ソ", "そ"],
|
||||||
|
["タ", "た"],
|
||||||
|
["テ", "て"],
|
||||||
|
["ト", "と"],
|
||||||
|
["ナ", "な"],
|
||||||
|
["ニ", "に"],
|
||||||
|
["ヌ", "ぬ"],
|
||||||
|
["ネ", "ね"],
|
||||||
|
["ノ", "の"],
|
||||||
|
["ハ", "は"],
|
||||||
|
["ヒ", "ひ"],
|
||||||
|
["フ", "ふ"],
|
||||||
|
["ヘ", "へ"],
|
||||||
|
["ホ", "ほ"],
|
||||||
|
["マ", "ま"],
|
||||||
|
["ミ", "み"],
|
||||||
|
["ム", "む"],
|
||||||
|
["メ", "め"],
|
||||||
|
["モ", "も"],
|
||||||
|
["ヤ", "や"],
|
||||||
|
["ユ", "ゆ"],
|
||||||
|
["ヨ", "よ"],
|
||||||
|
["ラ", "ら"],
|
||||||
|
["リ", "り"],
|
||||||
|
["ル", "る"],
|
||||||
|
["レ", "れ"],
|
||||||
|
["ロ", "ろ"],
|
||||||
|
["ワ", "わ"],
|
||||||
|
["ヲ", "を"],
|
||||||
|
["ガ", "が"],
|
||||||
|
["ギ", "ぎ"],
|
||||||
|
["グ", "ぐ"],
|
||||||
|
["ゲ", "げ"],
|
||||||
|
["ゴ", "ご"],
|
||||||
|
["ジ", "じ"],
|
||||||
|
["ダ", "だ"],
|
||||||
|
["デ", "で"],
|
||||||
|
["ド", "ど"],
|
||||||
|
["バ", "ば"],
|
||||||
|
["ビ", "び"],
|
||||||
|
["ブ", "ぶ"],
|
||||||
|
["ベ", "べ"],
|
||||||
|
["ボ", "ぼ"],
|
||||||
|
["パ", "ぱ"],
|
||||||
|
["ピ", "ぴ"],
|
||||||
|
["プ", "ぷ"],
|
||||||
|
["ペ", "ぺ"],
|
||||||
|
["ポ", "ぽ"],
|
||||||
|
["ザ", "ざ"],
|
||||||
|
["ズ", "ず"],
|
||||||
|
["ゼ", "ぜ"],
|
||||||
|
["ゾ", "ぞ"],
|
||||||
|
["ァ", "ぁ"],
|
||||||
|
["ィ", "ぃ"],
|
||||||
|
["ゥ", "ぅ"],
|
||||||
|
["ェ", "ぇ"],
|
||||||
|
["ォ", "ぉ"],
|
||||||
|
["ン", "ん"],
|
||||||
|
["ア", "あ"],
|
||||||
|
["イ", "い"],
|
||||||
|
["ウ", "う"],
|
||||||
|
["エ", "え"],
|
||||||
|
["オ", "お"]
|
||||||
|
],
|
||||||
|
|
||||||
|
"katakanaToHiragana": [
|
||||||
|
["っ", "ッ"],
|
||||||
|
["ち", "チ"],
|
||||||
|
["し", "シ"],
|
||||||
|
["つ", "ツ"],
|
||||||
|
["づ", "ヅ"],
|
||||||
|
["ぢ", "ヂ"],
|
||||||
|
["ゎ", "ヮ"],
|
||||||
|
["ゃ", "ャ"],
|
||||||
|
["ぃ", "ィ"],
|
||||||
|
["ゅ", "ュ"],
|
||||||
|
["ぇ", "ェ"],
|
||||||
|
["ょ", "ョ"],
|
||||||
|
["か", "カ"],
|
||||||
|
["き", "キ"],
|
||||||
|
["く", "ク"],
|
||||||
|
["け", "ケ"],
|
||||||
|
["こ", "コ"],
|
||||||
|
["さ", "サ"],
|
||||||
|
["す", "ス"],
|
||||||
|
["せ", "セ"],
|
||||||
|
["そ", "ソ"],
|
||||||
|
["た", "タ"],
|
||||||
|
["て", "テ"],
|
||||||
|
["と", "ト"],
|
||||||
|
["な", "ナ"],
|
||||||
|
["に", "ニ"],
|
||||||
|
["ぬ", "ヌ"],
|
||||||
|
["ね", "ネ"],
|
||||||
|
["の", "ノ"],
|
||||||
|
["は", "ハ"],
|
||||||
|
["ひ", "ヒ"],
|
||||||
|
["ふ", "フ"],
|
||||||
|
["へ", "ヘ"],
|
||||||
|
["ほ", "ホ"],
|
||||||
|
["ま", "マ"],
|
||||||
|
["み", "ミ"],
|
||||||
|
["む", "ム"],
|
||||||
|
["め", "メ"],
|
||||||
|
["も", "モ"],
|
||||||
|
["や", "ヤ"],
|
||||||
|
["ゆ", "ユ"],
|
||||||
|
["よ", "ヨ"],
|
||||||
|
["ら", "ラ"],
|
||||||
|
["り", "リ"],
|
||||||
|
["る", "ル"],
|
||||||
|
["れ", "レ"],
|
||||||
|
["ろ", "ロ"],
|
||||||
|
["わ", "ワ"],
|
||||||
|
["を", "ヲ"],
|
||||||
|
["が", "ガ"],
|
||||||
|
["ぎ", "ギ"],
|
||||||
|
["ぐ", "グ"],
|
||||||
|
["げ", "ゲ"],
|
||||||
|
["ご", "ゴ"],
|
||||||
|
["じ", "ジ"],
|
||||||
|
["だ", "ダ"],
|
||||||
|
["で", "デ"],
|
||||||
|
["ど", "ド"],
|
||||||
|
["ば", "バ"],
|
||||||
|
["び", "ビ"],
|
||||||
|
["ぶ", "ブ"],
|
||||||
|
["べ", "ベ"],
|
||||||
|
["ぼ", "ボ"],
|
||||||
|
["ぱ", "パ"],
|
||||||
|
["ぴ", "ピ"],
|
||||||
|
["ぷ", "プ"],
|
||||||
|
["ぺ", "ペ"],
|
||||||
|
["ぽ", "ポ"],
|
||||||
|
["ざ", "ザ"],
|
||||||
|
["ず", "ズ"],
|
||||||
|
["ぜ", "ゼ"],
|
||||||
|
["ぞ", "ゾ"],
|
||||||
|
["ぁ", "ァ"],
|
||||||
|
["ぃ", "ィ"],
|
||||||
|
["ぅ", "ゥ"],
|
||||||
|
["ぇ", "ェ"],
|
||||||
|
["ぉ", "ォ"],
|
||||||
|
["ん", "ン"],
|
||||||
|
["あ", "ア"],
|
||||||
|
["い", "イ"],
|
||||||
|
["う", "ウ"],
|
||||||
|
["え", "エ"],
|
||||||
|
["お", "オ"]
|
||||||
|
],
|
||||||
|
|
||||||
|
"kanaToRomaji": {
|
||||||
|
"2letter": [
|
||||||
|
["イェ", "YE"],
|
||||||
|
["いぇ", "ye"],
|
||||||
|
["ウィ", "WI"],
|
||||||
|
["うぃ", "wi"],
|
||||||
|
["ウェ", "WE"],
|
||||||
|
["うぇ", "we"],
|
||||||
|
["ヴァ", "WA"],
|
||||||
|
["ヴぁ", "wa"],
|
||||||
|
["ヴィ", "WI"],
|
||||||
|
["ヴぃ", "wi"],
|
||||||
|
["ヴェ", "WE"],
|
||||||
|
["ヴぇ", "we"],
|
||||||
|
["ヴォ", "WO"],
|
||||||
|
["ヴぉ", "wo"],
|
||||||
|
["ファ", "FA"],
|
||||||
|
["ふぁ", "fa"],
|
||||||
|
["フィ", "FI"],
|
||||||
|
["ふぃ", "fi"],
|
||||||
|
["フェ", "FE"],
|
||||||
|
["ふぇ", "fe"],
|
||||||
|
["フォ", "FO"],
|
||||||
|
["ふぉ", "fo"],
|
||||||
|
["チャ", "CHA"],
|
||||||
|
["ちゃ", "cha"],
|
||||||
|
["チュ", "CHU"],
|
||||||
|
["ちゅ", "chu"],
|
||||||
|
["チェ", "CHE"],
|
||||||
|
["ちぇ", "che"],
|
||||||
|
["チョ", "CHO"],
|
||||||
|
["ちょ", "cho"],
|
||||||
|
["シャ", "SHA"],
|
||||||
|
["しゃ", "sha"],
|
||||||
|
["シュ", "SHU"],
|
||||||
|
["しゅ", "shu"],
|
||||||
|
["シェ", "SHE"],
|
||||||
|
["しぇ", "she"],
|
||||||
|
["ショ", "SHO"],
|
||||||
|
["しょ", "sho"],
|
||||||
|
["リャ", "RYA"],
|
||||||
|
["りゃ", "rya"],
|
||||||
|
["リィ", "RYI"],
|
||||||
|
["りぃ", "ryi"],
|
||||||
|
["リュ", "RYU"],
|
||||||
|
["りゅ", "ryu"],
|
||||||
|
["リェ", "RYE"],
|
||||||
|
["りぇ", "rye"],
|
||||||
|
["リョ", "RYO"],
|
||||||
|
["りょ", "ryo"],
|
||||||
|
["ヒャ", "HYA"],
|
||||||
|
["ひゃ", "hya"],
|
||||||
|
["ヒィ", "HYI"],
|
||||||
|
["ひぃ", "hyi"],
|
||||||
|
["ヒュ", "HYU"],
|
||||||
|
["ひゅ", "hyu"],
|
||||||
|
["ヒェ", "HYE"],
|
||||||
|
["ひぇ", "hye"],
|
||||||
|
["ヒョ", "HYO"],
|
||||||
|
["ひょ", "hyo"],
|
||||||
|
["ビャ", "BYA"],
|
||||||
|
["びゃ", "bya"],
|
||||||
|
["ビィ", "BYI"],
|
||||||
|
["びぃ", "byi"],
|
||||||
|
["ビュ", "BYU"],
|
||||||
|
["びゅ", "byu"],
|
||||||
|
["ビェ", "BYE"],
|
||||||
|
["びぇ", "bye"],
|
||||||
|
["ビョ", "BYO"],
|
||||||
|
["びょ", "byo"],
|
||||||
|
["ピャ", "PYA"],
|
||||||
|
["ぴゃ", "pya"],
|
||||||
|
["ピィ", "PYI"],
|
||||||
|
["ぴぃ", "pyi"],
|
||||||
|
["ピュ", "PYU"],
|
||||||
|
["ぴゅ", "pyu"],
|
||||||
|
["ピェ", "PYE"],
|
||||||
|
["ぴぇ", "pye"],
|
||||||
|
["ピョ", "PYO"],
|
||||||
|
["ぴょ", "pyo"],
|
||||||
|
["ミャ", "MYA"],
|
||||||
|
["みゃ", "mya"],
|
||||||
|
["ミィ", "MYI"],
|
||||||
|
["みぃ", "myi"],
|
||||||
|
["ミュ", "MYU"],
|
||||||
|
["みゅ", "myu"],
|
||||||
|
["ミェ", "MYE"],
|
||||||
|
["みぇ", "mye"],
|
||||||
|
["ミョ", "MYO"],
|
||||||
|
["みょ", "myo"],
|
||||||
|
["キャ", "KYA"],
|
||||||
|
["きゃ", "kya"],
|
||||||
|
["キィ", "KYI"],
|
||||||
|
["きぃ", "kyi"],
|
||||||
|
["キュ", "KYU"],
|
||||||
|
["きゅ", "kyu"],
|
||||||
|
["キェ", "KYE"],
|
||||||
|
["きぇ", "kye"],
|
||||||
|
["キョ", "KYO"],
|
||||||
|
["きょ", "kyo"],
|
||||||
|
["ギャ", "GYA"],
|
||||||
|
["ぎゃ", "gya"],
|
||||||
|
["ギィ", "GYI"],
|
||||||
|
["ぎぃ", "gyi"],
|
||||||
|
["ギュ", "GYU"],
|
||||||
|
["ぎゅ", "gyu"],
|
||||||
|
["ギェ", "GYE"],
|
||||||
|
["ぎぇ", "gye"],
|
||||||
|
["ギョ", "GYO"],
|
||||||
|
["ぎょ", "gyo"],
|
||||||
|
["ニャ", "NYA"],
|
||||||
|
["にゃ", "nya"],
|
||||||
|
["ニィ", "NYI"],
|
||||||
|
["にぃ", "nyi"],
|
||||||
|
["ニュ", "NYU"],
|
||||||
|
["にゅ", "nyu"],
|
||||||
|
["ニェ", "NYE"],
|
||||||
|
["にぇ", "nye"],
|
||||||
|
["ニョ", "NYO"],
|
||||||
|
["にょ", "nyo"],
|
||||||
|
["ジャ", "JA"],
|
||||||
|
["じゃ", "ja"],
|
||||||
|
["ジィ", "JI"],
|
||||||
|
["じぃ", "ji"],
|
||||||
|
["ジュ", "JU"],
|
||||||
|
["じゅ", "ju"],
|
||||||
|
["ジェ", "JE"],
|
||||||
|
["じぇ", "je"],
|
||||||
|
["ジョ", "JO"],
|
||||||
|
["じょ", "jo"],
|
||||||
|
["ヂャ", "DYA"],
|
||||||
|
["ぢゃ", "dya"],
|
||||||
|
["ヂィ", "DYI"],
|
||||||
|
["ぢぃ", "dyi"],
|
||||||
|
["ヂュ", "DYU"],
|
||||||
|
["ぢゅ", "dyu"],
|
||||||
|
["ヂェ", "DYE"],
|
||||||
|
["ぢぇ", "dye"],
|
||||||
|
["ヂョ", "DYO"],
|
||||||
|
["ぢょ", "dyo"]
|
||||||
|
],
|
||||||
|
"1letter": [
|
||||||
|
["ア", "A"],
|
||||||
|
["あ", "a"],
|
||||||
|
["イ", "I"],
|
||||||
|
["い", "i"],
|
||||||
|
["ウ", "U"],
|
||||||
|
["う", "u"],
|
||||||
|
["エ", "E"],
|
||||||
|
["え", "e"],
|
||||||
|
["オ", "O"],
|
||||||
|
["お", "o"],
|
||||||
|
["カ", "KA"],
|
||||||
|
["か", "ka"],
|
||||||
|
["キ", "KI"],
|
||||||
|
["き", "ki"],
|
||||||
|
["ク", "KU"],
|
||||||
|
["く", "ku"],
|
||||||
|
["ケ", "KE"],
|
||||||
|
["け", "ke"],
|
||||||
|
["コ", "KO"],
|
||||||
|
["こ", "ko"],
|
||||||
|
["サ", "SA"],
|
||||||
|
["さ", "sa"],
|
||||||
|
["シ", "SI"],
|
||||||
|
["し", "si"],
|
||||||
|
["ス", "SU"],
|
||||||
|
["す", "su"],
|
||||||
|
["セ", "SE"],
|
||||||
|
["せ", "se"],
|
||||||
|
["ソ", "SO"],
|
||||||
|
["そ", "so"],
|
||||||
|
["タ", "TA"],
|
||||||
|
["た", "ta"],
|
||||||
|
["テ", "TE"],
|
||||||
|
["て", "te"],
|
||||||
|
["ト", "TO"],
|
||||||
|
["と", "to"],
|
||||||
|
["ナ", "NA"],
|
||||||
|
["な", "na"],
|
||||||
|
["ニ", "NI"],
|
||||||
|
["に", "ni"],
|
||||||
|
["ヌ", "NU"],
|
||||||
|
["ぬ", "nu"],
|
||||||
|
["ネ", "NE"],
|
||||||
|
["ね", "ne"],
|
||||||
|
["ノ", "NO"],
|
||||||
|
["の", "no"],
|
||||||
|
["ハ", "HA"],
|
||||||
|
["は", "ha"],
|
||||||
|
["ヒ", "HI"],
|
||||||
|
["ひ", "hi"],
|
||||||
|
["フ", "HU"],
|
||||||
|
["ふ", "hu"],
|
||||||
|
["ヘ", "HE"],
|
||||||
|
["へ", "he"],
|
||||||
|
["ホ", "HO"],
|
||||||
|
["ほ", "ho"],
|
||||||
|
["マ", "MA"],
|
||||||
|
["ま", "ma"],
|
||||||
|
["ミ", "MI"],
|
||||||
|
["み", "mi"],
|
||||||
|
["ム", "MU"],
|
||||||
|
["む", "mu"],
|
||||||
|
["メ", "ME"],
|
||||||
|
["め", "me"],
|
||||||
|
["モ", "MO"],
|
||||||
|
["も", "mo"],
|
||||||
|
["ヤ", "YA"],
|
||||||
|
["や", "ya"],
|
||||||
|
["ユ", "YU"],
|
||||||
|
["ゆ", "yu"],
|
||||||
|
["ヨ", "YO"],
|
||||||
|
["よ", "yo"],
|
||||||
|
["ラ", "RA"],
|
||||||
|
["ら", "ra"],
|
||||||
|
["リ", "RI"],
|
||||||
|
["り", "ri"],
|
||||||
|
["ル", "RU"],
|
||||||
|
["る", "ru"],
|
||||||
|
["レ", "RE"],
|
||||||
|
["れ", "re"],
|
||||||
|
["ロ", "RO"],
|
||||||
|
["ろ", "ro"],
|
||||||
|
["ワ", "WA"],
|
||||||
|
["わ", "wa"],
|
||||||
|
["ヲ", "WO"],
|
||||||
|
["を", "wo"],
|
||||||
|
["ガ", "GA"],
|
||||||
|
["が", "ga"],
|
||||||
|
["ギ", "GI"],
|
||||||
|
["ぎ", "gi"],
|
||||||
|
["グ", "GU"],
|
||||||
|
["ぐ", "gu"],
|
||||||
|
["ゲ", "GE"],
|
||||||
|
["げ", "ge"],
|
||||||
|
["ゴ", "GO"],
|
||||||
|
["ご", "go"],
|
||||||
|
["チ", "CHI"],
|
||||||
|
["ち", "chi"],
|
||||||
|
["ジ", "JI"],
|
||||||
|
["じ", "ji"],
|
||||||
|
["ダ", "DA"],
|
||||||
|
["だ", "da"],
|
||||||
|
["ヂ", "DI"],
|
||||||
|
["ぢ", "di"],
|
||||||
|
["ヅ", "DU"],
|
||||||
|
["づ", "du"],
|
||||||
|
["デ", "DE"],
|
||||||
|
["で", "de"],
|
||||||
|
["ド", "DO"],
|
||||||
|
["ど", "do"],
|
||||||
|
["バ", "BA"],
|
||||||
|
["ば", "ba"],
|
||||||
|
["ビ", "BI"],
|
||||||
|
["び", "bi"],
|
||||||
|
["ブ", "BU"],
|
||||||
|
["ぶ", "bu"],
|
||||||
|
["ベ", "BE"],
|
||||||
|
["べ", "be"],
|
||||||
|
["ボ", "BO"],
|
||||||
|
["ぼ", "bo"],
|
||||||
|
["ヴ", "VU"],
|
||||||
|
["ヴ", "vu"],
|
||||||
|
["パ", "PA"],
|
||||||
|
["ぱ", "pa"],
|
||||||
|
["ピ", "PI"],
|
||||||
|
["ぴ", "pi"],
|
||||||
|
["プ", "PU"],
|
||||||
|
["ぷ", "pu"],
|
||||||
|
["ペ", "PE"],
|
||||||
|
["ぺ", "pe"],
|
||||||
|
["ポ", "PO"],
|
||||||
|
["ぽ", "po"],
|
||||||
|
["ザ", "ZA"],
|
||||||
|
["ざ", "za"],
|
||||||
|
["ジ", "ZI"],
|
||||||
|
["じ", "zi"],
|
||||||
|
["ズ", "ZU"],
|
||||||
|
["ず", "zu"],
|
||||||
|
["ゼ", "ZE"],
|
||||||
|
["ぜ", "ze"],
|
||||||
|
["ゾ", "ZO"],
|
||||||
|
["ぞ", "zo"],
|
||||||
|
["フ", "FU"],
|
||||||
|
["ふ", "fu"],
|
||||||
|
["ァ", "A"],
|
||||||
|
["ぁ", "a"],
|
||||||
|
["ィ", "I"],
|
||||||
|
["ぃ", "i"],
|
||||||
|
["ゥ", "U"],
|
||||||
|
["ぅ", "u"],
|
||||||
|
["ェ", "E"],
|
||||||
|
["ぇ", "e"],
|
||||||
|
["ォ", "O"],
|
||||||
|
["ぉ", "o"],
|
||||||
|
["ン", "N"],
|
||||||
|
["ん", "n"],
|
||||||
|
["シ", "SHI"],
|
||||||
|
["し", "shi"],
|
||||||
|
["ツ", "TSU"],
|
||||||
|
["つ", "tsu"],
|
||||||
|
["ヅ", "DU"],
|
||||||
|
["づ", "du"],
|
||||||
|
["ヂ", "DI"],
|
||||||
|
["ぢ", "di"],
|
||||||
|
["ヮ", "WA"],
|
||||||
|
["ゎ", "wa"],
|
||||||
|
["ヵ", "KA"],
|
||||||
|
["ヵ", "ka"],
|
||||||
|
["ヶ", "KE"],
|
||||||
|
["ヶ", "ke"],
|
||||||
|
["ャ", "YA"],
|
||||||
|
["ゃ", "ya"],
|
||||||
|
["ィ", "YI"],
|
||||||
|
["ぃ", "yi"],
|
||||||
|
["ュ", "YU"],
|
||||||
|
["ゅ", "yu"],
|
||||||
|
["ェ", "YE"],
|
||||||
|
["ぇ", "ye"],
|
||||||
|
["ョ", "YO"],
|
||||||
|
["ょ", "yo"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"romajiToKana": {
|
||||||
|
"4letter": [
|
||||||
|
["XTSU", "ッ"],
|
||||||
|
["xtsu", "っ"],
|
||||||
|
["LTSU", "ッ"],
|
||||||
|
["ltsu", "っ"]
|
||||||
|
],
|
||||||
|
"3letter": [
|
||||||
|
["CHA", "チャ"],
|
||||||
|
["cha", "ちゃ"],
|
||||||
|
["CHI", "チ"],
|
||||||
|
["chi", "ち"],
|
||||||
|
["CHU", "チュ"],
|
||||||
|
["chu", "ちゅ"],
|
||||||
|
["CHE", "チェ"],
|
||||||
|
["che", "ちぇ"],
|
||||||
|
["CHO", "チョ"],
|
||||||
|
["cho", "ちょ"],
|
||||||
|
["SHA", "シャ"],
|
||||||
|
["sha", "しゃ"],
|
||||||
|
["SHI", "シ"],
|
||||||
|
["shi", "し"],
|
||||||
|
["SHU", "シュ"],
|
||||||
|
["shu", "しゅ"],
|
||||||
|
["SHE", "シェ"],
|
||||||
|
["she", "しぇ"],
|
||||||
|
["SHO", "ショ"],
|
||||||
|
["sho", "しょ"],
|
||||||
|
["RYA", "リャ"],
|
||||||
|
["rya", "りゃ"],
|
||||||
|
["RYI", "リィ"],
|
||||||
|
["ryi", "りぃ"],
|
||||||
|
["RYU", "リュ"],
|
||||||
|
["ryu", "りゅ"],
|
||||||
|
["RYE", "リェ"],
|
||||||
|
["rye", "りぇ"],
|
||||||
|
["RYO", "リョ"],
|
||||||
|
["ryo", "りょ"],
|
||||||
|
["HYA", "ヒャ"],
|
||||||
|
["hya", "ひゃ"],
|
||||||
|
["HYI", "ヒィ"],
|
||||||
|
["hyi", "ひぃ"],
|
||||||
|
["HYU", "ヒュ"],
|
||||||
|
["hyu", "ひゅ"],
|
||||||
|
["HYE", "ヒェ"],
|
||||||
|
["hye", "ひぇ"],
|
||||||
|
["HYO", "ヒョ"],
|
||||||
|
["hyo", "ひょ"],
|
||||||
|
["BYA", "ビャ"],
|
||||||
|
["bya", "びゃ"],
|
||||||
|
["BYI", "ビィ"],
|
||||||
|
["byi", "びぃ"],
|
||||||
|
["BYU", "ビュ"],
|
||||||
|
["byu", "びゅ"],
|
||||||
|
["BYE", "ビェ"],
|
||||||
|
["bye", "びぇ"],
|
||||||
|
["BYO", "ビョ"],
|
||||||
|
["byo", "びょ"],
|
||||||
|
["PYA", "ピャ"],
|
||||||
|
["pya", "ぴゃ"],
|
||||||
|
["PYI", "ピィ"],
|
||||||
|
["pyi", "ぴぃ"],
|
||||||
|
["PYU", "ピュ"],
|
||||||
|
["pyu", "ぴゅ"],
|
||||||
|
["PYE", "ピェ"],
|
||||||
|
["pye", "ぴぇ"],
|
||||||
|
["PYO", "ピョ"],
|
||||||
|
["pyo", "ぴょ"],
|
||||||
|
["MYA", "ミャ"],
|
||||||
|
["mya", "みゃ"],
|
||||||
|
["MYI", "ミィ"],
|
||||||
|
["myi", "みぃ"],
|
||||||
|
["MYU", "ミュ"],
|
||||||
|
["myu", "みゅ"],
|
||||||
|
["MYE", "ミェ"],
|
||||||
|
["mye", "みぇ"],
|
||||||
|
["MYO", "ミョ"],
|
||||||
|
["myo", "みょ"],
|
||||||
|
["KYA", "キャ"],
|
||||||
|
["kya", "きゃ"],
|
||||||
|
["KYI", "キィ"],
|
||||||
|
["kyi", "きぃ"],
|
||||||
|
["KYU", "キュ"],
|
||||||
|
["kyu", "きゅ"],
|
||||||
|
["KYE", "キェ"],
|
||||||
|
["kye", "きぇ"],
|
||||||
|
["KYO", "キョ"],
|
||||||
|
["kyo", "きょ"],
|
||||||
|
["GYA", "ギャ"],
|
||||||
|
["gya", "ぎゃ"],
|
||||||
|
["GYI", "ギィ"],
|
||||||
|
["gyi", "ぎぃ"],
|
||||||
|
["GYU", "ギュ"],
|
||||||
|
["gyu", "ぎゅ"],
|
||||||
|
["GYE", "ギェ"],
|
||||||
|
["gye", "ぎぇ"],
|
||||||
|
["GYO", "ギョ"],
|
||||||
|
["gyo", "ぎょ"],
|
||||||
|
["NYA", "ニャ"],
|
||||||
|
["nya", "にゃ"],
|
||||||
|
["NYI", "ニィ"],
|
||||||
|
["nyi", "にぃ"],
|
||||||
|
["NYU", "ニュ"],
|
||||||
|
["nyu", "にゅ"],
|
||||||
|
["NYE", "ニェ"],
|
||||||
|
["nye", "にぇ"],
|
||||||
|
["NYO", "ニョ"],
|
||||||
|
["nyo", "にょ"],
|
||||||
|
["JYA", "ジャ"],
|
||||||
|
["jya", "じゃ"],
|
||||||
|
["JYI", "ジィ"],
|
||||||
|
["jyi", "じぃ"],
|
||||||
|
["JYU", "ジュ"],
|
||||||
|
["jyu", "じゅ"],
|
||||||
|
["JYE", "ジェ"],
|
||||||
|
["jye", "じぇ"],
|
||||||
|
["JYO", "ジョ"],
|
||||||
|
["jyo", "じょ"],
|
||||||
|
["TSU", "ツ"],
|
||||||
|
["tsu", "つ"],
|
||||||
|
["DZU", "ヅ"],
|
||||||
|
["dzu", "づ"],
|
||||||
|
["DZI", "ヂ"],
|
||||||
|
["dzi", "ぢ"],
|
||||||
|
["DYA", "ヂャ"],
|
||||||
|
["dya", "ぢゃ"],
|
||||||
|
["DYI", "ヂィ"],
|
||||||
|
["dyi", "ぢぃ"],
|
||||||
|
["DYU", "ヂュ"],
|
||||||
|
["dyu", "ぢゅ"],
|
||||||
|
["DYE", "ヂェ"],
|
||||||
|
["dye", "ぢぇ"],
|
||||||
|
["DYO", "ヂョ"],
|
||||||
|
["dyo", "ぢょ"],
|
||||||
|
["XTU", "ッ"],
|
||||||
|
["xtu", "っ"],
|
||||||
|
["XWA", "ヮ"],
|
||||||
|
["xwa", "ゎ"],
|
||||||
|
["XKA", "ヵ"],
|
||||||
|
["xka", "ヵ"],
|
||||||
|
["XKE", "ヶ"],
|
||||||
|
["xke", "ヶ"],
|
||||||
|
["XYA", "ャ"],
|
||||||
|
["xya", "ゃ"],
|
||||||
|
["XYI", "ィ"],
|
||||||
|
["xyi", "ぃ"],
|
||||||
|
["XYU", "ュ"],
|
||||||
|
["xyu", "ゅ"],
|
||||||
|
["XYE", "ェ"],
|
||||||
|
["xye", "ぇ"],
|
||||||
|
["XYO", "ョ"],
|
||||||
|
["xyo", "ょ"],
|
||||||
|
["cya", "ちゃ"],
|
||||||
|
["cyu", "ちゅ"],
|
||||||
|
["cyo", "ちょ"],
|
||||||
|
["sya", "しゃ"],
|
||||||
|
["syu", "しゅ"],
|
||||||
|
["syo", "しょ"],
|
||||||
|
["tya", "ちゃ"],
|
||||||
|
["tyu", "ちゅ"],
|
||||||
|
["tyo", "ちょ"],
|
||||||
|
["TYA", "チャ"],
|
||||||
|
["TYU", "チュ"],
|
||||||
|
["TYO", "チョ"]
|
||||||
|
],
|
||||||
|
"2letter": [
|
||||||
|
["KA", "カ"],
|
||||||
|
["ka", "か"],
|
||||||
|
["KI", "キ"],
|
||||||
|
["ki", "き"],
|
||||||
|
["KU", "ク"],
|
||||||
|
["ku", "く"],
|
||||||
|
["KE", "ケ"],
|
||||||
|
["ke", "け"],
|
||||||
|
["KO", "コ"],
|
||||||
|
["ko", "こ"],
|
||||||
|
["CA", "カ"],
|
||||||
|
["ca", "か"],
|
||||||
|
["CI", "キ"],
|
||||||
|
["ci", "き"],
|
||||||
|
["CU", "ク"],
|
||||||
|
["cu", "く"],
|
||||||
|
["CE", "ケ"],
|
||||||
|
["ce", "け"],
|
||||||
|
["CO", "コ"],
|
||||||
|
["co", "こ"],
|
||||||
|
["SA", "サ"],
|
||||||
|
["sa", "さ"],
|
||||||
|
["SI", "シ"],
|
||||||
|
["si", "し"],
|
||||||
|
["SU", "ス"],
|
||||||
|
["su", "す"],
|
||||||
|
["SE", "セ"],
|
||||||
|
["se", "せ"],
|
||||||
|
["SO", "ソ"],
|
||||||
|
["so", "そ"],
|
||||||
|
["TA", "タ"],
|
||||||
|
["ta", "た"],
|
||||||
|
["TE", "テ"],
|
||||||
|
["ti", "ち"],
|
||||||
|
["TI", "チ"],
|
||||||
|
["tu", "つ"],
|
||||||
|
["TU", "ツ"],
|
||||||
|
["te", "て"],
|
||||||
|
["TO", "ト"],
|
||||||
|
["to", "と"],
|
||||||
|
["NA", "ナ"],
|
||||||
|
["na", "な"],
|
||||||
|
["NI", "ニ"],
|
||||||
|
["ni", "に"],
|
||||||
|
["NU", "ヌ"],
|
||||||
|
["nu", "ぬ"],
|
||||||
|
["NE", "ネ"],
|
||||||
|
["ne", "ね"],
|
||||||
|
["NO", "ノ"],
|
||||||
|
["no", "の"],
|
||||||
|
["HA", "ハ"],
|
||||||
|
["ha", "は"],
|
||||||
|
["HI", "ヒ"],
|
||||||
|
["hi", "ひ"],
|
||||||
|
["HU", "フ"],
|
||||||
|
["hu", "ふ"],
|
||||||
|
["HE", "ヘ"],
|
||||||
|
["he", "へ"],
|
||||||
|
["HO", "ホ"],
|
||||||
|
["ho", "ほ"],
|
||||||
|
["MA", "マ"],
|
||||||
|
["ma", "ま"],
|
||||||
|
["MI", "ミ"],
|
||||||
|
["mi", "み"],
|
||||||
|
["MU", "ム"],
|
||||||
|
["mu", "む"],
|
||||||
|
["ME", "メ"],
|
||||||
|
["me", "め"],
|
||||||
|
["MO", "モ"],
|
||||||
|
["mo", "も"],
|
||||||
|
["YA", "ヤ"],
|
||||||
|
["ya", "や"],
|
||||||
|
["YU", "ユ"],
|
||||||
|
["yu", "ゆ"],
|
||||||
|
["YE", "イェ"],
|
||||||
|
["ye", "いぇ"],
|
||||||
|
["YO", "ヨ"],
|
||||||
|
["yo", "よ"],
|
||||||
|
["RA", "ラ"],
|
||||||
|
["ra", "ら"],
|
||||||
|
["RI", "リ"],
|
||||||
|
["ri", "り"],
|
||||||
|
["RU", "ル"],
|
||||||
|
["ru", "る"],
|
||||||
|
["RE", "レ"],
|
||||||
|
["re", "れ"],
|
||||||
|
["RO", "ロ"],
|
||||||
|
["ro", "ろ"],
|
||||||
|
["LA", "ラ"],
|
||||||
|
["la", "ら"],
|
||||||
|
["LI", "リ"],
|
||||||
|
["li", "り"],
|
||||||
|
["LU", "ル"],
|
||||||
|
["lu", "る"],
|
||||||
|
["LE", "レ"],
|
||||||
|
["le", "れ"],
|
||||||
|
["LO", "ロ"],
|
||||||
|
["lo", "ろ"],
|
||||||
|
["WA", "ワ"],
|
||||||
|
["wa", "わ"],
|
||||||
|
["WI", "ウィ"],
|
||||||
|
["wi", "うぃ"],
|
||||||
|
["WU", "ウ"],
|
||||||
|
["wu", "う"],
|
||||||
|
["WE", "ウェ"],
|
||||||
|
["we", "うぇ"],
|
||||||
|
["WO", "ヲ"],
|
||||||
|
["wo", "を"],
|
||||||
|
["GA", "ガ"],
|
||||||
|
["ga", "が"],
|
||||||
|
["GI", "ギ"],
|
||||||
|
["gi", "ぎ"],
|
||||||
|
["GU", "グ"],
|
||||||
|
["gu", "ぐ"],
|
||||||
|
["GE", "ゲ"],
|
||||||
|
["ge", "げ"],
|
||||||
|
["GO", "ゴ"],
|
||||||
|
["go", "ご"],
|
||||||
|
["JA", "ジャ"],
|
||||||
|
["ja", "じゃ"],
|
||||||
|
["JI", "ジ"],
|
||||||
|
["ji", "じ"],
|
||||||
|
["JU", "ジュ"],
|
||||||
|
["ju", "じゅ"],
|
||||||
|
["JE", "ジェ"],
|
||||||
|
["je", "じぇ"],
|
||||||
|
["JO", "ジョ"],
|
||||||
|
["jo", "じょ"],
|
||||||
|
["DA", "ダ"],
|
||||||
|
["da", "だ"],
|
||||||
|
["DI", "ヂ"],
|
||||||
|
["di", "ぢ"],
|
||||||
|
["DU", "ヅ"],
|
||||||
|
["du", "づ"],
|
||||||
|
["DE", "デ"],
|
||||||
|
["de", "で"],
|
||||||
|
["DO", "ド"],
|
||||||
|
["do", "ど"],
|
||||||
|
["BA", "バ"],
|
||||||
|
["ba", "ば"],
|
||||||
|
["BI", "ビ"],
|
||||||
|
["bi", "び"],
|
||||||
|
["BU", "ブ"],
|
||||||
|
["bu", "ぶ"],
|
||||||
|
["BE", "ベ"],
|
||||||
|
["be", "べ"],
|
||||||
|
["BO", "ボ"],
|
||||||
|
["bo", "ぼ"],
|
||||||
|
["VA", "ヴァ"],
|
||||||
|
["va", "ヴぁ"],
|
||||||
|
["VI", "ヴィ"],
|
||||||
|
["vi", "ヴぃ"],
|
||||||
|
["VU", "ヴ"],
|
||||||
|
["vu", "ヴ"],
|
||||||
|
["VE", "ヴェ"],
|
||||||
|
["ve", "ヴぇ"],
|
||||||
|
["VO", "ヴォ"],
|
||||||
|
["vo", "ヴぉ"],
|
||||||
|
["PA", "パ"],
|
||||||
|
["pa", "ぱ"],
|
||||||
|
["PI", "ピ"],
|
||||||
|
["pi", "ぴ"],
|
||||||
|
["PU", "プ"],
|
||||||
|
["pu", "ぷ"],
|
||||||
|
["PE", "ペ"],
|
||||||
|
["pe", "ぺ"],
|
||||||
|
["PO", "ポ"],
|
||||||
|
["po", "ぽ"],
|
||||||
|
["ZA", "ザ"],
|
||||||
|
["za", "ざ"],
|
||||||
|
["ZI", "ジ"],
|
||||||
|
["zi", "じ"],
|
||||||
|
["ZU", "ズ"],
|
||||||
|
["zu", "ず"],
|
||||||
|
["ZE", "ゼ"],
|
||||||
|
["ze", "ぜ"],
|
||||||
|
["ZO", "ゾ"],
|
||||||
|
["zo", "ぞ"],
|
||||||
|
["FA", "ファ"],
|
||||||
|
["fa", "ふぁ"],
|
||||||
|
["FI", "フィ"],
|
||||||
|
["fi", "ふぃ"],
|
||||||
|
["FU", "フ"],
|
||||||
|
["fu", "ふ"],
|
||||||
|
["FE", "フェ"],
|
||||||
|
["fe", "ふぇ"],
|
||||||
|
["FO", "フォ"],
|
||||||
|
["fo", "ふぉ"],
|
||||||
|
["XA", "ァ"],
|
||||||
|
["xa", "ぁ"],
|
||||||
|
["XI", "ィ"],
|
||||||
|
["xi", "ぃ"],
|
||||||
|
["XU", "ゥ"],
|
||||||
|
["xu", "ぅ"],
|
||||||
|
["XE", "ェ"],
|
||||||
|
["xe", "ぇ"],
|
||||||
|
["XO", "ォ"],
|
||||||
|
["xo", "ぉ"],
|
||||||
|
["XN", "ン"],
|
||||||
|
["xn", "ん"],
|
||||||
|
["NN", "ン"],
|
||||||
|
["N'", "ン"],
|
||||||
|
["nn", "ん"],
|
||||||
|
["n'", "ん"]
|
||||||
|
],
|
||||||
|
"1letter": [
|
||||||
|
["A", "ア"],
|
||||||
|
["a", "あ"],
|
||||||
|
["I", "イ"],
|
||||||
|
["i", "い"],
|
||||||
|
["U", "ウ"],
|
||||||
|
["u", "う"],
|
||||||
|
["E", "エ"],
|
||||||
|
["e", "え"],
|
||||||
|
["O", "オ"],
|
||||||
|
["o", "お"]
|
||||||
|
],
|
||||||
|
"replaceN": [
|
||||||
|
["N", "ン"],
|
||||||
|
["n", "ん"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
69
src/data/srslevels.json
Normal file
69
src/data/srslevels.json
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"group": "Set In Stone",
|
||||||
|
"name": "★",
|
||||||
|
"value": 8,
|
||||||
|
"delay": null,
|
||||||
|
"color": "#1C1C1C"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"group": "Assimilating",
|
||||||
|
"name": "A2",
|
||||||
|
"value": 7,
|
||||||
|
"delay": 10368000,
|
||||||
|
"color": "#890062"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Assimilating",
|
||||||
|
"name": "A1",
|
||||||
|
"value": 6,
|
||||||
|
"delay": 2592000,
|
||||||
|
"color": "#890062"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"group": "Bolstering",
|
||||||
|
"name": "B2",
|
||||||
|
"value": 5,
|
||||||
|
"delay": 1209600,
|
||||||
|
"color": "#5C1696"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Bolstering",
|
||||||
|
"name": "B1",
|
||||||
|
"value": 4,
|
||||||
|
"delay": 604800,
|
||||||
|
"color": "#5C1696"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"group": "Committing",
|
||||||
|
"name": "C2",
|
||||||
|
"value": 3,
|
||||||
|
"delay": 259200,
|
||||||
|
"color": "#004E8E"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Committing",
|
||||||
|
"name": "C1",
|
||||||
|
"value": 2,
|
||||||
|
"delay": 86400,
|
||||||
|
"color": "#004E8E"
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"group": "Discovering",
|
||||||
|
"name": "D2",
|
||||||
|
"value": 1,
|
||||||
|
"delay": 28800,
|
||||||
|
"color": "#1A814D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": "Discovering",
|
||||||
|
"name": "D1",
|
||||||
|
"value": 0,
|
||||||
|
"delay": 14400,
|
||||||
|
"color": "#1A814D"
|
||||||
|
}
|
||||||
|
]
|
44
src/lib/kanaHelper.ts
Normal file
44
src/lib/kanaHelper.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
// See https://github.com/Doublevil/Houhou-SRS/blob/master/Kanji.Common/Helpers/KanaHelper.cs
|
||||||
|
|
||||||
|
import kanadata from "../data/kanadata.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifically converts a romaji string to kana.
|
||||||
|
* The result may be hiragana, katakana or mixed, depending on the case
|
||||||
|
* of the input romaji.
|
||||||
|
*
|
||||||
|
* @param romaji Input romaji string.
|
||||||
|
* @param isLive Set to true to specify that the conversion is done in live (while the user is writing). Disables certain functions.
|
||||||
|
* @returns Output kana string.
|
||||||
|
*/
|
||||||
|
export function romajiToKana(romaji: string, isLive = false): string | null {
|
||||||
|
let s = romaji.trim();
|
||||||
|
if (s == "") return null;
|
||||||
|
|
||||||
|
// Replace the double vowels for katakana.
|
||||||
|
const doubleVowelRegex = /([AEIOU])\1/g;
|
||||||
|
s = s.replaceAll(doubleVowelRegex, "$1ー");
|
||||||
|
|
||||||
|
// Replace the double consonants.
|
||||||
|
|
||||||
|
// Then, replace - by ー.
|
||||||
|
s = s.replaceAll("-", "ー");
|
||||||
|
s = s.replaceAll("_", "ー");
|
||||||
|
|
||||||
|
// Then, replace 4 letter characters:
|
||||||
|
for (const [find, repl] of kanadata.romajiToKana["4letter"]) s = s.replaceAll(find, repl);
|
||||||
|
|
||||||
|
// Then, replace 3 letter characters:
|
||||||
|
for (const [find, repl] of kanadata.romajiToKana["3letter"]) s = s.replaceAll(find, repl);
|
||||||
|
|
||||||
|
// Then, replace 2 letter characters:
|
||||||
|
for (const [find, repl] of kanadata.romajiToKana["2letter"]) s = s.replaceAll(find, repl);
|
||||||
|
|
||||||
|
// Then, replace 1 letter characters:
|
||||||
|
for (const [find, repl] of kanadata.romajiToKana["1letter"]) s = s.replaceAll(find, repl);
|
||||||
|
|
||||||
|
if (!isLive)
|
||||||
|
for (const [find, repl] of kanadata.romajiToKana["replaceN"]) s = s.replaceAll(find, repl);
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
18
src/lib/kanji.ts
Normal file
18
src/lib/kanji.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export interface Kanji {
|
||||||
|
character: string;
|
||||||
|
most_used_rank: number;
|
||||||
|
meanings: KanjiMeaning[];
|
||||||
|
srs_info?: KanjiSrsInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanjiMeaning {
|
||||||
|
id: number;
|
||||||
|
meaning: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanjiSrsInfo {
|
||||||
|
id: number;
|
||||||
|
current_grade: number;
|
||||||
|
next_answer_date: number;
|
||||||
|
associated_kanji: string;
|
||||||
|
}
|
57
src/lib/srs.ts
Normal file
57
src/lib/srs.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
export interface SrsEntry {
|
||||||
|
id: number;
|
||||||
|
associated_kanji: string;
|
||||||
|
current_grade: number;
|
||||||
|
meanings: string[];
|
||||||
|
readings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SrsLevel {
|
||||||
|
group: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
/** In seconds */
|
||||||
|
delay: number | null;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
import srsLevelMap from "../data/srslevels.json";
|
||||||
|
export const srsLevels: Map<number, SrsLevel> = new Map(srsLevelMap.map((v) => [v.value, v]));
|
||||||
|
|
||||||
|
export interface SrsQuestionGroup {
|
||||||
|
srsEntry: SrsEntry;
|
||||||
|
questions: { meaningQuestion: ReviewItem; readingQuestion: ReviewItem };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allQuestionsAnswered(group: SrsQuestionGroup): boolean {
|
||||||
|
return Object.values(group.questions).every((v) => v.isCorrect != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGroupCorrect(group: SrsQuestionGroup): boolean {
|
||||||
|
return Object.values(group.questions).every((v) => v.isCorrect == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupUpdatedLevel(group: SrsQuestionGroup): SrsLevel {
|
||||||
|
const grade = group.srsEntry.current_grade;
|
||||||
|
const modifier = isGroupCorrect(group) ? 1 : -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
srsLevels.get(grade + modifier) ??
|
||||||
|
// Rip type coercion, but current grade should be pretty much set
|
||||||
|
(srsLevels.get(grade) as SrsLevel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReviewItemType {
|
||||||
|
MEANING = "MEANING",
|
||||||
|
READING = "READING",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewItem {
|
||||||
|
parent: SrsQuestionGroup;
|
||||||
|
type: ReviewItemType;
|
||||||
|
challenge: string;
|
||||||
|
possibleAnswers: string[];
|
||||||
|
isCorrect: boolean | null;
|
||||||
|
timesRepeated: number;
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
export default function HomePane() {
|
|
||||||
return <>
|
|
||||||
hellosu
|
|
||||||
</>
|
|
||||||
}
|
|
|
@ -1,10 +1,21 @@
|
||||||
$kanjiDisplaySize: 80px;
|
.kanji-pane-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
.kanjiDisplay {
|
padding: 16px;
|
||||||
border: 1px solid rgb(87, 87, 210);
|
gap: 16px;
|
||||||
width: $kanjiDisplaySize;
|
}
|
||||||
height: $kanjiDisplaySize;
|
|
||||||
font-size: $kanjiDisplaySize * 0.8;
|
.kanji-pane-list {
|
||||||
text-align: center;
|
display: flex;
|
||||||
vertical-align: middle;
|
flex-direction: column;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-side {
|
||||||
|
flex-grow: 5;
|
||||||
}
|
}
|
|
@ -1,13 +1,63 @@
|
||||||
import { useState } from "react"
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
import { Kanji } from "../types/Kanji"
|
import useSWR from "swr";
|
||||||
import styles from "./KanjiPane.module.scss"
|
import { Box, Flex, Grid, GridItem, LinkBox, Stack } from "@chakra-ui/layout";
|
||||||
|
|
||||||
export default function KanjiPane() {
|
import styles from "./KanjiPane.module.scss";
|
||||||
const [selectedKanji, setSelectedKanji] = useState(null);
|
import { useParams } from "react-router-dom";
|
||||||
|
import KanjiDisplay from "../components/KanjiDisplay";
|
||||||
|
import { Kanji } from "../lib/kanji";
|
||||||
|
import { KanjiList } from "../components/KanjiList";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
return <>
|
export interface GetKanjiResult {
|
||||||
{JSON.stringify(selectedKanji)}
|
count: number;
|
||||||
|
kanji: Kanji[];
|
||||||
<div className={styles.kanjiDisplay}></div>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
const { selectedKanji } = useParams();
|
||||||
|
const { data: baseData, error, isLoading } = useSWR("get_kanji", invoke<GetKanjiResult>);
|
||||||
|
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [kanjiList, setKanjiList] = useState<Kanji[]>([]);
|
||||||
|
|
||||||
|
// Set the base info
|
||||||
|
useEffect(() => {
|
||||||
|
if (baseData) {
|
||||||
|
setTotalCount(baseData.count);
|
||||||
|
setKanjiList(baseData.kanji);
|
||||||
|
}
|
||||||
|
}, [baseData]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreKanji = async () => {
|
||||||
|
const result = await invoke<GetKanjiResult>("get_kanji", {
|
||||||
|
options: { skip: kanjiList.length, include_srs_info: true },
|
||||||
|
});
|
||||||
|
setKanjiList([...kanjiList, ...result.kanji]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex className={styles["kanji-pane-container"]}>
|
||||||
|
<Box className={styles["kanji-pane-list"]}>
|
||||||
|
{kanjiList && (
|
||||||
|
<KanjiList
|
||||||
|
kanjiList={kanjiList}
|
||||||
|
totalCount={totalCount}
|
||||||
|
selectedCharacter={selectedKanji}
|
||||||
|
loadMoreKanji={loadMoreKanji}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box className={styles["right-side"]}>
|
||||||
|
{selectedKanji ? <KanjiDisplay kanjiCharacter={selectedKanji} /> : "nothing selected"}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "KanjiPane";
|
||||||
|
|
5
src/panes/SettingsPane.tsx
Normal file
5
src/panes/SettingsPane.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export function Component() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "SettingsPane";
|
8
src/panes/SrsPane.module.scss
Normal file
8
src/panes/SrsPane.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.main {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
|
@ -1,3 +1,14 @@
|
||||||
export default function SrsPane() {
|
import DashboardReviewStats from "../components/DashboardReviewStats";
|
||||||
|
import styles from "./SrsPane.module.scss";
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<main className={styles.main}>
|
||||||
|
<DashboardReviewStats />
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component.displayName = "SrsPane";
|
||||||
|
|
22
src/panes/SrsReviewPane.module.scss
Normal file
22
src/panes/SrsReviewPane.module.scss
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
.main {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: lighten(#393, 45%);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-word {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 4em;
|
||||||
|
padding: 64px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.incorrect {
|
||||||
|
background-color: rgb(255, 202, 202) !important;
|
||||||
|
}
|
249
src/panes/SrsReviewPane.tsx
Normal file
249
src/panes/SrsReviewPane.tsx
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
InputRightElement,
|
||||||
|
Progress,
|
||||||
|
Spinner,
|
||||||
|
useDisclosure,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import styles from "./SrsReviewPane.module.scss";
|
||||||
|
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/tauri";
|
||||||
|
import { Link, ScrollRestoration, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowBackIcon } from "@chakra-ui/icons";
|
||||||
|
import ConfirmQuitModal from "../components/utils/ConfirmQuitModal";
|
||||||
|
import * as _ from "lodash-es";
|
||||||
|
import { romajiToKana } from "../lib/kanaHelper";
|
||||||
|
import {
|
||||||
|
ReviewItem,
|
||||||
|
ReviewItemType,
|
||||||
|
SrsEntry,
|
||||||
|
SrsQuestionGroup,
|
||||||
|
allQuestionsAnswered,
|
||||||
|
groupUpdatedLevel,
|
||||||
|
isGroupCorrect,
|
||||||
|
} from "../lib/srs";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
const batchSize = 10;
|
||||||
|
|
||||||
|
function Done() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>oh Shit you're done!!! poggerse</p>
|
||||||
|
<Link to="/">
|
||||||
|
<Button colorScheme="blue">
|
||||||
|
<ArrowBackIcon /> Return
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
// null = has not started, (.length == 0) = finished
|
||||||
|
const [reviewQueue, setReviewQueue] = useState<ReviewItem[] | null>(null);
|
||||||
|
const [completedQueue, setCompletedQueue] = useState<ReviewItem[]>([]);
|
||||||
|
|
||||||
|
const [anyProgress, setAnyProgress] = useState(false);
|
||||||
|
const [startingSize, setStartingSize] = useState<number | null>(null);
|
||||||
|
const [currentAnswer, setCurrentAnswer] = useState("");
|
||||||
|
const [incorrectTimes, setIncorrectTimes] = useState(0);
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reviewQueue) {
|
||||||
|
invoke<SrsEntry[]>("generate_review_batch")
|
||||||
|
.then((result) => {
|
||||||
|
const newReviews: ReviewItem[] = result.flatMap((srsEntry) => {
|
||||||
|
// L @ breaking type safety, but this is mutually recursive too
|
||||||
|
const srsQuestionGroup: SrsQuestionGroup = {
|
||||||
|
srsEntry,
|
||||||
|
questions: {},
|
||||||
|
} as SrsQuestionGroup;
|
||||||
|
|
||||||
|
const meaningQuestion: ReviewItem = {
|
||||||
|
parent: srsQuestionGroup,
|
||||||
|
type: ReviewItemType.MEANING,
|
||||||
|
challenge: srsEntry.associated_kanji,
|
||||||
|
possibleAnswers: srsEntry.meanings,
|
||||||
|
isCorrect: null,
|
||||||
|
timesRepeated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const readingQuestion: ReviewItem = {
|
||||||
|
parent: srsQuestionGroup,
|
||||||
|
type: ReviewItemType.READING,
|
||||||
|
challenge: srsEntry.associated_kanji,
|
||||||
|
possibleAnswers: srsEntry.readings,
|
||||||
|
isCorrect: null,
|
||||||
|
timesRepeated: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
srsQuestionGroup.questions.meaningQuestion = meaningQuestion;
|
||||||
|
srsQuestionGroup.questions.readingQuestion = readingQuestion;
|
||||||
|
|
||||||
|
return [meaningQuestion, readingQuestion];
|
||||||
|
});
|
||||||
|
const newReviewsShuffled = _.shuffle(newReviews);
|
||||||
|
|
||||||
|
setReviewQueue(newReviewsShuffled);
|
||||||
|
setStartingSize(newReviews.length);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("fuck!", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [reviewQueue]);
|
||||||
|
|
||||||
|
if (!reviewQueue) return <Spinner />;
|
||||||
|
if (reviewQueue.length == 0) return <Done />;
|
||||||
|
const [nextItem, ...restOfQueue] = reviewQueue;
|
||||||
|
const possibleAnswers = new Set(nextItem.possibleAnswers);
|
||||||
|
|
||||||
|
const formSubmit = async (evt: FormEvent) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (!reviewQueue) return;
|
||||||
|
|
||||||
|
const isCorrect = possibleAnswers.has(currentAnswer);
|
||||||
|
nextItem.isCorrect =
|
||||||
|
new Map([
|
||||||
|
[null, isCorrect],
|
||||||
|
[false, false],
|
||||||
|
[true, isCorrect],
|
||||||
|
]).get(nextItem.isCorrect) ?? isCorrect;
|
||||||
|
|
||||||
|
// Figure out if we need to update the backend
|
||||||
|
if (allQuestionsAnswered(nextItem.parent)) {
|
||||||
|
console.log("SHIET");
|
||||||
|
|
||||||
|
const group = nextItem.parent;
|
||||||
|
const newLevel = groupUpdatedLevel(group);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
itemId: nextItem.parent.srsEntry.id,
|
||||||
|
correct: isGroupCorrect(nextItem.parent),
|
||||||
|
newGrade: newLevel.value,
|
||||||
|
delay: newLevel.delay,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await invoke("update_srs_item", params);
|
||||||
|
console.log("result", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's wrong this time
|
||||||
|
if (!isCorrect) {
|
||||||
|
setCurrentAnswer("");
|
||||||
|
setIncorrectTimes(incorrectTimes + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up for next question!
|
||||||
|
setAnyProgress(true);
|
||||||
|
setIncorrectTimes(0);
|
||||||
|
setCurrentAnswer("");
|
||||||
|
|
||||||
|
if (nextItem.isCorrect || nextItem.timesRepeated > 0) {
|
||||||
|
setCompletedQueue([...completedQueue, nextItem]);
|
||||||
|
setReviewQueue(restOfQueue);
|
||||||
|
} else {
|
||||||
|
nextItem.timesRepeated++;
|
||||||
|
setReviewQueue([...restOfQueue, nextItem]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputBox = (kanaInput: boolean) => {
|
||||||
|
const onChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
let newValue = evt.target.value;
|
||||||
|
if (kanaInput) newValue = romajiToKana(newValue) ?? newValue;
|
||||||
|
|
||||||
|
setCurrentAnswer(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const className = classNames(styles["input-box"], incorrectTimes > 0 && styles["incorrect"]);
|
||||||
|
const placeholder =
|
||||||
|
{
|
||||||
|
0: "Enter your answer...",
|
||||||
|
1: "Wrong! Try again...",
|
||||||
|
}[incorrectTimes] || `Answer is: ${nextItem.possibleAnswers.join(", ")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputGroup>
|
||||||
|
<InputLeftElement>{kanaInput ? "あ" : "A"}</InputLeftElement>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={currentAnswer}
|
||||||
|
onChange={onChange}
|
||||||
|
autoFocus
|
||||||
|
className={className}
|
||||||
|
placeholder={placeholder}
|
||||||
|
spellCheck={false}
|
||||||
|
backgroundColor={"white"}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderInside = () => {
|
||||||
|
const kanaInput = nextItem.type == ReviewItemType.READING;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>{JSON.stringify(completedQueue.map((x) => x.challenge))}</p>
|
||||||
|
<p>{JSON.stringify(reviewQueue.map((x) => x.challenge))}</p>
|
||||||
|
|
||||||
|
{startingSize && (
|
||||||
|
<Progress
|
||||||
|
colorScheme="linkedin"
|
||||||
|
hasStripe
|
||||||
|
isAnimated
|
||||||
|
max={startingSize}
|
||||||
|
value={completedQueue.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className={styles["test-word"]}>{nextItem.challenge}</h1>
|
||||||
|
|
||||||
|
<form onSubmit={formSubmit}>
|
||||||
|
{
|
||||||
|
{
|
||||||
|
[ReviewItemType.MEANING]: "What is the meaning?",
|
||||||
|
[ReviewItemType.READING]: "What is the reading?",
|
||||||
|
}[nextItem.type]
|
||||||
|
}
|
||||||
|
|
||||||
|
{inputBox(kanaInput)}
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const quit = () => {
|
||||||
|
if (!reviewQueue || !anyProgress) {
|
||||||
|
return navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className={styles.main}>
|
||||||
|
<Container>
|
||||||
|
<Button onClick={quit}>
|
||||||
|
<ArrowBackIcon />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className={styles.container}>{renderInside()}</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<ConfirmQuitModal isOpen={isOpen} onClose={onClose} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "SrsReviewPane";
|
5
src/panes/VocabPane.tsx
Normal file
5
src/panes/VocabPane.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export function Component() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.displayName = "VocabPane";
|
114
src/styles.css
114
src/styles.css
|
@ -1,109 +1,15 @@
|
||||||
:root {
|
@tailwind base;
|
||||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
@tailwind components;
|
||||||
font-size: 16px;
|
@tailwind utilities;
|
||||||
line-height: 24px;
|
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color: #0f0f0f;
|
html,
|
||||||
background-color: #f6f6f6;
|
body {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
font-synthesis: none;
|
height: 100%;
|
||||||
text-rendering: optimizeLegibility;
|
padding: 0;
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
body {
|
||||||
margin: 0;
|
overflow: hidden;
|
||||||
padding-top: 10vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: 0.75s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo.tauri:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #24c8db);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #646cff;
|
|
||||||
text-decoration: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #535bf2;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
padding: 0.6em 1.2em;
|
|
||||||
font-size: 1em;
|
|
||||||
font-weight: 500;
|
|
||||||
font-family: inherit;
|
|
||||||
color: #0f0f0f;
|
|
||||||
background-color: #ffffff;
|
|
||||||
transition: border-color 0.25s;
|
|
||||||
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: #396cd8;
|
|
||||||
}
|
|
||||||
button:active {
|
|
||||||
border-color: #396cd8;
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#greet-input {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
color: #f6f6f6;
|
|
||||||
background-color: #2f2f2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: #24c8db;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button {
|
|
||||||
color: #ffffff;
|
|
||||||
background-color: #0f0f0f98;
|
|
||||||
}
|
|
||||||
button:active {
|
|
||||||
background-color: #0f0f0f69;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
export interface Kanji {
|
|
||||||
character: string;
|
|
||||||
translation: string;
|
|
||||||
}
|
|
10
tailwind.config.js
Normal file
10
tailwind.config.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{html,js,jsx,ts,tsx}", "./node_modules/flowbite/**/*.js"],
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [require("flowbite/plugin")],
|
||||||
|
};
|
|
@ -1,5 +1,5 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
|
|
Loading…
Reference in a new issue