infinite scroll

This commit is contained in:
Michael Zhang 2023-06-11 15:08:18 -05:00
parent cb5340da17
commit 16dfeca43c
9 changed files with 141 additions and 28 deletions

View file

@ -1,3 +1,4 @@
max_width = 80 max_width = 80
tab_spaces = 2 tab_spaces = 2
wrap_comments = true wrap_comments = true
fn_single_line = true

View file

@ -16,16 +16,23 @@ pub struct GetKanjiOptions {
#[serde(default)] #[serde(default)]
character: Option<String>, character: Option<String>,
#[serde(default = "default_skip")]
#[derivative(Default(value = "0"))]
skip: u32,
#[serde(default = "default_how_many")] #[serde(default = "default_how_many")]
#[derivative(Default(value = "10"))] #[derivative(Default(value = "40"))]
how_many: u32, how_many: u32,
#[serde(default)] #[serde(default)]
include_srs_info: bool, include_srs_info: bool,
} }
fn default_skip() -> u32 {
0
}
fn default_how_many() -> u32 { fn default_how_many() -> u32 {
10 40
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -62,6 +69,7 @@ pub async fn get_kanji(
options: Option<GetKanjiOptions>, options: Option<GetKanjiOptions>,
) -> Result<GetKanjiResult, String> { ) -> Result<GetKanjiResult, String> {
let opts = options.unwrap_or_default(); let opts = options.unwrap_or_default();
println!("opts: {opts:?}");
let looking_for_character_clause = match opts.character { let looking_for_character_clause = match opts.character {
None => String::new(), None => String::new(),
@ -80,7 +88,7 @@ pub async fn get_kanji(
FROM KanjiSet FROM KanjiSet
WHERE MostUsedRank IS NOT NULL WHERE MostUsedRank IS NOT NULL
{looking_for_character_clause} {looking_for_character_clause}
ORDER BY MostUsedRank LIMIT ? ORDER BY MostUsedRank LIMIT ?, ?
) as Kanji ) as Kanji
JOIN KanjiMeaningSet ON Kanji.ID = KanjiMeaningSet.Kanji_ID JOIN KanjiMeaningSet ON Kanji.ID = KanjiMeaningSet.Kanji_ID
ORDER BY MostUsedRank, KanjiMeaningSet.ID ORDER BY MostUsedRank, KanjiMeaningSet.ID
@ -94,6 +102,7 @@ pub async fn get_kanji(
if let Some(character) = &opts.character { if let Some(character) = &opts.character {
query = query.bind(character.clone()); query = query.bind(character.clone());
} }
query = query.bind(opts.skip);
query = query.bind(opts.how_many); query = query.bind(opts.how_many);
let result = query let result = query

View file

@ -3,9 +3,10 @@ $navLinkColor: hsl(203, 91%, 91%);
.main { .main {
min-height: 0; min-height: 0;
display: inline-flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
height: 100vh;
} }
.header { .header {
@ -23,9 +24,8 @@ $navLinkColor: hsl(203, 91%, 91%);
.body { .body {
min-height: 0; min-height: 0;
flex-basis: 0px;
flex-grow: 1; flex-grow: 1;
display: inline-flex; display: flex;
} }
.link { .link {

View file

@ -60,7 +60,12 @@ export default function App() {
}); });
} else { } else {
return ( return (
<Route key={`route-${route.key}`} index={idx == 0} path={route.path} element={route.element} /> <Route
key={`route-${route.key}`}
index={idx == 0}
path={route.path}
element={route.element}
/>
); );
} }
})} })}
@ -84,7 +89,7 @@ const navLinks = [
title: "SRS", title: "SRS",
element: <SrsPane />, element: <SrsPane />,
subPaths: [ subPaths: [
{ key: "index", path: "/srs" }, { key: "index", path: "/" },
{ key: "review", path: "/srs/review", element: <SrsReviewPane /> }, { key: "review", path: "/srs/review", element: <SrsReviewPane /> },
], ],
}, },

View file

@ -9,7 +9,15 @@
.kanji-link { .kanji-link {
padding: 4px 8px; padding: 4px 8px;
border-left: 4px solid transparent; border-left: 4px solid transparent;
transition: background-color 0.1s ease-out, border-left-color 0.1s ease-out;
user-select: none; user-select: none;
-webkit-user-drag: none;
&:not(.kanji-link-active):hover {
border-top-color: transparent;
background-color: #f4f4f4;
}
} }
.kanji-list-scroll { .kanji-list-scroll {
@ -29,3 +37,16 @@
border-left-color: #09c; border-left-color: #09c;
background-color: hsl(203, 91%, 91%); background-color: hsl(203, 91%, 91%);
} }
.loading {
padding: 4px 8px;
text-align: center;
}
.result-count {
padding: 4px 8px;
}
.search-container {
padding: 4px 8px;
}

View file

@ -4,13 +4,54 @@ import { Link } from "react-router-dom";
import { Badge, Grid, GridItem } from "@chakra-ui/layout"; import { Badge, Grid, GridItem } from "@chakra-ui/layout";
import styles from "./KanjiList.module.scss"; import styles from "./KanjiList.module.scss";
import { Kanji } from "../types/Kanji"; import { Kanji } from "../types/Kanji";
import { Input, Spinner } from "@chakra-ui/react";
import { useCallback, useEffect, useState } from "react";
export interface KanjiListProps { export interface KanjiListProps {
data: GetKanjiResult; kanjiList: Kanji[];
totalCount: number;
selectedCharacter?: string; selectedCharacter?: string;
loadMoreKanji: () => void;
} }
export function KanjiList({ data, selectedCharacter }: KanjiListProps) { export function KanjiList({
kanjiList,
totalCount,
selectedCharacter,
loadMoreKanji,
}: KanjiListProps) {
// Set up intersection observer
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadingCanary, setLoadingCanary] = useState(null);
const loadingCanaryRef = useCallback(
(element) => {
if (element) setLoadingCanary(element);
},
[setLoadingCanary],
);
useEffect(() => {
if (loadingCanary && !isLoadingMore) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log("loading more shit");
loadMoreKanji();
setIsLoadingMore(true);
}
}
});
observer.observe(loadingCanary);
return () => {
observer.unobserve(loadingCanary);
};
}
}, [loadingCanary, isLoadingMore]);
useEffect(() => {
setIsLoadingMore(false);
}, [kanjiList]);
const renderKanjiItem = (kanji: Kanji, active: boolean) => { const renderKanjiItem = (kanji: Kanji, active: boolean) => {
const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]); const className = classNames(styles["kanji-link"], active && styles["kanji-link-active"]);
return ( return (
@ -21,7 +62,7 @@ export function KanjiList({ data, selectedCharacter }: KanjiListProps) {
</GridItem> </GridItem>
<GridItem>{kanji.meanings[0].meaning}</GridItem> <GridItem>{kanji.meanings[0].meaning}</GridItem>
<GridItem> <GridItem>
<Badge>#{kanji.most_used_rank} most used</Badge> <Badge>#{kanji.most_used_rank} common</Badge>
</GridItem> </GridItem>
</Grid> </Grid>
</Link> </Link>
@ -30,16 +71,24 @@ export function KanjiList({ data, selectedCharacter }: KanjiListProps) {
return ( return (
<> <>
<small> <div className={styles["search-container"]}>
Displaying {data.kanji.length} of {data.count} results. <Input autoFocus placeholder="Search..." />
</div>
<small className={styles["result-count"]}>
Displaying {kanjiList.length} of {totalCount} results.
</small> </small>
<div className={styles["kanji-list-scroll"]}> <div className={styles["kanji-list-scroll"]}>
<div className={styles["kanji-list-inner"]}> <div className={styles["kanji-list-inner"]}>
{data.kanji.map((kanji) => { {kanjiList.map((kanji) => {
const active = kanji.character == selectedCharacter; const active = kanji.character == selectedCharacter;
return renderKanjiItem(kanji, active); return renderKanjiItem(kanji, active);
})} })}
<div className={styles.loading} ref={loadingCanaryRef}>
<Spinner />
</div>
</div> </div>
</div> </div>
</> </>

View file

@ -1,14 +1,16 @@
.kanji-pane-container { .kanji-pane-container {
display: flex; display: flex;
align-items: stretch;
width: 100%; width: 100%;
height: 100%;
} }
.kanji-list { .kanji-pane-list {
// display: flex; display: flex;
// flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
width: 240px; min-width: 280px;
} }
.right-side { .right-side {

View file

@ -8,6 +8,7 @@ import KanjiDisplay from "../components/KanjiDisplay";
import { Kanji } from "../types/Kanji"; import { Kanji } from "../types/Kanji";
import classNames from "classnames"; import classNames from "classnames";
import { KanjiList } from "../components/KanjiList"; import { KanjiList } from "../components/KanjiList";
import { useEffect, useState } from "react";
export interface GetKanjiResult { export interface GetKanjiResult {
count: number; count: number;
@ -16,19 +17,43 @@ export interface GetKanjiResult {
export default function KanjiPane() { export default function KanjiPane() {
const { selectedKanji } = useParams(); const { selectedKanji } = useParams();
const { data, error, isLoading } = useSWR("get_kanji", invoke<GetKanjiResult>); 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) { if (error) {
console.error(error); console.error(error);
} }
const loadMoreKanji = async () => {
const result = await invoke<GetKanjiResult>("get_kanji", { options: {skip: kanjiList.length }});
console.log("invoked result", result);
setKanjiList([...kanjiList, ...result.kanji])
};
return ( return (
<Flex gap={3} className={styles['kanji-pane-container']}> <Flex gap={3} className={styles["kanji-pane-container"]}>
<Box className={styles["kanji-list"]}> <Box className={styles["kanji-pane-list"]}>
{data && <KanjiList data={data} selectedCharacter={selectedKanji} />} {kanjiList && (
<KanjiList
kanjiList={kanjiList}
totalCount={totalCount}
selectedCharacter={selectedKanji}
loadMoreKanji={loadMoreKanji}
/>
)}
</Box> </Box>
<Box className={styles['right-side']}> <Box className={styles["right-side"]}>
{selectedKanji ? <KanjiDisplay kanjiCharacter={selectedKanji} /> : "nothing selected"} {selectedKanji ? <KanjiDisplay kanjiCharacter={selectedKanji} /> : "nothing selected"}
</Box> </Box>
</Flex> </Flex>

View file

@ -5,6 +5,7 @@
html, html,
body { body {
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0; padding: 0;
} }