From 02f3cd9bd1cd3d7a55f6e07d8bc1a18a97081fc7 Mon Sep 17 00:00:00 2001 From: monoid Date: Sun, 7 Apr 2024 22:12:41 +0900 Subject: [PATCH] tags search --- .../src/components/gallery/TagBadge.tsx | 61 ++++-- .../src/components/gallery/TagInput.tsx | 181 ++++++++++++++++++ packages/client/src/hook/useTags.ts | 9 + packages/client/src/page/galleryPage.tsx | 35 +++- 4 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 packages/client/src/components/gallery/TagInput.tsx create mode 100644 packages/client/src/hook/useTags.ts diff --git a/packages/client/src/components/gallery/TagBadge.tsx b/packages/client/src/components/gallery/TagBadge.tsx index 4b82c69..a1ff5d1 100644 --- a/packages/client/src/components/gallery/TagBadge.tsx +++ b/packages/client/src/components/gallery/TagBadge.tsx @@ -1,16 +1,30 @@ import { badgeVariants } from "@/components/ui/badge.tsx"; import { Link } from "wouter"; import { cn } from "@/lib/utils.ts"; +import { cva } from "class-variance-authority" -function getTagKind(tagname: string) { +enum TagKind { + Default = "default", + Type = "type", + Character = "character", + Series = "series", + Group = "group", + Artist = "artist", + Male = "male", + Female = "female", +} + +type TagKindType = `${TagKind}`; + +export function getTagKind(tagname: string): TagKindType { if (tagname.match(":") === null) { return "default"; } const prefix = tagname.split(":")[0]; - return prefix; + return prefix as TagKindType; } -function toPrettyTagname(tagname: string): string { +export function toPrettyTagname(tagname: string): string { const kind = getTagKind(tagname); const name = tagname.slice(kind.length + 1); @@ -34,20 +48,39 @@ function toPrettyTagname(tagname: string): string { } } -export default function TagBadge(props: { tagname: string, className?: string; disabled?: boolean;}) { +interface TagBadgeProps { + tagname: string; + className?: string; + disabled?: boolean; +} + + +export const tagBadgeVariants = cva( + cn(badgeVariants({ variant: "default"}), "px-1"), + { + variants: { + variant: { + default: "bg-[#4a5568] hover:bg-[#718096]", + type: "bg-[#d53f8c] hover:bg-[#e24996]", + character: "bg-[#52952c] hover:bg-[#6cc24a]", + series: "bg-[#dc8f09] hover:bg-[#e69d17]", + group: "bg-[#805ad5] hover:bg-[#8b5cd6]", + artist: "bg-[#319795] hover:bg-[#38a89d]", + female: "bg-[#c21f58] hover:bg-[#db2d67]", + male: "bg-[#2a7bbf] hover:bg-[#3091e7]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export default function TagBadge(props: TagBadgeProps) { const { tagname } = props; const kind = getTagKind(tagname); return
  • void; + onFirstArrowUp?: () => void; +} + +function TagsSelectList({ + search = "", + onSelect, + onFirstArrowUp = () => { }, +}: TagsSelectListProps) { + const { data, isLoading } = useTags(); + const candidates = data?.filter(s => s.name.startsWith(search)); + + return +} + +interface TagInputProps { + className?: string; + tags: string[]; + onTagsChange: (tags: string[]) => void; + input: string; + onInputChange: (input: string) => void; +} + +export default function TagInput({ + className, + tags = [], + onTagsChange = () => { }, + input = "", + onInputChange = () => { }, +}: TagInputProps) { + const inputRef = useRef(null); + const setTags = onTagsChange; + const setInput = onInputChange; + const [isFocused, setIsFocused] = useState(false); + const [openInfo, setOpenInfo] = useState<{ + top: number; + left: number; + } | null>(null); + const autocompleteRef = useRef(null); + useOnClickOutside(autocompleteRef, () => { + setOpenInfo(null); + }); + useEffect(() => { + const listener = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setOpenInfo(null); + } + } + document.addEventListener("keyup", listener); + return () => { + document.removeEventListener("keyup", listener); + } + }, []); + + return <> + {/* biome-ignore lint/a11y/useKeyWithClickEvents: input exist */} +
    inputRef.current?.focus()} + > +
      + {tags.map((tag) =>
    • { + setTags(tags.filter(x=>x!==tag)); + }}>{tag}
    • )} +
    + setIsFocused(true)} onBlur={() => setIsFocused(false)} + value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { + if (e.key === "Enter") { + if (input.trim() === "") return; + setTags([...tags, input]); + setInput(""); + setOpenInfo(null); + } + if (e.key === "Backspace" && input === "") { + setTags(tags.slice(0, -1)); + setOpenInfo(null); + } + if (e.key === ":" || (e.ctrlKey && e.key === " ")) { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setOpenInfo({ + top: rect.bottom, + left: rect.left, + }); + } + } + if (e.key === "Down" || e.key === "ArrowDown") { + if (openInfo && autocompleteRef.current) { + const firstChild = autocompleteRef.current.firstElementChild?.firstElementChild as HTMLElement; + firstChild?.focus(); + e.preventDefault(); + } + } + }} + /> + { + openInfo &&
    + { + setTags([...tags, tag]); + setInput(""); + setOpenInfo(null); + }} + onFirstArrowUp={() => { + inputRef.current?.focus(); + }} + /> +
    + } + { + tags.length > 0 && + } +
    + +} \ No newline at end of file diff --git a/packages/client/src/hook/useTags.ts b/packages/client/src/hook/useTags.ts new file mode 100644 index 0000000..3141274 --- /dev/null +++ b/packages/client/src/hook/useTags.ts @@ -0,0 +1,9 @@ +import useSWR from "swr"; +import { fetcher } from "./fetcher"; + +export function useTags() { + return useSWR<{ + name: string; + description: string; + }[]>("/api/tags", fetcher); +} \ No newline at end of file diff --git a/packages/client/src/page/galleryPage.tsx b/packages/client/src/page/galleryPage.tsx index 20d15f7..b291882 100644 --- a/packages/client/src/page/galleryPage.tsx +++ b/packages/client/src/page/galleryPage.tsx @@ -1,10 +1,11 @@ -import { useSearch } from "wouter"; -import { Input } from "@/components/ui/input.tsx"; +import { useLocation, useSearch } from "wouter"; import { Button } from "@/components/ui/button.tsx"; import { GalleryCard } from "@/components/gallery/GalleryCard.tsx"; import TagBadge from "@/components/gallery/TagBadge.tsx"; import { useSearchGallery } from "../hook/useSearchGallery.ts"; import { Spinner } from "../components/Spinner.tsx"; +import TagInput from "@/components/gallery/TagInput.tsx"; +import { useState } from "react"; export default function Gallery() { const search = useSearch(); @@ -30,10 +31,7 @@ export default function Gallery() { const isReachingEnd = data && data[size - 1]?.hasMore === false; return (
    -
    - - -
    + {(word || tags) &&
    {word && Search: {word}} @@ -62,3 +60,28 @@ export default function Gallery() { ); } +function Search() { + const search = useSearch(); + const [, navigate] = useLocation(); + const searchParams = new URLSearchParams(search); + const [tags, setTags] = useState(searchParams.get("allow_tag")?.split(",") ?? []); + const [word, setWord] = useState(searchParams.get("word") ?? ""); + return
    + + +
    ; +} +