Compare commits
	
		
			10 commits
		
	
	
		
			7704389d17
			...
			a9e646dd81
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a9e646dd81 | |||
| 4cf1381faa | |||
| ef77706c56 | |||
| 2df64ac2fe | |||
| 3813dbfeff | |||
| 37d49069e5 | |||
| 88e81853e6 | |||
| 23922ed100 | |||
| 62ec80565e | |||
| bbcda35303 | 
					 69 changed files with 3390 additions and 2080 deletions
				
			
		
							
								
								
									
										17
									
								
								packages/client/components.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								packages/client/components.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| { | ||||
|   "$schema": "https://ui.shadcn.com/schema.json", | ||||
|   "style": "new-york", | ||||
|   "rsc": false, | ||||
|   "tsx": true, | ||||
|   "tailwind": { | ||||
|     "config": "tailwind.config.js", | ||||
|     "css": "src/index.css", | ||||
|     "baseColor": "neutral", | ||||
|     "cssVariables": true, | ||||
|     "prefix": "" | ||||
|   }, | ||||
|   "aliases": { | ||||
|     "components": "@/components", | ||||
|     "utils": "@/lib/utils" | ||||
|   } | ||||
| } | ||||
|  | @ -7,13 +7,30 @@ | |||
|     "dev": "vite", | ||||
|     "build": "tsc && vite build", | ||||
|     "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", | ||||
|     "preview": "vite preview" | ||||
|     "preview": "vite preview", | ||||
|     "shadcn": "shadcn-ui" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@radix-ui/react-icons": "^1.3.0", | ||||
|     "@radix-ui/react-label": "^2.0.2", | ||||
|     "@radix-ui/react-radio-group": "^1.1.3", | ||||
|     "@radix-ui/react-slot": "^1.0.2", | ||||
|     "@radix-ui/react-tooltip": "^1.0.7", | ||||
|     "class-variance-authority": "^0.7.0", | ||||
|     "clsx": "^2.1.0", | ||||
|     "dbtype": "workspace:*", | ||||
|     "jotai": "^2.7.2", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0" | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-resizable-panels": "^2.0.16", | ||||
|     "swr": "^2.2.5", | ||||
|     "tailwind-merge": "^2.2.2", | ||||
|     "tailwindcss-animate": "^1.0.7", | ||||
|     "usehooks-ts": "^3.1.0", | ||||
|     "wouter": "^3.1.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/node": ">=20.0.0", | ||||
|     "@types/react": "^18.2.66", | ||||
|     "@types/react-dom": "^18.2.22", | ||||
|     "@typescript-eslint/eslint-plugin": "^7.2.0", | ||||
|  | @ -24,6 +41,7 @@ | |||
|     "eslint-plugin-react-hooks": "^4.6.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.6", | ||||
|     "postcss": "^8.4.38", | ||||
|     "shadcn-ui": "^0.8.0", | ||||
|     "tailwindcss": "^3.4.3", | ||||
|     "typescript": "^5.2.2", | ||||
|     "vite": "^5.2.0" | ||||
|  |  | |||
|  | @ -0,0 +1,13 @@ | |||
| @import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&display=swap'); | ||||
| 
 | ||||
| body { | ||||
|     margin: 0; | ||||
|     font-family: "Noto Sans KR", sans-serif; | ||||
|     font-optical-sizing: auto; | ||||
|     min-height: 100vh; | ||||
| } | ||||
| 
 | ||||
| #root { | ||||
|     margin: 0; | ||||
|     min-height: 100vh; | ||||
| } | ||||
|  | @ -1,59 +1,50 @@ | |||
| import { Route, Switch, Redirect } from "wouter"; | ||||
| import { useTernaryDarkMode } from "usehooks-ts"; | ||||
| import { useEffect } from "react"; | ||||
| 
 | ||||
| import './App.css' | ||||
| // import React, { createContext, useEffect, useRef, useState } from "react";
 | ||||
| // import ReactDom from "react-dom";
 | ||||
| // import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
 | ||||
| // import {
 | ||||
| // 	DifferencePage,
 | ||||
| // 	DocumentAbout,
 | ||||
| // 	Gallery,
 | ||||
| // 	LoginPage,
 | ||||
| // 	NotFoundPage,
 | ||||
| // 	ProfilePage,
 | ||||
| // 	ReaderPage,
 | ||||
| // 	SettingPage,
 | ||||
| // 	TagsPage,
 | ||||
| // } from "./page/mod";
 | ||||
| // import { getInitialValue, UserContext } from "./state";
 | ||||
| 
 | ||||
| import { TooltipProvider } from "./components/ui/tooltip.tsx"; | ||||
| import Layout from "./components/layout/layout.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 = () => { | ||||
| 	// const [user, setUser] = useState("");
 | ||||
| 	// const [userPermission, setUserPermission] = useState<string[]>([]);
 | ||||
| 	// (async () => {
 | ||||
| 	// 	const { username, permission } = await getInitialValue();
 | ||||
| 	// 	if (username !== user) {
 | ||||
| 	// 		setUser(username);
 | ||||
| 	// 		setUserPermission(permission);
 | ||||
| 	// 	}
 | ||||
| 	// })();
 | ||||
| 	// useEffect(()=>{});
 | ||||
| 	return ( <h1 className="text-3xl font-bold underline"> | ||||
| 	Hello world! | ||||
|   </h1> | ||||
| 		// <UserContext.Provider
 | ||||
| 		// 	value={{
 | ||||
| 		// 		username: user,
 | ||||
| 		// 		setUsername: setUser,
 | ||||
| 		// 		permission: userPermission,
 | ||||
| 		// 		setPermission: setUserPermission,
 | ||||
| 		// 	}}
 | ||||
| 		// >
 | ||||
| 		// 		<BrowserRouter>
 | ||||
| 		// 			<Routes>
 | ||||
| 		// 				<Route path="/" element={<Navigate replace to="/search?" />} />
 | ||||
| 		// 				<Route path="/search" element={<Gallery />} />
 | ||||
| 		// 				<Route path="/doc/:id" element={<DocumentAbout />}></Route>
 | ||||
| 		// 				<Route path="/doc/:id/reader" element={<ReaderPage />}></Route>
 | ||||
| 		// 				<Route path="/login" element={<LoginPage></LoginPage>} />
 | ||||
| 		// 				<Route path="/profile" element={<ProfilePage />}></Route>
 | ||||
| 		// 				<Route path="/difference" element={<DifferencePage />}></Route>
 | ||||
| 		// 				<Route path="/setting" element={<SettingPage />}></Route>
 | ||||
| 		// 				<Route path="/tags" element={<TagsPage />}></Route>
 | ||||
| 		// 				<Route path="*" element={<NotFoundPage />} />
 | ||||
| 		// 			</Routes>
 | ||||
| 		// 		</BrowserRouter>
 | ||||
| 		// </UserContext.Provider>
 | ||||
| 	); | ||||
| 	const { isDarkMode } = useTernaryDarkMode(); | ||||
| 	 | ||||
|     useEffect(() => { | ||||
|         if (isDarkMode) { | ||||
|             document.body.classList.add("dark"); | ||||
|         } | ||||
|         else { | ||||
|             document.body.classList.remove("dark"); | ||||
|         } | ||||
|     }, [isDarkMode]); | ||||
| 
 | ||||
| 	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={ContentInfoPage}/> | ||||
| 					<Route path="/setting" component={SettingPage} /> | ||||
| 					<Route path="/doc/:id/reader" component={ComicPage}/> | ||||
| 					{/*  | ||||
| 						<Route path="/difference" component={<DifferencePage />}/> | ||||
| 					*/} | ||||
| 					<Route component={NotFoundPage} /> | ||||
| 				</Switch> | ||||
| 			</Layout> | ||||
| 		</TooltipProvider>); | ||||
| }; | ||||
| 
 | ||||
| export default App | ||||
|  |  | |||
|  | @ -1,99 +0,0 @@ | |||
| import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../../model/doc"; | ||||
| import { toQueryString } from "./util"; | ||||
| const baseurl = "/api/doc"; | ||||
| 
 | ||||
| export * from "../../model/doc"; | ||||
| 
 | ||||
| export class FetchFailError extends Error {} | ||||
| 
 | ||||
| export class ClientDocumentAccessor implements DocumentAccessor { | ||||
| 	search: (search_word: string) => Promise<Document[]>; | ||||
| 	addList: (content_list: DocumentBody[]) => Promise<number[]>; | ||||
| 	async findByPath(basepath: string, filename?: string): Promise<Document[]> { | ||||
| 		throw new Error("not allowed"); | ||||
| 	} | ||||
| 	async findDeleted(content_type: string): Promise<Document[]> { | ||||
| 		throw new Error("not allowed"); | ||||
| 	} | ||||
| 	async findList(option?: QueryListOption | undefined): Promise<Document[]> { | ||||
| 		let res = await fetch(`${baseurl}/search?${option !== undefined ? toQueryString(option) : ""}`); | ||||
| 		if (res.status == 401) throw new FetchFailError("Unauthorized"); | ||||
| 		if (res.status !== 200) throw new FetchFailError("findList Failed"); | ||||
| 		let ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined> { | ||||
| 		let res = await fetch(`${baseurl}/${id}`); | ||||
| 		if (res.status !== 200) throw new FetchFailError("findById Failed"); | ||||
| 		let ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	/** | ||||
| 	 * not implement | ||||
| 	 */ | ||||
| 	async findListByBasePath(basepath: string): Promise<Document[]> { | ||||
| 		throw new Error("not implement"); | ||||
| 		return []; | ||||
| 	} | ||||
| 	async update(c: Partial<Document> & { id: number }): Promise<boolean> { | ||||
| 		const { id, ...rest } = c; | ||||
| 		const res = await fetch(`${baseurl}/${id}`, { | ||||
| 			method: "POST", | ||||
| 			body: JSON.stringify(rest), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async add(c: DocumentBody): Promise<number> { | ||||
| 		throw new Error("not allow"); | ||||
| 		const res = await fetch(`${baseurl}`, { | ||||
| 			method: "POST", | ||||
| 			body: JSON.stringify(c), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async del(id: number): Promise<boolean> { | ||||
| 		const res = await fetch(`${baseurl}/${id}`, { | ||||
| 			method: "DELETE", | ||||
| 		}); | ||||
| 		const ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async addTag(c: Document, tag_name: string): Promise<boolean> { | ||||
| 		const { id, ...rest } = c; | ||||
| 		const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, { | ||||
| 			method: "POST", | ||||
| 			body: JSON.stringify(rest), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async delTag(c: Document, tag_name: string): Promise<boolean> { | ||||
| 		const { id, ...rest } = c; | ||||
| 		const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, { | ||||
| 			method: "DELETE", | ||||
| 			body: JSON.stringify(rest), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const ret = await res.json(); | ||||
| 		return ret; | ||||
| 	} | ||||
| } | ||||
| export const CDocumentAccessor = new ClientDocumentAccessor(); | ||||
| export const makeThumbnailUrl = (x: Document) => { | ||||
| 	return `${baseurl}/${x.id}/${x.content_type}/thumbnail`; | ||||
| }; | ||||
| 
 | ||||
| export default CDocumentAccessor; | ||||
|  | @ -1,28 +0,0 @@ | |||
| type Representable = string | number | boolean; | ||||
| 
 | ||||
| type ToQueryStringA = { | ||||
| 	[name: string]: Representable | Representable[] | undefined; | ||||
| }; | ||||
| 
 | ||||
| export const toQueryString = (obj: ToQueryStringA) => { | ||||
| 	return Object.entries(obj) | ||||
| 		.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined) | ||||
| 		.map((e) => (e[1] instanceof Array ? e[1].map((f) => `${e[0]}=${f}`).join("&") : `${e[0]}=${e[1]}`)) | ||||
| 		.join("&"); | ||||
| }; | ||||
| export const QueryStringToMap = (query: string) => { | ||||
| 	const keyValue = query.slice(query.indexOf("?") + 1).split("&"); | ||||
| 	const param: { [k: string]: string | string[] } = {}; | ||||
| 	keyValue.forEach((p) => { | ||||
| 		const [k, v] = p.split("="); | ||||
| 		const pv = param[k]; | ||||
| 		if (pv === undefined) { | ||||
| 			param[k] = v; | ||||
| 		} else if (typeof pv === "string") { | ||||
| 			param[k] = [pv, v]; | ||||
| 		} else { | ||||
| 			pv.push(v); | ||||
| 		} | ||||
| 	}); | ||||
| 	return param; | ||||
| }; | ||||
|  | @ -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,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,5 +0,0 @@ | |||
| export * from "./contentinfo"; | ||||
| export * from "./headline"; | ||||
| export * from "./loading"; | ||||
| export * from "./navlist"; | ||||
| export * from "./tagchip"; | ||||
|  | @ -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; | ||||
| }; | ||||
							
								
								
									
										25
									
								
								packages/client/src/components/Spinner.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/client/src/components/Spinner.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import React from "react"; | ||||
| 
 | ||||
| export function Spinner(props: { className?: string; }) { | ||||
|     const chars = ["⠋", | ||||
|         "⠙", | ||||
|         "⠹", | ||||
|         "⠸", | ||||
|         "⠼", | ||||
|         "⠴", | ||||
|         "⠦", | ||||
|         "⠧", | ||||
|         "⠇", | ||||
|         "⠏" | ||||
|     ]; | ||||
|     const [index, setIndex] = React.useState(0); | ||||
|     React.useEffect(() => { | ||||
|         const interval = setInterval(() => { | ||||
|             setIndex((index + 1) % chars.length); | ||||
|         }, 80); | ||||
|         return () => clearInterval(interval); | ||||
|         // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     }, [index]); | ||||
| 
 | ||||
|     return <span className={props.className}>{chars[index]}</span>; | ||||
| } | ||||
							
								
								
									
										82
									
								
								packages/client/src/components/gallery/GalleryCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								packages/client/src/components/gallery/GalleryCard.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,82 @@ | |||
| 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, useEffect, useRef, useState } from "react"; | ||||
| import { LazyImage } from "./LazyImage.tsx"; | ||||
| import StyledLink from "./StyledLink.tsx"; | ||||
| 
 | ||||
| function clipTagsWhenOverflow(tags: string[], limit: number) { | ||||
|     let l = 0; | ||||
|     for (let i = 0; i < tags.length; i++) { | ||||
|         l += tags[i].length; | ||||
|         if (l > limit) { | ||||
|             return tags.slice(0, i); | ||||
|         } | ||||
|         l += 1; // for space
 | ||||
|     } | ||||
|     return tags; | ||||
| } | ||||
| 
 | ||||
| export function GalleryCard({ | ||||
|     doc: x | ||||
| }: { doc: Document; }) { | ||||
|     const ref = useRef<HTMLDivElement>(null); | ||||
|     const [clipCharCount, setClipCharCount] = useState(200); | ||||
| 
 | ||||
|     const artists = x.tags.filter(x => x.startsWith("artist:")).map(x => x.replace("artist:", "")); | ||||
|     const groups = x.tags.filter(x => x.startsWith("group:")).map(x => x.replace("group:", "")); | ||||
| 
 | ||||
|     const originalTags = x.tags.filter(x => !x.startsWith("artist:") && !x.startsWith("group:")); | ||||
|     const clippedTags = clipTagsWhenOverflow(originalTags, clipCharCount); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         const listener = () => { | ||||
|             if (ref.current) { | ||||
|                 const { width } = ref.current.getBoundingClientRect(); | ||||
|                 const charWidth = 8; // rough estimate
 | ||||
|                 const newClipCharCount = Math.floor(width / charWidth) * 3; | ||||
|                 setClipCharCount(newClipCharCount); | ||||
|             } | ||||
|         }; | ||||
|         window.addEventListener("resize", listener); | ||||
|         return () => { | ||||
|             window.removeEventListener("resize", listener); | ||||
|         }; | ||||
|     }, []); | ||||
|      | ||||
|     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"> | ||||
|             <LazyImage src={`/api/doc/${x.id}/comic/thumbnail`} | ||||
|                 alt={x.title} | ||||
|                 className="max-h-full max-w-full object-cover object-center" | ||||
|             /> | ||||
|         </div> | ||||
|         <div className="flex-1 flex flex-col"> | ||||
|             <CardHeader className="flex-none"> | ||||
|                 <CardTitle> | ||||
|                     <StyledLink className="" to={`/doc/${x.id}`}> | ||||
|                         {x.title} | ||||
|                     </StyledLink> | ||||
|                 </CardTitle> | ||||
|                 <CardDescription> | ||||
|                     {artists.map((x, i) => <Fragment key={`artist:${x}`}> | ||||
|                         <StyledLink to={`/search?allow_tag=artist:${x}`}>{x}</StyledLink> | ||||
|                         {i + 1 < artists.length && <span className="opacity-50">, </span>} | ||||
|                     </Fragment>)} | ||||
|                     {groups.length > 0 && <span key={"sep"}>{" | "}</span>} | ||||
|                     {groups.map((x, i) => <Fragment key={`group:${x}`}> | ||||
|                         <StyledLink to={`/search?allow_tag=group:${x}`}>{x}</StyledLink> | ||||
|                         {i + 1 < groups.length && <span className="opacity-50">, </span>} | ||||
|                     </Fragment> | ||||
|                     )} | ||||
|                 </CardDescription> | ||||
|             </CardHeader> | ||||
|             <CardContent className="flex-1" ref={ref}> | ||||
|                 <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 key={"..."} tagname="..." className="" disabled />} | ||||
|                 </ul> | ||||
|             </CardContent> | ||||
|         </div> | ||||
|     </Card>; | ||||
| } | ||||
							
								
								
									
										38
									
								
								packages/client/src/components/gallery/LazyImage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								packages/client/src/components/gallery/LazyImage.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | |||
| import { useEffect, useRef, useState } from "react"; | ||||
| 
 | ||||
| export 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) { | ||||
|             const observer = new IntersectionObserver((entries) => { | ||||
|                 if (entries.some(x => x.isIntersecting)) { | ||||
|                     setLoaded(true); | ||||
|                     ref.current?.animate([ | ||||
|                         { opacity: 0 }, | ||||
|                         { opacity: 1 } | ||||
|                     ], { | ||||
|                         duration: 300, | ||||
|                         easing: "ease-in-out" | ||||
|                     }); | ||||
|                     observer.disconnect(); | ||||
|                 } | ||||
|             }, { | ||||
|                 rootMargin: "200px", | ||||
|                 threshold: 0 | ||||
|             }); | ||||
|             observer.observe(ref.current); | ||||
|             return () => { | ||||
|                 observer.disconnect(); | ||||
|             }; | ||||
|         } | ||||
|     }, []); | ||||
| 
 | ||||
|     return <img | ||||
|         ref={ref} | ||||
|         src={loaded ? src : undefined} | ||||
|         alt={alt} | ||||
|         className={className} | ||||
|         loading="lazy" />; | ||||
| } | ||||
							
								
								
									
										14
									
								
								packages/client/src/components/gallery/StyledLink.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/client/src/components/gallery/StyledLink.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| import { cn } from "@/lib/utils"; | ||||
| import { Link } from "wouter"; | ||||
| 
 | ||||
| type StyledLinkProps = { | ||||
|     children?: React.ReactNode; | ||||
|     className?: string; | ||||
|     to: string; | ||||
| }; | ||||
| 
 | ||||
| export default function StyledLink({ children, className, ...rest }: StyledLinkProps) { | ||||
|     return <Link {...rest} | ||||
|         className={cn("hover:underline underline-offset-1 rounded-sm focus-visible:ring-1 focus-visible:ring-ring", className)} | ||||
|     >{children}</Link> | ||||
| } | ||||
							
								
								
									
										55
									
								
								packages/client/src/components/gallery/TagBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								packages/client/src/components/gallery/TagBadge.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,55 @@ | |||
| import { badgeVariants } from "@/components/ui/badge.tsx"; | ||||
| import { Link } from "wouter"; | ||||
| import { cn } from "@/lib/utils.ts"; | ||||
| 
 | ||||
| function getTagKind(tagname: string) { | ||||
|     if (tagname.match(":") === null) { | ||||
|         return "default"; | ||||
|     } | ||||
|     const prefix = tagname.split(":")[0]; | ||||
|     return prefix; | ||||
| } | ||||
| 
 | ||||
| function toPrettyTagname(tagname: string): string { | ||||
|     const kind = getTagKind(tagname); | ||||
|     const name = tagname.slice(kind.length + 1); | ||||
| 
 | ||||
|     switch (kind) { | ||||
|         case "male": | ||||
|             return `♂ ${name}`; | ||||
|         case "female": | ||||
|             return `♀ ${name}`; | ||||
|         case "artist": | ||||
|             return `🎨 ${name}`; | ||||
|         case "group": | ||||
|             return `🖿 ${name}`; | ||||
|         case "series": | ||||
|             return `📚 ${name}` | ||||
|         case "character": | ||||
|             return `👤 ${name}`; | ||||
|         case "default": | ||||
|             return tagname; | ||||
|         default: | ||||
|             return name; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default function TagBadge(props: { tagname: string, className?: string; disabled?: boolean;}) { | ||||
|     const { tagname } = props; | ||||
|     const kind = getTagKind(tagname); | ||||
|     return <li className={ | ||||
|         cn( badgeVariants({ variant: "default"}) , | ||||
|             "px-1", | ||||
|             kind === "male" && "bg-[#2a7bbf] hover:bg-[#3091e7]", | ||||
|             kind === "female" && "bg-[#c21f58] hover:bg-[#db2d67]", | ||||
|             kind === "artist" && "bg-[#319795] hover:bg-[#38a89d]", | ||||
|             kind === "group" && "bg-[#805ad5] hover:bg-[#8b5cd6]", | ||||
|             kind === "series" && "bg-[#dc8f09] hover:bg-[#e69d17]", | ||||
|             kind === "character" && "bg-[#52952c] hover:bg-[#6cc24a]", | ||||
|             kind === "type" && "bg-[#d53f8c] hover:bg-[#e24996]", | ||||
|             kind === "default" && "bg-[#4a5568] hover:bg-[#718096]", | ||||
|             props.disabled && "opacity-50", | ||||
|             props.className, | ||||
|         ) | ||||
|     }><Link to={props.disabled ? '': `/search?allow_tag=${tagname}`}>{toPrettyTagname(tagname)}</Link></li>; | ||||
| } | ||||
							
								
								
									
										49
									
								
								packages/client/src/components/layout/layout.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								packages/client/src/components/layout/layout.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | |||
| import { useLayoutEffect, useState } from "react"; | ||||
| import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../ui/resizable"; | ||||
| import { NavList } from "./nav"; | ||||
| 
 | ||||
| 
 | ||||
| interface LayoutProps { | ||||
|     children?: React.ReactNode; | ||||
| } | ||||
| 
 | ||||
| export default function Layout({ children }: LayoutProps) { | ||||
|     const MIN_SIZE_IN_PIXELS = 70; | ||||
|     const [minSize, setMinSize] = useState(MIN_SIZE_IN_PIXELS); | ||||
| 
 | ||||
|     useLayoutEffect(() => { | ||||
|         const panelGroup = document.querySelector('[data-panel-group-id="main"]'); | ||||
|         const resizeHandles = document.querySelectorAll( | ||||
|             "[data-panel-resize-handle-id]" | ||||
|         ); | ||||
|         if (!panelGroup || !resizeHandles) return; | ||||
|         const observer = new ResizeObserver(() => { | ||||
|             let width = panelGroup?.clientWidth; | ||||
|             if (!width) return; | ||||
|             width -= [...resizeHandles].reduce((acc, resizeHandle) => acc + resizeHandle.clientWidth, 0); | ||||
|             // Minimum size in pixels is a percentage of the PanelGroup's height,
 | ||||
|             // less the (fixed) height of the resize handles.
 | ||||
|             setMinSize((MIN_SIZE_IN_PIXELS / width) * 100); | ||||
|         }); | ||||
|         observer.observe(panelGroup); | ||||
|         for (const resizeHandle of resizeHandles) { | ||||
|             observer.observe(resizeHandle); | ||||
|         } | ||||
| 
 | ||||
|         return () => { | ||||
|             observer.disconnect(); | ||||
|         }; | ||||
|     }, []); | ||||
| 
 | ||||
|     return ( | ||||
|         <ResizablePanelGroup direction="horizontal" id="main"> | ||||
|             <ResizablePanel minSize={minSize} collapsible maxSize={minSize}> | ||||
|                 <NavList /> | ||||
|             </ResizablePanel> | ||||
|             <ResizableHandle withHandle /> | ||||
|             <ResizablePanel > | ||||
|                 {children} | ||||
|             </ResizablePanel> | ||||
|         </ResizablePanelGroup> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										46
									
								
								packages/client/src/components/layout/nav.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								packages/client/src/components/layout/nav.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import { Link } from "wouter" | ||||
| import { MagnifyingGlassIcon, GearIcon, ActivityLogIcon, ArchiveIcon, PersonIcon } from "@radix-ui/react-icons" | ||||
| import { buttonVariants } from "@/components/ui/button.tsx" | ||||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx" | ||||
| import { useLogin } from "@/state/user.ts"; | ||||
| 
 | ||||
| interface NavItemProps { | ||||
|     icon: React.ReactNode; | ||||
|     to: string; | ||||
|     name: string; | ||||
| } | ||||
| 
 | ||||
| export function NavItem({ | ||||
|     icon, | ||||
|     to, | ||||
|     name | ||||
| }: NavItemProps) { | ||||
|     return <Tooltip> | ||||
|         <TooltipTrigger asChild> | ||||
|             <Link | ||||
|                 href={to} | ||||
|                 className={buttonVariants({ variant: "ghost" })} | ||||
|             > | ||||
|                 {icon} | ||||
|                 <span className="sr-only">{name}</span> | ||||
|             </Link> | ||||
|         </TooltipTrigger> | ||||
|         <TooltipContent side="right">{name}</TooltipContent> | ||||
|     </Tooltip> | ||||
| } | ||||
| 
 | ||||
| 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" /> | ||||
|             <NavItem icon={<ActivityLogIcon className="h-5 w-5" />} to="/tags" name="Tags" /> | ||||
|             <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={ loginInfo ? "/profile" : "/login"} name={ loginInfo ? "Profiles" : "Login"} /> | ||||
|             <NavItem icon={<GearIcon className="h-5 w-5" />} to="/setting" name="Settings" /> | ||||
|         </nav> | ||||
|     </aside> | ||||
| } | ||||
							
								
								
									
										36
									
								
								packages/client/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/client/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| import * as React from "react" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| const badgeVariants = cva( | ||||
|   "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: | ||||
|           "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", | ||||
|         secondary: | ||||
|           "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", | ||||
|         destructive: | ||||
|           "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", | ||||
|         outline: "text-foreground", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| export interface BadgeProps | ||||
|   extends React.HTMLAttributes<HTMLDivElement>, | ||||
|     VariantProps<typeof badgeVariants> {} | ||||
| 
 | ||||
| function Badge({ className, variant, ...props }: BadgeProps) { | ||||
|   return ( | ||||
|     <div className={cn(badgeVariants({ variant }), className)} {...props} /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export { Badge, badgeVariants } | ||||
							
								
								
									
										57
									
								
								packages/client/src/components/ui/button.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								packages/client/src/components/ui/button.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| import * as React from "react" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| const buttonVariants = cva( | ||||
|   "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: | ||||
|           "bg-primary text-primary-foreground shadow hover:bg-primary/90", | ||||
|         destructive: | ||||
|           "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", | ||||
|         outline: | ||||
|           "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", | ||||
|         secondary: | ||||
|           "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", | ||||
|         ghost: "hover:bg-accent hover:text-accent-foreground", | ||||
|         link: "text-primary underline-offset-4 hover:underline", | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-9 px-4 py-2", | ||||
|         sm: "h-8 rounded-md px-3 text-xs", | ||||
|         lg: "h-10 rounded-md px-8", | ||||
|         icon: "h-9 w-9", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|       size: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
| export interface ButtonProps | ||||
|   extends React.ButtonHTMLAttributes<HTMLButtonElement>, | ||||
|     VariantProps<typeof buttonVariants> { | ||||
|   asChild?: boolean | ||||
| } | ||||
| 
 | ||||
| const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( | ||||
|   ({ className, variant, size, asChild = false, ...props }, ref) => { | ||||
|     const Comp = asChild ? Slot : "button" | ||||
|     return ( | ||||
|       <Comp | ||||
|         className={cn(buttonVariants({ variant, size, className }))} | ||||
|         ref={ref} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Button.displayName = "Button" | ||||
| 
 | ||||
| export { Button, buttonVariants } | ||||
							
								
								
									
										76
									
								
								packages/client/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/client/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| const Card = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn( | ||||
|       "rounded-xl border bg-card text-card-foreground shadow", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| Card.displayName = "Card" | ||||
| 
 | ||||
| const CardHeader = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex flex-col space-y-1.5 p-6", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardHeader.displayName = "CardHeader" | ||||
| 
 | ||||
| const CardTitle = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLHeadingElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <h3 | ||||
|     ref={ref} | ||||
|     className={cn("font-semibold leading-none tracking-tight", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardTitle.displayName = "CardTitle" | ||||
| 
 | ||||
| const CardDescription = React.forwardRef< | ||||
|   HTMLParagraphElement, | ||||
|   React.HTMLAttributes<HTMLParagraphElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <p | ||||
|     ref={ref} | ||||
|     className={cn("text-sm text-muted-foreground", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardDescription.displayName = "CardDescription" | ||||
| 
 | ||||
| const CardContent = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> | ||||
| )) | ||||
| CardContent.displayName = "CardContent" | ||||
| 
 | ||||
| const CardFooter = React.forwardRef< | ||||
|   HTMLDivElement, | ||||
|   React.HTMLAttributes<HTMLDivElement> | ||||
| >(({ className, ...props }, ref) => ( | ||||
|   <div | ||||
|     ref={ref} | ||||
|     className={cn("flex items-center p-6 pt-0", className)} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| CardFooter.displayName = "CardFooter" | ||||
| 
 | ||||
| export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } | ||||
							
								
								
									
										25
									
								
								packages/client/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/client/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| import * as React from "react" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| export interface InputProps | ||||
|   extends React.InputHTMLAttributes<HTMLInputElement> {} | ||||
| 
 | ||||
| const Input = React.forwardRef<HTMLInputElement, InputProps>( | ||||
|   ({ className, type, ...props }, ref) => { | ||||
|     return ( | ||||
|       <input | ||||
|         type={type} | ||||
|         className={cn( | ||||
|           "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", | ||||
|           className | ||||
|         )} | ||||
|         ref={ref} | ||||
|         {...props} | ||||
|       /> | ||||
|     ) | ||||
|   } | ||||
| ) | ||||
| Input.displayName = "Input" | ||||
| 
 | ||||
| export { Input } | ||||
							
								
								
									
										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.ts" | ||||
| 
 | ||||
| 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 } | ||||
							
								
								
									
										42
									
								
								packages/client/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								packages/client/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | |||
| import * as React from "react" | ||||
| import { CheckIcon } from "@radix-ui/react-icons" | ||||
| import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils" | ||||
| 
 | ||||
| const RadioGroup = React.forwardRef< | ||||
|   React.ElementRef<typeof RadioGroupPrimitive.Root>, | ||||
|   React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <RadioGroupPrimitive.Root | ||||
|       className={cn("grid gap-2", className)} | ||||
|       {...props} | ||||
|       ref={ref} | ||||
|     /> | ||||
|   ) | ||||
| }) | ||||
| RadioGroup.displayName = RadioGroupPrimitive.Root.displayName | ||||
| 
 | ||||
| const RadioGroupItem = React.forwardRef< | ||||
|   React.ElementRef<typeof RadioGroupPrimitive.Item>, | ||||
|   React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> | ||||
| >(({ className, ...props }, ref) => { | ||||
|   return ( | ||||
|     <RadioGroupPrimitive.Item | ||||
|       ref={ref} | ||||
|       className={cn( | ||||
|         "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     > | ||||
|       <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> | ||||
|         <CheckIcon className="h-3.5 w-3.5 fill-primary" /> | ||||
|       </RadioGroupPrimitive.Indicator> | ||||
|     </RadioGroupPrimitive.Item> | ||||
|   ) | ||||
| }) | ||||
| RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName | ||||
| 
 | ||||
| export { RadioGroup, RadioGroupItem } | ||||
							
								
								
									
										43
									
								
								packages/client/src/components/ui/resizable.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								packages/client/src/components/ui/resizable.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import { DragHandleDots2Icon } from "@radix-ui/react-icons" | ||||
| import * as ResizablePrimitive from "react-resizable-panels" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| const ResizablePanelGroup = ({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( | ||||
|   <ResizablePrimitive.PanelGroup | ||||
|     className={cn( | ||||
|       "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| ) | ||||
| 
 | ||||
| const ResizablePanel = ResizablePrimitive.Panel | ||||
| 
 | ||||
| const ResizableHandle = ({ | ||||
|   withHandle, | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { | ||||
|   withHandle?: boolean | ||||
| }) => ( | ||||
|   <ResizablePrimitive.PanelResizeHandle | ||||
|     className={cn( | ||||
|       "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   > | ||||
|     {withHandle && ( | ||||
|       <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> | ||||
|         <DragHandleDots2Icon className="h-2.5 w-2.5" /> | ||||
|       </div> | ||||
|     )} | ||||
|   </ResizablePrimitive.PanelResizeHandle> | ||||
| ) | ||||
| 
 | ||||
| export { ResizablePanelGroup, ResizablePanel, ResizableHandle } | ||||
							
								
								
									
										15
									
								
								packages/client/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/client/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,15 @@ | |||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| function Skeleton({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.HTMLAttributes<HTMLDivElement>) { | ||||
|   return ( | ||||
|     <div | ||||
|       className={cn("animate-pulse rounded-md bg-primary/10", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| export { Skeleton } | ||||
							
								
								
									
										28
									
								
								packages/client/src/components/ui/tooltip.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/client/src/components/ui/tooltip.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import * as React from "react" | ||||
| import * as TooltipPrimitive from "@radix-ui/react-tooltip" | ||||
| 
 | ||||
| import { cn } from "@/lib/utils.ts" | ||||
| 
 | ||||
| const TooltipProvider = TooltipPrimitive.Provider | ||||
| 
 | ||||
| const Tooltip = TooltipPrimitive.Root | ||||
| 
 | ||||
| const TooltipTrigger = TooltipPrimitive.Trigger | ||||
| 
 | ||||
| const TooltipContent = React.forwardRef< | ||||
|   React.ElementRef<typeof TooltipPrimitive.Content>, | ||||
|   React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> | ||||
| >(({ className, sideOffset = 4, ...props }, ref) => ( | ||||
|   <TooltipPrimitive.Content | ||||
|     ref={ref} | ||||
|     sideOffset={sideOffset} | ||||
|     className={cn( | ||||
|       "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", | ||||
|       className | ||||
|     )} | ||||
|     {...props} | ||||
|   /> | ||||
| )) | ||||
| TooltipContent.displayName = TooltipPrimitive.Content.displayName | ||||
| 
 | ||||
| export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } | ||||
							
								
								
									
										4
									
								
								packages/client/src/hook/fetcher.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/client/src/hook/fetcher.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| export async function fetcher(url: string) { | ||||
|     const res = await fetch(url); | ||||
|     return res.json(); | ||||
| } | ||||
							
								
								
									
										7
									
								
								packages/client/src/hook/useGalleryDoc.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/client/src/hook/useGalleryDoc.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import useSWR from "swr"; | ||||
| import type { Document } from "dbtype/api"; | ||||
| import { fetcher } from "./fetcher"; | ||||
| 
 | ||||
| export function useGalleryDoc(id: string) { | ||||
|     return useSWR<Document>(`/api/doc/${id}`, fetcher); | ||||
| } | ||||
							
								
								
									
										46
									
								
								packages/client/src/hook/useSearchGallery.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								packages/client/src/hook/useSearchGallery.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | |||
| import useSWRInifinite from "swr/infinite"; | ||||
| import type { Document } from "dbtype/api"; | ||||
| import { fetcher } from "./fetcher"; | ||||
| 
 | ||||
| interface SearchParams { | ||||
|     word?: string; | ||||
|     tags?: string; | ||||
|     limit?: number; | ||||
|     cursor?: number; | ||||
| } | ||||
| 
 | ||||
| export function useSearchGallery({ | ||||
|     word, tags, limit, cursor, | ||||
| }: SearchParams) { | ||||
|      | ||||
|     return useSWRInifinite< | ||||
|     { | ||||
|         data: Document[]; | ||||
|         nextCursor: number | null; | ||||
|         hasMore: boolean; | ||||
|     } | ||||
|     >((index, previous) => { | ||||
|         if (!previous && index > 0) return null; | ||||
|         if (previous && !previous.hasMore) return null; | ||||
|         const search = new URLSearchParams(); | ||||
|         if (word) search.set("word", word); | ||||
|         if (tags) search.set("allow_tag", tags); | ||||
|         if (limit) search.set("limit", limit.toString()); | ||||
|         if (cursor) search.set("cursor", cursor.toString()); | ||||
|         if (index === 0) { | ||||
|             return `/api/doc/search?${search.toString()}`; | ||||
|         } | ||||
|         if (!previous || !previous.data) return null; | ||||
|         const last = previous.data[previous.data.length - 1]; | ||||
|         search.set("cursor", last.id.toString()); | ||||
|         return `/api/doc/search?${search.toString()}`; | ||||
|     }, async (url) => { | ||||
|         const res = await fetcher(url); | ||||
|         return { | ||||
|             data: res, | ||||
|             nextCursor: res.length === 0 ? null : res[res.length - 1].id, | ||||
|             hasMore: limit ? res.length === limit : (res.length === 20), | ||||
|         }; | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
|  | @ -1,3 +1,76 @@ | |||
| @tailwind base; | ||||
| @tailwind components; | ||||
| @tailwind utilities; | ||||
| 
 | ||||
| @layer base { | ||||
|   :root { | ||||
|     --background: 0 0% 100%; | ||||
|     --foreground: 0 0% 3.9%; | ||||
| 
 | ||||
|     --card: 0 0% 100%; | ||||
|     --card-foreground: 0 0% 3.9%; | ||||
| 
 | ||||
|     --popover: 0 0% 100%; | ||||
|     --popover-foreground: 0 0% 3.9%; | ||||
| 
 | ||||
|     --primary: 0 0% 9%; | ||||
|     --primary-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --secondary: 0 0% 96.1%; | ||||
|     --secondary-foreground: 0 0% 9%; | ||||
| 
 | ||||
|     --muted: 0 0% 96.1%; | ||||
|     --muted-foreground: 0 0% 45.1%; | ||||
| 
 | ||||
|     --accent: 0 0% 96.1%; | ||||
|     --accent-foreground: 0 0% 9%; | ||||
| 
 | ||||
|     --destructive: 0 84.2% 60.2%; | ||||
|     --destructive-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --border: 0 0% 89.8%; | ||||
|     --input: 0 0% 89.8%; | ||||
|     --ring: 0 0% 3.9%; | ||||
| 
 | ||||
|     --radius: 0.5rem; | ||||
|   } | ||||
| 
 | ||||
|   .dark { | ||||
|     --background: 0 0% 3.9%; | ||||
|     --foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --card: 0 0% 3.9%; | ||||
|     --card-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --popover: 0 0% 3.9%; | ||||
|     --popover-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --primary: 0 0% 98%; | ||||
|     --primary-foreground: 0 0% 9%; | ||||
| 
 | ||||
|     --secondary: 0 0% 14.9%; | ||||
|     --secondary-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --muted: 0 0% 14.9%; | ||||
|     --muted-foreground: 0 0% 63.9%; | ||||
| 
 | ||||
|     --accent: 0 0% 14.9%; | ||||
|     --accent-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --destructive: 0 62.8% 30.6%; | ||||
|     --destructive-foreground: 0 0% 98%; | ||||
| 
 | ||||
|     --border: 0 0% 14.9%; | ||||
|     --input: 0 0% 14.9%; | ||||
|     --ring: 0 0% 83.1%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @layer base { | ||||
|   * { | ||||
|     @apply border-border; | ||||
|   } | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										70
									
								
								packages/client/src/lib/atom.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								packages/client/src/lib/atom.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,70 @@ | |||
| import { useEffect, useReducer, useState } from "react"; | ||||
| 
 | ||||
| interface AtomState<T> { | ||||
|     value: T; | ||||
|     listeners: Set<() => void>; | ||||
| } | ||||
| interface Atom<T> { | ||||
|     key: string; | ||||
|     default: T; | ||||
| } | ||||
| 
 | ||||
| const atomStateMap = new WeakMap<Atom<unknown>, AtomState<unknown>>(); | ||||
| 
 | ||||
| export function atom<T>(key: string, defaultVal: T): Atom<T> { | ||||
|     return { key, default: defaultVal }; | ||||
| } | ||||
| 
 | ||||
| function getAtomState<T>(atom: Atom<T>): AtomState<T> { | ||||
|     let atomState = atomStateMap.get(atom); | ||||
|     if (!atomState) { | ||||
|         atomState = { | ||||
|             value: atom.default, | ||||
|             listeners: new Set(), | ||||
|         }; | ||||
|         atomStateMap.set(atom, atomState); | ||||
|     } | ||||
|     return atomState as AtomState<T>; | ||||
| } | ||||
| 
 | ||||
| export function useAtom<T>(atom: Atom<T>): [T, (val: T) => void] { | ||||
|     const state = getAtomState(atom); | ||||
|     const [, setState] = useState(state.value); | ||||
|     useEffect(() => { | ||||
|         const listener = () => setState(state.value); | ||||
|         state.listeners.add(listener); | ||||
|         return () => { | ||||
|             state.listeners.delete(listener); | ||||
|         }; | ||||
|     }, [state]); | ||||
|     return [ | ||||
|         state.value as T, | ||||
|         (val: T) => { | ||||
|             state.value = val; | ||||
|             // biome-ignore lint/complexity/noForEach: forEach is used to call each listener
 | ||||
|             state.listeners.forEach((listener) => listener()); | ||||
|         }, | ||||
|     ]; | ||||
| } | ||||
| 
 | ||||
| export function useAtomValue<T>(atom: Atom<T>): T { | ||||
|     const state = getAtomState(atom); | ||||
|     const update = useReducer((x) => x + 1, 0)[1]; | ||||
|     useEffect(() => { | ||||
|         const listener = () => update(); | ||||
|         state.listeners.add(listener); | ||||
|         return () => { | ||||
|             state.listeners.delete(listener); | ||||
|         }; | ||||
|     }, [state, update]); | ||||
|     return state.value; | ||||
| } | ||||
| 
 | ||||
| export function setAtomValue<T>(atom: Atom<T>): (val: T) => void { | ||||
|     const state = getAtomState(atom); | ||||
|     return (val: T) => { | ||||
|         state.value = val; | ||||
|         // biome-ignore lint/complexity/noForEach: forEach is used to call each listener
 | ||||
|         state.listeners.forEach((listener) => listener()); | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										6
									
								
								packages/client/src/lib/utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								packages/client/src/lib/utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | |||
| import { type ClassValue, clsx } from "clsx" | ||||
| import { twMerge } from "tailwind-merge" | ||||
| 
 | ||||
| export function cn(...inputs: ClassValue[]) { | ||||
|   return twMerge(clsx(inputs)) | ||||
| } | ||||
|  | @ -1,15 +1,9 @@ | |||
| import { Typography } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { CommonMenuList, Headline } from "../component/mod"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| export const NotFoundPage = () => { | ||||
| 	const menu = CommonMenuList(); | ||||
| 	return ( | ||||
| 		<Headline menu={menu}> | ||||
| 			<PagePad> | ||||
| 				<Typography variant="h2">404 Not Found</Typography> | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	return (<div className="flex items-center justify-center flex-col box-border h-screen space-y-2"> | ||||
| 				<h2 className="text-6xl">404 Not Found</h2> | ||||
| 				<p>찾을 수 없음</p> | ||||
| 			</div> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export default NotFoundPage; | ||||
							
								
								
									
										142
									
								
								packages/client/src/page/contentInfoPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								packages/client/src/page/contentInfoPage.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,142 @@ | |||
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | ||||
| import { useGalleryDoc } from "../hook/useGalleryDoc.ts"; | ||||
| 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: { | ||||
|         id: string; | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| interface TagClassifyResult { | ||||
|     artist: string[]; | ||||
|     group: string[]; | ||||
|     series: string[]; | ||||
|     type: string[]; | ||||
|     character: string[]; | ||||
|     rest: string[]; | ||||
| } | ||||
| 
 | ||||
| function classifyTags(tags: string[]): TagClassifyResult { | ||||
|     const result = { | ||||
|         artist: [], | ||||
|         group: [], | ||||
|         series: [], | ||||
|         type: [], | ||||
|         character: [], | ||||
|         rest: [], | ||||
|     } as TagClassifyResult; | ||||
|     const tagKind = new Set(["artist", "group", "series", "type", "character"]); | ||||
|     for (const tag of tags) { | ||||
|         const split = tag.split(":"); | ||||
|         if (split.length !== 2) { | ||||
|             continue; | ||||
|         } | ||||
|         const [prefix, name] = split; | ||||
|         if (tagKind.has(prefix)) { | ||||
|             result[prefix as keyof TagClassifyResult].push(name); | ||||
|         } else { | ||||
|             result.rest.push(tag); | ||||
|         } | ||||
|     } | ||||
|     return result; | ||||
| } | ||||
| 
 | ||||
| export function ContentInfoPage({ params }: ContentInfoPageProps) { | ||||
|     const { data, error, isLoading } = useGalleryDoc(params.id); | ||||
| 
 | ||||
|     if (isLoading) { | ||||
|         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> | ||||
|     } | ||||
| 
 | ||||
|     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 | ||||
|                         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> | ||||
|                         <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"} | ||||
|                         </StyledLink> | ||||
|                     </CardDescription> | ||||
|                 </CardHeader> | ||||
|                 <CardContent> | ||||
|                     <div className="grid gap-y-4 gap-x-3 lg:grid-cols-2"> | ||||
|                         <DescTagItem name="artist" items={classifiedTags.artist} /> | ||||
|                         <DescTagItem name="group" items={classifiedTags.group} /> | ||||
|                         <DescTagItem name="series" items={classifiedTags.series} /> | ||||
|                         <DescTagItem name="character" items={classifiedTags.character} /> | ||||
|                         <DescItem name="Created At">{new Date(data.created_at).toLocaleString()}</DescItem> | ||||
|                         <DescItem name="Modified At">{new Date(data.modified_at).toLocaleString()}</DescItem> | ||||
|                         <DescItem name="Path">{`${data.basepath}/${data.filename}`}</DescItem> | ||||
|                         <DescItem name="Page Count">{JSON.stringify(data.additional)}</DescItem> | ||||
|                     </div> | ||||
|                     <div className="grid mt-4"> | ||||
|                         <span className="text-muted-foreground text-sm">Tags</span> | ||||
|                         <ul className="mt-2 flex flex-wrap gap-1"> | ||||
|                             {classifiedTags.rest.map((tag) => <TagBadge key={tag} tagname={tag} />)} | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </CardContent> | ||||
|             </Card> | ||||
|         </div> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| export default ContentInfoPage; | ||||
| 
 | ||||
| function DescItem({ name, children, className }: { | ||||
|     name: string, | ||||
|     className?: string, | ||||
|     children?: React.ReactNode | ||||
| }) { | ||||
|     return <div className={cn("grid content-start", className)}> | ||||
|         <span className="text-muted-foreground text-sm">{name}</span> | ||||
|         <span className="text-primary leading-4 font-medium">{children}</span> | ||||
|     </div>; | ||||
| } | ||||
| 
 | ||||
| function DescTagItem({ | ||||
|     items, | ||||
|     name, | ||||
|     className, | ||||
| }: { | ||||
|     name: string; | ||||
|     items: string[]; | ||||
|     className?: string; | ||||
| }) { | ||||
|     return <DescItem name={name} className={className}> | ||||
|         {items.length === 0 ? "N/A" : items.map( | ||||
|             (x) => <StyledLink key={x} to={`/search?allow_tag=${name}:${x}`}>{x}</StyledLink> | ||||
|         )} | ||||
|     </DescItem> | ||||
| } | ||||
|  | @ -1,136 +0,0 @@ | |||
| import { IconButton, Theme, Typography } from "@mui/material"; | ||||
| import FullscreenIcon from "@mui/icons-material/Fullscreen"; | ||||
| import React, { useEffect, useRef, useState } from "react"; | ||||
| import { Route, Routes, useLocation, useParams } from "react-router-dom"; | ||||
| import DocumentAccessor, { Document } from "../accessor/document"; | ||||
| import { LoadingCircle } from "../component/loading"; | ||||
| import { CommonMenuList, ContentInfo, Headline } from "../component/mod"; | ||||
| import { NotFoundPage } from "./404"; | ||||
| import { getPresenter } from "./reader/reader"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| export const makeContentInfoUrl = (id: number) => `/doc/${id}`; | ||||
| export const makeComicReaderUrl = (id: number) => `/doc/${id}/reader`; | ||||
| 
 | ||||
| type DocumentState = { | ||||
| 	doc: Document | undefined; | ||||
| 	notfound: boolean; | ||||
| }; | ||||
| 
 | ||||
| export function ReaderPage(props?: {}) { | ||||
| 	const location = useLocation(); | ||||
| 	const match = useParams<{ id: string }>(); | ||||
| 	if (match == null) { | ||||
| 		throw new Error("unreachable"); | ||||
| 	} | ||||
| 	const id = Number.parseInt(match.id ?? "NaN"); | ||||
| 	const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false }); | ||||
| 	const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>; | ||||
| 	const fullScreenTargetRef = useRef<HTMLDivElement | null>(null); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		(async () => { | ||||
| 			if (!isNaN(id)) { | ||||
| 				const c = await DocumentAccessor.findById(id); | ||||
| 				setInfo({ doc: c, notfound: c === undefined }); | ||||
| 			} | ||||
| 		})(); | ||||
| 	}, []); | ||||
| 
 | ||||
| 	if (isNaN(id)) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<Typography variant="h2">Oops. Invalid ID</Typography> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else if (info.notfound) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<Typography variant="h2">Content has been removed.</Typography> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else if (info.doc === undefined) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<LoadingCircle /> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else { | ||||
| 		const ReaderPage = getPresenter(info.doc); | ||||
| 		return ( | ||||
| 			<Headline | ||||
| 				menu={menu_list(location.pathname)} | ||||
| 				rightAppbar={ | ||||
| 					<IconButton | ||||
| 						edge="start" | ||||
| 						aria-label="account of current user" | ||||
| 						aria-haspopup="true" | ||||
| 						onClick={() => { | ||||
| 							if (fullScreenTargetRef.current != null && document.fullscreenEnabled) { | ||||
| 								fullScreenTargetRef.current.requestFullscreen(); | ||||
| 							} | ||||
| 						}} | ||||
| 						color="inherit" | ||||
| 					> | ||||
| 						<FullscreenIcon /> | ||||
| 					</IconButton> | ||||
| 				} | ||||
| 			> | ||||
| 				<ReaderPage doc={info.doc} fullScreenTarget={fullScreenTargetRef}></ReaderPage> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| export const DocumentAbout = (prop?: {}) => { | ||||
| 	const match = useParams<{ id: string }>(); | ||||
| 	if (match == null) { | ||||
| 		throw new Error("unreachable"); | ||||
| 	} | ||||
| 	const id = Number.parseInt(match.id ?? "NaN"); | ||||
| 	const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false }); | ||||
| 	const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>; | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		(async () => { | ||||
| 			if (!isNaN(id)) { | ||||
| 				const c = await DocumentAccessor.findById(id); | ||||
| 				setInfo({ doc: c, notfound: c === undefined }); | ||||
| 			} | ||||
| 		})(); | ||||
| 	}, []); | ||||
| 
 | ||||
| 	if (isNaN(id)) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<PagePad> | ||||
| 					<Typography variant="h2">Oops. Invalid ID</Typography> | ||||
| 				</PagePad> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else if (info.notfound) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<PagePad> | ||||
| 					<Typography variant="h2">Content has been removed.</Typography> | ||||
| 				</PagePad> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else if (info.doc === undefined) { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<PagePad> | ||||
| 					<LoadingCircle /> | ||||
| 				</PagePad> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} else { | ||||
| 		return ( | ||||
| 			<Headline menu={menu_list()}> | ||||
| 				<PagePad> | ||||
| 					<ContentInfo document={info.doc}></ContentInfo> | ||||
| 				</PagePad> | ||||
| 			</Headline> | ||||
| 		); | ||||
| 	} | ||||
| }; | ||||
|  | @ -1,126 +1,126 @@ | |||
| import { Box, Button, Paper, Typography } from "@mui/material"; | ||||
| import React, { useContext, useEffect, useState } from "react"; | ||||
| import { CommonMenuList, Headline } from "../component/mod"; | ||||
| import { UserContext } from "../state"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| // import { Box, Button, Paper, Typography } from "@mui/material";
 | ||||
| // import React, { useContext, useEffect, useState } from "react";
 | ||||
| // import { CommonMenuList, Headline } from "../component/mod";
 | ||||
| // import { UserContext } from "../state";
 | ||||
| // import { PagePad } from "../component/pagepad";
 | ||||
| 
 | ||||
| type FileDifference = { | ||||
| 	type: string; | ||||
| 	value: { | ||||
| 		type: string; | ||||
| 		path: string; | ||||
| 	}[]; | ||||
| }; | ||||
| // type FileDifference = {
 | ||||
| // 	type: string;
 | ||||
| // 	value: {
 | ||||
| // 		type: string;
 | ||||
| // 		path: string;
 | ||||
| // 	}[];
 | ||||
| // };
 | ||||
| 
 | ||||
| function TypeDifference(prop: { | ||||
| 	content: FileDifference; | ||||
| 	onCommit: (v: { type: string; path: string }) => void; | ||||
| 	onCommitAll: (type: string) => void; | ||||
| }) { | ||||
| 	// const classes = useStyles();
 | ||||
| 	const x = prop.content; | ||||
| 	const [button_disable, set_disable] = useState(false); | ||||
| // function TypeDifference(prop: {
 | ||||
| // 	content: FileDifference;
 | ||||
| // 	onCommit: (v: { type: string; path: string }) => void;
 | ||||
| // 	onCommitAll: (type: string) => void;
 | ||||
| // }) {
 | ||||
| // 	// const classes = useStyles();
 | ||||
| // 	const x = prop.content;
 | ||||
| // 	const [button_disable, set_disable] = useState(false);
 | ||||
| 
 | ||||
| 	return ( | ||||
| 		<Paper /*className={classes.paper}*/> | ||||
| 			<Box /*className={classes.contentTitle}*/> | ||||
| 				<Typography variant="h3">{x.type}</Typography> | ||||
| 				<Button | ||||
| 					variant="contained" | ||||
| 					key={x.type} | ||||
| 					onClick={() => { | ||||
| 						set_disable(true); | ||||
| 						prop.onCommitAll(x.type); | ||||
| 						set_disable(false); | ||||
| 					}} | ||||
| 				> | ||||
| 					Commit all | ||||
| 				</Button> | ||||
| 			</Box> | ||||
| 			{x.value.map((y) => ( | ||||
| 				<Box sx={{ display: "flex" }} key={y.path}> | ||||
| 					<Button | ||||
| 						variant="contained" | ||||
| 						onClick={() => { | ||||
| 							set_disable(true); | ||||
| 							prop.onCommit(y); | ||||
| 							set_disable(false); | ||||
| 						}} | ||||
| 						disabled={button_disable} | ||||
| 					> | ||||
| 						Commit | ||||
| 					</Button> | ||||
| 					<Typography variant="h5">{y.path}</Typography> | ||||
| 				</Box> | ||||
| 			))} | ||||
| 		</Paper> | ||||
| 	); | ||||
| } | ||||
| // 	return (
 | ||||
| // 		<Paper /*className={classes.paper}*/>
 | ||||
| // 			<Box /*className={classes.contentTitle}*/>
 | ||||
| // 				<Typography variant="h3">{x.type}</Typography>
 | ||||
| // 				<Button
 | ||||
| // 					variant="contained"
 | ||||
| // 					key={x.type}
 | ||||
| // 					onClick={() => {
 | ||||
| // 						set_disable(true);
 | ||||
| // 						prop.onCommitAll(x.type);
 | ||||
| // 						set_disable(false);
 | ||||
| // 					}}
 | ||||
| // 				>
 | ||||
| // 					Commit all
 | ||||
| // 				</Button>
 | ||||
| // 			</Box>
 | ||||
| // 			{x.value.map((y) => (
 | ||||
| // 				<Box sx={{ display: "flex" }} key={y.path}>
 | ||||
| // 					<Button
 | ||||
| // 						variant="contained"
 | ||||
| // 						onClick={() => {
 | ||||
| // 							set_disable(true);
 | ||||
| // 							prop.onCommit(y);
 | ||||
| // 							set_disable(false);
 | ||||
| // 						}}
 | ||||
| // 						disabled={button_disable}
 | ||||
| // 					>
 | ||||
| // 						Commit
 | ||||
| // 					</Button>
 | ||||
| // 					<Typography variant="h5">{y.path}</Typography>
 | ||||
| // 				</Box>
 | ||||
| // 			))}
 | ||||
| // 		</Paper>
 | ||||
| // 	);
 | ||||
| // }
 | ||||
| 
 | ||||
| export function DifferencePage() { | ||||
| 	const ctx = useContext(UserContext); | ||||
| 	// const classes = useStyles();
 | ||||
| 	const [diffList, setDiffList] = useState<FileDifference[]>([]); | ||||
| 	const doLoad = async () => { | ||||
| 		const list = await fetch("/api/diff/list"); | ||||
| 		if (list.ok) { | ||||
| 			const inner = await list.json(); | ||||
| 			setDiffList(inner); | ||||
| 		} else { | ||||
| 			// setDiffList([]);
 | ||||
| 		} | ||||
| 	}; | ||||
| 	const Commit = async (x: { type: string; path: string }) => { | ||||
| 		const res = await fetch("/api/diff/commit", { | ||||
| 			method: "POST", | ||||
| 			body: JSON.stringify([{ ...x }]), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const bb = await res.json(); | ||||
| 		if (bb.ok) { | ||||
| 			doLoad(); | ||||
| 		} else { | ||||
| 			console.error("fail to add document"); | ||||
| 		} | ||||
| 	}; | ||||
| 	const CommitAll = async (type: string) => { | ||||
| 		const res = await fetch("/api/diff/commitall", { | ||||
| 			method: "POST", | ||||
| 			body: JSON.stringify({ type: type }), | ||||
| 			headers: { | ||||
| 				"content-type": "application/json", | ||||
| 			}, | ||||
| 		}); | ||||
| 		const bb = await res.json(); | ||||
| 		if (bb.ok) { | ||||
| 			doLoad(); | ||||
| 		} else { | ||||
| 			console.error("fail to add document"); | ||||
| 		} | ||||
| 	}; | ||||
| 	useEffect(() => { | ||||
| 		doLoad(); | ||||
| 		const i = setInterval(doLoad, 5000); | ||||
| 		return () => { | ||||
| 			clearInterval(i); | ||||
| 		}; | ||||
| 	}, []); | ||||
| 	const menu = CommonMenuList(); | ||||
| 	return ( | ||||
| 		<Headline menu={menu}> | ||||
| 			<PagePad> | ||||
| 				{ctx.username == "admin" ? ( | ||||
| 					<div> | ||||
| 						{diffList.map((x) => ( | ||||
| 							<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} /> | ||||
| 						))} | ||||
| 					</div> | ||||
| 				) : ( | ||||
| 					<Typography variant="h2">Not Allowed : please login as an admin</Typography> | ||||
| 				)} | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	); | ||||
| } | ||||
| // export function DifferencePage() {
 | ||||
| // 	const ctx = useContext(UserContext);
 | ||||
| // 	// const classes = useStyles();
 | ||||
| // 	const [diffList, setDiffList] = useState<FileDifference[]>([]);
 | ||||
| // 	const doLoad = async () => {
 | ||||
| // 		const list = await fetch("/api/diff/list");
 | ||||
| // 		if (list.ok) {
 | ||||
| // 			const inner = await list.json();
 | ||||
| // 			setDiffList(inner);
 | ||||
| // 		} else {
 | ||||
| // 			// setDiffList([]);
 | ||||
| // 		}
 | ||||
| // 	};
 | ||||
| // 	const Commit = async (x: { type: string; path: string }) => {
 | ||||
| // 		const res = await fetch("/api/diff/commit", {
 | ||||
| // 			method: "POST",
 | ||||
| // 			body: JSON.stringify([{ ...x }]),
 | ||||
| // 			headers: {
 | ||||
| // 				"content-type": "application/json",
 | ||||
| // 			},
 | ||||
| // 		});
 | ||||
| // 		const bb = await res.json();
 | ||||
| // 		if (bb.ok) {
 | ||||
| // 			doLoad();
 | ||||
| // 		} else {
 | ||||
| // 			console.error("fail to add document");
 | ||||
| // 		}
 | ||||
| // 	};
 | ||||
| // 	const CommitAll = async (type: string) => {
 | ||||
| // 		const res = await fetch("/api/diff/commitall", {
 | ||||
| // 			method: "POST",
 | ||||
| // 			body: JSON.stringify({ type: type }),
 | ||||
| // 			headers: {
 | ||||
| // 				"content-type": "application/json",
 | ||||
| // 			},
 | ||||
| // 		});
 | ||||
| // 		const bb = await res.json();
 | ||||
| // 		if (bb.ok) {
 | ||||
| // 			doLoad();
 | ||||
| // 		} else {
 | ||||
| // 			console.error("fail to add document");
 | ||||
| // 		}
 | ||||
| // 	};
 | ||||
| // 	useEffect(() => {
 | ||||
| // 		doLoad();
 | ||||
| // 		const i = setInterval(doLoad, 5000);
 | ||||
| // 		return () => {
 | ||||
| // 			clearInterval(i);
 | ||||
| // 		};
 | ||||
| // 	}, []);
 | ||||
| // 	const menu = CommonMenuList();
 | ||||
| // 	return (
 | ||||
| // 		<Headline menu={menu}>
 | ||||
| // 			<PagePad>
 | ||||
| // 				{ctx.username == "admin" ? (
 | ||||
| // 					<div>
 | ||||
| // 						{diffList.map((x) => (
 | ||||
| // 							<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
 | ||||
| // 						))}
 | ||||
| // 					</div>
 | ||||
| // 				) : (
 | ||||
| // 					<Typography variant="h2">Not Allowed : please login as an admin</Typography>
 | ||||
| // 				)}
 | ||||
| // 			</PagePad>
 | ||||
| // 		</Headline>
 | ||||
| // 	);
 | ||||
| // }
 | ||||
|  |  | |||
|  | @ -1,133 +0,0 @@ | |||
| import React, { useContext, useEffect, useState } from "react"; | ||||
| import { CommonMenuList, ContentInfo, Headline, LoadingCircle, NavItem, NavList, TagChip } from "../component/mod"; | ||||
| 
 | ||||
| import { Box, Button, Chip, Pagination, Typography } from "@mui/material"; | ||||
| import ContentAccessor, { Document, QueryListOption } from "../accessor/document"; | ||||
| import { toQueryString } from "../accessor/util"; | ||||
| 
 | ||||
| import { useLocation } from "react-router-dom"; | ||||
| import { QueryStringToMap } from "../accessor/util"; | ||||
| import { useIsElementInViewport } from "./reader/reader"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| export type GalleryProp = { | ||||
| 	option?: QueryListOption; | ||||
| 	diff: string; | ||||
| }; | ||||
| type GalleryState = { | ||||
| 	documents: Document[] | undefined; | ||||
| }; | ||||
| 
 | ||||
| export const GalleryInfo = (props: GalleryProp) => { | ||||
| 	const [state, setState] = useState<GalleryState>({ documents: undefined }); | ||||
| 	const [error, setError] = useState<string | null>(null); | ||||
| 	const [loadAll, setLoadAll] = useState(false); | ||||
| 	const { elementRef, isVisible: isLoadVisible } = useIsElementInViewport<HTMLButtonElement>({}); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		if (isLoadVisible && !loadAll && state.documents != undefined) { | ||||
| 			loadMore(); | ||||
| 		} | ||||
| 	}, [isLoadVisible]); | ||||
| 
 | ||||
| 	useEffect(() => { | ||||
| 		const abortController = new AbortController(); | ||||
| 		console.log("load first", props.option); | ||||
| 		const load = async () => { | ||||
| 			try { | ||||
| 				const c = await ContentAccessor.findList(props.option); | ||||
| 				// todo : if c is undefined, retry to fetch 3 times. and show error message.
 | ||||
| 				setState({ documents: c }); | ||||
| 				setLoadAll(c.length == 0); | ||||
| 			} catch (e) { | ||||
| 				if (e instanceof Error) { | ||||
| 					setError(e.message); | ||||
| 				} else { | ||||
| 					setError("unknown error"); | ||||
| 				} | ||||
| 			} | ||||
| 		}; | ||||
| 		load(); | ||||
| 	}, [props.diff]); | ||||
| 	const queryString = toQueryString(props.option ?? {}); | ||||
| 	if (state.documents === undefined && error == null) { | ||||
| 		return <LoadingCircle />; | ||||
| 	} else { | ||||
| 		return ( | ||||
| 			<Box | ||||
| 				sx={{ | ||||
| 					display: "grid", | ||||
| 					gridRowGap: "1rem", | ||||
| 				}} | ||||
| 			> | ||||
| 				{props.option !== undefined && props.diff !== "" && ( | ||||
| 					<Box> | ||||
| 						<Typography variant="h6">search for</Typography> | ||||
| 						{props.option.word !== undefined && ( | ||||
| 							<Chip label={"search : " + decodeURIComponent(props.option.word)}></Chip> | ||||
| 						)} | ||||
| 						{props.option.content_type !== undefined && <Chip label={"type : " + props.option.content_type}></Chip>} | ||||
| 						{props.option.allow_tag !== undefined && | ||||
| 							props.option.allow_tag.map((x) => ( | ||||
| 								<TagChip key={x} tagname={decodeURIComponent(x)} label={decodeURIComponent(x)} /> | ||||
| 							))} | ||||
| 					</Box> | ||||
| 				)} | ||||
| 				{state.documents && | ||||
| 					state.documents.map((x) => { | ||||
| 						return <ContentInfo document={x} key={x.id} gallery={`/search?${queryString}`} short />; | ||||
| 					})} | ||||
| 				{error && <Typography variant="h5">Error : {error}</Typography>} | ||||
| 				<Typography | ||||
| 					variant="body1" | ||||
| 					sx={{ | ||||
| 						justifyContent: "center", | ||||
| 						textAlign: "center", | ||||
| 					}} | ||||
| 				> | ||||
| 					{state.documents ? state.documents.length : "null"} loaded... | ||||
| 				</Typography> | ||||
| 				<Button onClick={() => loadMore()} disabled={loadAll} ref={elementRef}> | ||||
| 					{loadAll ? "Load All" : "Load More"} | ||||
| 				</Button> | ||||
| 			</Box> | ||||
| 		); | ||||
| 	} | ||||
| 	function loadMore() { | ||||
| 		let option = { ...props.option }; | ||||
| 		console.log(elementRef); | ||||
| 		if (state.documents === undefined || state.documents.length === 0) { | ||||
| 			console.log("loadall"); | ||||
| 			setLoadAll(true); | ||||
| 			return; | ||||
| 		} | ||||
| 		const prev_documents = state.documents; | ||||
| 		option.cursor = prev_documents[prev_documents.length - 1].id; | ||||
| 		console.log("load more", option); | ||||
| 		const load = async () => { | ||||
| 			const c = await ContentAccessor.findList(option); | ||||
| 			if (c.length === 0) { | ||||
| 				setLoadAll(true); | ||||
| 			} else { | ||||
| 				setState({ documents: [...prev_documents, ...c] }); | ||||
| 			} | ||||
| 		}; | ||||
| 		load(); | ||||
| 	} | ||||
| }; | ||||
| 
 | ||||
| export const Gallery = () => { | ||||
| 	const location = useLocation(); | ||||
| 	const query = QueryStringToMap(location.search); | ||||
| 	const menu_list = CommonMenuList({ url: location.search }); | ||||
| 	let option: QueryListOption = query; | ||||
| 	option.allow_tag = typeof option.allow_tag === "string" ? [option.allow_tag] : option.allow_tag; | ||||
| 	option.limit = typeof query["limit"] === "string" ? parseInt(query["limit"]) : undefined; | ||||
| 	return ( | ||||
| 		<Headline menu={menu_list}> | ||||
| 			<PagePad> | ||||
| 				<GalleryInfo diff={location.search} option={query}></GalleryInfo> | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										64
									
								
								packages/client/src/page/galleryPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								packages/client/src/page/galleryPage.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,64 @@ | |||
| import { useSearch } from "wouter"; | ||||
| import { Input } from "@/components/ui/input.tsx"; | ||||
| import { Button } from "@/components/ui/button.tsx"; | ||||
| import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; | ||||
| import TagBadge from "@/components/gallery/TagBadge.tsx"; | ||||
| import { useSearchGallery } from "../hook/useSearchGallery.ts"; | ||||
| import { Spinner } from "../components/Spinner.tsx"; | ||||
| 
 | ||||
| export default function Gallery() { | ||||
|     const search = useSearch(); | ||||
|     const searchParams = new URLSearchParams(search); | ||||
|     const word = searchParams.get("word") ?? undefined; | ||||
|     const tags = searchParams.get("allow_tag") ?? undefined; | ||||
|     const limit = searchParams.get("limit"); | ||||
|     const cursor = searchParams.get("cursor"); | ||||
|     const { data, error, isLoading, size, setSize } = useSearchGallery({ | ||||
|         word, tags, | ||||
|         limit: limit ? Number.parseInt(limit) : undefined, | ||||
|         cursor: cursor ? Number.parseInt(cursor) : undefined | ||||
|     }); | ||||
| 
 | ||||
|     if (isLoading) { | ||||
|         return <div className="p-4">Loading...</div> | ||||
|     } | ||||
|     if (error) { | ||||
|         return <div className="p-4">Error: {String(error)}</div> | ||||
|     } | ||||
| 
 | ||||
|     const isLoadingMore = data && size > 0 && (data[size - 1] === undefined); | ||||
|     const isReachingEnd = data && data[size - 1]?.hasMore === false; | ||||
| 
 | ||||
|     return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start"> | ||||
|         <div className="flex space-x-2"> | ||||
|             <Input className="flex-1" /> | ||||
|             <Button className="flex-none">Search</Button> | ||||
|         </div> | ||||
|         {(word || tags) && | ||||
|             <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md"> | ||||
|                 {word && <span className="text-primary-foreground ml-4">Search: {word}</span>} | ||||
|                 {tags && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex">{tags.split(",").map(x => <TagBadge tagname={x} key={x} />)}</ul></span>} | ||||
|             </div> | ||||
|         } | ||||
|         { | ||||
|             data?.length === 0 && <div className="p-4 text-3xl">No results</div> | ||||
|         } | ||||
|         { | ||||
|             // TODO: date based grouping 
 | ||||
|             data?.map((docs) => { | ||||
|                 return docs.data.map((x) => { | ||||
|                     return ( | ||||
|                         <GalleryCard doc={x} key={x.id} /> | ||||
|                     ); | ||||
|                 }); | ||||
|             }) | ||||
|         } | ||||
|         { | ||||
|             <Button className="w-full" onClick={() => setSize(size + 1)} | ||||
|                 disabled={isReachingEnd || isLoadingMore} | ||||
|             > {isLoadingMore && <Spinner className="mr-1" />}{size + 1} Load more</Button> | ||||
|         } | ||||
|     </div> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
|  | @ -1,90 +0,0 @@ | |||
| import { | ||||
| 	Button, | ||||
| 	Dialog, | ||||
| 	DialogActions, | ||||
| 	DialogContent, | ||||
| 	DialogContentText, | ||||
| 	DialogTitle, | ||||
| 	MenuList, | ||||
| 	Paper, | ||||
| 	TextField, | ||||
| 	Typography, | ||||
| 	useTheme, | ||||
| } from "@mui/material"; | ||||
| import React, { useContext, useState } from "react"; | ||||
| import { useNavigate } from "react-router-dom"; | ||||
| import { CommonMenuList, Headline } from "../component/mod"; | ||||
| import { UserContext } from "../state"; | ||||
| import { doLogin as doSessionLogin } from "../state"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| export const LoginPage = () => { | ||||
| 	const theme = useTheme(); | ||||
| 	const [userLoginInfo, setUserLoginInfo] = useState({ username: "", password: "" }); | ||||
| 	const [openDialog, setOpenDialog] = useState({ open: false, message: "" }); | ||||
| 	const { setUsername, setPermission } = useContext(UserContext); | ||||
| 	const navigate = useNavigate(); | ||||
| 	const handleDialogClose = () => { | ||||
| 		setOpenDialog({ ...openDialog, open: false }); | ||||
| 	}; | ||||
| 	const doLogin = async () => { | ||||
| 		try { | ||||
| 			const b = await doSessionLogin(userLoginInfo); | ||||
| 			if (typeof b === "string") { | ||||
| 				setOpenDialog({ open: true, message: b }); | ||||
| 				return; | ||||
| 			} | ||||
| 			console.log(`login as ${b.username}`); | ||||
| 			setUsername(b.username); | ||||
| 			setPermission(b.permission); | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof Error) { | ||||
| 				console.error(e); | ||||
| 				setOpenDialog({ open: true, message: e.message }); | ||||
| 			} else console.error(e); | ||||
| 			return; | ||||
| 		} | ||||
| 		navigate("/"); | ||||
| 	}; | ||||
| 	const menu = CommonMenuList(); | ||||
| 	return ( | ||||
| 		<Headline menu={menu}> | ||||
| 			<PagePad> | ||||
| 				<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf: "center" }}> | ||||
| 					<Typography variant="h4">Login</Typography> | ||||
| 					<div style={{ minHeight: theme.spacing(2) }}></div> | ||||
| 					<form style={{ display: "flex", flexFlow: "column", alignItems: "stretch" }}> | ||||
| 						<TextField | ||||
| 							label="username" | ||||
| 							onChange={(e) => setUserLoginInfo({ ...userLoginInfo, username: e.target.value ?? "" })} | ||||
| 						></TextField> | ||||
| 						<TextField | ||||
| 							label="password" | ||||
| 							type="password" | ||||
| 							onKeyDown={(e) => { | ||||
| 								if (e.key === "Enter") doLogin(); | ||||
| 							}} | ||||
| 							onChange={(e) => setUserLoginInfo({ ...userLoginInfo, password: e.target.value ?? "" })} | ||||
| 						/> | ||||
| 						<div style={{ minHeight: theme.spacing(2) }}></div> | ||||
| 						<div style={{ display: "flex" }}> | ||||
| 							<Button onClick={doLogin}>login</Button> | ||||
| 							<Button>signin</Button> | ||||
| 						</div> | ||||
| 					</form> | ||||
| 				</Paper> | ||||
| 				<Dialog open={openDialog.open} onClose={handleDialogClose}> | ||||
| 					<DialogTitle>Login Failed</DialogTitle> | ||||
| 					<DialogContent> | ||||
| 						<DialogContentText>detail : {openDialog.message}</DialogContentText> | ||||
| 					</DialogContent> | ||||
| 					<DialogActions> | ||||
| 						<Button onClick={handleDialogClose} color="primary" autoFocus> | ||||
| 							Close | ||||
| 						</Button> | ||||
| 					</DialogActions> | ||||
| 				</Dialog> | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										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.tsx"; | ||||
| import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx"; | ||||
| import { Input } from "@/components/ui/input.tsx"; | ||||
| import { Label } from "@/components/ui/label.tsx"; | ||||
| import { doLogin } from "@/state/user.ts"; | ||||
| 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"; | ||||
|  | @ -1,149 +0,0 @@ | |||
| import { | ||||
| 	Button, | ||||
| 	Chip, | ||||
| 	Dialog, | ||||
| 	DialogActions, | ||||
| 	DialogContent, | ||||
| 	DialogContentText, | ||||
| 	DialogTitle, | ||||
| 	Divider, | ||||
| 	Grid, | ||||
| 	Paper, | ||||
| 	TextField, | ||||
| 	Theme, | ||||
| 	Typography, | ||||
| } from "@mui/material"; | ||||
| import React, { useContext, useState } from "react"; | ||||
| import { CommonMenuList, Headline } from "../component/mod"; | ||||
| import { UserContext } from "../state"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| const useStyles = (theme: Theme) => ({ | ||||
| 	paper: { | ||||
| 		alignSelf: "center", | ||||
| 		padding: theme.spacing(2), | ||||
| 	}, | ||||
| 	formfield: { | ||||
| 		display: "flex", | ||||
| 		flexFlow: "column", | ||||
| 	}, | ||||
| }); | ||||
| 
 | ||||
| export function ProfilePage() { | ||||
| 	const userctx = useContext(UserContext); | ||||
| 	// const classes = useStyles();
 | ||||
| 	const menu = CommonMenuList(); | ||||
| 	const [pw_open, set_pw_open] = useState(false); | ||||
| 	const [oldpw, setOldpw] = useState(""); | ||||
| 	const [newpw, setNewpw] = useState(""); | ||||
| 	const [newpwch, setNewpwch] = useState(""); | ||||
| 	const [msg_dialog, set_msg_dialog] = useState({ opened: false, msg: "" }); | ||||
| 	const permission_list = userctx.permission.map((p) => <Chip key={p} label={p}></Chip>); | ||||
| 	const isElectronContent = ((window["electron"] as any) !== undefined) as boolean; | ||||
| 	const handle_open = () => set_pw_open(true); | ||||
| 	const handle_close = () => { | ||||
| 		set_pw_open(false); | ||||
| 		setNewpw(""); | ||||
| 		setNewpwch(""); | ||||
| 	}; | ||||
| 	const handle_ok = async () => { | ||||
| 		if (newpw != newpwch) { | ||||
| 			set_msg_dialog({ opened: true, msg: "password and password check is not equal." }); | ||||
| 			handle_close(); | ||||
| 			return; | ||||
| 		} | ||||
| 		if (isElectronContent) { | ||||
| 			const elec = window["electron"] as any; | ||||
| 			const success = elec.passwordReset(userctx.username, newpw); | ||||
| 			if (!success) { | ||||
| 				set_msg_dialog({ opened: true, msg: "user not exist." }); | ||||
| 			} | ||||
| 		} else { | ||||
| 			const res = await fetch("/user/reset", { | ||||
| 				method: "POST", | ||||
| 				body: JSON.stringify({ | ||||
| 					username: userctx.username, | ||||
| 					oldpassword: oldpw, | ||||
| 					newpassword: newpw, | ||||
| 				}), | ||||
| 				headers: { | ||||
| 					"content-type": "application/json", | ||||
| 				}, | ||||
| 			}); | ||||
| 			if (res.status != 200) { | ||||
| 				set_msg_dialog({ opened: true, msg: "failed to change password." }); | ||||
| 			} | ||||
| 		} | ||||
| 		handle_close(); | ||||
| 	}; | ||||
| 	return ( | ||||
| 		<Headline menu={menu}> | ||||
| 			<PagePad> | ||||
| 				<Paper /*className={classes.paper}*/> | ||||
| 					<Grid container direction="column" alignItems="center"> | ||||
| 						<Grid item> | ||||
| 							<Typography variant="h4">{userctx.username}</Typography> | ||||
| 						</Grid> | ||||
| 						<Divider></Divider> | ||||
| 						<Grid item>Permission</Grid> | ||||
| 						<Grid item>{permission_list.length == 0 ? "-" : permission_list}</Grid> | ||||
| 						<Grid item> | ||||
| 							<Button onClick={handle_open}>Password Reset</Button> | ||||
| 						</Grid> | ||||
| 					</Grid> | ||||
| 				</Paper> | ||||
| 				<Dialog open={pw_open} onClose={handle_close}> | ||||
| 					<DialogTitle>Password Reset</DialogTitle> | ||||
| 					<DialogContent> | ||||
| 						<Typography>type the old and new password</Typography> | ||||
| 						<div /*className={classes.formfield}*/> | ||||
| 							{!isElectronContent && ( | ||||
| 								<TextField | ||||
| 									autoFocus | ||||
| 									margin="dense" | ||||
| 									type="password" | ||||
| 									label="old password" | ||||
| 									value={oldpw} | ||||
| 									onChange={(e) => setOldpw(e.target.value)} | ||||
| 								></TextField> | ||||
| 							)} | ||||
| 							<TextField | ||||
| 								margin="dense" | ||||
| 								type="password" | ||||
| 								label="new password" | ||||
| 								value={newpw} | ||||
| 								onChange={(e) => setNewpw(e.target.value)} | ||||
| 							></TextField> | ||||
| 							<TextField | ||||
| 								margin="dense" | ||||
| 								type="password" | ||||
| 								label="new password check" | ||||
| 								value={newpwch} | ||||
| 								onChange={(e) => setNewpwch(e.target.value)} | ||||
| 							></TextField> | ||||
| 						</div> | ||||
| 					</DialogContent> | ||||
| 					<DialogActions> | ||||
| 						<Button onClick={handle_close} color="primary"> | ||||
| 							Cancel | ||||
| 						</Button> | ||||
| 						<Button onClick={handle_ok} color="primary"> | ||||
| 							Ok | ||||
| 						</Button> | ||||
| 					</DialogActions> | ||||
| 				</Dialog> | ||||
| 				<Dialog open={msg_dialog.opened} onClose={() => set_msg_dialog({ opened: false, msg: "" })}> | ||||
| 					<DialogTitle>Alert!</DialogTitle> | ||||
| 					<DialogContent> | ||||
| 						<DialogContentText>{msg_dialog.msg}</DialogContentText> | ||||
| 					</DialogContent> | ||||
| 					<DialogActions> | ||||
| 						<Button onClick={() => set_msg_dialog({ opened: false, msg: "" })} color="primary"> | ||||
| 							Close | ||||
| 						</Button> | ||||
| 					</DialogActions> | ||||
| 				</Dialog> | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	); | ||||
| } | ||||
							
								
								
									
										34
									
								
								packages/client/src/page/profilesPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								packages/client/src/page/profilesPage.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,34 @@ | |||
| import { Card, CardContent, 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" />; | ||||
|     } | ||||
|     // TODO: Add a logout button
 | ||||
|     // TODO: Add a change password button
 | ||||
|     return ( | ||||
|         <div className="p-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,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.ts"; | ||||
| 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,17 +0,0 @@ | |||
| import { Paper, Typography } from "@mui/material"; | ||||
| import React from "react"; | ||||
| import { CommonMenuList, Headline } from "../component/mod"; | ||||
| import { PagePad } from "../component/pagepad"; | ||||
| 
 | ||||
| export const SettingPage = () => { | ||||
| 	const menu = CommonMenuList(); | ||||
| 	return ( | ||||
| 		<Headline menu={menu}> | ||||
| 			<PagePad> | ||||
| 				<Paper> | ||||
| 					<Typography variant="h2">Setting</Typography> | ||||
| 				</Paper> | ||||
| 			</PagePad> | ||||
| 		</Headline> | ||||
| 	); | ||||
| }; | ||||
							
								
								
									
										93
									
								
								packages/client/src/page/settingPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								packages/client/src/page/settingPage.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | |||
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||||
| import { useEffect } from "react"; | ||||
| import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts"; | ||||
| import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; | ||||
| import { Label } from "@/components/ui/label"; | ||||
| 
 | ||||
| function LightModeView() { | ||||
|     return <div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent"> | ||||
|         <div className="space-y-2 rounded-sm bg-[#ecedef] p-2"> | ||||
|             <div className="space-y-2 rounded-md bg-white p-2 shadow-sm"> | ||||
|                 <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> | ||||
|             </div> | ||||
|             <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm"> | ||||
|                 <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> | ||||
|             </div> | ||||
|             <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm"> | ||||
|                 <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div>; | ||||
| } | ||||
| 
 | ||||
| function DarkModeView() { | ||||
|     return <div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"> | ||||
|         <div className="space-y-2 rounded-sm bg-slate-950 p-2"> | ||||
|             <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm"> | ||||
|                 <div className="h-2 w-[80px] rounded-lg bg-slate-400" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> | ||||
|             </div> | ||||
|             <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm"> | ||||
|                 <div className="h-4 w-4 rounded-full bg-slate-400" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> | ||||
|             </div> | ||||
|             <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm"> | ||||
|                 <div className="h-4 w-4 rounded-full bg-slate-400" /> | ||||
|                 <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| } | ||||
| 
 | ||||
| export function SettingPage() { | ||||
|     const { setTernaryDarkMode, ternaryDarkMode } = useTernaryDarkMode(); | ||||
|     const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); | ||||
| 
 | ||||
|     return ( | ||||
|         <div className="p-4"> | ||||
|             <Card> | ||||
|                 <CardHeader> | ||||
|                     <CardTitle className="text-2xl">Settings</CardTitle> | ||||
|                 </CardHeader> | ||||
|                 <CardContent> | ||||
|                     <div className="grid gap-4"> | ||||
|                         <div> | ||||
|                             <h3 className="text-lg">Appearance</h3> | ||||
|                             <span className="text-muted-foreground text-sm">Dark mode</span> | ||||
|                         </div> | ||||
|                         <RadioGroup value={ternaryDarkMode} onValueChange={(v) => setTernaryDarkMode(v as TernaryDarkMode)} | ||||
|                             className="flex space-x-2 items-center" | ||||
|                         > | ||||
|                             <RadioGroupItem id="dark" value="dark" className="sr-only" /> | ||||
|                             <Label htmlFor="dark"> | ||||
|                                 <div className="grid place-items-center"> | ||||
|                                     <DarkModeView /> | ||||
|                                     <span>Dark Mode</span> | ||||
|                                 </div> | ||||
|                             </Label> | ||||
|                             <RadioGroupItem id="light" value="light" className="sr-only" /> | ||||
|                             <Label htmlFor="light"> | ||||
|                                 <div className="grid place-items-center"> | ||||
|                                     <LightModeView /> | ||||
|                                     <span>Light Mode</span> | ||||
|                                 </div> | ||||
|                             </Label> | ||||
|                             <RadioGroupItem id="system" value="system" className="sr-only" /> | ||||
|                             <Label htmlFor="system"> | ||||
|                                 <div className="grid place-items-center"> | ||||
|                                     {isSystemDarkMode ? <DarkModeView /> : <LightModeView />} | ||||
|                                     <span>System Mode</span> | ||||
|                                 </div> | ||||
|                             </Label> | ||||
|                         </RadioGroup> | ||||
|                     </div> | ||||
|                 </CardContent> | ||||
|             </Card> | ||||
|         </div> | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| export default SettingPage; | ||||
|  | @ -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> | ||||
| 	); | ||||
| }; | ||||
|  | @ -1,94 +0,0 @@ | |||
| import React, { createContext, useRef, useState } from "react"; | ||||
| export const BackLinkContext = createContext({ backLink: "", setBackLink: (s: string) => {} }); | ||||
| 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; | ||||
| }; | ||||
							
								
								
									
										109
									
								
								packages/client/src/state/user.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								packages/client/src/state/user.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,109 @@ | |||
| import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts"; | ||||
| 
 | ||||
| type LoginLocalStorage = { | ||||
| 	username: string; | ||||
| 	permission: string[]; | ||||
| 	accessExpired: number; | ||||
| }; | ||||
| 
 | ||||
| let localObj: LoginLocalStorage | null = null; | ||||
| function getUserSessions() { | ||||
|     if (localObj === null) { | ||||
| 		const storagestr = 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, | ||||
|         }; | ||||
|     } | ||||
|     return null; | ||||
| } | ||||
| 
 | ||||
| async function refresh() { | ||||
|     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 = { | ||||
|             ...r | ||||
|         }; | ||||
|     } else { | ||||
|         localObj = { | ||||
|             accessExpired: 0, | ||||
|             username: "", | ||||
|             permission: r.permission, | ||||
|         }; | ||||
|     } | ||||
|     localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); | ||||
|     return { | ||||
|         username: r.username, | ||||
|         permission: r.permission, | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| export const doLogout = async () => { | ||||
| 	const req = await fetch("/api/user/logout", { | ||||
| 		method: "POST", | ||||
| 	}); | ||||
|     const setVal = setAtomValue(userLoginStateAtom); | ||||
| 	try { | ||||
| 		const res = await req.json(); | ||||
| 		localObj = { | ||||
| 			accessExpired: 0, | ||||
| 			username: "", | ||||
| 			permission: res.permission, | ||||
| 		}; | ||||
| 		window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); | ||||
|         setVal(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("/api/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; | ||||
| 	} | ||||
|     const setVal = setAtomValue(userLoginStateAtom); | ||||
| 	localObj = b; | ||||
|     setVal(b); | ||||
| 	window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); | ||||
| 	return b; | ||||
| }; | ||||
| 
 | ||||
| 
 | ||||
| export async function getInitialValue() { | ||||
|     const user = getUserSessions(); | ||||
|     if (user) { | ||||
|         return user; | ||||
|     } | ||||
|     return refresh(); | ||||
| } | ||||
| 
 | ||||
| export const userLoginStateAtom = atom("userLoginState", getUserSessions()); | ||||
| 
 | ||||
| export function useLogin() { | ||||
|     const val = useAtomValue(userLoginStateAtom); | ||||
|     return val; | ||||
| } | ||||
|  | @ -1,11 +1,77 @@ | |||
| /** @type {import('tailwindcss').Config} */ | ||||
| export default { | ||||
| module.exports = { | ||||
|   darkMode: ["class"], | ||||
|   content: [ | ||||
|     './src/**/*.{js,ts,jsx,tsx}' | ||||
|     './pages/**/*.{ts,tsx}', | ||||
|     './components/**/*.{ts,tsx}', | ||||
|     './app/**/*.{ts,tsx}', | ||||
|     './src/**/*.{ts,tsx}', | ||||
|   ], | ||||
|   prefix: "", | ||||
|   theme: { | ||||
|     extend: {}, | ||||
|     container: { | ||||
|       center: true, | ||||
|       padding: "2rem", | ||||
|       screens: { | ||||
|         "2xl": "1400px", | ||||
|       }, | ||||
|   plugins: [], | ||||
|     }, | ||||
|     extend: { | ||||
|       colors: { | ||||
|         border: "hsl(var(--border))", | ||||
|         input: "hsl(var(--input))", | ||||
|         ring: "hsl(var(--ring))", | ||||
|         background: "hsl(var(--background))", | ||||
|         foreground: "hsl(var(--foreground))", | ||||
|         primary: { | ||||
|           DEFAULT: "hsl(var(--primary))", | ||||
|           foreground: "hsl(var(--primary-foreground))", | ||||
|         }, | ||||
|         secondary: { | ||||
|           DEFAULT: "hsl(var(--secondary))", | ||||
|           foreground: "hsl(var(--secondary-foreground))", | ||||
|         }, | ||||
|         destructive: { | ||||
|           DEFAULT: "hsl(var(--destructive))", | ||||
|           foreground: "hsl(var(--destructive-foreground))", | ||||
|         }, | ||||
|         muted: { | ||||
|           DEFAULT: "hsl(var(--muted))", | ||||
|           foreground: "hsl(var(--muted-foreground))", | ||||
|         }, | ||||
|         accent: { | ||||
|           DEFAULT: "hsl(var(--accent))", | ||||
|           foreground: "hsl(var(--accent-foreground))", | ||||
|         }, | ||||
|         popover: { | ||||
|           DEFAULT: "hsl(var(--popover))", | ||||
|           foreground: "hsl(var(--popover-foreground))", | ||||
|         }, | ||||
|         card: { | ||||
|           DEFAULT: "hsl(var(--card))", | ||||
|           foreground: "hsl(var(--card-foreground))", | ||||
|         }, | ||||
|       }, | ||||
|       borderRadius: { | ||||
|         lg: "var(--radius)", | ||||
|         md: "calc(var(--radius) - 2px)", | ||||
|         sm: "calc(var(--radius) - 4px)", | ||||
|       }, | ||||
|       keyframes: { | ||||
|         "accordion-down": { | ||||
|           from: { height: "0" }, | ||||
|           to: { height: "var(--radix-accordion-content-height)" }, | ||||
|         }, | ||||
|         "accordion-up": { | ||||
|           from: { height: "var(--radix-accordion-content-height)" }, | ||||
|           to: { height: "0" }, | ||||
|         }, | ||||
|       }, | ||||
|       animation: { | ||||
|         "accordion-down": "accordion-down 0.2s ease-out", | ||||
|         "accordion-up": "accordion-up 0.2s ease-out", | ||||
|       }, | ||||
|     }, | ||||
|   }, | ||||
|   plugins: [require("tailwindcss-animate")], | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -14,6 +14,11 @@ | |||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
| 
 | ||||
|     "baseUrl": ".", | ||||
|     "paths": { | ||||
|       "@/*": ["./src/*"] | ||||
|     }, | ||||
| 
 | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
|  |  | |||
|  | @ -1,16 +1,18 @@ | |||
| import { defineConfig } from 'vite' | ||||
| import path from 'node:path' | ||||
| import react from '@vitejs/plugin-react-swc' | ||||
| 
 | ||||
| // https://vitejs.dev/config/
 | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
|   resolve: { | ||||
|     alias: { | ||||
|       '@': path.resolve(__dirname, './src'), | ||||
|     }, | ||||
|   }, | ||||
|   server: { | ||||
|     proxy: { | ||||
|       '/api': { | ||||
|         target: 'http://localhost:8080', | ||||
|         changeOrigin: true, | ||||
|         // rewrite: path => path.replace(/^\/api/, '')
 | ||||
|       } | ||||
|       '/api': "http://127.0.0.1:8080" | ||||
|     } | ||||
|   } | ||||
| }) | ||||
|  |  | |||
							
								
								
									
										53
									
								
								packages/dbtype/api.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								packages/dbtype/api.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,53 @@ | |||
| import type { JSONMap } from './jsonmap'; | ||||
| 
 | ||||
| export interface DocumentBody { | ||||
| 	title: string; | ||||
| 	content_type: string; | ||||
| 	basepath: string; | ||||
| 	filename: string; | ||||
| 	modified_at: number; | ||||
| 	content_hash: string | null; | ||||
| 	additional: JSONMap; | ||||
| 	tags: string[]; // eager loading
 | ||||
| } | ||||
| 
 | ||||
| export interface Document extends DocumentBody { | ||||
| 	readonly id: number; | ||||
| 	readonly created_at: number; | ||||
| 	readonly deleted_at: number | null; | ||||
| } | ||||
| 
 | ||||
| export type QueryListOption = { | ||||
| 	/** | ||||
| 	 * search word | ||||
| 	 */ | ||||
| 	word?: string; | ||||
| 	allow_tag?: string[]; | ||||
| 	/** | ||||
| 	 * limit of list | ||||
| 	 * @default 20 | ||||
| 	 */ | ||||
| 	limit?: number; | ||||
| 	/** | ||||
| 	 * use offset if true, otherwise | ||||
| 	 * @default false | ||||
| 	 */ | ||||
| 	use_offset?: boolean; | ||||
| 	/** | ||||
| 	 * cursor of documents | ||||
| 	 */ | ||||
| 	cursor?: number; | ||||
| 	/** | ||||
| 	 * offset of documents | ||||
| 	 */ | ||||
| 	offset?: number; | ||||
| 	/** | ||||
| 	 * tag eager loading | ||||
| 	 * @default true | ||||
| 	 */ | ||||
| 	eager_loading?: boolean; | ||||
| 	/** | ||||
| 	 * content type | ||||
| 	 */ | ||||
| 	content_type?: string; | ||||
| }; | ||||
							
								
								
									
										4
									
								
								packages/dbtype/jsonmap.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/dbtype/jsonmap.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,4 @@ | |||
| export type JSONPrimitive = null | boolean | number | string; | ||||
| export interface JSONMap extends Record<string, JSONType> {} | ||||
| export interface JSONArray extends Array<JSONType> {} | ||||
| export type JSONType = JSONMap | JSONPrimitive | JSONArray; | ||||
|  | @ -34,7 +34,7 @@ | |||
| 		"@types/koa-bodyparser": "^4.3.12", | ||||
| 		"@types/koa-compose": "^3.2.8", | ||||
| 		"@types/koa-router": "^7.4.8", | ||||
| 		"@types/node": "^14.18.63", | ||||
| 		"@types/node": ">=20.0.0", | ||||
| 		"@types/tiny-async-pool": "^1.0.5", | ||||
| 		"dbtype": "workspace:^", | ||||
| 		"nodemon": "^3.1.0" | ||||
|  |  | |||
|  | @ -1,7 +1,12 @@ | |||
| import { getKysely } from "./kysely"; | ||||
| import { jsonArrayFrom } from "kysely/helpers/sqlite"; | ||||
| import type { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc"; | ||||
| import { ParseJSONResultsPlugin, type NotNull } from "kysely"; | ||||
| import type { DocumentAccessor } from "../model/doc"; | ||||
| import type { | ||||
| 	Document, | ||||
| 	QueryListOption, | ||||
| 	DocumentBody | ||||
| } from "dbtype/api"; | ||||
| import type { NotNull } from "kysely"; | ||||
| import { MyParseJSONResultsPlugin } from "./plugin"; | ||||
| 
 | ||||
| export type DBTagContentRelation = { | ||||
|  | @ -144,7 +149,7 @@ class SqliteDocumentAccessor implements DocumentAccessor { | |||
| 			.selectAll() | ||||
| 			.$if(allow_tag.length > 0, (qb) => { | ||||
| 				return allow_tag.reduce((prevQb ,tag, index) => { | ||||
| 					return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.tag_name`, "document.id") | ||||
| 					return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "document.id") | ||||
| 						.where(`tags_${index}.tag_name`, "=", tag); | ||||
| 				}, qb) as unknown as typeof qb; | ||||
| 			}) | ||||
|  |  | |||
|  | @ -1,17 +1,9 @@ | |||
| import type { JSONMap } from "../types/json"; | ||||
| import { check_type } from "../util/type_check"; | ||||
| import { TagAccessor } from "./tag"; | ||||
| 
 | ||||
| export interface DocumentBody { | ||||
| 	title: string; | ||||
| 	content_type: string; | ||||
| 	basepath: string; | ||||
| 	filename: string; | ||||
| 	modified_at: number; | ||||
| 	content_hash: string | null; | ||||
| 	additional: JSONMap; | ||||
| 	tags: string[]; // eager loading
 | ||||
| } | ||||
| import type { | ||||
| 	DocumentBody,  | ||||
| 	Document, | ||||
| 	QueryListOption | ||||
| } from "dbtype/api"; | ||||
| 
 | ||||
| export const MetaContentBody = { | ||||
| 	title: "string", | ||||
|  | @ -27,12 +19,6 @@ export const isDocBody = (c: unknown): c is DocumentBody => { | |||
| 	return check_type<DocumentBody>(c, MetaContentBody); | ||||
| }; | ||||
| 
 | ||||
| export interface Document extends DocumentBody { | ||||
| 	readonly id: number; | ||||
| 	readonly created_at: number; | ||||
| 	readonly deleted_at: number | null; | ||||
| } | ||||
| 
 | ||||
| export const isDoc = (c: unknown): c is Document => { | ||||
| 	if (typeof c !== "object" || c === null) return false; | ||||
| 	if ("id" in c && typeof c.id === "number") { | ||||
|  | @ -42,41 +28,6 @@ export const isDoc = (c: unknown): c is Document => { | |||
| 	return false; | ||||
| }; | ||||
| 
 | ||||
| export type QueryListOption = { | ||||
| 	/** | ||||
| 	 * search word | ||||
| 	 */ | ||||
| 	word?: string; | ||||
| 	allow_tag?: string[]; | ||||
| 	/** | ||||
| 	 * limit of list | ||||
| 	 * @default 20 | ||||
| 	 */ | ||||
| 	limit?: number; | ||||
| 	/** | ||||
| 	 * use offset if true, otherwise | ||||
| 	 * @default false | ||||
| 	 */ | ||||
| 	use_offset?: boolean; | ||||
| 	/** | ||||
| 	 * cursor of documents | ||||
| 	 */ | ||||
| 	cursor?: number; | ||||
| 	/** | ||||
| 	 * offset of documents | ||||
| 	 */ | ||||
| 	offset?: number; | ||||
| 	/** | ||||
| 	 * tag eager loading | ||||
| 	 * @default true | ||||
| 	 */ | ||||
| 	eager_loading?: boolean; | ||||
| 	/** | ||||
| 	 * content type | ||||
| 	 */ | ||||
| 	content_type?: string; | ||||
| }; | ||||
| 
 | ||||
| export interface DocumentAccessor { | ||||
| 	/** | ||||
| 	 * find list by option | ||||
|  |  | |||
|  | @ -1,55 +1,76 @@ | |||
| import { type Context, DefaultContext, DefaultState, Next } from "koa"; | ||||
| import type { Context } from "koa"; | ||||
| import Router from "koa-router"; | ||||
| import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap"; | ||||
| import type { ContentContext } from "./context"; | ||||
| import { since_last_modified } from "./util"; | ||||
| import type { ZipReader } from "@zip.js/zip.js"; | ||||
| import type { FileHandle } from "node:fs/promises"; | ||||
| import { Readable, Writable } from "node:stream"; | ||||
| import { Readable } from "node:stream"; | ||||
| 
 | ||||
| /** | ||||
|  * zip stream cache. | ||||
|  */ | ||||
| const ZipStreamCache: { | ||||
| 	[path: string]: [{ | ||||
| const ZipStreamCache = new Map<string, { | ||||
| 	reader: ZipReader<FileHandle>, | ||||
| 		handle: FileHandle | ||||
| 	}, number] | ||||
| } = {}; | ||||
| 	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,11 +91,15 @@ async function renderZipImage(ctx: Context, path: string, page: number) { | |||
| 			}, | ||||
| 			close() { | ||||
| 				nodeReadableStream.push(null); | ||||
| 				setTimeout(() => { | ||||
| 					releaseZip(path); | ||||
| 				}, 100); | ||||
| 			}, | ||||
| 		})); | ||||
| 		nodeReadableStream.on("error", (err) => { | ||||
| 			console.error(err); | ||||
| 			releaseZip(path); | ||||
| 		}); | ||||
| 		nodeReadableStream.on("close", () => { | ||||
| 			releaseZip(path); | ||||
| 		}); | ||||
| 
 | ||||
| 		ctx.body = nodeReadableStream; | ||||
| 		ctx.response.length = entry.uncompressedSize; | ||||
|  |  | |||
|  | @ -1,8 +1,11 @@ | |||
| import type { Context, Next } from "koa"; | ||||
| import Router from "koa-router"; | ||||
| import { join } from "node:path"; | ||||
| import { type Document, type DocumentAccessor, isDocBody } from "../model/doc"; | ||||
| import type { QueryListOption } from "../model/doc"; | ||||
| import type { | ||||
| 	Document, | ||||
| 	QueryListOption, | ||||
| } from "dbtype/api"; | ||||
| import type { DocumentAccessor } from "../model/doc"; | ||||
| import { | ||||
| 	AdminOnlyMiddleware as AdminOnly, | ||||
| 	createPermissionCheckMiddleware as PerCheck, | ||||
|  | @ -43,7 +46,7 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex | |||
| 	) { | ||||
| 		return sendError(400, "paramter can not be array"); | ||||
| 	} | ||||
| 	const limit = ParseQueryNumber(query_limit); | ||||
| 	const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100); | ||||
| 	const cursor = ParseQueryNumber(query_cursor); | ||||
| 	const word = ParseQueryArgString(query_word); | ||||
| 	const content_type = ParseQueryArgString(query_content_type); | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -1,5 +1,4 @@ | |||
| import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs"; | ||||
| import { validate } from "jsonschema"; | ||||
| 
 | ||||
| export class ConfigManager<T extends object> { | ||||
| 	path: string; | ||||
|  | @ -37,10 +36,6 @@ export class ConfigManager<T extends object> { | |||
| 		if (this.emptyToDefault(ret)) { | ||||
| 			writeFileSync(this.path, JSON.stringify(ret)); | ||||
| 		} | ||||
| 		const result = validate(ret, this.schema); | ||||
| 		if (!result.valid) { | ||||
| 			throw new Error(result.toString()); | ||||
| 		} | ||||
| 		return ret; | ||||
| 	} | ||||
| 	async write_config_file(new_config: T) { | ||||
|  |  | |||
|  | @ -4,15 +4,12 @@ import { ZipReader, Reader, type Entry } from "@zip.js/zip.js"; | |||
| 
 | ||||
| class FileReader extends Reader<FileHandle> { | ||||
| 	private fd: FileHandle; | ||||
| 	private offset: number; | ||||
| 	constructor(fd: FileHandle) { | ||||
| 		super(fd); | ||||
| 		this.fd = fd; | ||||
| 		this.offset = 0; | ||||
| 	} | ||||
| 
 | ||||
| 	async init(): Promise<void> { | ||||
| 		this.offset = 0; | ||||
| 		this.size = (await this.fd.stat()).size; | ||||
| 	} | ||||
| 	close(): void { | ||||
|  | @ -34,10 +31,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 }; | ||||
| } | ||||
|  |  | |||
							
								
								
									
										1588
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										1588
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		
		Reference in a new issue