new GalleryPage
This commit is contained in:
parent
62ec80565e
commit
23922ed100
@ -16,6 +16,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"dbtype": "workspace:*",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-resizable-panels": "^2.0.16",
|
"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]"
|
"[data-panel-resize-handle-id]"
|
||||||
);
|
);
|
||||||
if (!panelGroup || !resizeHandles) return;
|
if (!panelGroup || !resizeHandles) return;
|
||||||
console.log(panelGroup, resizeHandles);
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
let width = panelGroup?.clientWidth;
|
let width = panelGroup?.clientWidth;
|
||||||
if (!width) return;
|
if (!width) return;
|
||||||
@ -42,7 +41,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<NavList />
|
<NavList />
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel>
|
<ResizablePanel >
|
||||||
{children}
|
{children}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</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() {
|
export default function Gallery() {
|
||||||
return (<div>
|
const search = useSearch();
|
||||||
a
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -12,11 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': "http://127.0.0.1:8080"
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true,
|
|
||||||
// rewrite: path => path.replace(/^\/api/, '')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
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 { getKysely } from "./kysely";
|
||||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||||
import type { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
|
import type { DocumentAccessor } from "../model/doc";
|
||||||
import { ParseJSONResultsPlugin, type NotNull } from "kysely";
|
import type {
|
||||||
|
Document,
|
||||||
|
QueryListOption,
|
||||||
|
DocumentBody
|
||||||
|
} from "dbtype/api";
|
||||||
|
import type { NotNull } from "kysely";
|
||||||
import { MyParseJSONResultsPlugin } from "./plugin";
|
import { MyParseJSONResultsPlugin } from "./plugin";
|
||||||
|
|
||||||
export type DBTagContentRelation = {
|
export type DBTagContentRelation = {
|
||||||
@ -144,7 +149,7 @@ class SqliteDocumentAccessor implements DocumentAccessor {
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.$if(allow_tag.length > 0, (qb) => {
|
.$if(allow_tag.length > 0, (qb) => {
|
||||||
return allow_tag.reduce((prevQb ,tag, index) => {
|
return allow_tag.reduce((prevQb ,tag, index) => {
|
||||||
return prevQb.innerJoin(`doc_tag_relation as tags_${index}`, `tags_${index}.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);
|
.where(`tags_${index}.tag_name`, "=", tag);
|
||||||
}, qb) as unknown as typeof qb;
|
}, qb) as unknown as typeof qb;
|
||||||
})
|
})
|
||||||
|
@ -1,17 +1,9 @@
|
|||||||
import type { JSONMap } from "../types/json";
|
|
||||||
import { check_type } from "../util/type_check";
|
import { check_type } from "../util/type_check";
|
||||||
import { TagAccessor } from "./tag";
|
import type {
|
||||||
|
DocumentBody,
|
||||||
export interface DocumentBody {
|
Document,
|
||||||
title: string;
|
QueryListOption
|
||||||
content_type: string;
|
} from "dbtype/api";
|
||||||
basepath: string;
|
|
||||||
filename: string;
|
|
||||||
modified_at: number;
|
|
||||||
content_hash: string | null;
|
|
||||||
additional: JSONMap;
|
|
||||||
tags: string[]; // eager loading
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MetaContentBody = {
|
export const MetaContentBody = {
|
||||||
title: "string",
|
title: "string",
|
||||||
@ -27,12 +19,6 @@ export const isDocBody = (c: unknown): c is DocumentBody => {
|
|||||||
return check_type<DocumentBody>(c, MetaContentBody);
|
return check_type<DocumentBody>(c, MetaContentBody);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Document extends DocumentBody {
|
|
||||||
readonly id: number;
|
|
||||||
readonly created_at: number;
|
|
||||||
readonly deleted_at: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isDoc = (c: unknown): c is Document => {
|
export const isDoc = (c: unknown): c is Document => {
|
||||||
if (typeof c !== "object" || c === null) return false;
|
if (typeof c !== "object" || c === null) return false;
|
||||||
if ("id" in c && typeof c.id === "number") {
|
if ("id" in c && typeof c.id === "number") {
|
||||||
@ -42,41 +28,6 @@ export const isDoc = (c: unknown): c is Document => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QueryListOption = {
|
|
||||||
/**
|
|
||||||
* search word
|
|
||||||
*/
|
|
||||||
word?: string;
|
|
||||||
allow_tag?: string[];
|
|
||||||
/**
|
|
||||||
* limit of list
|
|
||||||
* @default 20
|
|
||||||
*/
|
|
||||||
limit?: number;
|
|
||||||
/**
|
|
||||||
* use offset if true, otherwise
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
use_offset?: boolean;
|
|
||||||
/**
|
|
||||||
* cursor of documents
|
|
||||||
*/
|
|
||||||
cursor?: number;
|
|
||||||
/**
|
|
||||||
* offset of documents
|
|
||||||
*/
|
|
||||||
offset?: number;
|
|
||||||
/**
|
|
||||||
* tag eager loading
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
eager_loading?: boolean;
|
|
||||||
/**
|
|
||||||
* content type
|
|
||||||
*/
|
|
||||||
content_type?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DocumentAccessor {
|
export interface DocumentAccessor {
|
||||||
/**
|
/**
|
||||||
* find list by option
|
* find list by option
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import type { Context, Next } from "koa";
|
import type { Context, Next } from "koa";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { type Document, type DocumentAccessor, isDocBody } from "../model/doc";
|
import type {
|
||||||
import type { QueryListOption } from "../model/doc";
|
Document,
|
||||||
|
QueryListOption,
|
||||||
|
} from "dbtype/api";
|
||||||
|
import type { DocumentAccessor } from "../model/doc";
|
||||||
import {
|
import {
|
||||||
AdminOnlyMiddleware as AdminOnly,
|
AdminOnlyMiddleware as AdminOnly,
|
||||||
createPermissionCheckMiddleware as PerCheck,
|
createPermissionCheckMiddleware as PerCheck,
|
||||||
@ -43,7 +46,7 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex
|
|||||||
) {
|
) {
|
||||||
return sendError(400, "paramter can not be array");
|
return sendError(400, "paramter can not be array");
|
||||||
}
|
}
|
||||||
const limit = ParseQueryNumber(query_limit);
|
const limit = Math.min(ParseQueryNumber(query_limit) ?? 20, 100);
|
||||||
const cursor = ParseQueryNumber(query_cursor);
|
const cursor = ParseQueryNumber(query_cursor);
|
||||||
const word = ParseQueryArgString(query_word);
|
const word = ParseQueryArgString(query_word);
|
||||||
const content_type = ParseQueryArgString(query_content_type);
|
const content_type = ParseQueryArgString(query_content_type);
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { validate } from "jsonschema";
|
|
||||||
|
|
||||||
export class ConfigManager<T extends object> {
|
export class ConfigManager<T extends object> {
|
||||||
path: string;
|
path: string;
|
||||||
@ -37,10 +36,6 @@ export class ConfigManager<T extends object> {
|
|||||||
if (this.emptyToDefault(ret)) {
|
if (this.emptyToDefault(ret)) {
|
||||||
writeFileSync(this.path, JSON.stringify(ret));
|
writeFileSync(this.path, JSON.stringify(ret));
|
||||||
}
|
}
|
||||||
const result = validate(ret, this.schema);
|
|
||||||
if (!result.valid) {
|
|
||||||
throw new Error(result.toString());
|
|
||||||
}
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
async write_config_file(new_config: T) {
|
async write_config_file(new_config: T) {
|
||||||
|
@ -29,6 +29,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
|
dbtype:
|
||||||
|
specifier: link:..\dbtype
|
||||||
|
version: link:../dbtype
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.2.0
|
version: 18.2.0
|
||||||
|
Loading…
Reference in New Issue
Block a user