gallery infinite pagination
This commit is contained in:
parent
ef77706c56
commit
4cf1381faa
@ -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>
|
||||||
|
25
packages/client/src/components/Spinner.tsx
Normal file
25
packages/client/src/components/Spinner.tsx
Normal 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>;
|
||||||
|
}
|
46
packages/client/src/hook/useSearchGallery.ts
Normal file
46
packages/client/src/hook/useSearchGallery.ts
Normal 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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
@ -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";
|
||||||
|
@ -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>
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user