Rework #6
					 4 changed files with 266 additions and 20 deletions
				
			
		| 
						 | 
				
			
			@ -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 <li className={
 | 
			
		||||
        cn( badgeVariants({ variant: "default"}) ,
 | 
			
		||||
            "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]",
 | 
			
		||||
        cn( tagBadgeVariants({ variant: kind }),
 | 
			
		||||
            props.disabled && "opacity-50",
 | 
			
		||||
            props.className,
 | 
			
		||||
        )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										181
									
								
								packages/client/src/components/gallery/TagInput.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								packages/client/src/components/gallery/TagInput.tsx
									
										
									
									
									
										Normal 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>
 | 
			
		||||
    </>
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								packages/client/src/hook/useTags.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								packages/client/src/hook/useTags.ts
									
										
									
									
									
										Normal 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);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 (<div className="p-4 grid gap-2 overflow-auto h-screen items-start content-start">
 | 
			
		||||
        <div className="flex space-x-2">
 | 
			
		||||
            <Input className="flex-1" />
 | 
			
		||||
            <Button className="flex-none">Search</Button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <Search />
 | 
			
		||||
        {(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>}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue