Compare commits

..

No commits in common. "ec120e7d2619d9d28751b49bbc9bd1999a22bdf9" and "b2a584847fe30768c9ee5cef423454dea857cf34" have entirely different histories.

13 changed files with 63 additions and 163 deletions

View File

@ -17,7 +17,6 @@
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-virtual": "^3.2.1",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"dbtype": "workspace:*", "dbtype": "workspace:*",

View File

@ -4,7 +4,6 @@ import TagBadge from "@/components/gallery/TagBadge.tsx";
import { Fragment, useLayoutEffect, useRef, useState } from "react"; import { Fragment, useLayoutEffect, useRef, useState } from "react";
import { LazyImage } from "./LazyImage.tsx"; import { LazyImage } from "./LazyImage.tsx";
import StyledLink from "./StyledLink.tsx"; import StyledLink from "./StyledLink.tsx";
import React from "react";
function clipTagsWhenOverflow(tags: string[], limit: number) { function clipTagsWhenOverflow(tags: string[], limit: number) {
let l = 0; let l = 0;
@ -18,7 +17,7 @@ function clipTagsWhenOverflow(tags: string[], limit: number) {
return tags; return tags;
} }
function GalleryCardImpl({ export function GalleryCard({
doc: x doc: x
}: { doc: Document; }) { }: { doc: Document; }) {
const ref = useRef<HTMLUListElement>(null); const ref = useRef<HTMLUListElement>(null);
@ -86,5 +85,3 @@ function GalleryCardImpl({
</div> </div>
</Card>; </Card>;
} }
export const GalleryCard = React.memo(GalleryCardImpl);

View File

@ -40,7 +40,7 @@ export default function Layout({ children }: LayoutProps) {
<ResizablePanel minSize={minSize} collapsible maxSize={minSize}> <ResizablePanel minSize={minSize} collapsible maxSize={minSize}>
<NavList /> <NavList />
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle className="z-20" /> <ResizableHandle withHandle />
<ResizablePanel > <ResizablePanel >
{children} {children}
</ResizablePanel> </ResizablePanel>

View File

@ -32,7 +32,7 @@ export function NavItem({
export function NavList() { export function NavList() {
const loginInfo = useLogin(); const loginInfo = useLogin();
return <aside className="h-dvh flex flex-col"> return <aside className="h-screen flex flex-col">
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1"> <nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
<NavItem icon={<MagnifyingGlassIcon className="h-5 w-5" />} to="/search" name="Search" /> <NavItem icon={<MagnifyingGlassIcon className="h-5 w-5" />} to="/search" name="Search" />
<NavItem icon={<ActivityLogIcon className="h-5 w-5" />} to="/tags" name="Tags" /> <NavItem icon={<ActivityLogIcon className="h-5 w-5" />} to="/tags" name="Tags" />

View File

@ -1,20 +1,7 @@
export const BASE_API_URL = import.meta.env.VITE_API_URL ?? window.location.origin; export const BASE_API_URL = 'http://localhost:5173/';
export function makeApiUrl(pathnameAndQueryparam: string) { export async function fetcher(url: string) {
return new URL(pathnameAndQueryparam, BASE_API_URL).toString(); const u = new URL(url, BASE_API_URL);
} const res = await fetch(u);
export class ApiError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
}
}
export async function fetcher(url: string, init?: RequestInit) {
const u = makeApiUrl(url);
const res = await fetch(u, init);
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
return res.json(); return res.json();
} }

View File

@ -14,7 +14,7 @@ export function useDifferenceDoc() {
} }
export async function commit(path: string, type: string) { export async function commit(path: string, type: string) {
const data = await fetcher("/api/diff/commit", { const res = await fetch("/api/diff/commit", {
method: "POST", method: "POST",
body: JSON.stringify([{ path, type }]), body: JSON.stringify([{ path, type }]),
headers: { headers: {
@ -22,11 +22,11 @@ export async function commit(path: string, type: string) {
}, },
}); });
mutate("/api/diff/list"); mutate("/api/diff/list");
return data; return res.ok;
} }
export async function commitAll(type: string) { export async function commitAll(type: string) {
const data = await fetcher("/api/diff/commitall", { const res = await fetch("/api/diff/commitall", {
method: "POST", method: "POST",
body: JSON.stringify({ type }), body: JSON.stringify({ type }),
headers: { headers: {
@ -34,5 +34,5 @@ export async function commitAll(type: string) {
}, },
}); });
mutate("/api/diff/list"); mutate("/api/diff/list");
return data; return res.ok;
} }

View File

@ -1,7 +1,6 @@
import useSWRInifinite from "swr/infinite"; import useSWRInifinite from "swr/infinite";
import type { Document } from "dbtype/api"; import type { Document } from "dbtype/api";
import { fetcher } from "./fetcher"; import { fetcher } from "./fetcher";
import useSWR from "swr";
interface SearchParams { interface SearchParams {
word?: string; word?: string;
@ -10,33 +9,24 @@ interface SearchParams {
cursor?: number; cursor?: number;
} }
function makeSearchParams({ export function useSearchGallery({
word, tags, limit, cursor, word, tags, limit, cursor,
}: SearchParams){ }: 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 search;
}
export function useSearchGallery(searchParams: SearchParams = {}) {
return useSWR<Document[]>(`/api/doc/search?${makeSearchParams(searchParams).toString()}`, fetcher);
}
export function useSearchGalleryInfinite(searchParams: SearchParams = {}) {
return useSWRInifinite< return useSWRInifinite<
{ {
data: Document[]; data: Document[];
nextCursor: number | null; nextCursor: number | null;
startCursor: number | null;
hasMore: boolean; hasMore: boolean;
} }
>((index, previous) => { >((index, previous) => {
if (!previous && index > 0) return null; if (!previous && index > 0) return null;
if (previous && !previous.hasMore) return null; if (previous && !previous.hasMore) return null;
const search = makeSearchParams(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());
if (index === 0) { if (index === 0) {
return `/api/doc/search?${search.toString()}`; return `/api/doc/search?${search.toString()}`;
} }
@ -45,11 +35,9 @@ export function useSearchGalleryInfinite(searchParams: SearchParams = {}) {
search.set("cursor", last.id.toString()); search.set("cursor", last.id.toString());
return `/api/doc/search?${search.toString()}`; return `/api/doc/search?${search.toString()}`;
}, async (url) => { }, async (url) => {
const limit = searchParams.limit;
const res = await fetcher(url); const res = await fetcher(url);
return { return {
data: res, data: res,
startCursor: res.length === 0 ? null : res[0].id,
nextCursor: res.length === 0 ? null : res[res.length - 1].id, nextCursor: res.length === 0 ? null : res[res.length - 1].id,
hasMore: limit ? res.length === limit : (res.length === 20), hasMore: limit ? res.length === limit : (res.length === 20),
}; };

View File

@ -26,7 +26,7 @@ export function DifferencePage() {
<Card> <Card>
<CardHeader className="relative"> <CardHeader className="relative">
<Button className="absolute right-2 top-8" variant="ghost" <Button className="absolute right-2 top-8" variant="ghost"
onClick={() => {commitAll("comic")}} onClick={() => commitAll("comic")}
>Commit All</Button> >Commit All</Button>
<CardTitle className="text-2xl">Difference</CardTitle> <CardTitle className="text-2xl">Difference</CardTitle>
<CardDescription>Scanned Files List</CardDescription> <CardDescription>Scanned Files List</CardDescription>
@ -45,7 +45,8 @@ export function DifferencePage() {
<Button <Button
className="flex-none ml-2" className="flex-none ml-2"
variant="outline" variant="outline"
onClick={() => {commit(y.path, y.type)}}> onClick={() => commit(y.path, y.type)}
>
Commit Commit
</Button> </Button>
</div> </div>

View File

@ -2,12 +2,10 @@ import { useLocation, useSearch } from "wouter";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; import { GalleryCard } from "@/components/gallery/GalleryCard.tsx";
import TagBadge from "@/components/gallery/TagBadge.tsx"; import TagBadge from "@/components/gallery/TagBadge.tsx";
import { useSearchGalleryInfinite } from "../hook/useSearchGallery.ts"; import { useSearchGallery } from "../hook/useSearchGallery.ts";
import { Spinner } from "../components/Spinner.tsx"; import { Spinner } from "../components/Spinner.tsx";
import TagInput from "@/components/gallery/TagInput.tsx"; import TagInput from "@/components/gallery/TagInput.tsx";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { Separator } from "@/components/ui/separator.tsx";
import { useVirtualizer } from "@tanstack/react-virtual";
export default function Gallery() { export default function Gallery() {
const search = useSearch(); const search = useSearch();
@ -16,36 +14,11 @@ export default function Gallery() {
const tags = searchParams.get("allow_tag") ?? undefined; const tags = searchParams.get("allow_tag") ?? undefined;
const limit = searchParams.get("limit"); const limit = searchParams.get("limit");
const cursor = searchParams.get("cursor"); const cursor = searchParams.get("cursor");
const { data, error, isLoading, size, setSize } = useSearchGalleryInfinite({ const { data, error, isLoading, size, setSize } = useSearchGallery({
word, tags, word, tags,
limit: limit ? Number.parseInt(limit) : undefined, limit: limit ? Number.parseInt(limit) : undefined,
cursor: cursor ? Number.parseInt(cursor) : undefined cursor: cursor ? Number.parseInt(cursor) : undefined
}); });
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: size,
// biome-ignore lint/style/noNonNullAssertion: <explanation>
getScrollElement: () => parentRef.current!,
estimateSize: (index) => {
const docs = data?.[index];
if (!docs) return 8;
return docs.data.length * (200 + 8) + 37 + 8;
},
overscan: 1,
});
const virtualItems = virtualizer.getVirtualItems();
useEffect(() => {
const lastItems = virtualItems.slice(-1);
// console.log(virtualItems);
if (lastItems.some(x => x.index >= size - 1)) {
const last = lastItems[0];
const docs = data?.[last.index];
if (docs?.hasMore) {
setSize(size + 1);
}
}
}, [virtualItems, setSize, size, data]);
if (isLoading) { if (isLoading) {
return <div className="p-4">Loading...</div> return <div className="p-4">Loading...</div>
@ -53,59 +26,36 @@ export default function Gallery() {
if (error) { if (error) {
return <div className="p-4">Error: {String(error)}</div> return <div className="p-4">Error: {String(error)}</div>
} }
if (!data) {
return <div className="p-4">No data</div>
}
const isLoadingMore = data && size > 0 && (data[size - 1] === undefined); const isLoadingMore = data && size > 0 && (data[size - 1] === undefined);
const isReachingEnd = data && data[size - 1]?.hasMore === false; const isReachingEnd = data && data[size - 1]?.hasMore === false;
return (<div className="p-4 grid gap-2 overflow-auto h-dvh items-start content-start" ref={parentRef}> return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start">
<Search /> <Search />
{(word || tags) && {(word || tags) &&
<div className="bg-primary rounded-full p-1 sticky top-0 shadow-md z-20"> <div className="bg-primary rounded-full p-1 sticky top-0 shadow-md">
{word && <span className="text-primary-foreground ml-4">Search: {word}</span>} {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 && <span className="text-primary-foreground ml-4">Tags: <ul className="inline-flex">{tags.split(",").map(x => <TagBadge tagname={x} key={x} />)}</ul></span>}
tags.split(",").map(x => <TagBadge tagname={x} key={x} />)}
</ul></span>}
</div> </div>
} }
{data?.length === 0 && <div className="p-4 text-3xl">No results</div>} {
<div className="w-full relative" data?.length === 0 && <div className="p-4 text-3xl">No results</div>
style={{ height: virtualizer.getTotalSize() }}> }
{// TODO: date based grouping {
virtualItems.map((item) => { // TODO: date based grouping
const isLoaderRow = item.index === size - 1 && isLoadingMore; data?.map((docs) => {
if (isLoaderRow) { return docs.data.map((x) => {
return <div key={item.index} className="w-full flex justify-center top-0 left-0 absolute" return (
style={{ <GalleryCard doc={x} key={x.id} />
height: `${item.size}px`, );
transform: `translateY(${item.start}px)` });
}}> })
<Spinner /> }
</div>; {
} <Button className="w-full" onClick={() => setSize(size + 1)}
const docs = data[item.index]; disabled={isReachingEnd || isLoadingMore}
if (!docs) return null; > {isLoadingMore && <Spinner className="mr-1" />}{size + 1} Load more</Button>
return <div className="w-full grid gap-2 content-start top-0 left-0 absolute mb-2" key={item.index} }
style={{
height: `${item.size}px`,
transform: `translateY(${item.start}px)`
}}>
{docs.startCursor && <div>
<h3 className="text-3xl">Start with {docs.startCursor}</h3>
<Separator />
</div>}
{docs?.data?.map((x) => {
return (
<GalleryCard doc={x} key={x.id} />
);
})}
</div>
})
}
</div>
</div> </div>
); );
} }
@ -120,7 +70,7 @@ function Search() {
<TagInput className="flex-1" input={word} onInputChange={setWord} <TagInput className="flex-1" input={word} onInputChange={setWord}
tags={tags} onTagsChange={setTags} tags={tags} onTagsChange={setTags}
/> />
<Button className="flex-none" onClick={() => { <Button className="flex-none" onClick={()=>{
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tags.length > 0) { if (tags.length > 0) {
for (const tag of tags) { for (const tag of tags) {

View File

@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useEffect } from "react";
import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts"; import { type TernaryDarkMode, useTernaryDarkMode, useMediaQuery } from "usehooks-ts";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";

View File

@ -1,5 +1,5 @@
import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts"; import { atom, useAtomValue, setAtomValue } from "../lib/atom.ts";
import { makeApiUrl } from "../hook/fetcher.ts"; import { BASE_API_URL } from "../hook/fetcher.ts";
type LoginLocalStorage = { type LoginLocalStorage = {
username: string; username: string;
@ -24,7 +24,7 @@ function getUserSessions() {
} }
export async function refresh() { export async function refresh() {
const u = makeApiUrl("/api/user/refresh"); const u = new URL("/api/user/refresh", BASE_API_URL);
const res = await fetch(u, { const res = await fetch(u, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@ -50,7 +50,7 @@ export async function refresh() {
} }
export const doLogout = async () => { export const doLogout = async () => {
const u = makeApiUrl("/api/user/logout"); const u = new URL("/api/user/logout", BASE_API_URL);
const req = await fetch(u, { const req = await fetch(u, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@ -81,7 +81,7 @@ export const doLogin = async (userLoginInfo: {
username: string; username: string;
password: string; password: string;
}): Promise<string | LoginLocalStorage> => { }): Promise<string | LoginLocalStorage> => {
const u = makeApiUrl("/api/user/login"); const u = new URL("/api/user/login", BASE_API_URL);
const res = await fetch(u, { const res = await fetch(u, {
method: "POST", method: "POST",
body: JSON.stringify(userLoginInfo), body: JSON.stringify(userLoginInfo),

View File

@ -61,10 +61,10 @@ class SqliteDocumentAccessor implements DocumentAccessor {
return await this.kysely.transaction().execute(async (trx) => { return await this.kysely.transaction().execute(async (trx) => {
const { tags, additional, ...rest } = c; const { tags, additional, ...rest } = c;
const id_lst = await trx.insertInto("document").values({ const id_lst = await trx.insertInto("document").values({
additional: JSON.stringify(additional), additional: JSON.stringify(additional),
created_at: Date.now(), created_at: Date.now(),
...rest, ...rest,
}) })
.returning("id") .returning("id")
.executeTakeFirst() as { id: number }; .executeTakeFirst() as { id: number };
const id = id_lst.id; const id = id_lst.id;
@ -150,7 +150,7 @@ class SqliteDocumentAccessor implements DocumentAccessor {
.selectFrom("document") .selectFrom("document")
.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}.doc_id`, "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;
@ -161,16 +161,11 @@ class SqliteDocumentAccessor implements DocumentAccessor {
.$if(!use_offset && cursor !== undefined, (qb) => qb.where("id", "<", cursor as number)) .$if(!use_offset && cursor !== undefined, (qb) => qb.where("id", "<", cursor as number))
.limit(limit) .limit(limit)
.$if(eager_loading, (qb) => { .$if(eager_loading, (qb) => {
return qb.select(eb => return qb.select(eb => jsonArrayFrom(
eb.selectFrom(e => eb.selectFrom("doc_tag_relation")
e.selectFrom("doc_tag_relation") .select(["doc_tag_relation.tag_name"])
.select(["doc_tag_relation.tag_name"]) .whereRef("document.id", "=", "doc_tag_relation.doc_id")
.whereRef("document.id", "=", "doc_tag_relation.doc_id") ).as("tags")).withPlugin(new MyParseJSONResultsPlugin("tags"))
.as("agg")
).select(e => e.fn.agg<string>("json_group_array", ["agg.tag_name"])
.as("tags_list")
).as("tags")
)
}) })
.orderBy("id", "desc") .orderBy("id", "desc")
.execute(); .execute();
@ -178,7 +173,7 @@ class SqliteDocumentAccessor implements DocumentAccessor {
...x, ...x,
content_hash: x.content_hash ?? "", content_hash: x.content_hash ?? "",
additional: x.additional !== null ? (JSON.parse(x.additional)) : {}, additional: x.additional !== null ? (JSON.parse(x.additional)) : {},
tags: JSON.parse(x.tags ?? "[]"), //?.map((x: { tag_name: string }) => x.tag_name) ?? [], tags: x.tags?.map((x: { tag_name: string }) => x.tag_name) ?? [],
})); }));
} }
async findByPath(path: string, filename?: string): Promise<Document[]> { async findByPath(path: string, filename?: string): Promise<Document[]> {

View File

@ -32,9 +32,6 @@ importers:
'@radix-ui/react-tooltip': '@radix-ui/react-tooltip':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0) version: 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.71)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-virtual':
specifier: ^3.2.1
version: 3.2.1(react-dom@18.2.0)(react@18.2.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@ -1789,21 +1786,6 @@ packages:
defer-to-connect: 2.0.1 defer-to-connect: 2.0.1
dev: true dev: true
/@tanstack/react-virtual@3.2.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-i9Nt0ssIh2bSjomJZlr6Iq5usT/9+ewo2/fKHRNk6kjVKS8jrhXbnO8NEawarCuBx/efv0xpoUUKKGxa0cQb4Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@tanstack/virtual-core': 3.2.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/virtual-core@3.2.1:
resolution: {integrity: sha512-nO0d4vRzsmpBQCJYyClNHPPoUMI4nXNfrm6IcCRL33ncWMoNVpURh9YebEHPw8KrtsP2VSJIHE4gf4XFGk1OGg==}
dev: false
/@tokenizer/token@0.3.0: /@tokenizer/token@0.3.0:
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
dev: true dev: true