diff --git a/packages/client/package.json b/packages/client/package.json index cb28194..dd8e595 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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", diff --git a/packages/client/src/components/gallery/GalleryCard.tsx b/packages/client/src/components/gallery/GalleryCard.tsx new file mode 100644 index 0000000..797c075 --- /dev/null +++ b/packages/client/src/components/gallery/GalleryCard.tsx @@ -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(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 +
+ {x.title} +
+
+ + {x.title} + + {artists.join(", ")} {groups.length > 0 && "|"} {groups.join(", ")} + + + +
  • + {clippedTags.map(tag => )} + {clippedTags.length < originalTags.length && } +
  • +
    +
    +
    ; +} diff --git a/packages/client/src/components/gallery/TagBadge.tsx b/packages/client/src/components/gallery/TagBadge.tsx new file mode 100644 index 0000000..36f9a81 --- /dev/null +++ b/packages/client/src/components/gallery/TagBadge.tsx @@ -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
  • {toPrettyTagname(tagname)}
  • ; +} \ No newline at end of file diff --git a/packages/client/src/components/layout/layout.tsx b/packages/client/src/components/layout/layout.tsx index fe1f1dc..c4380d6 100644 --- a/packages/client/src/components/layout/layout.tsx +++ b/packages/client/src/components/layout/layout.tsx @@ -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) { - + {children} diff --git a/packages/client/src/components/ui/badge.tsx b/packages/client/src/components/ui/badge.tsx new file mode 100644 index 0000000..e87d62b --- /dev/null +++ b/packages/client/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ) +} + +export { Badge, badgeVariants } diff --git a/packages/client/src/components/ui/card.tsx b/packages/client/src/components/ui/card.tsx new file mode 100644 index 0000000..77e9fb7 --- /dev/null +++ b/packages/client/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

    +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
    +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/packages/client/src/components/ui/input.tsx b/packages/client/src/components/ui/input.tsx new file mode 100644 index 0000000..a92b8e0 --- /dev/null +++ b/packages/client/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/packages/client/src/components/ui/skeleton.tsx b/packages/client/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..d7e45f7 --- /dev/null +++ b/packages/client/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
    + ) +} + +export { Skeleton } diff --git a/packages/client/src/page/gallery.tsx b/packages/client/src/page/gallery.tsx deleted file mode 100644 index f0a6cfd..0000000 --- a/packages/client/src/page/gallery.tsx +++ /dev/null @@ -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({ documents: undefined }); - const [error, setError] = useState(null); - const [loadAll, setLoadAll] = useState(false); - const { elementRef, isVisible: isLoadVisible } = useIsElementInViewport({}); - - 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 ; - } else { - return ( - - {props.option !== undefined && props.diff !== "" && ( - - search for - {props.option.word !== undefined && ( - - )} - {props.option.content_type !== undefined && } - {props.option.allow_tag !== undefined && - props.option.allow_tag.map((x) => ( - - ))} - - )} - {state.documents && - state.documents.map((x) => { - return ; - })} - {error && Error : {error}} - - {state.documents ? state.documents.length : "null"} loaded... - - - - ); - } - 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 ( - - - - - - ); -}; diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx index 429a4b9..1c44988 100644 --- a/packages/client/src/page/galleryPage.tsx +++ b/packages/client/src/page/galleryPage.tsx @@ -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 (
    - 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
    Loading...
    + } + if (error) { + return
    Error: {String(error)}
    + } + + return (
    +
    + + +
    + {(word || tags) && +
    + {word && Search: {word}} + {tags && Tags:
      {tags.split(",").map(x=> )}
    } +
    + } + { + data?.length === 0 &&
    No results
    + } + { + data?.map((x) => { + return ( + + ); + }) + }
    ); -} \ No newline at end of file +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 5176d98..ace84a6 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -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" } } }) diff --git a/packages/dbtype/api.ts b/packages/dbtype/api.ts new file mode 100644 index 0000000..bc5e33b --- /dev/null +++ b/packages/dbtype/api.ts @@ -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; +}; \ No newline at end of file diff --git a/packages/dbtype/jsonmap.ts b/packages/dbtype/jsonmap.ts new file mode 100644 index 0000000..b774877 --- /dev/null +++ b/packages/dbtype/jsonmap.ts @@ -0,0 +1,4 @@ +export type JSONPrimitive = null | boolean | number | string; +export interface JSONMap extends Record {} +export interface JSONArray extends Array {} +export type JSONType = JSONMap | JSONPrimitive | JSONArray; diff --git a/packages/server/src/db/doc.ts b/packages/server/src/db/doc.ts index b89a7d3..4464785 100644 --- a/packages/server/src/db/doc.ts +++ b/packages/server/src/db/doc.ts @@ -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; }) diff --git a/packages/server/src/model/doc.ts b/packages/server/src/model/doc.ts index 9b2dca2..dab7cb0 100644 --- a/packages/server/src/model/doc.ts +++ b/packages/server/src/model/doc.ts @@ -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(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 diff --git a/packages/server/src/route/contents.ts b/packages/server/src/route/contents.ts index af995d0..5e407e3 100644 --- a/packages/server/src/route/contents.ts +++ b/packages/server/src/route/contents.ts @@ -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); diff --git a/packages/server/src/util/configRW.ts b/packages/server/src/util/configRW.ts index e39d1b4..dd873ad 100644 --- a/packages/server/src/util/configRW.ts +++ b/packages/server/src/util/configRW.ts @@ -1,5 +1,4 @@ import { existsSync, promises as fs, readFileSync, writeFileSync } from "node:fs"; -import { validate } from "jsonschema"; export class ConfigManager { path: string; @@ -37,10 +36,6 @@ export class ConfigManager { 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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25e9a14..7f695d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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