Rework #6
@ -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…
Reference in New Issue
Block a user