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
+ {isLoading && <>
+
+
+
+ >}
+ {
+ candidates?.length === 0 && - No results
+ }
+ {candidates?.map((tag) => - 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}
)}
+
+}
+
+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
+
+
+
;
+}
+