reader page

This commit is contained in:
monoid 2024-04-07 00:25:45 +09:00
parent 2df64ac2fe
commit ef77706c56
11 changed files with 137 additions and 515 deletions

View File

@ -15,14 +15,15 @@ import './App.css'
// } from "./page/mod";
import { TooltipProvider } from "./components/ui/tooltip.tsx";
import Gallery from "./page/galleryPage.tsx";
import Layout from "./components/layout/layout.tsx";
import NotFoundPage from "./page/404.tsx";
import LoginPage from "./page/loginPage.tsx";
import ProfilePage from "./page/profilesPage.tsx";
import ContentInfoPage from "./page/contentInfoPage.tsx";
import SettingPage from "./page/settingPage.tsx";
import Gallery from "@/page/galleryPage.tsx";
import NotFoundPage from "@/page/404.tsx";
import LoginPage from "@/page/loginPage.tsx";
import ProfilePage from "@/page/profilesPage.tsx";
import ContentInfoPage from "@/page/contentInfoPage.tsx";
import SettingPage from "@/page/settingPage.tsx";
import ComicPage from "@/page/reader/comicPage.tsx";
const App = () => {
return (
@ -35,10 +36,10 @@ const App = () => {
<Route path="/profile" component={ProfilePage}/>
<Route path="/doc/:id" component={ContentInfoPage}/>
<Route path="/setting" component={SettingPage} />
<Route path="/doc/:id/reader" component={ComicPage}/>
{/*
<Route path="/doc/:id/reader" component={<ReaderPage />}></Route>
<Route path="/difference" component={<DifferencePage />}></Route>
<Route path="/tags" component={<TagsPage />}></Route>*/}
<Route path="/difference" component={<DifferencePage />}/>
*/}
<Route component={NotFoundPage} />
</Switch>
</Layout>

View File

@ -1,238 +0,0 @@
import React, {} from "react";
import { Link as RouterLink } from "react-router-dom";
import { Document } from "../accessor/document";
import { Box, Button, Grid, Link, Paper, Theme, Typography, useTheme } from "@mui/material";
import { TagChip } from "../component/tagchip";
import { ThumbnailContainer } from "../page/reader/reader";
import DocumentAccessor from "../accessor/document";
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
export const makeContentReaderUrl = (id: number) => `/doc/${id}/reader`;
const useStyles = (theme: Theme) => ({
thumbnail_content: {
maxHeight: "400px",
maxWidth: "min(400px, 100vw)",
},
tag_list: {
display: "flex",
justifyContent: "flex-start",
flexWrap: "wrap",
overflowY: "hidden",
"& > *": {
margin: theme.spacing(0.5),
},
},
title: {
marginLeft: theme.spacing(1),
},
infoContainer: {
padding: theme.spacing(2),
},
subinfoContainer: {
display: "grid",
gridTemplateColumns: "100px auto",
overflowY: "hidden",
alignItems: "baseline",
},
short_subinfoContainer: {
[theme.breakpoints.down("md")]: {
display: "none",
},
},
short_root: {
overflowY: "hidden",
display: "flex",
flexDirection: "column",
[theme.breakpoints.up("sm")]: {
height: 200,
flexDirection: "row",
},
},
short_thumbnail_anchor: {
background: "#272733",
display: "flex",
alignItems: "center",
justifyContent: "center",
[theme.breakpoints.up("sm")]: {
width: theme.spacing(25),
height: theme.spacing(25),
flexShrink: 0,
},
},
short_thumbnail_content: {
maxWidth: "100%",
maxHeight: "100%",
},
});
export const ContentInfo = (props: {
document: Document;
children?: React.ReactNode;
classes?: {
root?: string;
thumbnail_anchor?: string;
thumbnail_content?: string;
tag_list?: string;
title?: string;
infoContainer?: string;
subinfoContainer?: string;
};
gallery?: string;
short?: boolean;
}) => {
const theme = useTheme();
const document = props.document;
const url = props.gallery === undefined ? makeContentReaderUrl(document.id) : makeContentInfoUrl(document.id);
return (
<Paper
sx={{
display: "flex",
height: props.short ? "400px" : "auto",
overflow: "hidden",
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
alignItems: "center",
height: "auto",
},
}}
elevation={4}
>
<Link
component={RouterLink}
to={{
pathname: makeContentReaderUrl(document.id),
}}
>
{document.deleted_at === null ? (
<ThumbnailContainer content={document} />
) : (
<Typography variant="h4">Deleted</Typography>
)}
</Link>
<Box>
<Link variant="h5" color="inherit" component={RouterLink} to={{ pathname: url }}>
{document.title}
</Link>
<Box>
{props.short ? (
<Box>
{document.tags.map((x) => (
<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>
))}
</Box>
) : (
<ComicDetailTag
tags={document.tags}
path={document.basepath + "/" + document.filename}
createdAt={document.created_at}
deletedAt={document.deleted_at != null ? document.deleted_at : undefined}
/>
)}
</Box>
{document.deleted_at != null && (
<Button
onClick={() => {
documentDelete(document.id);
}}
>
Delete
</Button>
)}
</Box>
</Paper>
);
};
async function documentDelete(id: number) {
const t = await DocumentAccessor.del(id);
if (t) {
alert("document deleted!");
} else {
alert("document already deleted.");
}
}
function ComicDetailTag(prop: {
tags: string[] /*classes:{
tag_list:string
}*/;
path?: string;
createdAt?: number;
deletedAt?: number;
}) {
let allTag = prop.tags;
const tagKind = ["artist", "group", "series", "type", "character"];
let tagTable: { [kind: string]: string[] } = {};
for (const kind of tagKind) {
const tags = allTag.filter((x) => x.startsWith(kind + ":")).map((x) => x.slice(kind.length + 1));
tagTable[kind] = tags;
allTag = allTag.filter((x) => !x.startsWith(kind + ":"));
}
return (
<Grid container>
{tagKind.map((key) => (
<React.Fragment key={key}>
<Grid item xs={3}>
<Typography variant="subtitle1">{key}</Typography>
</Grid>
<Grid item xs={9}>
<Box>
{tagTable[key].length !== 0
? tagTable[key].map((elem, i) => {
return (
<>
<Link to={`/search?allow_tag=${key}:${encodeURIComponent(elem)}`} component={RouterLink}>
{elem}
</Link>
{i < tagTable[key].length - 1 ? "," : ""}
</>
);
})
: "N/A"}
</Box>
</Grid>
</React.Fragment>
))}
{prop.path != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">Path</Typography>
</Grid>
<Grid item xs={9}>
<Box>{prop.path}</Box>
</Grid>
</>
)}
{prop.createdAt != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">CreatedAt</Typography>
</Grid>
<Grid item xs={9}>
<Box>{new Date(prop.createdAt).toUTCString()}</Box>
</Grid>
</>
)}
{prop.deletedAt != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">DeletedAt</Typography>
</Grid>
<Grid item xs={9}>
<Box>{new Date(prop.deletedAt).toUTCString()}</Box>
</Grid>
</>
)}
<Grid item xs={3}>
<Typography variant="subtitle1">Tags</Typography>
</Grid>
<Grid item xs={9}>
{allTag.map((x) => (
<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>
))}
</Grid>
</Grid>
);
}

View File

@ -1,5 +0,0 @@
export * from "./contentinfo";
export * from "./headline";
export * from "./loading";
export * from "./navlist";
export * from "./tagchip";

View File

@ -1,8 +1,7 @@
import type { Document } from "dbtype/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx";
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { Fragment, useEffect, useRef, useState } from "react";
import { LazyImage } from "./LazyImage.tsx";
import StyledLink from "./StyledLink.tsx";

View File

@ -3,6 +3,7 @@ import { useGalleryDoc } from "../hook/useGalleryDoc";
import TagBadge from "@/components/gallery/TagBadge";
import StyledLink from "@/components/gallery/StyledLink";
import { cn } from "@/lib/utils";
import { Link } from "wouter";
export interface ContentInfoPageProps {
params: {
@ -62,8 +63,11 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
const tags = data?.tags ?? [];
const classifiedTags = classifyTags(tags);
const contentLocation = `/doc/${params.id}/reader`;
return (
<div className="p-4">
<Link to={contentLocation}>
<div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
rounded-xl shadow-lg overflow-hidden">
<img
@ -71,9 +75,14 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
src={`/api/doc/${data.id}/comic/thumbnail`}
alt={data.title} />
</div>
</Link>
<Card className="flex-1">
<CardHeader>
<CardTitle>{data.title}</CardTitle>
<CardTitle>
<StyledLink to={contentLocation}>
{data.title}
</StyledLink>
</CardTitle>
<CardDescription>
<StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`} className="text-sm">
{classifiedTags.type[0] ?? "N/A"}

View File

@ -40,6 +40,7 @@ export default function Gallery() {
data?.length === 0 && <div className="p-4 text-3xl">No results</div>
}
{
// TODO: implement infinite scroll
data?.map((x) => {
return (
<GalleryCard doc={x} key={x.id} />

View File

@ -1,83 +0,0 @@
import { Typography, styled } from "@mui/material";
import React, { RefObject, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Document } from "../../accessor/document";
type ComicType = "comic" | "artist cg" | "donjinshi" | "western";
export type PresentableTag = {
artist: string[];
group: string[];
series: string[];
type: ComicType;
character: string[];
tags: string[];
};
const ViewMain = styled("div")(({ theme }) => ({
overflow: "hidden",
width: "100%",
height: "calc(100vh - 64px)",
position: "relative",
}));
const CurrentView = styled("img")(({ theme }) => ({
maxWidth: "100%",
maxHeight: "100%",
top: "50%",
left: "50%",
transform: "translate(-50%,-50%)",
position: "absolute",
}));
export const ComicReader = (props: { doc: Document; fullScreenTarget?: RefObject<HTMLDivElement> }) => {
const additional = props.doc.additional;
const [searchParams, setSearchParams] = useSearchParams();
const curPage = parseInt(searchParams.get("page") ?? "0");
const setCurPage = (n: number) => {
setSearchParams([["page", n.toString()]]);
};
if (isNaN(curPage)) {
return <Typography>Error. Page number is not a number.</Typography>;
}
if (!("page" in additional)) {
console.error("invalid content : page read fail : " + JSON.stringify(additional));
return <Typography>Error. DB error. page restriction</Typography>;
}
const maxPage: number = additional["page"] as number;
const PageDown = () => setCurPage(Math.max(curPage - 1, 0));
const PageUp = () => setCurPage(Math.min(curPage + 1, maxPage - 1));
const onKeyUp = (e: KeyboardEvent) => {
console.log(`currently: ${curPage}/${maxPage}`);
if (e.code === "ArrowLeft") {
PageDown();
} else if (e.code === "ArrowRight") {
PageUp();
}
};
useEffect(() => {
document.addEventListener("keydown", onKeyUp);
return () => {
document.removeEventListener("keydown", onKeyUp);
};
}, [curPage]);
// theme.mixins.toolbar.minHeight;
return (
<ViewMain ref={props.fullScreenTarget}>
<div
onClick={PageDown}
style={{ left: "0", width: "50%", position: "absolute", height: "100%", zIndex: 100 }}
></div>
<CurrentView onClick={PageUp} src={`/api/doc/${props.doc.id}/comic/${curPage}`}></CurrentView>
<div
onClick={PageUp}
style={{ right: "0", width: "50%", position: "absolute", height: "100%", zIndex: 100 }}
></div>
</ViewMain>
);
};
export default ComicReader;

View File

@ -0,0 +1,108 @@
import { useGalleryDoc } from "@/hook/useGalleryDoc";
import { cn } from "@/lib/utils";
import type { Document } from "dbtype/api";
import { useCallback, useEffect, useRef, useState } from "react";
interface ComicPageProps {
params: {
id: string;
};
}
function ComicViewer({
doc,
totalPage,
}: {
doc: Document;
totalPage: number;
}) {
const [curPage, setCurPage] = useState(0);
const [fade, setFade] = useState(false);
const PageDown = useCallback((step: number) => setCurPage(Math.max(curPage - step, 0)), [curPage]);
const PageUp = useCallback((step: number) => setCurPage(Math.min(curPage + step, totalPage - 1)), [curPage, totalPage]);
const currentImageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const onKeyUp = (e: KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
if (e.code === "ArrowLeft") {
PageDown(step);
} else if (e.code === "ArrowRight") {
PageUp(step);
}
};
window.addEventListener("keyup", onKeyUp);
return () => {
window.removeEventListener("keyup", onKeyUp);
};
}, [PageDown, PageUp]);
useEffect(() => {
if(currentImageRef.current){
const img = new Image();
img.src = `/api/doc/${doc.id}/comic/${curPage}`;
if (img.complete) {
currentImageRef.current.src = img.src;
return;
}
setFade(true);
const listener = () => {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const currentImage = currentImageRef.current!;
currentImage.src = img.src;
setFade(false);
};
img.addEventListener("load", listener);
return () => {
img.removeEventListener("load", listener);
// abort loading
img.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=;';
// TODO: use web worker to abort loading image in the future
};
}
}, [curPage, doc.id]);
return (
<div className="overflow-hidden w-full h-full relative">
<div className="absolute left-0 w-1/2 h-full z-10" onMouseDown={() => PageDown(1)} />
<img
ref={currentImageRef}
className={cn("max-w-full max-h-full top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 absolute",
fade ? "opacity-70 transition-opacity duration-300 ease-in-out" : "opacity-100"
)}
alt="main content"/>
<div className="absolute right-0 w-1/2 h-full z-10" onMouseDown={() => PageUp(1)} />
</div>
);
}
export default function ComicPage({
params
}: ComicPageProps) {
const { data, error, isLoading } = useGalleryDoc(params.id);
if (isLoading) {
// TODO: Add a loading spinner
return <div className="p-4">
Loading...
</div>
}
if (error) {
return <div className="p-4">Error: {String(error)}</div>
}
if (!data) {
return <div className="p-4">Not found</div>
}
if (data.content_type !== "comic") {
return <div className="p-4">Not a comic</div>
}
if (!("page" in data.additional)) {
console.error(`invalid content : page read fail : ${JSON.stringify(data.additional)}`);
return <div className="p-4">Error. DB error. page restriction</div>
}
return (
<ComicViewer doc={data} totalPage={data.additional.page as number} />
)
}

View File

@ -1,80 +0,0 @@
import { styled, Typography } from "@mui/material";
import React from "react";
import { Document, makeThumbnailUrl } from "../../accessor/document";
import { ComicReader } from "./comic";
import { VideoReader } from "./video";
export interface PagePresenterProp {
doc: Document;
className?: string;
fullScreenTarget?: React.RefObject<HTMLDivElement>;
}
interface PagePresenter {
(prop: PagePresenterProp): JSX.Element;
}
export const getPresenter = (content: Document): PagePresenter => {
switch (content.content_type) {
case "comic":
return ComicReader;
case "video":
return VideoReader;
}
return () => <Typography variant="h2">Not implemented reader</Typography>;
};
const BackgroundDiv = styled("div")({
height: "400px",
width: "300px",
backgroundColor: "#272733",
display: "flex",
alignItems: "center",
justifyContent: "center",
});
import { useEffect, useRef, useState } from "react";
import "./thumbnail.css";
export function useIsElementInViewport<T extends HTMLElement>(options?: IntersectionObserverInit) {
const elementRef = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
const callback = (entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
setIsVisible(entry.isIntersecting);
};
useEffect(() => {
const observer = new IntersectionObserver(callback, options);
elementRef.current && observer.observe(elementRef.current);
return () => observer.disconnect();
}, [elementRef, options]);
return { elementRef, isVisible };
}
export function ThumbnailContainer(props: {
content: Document;
className?: string;
}) {
const { elementRef, isVisible } = useIsElementInViewport<HTMLDivElement>({});
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (isVisible) {
setLoaded(true);
}
}, [isVisible]);
const style = {
maxHeight: "400px",
maxWidth: "min(400px, 100vw)",
};
const thumbnailurl = makeThumbnailUrl(props.content);
if (props.content.content_type === "video") {
return <video src={thumbnailurl} muted autoPlay loop className={props.className} style={style}></video>;
} else {
return (
<BackgroundDiv ref={elementRef}>
{loaded && <img src={thumbnailurl} className={props.className + " thumbnail_img"} loading="lazy"></img>}
</BackgroundDiv>
);
}
}

View File

@ -1,14 +0,0 @@
import React from "react";
import { Document } from "../../accessor/document";
export const VideoReader = (props: { doc: Document }) => {
const id = props.doc.id;
return (
<video
controls
autoPlay
src={`/api/doc/${props.doc.id}/video`}
style={{ maxHeight: "100%", maxWidth: "100%" }}
></video>
);
};

View File

@ -1,76 +0,0 @@
import { Box, Paper, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import React, { useEffect, useState } from "react";
import { LoadingCircle } from "../component/loading";
import { CommonMenuList, Headline } from "../component/mod";
import { PagePad } from "../component/pagepad";
type TagCount = {
tag_name: string;
occurs: number;
};
const tagTableColumn: GridColDef[] = [
{
field: "tag_name",
headerName: "Tag Name",
width: 200,
},
{
field: "occurs",
headerName: "Occurs",
width: 100,
type: "number",
},
];
function TagTable() {
const [data, setData] = useState<TagCount[] | undefined>();
const [error, setErrorMsg] = useState<string | undefined>(undefined);
const isLoading = data === undefined;
useEffect(() => {
loadData();
}, []);
if (isLoading) {
return <LoadingCircle />;
}
if (error !== undefined) {
return <Typography variant="h3">{error}</Typography>;
}
return (
<Box sx={{ height: "400px", width: "100%" }}>
<Paper sx={{ height: "100%" }} elevation={2}>
<DataGrid rows={data} columns={tagTableColumn} getRowId={(t) => t.tag_name}></DataGrid>
</Paper>
</Box>
);
async function loadData() {
try {
const res = await fetch("/api/tags?withCount=true");
const data = await res.json();
setData(data);
} catch (e) {
setData([]);
if (e instanceof Error) {
setErrorMsg(e.message);
} else {
console.log(e);
setErrorMsg("");
}
}
}
}
export const TagsPage = () => {
const menu = CommonMenuList();
return (
<Headline menu={menu}>
<PagePad>
<TagTable></TagTable>
</PagePad>
</Headline>
);
};