From 35c7c1c72284d53390e87a1048fd90fa40eaf561 Mon Sep 17 00:00:00 2001 From: Michael Zhang Date: Wed, 14 Jun 2023 15:34:43 -0500 Subject: [PATCH] ui changes --- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 2 + src-tauri/src/kanji.rs | 65 ++++++++++++++++++++- src/components/KanjiDisplay.module.scss | 77 ++++++++++++++++++++++++- src/components/KanjiDisplay.tsx | 76 ++++++++++++++---------- src/components/SrsPart.tsx | 53 +++++++++++++++++ src/components/Strokes.tsx | 57 ++++++++++++++++++ src/components/utils/LevelBadge.tsx | 20 ++----- src/lib/kanji.ts | 9 ++- src/panes/KanjiPane.tsx | 6 +- src/panes/SrsReviewPane.tsx | 20 ++----- 11 files changed, 316 insertions(+), 71 deletions(-) create mode 100644 src/components/SrsPart.tsx create mode 100644 src/components/Strokes.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 91971df..625535d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1569,9 +1569,11 @@ name = "houhou" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.21.2", "clap", "derivative", "dirs", + "flate2", "serde", "serde_json", "sqlx", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 20cfb3f..6c04673 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,8 @@ 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" +flate2 = "1.0.26" +base64 = "0.21.2" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/src-tauri/src/kanji.rs b/src-tauri/src/kanji.rs index be114e7..bea2cae 100644 --- a/src-tauri/src/kanji.rs +++ b/src-tauri/src/kanji.rs @@ -1,5 +1,7 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{collections::HashMap, io::Read, path::PathBuf}; +use base64::{engine::general_purpose, Engine as _}; +use flate2::{read::GzDecoder, Decompress, FlushDecompress}; use sqlx::{sqlite::SqliteRow, Encode, Row, SqlitePool, Type}; use tauri::State; @@ -24,6 +26,9 @@ pub struct GetKanjiOptions { #[derivative(Default(value = "40"))] how_many: u32, + #[serde(default)] + include_strokes: bool, + #[serde(default)] include_srs_info: bool, } @@ -38,9 +43,18 @@ fn default_how_many() -> u32 { #[derive(Debug, Serialize, Deserialize)] pub struct Kanji { character: String, - most_used_rank: u32, meanings: Vec, srs_info: Option, + + // Stats + grade: u32, + jlpt_level: u32, + wanikani_level: u32, + newspaper_rank: u32, + most_used_rank: u32, + + // Base64-encoded utf8 + strokes: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -79,9 +93,16 @@ pub async fn get_kanji( let query_string = format!( r#" SELECT + Kanji.ID as ID, Character, KanjiMeaningSet.Meaning, + + Grade, MostUsedRank, + JlptLevel, + WkLevel, + NewspaperRank, + KanjiMeaningSet.ID as KanjiMeaningID FROM ( SELECT * @@ -166,9 +187,16 @@ pub async fn get_kanji( let mut new_vec: Vec = Vec::with_capacity(result.len()); let mut last_character: Option = None; + // collect multiple meanings for a single kanji for row in result { + let id: u32 = row.get("ID"); let character: String = row.get("Character"); + + let grade = row.get("Grade"); let most_used_rank = row.get("MostUsedRank"); + let wanikani_level = row.get("WkLevel"); + let jlpt_level = row.get("JlptLevel"); + let newspaper_rank = row.get("NewspaperRank"); let meaning = KanjiMeaning { id: row.get("KanjiMeaningID"), @@ -190,11 +218,42 @@ pub async fn get_kanji( } else { let srs_info = srs_info_map.remove(&character); + // Fetch strokes if requested + let mut strokes = None; + if opts.include_strokes { + let row = sqlx::query( + r#" + SELECT FramesSvg FROM KanjiStrokes WHERE ID = ? + "#, + ) + .bind(id) + .fetch_one(&kanji_db.0) + .await + .map_err(|err| err.to_string())?; + + let frames: Vec = row.get("FramesSvg"); + let mut frames_decompress = Vec::new(); + + let mut gz = GzDecoder::new(&*frames); + gz.read_to_end(&mut frames_decompress) + .map_err(|err| err.to_string())?; + + let result = general_purpose::STANDARD.encode(&frames_decompress); + strokes = Some(result); + } + new_vec.push(Kanji { character, - most_used_rank, meanings: vec![meaning], + + grade, + jlpt_level, + wanikani_level, + newspaper_rank, + most_used_rank, + srs_info, + strokes, }); } } diff --git a/src/components/KanjiDisplay.module.scss b/src/components/KanjiDisplay.module.scss index d5e34e2..fcc1b4f 100644 --- a/src/components/KanjiDisplay.module.scss +++ b/src/components/KanjiDisplay.module.scss @@ -1,4 +1,71 @@ -$kanjiDisplaySize: 80px; +$kanjiDisplaySize: 120px; + +.main { + display: flex; + flex-direction: column; + gap: 16px; +} + +.topRow { + width: 100%; + + display: flex; + gap: 8px; +} + +.kanjiInfo { + flex-grow: 1; + + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; +} + +.meanings { + border-bottom: 1px solid black; +} + +.boxes { + display: flex; + gap: 8px; +} + +.box { + display: flex; + flex-direction: column; + gap: 4px; + + min-width: 72px; + height: 72px; + border: 1px solid gray; + border-radius: 4px; + text-align: center; + align-items: stretch; + + font-size: 0.75em; + line-height: 1; + + .big { + font-size: 1.2rem; + } + + .icon { + font-size: 1.2rem; + } + + .badge { + width: 50%; + margin: auto; + padding: 4px; + } + + &:not(.strokes) { + justify-content: center; + padding: 8px; + background-color: rgb(194, 226, 228); + } +} .display { border: 1px solid rgb(87, 87, 210); @@ -9,3 +76,11 @@ $kanjiDisplaySize: 80px; text-align: center; vertical-align: middle; } + +.vocabSection { + flex-grow: 1; + + h2 { + font-size: 1.5em; + } +} diff --git a/src/components/KanjiDisplay.tsx b/src/components/KanjiDisplay.tsx index 7f07299..7863503 100644 --- a/src/components/KanjiDisplay.tsx +++ b/src/components/KanjiDisplay.tsx @@ -1,13 +1,11 @@ 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"; +import classNames from "classnames"; +import Strokes from "./Strokes"; +import SrsPart from "./SrsPart"; interface KanjiDisplayProps { kanjiCharacter: string; @@ -20,7 +18,9 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) { isLoading, mutate, } = useSWR(["get_kanji", kanjiCharacter], ([command, character]) => - invoke(command, { options: { character, include_srs_info: true } }), + invoke(command, { + options: { character, include_strokes: true, include_srs_info: true }, + }), ); if (!kanjiResult || !kanjiResult.kanji) @@ -42,27 +42,6 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) { mutate(); }; - let srsPart = ( - - ); - if (kanji.srs_info) { - const nextAnswerDate = new Date(kanji.srs_info.next_answer_date); - srsPart = ( - - -

You are learning this item!

- - {isValid(nextAnswerDate) && ( -

- (Due: ) -

- )} -
- ); - } - return ( <>
@@ -70,11 +49,48 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
{JSON.stringify(kanji, null, 2)}
- {kanji.character} +
+
+ {kanji.character} - {kanji.meanings.map((m) => m.meaning).join(", ")} +
+
{kanji.meanings.map((m) => m.meaning).join(", ")}
-
{srsPart}
+
+ {kanji.strokes && ( + + )} + +
+ {kanji.most_used_rank} + most used +
+ +
+ N{kanji.jlpt_level} + JLPT Level +
+ + {kanji.wanikani_level && ( +
+ Level {kanji.wanikani_level} + on Wanikani +
+ )} + + +
+
+
+ +
+

Related Vocab

+
+
); } diff --git a/src/components/SrsPart.tsx b/src/components/SrsPart.tsx new file mode 100644 index 0000000..6f089c3 --- /dev/null +++ b/src/components/SrsPart.tsx @@ -0,0 +1,53 @@ +import buildFormatter from "react-timeago/es6/formatters/buildFormatter"; +import shortEnStrings from "react-timeago/es6/language-strings/en-short"; +import classNames from "classnames"; +import { KanjiSrsInfo } from "../lib/kanji"; + +import styles from "./KanjiDisplay.module.scss"; +import ReactTimeago, { Formatter } from "react-timeago"; +import LevelBadge from "./utils/LevelBadge"; +import { AddIcon, StarIcon } from "@chakra-ui/icons"; + +export interface SrsPartProps { + srsInfo?: KanjiSrsInfo; + addSrsItem: () => void; +} + +export default function SrsPart({ srsInfo, addSrsItem }: SrsPartProps) { + if (!srsInfo) { + return ( + + ); + } + + if (srsInfo.next_answer_date == null) { + return ( +
+ + + + Learned +
+ ); + } + + const nextAnswerDate = new Date(srsInfo.next_answer_date); + const formatter: Formatter = (value, unit, suffix, epochMilliseconds, nextFormatter) => { + if (epochMilliseconds < Date.now()) return "now"; + return buildFormatter(shortEnStrings)(value, unit, suffix, epochMilliseconds); + }; + return ( +
+ + + + In SRS + +
+ ); +} diff --git a/src/components/Strokes.tsx b/src/components/Strokes.tsx new file mode 100644 index 0000000..98c9387 --- /dev/null +++ b/src/components/Strokes.tsx @@ -0,0 +1,57 @@ +import { DetailsHTMLAttributes } from "react"; + +const SVG_CELL_SIZE = 109; + +export interface StrokesProps extends DetailsHTMLAttributes { + strokeData: string; + size: number; +} + +export default function Strokes({ strokeData, size, style, ...props }: StrokesProps) { + const decoded = atob(strokeData); + + const svgWidthMatch = decoded.match(/width="(\d+)px"/); + if (!svgWidthMatch) return null; + const svgWidth = parseInt(svgWidthMatch[1]); + console.log("width", svgWidth); + + const numFrames = Math.round(svgWidth / SVG_CELL_SIZE); + console.log("numFrames", numFrames); + + const keyframes = new Array(numFrames) + .fill(0) + .map((_, idx) => { + const percent = Math.round(idx * (100 / (numFrames + 1))); + const offset = -size * idx; + return `${percent}% { + background-position: ${offset}px; + }`; + }) + .join(""); + + const encoded = encodeURIComponent(decoded); + const frameDuration = 1; + + const bgHorizontal = size * numFrames; + const bgVertical = size; + + return ( + <> + +
+ + ); +} diff --git a/src/components/utils/LevelBadge.tsx b/src/components/utils/LevelBadge.tsx index 4fe4444..10e515d 100644 --- a/src/components/utils/LevelBadge.tsx +++ b/src/components/utils/LevelBadge.tsx @@ -1,11 +1,11 @@ -import { Badge } from "@chakra-ui/react"; +import { Badge, BadgeProps } from "@chakra-ui/react"; import { srsLevels } from "../../lib/srs"; -export interface LevelBadgeProps { +export interface LevelBadgeProps extends BadgeProps { grade?: number; } -export default function LevelBadge({ grade }: LevelBadgeProps) { +export default function LevelBadge({ grade, ...props }: LevelBadgeProps) { if (grade == undefined) return null; const levelInfo = srsLevels.get(grade); @@ -14,20 +14,8 @@ export default function LevelBadge({ grade }: LevelBadgeProps) { const { color, name } = levelInfo; return ( - + {name} ); } - -const badgeMap = new Map([ - [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"]], -]); diff --git a/src/lib/kanji.ts b/src/lib/kanji.ts index 88bee2a..dc9ccae 100644 --- a/src/lib/kanji.ts +++ b/src/lib/kanji.ts @@ -1,8 +1,15 @@ export interface Kanji { character: string; - most_used_rank: number; meanings: KanjiMeaning[]; + + grade: number; + jlpt_level: number; + wanikani_level?: number; + newspaper_rank: number; + most_used_rank: number; + srs_info?: KanjiSrsInfo; + strokes?: string; } export interface KanjiMeaning { diff --git a/src/panes/KanjiPane.tsx b/src/panes/KanjiPane.tsx index 033749b..c96a582 100644 --- a/src/panes/KanjiPane.tsx +++ b/src/panes/KanjiPane.tsx @@ -16,11 +16,7 @@ export interface GetKanjiResult { export function Component() { const { selectedKanji } = useParams(); - const { - data: baseData, - error, - isLoading, - } = useSWR("get_kanji", () => + const { data: baseData, error } = useSWR("get_kanji", () => invoke("get_kanji", { options: { include_srs_info: true } }), ); diff --git a/src/panes/SrsReviewPane.tsx b/src/panes/SrsReviewPane.tsx index 75d08d4..405666d 100644 --- a/src/panes/SrsReviewPane.tsx +++ b/src/panes/SrsReviewPane.tsx @@ -30,19 +30,6 @@ import classNames from "classnames"; const batchSize = 10; -function Done() { - return ( - <> -

oh Shit you're done!!! poggerse

- - - - - ); -} - export function Component() { // null = has not started, (.length == 0) = finished const [reviewQueue, setReviewQueue] = useState(null); @@ -79,7 +66,7 @@ export function Component() { parent: srsQuestionGroup, type: ReviewItemType.READING, challenge: srsEntry.associated_kanji, - possibleAnswers: srsEntry.readings, + possibleAnswers: srsEntry.readings.map((reading) => reading.replaceAll(/\./g, "")), isCorrect: null, timesRepeated: 0, }; @@ -101,7 +88,10 @@ export function Component() { }, [reviewQueue]); if (!reviewQueue) return ; - if (reviewQueue.length == 0) return ; + + // Done! Go back to the home page + if (reviewQueue.length == 0) return navigate("/"); + const [nextItem, ...restOfQueue] = reviewQueue; const possibleAnswers = new Set(nextItem.possibleAnswers);