Rework #6
					 11 changed files with 137 additions and 515 deletions
				
			
		| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +0,0 @@
 | 
			
		|||
export * from "./contentinfo";
 | 
			
		||||
export * from "./headline";
 | 
			
		||||
export * from "./loading";
 | 
			
		||||
export * from "./navlist";
 | 
			
		||||
export * from "./tagchip";
 | 
			
		||||
| 
						 | 
				
			
			@ -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";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,18 +63,26 @@ export function ContentInfoPage({ params }: ContentInfoPageProps) {
 | 
			
		|||
    const tags = data?.tags ?? [];
 | 
			
		||||
    const classifiedTags = classifyTags(tags);
 | 
			
		||||
 | 
			
		||||
    const contentLocation = `/doc/${params.id}/reader`;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div className="p-4">
 | 
			
		||||
            <div className="m-auto h-[400px] mb-4 flex justify-center items-center flex-none bg-[#272733]
 | 
			
		||||
            <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
 | 
			
		||||
                    className="max-w-full max-h-full object-cover object-center"
 | 
			
		||||
                    src={`/api/doc/${data.id}/comic/thumbnail`}
 | 
			
		||||
                    alt={data.title} />
 | 
			
		||||
            </div>
 | 
			
		||||
                    <img
 | 
			
		||||
                        className="max-w-full max-h-full object-cover object-center"
 | 
			
		||||
                        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"}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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} />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
							
								
								
									
										108
									
								
								packages/client/src/page/reader/comicPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								packages/client/src/page/reader/comicPage.tsx
									
										
									
									
									
										Normal 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} />
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
	Add table
		
		Reference in a new issue