add stuff to srs
This commit is contained in:
parent
1fe57e2bb6
commit
31728966c5
17 changed files with 485 additions and 133 deletions
58
package-lock.json
generated
58
package-lock.json
generated
|
@ -14,12 +14,14 @@
|
|||
"@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",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.11.2",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-timeago": "^7.1.0",
|
||||
"swr": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -27,6 +29,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",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
|
@ -2059,6 +2062,15 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-timeago": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-timeago/-/react-timeago-4.1.3.tgz",
|
||||
"integrity": "sha512-XaaMBzuXLw7lxPPDs/fenlohcf3NDqM5qP4oOL/Meu+Hb1QChW4Igw/SruS1llEqch18RQB3wDTIwvqq4nivvw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
|
||||
|
@ -2438,6 +2450,21 @@
|
|||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
@ -3520,6 +3547,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-timeago": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-timeago/-/react-timeago-7.1.0.tgz",
|
||||
"integrity": "sha512-rouF7MiEm55fH791Y8cg+VobIJgx8gtNJ+gjr86R4ZqO1WKPkXiXjdT/lRzrvEkUzsxT1exHqV2V+Zdi114H3A==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
@ -5503,6 +5538,15 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-timeago": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-timeago/-/react-timeago-4.1.3.tgz",
|
||||
"integrity": "sha512-XaaMBzuXLw7lxPPDs/fenlohcf3NDqM5qP4oOL/Meu+Hb1QChW4Igw/SruS1llEqch18RQB3wDTIwvqq4nivvw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/scheduler": {
|
||||
"version": "0.16.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
|
||||
|
@ -5771,6 +5815,14 @@
|
|||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
},
|
||||
"date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
|
@ -6516,6 +6568,12 @@
|
|||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"react-timeago": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-timeago/-/react-timeago-7.1.0.tgz",
|
||||
"integrity": "sha512-rouF7MiEm55fH791Y8cg+VobIJgx8gtNJ+gjr86R4ZqO1WKPkXiXjdT/lRzrvEkUzsxT1exHqV2V+Zdi114H3A==",
|
||||
"requires": {}
|
||||
},
|
||||
"read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
|
|
@ -16,12 +16,14 @@
|
|||
"@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",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router": "^6.11.2",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-timeago": "^7.1.0",
|
||||
"swr": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -29,6 +31,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",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
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)]
|
||||
|
@ -12,6 +19,9 @@ pub struct GetKanjiOptions {
|
|||
#[serde(default = "default_how_many")]
|
||||
#[derivative(Default(value = "10"))]
|
||||
how_many: u32,
|
||||
|
||||
#[serde(default)]
|
||||
include_srs_info: bool,
|
||||
}
|
||||
|
||||
fn default_how_many() -> u32 {
|
||||
|
@ -23,6 +33,7 @@ pub struct Kanji {
|
|||
character: String,
|
||||
most_used_rank: u32,
|
||||
meanings: Vec<KanjiMeaning>,
|
||||
srs_info: Option<KanjiSrsInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
@ -37,22 +48,33 @@ pub struct GetKanjiResult {
|
|||
kanji: Vec<Kanji>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KanjiSrsInfo {
|
||||
id: u32,
|
||||
next_answer_date: EpochMs,
|
||||
associated_kanji: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_kanji(
|
||||
db: State<'_, KanjiDb>,
|
||||
kanji_db: State<'_, KanjiDb>,
|
||||
srs_db: State<'_, SrsDb>,
|
||||
options: Option<GetKanjiOptions>,
|
||||
) -> Result<GetKanjiResult, String> {
|
||||
let opts = options.unwrap_or_default();
|
||||
println!("Options: {:?}", opts);
|
||||
|
||||
let looking_for_character_clause = match opts.character {
|
||||
None => String::new(),
|
||||
Some(_) => format!(" AND KanjiSet.Character = ?"),
|
||||
Some(_) => format!("AND KanjiSet.Character = ?"),
|
||||
};
|
||||
|
||||
let query_string = format!(
|
||||
r#"
|
||||
SELECT *, KanjiMeaningSet.ID as KanjiMeaningID
|
||||
SELECT
|
||||
Character,
|
||||
KanjiMeaningSet.Meaning,
|
||||
MostUsedRank,
|
||||
KanjiMeaningSet.ID as KanjiMeaningID
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM KanjiSet
|
||||
|
@ -69,21 +91,68 @@ pub async fn get_kanji(
|
|||
|
||||
// Do all the binds
|
||||
|
||||
if let Some(character) = opts.character {
|
||||
println!("Bind {}", character);
|
||||
query = query.bind(character);
|
||||
if let Some(character) = &opts.character {
|
||||
query = query.bind(character.clone());
|
||||
}
|
||||
|
||||
println!("Bind {}", opts.how_many);
|
||||
query = query.bind(opts.how_many);
|
||||
|
||||
println!("Query: {query_string}");
|
||||
|
||||
let result = query
|
||||
.fetch_all(&db.0)
|
||||
.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 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,
|
||||
next_answer_date,
|
||||
associated_kanji,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
println!("SRS MAP: {srs_info_map:?}");
|
||||
};
|
||||
|
||||
// Put it all together
|
||||
let kanji = {
|
||||
let mut new_vec: Vec<Kanji> = Vec::with_capacity(result.len());
|
||||
let mut last_character: Option<String> = None;
|
||||
|
@ -110,10 +179,13 @@ pub async fn get_kanji(
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -121,10 +193,8 @@ pub async fn get_kanji(
|
|||
new_vec
|
||||
};
|
||||
|
||||
println!("Result: {kanji:?}");
|
||||
|
||||
let count = sqlx::query("SELECT COUNT(*) FROM KanjiSet")
|
||||
.fetch_one(&db.0)
|
||||
.fetch_one(&kanji_db.0)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let count = count.try_get(0).map_err(|err| err.to_string())?;
|
||||
|
|
|
@ -6,8 +6,9 @@ extern crate derivative;
|
|||
#[macro_use]
|
||||
extern crate serde;
|
||||
|
||||
mod kanji;
|
||||
mod srs;
|
||||
pub mod kanji;
|
||||
pub mod srs;
|
||||
pub mod utils;
|
||||
|
||||
use std::process;
|
||||
use std::str::FromStr;
|
||||
|
@ -61,6 +62,7 @@ async fn main() -> Result<()> {
|
|||
.system_tray(tray)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
srs::get_srs_stats,
|
||||
srs::add_srs_item,
|
||||
kanji::get_kanji,
|
||||
])
|
||||
.on_window_event(|event| match event.event() {
|
||||
|
|
|
@ -6,6 +6,8 @@ use std::{
|
|||
use sqlx::{Row, SqlitePool};
|
||||
use tauri::State;
|
||||
|
||||
use crate::utils::Ticks;
|
||||
|
||||
pub struct SrsDb(pub SqlitePool);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
@ -38,17 +40,8 @@ pub async fn get_srs_stats(db: State<'_, SrsDb>) -> Result<SrsStats, String> {
|
|||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// reviews query
|
||||
let utc_now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| err.to_string())?
|
||||
.as_secs() as i64
|
||||
* 1000000000;
|
||||
let utc_tomorrow = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| err.to_string())?
|
||||
.add(Duration::from_secs(60 * 60 * 24))
|
||||
.as_secs() as i64
|
||||
* 1000000000;
|
||||
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
|
||||
|
@ -79,3 +72,38 @@ pub async fn get_srs_stats(db: State<'_, SrsDb>) -> Result<SrsStats, String> {
|
|||
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(
|
||||
db: State<'_, SrsDb>,
|
||||
options: Option<AddSrsItemOptions>,
|
||||
) -> Result<(), String> {
|
||||
let opts = options.unwrap_or_default();
|
||||
println!("Opts: {opts:?}");
|
||||
|
||||
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(&opts.character)
|
||||
.bind(&utc_now);
|
||||
|
||||
query.execute(&db.0).await.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
66
src-tauri/src/utils.rs
Normal file
66
src-tauri/src/utils.rs
Normal file
|
@ -0,0 +1,66 @@
|
|||
use std::{
|
||||
ops::Add,
|
||||
time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use sqlx::{
|
||||
database::{HasArguments, HasValueRef},
|
||||
encode::IsNull,
|
||||
error::BoxDynError,
|
||||
Decode, Encode, Sqlite, Type,
|
||||
};
|
||||
|
||||
#[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 * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -1,3 +1,6 @@
|
|||
$navLinkAccentColor: #09c;
|
||||
$navLinkColor: hsl(203, 91%, 91%);
|
||||
|
||||
main.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -22,10 +25,20 @@ main.main {
|
|||
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: transparent;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
}
|
||||
|
||||
.link-active {
|
||||
border-top-color: #09c;
|
||||
background-color: hsl(203, 91%, 91%);
|
||||
border-top-color: $navLinkAccentColor;
|
||||
background-color: $navLinkColor;
|
||||
}
|
||||
|
|
13
src/App.tsx
13
src/App.tsx
|
@ -2,7 +2,6 @@ import { Link, RouterProvider, createHashRouter } from "react-router-dom";
|
|||
import KanjiPane from "./panes/KanjiPane";
|
||||
import classNames from "classnames";
|
||||
import { ChakraProvider } 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 SrsPane from "./panes/SrsPane";
|
||||
|
@ -25,8 +24,9 @@ function Layout() {
|
|||
).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.path}>
|
||||
<li key={`navLink-${navLink.key}`}>
|
||||
<Link to={mainPath} className={className}>
|
||||
{navLink.title}
|
||||
</Link>
|
||||
|
@ -49,7 +49,7 @@ export default function App() {
|
|||
return route.subPaths.map((subRoute, idx2) => {
|
||||
return (
|
||||
<Route
|
||||
key={`${route.key}-${subRoute.key}`}
|
||||
key={`route-${route.key}-${subRoute.key}`}
|
||||
index={idx + idx2 == 0}
|
||||
path={subRoute.path}
|
||||
element={subRoute.element ?? route.element}
|
||||
|
@ -58,7 +58,12 @@ export default function App() {
|
|||
});
|
||||
} else {
|
||||
return (
|
||||
<Route key={route.path} index={idx == 0} path={route.path} element={route.element} />
|
||||
<Route
|
||||
key={`route-${route.key}`}
|
||||
index={idx == 0}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
|
|
@ -29,6 +29,11 @@ interface SrsStats {
|
|||
num_failure: number;
|
||||
}
|
||||
|
||||
interface Stat {
|
||||
label: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export default function DashboardReviewStats() {
|
||||
const {
|
||||
data: srsStats,
|
||||
|
@ -44,12 +49,12 @@ export default function DashboardReviewStats() {
|
|||
</>
|
||||
);
|
||||
|
||||
const averageSuccess = srsStats.num_success / ((srsStats.num_success + srsStats.num_failure) || 1);
|
||||
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) => {
|
||||
const generateStat = (stat: Stat) => {
|
||||
return (
|
||||
<GridItem>
|
||||
<Stat>
|
||||
|
@ -67,12 +72,16 @@ export default function DashboardReviewStats() {
|
|||
<Stat>
|
||||
<StatLabel>reviews available</StatLabel>
|
||||
<StatNumber>{srsStats.reviews_available}</StatNumber>
|
||||
<ConditionalWrapper condition={canReview} wrapper={children => <Tooltip label="Add items to start reviewing">{children}</Tooltip>}>
|
||||
<Link to="/srs/review">
|
||||
<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>
|
||||
</Link>
|
||||
</ConditionalWrapper>
|
||||
</Stat>
|
||||
</GridItem>
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { invoke } from "@tauri-apps/api/tauri";
|
||||
import { GetKanjiResult } from "../panes/KanjiPane";
|
||||
import { Kanji } from "../types/Kanji";
|
||||
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 "../lib/SelectOnClick";
|
||||
import { Alert, AlertIcon } from "@chakra-ui/alert";
|
||||
|
||||
interface KanjiDisplayProps {
|
||||
kanjiCharacter: string;
|
||||
|
@ -15,30 +18,59 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
|
|||
data: kanjiResult,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR(["get_kanji", kanjiCharacter], ([command, character]) =>
|
||||
invoke<GetKanjiResult>(command, { options: { character } }),
|
||||
invoke<GetKanjiResult>(command, { options: { character, include_srs_info: true } }),
|
||||
);
|
||||
|
||||
if (!kanjiResult || !kanjiResult.kanji) return <>
|
||||
if (!kanjiResult || !kanjiResult.kanji)
|
||||
return (
|
||||
<>
|
||||
{JSON.stringify([kanjiResult, error, isLoading])}
|
||||
Loading...
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
|
||||
const kanji = kanjiResult.kanji[0];
|
||||
|
||||
const addSrsItem = () => {
|
||||
|
||||
const addSrsItem = async () => {
|
||||
await invoke("add_srs_item", {
|
||||
options: {
|
||||
character: kanji.character,
|
||||
},
|
||||
});
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.display}>{kanji.character}</div>
|
||||
|
||||
{kanji.meanings.map(m => m.meaning).join(", ")}
|
||||
|
||||
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>This character is being tracked in SRS!</p>
|
||||
<p>
|
||||
(Next test: <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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
29
src/components/KanjiList.module.scss
Normal file
29
src/components/KanjiList.module.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
.kanji-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kanji-link {
|
||||
padding: 4px 8px;
|
||||
border-left: 4px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
47
src/components/KanjiList.tsx
Normal file
47
src/components/KanjiList.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
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 "../types/Kanji";
|
||||
|
||||
export interface KanjiListProps {
|
||||
data: GetKanjiResult;
|
||||
selectedCharacter?: string;
|
||||
}
|
||||
|
||||
export function KanjiList({ data, selectedCharacter }: KanjiListProps) {
|
||||
const renderKanjiItem = (kanji: Kanji, active: boolean) => {
|
||||
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
|
||||
return (
|
||||
<Link key={kanji.character} className={className} to={`/kanji/${kanji.character}`}>
|
||||
<Grid templateRows="repeat(2, 1fr)" templateColumns="1fr 3fr">
|
||||
<GridItem rowSpan={2} style={{ fontSize: "24px", textAlign: "center" }}>
|
||||
{kanji.character}
|
||||
</GridItem>
|
||||
<GridItem>{kanji.meanings[0].meaning}</GridItem>
|
||||
<GridItem>
|
||||
<Badge>#{kanji.most_used_rank} most used</Badge>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<small>
|
||||
Displaying {data.kanji.length} of {data.count} results.
|
||||
</small>
|
||||
|
||||
<div className={styles["kanji-list-scroll"]}>
|
||||
<div className={styles["kanji-list-inner"]}>
|
||||
{data.kanji.map((kanji) => {
|
||||
const active = kanji.character == selectedCharacter;
|
||||
return renderKanjiItem(kanji, active);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,21 @@
|
|||
export default function ConditionalWrapper({ condition, wrapper, children }) {
|
||||
return condition ? wrapper(children) : children
|
||||
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;
|
||||
}
|
30
src/lib/SelectOnClick.tsx
Normal file
30
src/lib/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>
|
||||
);
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
.kanji-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.kanji-link {
|
||||
padding: 4px 8px;
|
||||
border-left: 4px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
|
@ -7,68 +7,32 @@ import { Link, useParams } from "react-router-dom";
|
|||
import KanjiDisplay from "../components/KanjiDisplay";
|
||||
import { Kanji } from "../types/Kanji";
|
||||
import classNames from "classnames";
|
||||
import { KanjiList } from "../components/KanjiList";
|
||||
|
||||
export interface GetKanjiResult {
|
||||
count: number;
|
||||
kanji: Kanji[];
|
||||
}
|
||||
|
||||
interface KanjiListProps {
|
||||
data: GetKanjiResult;
|
||||
selectedCharacter?: string;
|
||||
}
|
||||
|
||||
function KanjiList({ data, selectedCharacter }: KanjiListProps) {
|
||||
return (
|
||||
<>
|
||||
<small>
|
||||
Displaying {data.kanji.length} of {data.count} results.
|
||||
</small>
|
||||
|
||||
<div className={styles["kanji-list-scroll"]}>
|
||||
<div className={styles["kanji-list-inner"]}>
|
||||
{data.kanji.map((kanji) => {
|
||||
const active = kanji.character == selectedCharacter;
|
||||
const className = classNames(styles['kanji-link'], active && styles['kanji-link-active'])
|
||||
return <Link
|
||||
key={kanji.character}
|
||||
className={className}
|
||||
to={`/kanji/${kanji.character}`}
|
||||
>
|
||||
<Grid templateRows="repeat(2, 1fr)" templateColumns="1fr 3fr">
|
||||
<GridItem rowSpan={2} style={{ fontSize: "24px", textAlign: "center" }}>
|
||||
{kanji.character}
|
||||
</GridItem>
|
||||
<GridItem>{kanji.meanings[0].meaning}</GridItem>
|
||||
<GridItem>
|
||||
<small>
|
||||
#{kanji.most_used_rank} most used
|
||||
</small>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Link>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanjiPane() {
|
||||
const { selectedKanji } = useParams();
|
||||
const { data, error, isLoading } = useSWR("get_kanji", invoke<GetKanjiResult>);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing={7} direction="row">
|
||||
<Box p={2} className={styles["kanji-list"]}>
|
||||
<Grid templateRows="1fr" templateColumns="3fr 5fr">
|
||||
<GridItem className={styles["kanji-list"]}>
|
||||
{data && <KanjiList data={data} selectedCharacter={selectedKanji} />}
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
<Box p={5}>
|
||||
<GridItem>
|
||||
{selectedKanji ? <KanjiDisplay kanjiCharacter={selectedKanji} /> : "nothing selected"}
|
||||
</Box>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,9 +2,16 @@ 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;
|
||||
next_answer_date: number;
|
||||
associated_kanji: string;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue