Compare commits
No commits in common. "a9e646dd8192cde904d38512b67877cf047f0e22" and "7704389d17c0a8375ab3dc59d918ea8b94a12d7b" have entirely different histories.
a9e646dd81
...
7704389d17
@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"$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,30 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"shadcn": "shadcn-ui"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^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": {
|
"devDependencies": {
|
||||||
"@types/node": ">=20.0.0",
|
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
@ -41,7 +24,6 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react-refresh": "^0.4.6",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"shadcn-ui": "^0.8.0",
|
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
@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,50 +1,59 @@
|
|||||||
import { Route, Switch, Redirect } from "wouter";
|
|
||||||
import { useTernaryDarkMode } from "usehooks-ts";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
// import React, { createContext, useEffect, useRef, useState } from "react";
|
||||||
import { TooltipProvider } from "./components/ui/tooltip.tsx";
|
// import ReactDom from "react-dom";
|
||||||
import Layout from "./components/layout/layout.tsx";
|
// import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
// import {
|
||||||
import Gallery from "@/page/galleryPage.tsx";
|
// DifferencePage,
|
||||||
import NotFoundPage from "@/page/404.tsx";
|
// DocumentAbout,
|
||||||
import LoginPage from "@/page/loginPage.tsx";
|
// Gallery,
|
||||||
import ProfilePage from "@/page/profilesPage.tsx";
|
// LoginPage,
|
||||||
import ContentInfoPage from "@/page/contentInfoPage.tsx";
|
// NotFoundPage,
|
||||||
import SettingPage from "@/page/settingPage.tsx";
|
// ProfilePage,
|
||||||
import ComicPage from "@/page/reader/comicPage.tsx";
|
// ReaderPage,
|
||||||
|
// SettingPage,
|
||||||
|
// TagsPage,
|
||||||
|
// } from "./page/mod";
|
||||||
|
// import { getInitialValue, UserContext } from "./state";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const { isDarkMode } = useTernaryDarkMode();
|
// const [user, setUser] = useState("");
|
||||||
|
// const [userPermission, setUserPermission] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
// (async () => {
|
||||||
if (isDarkMode) {
|
// const { username, permission } = await getInitialValue();
|
||||||
document.body.classList.add("dark");
|
// if (username !== user) {
|
||||||
}
|
// setUser(username);
|
||||||
else {
|
// setUserPermission(permission);
|
||||||
document.body.classList.remove("dark");
|
// }
|
||||||
}
|
// })();
|
||||||
}, [isDarkMode]);
|
// useEffect(()=>{});
|
||||||
|
return ( <h1 className="text-3xl font-bold underline">
|
||||||
return (
|
Hello world!
|
||||||
<TooltipProvider>
|
</h1>
|
||||||
<Layout>
|
// <UserContext.Provider
|
||||||
<Switch>
|
// value={{
|
||||||
<Route path="/" component={() => <Redirect replace to="/search?" />} />
|
// username: user,
|
||||||
<Route path="/search" component={Gallery} />
|
// setUsername: setUser,
|
||||||
<Route path="/login" component={LoginPage} />
|
// permission: userPermission,
|
||||||
<Route path="/profile" component={ProfilePage}/>
|
// setPermission: setUserPermission,
|
||||||
<Route path="/doc/:id" component={ContentInfoPage}/>
|
// }}
|
||||||
<Route path="/setting" component={SettingPage} />
|
// >
|
||||||
<Route path="/doc/:id/reader" component={ComicPage}/>
|
// <BrowserRouter>
|
||||||
{/*
|
// <Routes>
|
||||||
<Route path="/difference" component={<DifferencePage />}/>
|
// <Route path="/" element={<Navigate replace to="/search?" />} />
|
||||||
*/}
|
// <Route path="/search" element={<Gallery />} />
|
||||||
<Route component={NotFoundPage} />
|
// <Route path="/doc/:id" element={<DocumentAbout />}></Route>
|
||||||
</Switch>
|
// <Route path="/doc/:id/reader" element={<ReaderPage />}></Route>
|
||||||
</Layout>
|
// <Route path="/login" element={<LoginPage></LoginPage>} />
|
||||||
</TooltipProvider>);
|
// <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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
99
packages/client/src/accessor/document.ts
Normal file
99
packages/client/src/accessor/document.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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;
|
28
packages/client/src/accessor/util.ts
Normal file
28
packages/client/src/accessor/util.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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;
|
||||||
|
};
|
238
packages/client/src/component/contentinfo.tsx
Normal file
238
packages/client/src/component/contentinfo.tsx
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
273
packages/client/src/component/headline.tsx
Normal file
273
packages/client/src/component/headline.tsx
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
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;
|
10
packages/client/src/component/loading.tsx
Normal file
10
packages/client/src/component/loading.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
5
packages/client/src/component/mod.ts
Normal file
5
packages/client/src/component/mod.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "./contentinfo";
|
||||||
|
export * from "./headline";
|
||||||
|
export * from "./loading";
|
||||||
|
export * from "./navlist";
|
||||||
|
export * from "./tagchip";
|
54
packages/client/src/component/navlist.tsx
Normal file
54
packages/client/src/component/navlist.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
5
packages/client/src/component/pagepad.tsx
Normal file
5
packages/client/src/component/pagepad.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { styled } from "@mui/material";
|
||||||
|
|
||||||
|
export const PagePad = styled("div")(({ theme }) => ({
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
}));
|
80
packages/client/src/component/tagchip.tsx
Normal file
80
packages/client/src/component/tagchip.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
};
|
@ -1,25 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
@ -1,82 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
@ -1,38 +0,0 @@
|
|||||||
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" />;
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
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 }
|
|
@ -1,57 +0,0 @@
|
|||||||
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 }
|
|
@ -1,76 +0,0 @@
|
|||||||
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 }
|
|
@ -1,25 +0,0 @@
|
|||||||
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 }
|
|
@ -1,24 +0,0 @@
|
|||||||
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 }
|
|
@ -1,42 +0,0 @@
|
|||||||
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 }
|
|
@ -1,43 +0,0 @@
|
|||||||
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 }
|
|
@ -1,15 +0,0 @@
|
|||||||
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 }
|
|
@ -1,28 +0,0 @@
|
|||||||
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 }
|
|
@ -1,4 +0,0 @@
|
|||||||
export async function fetcher(url: string) {
|
|
||||||
const res = await fetch(url);
|
|
||||||
return res.json();
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
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,76 +1,3 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
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());
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
@ -1,9 +1,15 @@
|
|||||||
|
import { Typography } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { CommonMenuList, Headline } from "../component/mod";
|
||||||
|
import { PagePad } from "../component/pagepad";
|
||||||
|
|
||||||
export const NotFoundPage = () => {
|
export const NotFoundPage = () => {
|
||||||
return (<div className="flex items-center justify-center flex-col box-border h-screen space-y-2">
|
const menu = CommonMenuList();
|
||||||
<h2 className="text-6xl">404 Not Found</h2>
|
return (
|
||||||
<p>찾을 수 없음</p>
|
<Headline menu={menu}>
|
||||||
</div>
|
<PagePad>
|
||||||
|
<Typography variant="h2">404 Not Found</Typography>
|
||||||
|
</PagePad>
|
||||||
|
</Headline>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NotFoundPage;
|
|
@ -1,142 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
136
packages/client/src/page/contentinfo.tsx
Normal file
136
packages/client/src/page/contentinfo.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
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 { Box, Button, Paper, Typography } from "@mui/material";
|
||||||
// import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
// import { CommonMenuList, Headline } from "../component/mod";
|
import { CommonMenuList, Headline } from "../component/mod";
|
||||||
// import { UserContext } from "../state";
|
import { UserContext } from "../state";
|
||||||
// import { PagePad } from "../component/pagepad";
|
import { PagePad } from "../component/pagepad";
|
||||||
|
|
||||||
// type FileDifference = {
|
type FileDifference = {
|
||||||
// type: string;
|
type: string;
|
||||||
// value: {
|
value: {
|
||||||
// type: string;
|
type: string;
|
||||||
// path: string;
|
path: string;
|
||||||
// }[];
|
}[];
|
||||||
// };
|
};
|
||||||
|
|
||||||
// function TypeDifference(prop: {
|
function TypeDifference(prop: {
|
||||||
// content: FileDifference;
|
content: FileDifference;
|
||||||
// onCommit: (v: { type: string; path: string }) => void;
|
onCommit: (v: { type: string; path: string }) => void;
|
||||||
// onCommitAll: (type: string) => void;
|
onCommitAll: (type: string) => void;
|
||||||
// }) {
|
}) {
|
||||||
// // const classes = useStyles();
|
// const classes = useStyles();
|
||||||
// const x = prop.content;
|
const x = prop.content;
|
||||||
// const [button_disable, set_disable] = useState(false);
|
const [button_disable, set_disable] = useState(false);
|
||||||
|
|
||||||
// return (
|
return (
|
||||||
// <Paper /*className={classes.paper}*/>
|
<Paper /*className={classes.paper}*/>
|
||||||
// <Box /*className={classes.contentTitle}*/>
|
<Box /*className={classes.contentTitle}*/>
|
||||||
// <Typography variant="h3">{x.type}</Typography>
|
<Typography variant="h3">{x.type}</Typography>
|
||||||
// <Button
|
<Button
|
||||||
// variant="contained"
|
variant="contained"
|
||||||
// key={x.type}
|
key={x.type}
|
||||||
// onClick={() => {
|
onClick={() => {
|
||||||
// set_disable(true);
|
set_disable(true);
|
||||||
// prop.onCommitAll(x.type);
|
prop.onCommitAll(x.type);
|
||||||
// set_disable(false);
|
set_disable(false);
|
||||||
// }}
|
}}
|
||||||
// >
|
>
|
||||||
// Commit all
|
Commit all
|
||||||
// </Button>
|
</Button>
|
||||||
// </Box>
|
</Box>
|
||||||
// {x.value.map((y) => (
|
{x.value.map((y) => (
|
||||||
// <Box sx={{ display: "flex" }} key={y.path}>
|
<Box sx={{ display: "flex" }} key={y.path}>
|
||||||
// <Button
|
<Button
|
||||||
// variant="contained"
|
variant="contained"
|
||||||
// onClick={() => {
|
onClick={() => {
|
||||||
// set_disable(true);
|
set_disable(true);
|
||||||
// prop.onCommit(y);
|
prop.onCommit(y);
|
||||||
// set_disable(false);
|
set_disable(false);
|
||||||
// }}
|
}}
|
||||||
// disabled={button_disable}
|
disabled={button_disable}
|
||||||
// >
|
>
|
||||||
// Commit
|
Commit
|
||||||
// </Button>
|
</Button>
|
||||||
// <Typography variant="h5">{y.path}</Typography>
|
<Typography variant="h5">{y.path}</Typography>
|
||||||
// </Box>
|
</Box>
|
||||||
// ))}
|
))}
|
||||||
// </Paper>
|
</Paper>
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// export function DifferencePage() {
|
export function DifferencePage() {
|
||||||
// const ctx = useContext(UserContext);
|
const ctx = useContext(UserContext);
|
||||||
// // const classes = useStyles();
|
// const classes = useStyles();
|
||||||
// const [diffList, setDiffList] = useState<FileDifference[]>([]);
|
const [diffList, setDiffList] = useState<FileDifference[]>([]);
|
||||||
// const doLoad = async () => {
|
const doLoad = async () => {
|
||||||
// const list = await fetch("/api/diff/list");
|
const list = await fetch("/api/diff/list");
|
||||||
// if (list.ok) {
|
if (list.ok) {
|
||||||
// const inner = await list.json();
|
const inner = await list.json();
|
||||||
// setDiffList(inner);
|
setDiffList(inner);
|
||||||
// } else {
|
} else {
|
||||||
// // setDiffList([]);
|
// setDiffList([]);
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
// const Commit = async (x: { type: string; path: string }) => {
|
const Commit = async (x: { type: string; path: string }) => {
|
||||||
// const res = await fetch("/api/diff/commit", {
|
const res = await fetch("/api/diff/commit", {
|
||||||
// method: "POST",
|
method: "POST",
|
||||||
// body: JSON.stringify([{ ...x }]),
|
body: JSON.stringify([{ ...x }]),
|
||||||
// headers: {
|
headers: {
|
||||||
// "content-type": "application/json",
|
"content-type": "application/json",
|
||||||
// },
|
},
|
||||||
// });
|
});
|
||||||
// const bb = await res.json();
|
const bb = await res.json();
|
||||||
// if (bb.ok) {
|
if (bb.ok) {
|
||||||
// doLoad();
|
doLoad();
|
||||||
// } else {
|
} else {
|
||||||
// console.error("fail to add document");
|
console.error("fail to add document");
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
// const CommitAll = async (type: string) => {
|
const CommitAll = async (type: string) => {
|
||||||
// const res = await fetch("/api/diff/commitall", {
|
const res = await fetch("/api/diff/commitall", {
|
||||||
// method: "POST",
|
method: "POST",
|
||||||
// body: JSON.stringify({ type: type }),
|
body: JSON.stringify({ type: type }),
|
||||||
// headers: {
|
headers: {
|
||||||
// "content-type": "application/json",
|
"content-type": "application/json",
|
||||||
// },
|
},
|
||||||
// });
|
});
|
||||||
// const bb = await res.json();
|
const bb = await res.json();
|
||||||
// if (bb.ok) {
|
if (bb.ok) {
|
||||||
// doLoad();
|
doLoad();
|
||||||
// } else {
|
} else {
|
||||||
// console.error("fail to add document");
|
console.error("fail to add document");
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// doLoad();
|
doLoad();
|
||||||
// const i = setInterval(doLoad, 5000);
|
const i = setInterval(doLoad, 5000);
|
||||||
// return () => {
|
return () => {
|
||||||
// clearInterval(i);
|
clearInterval(i);
|
||||||
// };
|
};
|
||||||
// }, []);
|
}, []);
|
||||||
// const menu = CommonMenuList();
|
const menu = CommonMenuList();
|
||||||
// return (
|
return (
|
||||||
// <Headline menu={menu}>
|
<Headline menu={menu}>
|
||||||
// <PagePad>
|
<PagePad>
|
||||||
// {ctx.username == "admin" ? (
|
{ctx.username == "admin" ? (
|
||||||
// <div>
|
<div>
|
||||||
// {diffList.map((x) => (
|
{diffList.map((x) => (
|
||||||
// <TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
|
<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
|
||||||
// ))}
|
))}
|
||||||
// </div>
|
</div>
|
||||||
// ) : (
|
) : (
|
||||||
// <Typography variant="h2">Not Allowed : please login as an admin</Typography>
|
<Typography variant="h2">Not Allowed : please login as an admin</Typography>
|
||||||
// )}
|
)}
|
||||||
// </PagePad>
|
</PagePad>
|
||||||
// </Headline>
|
</Headline>
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
|
133
packages/client/src/page/gallery.tsx
Normal file
133
packages/client/src/page/gallery.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
90
packages/client/src/page/login.tsx
Normal file
90
packages/client/src/page/login.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,58 +0,0 @@
|
|||||||
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;
|
|
8
packages/client/src/page/mod.ts
Normal file
8
packages/client/src/page/mod.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from "./404";
|
||||||
|
export * from "./contentinfo";
|
||||||
|
export * from "./difference";
|
||||||
|
export * from "./gallery";
|
||||||
|
export * from "./login";
|
||||||
|
export * from "./profile";
|
||||||
|
export * from "./setting";
|
||||||
|
export * from "./tags";
|
149
packages/client/src/page/profile.tsx
Normal file
149
packages/client/src/page/profile.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
83
packages/client/src/page/reader/comic.tsx
Normal file
83
packages/client/src/page/reader/comic.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
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;
|
@ -1,108 +0,0 @@
|
|||||||
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} />
|
|
||||||
)
|
|
||||||
}
|
|
80
packages/client/src/page/reader/reader.tsx
Normal file
80
packages/client/src/page/reader/reader.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
14
packages/client/src/page/reader/video.tsx
Normal file
14
packages/client/src/page/reader/video.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
17
packages/client/src/page/setting.tsx
Normal file
17
packages/client/src/page/setting.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -1,93 +0,0 @@
|
|||||||
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;
|
|
76
packages/client/src/page/tags.tsx
Normal file
76
packages/client/src/page/tags.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
94
packages/client/src/state.tsx
Normal file
94
packages/client/src/state.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
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;
|
||||||
|
};
|
@ -1,109 +0,0 @@
|
|||||||
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,77 +1,11 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
export default {
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
content: [
|
||||||
'./pages/**/*.{ts,tsx}',
|
'./src/**/*.{js,ts,jsx,tsx}'
|
||||||
'./components/**/*.{ts,tsx}',
|
|
||||||
'./app/**/*.{ts,tsx}',
|
|
||||||
'./src/**/*.{ts,tsx}',
|
|
||||||
],
|
],
|
||||||
prefix: "",
|
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
extend: {},
|
||||||
center: true,
|
|
||||||
padding: "2rem",
|
|
||||||
screens: {
|
|
||||||
"2xl": "1400px",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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")],
|
plugins: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,11 +14,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import path from 'node:path'
|
|
||||||
import react from '@vitejs/plugin-react-swc'
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': "http://127.0.0.1:8080"
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
// rewrite: path => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
@ -1,4 +0,0 @@
|
|||||||
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-bodyparser": "^4.3.12",
|
||||||
"@types/koa-compose": "^3.2.8",
|
"@types/koa-compose": "^3.2.8",
|
||||||
"@types/koa-router": "^7.4.8",
|
"@types/koa-router": "^7.4.8",
|
||||||
"@types/node": ">=20.0.0",
|
"@types/node": "^14.18.63",
|
||||||
"@types/tiny-async-pool": "^1.0.5",
|
"@types/tiny-async-pool": "^1.0.5",
|
||||||
"dbtype": "workspace:^",
|
"dbtype": "workspace:^",
|
||||||
"nodemon": "^3.1.0"
|
"nodemon": "^3.1.0"
|
||||||
|
@ -1,12 +1,7 @@
|
|||||||
import { getKysely } from "./kysely";
|
import { getKysely } from "./kysely";
|
||||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||||
import type { DocumentAccessor } from "../model/doc";
|
import type { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
|
||||||
import type {
|
import { ParseJSONResultsPlugin, type NotNull } from "kysely";
|
||||||
Document,
|
|
||||||
QueryListOption,
|
|
||||||
DocumentBody
|
|
||||||
} from "dbtype/api";
|
|
||||||
import type { NotNull } from "kysely";
|
|
||||||
import { MyParseJSONResultsPlugin } from "./plugin";
|
import { MyParseJSONResultsPlugin } from "./plugin";
|
||||||
|
|
||||||
export type DBTagContentRelation = {
|
export type DBTagContentRelation = {
|
||||||
@ -149,7 +144,7 @@ class SqliteDocumentAccessor implements DocumentAccessor {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.$if(allow_tag.length > 0, (qb) => {
|
.$if(allow_tag.length > 0, (qb) => {
|
||||||
return allow_tag.reduce((prevQb ,tag, index) => {
|
return allow_tag.reduce((prevQb ,tag, index) => {
|
||||||
return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "document.id")
|
return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.tag_name`, "document.id")
|
||||||
.where(`tags_${index}.tag_name`, "=", tag);
|
.where(`tags_${index}.tag_name`, "=", tag);
|
||||||
}, qb) as unknown as typeof qb;
|
}, qb) as unknown as typeof qb;
|
||||||
})
|
})
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
|
import type { JSONMap } from "../types/json";
|
||||||
import { check_type } from "../util/type_check";
|
import { check_type } from "../util/type_check";
|
||||||
import type {
|
import { TagAccessor } from "./tag";
|
||||||
DocumentBody,
|
|
||||||
Document,
|
export interface DocumentBody {
|
||||||
QueryListOption
|
title: string;
|
||||||
} from "dbtype/api";
|
content_type: string;
|
||||||
|
basepath: string;
|
||||||
|
filename: string;
|
||||||
|
modified_at: number;
|
||||||
|
content_hash: string | null;
|
||||||
|
additional: JSONMap;
|
||||||
|
tags: string[]; // eager loading
|
||||||
|
}
|
||||||
|
|
||||||
export const MetaContentBody = {
|
export const MetaContentBody = {
|
||||||
title: "string",
|
title: "string",
|
||||||
@ -19,6 +27,12 @@ export const isDocBody = (c: unknown): c is DocumentBody => {
|
|||||||
return check_type<DocumentBody>(c, MetaContentBody);
|
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 => {
|
export const isDoc = (c: unknown): c is Document => {
|
||||||
if (typeof c !== "object" || c === null) return false;
|
if (typeof c !== "object" || c === null) return false;
|
||||||
if ("id" in c && typeof c.id === "number") {
|
if ("id" in c && typeof c.id === "number") {
|
||||||
@ -28,6 +42,41 @@ export const isDoc = (c: unknown): c is Document => {
|
|||||||
return false;
|
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 {
|
export interface DocumentAccessor {
|
||||||
/**
|
/**
|
||||||
* find list by option
|
* find list by option
|
||||||
|
@ -1,76 +1,55 @@
|
|||||||
import type { Context } from "koa";
|
import { type Context, DefaultContext, DefaultState, Next } from "koa";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap";
|
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip } from "../util/zipwrap";
|
||||||
import type { ContentContext } from "./context";
|
import type { ContentContext } from "./context";
|
||||||
import { since_last_modified } from "./util";
|
import { since_last_modified } from "./util";
|
||||||
import type { ZipReader } from "@zip.js/zip.js";
|
import type { ZipReader } from "@zip.js/zip.js";
|
||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
import { Readable } from "node:stream";
|
import { Readable, Writable } from "node:stream";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* zip stream cache.
|
* zip stream cache.
|
||||||
*/
|
*/
|
||||||
const ZipStreamCache = new Map<string, {
|
const ZipStreamCache: {
|
||||||
reader: ZipReader<FileHandle>,
|
[path: string]: [{
|
||||||
handle: FileHandle,
|
reader: ZipReader<FileHandle>,
|
||||||
refCount: number,
|
handle: FileHandle
|
||||||
}>();
|
}, number]
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
async function acquireZip(path: string) {
|
||||||
function markUseZip(path: string) {
|
if (!(path in ZipStreamCache)) {
|
||||||
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);
|
const obj = await readZip(path);
|
||||||
const check = ZipStreamCache.get(path);
|
ZipStreamCache[path] = [obj, 1];
|
||||||
if (check) {
|
// console.log(`acquire ${path} 1`);
|
||||||
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;
|
return obj.reader;
|
||||||
}
|
}
|
||||||
if (!marked) {
|
const [ret, refCount] = ZipStreamCache[path];
|
||||||
ret.refCount++;
|
ZipStreamCache[path] = [ret, refCount + 1];
|
||||||
}
|
// console.log(`acquire ${path} ${refCount + 1}`);
|
||||||
return ret.reader;
|
return ret.reader;
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseZip(path: string) {
|
function releaseZip(path: string) {
|
||||||
const obj = ZipStreamCache.get(path);
|
const obj = ZipStreamCache[path];
|
||||||
if (obj === undefined) {
|
if (obj === undefined) throw new Error("error! key invalid");
|
||||||
console.warn(`warning! duplicate release at ${path}`);
|
const [ref, refCount] = obj;
|
||||||
return;
|
// console.log(`release ${path} : ${refCount}`);
|
||||||
}
|
if (refCount === 1) {
|
||||||
if (obj.refCount === 1) {
|
const { reader, handle } = ref;
|
||||||
const { reader, handle } = obj;
|
|
||||||
reader.close().then(() => {
|
reader.close().then(() => {
|
||||||
handle.close();
|
handle.close();
|
||||||
});
|
});
|
||||||
ZipStreamCache.delete(path);
|
delete ZipStreamCache[path];
|
||||||
} else {
|
} else {
|
||||||
obj.refCount--;
|
ZipStreamCache[path] = [ref, refCount - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderZipImage(ctx: Context, path: string, page: number) {
|
async function renderZipImage(ctx: Context, path: string, page: number) {
|
||||||
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
|
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
|
||||||
const marked = markUseZip(path);
|
// console.log(`opened ${page}`);
|
||||||
const zip = await acquireZip(path, marked);
|
const zip = await acquireZip(path);
|
||||||
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
|
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
|
||||||
const ext = x.filename.split(".").pop();
|
const ext = x.filename.split(".").pop();
|
||||||
return ext !== undefined && image_ext.includes(ext);
|
return ext !== undefined && image_ext.includes(ext);
|
||||||
@ -91,15 +70,11 @@ async function renderZipImage(ctx: Context, path: string, page: number) {
|
|||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
nodeReadableStream.push(null);
|
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.body = nodeReadableStream;
|
||||||
ctx.response.length = entry.uncompressedSize;
|
ctx.response.length = entry.uncompressedSize;
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import type { Context, Next } from "koa";
|
import type { Context, Next } from "koa";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type {
|
import { type Document, type DocumentAccessor, isDocBody } from "../model/doc";
|
||||||
Document,
|
import type { QueryListOption } from "../model/doc";
|
||||||
QueryListOption,
|
|
||||||
} from "dbtype/api";
|
|
||||||
import type { DocumentAccessor } from "../model/doc";
|
|
||||||
import {
|
import {
|
||||||
AdminOnlyMiddleware as AdminOnly,
|
AdminOnlyMiddleware as AdminOnly,
|
||||||
createPermissionCheckMiddleware as PerCheck,
|
createPermissionCheckMiddleware as PerCheck,
|
||||||
@ -46,7 +43,7 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex
|
|||||||
) {
|
) {
|
||||||
return sendError(400, "paramter can not be array");
|
return sendError(400, "paramter can not be array");
|
||||||
}
|
}
|
||||||
const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100);
|
const limit = ParseQueryNumber(query_limit);
|
||||||
const cursor = ParseQueryNumber(query_cursor);
|
const cursor = ParseQueryNumber(query_cursor);
|
||||||
const word = ParseQueryArgString(query_word);
|
const word = ParseQueryArgString(query_word);
|
||||||
const content_type = ParseQueryArgString(query_content_type);
|
const content_type = ParseQueryArgString(query_content_type);
|
||||||
|
@ -92,8 +92,8 @@ class ServerApplication {
|
|||||||
this.serve_static_file(router);
|
this.serve_static_file(router);
|
||||||
|
|
||||||
const login_router = createLoginRouter(this.userController);
|
const login_router = createLoginRouter(this.userController);
|
||||||
router.use("/api/user", login_router.routes());
|
router.use("/user", login_router.routes());
|
||||||
router.use("/api/user", login_router.allowedMethods());
|
router.use("/user", login_router.allowedMethods());
|
||||||
|
|
||||||
if (setting.mode === "development") {
|
if (setting.mode === "development") {
|
||||||
let mm_count = 0;
|
let mm_count = 0;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { validate } from "jsonschema";
|
||||||
|
|
||||||
export class ConfigManager<T extends object> {
|
export class ConfigManager<T extends object> {
|
||||||
path: string;
|
path: string;
|
||||||
@ -36,6 +37,10 @@ export class ConfigManager<T extends object> {
|
|||||||
if (this.emptyToDefault(ret)) {
|
if (this.emptyToDefault(ret)) {
|
||||||
writeFileSync(this.path, JSON.stringify(ret));
|
writeFileSync(this.path, JSON.stringify(ret));
|
||||||
}
|
}
|
||||||
|
const result = validate(ret, this.schema);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(result.toString());
|
||||||
|
}
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
async write_config_file(new_config: T) {
|
async write_config_file(new_config: T) {
|
||||||
|
@ -4,12 +4,15 @@ import { ZipReader, Reader, type Entry } from "@zip.js/zip.js";
|
|||||||
|
|
||||||
class FileReader extends Reader<FileHandle> {
|
class FileReader extends Reader<FileHandle> {
|
||||||
private fd: FileHandle;
|
private fd: FileHandle;
|
||||||
|
private offset: number;
|
||||||
constructor(fd: FileHandle) {
|
constructor(fd: FileHandle) {
|
||||||
super(fd);
|
super(fd);
|
||||||
this.fd = fd;
|
this.fd = fd;
|
||||||
|
this.offset = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(): Promise<void> {
|
async init(): Promise<void> {
|
||||||
|
this.offset = 0;
|
||||||
this.size = (await this.fd.stat()).size;
|
this.size = (await this.fd.stat()).size;
|
||||||
}
|
}
|
||||||
close(): void {
|
close(): void {
|
||||||
@ -31,10 +34,10 @@ export async function readZip(path: string): Promise<{
|
|||||||
reader: ZipReader<FileHandle>
|
reader: ZipReader<FileHandle>
|
||||||
handle: FileHandle
|
handle: FileHandle
|
||||||
}> {
|
}> {
|
||||||
const fd = await open(path, "r");
|
const fd = await open(path);
|
||||||
const reader = new ZipReader(new FileReader(fd), {
|
const reader = new ZipReader(new FileReader(fd), {
|
||||||
useCompressionStream: true,
|
useCompressionStream: true,
|
||||||
preventClose: true,
|
preventClose: false,
|
||||||
});
|
});
|
||||||
return { reader, handle: fd };
|
return { reader, handle: fd };
|
||||||
}
|
}
|
||||||
|
1588
pnpm-lock.yaml
1588
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user