Rework #6
@ -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,
|
||||||
)
|
)
|
||||||
|
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 { 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user