Compare commits
28 commits
561cb49fbf
...
30ba15fd9a
Author | SHA1 | Date | |
---|---|---|---|
|
30ba15fd9a | ||
|
419353b3b5 | ||
|
655e4f169e | ||
|
880321aa9c | ||
|
0f0190a73d | ||
|
f98b79a00e | ||
|
6e4f9eadc1 | ||
|
3c5fc20408 | ||
|
f67e917005 | ||
|
c687acfc7b | ||
|
e2de725e24 | ||
|
dda25dcd5d | ||
f9039a9fa4 | |||
539cd13dda | |||
99543c3a43 | |||
|
16c154471f | ||
|
4a0ec0391d | ||
89022599b9 | |||
214124ad93 | |||
|
dfc7ce2634 | ||
|
5f6c3d11ce | ||
4cbdc7c142 | |||
db3ef039c7 | |||
|
0a830d5189 | ||
|
f92d8445c7 | ||
fcf62d094d | |||
426cd60a6c | |||
|
62f1acbcd3 |
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
|
||||
*.sln
|
||||
*.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
24
package.json
24
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "houhou",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@ -10,20 +10,38 @@
|
|||
"tauri": "tauri"
|
||||
},
|
||||
"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",
|
||||
"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-dom": "^18.2.0",
|
||||
"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": {
|
||||
"@tauri-apps/cli": "^1.3.1",
|
||||
"@types/lodash-es": "^4.17.7",
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-timeago": "^4.1.3",
|
||||
"@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",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"typescript": "^4.9.5",
|
||||
"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
|
||||
/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]
|
||||
name = "houhou"
|
||||
version = "0.0.0"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[workspace]
|
||||
members = ["database-maker"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1.3", features = [] }
|
||||
|
||||
[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_json = "1.0"
|
||||
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]
|
||||
# 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!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
#[macro_use]
|
||||
extern crate derivative;
|
||||
#[macro_use]
|
||||
extern crate serde;
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
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()
|
||||
.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!())
|
||||
.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": {
|
||||
"productName": "houhou",
|
||||
"version": "0.0.0"
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
@ -16,12 +16,15 @@
|
|||
"shell": {
|
||||
"all": false,
|
||||
"open": true
|
||||
},
|
||||
"notification": {
|
||||
"all": true
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"identifier": "com.tauri.dev",
|
||||
"identifier": "io.mzhang.houhou",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
@ -33,6 +36,10 @@
|
|||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"systemTray": {
|
||||
"iconPath": "icons/icon.ico",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"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 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -6,6 +17,38 @@
|
|||
list-style-type: none;
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
126
src/App.tsx
126
src/App.tsx
|
@ -1,27 +1,117 @@
|
|||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import styles from "./App.module.scss"
|
||||
import KanjiPane from "./panes/KanjiPane";
|
||||
import HomePane from "./panes/HomePane";
|
||||
import { Link, RouterProvider, createHashRouter } from "react-router-dom";
|
||||
import classNames from "classnames";
|
||||
import { ChakraProvider, Flex } from "@chakra-ui/react";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { Outlet, Route, createRoutesFromElements, matchPath, useLocation } from "react-router";
|
||||
import { StrictMode } from "react";
|
||||
|
||||
export default function App() {
|
||||
const router = createBrowserRouter(routes);
|
||||
import styles from "./App.module.scss";
|
||||
|
||||
function Layout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex className={styles.main} direction="column" alignSelf="start">
|
||||
<ul className={styles.header}>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/srs">Srs</a></li>
|
||||
<li><a href="/kanji">Kanji</a></li>
|
||||
<li><a href="/vocab">Vocab</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
{navLinks.map((navLink: NavLink) => {
|
||||
const active = (
|
||||
navLink.subPaths ? navLink.subPaths : [{ key: navLink.key, path: navLink.path }]
|
||||
).some((item) => matchPath({ path: item.path }, location.pathname));
|
||||
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>
|
||||
|
||||
<RouterProvider router={router} />
|
||||
</>
|
||||
<div className={styles.body}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: "/", element: <HomePane /> },
|
||||
{ path: "/kanji", element: <KanjiPane /> },
|
||||
];
|
||||
export default function App() {
|
||||
const router = createBrowserRouter(
|
||||
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 ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<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 {
|
||||
border: 1px solid rgb(87, 87, 210);
|
||||
width: $kanjiDisplaySize;
|
||||
height: $kanjiDisplaySize;
|
||||
font-size: $kanjiDisplaySize * 0.8;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kanji-pane-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-height: 0;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.right-side {
|
||||
flex-grow: 5;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,63 @@
|
|||
import { useState } from "react"
|
||||
import { Kanji } from "../types/Kanji"
|
||||
import styles from "./KanjiPane.module.scss"
|
||||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import useSWR from "swr";
|
||||
import { Box, Flex, Grid, GridItem, LinkBox, Stack } from "@chakra-ui/layout";
|
||||
|
||||
export default function KanjiPane() {
|
||||
const [selectedKanji, setSelectedKanji] = useState(null);
|
||||
import styles from "./KanjiPane.module.scss";
|
||||
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 <>
|
||||
{JSON.stringify(selectedKanji)}
|
||||
export interface GetKanjiResult {
|
||||
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 {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
color: #0f0f0f;
|
||||
background-color: #f6f6f6;
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
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;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
@ -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 react from "@vitejs/plugin-react";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
|
|
Loading…
Reference in a new issue