tags search

This commit is contained in:
monoid 2024-04-07 22:12:41 +09:00
parent a9e646dd81
commit 02f3cd9bd1
4 changed files with 266 additions and 20 deletions

View File

@ -1,16 +1,30 @@
import { badgeVariants } from "@/components/ui/badge.tsx"; import { badgeVariants } from "@/components/ui/badge.tsx";
import { Link } from "wouter"; import { Link } from "wouter";
import { cn } from "@/lib/utils.ts"; 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) { if (tagname.match(":") === null) {
return "default"; return "default";
} }
const prefix = tagname.split(":")[0]; 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 kind = getTagKind(tagname);
const name = tagname.slice(kind.length + 1); 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 { tagname } = props;
const kind = getTagKind(tagname); const kind = getTagKind(tagname);
return <li className={ return <li className={
cn( badgeVariants({ variant: "default"}) , cn( tagBadgeVariants({ variant: kind }),
"px-1",
kind === "male" && "bg-[#2a7bbf] hover:bg-[#3091e7]",
kind === "female" && "bg-[#c21f58] hover:bg-[#db2d67]",
kind === "artist" && "bg-[#319795] hover:bg-[#38a89d]",
kind === "group" && "bg-[#805ad5] hover:bg-[#8b5cd6]",
kind === "series" && "bg-[#dc8f09] hover:bg-[#e69d17]",
kind === "character" && "bg-[#52952c] hover:bg-[#6cc24a]",
kind === "type" && "bg-[#d53f8c] hover:bg-[#e24996]",
kind === "default" && "bg-[#4a5568] hover:bg-[#718096]",
props.disabled && "opacity-50", props.disabled && "opacity-50",
props.className, props.className,
) )

View File

@ -0,0 +1,181 @@
import { cn } from "@/lib/utils";
import { getTagKind, tagBadgeVariants } from "./TagBadge";
import { useEffect, useRef, useState } from "react";
import { Button } from "../ui/button";
import { useOnClickOutside } from "usehooks-ts";
import { useTags } from "@/hook/useTags";
import { Skeleton } from "../ui/skeleton";
interface TagsSelectListProps {
className?: string;
search?: string;
onSelect?: (tag: string) => void;
onFirstArrowUp?: () => void;
}
function TagsSelectList({
search = "",
onSelect,
onFirstArrowUp = () => { },
}: TagsSelectListProps) {
const { data, isLoading } = useTags();
const candidates = data?.filter(s => s.name.startsWith(search));
return <ul className="max-h-[400px] overflow-scroll overflow-x-hidden">
{isLoading && <>
<li><Skeleton /></li>
<li><Skeleton /></li>
<li><Skeleton /></li>
</>}
{
candidates?.length === 0 && <li className="p-2">No results</li>
}
{candidates?.map((tag) => <li key={tag.name}
className="hover:bg-accent cursor-pointer p-1 rounded-sm transition-colors
focus:outline-none focus:bg-accent focus:text-accent-foreground"
tabIndex={-1}
onClick={() => onSelect?.(tag.name)}
onKeyDown={(e) => {
if (e.key === "Enter") {
onSelect?.(tag.name);
}
if (e.key === "ArrowDown") {
const next = e.currentTarget.nextElementSibling as HTMLElement;
next?.focus();
e.preventDefault();
}
if (e.key === "ArrowUp") {
const prev = e.currentTarget.previousElementSibling as HTMLElement;
if (prev){
prev.focus();
}
else {
onFirstArrowUp();
}
e.preventDefault();
}
}}
onPointerMove={(e) => {
e.currentTarget.focus();
}}
>{tag.name}</li>)}
</ul>
}
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<HTMLInputElement>(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<HTMLDivElement>(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 */}
<div className={cn(`flex h-9 w-full rounded-md border border-input bg-transparent
px-3 py-1 text-sm shadow-sm transition-colors justify-start items-center pr-0
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50`,
isFocused && "outline-none ring-1 ring-ring",
className
)}
onClick={() => inputRef.current?.focus()}
>
<ul className="flex gap-1 flex-none">
{tags.map((tag) => <li className={cn(
tagBadgeVariants({ variant: getTagKind(tag) }),
"cursor-pointer"
)} key={tag} onPointerDown={() =>{
setTags(tags.filter(x=>x!==tag));
}}>{tag}</li>)}
</ul>
<input ref={inputRef} type="text" className="flex-1 border-0 ml-2 focus:border-0 focus:outline-none" placeholder="Add tag"
onFocus={() => 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 && <div
ref={autocompleteRef}
className="absolute z-10 shadow-md bg-popover text-popover-foreground
border
rounded-sm p-2 w-[200px]"
style={{ top: openInfo.top, left: openInfo.left }}
>
<TagsSelectList search={input} onSelect={(tag) => {
setTags([...tags, tag]);
setInput("");
setOpenInfo(null);
}}
onFirstArrowUp={() => {
inputRef.current?.focus();
}}
/>
</div>
}
{
tags.length > 0 && <Button variant="ghost" className="flex-none" onClick={() => {
setTags([]);
setOpenInfo(null);
}}>Clear</Button>
}
</div>
</>
}

View File

@ -0,0 +1,9 @@
import useSWR from "swr";
import { fetcher } from "./fetcher";
export function useTags() {
return useSWR<{
name: string;
description: string;
}[]>("/api/tags", fetcher);
}

View File

@ -1,10 +1,11 @@
import { useSearch } from "wouter"; import { useLocation, useSearch } from "wouter";
import { Input } from "@/components/ui/input.tsx";
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 { useSearchGallery } 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 { useState } from "react";
export default function Gallery() { export default function Gallery() {
const search = useSearch(); const search = useSearch();
@ -30,10 +31,7 @@ export default function Gallery() {
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-screen items-start content-start"> return (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start">
<div className="flex space-x-2"> <Search />
<Input className="flex-1" />
<Button className="flex-none">Search</Button>
</div>
{(word || tags) && {(word || tags) &&
<div className="bg-primary rounded-full p-1 sticky top-0 shadow-md"> <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>}
@ -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 <div className="flex space-x-2">
<TagInput className="flex-1" input={word} onInputChange={setWord}
tags={tags} onTagsChange={setTags}
/>
<Button className="flex-none" onClick={()=>{
const params = new URLSearchParams();
if (tags.length > 0) {
for (const tag of tags) {
params.append("allow_tag", tag);
}
}
if (word) {
params.set("word", word);
}
navigate(`/search?${params.toString()}`);
}}>Search</Button>
</div>;
}