Rework #6
					 18 changed files with 418 additions and 208 deletions
				
			
		| 
						 | 
				
			
			@ -16,6 +16,7 @@
 | 
			
		|||
    "@radix-ui/react-tooltip": "^1.0.7",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.0",
 | 
			
		||||
    "dbtype": "workspace:*",
 | 
			
		||||
    "react": "^18.2.0",
 | 
			
		||||
    "react-dom": "^18.2.0",
 | 
			
		||||
    "react-resizable-panels": "^2.0.16",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										66
									
								
								packages/client/src/components/gallery/GalleryCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								packages/client/src/components/gallery/GalleryCard.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,66 @@
 | 
			
		|||
import type { Document } from "dbtype/api";
 | 
			
		||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 | 
			
		||||
import TagBadge from "@/components/gallery/TagBadge";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
 | 
			
		||||
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.map(x => x.replace("artist:", "").replace("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 key={x.id} className="flex h-[200px]">
 | 
			
		||||
        <div className="rounded-xl overflow-hidden h-[200px] w-[142px] flex-none bg-[#272733] flex items-center justify-center">
 | 
			
		||||
            <img src={`/api/doc/${x.id}/comic/thumbnail`}
 | 
			
		||||
                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>{x.title}</CardTitle>
 | 
			
		||||
                <CardDescription>
 | 
			
		||||
                    {artists.join(", ")} {groups.length > 0 && "|"} {groups.join(", ")}
 | 
			
		||||
                </CardDescription>
 | 
			
		||||
            </CardHeader>
 | 
			
		||||
            <CardContent className="flex-1" ref={ref}>
 | 
			
		||||
                <li className="flex flex-wrap gap-2 items-baseline content-start">
 | 
			
		||||
                    {clippedTags.map(tag => <TagBadge key={tag} tagname={tag} className="" />)}
 | 
			
		||||
                    {clippedTags.length < originalTags.length && <TagBadge tagname="..." className="" />}
 | 
			
		||||
                </li>
 | 
			
		||||
            </CardContent>
 | 
			
		||||
        </div>
 | 
			
		||||
    </Card>;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										41
									
								
								packages/client/src/components/gallery/TagBadge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								packages/client/src/components/gallery/TagBadge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
import { Badge, badgeVariants } from "@/components/ui/badge";
 | 
			
		||||
import { cn } from "@/lib/utils";
 | 
			
		||||
 | 
			
		||||
const femaleTagPrefix = "female:";
 | 
			
		||||
const maleTagPrefix = "male:";
 | 
			
		||||
 | 
			
		||||
function getTagKind(tagname: string) {
 | 
			
		||||
    if (tagname.startsWith(femaleTagPrefix)) {
 | 
			
		||||
        return "female";
 | 
			
		||||
    }
 | 
			
		||||
    if (tagname.startsWith(maleTagPrefix)){
 | 
			
		||||
        return "male";
 | 
			
		||||
    }
 | 
			
		||||
    return "default";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toPrettyTagname(tagname: string): string {
 | 
			
		||||
    const kind = getTagKind(tagname);
 | 
			
		||||
    switch (kind) {
 | 
			
		||||
        case "male":
 | 
			
		||||
            return `♂ ${tagname.slice(maleTagPrefix.length)}`;
 | 
			
		||||
        case "female":
 | 
			
		||||
            return `♀ ${tagname.slice(femaleTagPrefix.length)}`;
 | 
			
		||||
        default:
 | 
			
		||||
            return tagname;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function TagBadge(props: { tagname: string, className?: string}) {
 | 
			
		||||
    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 === "default" && "bg-[#4a5568] hover:bg-[#718096]",
 | 
			
		||||
            props.className,
 | 
			
		||||
        )
 | 
			
		||||
    }>{toPrettyTagname(tagname)}</li>;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -17,7 +17,6 @@ export default function Layout({ children }: LayoutProps) {
 | 
			
		|||
            "[data-panel-resize-handle-id]"
 | 
			
		||||
        );
 | 
			
		||||
        if (!panelGroup || !resizeHandles) return;
 | 
			
		||||
        console.log(panelGroup, resizeHandles);
 | 
			
		||||
        const observer = new ResizeObserver(() => {
 | 
			
		||||
            let width = panelGroup?.clientWidth;
 | 
			
		||||
            if (!width) return;
 | 
			
		||||
| 
						 | 
				
			
			@ -42,7 +41,7 @@ export default function Layout({ children }: LayoutProps) {
 | 
			
		|||
                <NavList />
 | 
			
		||||
            </ResizablePanel>
 | 
			
		||||
            <ResizableHandle withHandle />
 | 
			
		||||
            <ResizablePanel>
 | 
			
		||||
            <ResizablePanel >
 | 
			
		||||
                {children}
 | 
			
		||||
            </ResizablePanel>
 | 
			
		||||
        </ResizablePanelGroup>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										36
									
								
								packages/client/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/client/src/components/ui/badge.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
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 }
 | 
			
		||||
							
								
								
									
										76
									
								
								packages/client/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								packages/client/src/components/ui/card.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,76 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const Card = React.forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLDivElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "rounded-xl border bg-card text-card-foreground shadow",
 | 
			
		||||
      className
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
Card.displayName = "Card"
 | 
			
		||||
 | 
			
		||||
const CardHeader = React.forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLDivElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn("flex flex-col space-y-1.5 p-6", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
CardHeader.displayName = "CardHeader"
 | 
			
		||||
 | 
			
		||||
const CardTitle = React.forwardRef<
 | 
			
		||||
  HTMLParagraphElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLHeadingElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <h3
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn("font-semibold leading-none tracking-tight", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
CardTitle.displayName = "CardTitle"
 | 
			
		||||
 | 
			
		||||
const CardDescription = React.forwardRef<
 | 
			
		||||
  HTMLParagraphElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLParagraphElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <p
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn("text-sm text-muted-foreground", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
CardDescription.displayName = "CardDescription"
 | 
			
		||||
 | 
			
		||||
const CardContent = React.forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLDivElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
 | 
			
		||||
))
 | 
			
		||||
CardContent.displayName = "CardContent"
 | 
			
		||||
 | 
			
		||||
const CardFooter = React.forwardRef<
 | 
			
		||||
  HTMLDivElement,
 | 
			
		||||
  React.HTMLAttributes<HTMLDivElement>
 | 
			
		||||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <div
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn("flex items-center p-6 pt-0", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
CardFooter.displayName = "CardFooter"
 | 
			
		||||
 | 
			
		||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
 | 
			
		||||
							
								
								
									
										25
									
								
								packages/client/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/client/src/components/ui/input.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
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 }
 | 
			
		||||
							
								
								
									
										15
									
								
								packages/client/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								packages/client/src/components/ui/skeleton.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
function Skeleton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.HTMLAttributes<HTMLDivElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      className={cn("animate-pulse rounded-md bg-primary/10", className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Skeleton }
 | 
			
		||||
| 
						 | 
				
			
			@ -1,133 +0,0 @@
 | 
			
		|||
import React, { useContext, useEffect, useState } from "react";
 | 
			
		||||
import { CommonMenuList, ContentInfo, Headline, LoadingCircle, NavItem, NavList, TagChip } from "../component/mod";
 | 
			
		||||
 | 
			
		||||
import { Box, Button, Chip, Pagination, Typography } from "@mui/material";
 | 
			
		||||
import ContentAccessor, { Document, QueryListOption } from "../accessor/document";
 | 
			
		||||
import { toQueryString } from "../accessor/util";
 | 
			
		||||
 | 
			
		||||
import { useLocation } from "react-router-dom";
 | 
			
		||||
import { QueryStringToMap } from "../accessor/util";
 | 
			
		||||
import { useIsElementInViewport } from "./reader/reader";
 | 
			
		||||
import { PagePad } from "../component/pagepad";
 | 
			
		||||
 | 
			
		||||
export type GalleryProp = {
 | 
			
		||||
	option?: QueryListOption;
 | 
			
		||||
	diff: string;
 | 
			
		||||
};
 | 
			
		||||
type GalleryState = {
 | 
			
		||||
	documents: Document[] | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const GalleryInfo = (props: GalleryProp) => {
 | 
			
		||||
	const [state, setState] = useState<GalleryState>({ documents: undefined });
 | 
			
		||||
	const [error, setError] = useState<string | null>(null);
 | 
			
		||||
	const [loadAll, setLoadAll] = useState(false);
 | 
			
		||||
	const { elementRef, isVisible: isLoadVisible } = useIsElementInViewport<HTMLButtonElement>({});
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		if (isLoadVisible && !loadAll && state.documents != undefined) {
 | 
			
		||||
			loadMore();
 | 
			
		||||
		}
 | 
			
		||||
	}, [isLoadVisible]);
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const abortController = new AbortController();
 | 
			
		||||
		console.log("load first", props.option);
 | 
			
		||||
		const load = async () => {
 | 
			
		||||
			try {
 | 
			
		||||
				const c = await ContentAccessor.findList(props.option);
 | 
			
		||||
				// todo : if c is undefined, retry to fetch 3 times. and show error message.
 | 
			
		||||
				setState({ documents: c });
 | 
			
		||||
				setLoadAll(c.length == 0);
 | 
			
		||||
			} catch (e) {
 | 
			
		||||
				if (e instanceof Error) {
 | 
			
		||||
					setError(e.message);
 | 
			
		||||
				} else {
 | 
			
		||||
					setError("unknown error");
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
		load();
 | 
			
		||||
	}, [props.diff]);
 | 
			
		||||
	const queryString = toQueryString(props.option ?? {});
 | 
			
		||||
	if (state.documents === undefined && error == null) {
 | 
			
		||||
		return <LoadingCircle />;
 | 
			
		||||
	} else {
 | 
			
		||||
		return (
 | 
			
		||||
			<Box
 | 
			
		||||
				sx={{
 | 
			
		||||
					display: "grid",
 | 
			
		||||
					gridRowGap: "1rem",
 | 
			
		||||
				}}
 | 
			
		||||
			>
 | 
			
		||||
				{props.option !== undefined && props.diff !== "" && (
 | 
			
		||||
					<Box>
 | 
			
		||||
						<Typography variant="h6">search for</Typography>
 | 
			
		||||
						{props.option.word !== undefined && (
 | 
			
		||||
							<Chip label={"search : " + decodeURIComponent(props.option.word)}></Chip>
 | 
			
		||||
						)}
 | 
			
		||||
						{props.option.content_type !== undefined && <Chip label={"type : " + props.option.content_type}></Chip>}
 | 
			
		||||
						{props.option.allow_tag !== undefined &&
 | 
			
		||||
							props.option.allow_tag.map((x) => (
 | 
			
		||||
								<TagChip key={x} tagname={decodeURIComponent(x)} label={decodeURIComponent(x)} />
 | 
			
		||||
							))}
 | 
			
		||||
					</Box>
 | 
			
		||||
				)}
 | 
			
		||||
				{state.documents &&
 | 
			
		||||
					state.documents.map((x) => {
 | 
			
		||||
						return <ContentInfo document={x} key={x.id} gallery={`/search?${queryString}`} short />;
 | 
			
		||||
					})}
 | 
			
		||||
				{error && <Typography variant="h5">Error : {error}</Typography>}
 | 
			
		||||
				<Typography
 | 
			
		||||
					variant="body1"
 | 
			
		||||
					sx={{
 | 
			
		||||
						justifyContent: "center",
 | 
			
		||||
						textAlign: "center",
 | 
			
		||||
					}}
 | 
			
		||||
				>
 | 
			
		||||
					{state.documents ? state.documents.length : "null"} loaded...
 | 
			
		||||
				</Typography>
 | 
			
		||||
				<Button onClick={() => loadMore()} disabled={loadAll} ref={elementRef}>
 | 
			
		||||
					{loadAll ? "Load All" : "Load More"}
 | 
			
		||||
				</Button>
 | 
			
		||||
			</Box>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
	function loadMore() {
 | 
			
		||||
		let option = { ...props.option };
 | 
			
		||||
		console.log(elementRef);
 | 
			
		||||
		if (state.documents === undefined || state.documents.length === 0) {
 | 
			
		||||
			console.log("loadall");
 | 
			
		||||
			setLoadAll(true);
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		const prev_documents = state.documents;
 | 
			
		||||
		option.cursor = prev_documents[prev_documents.length - 1].id;
 | 
			
		||||
		console.log("load more", option);
 | 
			
		||||
		const load = async () => {
 | 
			
		||||
			const c = await ContentAccessor.findList(option);
 | 
			
		||||
			if (c.length === 0) {
 | 
			
		||||
				setLoadAll(true);
 | 
			
		||||
			} else {
 | 
			
		||||
				setState({ documents: [...prev_documents, ...c] });
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
		load();
 | 
			
		||||
	}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Gallery = () => {
 | 
			
		||||
	const location = useLocation();
 | 
			
		||||
	const query = QueryStringToMap(location.search);
 | 
			
		||||
	const menu_list = CommonMenuList({ url: location.search });
 | 
			
		||||
	let option: QueryListOption = query;
 | 
			
		||||
	option.allow_tag = typeof option.allow_tag === "string" ? [option.allow_tag] : option.allow_tag;
 | 
			
		||||
	option.limit = typeof query["limit"] === "string" ? parseInt(query["limit"]) : undefined;
 | 
			
		||||
	return (
 | 
			
		||||
		<Headline menu={menu_list}>
 | 
			
		||||
			<PagePad>
 | 
			
		||||
				<GalleryInfo diff={location.search} option={query}></GalleryInfo>
 | 
			
		||||
			</PagePad>
 | 
			
		||||
		</Headline>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,80 @@
 | 
			
		|||
import useSWR from "swr";
 | 
			
		||||
import { useSearch } from "wouter";
 | 
			
		||||
import type { Document } from "dbtype/api";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { GalleryCard } from "../components/gallery/GalleryCard";
 | 
			
		||||
import TagBadge from "@/components/gallery/TagBadge";
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default function Gallery() {
 | 
			
		||||
    return (<div>
 | 
			
		||||
        a
 | 
			
		||||
    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 } = 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>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (<div className="p-4 grid gap-2 overflow-auto h-screen">
 | 
			
		||||
        <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">No results</div>
 | 
			
		||||
        }
 | 
			
		||||
        {
 | 
			
		||||
            data?.map((x) => {
 | 
			
		||||
                return (
 | 
			
		||||
                    <GalleryCard doc={x} />
 | 
			
		||||
                );
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
    </div>
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,11 +12,7 @@ export default defineConfig({
 | 
			
		|||
  },
 | 
			
		||||
  server: {
 | 
			
		||||
    proxy: {
 | 
			
		||||
      '/api': {
 | 
			
		||||
        target: 'http://localhost:8080',
 | 
			
		||||
        changeOrigin: true,
 | 
			
		||||
        // rewrite: path => path.replace(/^\/api/, '')
 | 
			
		||||
      }
 | 
			
		||||
      '/api': "http://127.0.0.1:8080"
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										53
									
								
								packages/dbtype/api.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								packages/dbtype/api.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
import type { JSONMap } from './jsonmap';
 | 
			
		||||
 | 
			
		||||
export interface DocumentBody {
 | 
			
		||||
	title: string;
 | 
			
		||||
	content_type: string;
 | 
			
		||||
	basepath: string;
 | 
			
		||||
	filename: string;
 | 
			
		||||
	modified_at: number;
 | 
			
		||||
	content_hash: string | null;
 | 
			
		||||
	additional: JSONMap;
 | 
			
		||||
	tags: string[]; // eager loading
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Document extends DocumentBody {
 | 
			
		||||
	readonly id: number;
 | 
			
		||||
	readonly created_at: number;
 | 
			
		||||
	readonly deleted_at: number | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type QueryListOption = {
 | 
			
		||||
	/**
 | 
			
		||||
	 * search word
 | 
			
		||||
	 */
 | 
			
		||||
	word?: string;
 | 
			
		||||
	allow_tag?: string[];
 | 
			
		||||
	/**
 | 
			
		||||
	 * limit of list
 | 
			
		||||
	 * @default 20
 | 
			
		||||
	 */
 | 
			
		||||
	limit?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * use offset if true, otherwise
 | 
			
		||||
	 * @default false
 | 
			
		||||
	 */
 | 
			
		||||
	use_offset?: boolean;
 | 
			
		||||
	/**
 | 
			
		||||
	 * cursor of documents
 | 
			
		||||
	 */
 | 
			
		||||
	cursor?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * offset of documents
 | 
			
		||||
	 */
 | 
			
		||||
	offset?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * tag eager loading
 | 
			
		||||
	 * @default true
 | 
			
		||||
	 */
 | 
			
		||||
	eager_loading?: boolean;
 | 
			
		||||
	/**
 | 
			
		||||
	 * content type
 | 
			
		||||
	 */
 | 
			
		||||
	content_type?: string;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										4
									
								
								packages/dbtype/jsonmap.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/dbtype/jsonmap.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export type JSONPrimitive = null | boolean | number | string;
 | 
			
		||||
export interface JSONMap extends Record<string, JSONType> {}
 | 
			
		||||
export interface JSONArray extends Array<JSONType> {}
 | 
			
		||||
export type JSONType = JSONMap | JSONPrimitive | JSONArray;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,12 @@
 | 
			
		|||
import { getKysely } from "./kysely";
 | 
			
		||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
 | 
			
		||||
import type { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
 | 
			
		||||
import { ParseJSONResultsPlugin, type NotNull } from "kysely";
 | 
			
		||||
import type { DocumentAccessor } from "../model/doc";
 | 
			
		||||
import type {
 | 
			
		||||
	Document,
 | 
			
		||||
	QueryListOption,
 | 
			
		||||
	DocumentBody
 | 
			
		||||
} from "dbtype/api";
 | 
			
		||||
import type { NotNull } from "kysely";
 | 
			
		||||
import { MyParseJSONResultsPlugin } from "./plugin";
 | 
			
		||||
 | 
			
		||||
export type DBTagContentRelation = {
 | 
			
		||||
| 
						 | 
				
			
			@ -144,7 +149,7 @@ class SqliteDocumentAccessor implements DocumentAccessor {
 | 
			
		|||
			.selectAll()
 | 
			
		||||
			.$if(allow_tag.length > 0, (qb) => {
 | 
			
		||||
				return allow_tag.reduce((prevQb ,tag, index) => {
 | 
			
		||||
					return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.tag_name`, "document.id")
 | 
			
		||||
					return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.doc_id`, "document.id")
 | 
			
		||||
						.where(`tags_${index}.tag_name`, "=", tag);
 | 
			
		||||
				}, qb) as unknown as typeof qb;
 | 
			
		||||
			})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,9 @@
 | 
			
		|||
import type { JSONMap } from "../types/json";
 | 
			
		||||
import { check_type } from "../util/type_check";
 | 
			
		||||
import { TagAccessor } from "./tag";
 | 
			
		||||
 | 
			
		||||
export interface DocumentBody {
 | 
			
		||||
	title: string;
 | 
			
		||||
	content_type: string;
 | 
			
		||||
	basepath: string;
 | 
			
		||||
	filename: string;
 | 
			
		||||
	modified_at: number;
 | 
			
		||||
	content_hash: string | null;
 | 
			
		||||
	additional: JSONMap;
 | 
			
		||||
	tags: string[]; // eager loading
 | 
			
		||||
}
 | 
			
		||||
import type {
 | 
			
		||||
	DocumentBody, 
 | 
			
		||||
	Document,
 | 
			
		||||
	QueryListOption
 | 
			
		||||
} from "dbtype/api";
 | 
			
		||||
 | 
			
		||||
export const MetaContentBody = {
 | 
			
		||||
	title: "string",
 | 
			
		||||
| 
						 | 
				
			
			@ -27,12 +19,6 @@ export const isDocBody = (c: unknown): c is DocumentBody => {
 | 
			
		|||
	return check_type<DocumentBody>(c, MetaContentBody);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface Document extends DocumentBody {
 | 
			
		||||
	readonly id: number;
 | 
			
		||||
	readonly created_at: number;
 | 
			
		||||
	readonly deleted_at: number | null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isDoc = (c: unknown): c is Document => {
 | 
			
		||||
	if (typeof c !== "object" || c === null) return false;
 | 
			
		||||
	if ("id" in c && typeof c.id === "number") {
 | 
			
		||||
| 
						 | 
				
			
			@ -42,41 +28,6 @@ export const isDoc = (c: unknown): c is Document => {
 | 
			
		|||
	return false;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type QueryListOption = {
 | 
			
		||||
	/**
 | 
			
		||||
	 * search word
 | 
			
		||||
	 */
 | 
			
		||||
	word?: string;
 | 
			
		||||
	allow_tag?: string[];
 | 
			
		||||
	/**
 | 
			
		||||
	 * limit of list
 | 
			
		||||
	 * @default 20
 | 
			
		||||
	 */
 | 
			
		||||
	limit?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * use offset if true, otherwise
 | 
			
		||||
	 * @default false
 | 
			
		||||
	 */
 | 
			
		||||
	use_offset?: boolean;
 | 
			
		||||
	/**
 | 
			
		||||
	 * cursor of documents
 | 
			
		||||
	 */
 | 
			
		||||
	cursor?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * offset of documents
 | 
			
		||||
	 */
 | 
			
		||||
	offset?: number;
 | 
			
		||||
	/**
 | 
			
		||||
	 * tag eager loading
 | 
			
		||||
	 * @default true
 | 
			
		||||
	 */
 | 
			
		||||
	eager_loading?: boolean;
 | 
			
		||||
	/**
 | 
			
		||||
	 * content type
 | 
			
		||||
	 */
 | 
			
		||||
	content_type?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export interface DocumentAccessor {
 | 
			
		||||
	/**
 | 
			
		||||
	 * find list by option
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,11 @@
 | 
			
		|||
import type { Context, Next } from "koa";
 | 
			
		||||
import Router from "koa-router";
 | 
			
		||||
import { join } from "node:path";
 | 
			
		||||
import { type Document, type DocumentAccessor, isDocBody } from "../model/doc";
 | 
			
		||||
import type { QueryListOption } from "../model/doc";
 | 
			
		||||
import type {
 | 
			
		||||
	Document,
 | 
			
		||||
	QueryListOption,
 | 
			
		||||
} from "dbtype/api";
 | 
			
		||||
import type { DocumentAccessor } from "../model/doc";
 | 
			
		||||
import {
 | 
			
		||||
	AdminOnlyMiddleware as AdminOnly,
 | 
			
		||||
	createPermissionCheckMiddleware as PerCheck,
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +46,7 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex
 | 
			
		|||
	) {
 | 
			
		||||
		return sendError(400, "paramter can not be array");
 | 
			
		||||
	}
 | 
			
		||||
	const limit = ParseQueryNumber(query_limit);
 | 
			
		||||
	const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100);
 | 
			
		||||
	const cursor = ParseQueryNumber(query_cursor);
 | 
			
		||||
	const word = ParseQueryArgString(query_word);
 | 
			
		||||
	const content_type = ParseQueryArgString(query_content_type);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,4 @@
 | 
			
		|||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
 | 
			
		||||
import { validate } from "jsonschema";
 | 
			
		||||
 | 
			
		||||
export class ConfigManager<T extends object> {
 | 
			
		||||
	path: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -37,10 +36,6 @@ export class ConfigManager<T extends object> {
 | 
			
		|||
		if (this.emptyToDefault(ret)) {
 | 
			
		||||
			writeFileSync(this.path, JSON.stringify(ret));
 | 
			
		||||
		}
 | 
			
		||||
		const result = validate(ret, this.schema);
 | 
			
		||||
		if (!result.valid) {
 | 
			
		||||
			throw new Error(result.toString());
 | 
			
		||||
		}
 | 
			
		||||
		return ret;
 | 
			
		||||
	}
 | 
			
		||||
	async write_config_file(new_config: T) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -29,6 +29,9 @@ importers:
 | 
			
		|||
      clsx:
 | 
			
		||||
        specifier: ^2.1.0
 | 
			
		||||
        version: 2.1.0
 | 
			
		||||
      dbtype:
 | 
			
		||||
        specifier: link:..\dbtype
 | 
			
		||||
        version: link:../dbtype
 | 
			
		||||
      react:
 | 
			
		||||
        specifier: ^18.2.0
 | 
			
		||||
        version: 18.2.0
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue