gallery infinite pagination

This commit is contained in:
monoid 2024-04-07 17:41:56 +09:00
parent ef77706c56
commit 4cf1381faa
11 changed files with 230 additions and 178 deletions

View File

@ -1,19 +1,9 @@
import { Route, Switch, Redirect } from "wouter"; import { Route, Switch, Redirect } from "wouter";
import { useTernaryDarkMode } from "usehooks-ts";
import { useEffect } from "react";
import './App.css' import './App.css'
// import {
// // DifferencePage,
// // DocumentAbout,
// // Gallery,
// // LoginPage,
// // NotFoundPage,
// // ProfilePage,
// // ReaderPage,
// // SettingPage,
// // TagsPage,
// } from "./page/mod";
import { TooltipProvider } from "./components/ui/tooltip.tsx"; import { TooltipProvider } from "./components/ui/tooltip.tsx";
import Layout from "./components/layout/layout.tsx"; import Layout from "./components/layout/layout.tsx";
@ -26,6 +16,17 @@ import SettingPage from "@/page/settingPage.tsx";
import ComicPage from "@/page/reader/comicPage.tsx"; import ComicPage from "@/page/reader/comicPage.tsx";
const App = () => { const App = () => {
const { isDarkMode } = useTernaryDarkMode();
useEffect(() => {
if (isDarkMode) {
document.body.classList.add("dark");
}
else {
document.body.classList.remove("dark");
}
}, [isDarkMode]);
return ( return (
<TooltipProvider> <TooltipProvider>
<Layout> <Layout>

View File

@ -0,0 +1,25 @@
import React from "react";
export function Spinner(props: { className?: string; }) {
const chars = ["⠋",
"⠙",
"⠹",
"⠸",
"⠼",
"⠴",
"⠦",
"⠧",
"⠇",
"⠏"
];
const [index, setIndex] = React.useState(0);
React.useEffect(() => {
const interval = setInterval(() => {
setIndex((index + 1) % chars.length);
}, 80);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index]);
return <span className={props.className}>{chars[index]}</span>;
}

View File

@ -0,0 +1,46 @@
import useSWRInifinite from "swr/infinite";
import type { Document } from "dbtype/api";
import { fetcher } from "./fetcher";
interface SearchParams {
word?: string;
tags?: string;
limit?: number;
cursor?: number;
}
export function useSearchGallery({
word, tags, limit, cursor,
}: SearchParams) {
return useSWRInifinite<
{
data: Document[];
nextCursor: number | null;
hasMore: boolean;
}
>((index, previous) => {
if (!previous && index > 0) return null;
if (previous && !previous.hasMore) return null;
const search = new URLSearchParams();
if (word) search.set("word", word);
if (tags) search.set("allow_tag", tags);
if (limit) search.set("limit", limit.toString());
if (cursor) search.set("cursor", cursor.toString());
if (index === 0) {
return `/api/doc/search?${search.toString()}`;
}
if (!previous || !previous.data) return null;
const last = previous.data[previous.data.length - 1];
search.set("cursor", last.id.toString());
return `/api/doc/search?${search.toString()}`;
}, async (url) => {
const res = await fetcher(url);
return {
data: res,
nextCursor: res.length === 0 ? null : res[res.length - 1].id,
hasMore: limit ? res.length === limit : (res.length === 20),
};
});
}

View File

@ -1,23 +0,0 @@
import useSWR from "swr";
import type { Document } from "dbtype/api";
import { fetcher } from "./fetcher";
interface SearchParams {
word?: string;
tags?: string;
limit?: number;
cursor?: number;
}
export function useSearchGallery({
word, tags, limit, cursor,
}: SearchParams) {
const search = new URLSearchParams();
if (word) search.set("word", word);
if (tags) search.set("allow_tag", tags);
if (limit) search.set("limit", limit.toString());
if (cursor) search.set("cursor", cursor.toString());
return useSWR<
Document[]
>(`/api/doc/search?${search.toString()}`, fetcher);
}

View File

@ -1,5 +1,5 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGalleryDoc } from "../hook/useGalleryDoc"; import { useGalleryDoc } from "../hook/useGalleryDoc.ts";
import TagBadge from "@/components/gallery/TagBadge"; import TagBadge from "@/components/gallery/TagBadge";
import StyledLink from "@/components/gallery/StyledLink"; import StyledLink from "@/components/gallery/StyledLink";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";

View File

@ -1,126 +1,126 @@
import { Box, Button, Paper, Typography } from "@mui/material"; // import { Box, Button, Paper, Typography } from "@mui/material";
import React, { useContext, useEffect, useState } from "react"; // import React, { useContext, useEffect, useState } from "react";
import { CommonMenuList, Headline } from "../component/mod"; // import { CommonMenuList, Headline } from "../component/mod";
import { UserContext } from "../state"; // import { UserContext } from "../state";
import { PagePad } from "../component/pagepad"; // import { PagePad } from "../component/pagepad";
type FileDifference = { // type FileDifference = {
type: string; // type: string;
value: { // value: {
type: string; // type: string;
path: string; // path: string;
}[]; // }[];
}; // };
function TypeDifference(prop: { // function TypeDifference(prop: {
content: FileDifference; // content: FileDifference;
onCommit: (v: { type: string; path: string }) => void; // onCommit: (v: { type: string; path: string }) => void;
onCommitAll: (type: string) => void; // onCommitAll: (type: string) => void;
}) { // }) {
// const classes = useStyles(); // // const classes = useStyles();
const x = prop.content; // const x = prop.content;
const [button_disable, set_disable] = useState(false); // const [button_disable, set_disable] = useState(false);
return ( // return (
<Paper /*className={classes.paper}*/> // <Paper /*className={classes.paper}*/>
<Box /*className={classes.contentTitle}*/> // <Box /*className={classes.contentTitle}*/>
<Typography variant="h3">{x.type}</Typography> // <Typography variant="h3">{x.type}</Typography>
<Button // <Button
variant="contained" // variant="contained"
key={x.type} // key={x.type}
onClick={() => { // onClick={() => {
set_disable(true); // set_disable(true);
prop.onCommitAll(x.type); // prop.onCommitAll(x.type);
set_disable(false); // set_disable(false);
}} // }}
> // >
Commit all // Commit all
</Button> // </Button>
</Box> // </Box>
{x.value.map((y) => ( // {x.value.map((y) => (
<Box sx={{ display: "flex" }} key={y.path}> // <Box sx={{ display: "flex" }} key={y.path}>
<Button // <Button
variant="contained" // variant="contained"
onClick={() => { // onClick={() => {
set_disable(true); // set_disable(true);
prop.onCommit(y); // prop.onCommit(y);
set_disable(false); // set_disable(false);
}} // }}
disabled={button_disable} // disabled={button_disable}
> // >
Commit // Commit
</Button> // </Button>
<Typography variant="h5">{y.path}</Typography> // <Typography variant="h5">{y.path}</Typography>
</Box> // </Box>
))} // ))}
</Paper> // </Paper>
); // );
} // }
export function DifferencePage() { // export function DifferencePage() {
const ctx = useContext(UserContext); // const ctx = useContext(UserContext);
// const classes = useStyles(); // // const classes = useStyles();
const [diffList, setDiffList] = useState<FileDifference[]>([]); // const [diffList, setDiffList] = useState<FileDifference[]>([]);
const doLoad = async () => { // const doLoad = async () => {
const list = await fetch("/api/diff/list"); // const list = await fetch("/api/diff/list");
if (list.ok) { // if (list.ok) {
const inner = await list.json(); // const inner = await list.json();
setDiffList(inner); // setDiffList(inner);
} else { // } else {
// setDiffList([]); // // setDiffList([]);
} // }
}; // };
const Commit = async (x: { type: string; path: string }) => { // const Commit = async (x: { type: string; path: string }) => {
const res = await fetch("/api/diff/commit", { // const res = await fetch("/api/diff/commit", {
method: "POST", // method: "POST",
body: JSON.stringify([{ ...x }]), // body: JSON.stringify([{ ...x }]),
headers: { // headers: {
"content-type": "application/json", // "content-type": "application/json",
}, // },
}); // });
const bb = await res.json(); // const bb = await res.json();
if (bb.ok) { // if (bb.ok) {
doLoad(); // doLoad();
} else { // } else {
console.error("fail to add document"); // console.error("fail to add document");
} // }
}; // };
const CommitAll = async (type: string) => { // const CommitAll = async (type: string) => {
const res = await fetch("/api/diff/commitall", { // const res = await fetch("/api/diff/commitall", {
method: "POST", // method: "POST",
body: JSON.stringify({ type: type }), // body: JSON.stringify({ type: type }),
headers: { // headers: {
"content-type": "application/json", // "content-type": "application/json",
}, // },
}); // });
const bb = await res.json(); // const bb = await res.json();
if (bb.ok) { // if (bb.ok) {
doLoad(); // doLoad();
} else { // } else {
console.error("fail to add document"); // console.error("fail to add document");
} // }
}; // };
useEffect(() => { // useEffect(() => {
doLoad(); // doLoad();
const i = setInterval(doLoad, 5000); // const i = setInterval(doLoad, 5000);
return () => { // return () => {
clearInterval(i); // clearInterval(i);
}; // };
}, []); // }, []);
const menu = CommonMenuList(); // const menu = CommonMenuList();
return ( // return (
<Headline menu={menu}> // <Headline menu={menu}>
<PagePad> // <PagePad>
{ctx.username == "admin" ? ( // {ctx.username == "admin" ? (
<div> // <div>
{diffList.map((x) => ( // {diffList.map((x) => (
<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} /> // <TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
))} // ))}
</div> // </div>
) : ( // ) : (
<Typography variant="h2">Not Allowed : please login as an admin</Typography> // <Typography variant="h2">Not Allowed : please login as an admin</Typography>
)} // )}
</PagePad> // </PagePad>
</Headline> // </Headline>
); // );
} // }

View File

@ -3,7 +3,8 @@ import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx"; import TagBadge from "@/components/gallery/TagBadge.tsx";
import { useSearchGallery } from "../hook/useSearchGallery"; import { useSearchGallery } from "../hook/useSearchGallery.ts";
import { Spinner } from "../components/Spinner.tsx";
export default function Gallery() { export default function Gallery() {
const search = useSearch(); const search = useSearch();
@ -12,7 +13,7 @@ export default function Gallery() {
const tags = searchParams.get("allow_tag") ?? undefined; const tags = searchParams.get("allow_tag") ?? undefined;
const limit = searchParams.get("limit"); const limit = searchParams.get("limit");
const cursor = searchParams.get("cursor"); const cursor = searchParams.get("cursor");
const { data, error, isLoading } = useSearchGallery({ const { data, error, isLoading, size, setSize } = useSearchGallery({
word, tags, word, tags,
limit: limit ? Number.parseInt(limit) : undefined, limit: limit ? Number.parseInt(limit) : undefined,
cursor: cursor ? Number.parseInt(cursor) : undefined cursor: cursor ? Number.parseInt(cursor) : undefined
@ -25,28 +26,39 @@ export default function Gallery() {
return <div className="p-4">Error: {String(error)}</div> return <div className="p-4">Error: {String(error)}</div>
} }
const isLoadingMore = data && size > 0 && (data[size - 1] === undefined);
const isReachingEnd = data && data[size - 1]?.hasMore === false;
return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start"> return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start">
<div className="flex space-x-2"> <div className="flex space-x-2">
<Input className="flex-1"/> <Input className="flex-1" />
<Button className="flex-none">Search</Button> <Button className="flex-none">Search</Button>
</div> </div>
{(word || tags) && {(word || tags) &&
<div className="bg-primary rounded-full p-1 sticky top-0 shadow-md"> <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md">
{word && <span className="text-primary-foreground ml-4">Search: {word}</span>} {word && <span className="text-primary-foreground ml-4">Search: {word}</span>}
{tags && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex">{tags.split(",").map(x=> <TagBadge tagname={x} key={x}/>)}</ul></span>} {tags && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex">{tags.split(",").map(x => <TagBadge tagname={x} key={x} />)}</ul></span>}
</div> </div>
} }
{ {
data?.length === 0 && <div className="p-4 text-3xl">No results</div> data?.length === 0 && <div className="p-4 text-3xl">No results</div>
} }
{ {
// TODO: implement infinite scroll // TODO: date based grouping
data?.map((x) => { data?.map((docs) => {
return ( return docs.data.map((x) => {
<GalleryCard doc={x} key={x.id} /> return (
); <GalleryCard doc={x} key={x.id} />
);
});
}) })
} }
{
<Button className="w-full" onClick={() => setSize(size + 1)}
disabled={isReachingEnd || isLoadingMore}
> {isLoadingMore && <Spinner className="mr-1" />}{size + 1} Load more</Button>
}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import { useGalleryDoc } from "@/hook/useGalleryDoc"; import { useGalleryDoc } from "@/hook/useGalleryDoc.ts";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Document } from "dbtype/api"; import type { Document } from "dbtype/api";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";

View File

@ -43,18 +43,9 @@ function DarkModeView() {
} }
export function SettingPage() { export function SettingPage() {
const { setTernaryDarkMode, ternaryDarkMode, isDarkMode } = useTernaryDarkMode(); const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode();
const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
useEffect(() => {
if (isDarkMode) {
document.body.classList.add("dark");
}
else {
document.body.classList.remove("dark");
}
}, [isDarkMode]);
return ( return (
<div className="p-4"> <div className="p-4">
<Card> <Card>