ui changes
This commit is contained in:
parent
b2ff5d8416
commit
35c7c1c722
11 changed files with 316 additions and 71 deletions
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
@ -1569,9 +1569,11 @@ name = "houhou"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.21.2",
|
||||
"clap",
|
||||
"derivative",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
53
src/components/SrsPart.tsx
Normal file
53
src/components/SrsPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
src/components/Strokes.tsx
Normal file
57
src/components/Strokes.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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, [<>★</>, "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"]],
|
||||
]);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 } }),
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
Loading…
Reference in a new issue