Rework #6
					 21 changed files with 253 additions and 573 deletions
				
			
		| 
						 | 
				
			
			@ -12,6 +12,7 @@
 | 
			
		|||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@radix-ui/react-icons": "^1.3.0",
 | 
			
		||||
    "@radix-ui/react-label": "^2.0.2",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.0.2",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.0.7",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,19 +19,20 @@ import { TooltipProvider } from "./components/ui/tooltip";
 | 
			
		|||
import Gallery from "./page/galleryPage";
 | 
			
		||||
import Layout from "./components/layout/layout";
 | 
			
		||||
import NotFoundPage from "./page/404";
 | 
			
		||||
import LoginPage from "./page/loginPage";
 | 
			
		||||
import ProfilePage from "./page/profilesPage";
 | 
			
		||||
 | 
			
		||||
const App = () => {
 | 
			
		||||
	return (
 | 
			
		||||
		<TooltipProvider>
 | 
			
		||||
 | 
			
		||||
			<Layout>
 | 
			
		||||
				<Switch>
 | 
			
		||||
					<Route path="/" component={() => <Redirect replace to="/search?" />} />
 | 
			
		||||
					<Route path="/search" component={Gallery} />
 | 
			
		||||
					<Route path="/login" component={LoginPage} />
 | 
			
		||||
					<Route path="/profile" component={ProfilePage}/>
 | 
			
		||||
					{/* <Route path="/doc/:id" component={<DocumentAbout />}></Route>
 | 
			
		||||
				<Route path="/doc/:id/reader" component={<ReaderPage />}></Route>
 | 
			
		||||
				<Route path="/login" component={<LoginPage></LoginPage>} />
 | 
			
		||||
				<Route path="/profile" component={<ProfilePage />}></Route>
 | 
			
		||||
				<Route path="/difference" component={<DifferencePage />}></Route>
 | 
			
		||||
				<Route path="/setting" component={<SettingPage />}></Route>
 | 
			
		||||
			<Route path="/tags" component={<TagsPage />}></Route>*/}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,273 +0,0 @@
 | 
			
		|||
import { AccountCircle, ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon } from "@mui/icons-material";
 | 
			
		||||
import {
 | 
			
		||||
	AppBar,
 | 
			
		||||
	Button,
 | 
			
		||||
	CssBaseline,
 | 
			
		||||
	Divider,
 | 
			
		||||
	Drawer,
 | 
			
		||||
	Hidden,
 | 
			
		||||
	IconButton,
 | 
			
		||||
	InputBase,
 | 
			
		||||
	Link,
 | 
			
		||||
	List,
 | 
			
		||||
	ListItem,
 | 
			
		||||
	ListItemIcon,
 | 
			
		||||
	ListItemText,
 | 
			
		||||
	Menu,
 | 
			
		||||
	MenuItem,
 | 
			
		||||
	styled,
 | 
			
		||||
	Toolbar,
 | 
			
		||||
	Tooltip,
 | 
			
		||||
	Typography,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import { alpha, Theme, useTheme } from "@mui/material/styles";
 | 
			
		||||
import React, { useContext, useState } from "react";
 | 
			
		||||
 | 
			
		||||
import { Link as RouterLink, useNavigate } from "react-router-dom";
 | 
			
		||||
import { doLogout, UserContext } from "../state";
 | 
			
		||||
 | 
			
		||||
const drawerWidth = 270;
 | 
			
		||||
 | 
			
		||||
const DrawerHeader = styled("div")(({ theme }) => ({
 | 
			
		||||
	...theme.mixins.toolbar,
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const StyledDrawer = styled(Drawer)(({ theme }) => ({
 | 
			
		||||
	flexShrink: 0,
 | 
			
		||||
	whiteSpace: "nowrap",
 | 
			
		||||
	[theme.breakpoints.up("sm")]: {
 | 
			
		||||
		width: drawerWidth,
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
const StyledSearchBar = styled("div")(({ theme }) => ({
 | 
			
		||||
	position: "relative",
 | 
			
		||||
	borderRadius: theme.shape.borderRadius,
 | 
			
		||||
	backgroundColor: alpha(theme.palette.common.white, 0.15),
 | 
			
		||||
	"&:hover": {
 | 
			
		||||
		backgroundColor: alpha(theme.palette.common.white, 0.25),
 | 
			
		||||
	},
 | 
			
		||||
	marginLeft: 0,
 | 
			
		||||
	width: "100%",
 | 
			
		||||
	[theme.breakpoints.up("sm")]: {
 | 
			
		||||
		marginLeft: theme.spacing(1),
 | 
			
		||||
		width: "auto",
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
const StyledInputBase = styled(InputBase)(({ theme }) => ({
 | 
			
		||||
	color: "inherit",
 | 
			
		||||
	"& .MuiInputBase-input": {
 | 
			
		||||
		padding: theme.spacing(1, 1, 1, 0),
 | 
			
		||||
		// vertical padding + font size from searchIcon
 | 
			
		||||
		paddingLeft: `calc(1em + ${theme.spacing(4)})`,
 | 
			
		||||
		transition: theme.transitions.create("width"),
 | 
			
		||||
		width: "100%",
 | 
			
		||||
		[theme.breakpoints.up("sm")]: {
 | 
			
		||||
			width: "12ch",
 | 
			
		||||
			"&:focus": {
 | 
			
		||||
				width: "20ch",
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const StyledNav = styled("nav")(({ theme }) => ({
 | 
			
		||||
	[theme.breakpoints.up("sm")]: {
 | 
			
		||||
		width: theme.spacing(7),
 | 
			
		||||
	},
 | 
			
		||||
}));
 | 
			
		||||
 | 
			
		||||
const closedMixin = (theme: Theme) => ({
 | 
			
		||||
	overflowX: "hidden",
 | 
			
		||||
	width: `calc(${theme.spacing(7)} + 1px)`,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const Headline = (prop: {
 | 
			
		||||
	children?: React.ReactNode;
 | 
			
		||||
	classes?: {
 | 
			
		||||
		content?: string;
 | 
			
		||||
		toolbar?: string;
 | 
			
		||||
	};
 | 
			
		||||
	rightAppbar?: React.ReactNode;
 | 
			
		||||
	menu: React.ReactNode;
 | 
			
		||||
}) => {
 | 
			
		||||
	const [v, setv] = useState(false);
 | 
			
		||||
	const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
 | 
			
		||||
	const theme = useTheme();
 | 
			
		||||
	const toggleV = () => setv(!v);
 | 
			
		||||
	const handleProfileMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
 | 
			
		||||
	const handleProfileMenuClose = () => setAnchorEl(null);
 | 
			
		||||
	const isProfileMenuOpened = Boolean(anchorEl);
 | 
			
		||||
	const menuId = "primary-search-account-menu";
 | 
			
		||||
	const user_ctx = useContext(UserContext);
 | 
			
		||||
	const isLogin = user_ctx.username !== "";
 | 
			
		||||
	const navigate = useNavigate();
 | 
			
		||||
	const [search, setSearch] = useState("");
 | 
			
		||||
 | 
			
		||||
	const renderProfileMenu = (
 | 
			
		||||
		<Menu
 | 
			
		||||
			anchorEl={anchorEl}
 | 
			
		||||
			anchorOrigin={{ horizontal: "right", vertical: "top" }}
 | 
			
		||||
			id={menuId}
 | 
			
		||||
			open={isProfileMenuOpened}
 | 
			
		||||
			keepMounted
 | 
			
		||||
			transformOrigin={{ horizontal: "right", vertical: "top" }}
 | 
			
		||||
			onClose={handleProfileMenuClose}
 | 
			
		||||
		>
 | 
			
		||||
			<MenuItem component={RouterLink} to="/profile">
 | 
			
		||||
				Profile
 | 
			
		||||
			</MenuItem>
 | 
			
		||||
			<MenuItem
 | 
			
		||||
				onClick={async () => {
 | 
			
		||||
					handleProfileMenuClose();
 | 
			
		||||
					await doLogout();
 | 
			
		||||
					user_ctx.setUsername("");
 | 
			
		||||
				}}
 | 
			
		||||
			>
 | 
			
		||||
				Logout
 | 
			
		||||
			</MenuItem>
 | 
			
		||||
		</Menu>
 | 
			
		||||
	);
 | 
			
		||||
	const drawer_contents = (
 | 
			
		||||
		<>
 | 
			
		||||
			<DrawerHeader>
 | 
			
		||||
				<IconButton onClick={toggleV}>{theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />}</IconButton>
 | 
			
		||||
			</DrawerHeader>
 | 
			
		||||
			<Divider />
 | 
			
		||||
			{prop.menu}
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div style={{ display: "flex" }}>
 | 
			
		||||
			<CssBaseline />
 | 
			
		||||
			<AppBar
 | 
			
		||||
				position="fixed"
 | 
			
		||||
				sx={{
 | 
			
		||||
					zIndex: theme.zIndex.drawer + 1,
 | 
			
		||||
					transition: theme.transitions.create(["width", "margin"], {
 | 
			
		||||
						easing: theme.transitions.easing.sharp,
 | 
			
		||||
						duration: theme.transitions.duration.leavingScreen,
 | 
			
		||||
					}),
 | 
			
		||||
				}}
 | 
			
		||||
			>
 | 
			
		||||
				<Toolbar>
 | 
			
		||||
					<IconButton
 | 
			
		||||
						color="inherit"
 | 
			
		||||
						aria-label="open drawer"
 | 
			
		||||
						onClick={toggleV}
 | 
			
		||||
						edge="start"
 | 
			
		||||
						style={{ marginRight: 36 }}
 | 
			
		||||
					>
 | 
			
		||||
						<MenuIcon></MenuIcon>
 | 
			
		||||
					</IconButton>
 | 
			
		||||
					<Link
 | 
			
		||||
						variant="h5"
 | 
			
		||||
						noWrap
 | 
			
		||||
						sx={{
 | 
			
		||||
							display: "none",
 | 
			
		||||
							[theme.breakpoints.up("sm")]: {
 | 
			
		||||
								display: "block",
 | 
			
		||||
							},
 | 
			
		||||
						}}
 | 
			
		||||
						color="inherit"
 | 
			
		||||
						component={RouterLink}
 | 
			
		||||
						to="/"
 | 
			
		||||
					>
 | 
			
		||||
						Ionian
 | 
			
		||||
					</Link>
 | 
			
		||||
					<div style={{ flexGrow: 1 }}></div>
 | 
			
		||||
					{prop.rightAppbar}
 | 
			
		||||
					<StyledSearchBar>
 | 
			
		||||
						<div
 | 
			
		||||
							style={{
 | 
			
		||||
								padding: theme.spacing(0, 2),
 | 
			
		||||
								height: "100%",
 | 
			
		||||
								position: "absolute",
 | 
			
		||||
								pointerEvents: "none",
 | 
			
		||||
								display: "flex",
 | 
			
		||||
								alignItems: "center",
 | 
			
		||||
								justifyContent: "center",
 | 
			
		||||
							}}
 | 
			
		||||
						>
 | 
			
		||||
							<SearchIcon onClick={() => navSearch(search)} />
 | 
			
		||||
						</div>
 | 
			
		||||
						<StyledInputBase
 | 
			
		||||
							placeholder="search"
 | 
			
		||||
							onChange={(e) => setSearch(e.target.value)}
 | 
			
		||||
							onKeyUp={(e) => {
 | 
			
		||||
								if (e.key === "Enter") {
 | 
			
		||||
									navSearch(search);
 | 
			
		||||
								}
 | 
			
		||||
							}}
 | 
			
		||||
							value={search}
 | 
			
		||||
						/>
 | 
			
		||||
					</StyledSearchBar>
 | 
			
		||||
					{isLogin ? (
 | 
			
		||||
						<IconButton
 | 
			
		||||
							edge="end"
 | 
			
		||||
							aria-label="account of current user"
 | 
			
		||||
							aria-controls={menuId}
 | 
			
		||||
							aria-haspopup="true"
 | 
			
		||||
							onClick={handleProfileMenuOpen}
 | 
			
		||||
							color="inherit"
 | 
			
		||||
						>
 | 
			
		||||
							<AccountCircle />
 | 
			
		||||
						</IconButton>
 | 
			
		||||
					) : (
 | 
			
		||||
						<Button color="inherit" component={RouterLink} to="/login">
 | 
			
		||||
							Login
 | 
			
		||||
						</Button>
 | 
			
		||||
					)}
 | 
			
		||||
				</Toolbar>
 | 
			
		||||
			</AppBar>
 | 
			
		||||
			{renderProfileMenu}
 | 
			
		||||
			<StyledNav>
 | 
			
		||||
				<Hidden smUp implementation="css">
 | 
			
		||||
					<StyledDrawer
 | 
			
		||||
						variant="temporary"
 | 
			
		||||
						anchor="left"
 | 
			
		||||
						open={v}
 | 
			
		||||
						onClose={toggleV}
 | 
			
		||||
						sx={{
 | 
			
		||||
							width: drawerWidth,
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
						{drawer_contents}
 | 
			
		||||
					</StyledDrawer>
 | 
			
		||||
				</Hidden>
 | 
			
		||||
				<Hidden smDown implementation="css">
 | 
			
		||||
					<StyledDrawer
 | 
			
		||||
						variant="permanent"
 | 
			
		||||
						anchor="left"
 | 
			
		||||
						sx={{
 | 
			
		||||
							...closedMixin(theme),
 | 
			
		||||
							"& .MuiDrawer-paper": closedMixin(theme),
 | 
			
		||||
						}}
 | 
			
		||||
					>
 | 
			
		||||
						{drawer_contents}
 | 
			
		||||
					</StyledDrawer>
 | 
			
		||||
				</Hidden>
 | 
			
		||||
			</StyledNav>
 | 
			
		||||
			<main
 | 
			
		||||
				style={{
 | 
			
		||||
					display: "flex",
 | 
			
		||||
					flexFlow: "column",
 | 
			
		||||
					flexGrow: 1,
 | 
			
		||||
					padding: "0px",
 | 
			
		||||
					marginTop: "64px",
 | 
			
		||||
				}}
 | 
			
		||||
			>
 | 
			
		||||
				{prop.children}
 | 
			
		||||
			</main>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
	function navSearch(search: string) {
 | 
			
		||||
		let words = search.includes("&") ? search.split("&") : [search];
 | 
			
		||||
		words = words
 | 
			
		||||
			.map((w) => w.trim())
 | 
			
		||||
			.map((w) => (w.includes(":") ? `allow_tag=${w}` : `word=${encodeURIComponent(w)}`));
 | 
			
		||||
		navigate(`/search?${words.join("&")}`);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Headline;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +0,0 @@
 | 
			
		|||
import { Box, CircularProgress } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
export const LoadingCircle = () => {
 | 
			
		||||
	return (
 | 
			
		||||
		<Box style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
 | 
			
		||||
			<CircularProgress title="loading" />
 | 
			
		||||
		</Box>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,54 +0,0 @@
 | 
			
		|||
import {
 | 
			
		||||
	ArrowBack as ArrowBackIcon,
 | 
			
		||||
	Collections as CollectionIcon,
 | 
			
		||||
	Folder as FolderIcon,
 | 
			
		||||
	Home as HomeIcon,
 | 
			
		||||
	List as ListIcon,
 | 
			
		||||
	Settings as SettingIcon,
 | 
			
		||||
	VideoLibrary as VideoIcon,
 | 
			
		||||
} from "@mui/icons-material";
 | 
			
		||||
import { Divider, List, ListItem, ListItemIcon, ListItemText, Tooltip } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Link as RouterLink } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
export const NavItem = (props: { name: string; to: string; icon: React.ReactElement<any, any> }) => {
 | 
			
		||||
	return (
 | 
			
		||||
		<ListItem button key={props.name} component={RouterLink} to={props.to}>
 | 
			
		||||
			<ListItemIcon>
 | 
			
		||||
				<Tooltip title={props.name.toLocaleLowerCase()} placement="bottom">
 | 
			
		||||
					{props.icon}
 | 
			
		||||
				</Tooltip>
 | 
			
		||||
			</ListItemIcon>
 | 
			
		||||
			<ListItemText primary={props.name}></ListItemText>
 | 
			
		||||
		</ListItem>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const NavList = (props: { children?: React.ReactNode }) => {
 | 
			
		||||
	return <List>{props.children}</List>;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const BackItem = (props: { to?: string }) => {
 | 
			
		||||
	return <NavItem name="Back" to={props.to ?? "/"} icon={<ArrowBackIcon></ArrowBackIcon>} />;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function CommonMenuList(props?: { url?: string }) {
 | 
			
		||||
	let url = props?.url ?? "";
 | 
			
		||||
	return (
 | 
			
		||||
		<NavList>
 | 
			
		||||
			{url !== "" && (
 | 
			
		||||
				<>
 | 
			
		||||
					<BackItem to={url} /> <Divider />
 | 
			
		||||
				</>
 | 
			
		||||
			)}
 | 
			
		||||
			<NavItem name="All" to="/" icon={<HomeIcon />} />
 | 
			
		||||
			<NavItem name="Comic" to="/search?content_type=comic" icon={<CollectionIcon />}></NavItem>
 | 
			
		||||
			<NavItem name="Video" to="/search?content_type=video" icon={<VideoIcon />} />
 | 
			
		||||
			<Divider />
 | 
			
		||||
			<NavItem name="Tags" to="/tags" icon={<ListIcon />} />
 | 
			
		||||
			<Divider />
 | 
			
		||||
			<NavItem name="Difference" to="/difference" icon={<FolderIcon />}></NavItem>
 | 
			
		||||
			<NavItem name="Settings" to="/setting" icon={<SettingIcon />} />
 | 
			
		||||
		</NavList>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +0,0 @@
 | 
			
		|||
import { styled } from "@mui/material";
 | 
			
		||||
 | 
			
		||||
export const PagePad = styled("div")(({ theme }) => ({
 | 
			
		||||
	padding: theme.spacing(3),
 | 
			
		||||
}));
 | 
			
		||||
| 
						 | 
				
			
			@ -1,80 +0,0 @@
 | 
			
		|||
import * as colors from "@mui/material/colors";
 | 
			
		||||
import Chip, { ChipTypeMap } from "@mui/material/Chip";
 | 
			
		||||
import { emphasize, styled, Theme, useTheme } from "@mui/material/styles";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Link as RouterLink } from "react-router-dom";
 | 
			
		||||
 | 
			
		||||
type TagChipStyleProp = {
 | 
			
		||||
	color: `rgba(${number},${number},${number},${number})` | `#${string}` | "default";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const { blue, pink } = colors;
 | 
			
		||||
const getTagColorName = (tagname: string): TagChipStyleProp["color"] => {
 | 
			
		||||
	if (tagname.startsWith("female")) {
 | 
			
		||||
		return pink[600];
 | 
			
		||||
	} else if (tagname.startsWith("male")) {
 | 
			
		||||
		return blue[600];
 | 
			
		||||
	} else return "default";
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ColorChipProp = Omit<ChipTypeMap["props"], "color"> &
 | 
			
		||||
	TagChipStyleProp & {
 | 
			
		||||
		component?: React.ElementType;
 | 
			
		||||
		to?: string;
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
export const ColorChip = (props: ColorChipProp) => {
 | 
			
		||||
	const { color, ...rest } = props;
 | 
			
		||||
	const theme = useTheme();
 | 
			
		||||
 | 
			
		||||
	let newcolor = color;
 | 
			
		||||
	if (color === "default") {
 | 
			
		||||
		newcolor = "#ebebeb";
 | 
			
		||||
	}
 | 
			
		||||
	return (
 | 
			
		||||
		<Chip
 | 
			
		||||
			sx={{
 | 
			
		||||
				color: theme.palette.getContrastText(newcolor),
 | 
			
		||||
				backgroundColor: newcolor,
 | 
			
		||||
				["&:hover, &:focus"]: {
 | 
			
		||||
					backgroundColor: emphasize(newcolor, 0.08),
 | 
			
		||||
				},
 | 
			
		||||
			}}
 | 
			
		||||
			{...rest}
 | 
			
		||||
		></Chip>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type TagChipProp = Omit<ChipTypeMap["props"], "color"> & {
 | 
			
		||||
	tagname: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const TagChip = (props: TagChipProp) => {
 | 
			
		||||
	const { tagname, label, clickable, ...rest } = props;
 | 
			
		||||
	const colorName = getTagColorName(tagname);
 | 
			
		||||
 | 
			
		||||
	let newlabel: React.ReactNode = label;
 | 
			
		||||
	if (typeof label === "string") {
 | 
			
		||||
		const female = "female:";
 | 
			
		||||
		const male = "male:";
 | 
			
		||||
		if (label.startsWith(female)) {
 | 
			
		||||
			newlabel = "♀ " + label.slice(female.length);
 | 
			
		||||
		} else if (label.startsWith(male)) {
 | 
			
		||||
			newlabel = "♂ " + label.slice(male.length);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const inner = clickable ? (
 | 
			
		||||
		<ColorChip
 | 
			
		||||
			color={colorName}
 | 
			
		||||
			clickable={clickable}
 | 
			
		||||
			label={newlabel ?? label}
 | 
			
		||||
			{...rest}
 | 
			
		||||
			component={RouterLink}
 | 
			
		||||
			to={`/search?allow_tag=${tagname}`}
 | 
			
		||||
		/>
 | 
			
		||||
	) : (
 | 
			
		||||
		<ColorChip color={colorName} clickable={clickable} label={newlabel ?? label} {...rest} />
 | 
			
		||||
	);
 | 
			
		||||
	return inner;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -15,6 +15,43 @@ function clipTagsWhenOverflow(tags: string[], limit: number) {
 | 
			
		|||
    return tags;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function LazyImage({ src, alt, className }: { src: string; alt: string; className?: string; }) {
 | 
			
		||||
    const ref = useRef<HTMLImageElement>(null);
 | 
			
		||||
    const [loaded, setLoaded] = useState(false);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (ref.current) {
 | 
			
		||||
            let toggle = false;
 | 
			
		||||
            const observer = new IntersectionObserver((entries) => {
 | 
			
		||||
                if (entries.some(x => x.isIntersecting)) {
 | 
			
		||||
                    setLoaded(true);
 | 
			
		||||
                    toggle = !toggle;
 | 
			
		||||
                    ref.current?.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 500, easing: "ease-in-out" });
 | 
			
		||||
                    observer.disconnect();
 | 
			
		||||
                }
 | 
			
		||||
                else {
 | 
			
		||||
                    if (toggle) {
 | 
			
		||||
                        console.log("fade out");
 | 
			
		||||
                        ref.current?.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 500, easing: "ease-in-out" });
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            observer.observe(ref.current);
 | 
			
		||||
            return () => {
 | 
			
		||||
                observer.disconnect();
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    return <img
 | 
			
		||||
        ref={ref}
 | 
			
		||||
        src={loaded ? src : undefined}
 | 
			
		||||
        alt={alt}
 | 
			
		||||
        className={className}
 | 
			
		||||
        loading="lazy"
 | 
			
		||||
        />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function GalleryCard({
 | 
			
		||||
    doc: x
 | 
			
		||||
}: { doc: Document; }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -42,11 +79,12 @@ export function GalleryCard({
 | 
			
		|||
        };
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    return <Card key={x.id} className="flex h-[200px]">
 | 
			
		||||
    return <Card className="flex h-[200px]">
 | 
			
		||||
        <div className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none bg-[#272733] flex items-center justify-center">
 | 
			
		||||
            <img src={`/api/doc/${x.id}/comic/thumbnail`}
 | 
			
		||||
            <LazyImage src={`/api/doc/${x.id}/comic/thumbnail`}
 | 
			
		||||
                alt={x.title}
 | 
			
		||||
                className="max-h-full max-w-full object-cover object-center" />
 | 
			
		||||
                className="max-h-full max-w-full object-cover object-center"
 | 
			
		||||
                />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="flex-1 flex flex-col">
 | 
			
		||||
            <CardHeader className="flex-none">
 | 
			
		||||
| 
						 | 
				
			
			@ -56,10 +94,10 @@ export function GalleryCard({
 | 
			
		|||
                </CardDescription>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent className="flex-1" ref={ref}>
 | 
			
		||||
                <li className="flex flex-wrap gap-2 items-baseline content-start">
 | 
			
		||||
                <ul className="flex flex-wrap gap-2 items-baseline content-start">
 | 
			
		||||
                    {clippedTags.map(tag => <TagBadge key={tag} tagname={tag} className="" />)}
 | 
			
		||||
                    {clippedTags.length < originalTags.length && <TagBadge tagname="..." className="" />}
 | 
			
		||||
                </li>
 | 
			
		||||
                    {clippedTags.length < originalTags.length && <TagBadge tagname="..." className="" disabled />}
 | 
			
		||||
                </ul>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
        </div>
 | 
			
		||||
    </Card>;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
import { Badge, badgeVariants } from "@/components/ui/badge";
 | 
			
		||||
import { badgeVariants } from "@/components/ui/badge";
 | 
			
		||||
import { Link } from "wouter";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const femaleTagPrefix = "female:";
 | 
			
		||||
| 
						 | 
				
			
			@ -26,7 +27,7 @@ function toPrettyTagname(tagname: string): string {
 | 
			
		|||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function TagBadge(props: { tagname: string, className?: string}) {
 | 
			
		||||
export default function TagBadge(props: { tagname: string, className?: string; disabled?: boolean;}) {
 | 
			
		||||
    const { tagname } = props;
 | 
			
		||||
    const kind = getTagKind(tagname);
 | 
			
		||||
    return <li className={
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +36,8 @@ export default function TagBadge(props: { tagname: string, className?: string})
 | 
			
		|||
            kind === "male" && "bg-[#2a7bbf] hover:bg-[#3091e7]",
 | 
			
		||||
            kind === "female" && "bg-[#c21f58] hover:bg-[#db2d67]",
 | 
			
		||||
            kind === "default" && "bg-[#4a5568] hover:bg-[#718096]",
 | 
			
		||||
            props.disabled && "opacity-50",
 | 
			
		||||
            props.className,
 | 
			
		||||
        )
 | 
			
		||||
    }>{toPrettyTagname(tagname)}</li>;
 | 
			
		||||
    }><Link to={props.disabled ? '': `/search?allow_tag=${tagname}`}>{toPrettyTagname(tagname)}</Link></li>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
 | 
			
		|||
import { Link } from "wouter"
 | 
			
		||||
import { MagnifyingGlassIcon, GearIcon, ActivityLogIcon, ArchiveIcon, PersonIcon } from "@radix-ui/react-icons"
 | 
			
		||||
import { buttonVariants } from "../ui/button"
 | 
			
		||||
import { useLogin } from "@/state/user";
 | 
			
		||||
 | 
			
		||||
interface NavItemProps {
 | 
			
		||||
    icon: React.ReactNode;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +30,8 @@ export function NavItem({
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export function NavList() {
 | 
			
		||||
    const loginInfo = useLogin();
 | 
			
		||||
 | 
			
		||||
    return <aside className="h-screen flex flex-col">
 | 
			
		||||
        <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
 | 
			
		||||
            <NavItem icon={<MagnifyingGlassIcon className="h-5 w-5" />} to="/search" name="Search" />
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +39,7 @@ export function NavList() {
 | 
			
		|||
            <NavItem icon={<ArchiveIcon className="h-5 w-5" />} to="/difference" name="Difference" />
 | 
			
		||||
        </nav>
 | 
			
		||||
        <nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
 | 
			
		||||
            <NavItem icon={<PersonIcon className="h-5 w-5" />} to="/profile" name="Profiles" />
 | 
			
		||||
            <NavItem icon={<PersonIcon className="h-5 w-5" />} to={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} />
 | 
			
		||||
            <NavItem icon={<GearIcon className="h-5 w-5" />} to="/setting" name="Settings" />
 | 
			
		||||
        </nav>
 | 
			
		||||
    </aside>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										24
									
								
								packages/client/src/components/ui/label.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								packages/client/src/components/ui/label.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
import * as LabelPrimitive from "@radix-ui/react-label"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const labelVariants = cva(
 | 
			
		||||
  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const Label = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof LabelPrimitive.Root>,
 | 
			
		||||
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
 | 
			
		||||
    VariantProps<typeof labelVariants>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <LabelPrimitive.Root
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(labelVariants(), className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
Label.displayName = LabelPrimitive.Root.displayName
 | 
			
		||||
 | 
			
		||||
export { Label }
 | 
			
		||||
| 
						 | 
				
			
			@ -54,7 +54,7 @@ export default function Gallery() {
 | 
			
		|||
        return <div className="p-4">Error: {String(error)}</div>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (<div className="p-4 grid gap-2 overflow-auto h-screen">
 | 
			
		||||
    return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start">
 | 
			
		||||
        <div className="flex space-x-2">
 | 
			
		||||
            <Input className="flex-1"/>
 | 
			
		||||
            <Button className="flex-none">Search</Button>
 | 
			
		||||
| 
						 | 
				
			
			@ -66,12 +66,12 @@ export default function Gallery() {
 | 
			
		|||
            </div>
 | 
			
		||||
        }
 | 
			
		||||
        {
 | 
			
		||||
            data?.length === 0 && <div className="p-4">No results</div>
 | 
			
		||||
            data?.length === 0 && <div className="p-4 text-3xl">No results</div>
 | 
			
		||||
        }
 | 
			
		||||
        {
 | 
			
		||||
            data?.map((x) => {
 | 
			
		||||
                return (
 | 
			
		||||
                    <GalleryCard doc={x} />
 | 
			
		||||
                    <GalleryCard doc={x} key={x.id} />
 | 
			
		||||
                );
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										58
									
								
								packages/client/src/page/loginPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								packages/client/src/page/loginPage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,58 @@
 | 
			
		|||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { doLogin } from "@/state/user";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useLocation } from "wouter";
 | 
			
		||||
 | 
			
		||||
export function LoginForm() {
 | 
			
		||||
    const [username, setUsername] = useState("");
 | 
			
		||||
    const [password, setPassword] = useState("");
 | 
			
		||||
    const [, setLocation] = useLocation();
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Card className="w-full max-w-sm">
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle className="text-2xl">Login</CardTitle>
 | 
			
		||||
          <CardDescription>
 | 
			
		||||
            Enter your email below to login to your account.
 | 
			
		||||
          </CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent className="grid gap-4">
 | 
			
		||||
          <div className="grid gap-2">
 | 
			
		||||
            <Label htmlFor="username">Username</Label>
 | 
			
		||||
            <Input id="username" type="text" placeholder="username" required value={username} onChange={e=> setUsername(e.target.value)}/>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="grid gap-2">
 | 
			
		||||
            <Label htmlFor="password">Password</Label>
 | 
			
		||||
            <Input id="password" type="password" required value={password} onChange={e=> setPassword(e.target.value)}/>
 | 
			
		||||
          </div>
 | 
			
		||||
        </CardContent>
 | 
			
		||||
        <CardFooter>
 | 
			
		||||
          <Button className="w-full" onClick={()=>{
 | 
			
		||||
                doLogin({
 | 
			
		||||
                    username,
 | 
			
		||||
                    password,
 | 
			
		||||
                }).then((r)=>{
 | 
			
		||||
                    if (typeof r === "string") {
 | 
			
		||||
                        alert(r);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        setLocation("/");
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
          }}>Sign in</Button>
 | 
			
		||||
        </CardFooter>
 | 
			
		||||
      </Card>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LoginPage() {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex items-center justify-center h-screen">
 | 
			
		||||
        <LoginForm />
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default LoginPage;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +0,0 @@
 | 
			
		|||
export * from "./404";
 | 
			
		||||
export * from "./contentinfo";
 | 
			
		||||
export * from "./difference";
 | 
			
		||||
export * from "./gallery";
 | 
			
		||||
export * from "./login";
 | 
			
		||||
export * from "./profile";
 | 
			
		||||
export * from "./setting";
 | 
			
		||||
export * from "./tags";
 | 
			
		||||
							
								
								
									
										32
									
								
								packages/client/src/page/profilesPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								packages/client/src/page/profilesPage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { useLogin } from "@/state/user";
 | 
			
		||||
import { Redirect } from "wouter";
 | 
			
		||||
 | 
			
		||||
export function ProfilePage() {
 | 
			
		||||
    const userInfo = useLogin();
 | 
			
		||||
    if (!userInfo) {
 | 
			
		||||
        console.error("User session expired. Redirecting to login page.");
 | 
			
		||||
        return <Redirect to="/login" />;
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
        <div className="m-4">
 | 
			
		||||
            <Card>
 | 
			
		||||
                <CardHeader>
 | 
			
		||||
                    <CardTitle className="text-2xl">Profile</CardTitle>
 | 
			
		||||
                </CardHeader>
 | 
			
		||||
                <CardContent>
 | 
			
		||||
                    <div className="grid">
 | 
			
		||||
                        <span className="text-muted-foreground text-sm">Username</span>
 | 
			
		||||
                        <span className="text-primary text-lg">{userInfo.username}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="grid">
 | 
			
		||||
                        <span className="text-muted-foreground text-sm">Permission</span>
 | 
			
		||||
                        <span className="text-primary text-lg">{userInfo.permission.length > 1 ? userInfo.permission.join(",") : "N/A"}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </CardContent>
 | 
			
		||||
            </Card>
 | 
			
		||||
        </div>
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ProfilePage;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,94 +0,0 @@
 | 
			
		|||
import React, { createContext, useRef, useState } from "react";
 | 
			
		||||
 | 
			
		||||
export const UserContext = createContext({
 | 
			
		||||
	username: "",
 | 
			
		||||
	permission: [] as string[],
 | 
			
		||||
	setUsername: (s: string) => {},
 | 
			
		||||
	setPermission: (permission: string[]) => {},
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
type LoginLocalStorage = {
 | 
			
		||||
	username: string;
 | 
			
		||||
	permission: string[];
 | 
			
		||||
	accessExpired: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
let localObj: LoginLocalStorage | null = null;
 | 
			
		||||
 | 
			
		||||
export const getInitialValue = async () => {
 | 
			
		||||
	if (localObj === null) {
 | 
			
		||||
		const storagestr = window.localStorage.getItem("UserLoginContext") as string | null;
 | 
			
		||||
		const storage = storagestr !== null ? (JSON.parse(storagestr) as LoginLocalStorage | null) : null;
 | 
			
		||||
		localObj = storage;
 | 
			
		||||
	}
 | 
			
		||||
	if (localObj !== null && localObj.accessExpired > Math.floor(Date.now() / 1000)) {
 | 
			
		||||
		return {
 | 
			
		||||
			username: localObj.username,
 | 
			
		||||
			permission: localObj.permission,
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
	const res = await fetch("/user/refresh", {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
	});
 | 
			
		||||
	if (res.status !== 200) throw new Error("Maybe Network Error");
 | 
			
		||||
	const r = (await res.json()) as LoginLocalStorage & { refresh: boolean };
 | 
			
		||||
	if (r.refresh) {
 | 
			
		||||
		localObj = {
 | 
			
		||||
			username: r.username,
 | 
			
		||||
			permission: r.permission,
 | 
			
		||||
			accessExpired: r.accessExpired,
 | 
			
		||||
		};
 | 
			
		||||
	} else {
 | 
			
		||||
		localObj = {
 | 
			
		||||
			accessExpired: 0,
 | 
			
		||||
			username: "",
 | 
			
		||||
			permission: r.permission,
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
	window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
 | 
			
		||||
	return {
 | 
			
		||||
		username: r.username,
 | 
			
		||||
		permission: r.permission,
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
export const doLogout = async () => {
 | 
			
		||||
	const req = await fetch("/user/logout", {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
	});
 | 
			
		||||
	try {
 | 
			
		||||
		const res = await req.json();
 | 
			
		||||
		localObj = {
 | 
			
		||||
			accessExpired: 0,
 | 
			
		||||
			username: "",
 | 
			
		||||
			permission: res["permission"],
 | 
			
		||||
		};
 | 
			
		||||
		window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
 | 
			
		||||
		return {
 | 
			
		||||
			username: localObj.username,
 | 
			
		||||
			permission: localObj.permission,
 | 
			
		||||
		};
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(`Server Error ${error}`);
 | 
			
		||||
		return {
 | 
			
		||||
			username: "",
 | 
			
		||||
			permission: [],
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
export const doLogin = async (userLoginInfo: {
 | 
			
		||||
	username: string;
 | 
			
		||||
	password: string;
 | 
			
		||||
}): Promise<string | LoginLocalStorage> => {
 | 
			
		||||
	const res = await fetch("/user/login", {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
		body: JSON.stringify(userLoginInfo),
 | 
			
		||||
		headers: { "content-type": "application/json" },
 | 
			
		||||
	});
 | 
			
		||||
	const b = await res.json();
 | 
			
		||||
	if (res.status !== 200) {
 | 
			
		||||
		return b.detail as string;
 | 
			
		||||
	}
 | 
			
		||||
	localObj = b;
 | 
			
		||||
	window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
 | 
			
		||||
	return b;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -47,7 +47,7 @@ async function refresh() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
export const doLogout = async () => {
 | 
			
		||||
	const req = await fetch("/user/logout", {
 | 
			
		||||
	const req = await fetch("/api/user/logout", {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
	});
 | 
			
		||||
    const setVal = setAtomValue(userLoginStateAtom);
 | 
			
		||||
| 
						 | 
				
			
			@ -76,7 +76,7 @@ export const doLogin = async (userLoginInfo: {
 | 
			
		|||
	username: string;
 | 
			
		||||
	password: string;
 | 
			
		||||
}): Promise<string | LoginLocalStorage> => {
 | 
			
		||||
	const res = await fetch("/user/login", {
 | 
			
		||||
	const res = await fetch("/api/user/login", {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
		body: JSON.stringify(userLoginInfo),
 | 
			
		||||
		headers: { "content-type": "application/json" },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,46 +10,67 @@ import { Readable, Writable } from "node:stream";
 | 
			
		|||
/**
 | 
			
		||||
 * zip stream cache.
 | 
			
		||||
 */
 | 
			
		||||
const ZipStreamCache: {
 | 
			
		||||
	[path: string]: [{
 | 
			
		||||
		reader: ZipReader<FileHandle>,
 | 
			
		||||
		handle: FileHandle
 | 
			
		||||
	}, number]
 | 
			
		||||
} = {};
 | 
			
		||||
const ZipStreamCache = new Map<string, {
 | 
			
		||||
	reader: ZipReader<FileHandle>,
 | 
			
		||||
	handle: FileHandle,
 | 
			
		||||
	refCount: number,
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
async function acquireZip(path: string) {
 | 
			
		||||
	if (!(path in ZipStreamCache)) {
 | 
			
		||||
 | 
			
		||||
function markUseZip(path: string) {
 | 
			
		||||
	const ret = ZipStreamCache.get(path);
 | 
			
		||||
	if (ret) {
 | 
			
		||||
		ret.refCount++;
 | 
			
		||||
	}
 | 
			
		||||
	return ret !== undefined;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function acquireZip(path: string, marked = false) {
 | 
			
		||||
	const ret = ZipStreamCache.get(path);
 | 
			
		||||
	if (!ret) {
 | 
			
		||||
		const obj = await readZip(path);
 | 
			
		||||
		ZipStreamCache[path] = [obj, 1];
 | 
			
		||||
		// console.log(`acquire ${path} 1`);
 | 
			
		||||
		const check = ZipStreamCache.get(path);
 | 
			
		||||
		if (check) {
 | 
			
		||||
			check.refCount++;
 | 
			
		||||
			// if the cache is updated, release the previous one.
 | 
			
		||||
			releaseZip(path);
 | 
			
		||||
			return check.reader;
 | 
			
		||||
		}
 | 
			
		||||
		// if the cache is not updated, set the new one.
 | 
			
		||||
		ZipStreamCache.set(path, {
 | 
			
		||||
			reader: obj.reader,
 | 
			
		||||
			handle: obj.handle,
 | 
			
		||||
			refCount: 1,
 | 
			
		||||
		});
 | 
			
		||||
		return obj.reader;
 | 
			
		||||
	}
 | 
			
		||||
	const [ret, refCount] = ZipStreamCache[path];
 | 
			
		||||
	ZipStreamCache[path] = [ret, refCount + 1];
 | 
			
		||||
	// console.log(`acquire ${path} ${refCount + 1}`);
 | 
			
		||||
	if (!marked) {
 | 
			
		||||
		ret.refCount++;
 | 
			
		||||
	}
 | 
			
		||||
	return ret.reader;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function releaseZip(path: string) {
 | 
			
		||||
	const obj = ZipStreamCache[path];
 | 
			
		||||
	if (obj === undefined) throw new Error("error! key invalid");
 | 
			
		||||
	const [ref, refCount] = obj;
 | 
			
		||||
	// console.log(`release ${path} : ${refCount}`);
 | 
			
		||||
	if (refCount === 1) {
 | 
			
		||||
		const { reader, handle } = ref;
 | 
			
		||||
	const obj = ZipStreamCache.get(path);
 | 
			
		||||
	if (obj === undefined) {
 | 
			
		||||
		console.warn(`warning! duplicate release at ${path}`);
 | 
			
		||||
		return;
 | 
			
		||||
	}
 | 
			
		||||
	if (obj.refCount === 1) {
 | 
			
		||||
		const { reader, handle } = obj;
 | 
			
		||||
		reader.close().then(() => {
 | 
			
		||||
			handle.close();
 | 
			
		||||
		});
 | 
			
		||||
		delete ZipStreamCache[path];
 | 
			
		||||
		ZipStreamCache.delete(path);
 | 
			
		||||
	} else {
 | 
			
		||||
		ZipStreamCache[path] = [ref, refCount - 1];
 | 
			
		||||
		obj.refCount--;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function renderZipImage(ctx: Context, path: string, page: number) {
 | 
			
		||||
	const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
 | 
			
		||||
	// console.log(`opened ${page}`);
 | 
			
		||||
	const zip = await acquireZip(path);
 | 
			
		||||
	const marked = markUseZip(path);
 | 
			
		||||
	const zip = await acquireZip(path, marked);
 | 
			
		||||
	const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
 | 
			
		||||
		const ext = x.filename.split(".").pop();
 | 
			
		||||
		return ext !== undefined && image_ext.includes(ext);
 | 
			
		||||
| 
						 | 
				
			
			@ -70,9 +91,9 @@ async function renderZipImage(ctx: Context, path: string, page: number) {
 | 
			
		|||
			},
 | 
			
		||||
			close() {
 | 
			
		||||
				nodeReadableStream.push(null);
 | 
			
		||||
				setTimeout(() => {
 | 
			
		||||
					releaseZip(path);
 | 
			
		||||
				}, 100);
 | 
			
		||||
				releaseZip(path);
 | 
			
		||||
				// setTimeout(() => {
 | 
			
		||||
				// }, 500);
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,8 +92,8 @@ class ServerApplication {
 | 
			
		|||
		this.serve_static_file(router);
 | 
			
		||||
 | 
			
		||||
		const login_router = createLoginRouter(this.userController);
 | 
			
		||||
		router.use("/user", login_router.routes());
 | 
			
		||||
		router.use("/user", login_router.allowedMethods());
 | 
			
		||||
		router.use("/api/user", login_router.routes());
 | 
			
		||||
		router.use("/api/user", login_router.allowedMethods());
 | 
			
		||||
 | 
			
		||||
		if (setting.mode === "development") {
 | 
			
		||||
			let mm_count = 0;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -34,10 +34,10 @@ export async function readZip(path: string): Promise<{
 | 
			
		|||
	reader: ZipReader<FileHandle>
 | 
			
		||||
	handle: FileHandle
 | 
			
		||||
}> {
 | 
			
		||||
	const fd = await open(path);
 | 
			
		||||
	const fd = await open(path, "r");
 | 
			
		||||
	const reader = new ZipReader(new FileReader(fd), {
 | 
			
		||||
		useCompressionStream: true,
 | 
			
		||||
		preventClose: false,
 | 
			
		||||
		preventClose: true,
 | 
			
		||||
	});
 | 
			
		||||
	return { reader, handle: fd };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										26
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -17,6 +17,9 @@ importers:
 | 
			
		|||
      '@radix-ui/react-icons':
 | 
			
		||||
        specifier: ^1.3.0
 | 
			
		||||
        version: 1.3.0(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-label':
 | 
			
		||||
        specifier: ^2.0.2
 | 
			
		||||
        version: 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-slot':
 | 
			
		||||
        specifier: ^1.0.2
 | 
			
		||||
        version: 1.0.2(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +33,7 @@ importers:
 | 
			
		|||
        specifier: ^2.1.0
 | 
			
		||||
        version: 2.1.0
 | 
			
		||||
      dbtype:
 | 
			
		||||
        specifier: link:..\dbtype
 | 
			
		||||
        specifier: workspace:*
 | 
			
		||||
        version: link:../dbtype
 | 
			
		||||
      react:
 | 
			
		||||
        specifier: ^18.2.0
 | 
			
		||||
| 
						 | 
				
			
			@ -1060,6 +1063,27 @@ packages:
 | 
			
		|||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-label@2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      '@types/react-dom': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
      react-dom: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
      '@types/react-dom':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      '@types/react-dom': 18.2.22
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue