ui changes

This commit is contained in:
Michael Zhang 2023-06-14 15:34:43 -05:00
parent b2ff5d8416
commit 35c7c1c722
11 changed files with 316 additions and 71 deletions

2
src-tauri/Cargo.lock generated
View file

@ -1569,9 +1569,11 @@ name = "houhou"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.21.2",
"clap",
"derivative",
"dirs",
"flate2",
"serde",
"serde_json",
"sqlx",

View file

@ -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

View file

@ -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<KanjiMeaning>,
srs_info: Option<KanjiSrsInfo>,
// Stats
grade: u32,
jlpt_level: u32,
wanikani_level: u32,
newspaper_rank: u32,
most_used_rank: u32,
// Base64-encoded utf8
strokes: Option<String>,
}
#[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<Kanji> = Vec::with_capacity(result.len());
let mut last_character: Option<String> = 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<u8> = 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,
});
}
}

View file

@ -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;
}
}

View file

@ -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<GetKanjiResult>(command, { options: { character, include_srs_info: true } }),
invoke<GetKanjiResult>(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 = (
<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>
@ -70,11 +49,48 @@ export default function KanjiDisplay({ kanjiCharacter }: KanjiDisplayProps) {
<pre>{JSON.stringify(kanji, null, 2)}</pre>
</details>
<SelectOnClick className={styles.display}>{kanji.character}</SelectOnClick>
<main className={styles.main}>
<div className={styles.topRow}>
<SelectOnClick className={styles.display}>{kanji.character}</SelectOnClick>
{kanji.meanings.map((m) => m.meaning).join(", ")}
<div className={styles.kanjiInfo}>
<div className={styles.meanings}>{kanji.meanings.map((m) => m.meaning).join(", ")}</div>
<div>{srsPart}</div>
<div className={styles.boxes}>
{kanji.strokes && (
<Strokes
strokeData={kanji.strokes}
size={72}
className={classNames(styles.box, styles.strokes)}
/>
)}
<div className={styles.box}>
<span className={styles.big}>{kanji.most_used_rank}</span>
<span>most used</span>
</div>
<div className={styles.box}>
<span className={styles.big}>N{kanji.jlpt_level}</span>
<span>JLPT Level</span>
</div>
{kanji.wanikani_level && (
<div className={styles.box}>
<span className={styles.big}>Level {kanji.wanikani_level}</span>
<span>on Wanikani</span>
</div>
)}
<SrsPart srsInfo={kanji.srs_info} addSrsItem={addSrsItem} />
</div>
</div>
</div>
<div className={styles.vocabSection}>
<h2>Related Vocab</h2>
</div>
</main>
</>
);
}

View file

@ -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 (
<button type="button" className={classNames(styles.box)} onClick={addSrsItem}>
<span className={styles.icon}>
<AddIcon />
</span>
<span>Add to SRS</span>
</button>
);
}
if (srsInfo.next_answer_date == null) {
return (
<div className={classNames(styles.box)}>
<span className={styles.icon}>
<StarIcon />
</span>
<span>Learned</span>
</div>
);
}
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 (
<div className={classNames(styles.box)}>
<span className={styles.big}>
<ReactTimeago date={nextAnswerDate} formatter={formatter} />
</span>
<span>In SRS</span>
<LevelBadge grade={srsInfo.current_grade} className={styles.badge} />
</div>
);
}

View file

@ -0,0 +1,57 @@
import { DetailsHTMLAttributes } from "react";
const SVG_CELL_SIZE = 109;
export interface StrokesProps extends DetailsHTMLAttributes<HTMLDivElement> {
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 (
<>
<style>{`@keyframes kanjiStrokes { ${keyframes} }`}</style>
<div
style={{
...style,
width: size,
height: size,
backgroundImage: `url("data:image/svg+xml,${encoded}")`,
backgroundSize: `${bgHorizontal}px ${bgVertical}px`,
animationName: "kanjiStrokes",
animationDuration: `${frameDuration * numFrames}s`,
animationTimingFunction: "step-end",
animationIterationCount: "infinite",
}}
{...props}
/>
</>
);
}

View file

@ -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 (
<Badge backgroundColor={color} color="white">
<Badge backgroundColor={color} color="white" {...props}>
{name}
</Badge>
);
}
const badgeMap = new Map<number, [string | JSX.Element, string]>([
[8, [<>&#9733;</>, "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"]],
]);

View file

@ -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 {

View file

@ -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<GetKanjiResult>("get_kanji", { options: { include_srs_info: true } }),
);

View file

@ -30,19 +30,6 @@ 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);
@ -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 <Spinner />;
if (reviewQueue.length == 0) return <Done />;
// Done! Go back to the home page
if (reviewQueue.length == 0) return navigate("/");
const [nextItem, ...restOfQueue] = reviewQueue;
const possibleAnswers = new Set(nextItem.possibleAnswers);