Rework #6
					 20 changed files with 510 additions and 574 deletions
				
			
		| 
						 | 
				
			
			@ -13,17 +13,20 @@
 | 
			
		|||
  "dependencies": {
 | 
			
		||||
    "@radix-ui/react-icons": "^1.3.0",
 | 
			
		||||
    "@radix-ui/react-label": "^2.0.2",
 | 
			
		||||
    "@radix-ui/react-radio-group": "^1.1.3",
 | 
			
		||||
    "@radix-ui/react-slot": "^1.0.2",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.0.7",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.0",
 | 
			
		||||
    "dbtype": "workspace:*",
 | 
			
		||||
    "jotai": "^2.7.2",
 | 
			
		||||
    "react": "^18.2.0",
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "react-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": {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ import NotFoundPage from "./page/404.tsx";
 | 
			
		|||
import LoginPage from "./page/loginPage.tsx";
 | 
			
		||||
import ProfilePage from "./page/profilesPage.tsx";
 | 
			
		||||
import ContentInfoPage from "./page/contentInfoPage.tsx";
 | 
			
		||||
import SettingPage from "./page/settingPage.tsx";
 | 
			
		||||
 | 
			
		||||
const App = () => {
 | 
			
		||||
	return (
 | 
			
		||||
| 
						 | 
				
			
			@ -33,10 +34,10 @@ const App = () => {
 | 
			
		|||
					<Route path="/login" component={LoginPage} />
 | 
			
		||||
					<Route path="/profile" component={ProfilePage}/>
 | 
			
		||||
					<Route path="/doc/:id" component={ContentInfoPage}/>
 | 
			
		||||
					<Route path="/setting" component={SettingPage} />
 | 
			
		||||
					{/* 
 | 
			
		||||
				<Route path="/doc/:id/reader" component={<ReaderPage />}></Route>
 | 
			
		||||
				<Route path="/difference" component={<DifferencePage />}></Route>
 | 
			
		||||
				<Route path="/setting" component={<SettingPage />}></Route>
 | 
			
		||||
			<Route path="/tags" component={<TagsPage />}></Route>*/}
 | 
			
		||||
					<Route component={NotFoundPage} />
 | 
			
		||||
				</Switch>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,99 +0,0 @@
 | 
			
		|||
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../../model/doc";
 | 
			
		||||
import { toQueryString } from "./util";
 | 
			
		||||
const baseurl = "/api/doc";
 | 
			
		||||
 | 
			
		||||
export * from "../../model/doc";
 | 
			
		||||
 | 
			
		||||
export class FetchFailError extends Error {}
 | 
			
		||||
 | 
			
		||||
export class ClientDocumentAccessor implements DocumentAccessor {
 | 
			
		||||
	search: (search_word: string) => Promise<Document[]>;
 | 
			
		||||
	addList: (content_list: DocumentBody[]) => Promise<number[]>;
 | 
			
		||||
	async findByPath(basepath: string, filename?: string): Promise<Document[]> {
 | 
			
		||||
		throw new Error("not allowed");
 | 
			
		||||
	}
 | 
			
		||||
	async findDeleted(content_type: string): Promise<Document[]> {
 | 
			
		||||
		throw new Error("not allowed");
 | 
			
		||||
	}
 | 
			
		||||
	async findList(option?: QueryListOption | undefined): Promise<Document[]> {
 | 
			
		||||
		let res = await fetch(`${baseurl}/search?${option !== undefined ? toQueryString(option) : ""}`);
 | 
			
		||||
		if (res.status == 401) throw new FetchFailError("Unauthorized");
 | 
			
		||||
		if (res.status !== 200) throw new FetchFailError("findList Failed");
 | 
			
		||||
		let ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined> {
 | 
			
		||||
		let res = await fetch(`${baseurl}/${id}`);
 | 
			
		||||
		if (res.status !== 200) throw new FetchFailError("findById Failed");
 | 
			
		||||
		let ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	/**
 | 
			
		||||
	 * not implement
 | 
			
		||||
	 */
 | 
			
		||||
	async findListByBasePath(basepath: string): Promise<Document[]> {
 | 
			
		||||
		throw new Error("not implement");
 | 
			
		||||
		return [];
 | 
			
		||||
	}
 | 
			
		||||
	async update(c: Partial<Document> & { id: number }): Promise<boolean> {
 | 
			
		||||
		const { id, ...rest } = c;
 | 
			
		||||
		const res = await fetch(`${baseurl}/${id}`, {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			body: JSON.stringify(rest),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"content-type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		const ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async add(c: DocumentBody): Promise<number> {
 | 
			
		||||
		throw new Error("not allow");
 | 
			
		||||
		const res = await fetch(`${baseurl}`, {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			body: JSON.stringify(c),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"content-type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		const ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async del(id: number): Promise<boolean> {
 | 
			
		||||
		const res = await fetch(`${baseurl}/${id}`, {
 | 
			
		||||
			method: "DELETE",
 | 
			
		||||
		});
 | 
			
		||||
		const ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async addTag(c: Document, tag_name: string): Promise<boolean> {
 | 
			
		||||
		const { id, ...rest } = c;
 | 
			
		||||
		const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			body: JSON.stringify(rest),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"content-type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		const ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async delTag(c: Document, tag_name: string): Promise<boolean> {
 | 
			
		||||
		const { id, ...rest } = c;
 | 
			
		||||
		const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
 | 
			
		||||
			method: "DELETE",
 | 
			
		||||
			body: JSON.stringify(rest),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"content-type": "application/json",
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		const ret = await res.json();
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
export const CDocumentAccessor = new ClientDocumentAccessor();
 | 
			
		||||
export const makeThumbnailUrl = (x: Document) => {
 | 
			
		||||
	return `${baseurl}/${x.id}/${x.content_type}/thumbnail`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default CDocumentAccessor;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,28 +0,0 @@
 | 
			
		|||
type Representable = string | number | boolean;
 | 
			
		||||
 | 
			
		||||
type ToQueryStringA = {
 | 
			
		||||
	[name: string]: Representable | Representable[] | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const toQueryString = (obj: ToQueryStringA) => {
 | 
			
		||||
	return Object.entries(obj)
 | 
			
		||||
		.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined)
 | 
			
		||||
		.map((e) => (e[1] instanceof Array ? e[1].map((f) => `${e[0]}=${f}`).join("&") : `${e[0]}=${e[1]}`))
 | 
			
		||||
		.join("&");
 | 
			
		||||
};
 | 
			
		||||
export const QueryStringToMap = (query: string) => {
 | 
			
		||||
	const keyValue = query.slice(query.indexOf("?") + 1).split("&");
 | 
			
		||||
	const param: { [k: string]: string | string[] } = {};
 | 
			
		||||
	keyValue.forEach((p) => {
 | 
			
		||||
		const [k, v] = p.split("=");
 | 
			
		||||
		const pv = param[k];
 | 
			
		||||
		if (pv === undefined) {
 | 
			
		||||
			param[k] = v;
 | 
			
		||||
		} else if (typeof pv === "string") {
 | 
			
		||||
			param[k] = [pv, v];
 | 
			
		||||
		} else {
 | 
			
		||||
			pv.push(v);
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
	return param;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +1,10 @@
 | 
			
		|||
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 { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
 | 
			
		||||
import { Link, useLocation } from "wouter";
 | 
			
		||||
import { LazyImage } from "./LazyImage.tsx";
 | 
			
		||||
import StyledLink from "./StyledLink.tsx";
 | 
			
		||||
 | 
			
		||||
function clipTagsWhenOverflow(tags: string[], limit: number) {
 | 
			
		||||
    let l = 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -22,12 +23,11 @@ export function GalleryCard({
 | 
			
		|||
}: { doc: Document; }) {
 | 
			
		||||
    const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
    const [clipCharCount, setClipCharCount] = useState(200);
 | 
			
		||||
    const [location] = useLocation();
 | 
			
		||||
 | 
			
		||||
    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.map(x => x.replace("artist:", "").replace("group:", ""));
 | 
			
		||||
    const originalTags = x.tags.filter(x => !x.startsWith("artist:") && !x.startsWith("group:"));
 | 
			
		||||
    const clippedTags = clipTagsWhenOverflow(originalTags, clipCharCount);
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -55,16 +55,27 @@ export function GalleryCard({
 | 
			
		|||
        <div className="flex-1 flex flex-col">
 | 
			
		||||
            <CardHeader className="flex-none">
 | 
			
		||||
                <CardTitle>
 | 
			
		||||
                    <Link href={`/doc/${x.id}`} state={{fromUrl: location}}>{x.title}</Link>
 | 
			
		||||
                    <StyledLink className="" to={`/doc/${x.id}`}>
 | 
			
		||||
                        {x.title}
 | 
			
		||||
                    </StyledLink>
 | 
			
		||||
                </CardTitle>
 | 
			
		||||
                <CardDescription>
 | 
			
		||||
                    {artists.join(", ")} {groups.length > 0 && "|"} {groups.join(", ")}
 | 
			
		||||
                    {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 tagname="..." className="" disabled />}
 | 
			
		||||
                    {clippedTags.length < originalTags.length && <TagBadge key={"..."} tagname="..." className="" disabled />}
 | 
			
		||||
                </ul>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
        </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								packages/client/src/components/gallery/StyledLink.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/client/src/components/gallery/StyledLink.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { cn } from "@/lib/utils";
 | 
			
		||||
import { Link } from "wouter";
 | 
			
		||||
 | 
			
		||||
type StyledLinkProps = {
 | 
			
		||||
    children?: React.ReactNode;
 | 
			
		||||
    className?: string;
 | 
			
		||||
    to: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function StyledLink({ children, className, ...rest }: StyledLinkProps) {
 | 
			
		||||
    return <Link {...rest}
 | 
			
		||||
        className={cn("hover:underline underline-offset-1 rounded-sm focus-visible:ring-1 focus-visible:ring-ring", className)}
 | 
			
		||||
    >{children}</Link>
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,28 +2,35 @@ import { badgeVariants } from "@/components/ui/badge.tsx";
 | 
			
		|||
import { Link } from "wouter";
 | 
			
		||||
import { cn } from "@/lib/utils.ts";
 | 
			
		||||
 | 
			
		||||
const femaleTagPrefix = "female:";
 | 
			
		||||
const maleTagPrefix = "male:";
 | 
			
		||||
 | 
			
		||||
function getTagKind(tagname: string) {
 | 
			
		||||
    if (tagname.startsWith(femaleTagPrefix)) {
 | 
			
		||||
        return "female";
 | 
			
		||||
    if (tagname.match(":") === null) {
 | 
			
		||||
        return "default";
 | 
			
		||||
    }
 | 
			
		||||
    if (tagname.startsWith(maleTagPrefix)){
 | 
			
		||||
        return "male";
 | 
			
		||||
    }
 | 
			
		||||
    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 `♂ ${tagname.slice(maleTagPrefix.length)}`;
 | 
			
		||||
            return `♂ ${name}`;
 | 
			
		||||
        case "female":
 | 
			
		||||
            return `♀ ${tagname.slice(femaleTagPrefix.length)}`;
 | 
			
		||||
        default:
 | 
			
		||||
            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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +42,11 @@ export default function TagBadge(props: { tagname: string, className?: string; d
 | 
			
		|||
            "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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										42
									
								
								packages/client/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								packages/client/src/components/ui/radio-group.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
import { CheckIcon } from "@radix-ui/react-icons"
 | 
			
		||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const RadioGroup = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
 | 
			
		||||
  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
 | 
			
		||||
>(({ className, ...props }, ref) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <RadioGroupPrimitive.Root
 | 
			
		||||
      className={cn("grid gap-2", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
      ref={ref}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
})
 | 
			
		||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
 | 
			
		||||
 | 
			
		||||
const RadioGroupItem = React.forwardRef<
 | 
			
		||||
  React.ElementRef<typeof RadioGroupPrimitive.Item>,
 | 
			
		||||
  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
 | 
			
		||||
>(({ className, ...props }, ref) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <RadioGroupPrimitive.Item
 | 
			
		||||
      ref={ref}
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
 | 
			
		||||
        <CheckIcon className="h-3.5 w-3.5 fill-primary" />
 | 
			
		||||
      </RadioGroupPrimitive.Indicator>
 | 
			
		||||
    </RadioGroupPrimitive.Item>
 | 
			
		||||
  )
 | 
			
		||||
})
 | 
			
		||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
 | 
			
		||||
 | 
			
		||||
export { RadioGroup, RadioGroupItem }
 | 
			
		||||
							
								
								
									
										4
									
								
								packages/client/src/hook/fetcher.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/client/src/hook/fetcher.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export async function fetcher(url: string) {
 | 
			
		||||
    const res = await fetch(url);
 | 
			
		||||
    return res.json();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										7
									
								
								packages/client/src/hook/useGalleryDoc.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/client/src/hook/useGalleryDoc.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import useSWR from "swr";
 | 
			
		||||
import type { Document } from "dbtype/api";
 | 
			
		||||
import { fetcher } from "./fetcher";
 | 
			
		||||
 | 
			
		||||
export function useGalleryDoc(id: string) {
 | 
			
		||||
    return useSWR<Document>(`/api/doc/${id}`, fetcher);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								packages/client/src/hook/useSearchGallery.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								packages/client/src/hook/useSearchGallery.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import useSWR from "swr";
 | 
			
		||||
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) {
 | 
			
		||||
    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());
 | 
			
		||||
    return useSWR<
 | 
			
		||||
        Document[]
 | 
			
		||||
    >(`/api/doc/search?${search.toString()}`, fetcher);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,20 +1,133 @@
 | 
			
		|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import { useGalleryDoc } from "../hook/useGalleryDoc";
 | 
			
		||||
import TagBadge from "@/components/gallery/TagBadge";
 | 
			
		||||
import StyledLink from "@/components/gallery/StyledLink";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
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) {
 | 
			
		||||
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);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        <div className="p-4">
 | 
			
		||||
            <h1>ContentInfoPage</h1>
 | 
			
		||||
            {params.id}
 | 
			
		||||
            <p>Find me in packages/client/src/page/contentInfoPage.tsx</p>
 | 
			
		||||
            <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>
 | 
			
		||||
            <Card className="flex-1">
 | 
			
		||||
                <CardHeader>
 | 
			
		||||
                    <CardTitle>{data.title}</CardTitle>
 | 
			
		||||
                    <CardDescription>
 | 
			
		||||
                        <StyledLink to={`/search?allow_tag=type:${classifiedTags.type[0] ?? ""}`} className="text-sm">
 | 
			
		||||
                            {classifiedTags.type[0] ?? "N/A"}
 | 
			
		||||
                        </StyledLink>
 | 
			
		||||
                    </CardDescription>
 | 
			
		||||
                </CardHeader>
 | 
			
		||||
                <CardContent>
 | 
			
		||||
                    <div className="grid gap-y-4 gap-x-3 lg:grid-cols-2">
 | 
			
		||||
                        <DescTagItem name="artist" items={classifiedTags.artist} />
 | 
			
		||||
                        <DescTagItem name="group" items={classifiedTags.group} />
 | 
			
		||||
                        <DescTagItem name="series" items={classifiedTags.series} />
 | 
			
		||||
                        <DescTagItem name="character" items={classifiedTags.character} />
 | 
			
		||||
                        <DescItem name="Created At">{new Date(data.created_at).toLocaleString()}</DescItem>
 | 
			
		||||
                        <DescItem name="Modified At">{new Date(data.modified_at).toLocaleString()}</DescItem>
 | 
			
		||||
                        <DescItem name="Path">{`${data.basepath}/${data.filename}`}</DescItem>
 | 
			
		||||
                        <DescItem name="Page Count">{JSON.stringify(data.additional)}</DescItem>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div className="grid mt-4">
 | 
			
		||||
                        <span className="text-muted-foreground text-sm">Tags</span>
 | 
			
		||||
                        <ul className="mt-2 flex flex-wrap gap-1">
 | 
			
		||||
                            {classifiedTags.rest.map((tag) => <TagBadge key={tag} tagname={tag} />)}
 | 
			
		||||
                        </ul>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </CardContent>
 | 
			
		||||
            </Card>
 | 
			
		||||
        </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ContentInfoPage;
 | 
			
		||||
 | 
			
		||||
function DescItem({ name, children, className }: {
 | 
			
		||||
    name: string,
 | 
			
		||||
    className?: string,
 | 
			
		||||
    children?: React.ReactNode
 | 
			
		||||
}) {
 | 
			
		||||
    return <div className={cn("grid content-start", className)}>
 | 
			
		||||
        <span className="text-muted-foreground text-sm">{name}</span>
 | 
			
		||||
        <span className="text-primary leading-4 font-medium">{children}</span>
 | 
			
		||||
    </div>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DescTagItem({
 | 
			
		||||
    items,
 | 
			
		||||
    name,
 | 
			
		||||
    className,
 | 
			
		||||
}: {
 | 
			
		||||
    name: string;
 | 
			
		||||
    items: string[];
 | 
			
		||||
    className?: string;
 | 
			
		||||
}) {
 | 
			
		||||
    return <DescItem name={name} className={className}>
 | 
			
		||||
        {items.length === 0 ? "N/A" : items.map(
 | 
			
		||||
            (x) => <StyledLink key={x} to={`/search?allow_tag=${name}:${x}`}>{x}</StyledLink>
 | 
			
		||||
        )}
 | 
			
		||||
    </DescItem>
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,136 +0,0 @@
 | 
			
		|||
import { IconButton, Theme, Typography } from "@mui/material";
 | 
			
		||||
import FullscreenIcon from "@mui/icons-material/Fullscreen";
 | 
			
		||||
import React, { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { Route, Routes, useLocation, useParams } from "react-router-dom";
 | 
			
		||||
import DocumentAccessor, { Document } from "../accessor/document";
 | 
			
		||||
import { LoadingCircle } from "../component/loading";
 | 
			
		||||
import { CommonMenuList, ContentInfo, Headline } from "../component/mod";
 | 
			
		||||
import { NotFoundPage } from "./404";
 | 
			
		||||
import { getPresenter } from "./reader/reader";
 | 
			
		||||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
 | 
			
		||||
export const makeComicReaderUrl = (id: number) => `/doc/${id}/reader`;
 | 
			
		||||
 | 
			
		||||
type DocumentState = {
 | 
			
		||||
	doc: Document | undefined;
 | 
			
		||||
	notfound: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function ReaderPage(props?: {}) {
 | 
			
		||||
	const location = useLocation();
 | 
			
		||||
	const match = useParams<{ id: string }>();
 | 
			
		||||
	if (match == null) {
 | 
			
		||||
		throw new Error("unreachable");
 | 
			
		||||
	}
 | 
			
		||||
	const id = Number.parseInt(match.id ?? "NaN");
 | 
			
		||||
	const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
 | 
			
		||||
	const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
 | 
			
		||||
	const fullScreenTargetRef = useRef<HTMLDivElement | null>(null);
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		(async () => {
 | 
			
		||||
			if (!isNaN(id)) {
 | 
			
		||||
				const c = await DocumentAccessor.findById(id);
 | 
			
		||||
				setInfo({ doc: c, notfound: c === undefined });
 | 
			
		||||
			}
 | 
			
		||||
		})();
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
	if (isNaN(id)) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<Typography variant="h2">Oops. Invalid ID</Typography>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else if (info.notfound) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<Typography variant="h2">Content has been removed.</Typography>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else if (info.doc === undefined) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<LoadingCircle />
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else {
 | 
			
		||||
		const ReaderPage = getPresenter(info.doc);
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline
 | 
			
		||||
				menu={menu_list(location.pathname)}
 | 
			
		||||
				rightAppbar={
 | 
			
		||||
					<IconButton
 | 
			
		||||
						edge="start"
 | 
			
		||||
						aria-label="account of current user"
 | 
			
		||||
						aria-haspopup="true"
 | 
			
		||||
						onClick={() => {
 | 
			
		||||
							if (fullScreenTargetRef.current != null && document.fullscreenEnabled) {
 | 
			
		||||
								fullScreenTargetRef.current.requestFullscreen();
 | 
			
		||||
							}
 | 
			
		||||
						}}
 | 
			
		||||
						color="inherit"
 | 
			
		||||
					>
 | 
			
		||||
						<FullscreenIcon />
 | 
			
		||||
					</IconButton>
 | 
			
		||||
				}
 | 
			
		||||
			>
 | 
			
		||||
				<ReaderPage doc={info.doc} fullScreenTarget={fullScreenTargetRef}></ReaderPage>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const DocumentAbout = (prop?: {}) => {
 | 
			
		||||
	const match = useParams<{ id: string }>();
 | 
			
		||||
	if (match == null) {
 | 
			
		||||
		throw new Error("unreachable");
 | 
			
		||||
	}
 | 
			
		||||
	const id = Number.parseInt(match.id ?? "NaN");
 | 
			
		||||
	const [info, setInfo] = useState<DocumentState>({ doc: undefined, notfound: false });
 | 
			
		||||
	const menu_list = (link?: string) => <CommonMenuList url={link}></CommonMenuList>;
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		(async () => {
 | 
			
		||||
			if (!isNaN(id)) {
 | 
			
		||||
				const c = await DocumentAccessor.findById(id);
 | 
			
		||||
				setInfo({ doc: c, notfound: c === undefined });
 | 
			
		||||
			}
 | 
			
		||||
		})();
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
	if (isNaN(id)) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<PagePad>
 | 
			
		||||
					<Typography variant="h2">Oops. Invalid ID</Typography>
 | 
			
		||||
				</PagePad>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else if (info.notfound) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<PagePad>
 | 
			
		||||
					<Typography variant="h2">Content has been removed.</Typography>
 | 
			
		||||
				</PagePad>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else if (info.doc === undefined) {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<PagePad>
 | 
			
		||||
					<LoadingCircle />
 | 
			
		||||
				</PagePad>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	} else {
 | 
			
		||||
		return (
 | 
			
		||||
			<Headline menu={menu_list()}>
 | 
			
		||||
				<PagePad>
 | 
			
		||||
					<ContentInfo document={info.doc}></ContentInfo>
 | 
			
		||||
				</PagePad>
 | 
			
		||||
			</Headline>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,38 +1,9 @@
 | 
			
		|||
import useSWR from "swr";
 | 
			
		||||
import { useSearch } from "wouter";
 | 
			
		||||
import type { Document } from "dbtype/api";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
async function fetcher(url: string) {
 | 
			
		||||
    const res = await fetch(url);
 | 
			
		||||
    return res.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface SearchParams {
 | 
			
		||||
    word?: string;
 | 
			
		||||
    tags?: string;
 | 
			
		||||
    limit?: number;
 | 
			
		||||
    cursor?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function useSearchGallery({
 | 
			
		||||
    word,
 | 
			
		||||
    tags,
 | 
			
		||||
    limit,
 | 
			
		||||
    cursor,
 | 
			
		||||
}: SearchParams) {
 | 
			
		||||
    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());
 | 
			
		||||
    return useSWR<
 | 
			
		||||
        Document[]
 | 
			
		||||
    >(`/api/doc/search?${search.toString()}`, fetcher);
 | 
			
		||||
}
 | 
			
		||||
import { useSearchGallery } from "../hook/useSearchGallery";
 | 
			
		||||
 | 
			
		||||
export default function Gallery() {
 | 
			
		||||
    const search = useSearch();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,90 +0,0 @@
 | 
			
		|||
import {
 | 
			
		||||
	Button,
 | 
			
		||||
	Dialog,
 | 
			
		||||
	DialogActions,
 | 
			
		||||
	DialogContent,
 | 
			
		||||
	DialogContentText,
 | 
			
		||||
	DialogTitle,
 | 
			
		||||
	MenuList,
 | 
			
		||||
	Paper,
 | 
			
		||||
	TextField,
 | 
			
		||||
	Typography,
 | 
			
		||||
	useTheme,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import React, { useContext, useState } from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { CommonMenuList, Headline } from "../component/mod";
 | 
			
		||||
import { UserContext } from "../state";
 | 
			
		||||
import { doLogin as doSessionLogin } from "../state";
 | 
			
		||||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
export const LoginPage = () => {
 | 
			
		||||
	const theme = useTheme();
 | 
			
		||||
	const [userLoginInfo, setUserLoginInfo] = useState({ username: "", password: "" });
 | 
			
		||||
	const [openDialog, setOpenDialog] = useState({ open: false, message: "" });
 | 
			
		||||
	const { setUsername, setPermission } = useContext(UserContext);
 | 
			
		||||
	const navigate = useNavigate();
 | 
			
		||||
	const handleDialogClose = () => {
 | 
			
		||||
		setOpenDialog({ ...openDialog, open: false });
 | 
			
		||||
	};
 | 
			
		||||
	const doLogin = async () => {
 | 
			
		||||
		try {
 | 
			
		||||
			const b = await doSessionLogin(userLoginInfo);
 | 
			
		||||
			if (typeof b === "string") {
 | 
			
		||||
				setOpenDialog({ open: true, message: b });
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
			console.log(`login as ${b.username}`);
 | 
			
		||||
			setUsername(b.username);
 | 
			
		||||
			setPermission(b.permission);
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			if (e instanceof Error) {
 | 
			
		||||
				console.error(e);
 | 
			
		||||
				setOpenDialog({ open: true, message: e.message });
 | 
			
		||||
			} else console.error(e);
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		navigate("/");
 | 
			
		||||
	};
 | 
			
		||||
	const menu = CommonMenuList();
 | 
			
		||||
	return (
 | 
			
		||||
		<Headline menu={menu}>
 | 
			
		||||
			<PagePad>
 | 
			
		||||
				<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf: "center" }}>
 | 
			
		||||
					<Typography variant="h4">Login</Typography>
 | 
			
		||||
					<div style={{ minHeight: theme.spacing(2) }}></div>
 | 
			
		||||
					<form style={{ display: "flex", flexFlow: "column", alignItems: "stretch" }}>
 | 
			
		||||
						<TextField
 | 
			
		||||
							label="username"
 | 
			
		||||
							onChange={(e) => setUserLoginInfo({ ...userLoginInfo, username: e.target.value ?? "" })}
 | 
			
		||||
						></TextField>
 | 
			
		||||
						<TextField
 | 
			
		||||
							label="password"
 | 
			
		||||
							type="password"
 | 
			
		||||
							onKeyDown={(e) => {
 | 
			
		||||
								if (e.key === "Enter") doLogin();
 | 
			
		||||
							}}
 | 
			
		||||
							onChange={(e) => setUserLoginInfo({ ...userLoginInfo, password: e.target.value ?? "" })}
 | 
			
		||||
						/>
 | 
			
		||||
						<div style={{ minHeight: theme.spacing(2) }}></div>
 | 
			
		||||
						<div style={{ display: "flex" }}>
 | 
			
		||||
							<Button onClick={doLogin}>login</Button>
 | 
			
		||||
							<Button>signin</Button>
 | 
			
		||||
						</div>
 | 
			
		||||
					</form>
 | 
			
		||||
				</Paper>
 | 
			
		||||
				<Dialog open={openDialog.open} onClose={handleDialogClose}>
 | 
			
		||||
					<DialogTitle>Login Failed</DialogTitle>
 | 
			
		||||
					<DialogContent>
 | 
			
		||||
						<DialogContentText>detail : {openDialog.message}</DialogContentText>
 | 
			
		||||
					</DialogContent>
 | 
			
		||||
					<DialogActions>
 | 
			
		||||
						<Button onClick={handleDialogClose} color="primary" autoFocus>
 | 
			
		||||
							Close
 | 
			
		||||
						</Button>
 | 
			
		||||
					</DialogActions>
 | 
			
		||||
				</Dialog>
 | 
			
		||||
			</PagePad>
 | 
			
		||||
		</Headline>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,149 +0,0 @@
 | 
			
		|||
import {
 | 
			
		||||
	Button,
 | 
			
		||||
	Chip,
 | 
			
		||||
	Dialog,
 | 
			
		||||
	DialogActions,
 | 
			
		||||
	DialogContent,
 | 
			
		||||
	DialogContentText,
 | 
			
		||||
	DialogTitle,
 | 
			
		||||
	Divider,
 | 
			
		||||
	Grid,
 | 
			
		||||
	Paper,
 | 
			
		||||
	TextField,
 | 
			
		||||
	Theme,
 | 
			
		||||
	Typography,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import React, { useContext, useState } from "react";
 | 
			
		||||
import { CommonMenuList, Headline } from "../component/mod";
 | 
			
		||||
import { UserContext } from "../state";
 | 
			
		||||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
const useStyles = (theme: Theme) => ({
 | 
			
		||||
	paper: {
 | 
			
		||||
		alignSelf: "center",
 | 
			
		||||
		padding: theme.spacing(2),
 | 
			
		||||
	},
 | 
			
		||||
	formfield: {
 | 
			
		||||
		display: "flex",
 | 
			
		||||
		flexFlow: "column",
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export function ProfilePage() {
 | 
			
		||||
	const userctx = useContext(UserContext);
 | 
			
		||||
	// const classes = useStyles();
 | 
			
		||||
	const menu = CommonMenuList();
 | 
			
		||||
	const [pw_open, set_pw_open] = useState(false);
 | 
			
		||||
	const [oldpw, setOldpw] = useState("");
 | 
			
		||||
	const [newpw, setNewpw] = useState("");
 | 
			
		||||
	const [newpwch, setNewpwch] = useState("");
 | 
			
		||||
	const [msg_dialog, set_msg_dialog] = useState({ opened: false, msg: "" });
 | 
			
		||||
	const permission_list = userctx.permission.map((p) => <Chip key={p} label={p}></Chip>);
 | 
			
		||||
	const isElectronContent = ((window["electron"] as any) !== undefined) as boolean;
 | 
			
		||||
	const handle_open = () => set_pw_open(true);
 | 
			
		||||
	const handle_close = () => {
 | 
			
		||||
		set_pw_open(false);
 | 
			
		||||
		setNewpw("");
 | 
			
		||||
		setNewpwch("");
 | 
			
		||||
	};
 | 
			
		||||
	const handle_ok = async () => {
 | 
			
		||||
		if (newpw != newpwch) {
 | 
			
		||||
			set_msg_dialog({ opened: true, msg: "password and password check is not equal." });
 | 
			
		||||
			handle_close();
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		if (isElectronContent) {
 | 
			
		||||
			const elec = window["electron"] as any;
 | 
			
		||||
			const success = elec.passwordReset(userctx.username, newpw);
 | 
			
		||||
			if (!success) {
 | 
			
		||||
				set_msg_dialog({ opened: true, msg: "user not exist." });
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			const res = await fetch("/user/reset", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				body: JSON.stringify({
 | 
			
		||||
					username: userctx.username,
 | 
			
		||||
					oldpassword: oldpw,
 | 
			
		||||
					newpassword: newpw,
 | 
			
		||||
				}),
 | 
			
		||||
				headers: {
 | 
			
		||||
					"content-type": "application/json",
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
			if (res.status != 200) {
 | 
			
		||||
				set_msg_dialog({ opened: true, msg: "failed to change password." });
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		handle_close();
 | 
			
		||||
	};
 | 
			
		||||
	return (
 | 
			
		||||
		<Headline menu={menu}>
 | 
			
		||||
			<PagePad>
 | 
			
		||||
				<Paper /*className={classes.paper}*/>
 | 
			
		||||
					<Grid container direction="column" alignItems="center">
 | 
			
		||||
						<Grid item>
 | 
			
		||||
							<Typography variant="h4">{userctx.username}</Typography>
 | 
			
		||||
						</Grid>
 | 
			
		||||
						<Divider></Divider>
 | 
			
		||||
						<Grid item>Permission</Grid>
 | 
			
		||||
						<Grid item>{permission_list.length == 0 ? "-" : permission_list}</Grid>
 | 
			
		||||
						<Grid item>
 | 
			
		||||
							<Button onClick={handle_open}>Password Reset</Button>
 | 
			
		||||
						</Grid>
 | 
			
		||||
					</Grid>
 | 
			
		||||
				</Paper>
 | 
			
		||||
				<Dialog open={pw_open} onClose={handle_close}>
 | 
			
		||||
					<DialogTitle>Password Reset</DialogTitle>
 | 
			
		||||
					<DialogContent>
 | 
			
		||||
						<Typography>type the old and new password</Typography>
 | 
			
		||||
						<div /*className={classes.formfield}*/>
 | 
			
		||||
							{!isElectronContent && (
 | 
			
		||||
								<TextField
 | 
			
		||||
									autoFocus
 | 
			
		||||
									margin="dense"
 | 
			
		||||
									type="password"
 | 
			
		||||
									label="old password"
 | 
			
		||||
									value={oldpw}
 | 
			
		||||
									onChange={(e) => setOldpw(e.target.value)}
 | 
			
		||||
								></TextField>
 | 
			
		||||
							)}
 | 
			
		||||
							<TextField
 | 
			
		||||
								margin="dense"
 | 
			
		||||
								type="password"
 | 
			
		||||
								label="new password"
 | 
			
		||||
								value={newpw}
 | 
			
		||||
								onChange={(e) => setNewpw(e.target.value)}
 | 
			
		||||
							></TextField>
 | 
			
		||||
							<TextField
 | 
			
		||||
								margin="dense"
 | 
			
		||||
								type="password"
 | 
			
		||||
								label="new password check"
 | 
			
		||||
								value={newpwch}
 | 
			
		||||
								onChange={(e) => setNewpwch(e.target.value)}
 | 
			
		||||
							></TextField>
 | 
			
		||||
						</div>
 | 
			
		||||
					</DialogContent>
 | 
			
		||||
					<DialogActions>
 | 
			
		||||
						<Button onClick={handle_close} color="primary">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</Button>
 | 
			
		||||
						<Button onClick={handle_ok} color="primary">
 | 
			
		||||
							Ok
 | 
			
		||||
						</Button>
 | 
			
		||||
					</DialogActions>
 | 
			
		||||
				</Dialog>
 | 
			
		||||
				<Dialog open={msg_dialog.opened} onClose={() => set_msg_dialog({ opened: false, msg: "" })}>
 | 
			
		||||
					<DialogTitle>Alert!</DialogTitle>
 | 
			
		||||
					<DialogContent>
 | 
			
		||||
						<DialogContentText>{msg_dialog.msg}</DialogContentText>
 | 
			
		||||
					</DialogContent>
 | 
			
		||||
					<DialogActions>
 | 
			
		||||
						<Button onClick={() => set_msg_dialog({ opened: false, msg: "" })} color="primary">
 | 
			
		||||
							Close
 | 
			
		||||
						</Button>
 | 
			
		||||
					</DialogActions>
 | 
			
		||||
				</Dialog>
 | 
			
		||||
			</PagePad>
 | 
			
		||||
		</Headline>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -8,6 +8,8 @@ export function ProfilePage() {
 | 
			
		|||
        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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +0,0 @@
 | 
			
		|||
import { Paper, Typography } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { CommonMenuList, Headline } from "../component/mod";
 | 
			
		||||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
export const SettingPage = () => {
 | 
			
		||||
	const menu = CommonMenuList();
 | 
			
		||||
	return (
 | 
			
		||||
		<Headline menu={menu}>
 | 
			
		||||
			<PagePad>
 | 
			
		||||
				<Paper>
 | 
			
		||||
					<Typography variant="h2">Setting</Typography>
 | 
			
		||||
				</Paper>
 | 
			
		||||
			</PagePad>
 | 
			
		||||
		</Headline>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										102
									
								
								packages/client/src/page/settingPage.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								packages/client/src/page/settingPage.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,102 @@
 | 
			
		|||
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, isDarkMode } = useTernaryDarkMode();
 | 
			
		||||
    const isSystemDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
 | 
			
		||||
 | 
			
		||||
    useEffect(() => {
 | 
			
		||||
        if (isDarkMode) {
 | 
			
		||||
            document.body.classList.add("dark");
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
            document.body.classList.remove("dark");
 | 
			
		||||
        }
 | 
			
		||||
    }, [isDarkMode]);
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
							
								
								
									
										150
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										150
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -20,6 +20,9 @@ importers:
 | 
			
		|||
      '@radix-ui/react-label':
 | 
			
		||||
        specifier: ^2.0.2
 | 
			
		||||
        version: 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-radio-group':
 | 
			
		||||
        specifier: ^1.1.3
 | 
			
		||||
        version: 1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-slot':
 | 
			
		||||
        specifier: ^1.0.2
 | 
			
		||||
        version: 1.0.2(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +38,9 @@ importers:
 | 
			
		|||
      dbtype:
 | 
			
		||||
        specifier: workspace:*
 | 
			
		||||
        version: link:../dbtype
 | 
			
		||||
      jotai:
 | 
			
		||||
        specifier: ^2.7.2
 | 
			
		||||
        version: 2.7.2(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      react:
 | 
			
		||||
        specifier: ^18.2.0
 | 
			
		||||
        version: 18.2.0
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +59,9 @@ importers:
 | 
			
		|||
      tailwindcss-animate:
 | 
			
		||||
        specifier: ^1.0.7
 | 
			
		||||
        version: 1.0.7(tailwindcss@3.4.3)
 | 
			
		||||
      usehooks-ts:
 | 
			
		||||
        specifier: ^3.1.0
 | 
			
		||||
        version: 3.1.0(react@18.2.0)
 | 
			
		||||
      wouter:
 | 
			
		||||
        specifier: ^3.1.0
 | 
			
		||||
        version: 3.1.0(react@18.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -987,6 +996,30 @@ packages:
 | 
			
		|||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      '@types/react-dom': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
      react-dom: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
      '@types/react-dom':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-slot': 1.0.2(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      '@types/react-dom': 18.2.22
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -1015,6 +1048,20 @@ packages:
 | 
			
		|||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-direction@1.0.1(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -1178,6 +1225,65 @@ packages:
 | 
			
		|||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      '@types/react-dom': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
      react-dom: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
      '@types/react-dom':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@radix-ui/primitive': 1.0.1
 | 
			
		||||
      '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-direction': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      '@types/react-dom': 18.2.22
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      '@types/react-dom': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
      react-dom: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
      '@types/react-dom':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@radix-ui/primitive': 1.0.1
 | 
			
		||||
      '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-context': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-direction': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-id': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.71)(react@18.2.0)
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      '@types/react-dom': 18.2.22
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
      react-dom: 18.2.0(react@18.2.0)
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-slot@1.0.2(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -1283,6 +1389,20 @@ packages:
 | 
			
		|||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '*'
 | 
			
		||||
      react: ^16.8 || ^17.0 || ^18.0
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@babel/runtime': 7.24.1
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
| 
						 | 
				
			
			@ -3443,6 +3563,22 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==}
 | 
			
		||||
    hasBin: true
 | 
			
		||||
 | 
			
		||||
  /jotai@2.7.2(@types/react@18.2.71)(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-6Ft5kpNu8p93Ssf1Faoza3hYQZRIYp7rioK8MwTTFnbQKwUyZElwquPwl1h6U0uo9hC0jr+ghO3gcSjc6P35/Q==}
 | 
			
		||||
    engines: {node: '>=12.20.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      '@types/react': '>=17.0.0'
 | 
			
		||||
      react: '>=17.0.0'
 | 
			
		||||
    peerDependenciesMeta:
 | 
			
		||||
      '@types/react':
 | 
			
		||||
        optional: true
 | 
			
		||||
      react:
 | 
			
		||||
        optional: true
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@types/react': 18.2.71
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /js-tokens@4.0.0:
 | 
			
		||||
    resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3678,6 +3814,10 @@ packages:
 | 
			
		|||
    resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==}
 | 
			
		||||
    dev: true
 | 
			
		||||
 | 
			
		||||
  /lodash.debounce@4.0.8:
 | 
			
		||||
    resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /lodash.includes@4.3.0:
 | 
			
		||||
    resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
 | 
			
		||||
    dev: false
 | 
			
		||||
| 
						 | 
				
			
			@ -5069,6 +5209,16 @@ packages:
 | 
			
		|||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /usehooks-ts@3.1.0(react@18.2.0):
 | 
			
		||||
    resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==}
 | 
			
		||||
    engines: {node: '>=16.15.0'}
 | 
			
		||||
    peerDependencies:
 | 
			
		||||
      react: ^16.8.0  || ^17 || ^18
 | 
			
		||||
    dependencies:
 | 
			
		||||
      lodash.debounce: 4.0.8
 | 
			
		||||
      react: 18.2.0
 | 
			
		||||
    dev: false
 | 
			
		||||
 | 
			
		||||
  /util-deprecate@1.0.2:
 | 
			
		||||
    resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue