Rework #6
@ -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;
|
||||
|
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) {
|
||||
|
@ -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…
Reference in New Issue
Block a user