diff --git a/src/RequestAPI.d.ts b/src/RequestAPI.d.ts index 3784e3c2..67794b5f 100644 --- a/src/RequestAPI.d.ts +++ b/src/RequestAPI.d.ts @@ -66,6 +66,7 @@ export type RequestAPI = { ) => InfiniteScrollerResponse; "query::queue::init": () => InfiniteScrollerInitResponse; "query::queue": (request: InfiniteScrollerRequest) => InfiniteScrollerResponse; + "query::tags::search": () => string[]; "save::localVolume": (volume: number, song: ResourceID) => void; diff --git a/src/main/router/import.ts b/src/main/router/import.ts index f98f5ffb..2e92ce0a 100644 --- a/src/main/router/import.ts +++ b/src/main/router/import.ts @@ -11,4 +11,5 @@ import "./resource-router"; import "./settings-router"; import "./song-color-router"; import "./songs-pool-router"; +import "./tags-router"; import "./window-router"; diff --git a/src/main/router/tags-router.ts b/src/main/router/tags-router.ts new file mode 100644 index 00000000..48fbcdc2 --- /dev/null +++ b/src/main/router/tags-router.ts @@ -0,0 +1,31 @@ +import { Router } from "../lib/route-pass/Router"; +import { Storage } from "../lib/storage/Storage"; + +Router.respond("query::tags::search", () => { + const allTags = Storage.getTable("system").get("allTags"); + if (allTags.isNone) { + return []; + } + + const tagsByUse: string[][] = []; + Object.entries(allTags.value).forEach(([tagName, songs]) => { + if (!tagName) { + return; + } + + const size = songs.length; + tagsByUse[size] = (tagsByUse[size] ?? []).concat(tagName); + }); + + let result: string[] = []; + for (let i = tagsByUse.length - 1; i > 0; i--) { + const tagGroup = tagsByUse[i]; + if (!tagGroup) { + continue; + } + + result = result.concat(tagGroup); + } + + return result; +}); diff --git a/src/renderer/src/components/input/Input.tsx b/src/renderer/src/components/input/Input.tsx index 8458103c..e24974bf 100644 --- a/src/renderer/src/components/input/Input.tsx +++ b/src/renderer/src/components/input/Input.tsx @@ -3,7 +3,7 @@ import { Component, JSX, splitProps } from "solid-js"; export const inputStyles = cva( [ - "ring-offset-background placeholder:text-subtext flex h-[42px] w-full rounded-lg px-3.5 py-2 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:bg-surface", + "ring-offset-background placeholder:text-subtext flex w-full rounded-lg disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:bg-surface", ], { variants: { @@ -12,21 +12,27 @@ export const inputStyles = cva( outlined: "border border-stroke bg-transparent border-solid block focus-visible:border-grey-400", }, + size: { + sm: "h-[32px] text-sm px-2.5", + default: "h-[42px] text-base px-3.5 py-2", + }, }, defaultVariants: { variant: "outlined", + size: "default", }, }, ); type Props = JSX.IntrinsicElements["input"] & VariantProps; export const Input: Component = (_props) => { - const [props, rest] = splitProps(_props, ["class", "variant"]); + const [props, rest] = splitProps(_props, ["class", "variant", "size"]); return ( diff --git a/src/renderer/src/components/song/song-list-search/SongListSearch.tsx b/src/renderer/src/components/song/song-list-search/SongListSearch.tsx index 874309ab..70ef7691 100644 --- a/src/renderer/src/components/song/song-list-search/SongListSearch.tsx +++ b/src/renderer/src/components/song/song-list-search/SongListSearch.tsx @@ -2,17 +2,17 @@ import { Optional, Order, Tag } from "../../../../../@types"; import { SearchQueryError } from "../../../../../main/lib/search-parser/@search-types"; import { setSongsSearch } from "../song-list/song-list.utils"; import SongListSearchOrderBy from "./SongListSearchOrderBy"; -import { SongListSearchTags } from "./SongListSearchTags"; +import { SongListSearchTags, TagMode } from "./SongListSearchTags"; import Button from "@renderer/components/button/Button"; import { Input } from "@renderer/components/input/Input"; import { FilterIcon, SearchIcon, FilterXIcon } from "lucide-solid"; -import { Accessor, Component, createSignal, Match, Setter, Signal, Switch } from "solid-js"; +import { Accessor, Component, createSignal, Match, Setter, Switch } from "solid-js"; export type SearchProps = { - tags: Signal; count: Accessor; error: Accessor>; setOrder: Setter; + setTags: Setter; }; const SongListSearch: Component = (props) => { @@ -66,6 +66,17 @@ const SongListSearch: Component = (props) => { // } // }); + const handleValueChange = (tags: Map) => { + const searchFormattedTags = Array.from( + tags.entries(), + ([tagName, mode]): Tag => ({ + name: tagName, + isSpecial: mode === "discart", + }), + ); + props.setTags(searchFormattedTags); + }; + return (
@@ -109,8 +120,8 @@ const SongListSearch: Component = (props) => { }} >
- - + +
diff --git a/src/renderer/src/components/song/song-list-search/SongListSearchTags.tsx b/src/renderer/src/components/song/song-list-search/SongListSearchTags.tsx index b1bebf3b..ceb9ae2f 100644 --- a/src/renderer/src/components/song/song-list-search/SongListSearchTags.tsx +++ b/src/renderer/src/components/song/song-list-search/SongListSearchTags.tsx @@ -1,17 +1,269 @@ import FilterOption from "./FilterOption"; -import { Component } from "solid-js"; +import { Input } from "@renderer/components/input/Input"; +import useControllableState from "@renderer/lib/controllable-state"; +import { cva } from "class-variance-authority"; +import { XIcon } from "lucide-solid"; +import { + Accessor, + Component, + createMemo, + createSignal, + For, + JSX, + Match, + Show, + Switch, +} from "solid-js"; -type Props = { - disabled?: boolean; +export type TagMode = "include" | "discart"; +export type TagLabel = ReturnType; +export type Props = { + value?: Accessor>; + onValueChange?: (newValue: Map) => void; }; + +const tagsToLabel = (tags: [string, TagMode][]) => { + const fistTag = tags[0]; + let additionalInclude = 0; + let additionalDiscart = 0; + for (let i = 1; i < tags.length; i++) { + const [, tagMode] = tags[i]; + switch (tagMode) { + case "include": + additionalInclude++; + break; + case "discart": + additionalDiscart++; + break; + default: + break; + } + } + + return { + fistTag, + additionalDiscart, + additionalInclude, + }; +}; + +/** Groups selected tags by mode, returns a map with include and discart tags, having the include tags first */ +function groupSelectedTagsByMode(selectedTags: Map): Map { + const include = new Map(); + const discart = new Map(); + + selectedTags.forEach((mode, tag) => { + if (mode === "include") { + include.set(tag, mode); + } else if (mode === "discart") { + discart.set(tag, mode); + } + }); + + return new Map([...include, ...discart]); +} + export const SongListSearchTags: Component = (props) => { + const [hasFetchedTags, setHasFetchedTags] = createSignal(false); + const [isPopopOpen, setIsPopopOpen] = createSignal(false); + const [tags, setTags] = createSignal([]); + const [showHint, setShowHint] = createSignal(true); + const [selectedTags, setSelectedTags] = useControllableState({ + prop: props.value, + onChange: props.onValueChange, + defaultProp: new Map(), + }); + const [search, setSearch] = createSignal(""); + const [isScrolled, setIsScrolled] = createSignal(false); + + const label = createMemo(() => { + const s = Array.from(selectedTags().entries()); + if (s.length === 0) { + return "Tags"; + } + + return tagsToLabel(s); + }); + + const fetchTags = async () => { + setHasFetchedTags(true); + const remoteTags = await window.api.request("query::tags::search"); + setTags(remoteTags); + }; + + const handlePopoverChange = (newValue: boolean) => { + setIsPopopOpen(newValue); + + if (!newValue || hasFetchedTags()) { + return; + } + + fetchTags(); + }; + + const handleTagClick = (tag: string) => { + setSelectedTags((oldSelectedTags) => { + const newSelectedTags = new Map(oldSelectedTags); + const tagMode = newSelectedTags.get(tag); + + if (typeof tagMode === "undefined") { + newSelectedTags.set(tag, "include"); + } else if (tagMode === "include") { + newSelectedTags.set(tag, "discart"); + } else if (tagMode === "discart") { + newSelectedTags.delete(tag); + } + + return groupSelectedTagsByMode(newSelectedTags); + }); + }; + + const removeTag = (tag: string) => { + setSelectedTags((oldSelectedTags) => { + const newSelectedTags = new Map(oldSelectedTags); + newSelectedTags.delete(tag); + return groupSelectedTagsByMode(newSelectedTags); + }); + }; + + const clearAllTags = () => { + setSelectedTags(new Map()); + }; + + const filteredTags = createMemo(() => { + return tags().filter((tag) => tag.includes(search())); + }); + const selectedTagsEntries = createMemo(() => Array.from(selectedTags().entries())); + const selectedTagsCount = createMemo(() => selectedTagsEntries().length); + return ( - + Tags - None + + + + {label() as string} + + + } /> + + + - Tags placeholder + setIsScrolled(e.currentTarget.scrollTop > 0)} + > +
+
+ setSearch(e.currentTarget.value)} + /> + 0}> + + {([tag, mode]) => ( +
+ {tag} + +
+ )} +
+ +
+
+ +
+ + Click on any tag once to include it. Click on it again to exclude it. Click once + more to clear it + {" "} + +
+
+
+ + {(tag) => ( + + {tag} + + )} + +
+
+
); }; + +export type TagProps = { + mode?: TagMode; + tag: string; + children: JSX.Element; + onTagClick?: (tag: string) => void; +}; +const tagStyles = cva(["select-none rounded-md px-3 py-1 border"], { + variants: { + mode: { + default: "border-solid border-transparent bg-surface", + include: "border-solid border-green bg-green/5", + discart: "border-dashed border-red-500 bg-red-500/5", + }, + }, + defaultVariants: { + mode: "default", + }, +}); +const Tag: Component = (props) => { + return ( + + ); +}; + +export type TagSelectedLabelProps = { + label: Accessor; +}; +const TagSelectedLabel: Component = (props) => { + const firstTag = () => { + const [name, mode] = props.label().fistTag; + return { name, mode }; + }; + + return ( + + + {firstTag().name} + + 0}> + +{props.label().additionalInclude} + + 0}> + -{props.label().additionalDiscart} + + + ); +}; diff --git a/src/renderer/src/components/song/song-list/SongList.tsx b/src/renderer/src/components/song/song-list/SongList.tsx index a55ecdd9..5834dad6 100644 --- a/src/renderer/src/components/song/song-list/SongList.tsx +++ b/src/renderer/src/components/song/song-list/SongList.tsx @@ -21,7 +21,7 @@ const DEFAULT_TAGS_VALUE: Tag[] = []; const DEFAULT_ORDER_VALUE: Order = { option: "title", direction: "asc" }; const SongList: Component = (props) => { - const tagsSignal = createSignal(DEFAULT_TAGS_VALUE, { equals: false }); + const [tags, setTags] = createSignal(DEFAULT_TAGS_VALUE, { equals: false }); const [order, setOrder] = createSignal(DEFAULT_ORDER_VALUE); const [count, setCount] = createSignal(0); @@ -38,7 +38,7 @@ const SongList: Component = (props) => { const searchSongs = async () => { const o = order(); - const t = tagsSignal[0](); + const t = tags(); const parsedQuery = await window.api.request("parse::search", songsSearch()); if (parsedQuery.type === "error") { @@ -76,7 +76,7 @@ const SongList: Component = (props) => { return ( <> - +