new GalleryPage

This commit is contained in:
monoid 2024-04-06 01:03:56 +09:00
parent 62ec80565e
commit 23922ed100
18 changed files with 418 additions and 208 deletions

View File

@ -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",

View 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>;
}

View 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>;
}

View File

@ -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>

View 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 }

View 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 }

View 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 }

View 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 }

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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
View 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;
};

View 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;

View File

@ -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;
})

View File

@ -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

View File

@ -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);

View File

@ -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) {

View File

@ -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