diff --git a/Cargo.lock b/Cargo.lock index 54d835e..33645e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,7 +996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2329,8 +2329,8 @@ dependencies = [ [[package]] name = "ltk_fantome" -version = "0.2.0" -source = "git+https://github.com/LeagueToolkit/league-mod#1f3c5597743547537a8acd93d59148eba701ce99" +version = "0.3.0" +source = "git+https://github.com/LeagueToolkit/league-mod#058b1a9d8ade4488688cce4937d029ea6b7bfd6d" dependencies = [ "camino", "eyre", @@ -2382,7 +2382,7 @@ dependencies = [ [[package]] name = "ltk_mod_core" version = "0.1.0" -source = "git+https://github.com/LeagueToolkit/league-mod#1f3c5597743547537a8acd93d59148eba701ce99" +source = "git+https://github.com/LeagueToolkit/league-mod#058b1a9d8ade4488688cce4937d029ea6b7bfd6d" dependencies = [ "camino", "serde", @@ -2393,8 +2393,8 @@ dependencies = [ [[package]] name = "ltk_mod_project" -version = "0.2.0" -source = "git+https://github.com/LeagueToolkit/league-mod#1f3c5597743547537a8acd93d59148eba701ce99" +version = "0.3.0" +source = "git+https://github.com/LeagueToolkit/league-mod#058b1a9d8ade4488688cce4937d029ea6b7bfd6d" dependencies = [ "serde", "serde_json", @@ -2403,8 +2403,8 @@ dependencies = [ [[package]] name = "ltk_modpkg" -version = "0.2.0" -source = "git+https://github.com/LeagueToolkit/league-mod#1f3c5597743547537a8acd93d59148eba701ce99" +version = "0.3.0" +source = "git+https://github.com/LeagueToolkit/league-mod#058b1a9d8ade4488688cce4937d029ea6b7bfd6d" dependencies = [ "binrw", "byteorder", @@ -2428,8 +2428,8 @@ dependencies = [ [[package]] name = "ltk_overlay" -version = "0.1.1" -source = "git+https://github.com/LeagueToolkit/league-mod#1f3c5597743547537a8acd93d59148eba701ce99" +version = "0.1.2" +source = "git+https://github.com/LeagueToolkit/league-mod#058b1a9d8ade4488688cce4937d029ea6b7bfd6d" dependencies = [ "byteorder", "camino", @@ -2792,7 +2792,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3939,7 +3939,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3995,7 +3995,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5062,7 +5062,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5977,7 +5977,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 71a8c28..40c2391 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,11 +17,11 @@ tauri-plugin-fs = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" -ltk_modpkg = { version = "0.2", git = "https://github.com/LeagueToolkit/league-mod", features = ["project"] } -ltk_mod_project = { version = "0.2", git = "https://github.com/LeagueToolkit/league-mod" } +ltk_modpkg = { version = "0.3", git = "https://github.com/LeagueToolkit/league-mod", features = ["project"] } +ltk_mod_project = { version = "0.3", git = "https://github.com/LeagueToolkit/league-mod" } ltk_mod_core = { version = "0.1", git = "https://github.com/LeagueToolkit/league-mod" } -ltk_fantome = { version = "0.2", git = "https://github.com/LeagueToolkit/league-mod" } -ltk_overlay = { version = "0.1.1", git = "https://github.com/LeagueToolkit/league-mod" } +ltk_fantome = { version = "0.3", git = "https://github.com/LeagueToolkit/league-mod" } +ltk_overlay = { version = "0.1.2", git = "https://github.com/LeagueToolkit/league-mod" } ltk_wad = "0.2.12" ltk_file = "0.2.8" diff --git a/src-tauri/src/mods/library.rs b/src-tauri/src/mods/library.rs index d6a03c0..4d64c6e 100644 --- a/src-tauri/src/mods/library.rs +++ b/src-tauri/src/mods/library.rs @@ -1,7 +1,7 @@ use crate::error::{AppError, AppResult}; use crate::state::Settings; use chrono::Utc; -use ltk_mod_project::{ModProject, ModProjectLayer}; +use ltk_mod_project::{ModMap, ModProject, ModProjectLayer, ModTag}; use ltk_modpkg::Modpkg; use std::fs; use std::path::{Path, PathBuf}; @@ -460,6 +460,9 @@ fn read_installed_mod( enabled, installed_at: entry.installed_at, layers, + tags: project.tags.iter().map(|t| t.to_string()).collect(), + champions: project.champions.clone(), + maps: project.maps.iter().map(|m| m.to_string()).collect(), mod_dir: mod_dir.display().to_string(), }) } @@ -551,6 +554,9 @@ fn extract_fantome_metadata(file_path: &Path, metadata_dir: &Path) -> AppResult< description: info.description, authors: vec![ltk_mod_project::ModProjectAuthor::Name(info.author)], license: None, + tags: info.tags.into_iter().map(ModTag::from).collect(), + champions: info.champions, + maps: info.maps.into_iter().map(ModMap::from).collect(), transformers: Vec::new(), layers, thumbnail: None, @@ -626,6 +632,9 @@ fn extract_modpkg_metadata(file_path: &Path, metadata_dir: &Path) -> AppResult<( .map(|a| ltk_mod_project::ModProjectAuthor::Name(a.name)) .collect(), license: None, + tags: metadata.tags.into_iter().map(ModTag::from).collect(), + champions: metadata.champions, + maps: metadata.maps.into_iter().map(ModMap::from).collect(), transformers: Vec::new(), layers, thumbnail: None, diff --git a/src-tauri/src/mods/mod.rs b/src-tauri/src/mods/mod.rs index 55e3631..ad720ce 100644 --- a/src-tauri/src/mods/mod.rs +++ b/src-tauri/src/mods/mod.rs @@ -174,6 +174,9 @@ pub struct InstalledMod { pub enabled: bool, pub installed_at: DateTime, pub layers: Vec, + pub tags: Vec, + pub champions: Vec, + pub maps: Vec, /// Directory where the mod is installed pub mod_dir: String, } diff --git a/src-tauri/src/overlay/fantome_content.rs b/src-tauri/src/overlay/fantome_content.rs index 00a0006..fa8147e 100644 --- a/src-tauri/src/overlay/fantome_content.rs +++ b/src-tauri/src/overlay/fantome_content.rs @@ -67,6 +67,9 @@ impl ModContentProvider for FantomeContent { description: info.description, authors: vec![ModProjectAuthor::Name(info.author)], license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), transformers: Vec::new(), layers: default_layers(), thumbnail: None, diff --git a/src-tauri/src/overlay/modpkg_content.rs b/src-tauri/src/overlay/modpkg_content.rs index 1b55921..19f0609 100644 --- a/src-tauri/src/overlay/modpkg_content.rs +++ b/src-tauri/src/overlay/modpkg_content.rs @@ -56,6 +56,9 @@ impl ModContentProvider for ModpkgContent { .map(|a| ModProjectAuthor::Name(a.name)) .collect(), license: None, + tags: Vec::new(), + champions: Vec::new(), + maps: Vec::new(), transformers: Vec::new(), layers, thumbnail: None, diff --git a/src-tauri/src/workshop/mod.rs b/src-tauri/src/workshop/mod.rs index 92cabfe..bc9b44c 100644 --- a/src-tauri/src/workshop/mod.rs +++ b/src-tauri/src/workshop/mod.rs @@ -56,6 +56,12 @@ pub struct WorkshopProject { pub description: String, /// List of authors pub authors: Vec, + /// Categorization tags + pub tags: Vec, + /// Champion names this mod applies to + pub champions: Vec, + /// Map identifiers this mod applies to + pub maps: Vec, /// Project layers pub layers: Vec, /// Path to thumbnail image if exists @@ -100,6 +106,9 @@ pub struct SaveProjectConfigArgs { pub version: String, pub description: String, pub authors: Vec, + pub tags: Vec, + pub champions: Vec, + pub maps: Vec, } /// Arguments for packing a project. @@ -215,6 +224,10 @@ pub(crate) fn load_workshop_project(project_dir: &Path) -> AppResult AppResult ModProjectAuthor::Name(a.name), }) .collect(); + mod_project.tags = args.tags.into_iter().map(ModTag::from).collect(); + mod_project.champions = args.champions; + mod_project.maps = args.maps.into_iter().map(ModMap::from).collect(); let json_config_path = path.join("mod.config.json"); let config_content = serde_json::to_string_pretty(&mod_project)?; @@ -275,6 +283,17 @@ impl Workshop { .map(|a| ModProjectAuthor::Name(a.name)) .collect(), license: None, + tags: metadata + .tags + .into_iter() + .map(ltk_mod_project::ModTag::from) + .collect(), + champions: metadata.champions, + maps: metadata + .maps + .into_iter() + .map(ltk_mod_project::ModMap::from) + .collect(), transformers: Vec::new(), layers, thumbnail: None, diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx new file mode 100644 index 0000000..74f84b0 --- /dev/null +++ b/src/components/Combobox.tsx @@ -0,0 +1,476 @@ +import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox"; +import { forwardRef, type ReactNode } from "react"; +import { LuCheck, LuChevronDown, LuX } from "react-icons/lu"; +import { twMerge } from "tailwind-merge"; + +// Re-export the filter hook for consumers +export const useComboboxFilter = BaseCombobox.useFilter; + +// Root +export interface ComboboxRootProps< + Value = string, + Multiple extends boolean | undefined = false, +> extends BaseCombobox.Root.Props { + children?: ReactNode; +} + +export function ComboboxRoot({ + children, + ...props +}: ComboboxRootProps) { + return {...props}>{children}; +} +ComboboxRoot.displayName = "Combobox.Root"; + +// Input +export interface ComboboxInputProps extends Omit { + className?: string; + hasError?: boolean; +} + +export const ComboboxInput = forwardRef( + ({ className, hasError, ...props }, ref) => { + return ( + + ); + }, +); +ComboboxInput.displayName = "Combobox.Input"; + +// Trigger +export interface ComboboxTriggerProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxTrigger = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxTrigger.displayName = "Combobox.Trigger"; + +// Icon +export interface ComboboxIconProps extends Omit { + className?: string; +} + +export const ComboboxIcon = forwardRef( + ({ className, ...props }, ref) => { + return ( + + + + ); + }, +); +ComboboxIcon.displayName = "Combobox.Icon"; + +// Portal +export interface ComboboxPortalProps extends BaseCombobox.Portal.Props { + children?: ReactNode; +} + +export const ComboboxPortal = ({ children, ...props }: ComboboxPortalProps) => { + return {children}; +}; +ComboboxPortal.displayName = "Combobox.Portal"; + +// Positioner +export interface ComboboxPositionerProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxPositioner = forwardRef( + ({ className, children, side = "bottom", sideOffset = 4, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxPositioner.displayName = "Combobox.Positioner"; + +// Popup +export interface ComboboxPopupProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxPopup = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxPopup.displayName = "Combobox.Popup"; + +// List +export interface ComboboxListProps extends Omit { + className?: string; +} + +export const ComboboxList = forwardRef( + ({ className, ...props }, ref) => { + return ; + }, +); +ComboboxList.displayName = "Combobox.List"; + +// Item +export interface ComboboxItemProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxItem = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + + + + {children} + + ); + }, +); +ComboboxItem.displayName = "Combobox.Item"; + +// Empty +export interface ComboboxEmptyProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxEmpty = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children ?? "No results found"} + + ); + }, +); +ComboboxEmpty.displayName = "Combobox.Empty"; + +// Clear +export interface ComboboxClearProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxClear = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children ?? } + + ); + }, +); +ComboboxClear.displayName = "Combobox.Clear"; + +// Chips +export interface ComboboxChipsProps extends Omit { + className?: string; +} + +export const ComboboxChips = forwardRef( + ({ className, ...props }, ref) => { + return ( + + ); + }, +); +ComboboxChips.displayName = "Combobox.Chips"; + +// Chip +export interface ComboboxChipProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxChip = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxChip.displayName = "Combobox.Chip"; + +// ChipRemove +export interface ComboboxChipRemoveProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxChipRemove = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children ?? } + + ); + }, +); +ComboboxChipRemove.displayName = "Combobox.ChipRemove"; + +// Group +export interface ComboboxGroupProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxGroup = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxGroup.displayName = "Combobox.Group"; + +// GroupLabel +export interface ComboboxGroupLabelProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxGroupLabel = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxGroupLabel.displayName = "Combobox.GroupLabel"; + +// Status +export interface ComboboxStatusProps extends Omit { + className?: string; + children?: ReactNode; +} + +export const ComboboxStatus = forwardRef( + ({ className, children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); +ComboboxStatus.displayName = "Combobox.Status"; + +// Compound export +export const Combobox = { + Root: ComboboxRoot, + Input: ComboboxInput, + Trigger: ComboboxTrigger, + Icon: ComboboxIcon, + Portal: ComboboxPortal, + Positioner: ComboboxPositioner, + Popup: ComboboxPopup, + List: ComboboxList, + Item: ComboboxItem, + Empty: ComboboxEmpty, + Clear: ComboboxClear, + Chips: ComboboxChips, + Chip: ComboboxChip, + ChipRemove: ComboboxChipRemove, + Group: ComboboxGroup, + GroupLabel: ComboboxGroupLabel, + Status: ComboboxStatus, +}; + +// --- Simplified ComboboxField for common use cases --- + +export interface ComboboxOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface ComboboxFieldProps { + label?: string; + description?: string; + error?: string; + required?: boolean; + placeholder?: string; + options: ComboboxOption[]; + value?: string; + defaultValue?: string; + onValueChange?: (value: string | null) => void; + disabled?: boolean; + name?: string; + className?: string; + inputClassName?: string; +} + +export function ComboboxField({ + label, + description, + error, + required, + placeholder, + options, + value, + defaultValue, + onValueChange, + disabled, + name, + className, + inputClassName, +}: ComboboxFieldProps) { + const filter = useComboboxFilter(); + + const selectedOption = value != null ? options.find((o) => o.value === value) : undefined; + const defaultOption = + defaultValue != null ? options.find((o) => o.value === defaultValue) : undefined; + + return ( +
+ {label && ( + + )} + {description &&

{description}

} + + value={selectedOption} + defaultValue={defaultOption} + onValueChange={(opt) => onValueChange?.(opt?.value ?? null)} + disabled={disabled} + name={name} + items={options} + filter={(item, query) => filter.contains(item, query, (o) => o.label)} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + > +
+ + + + +
+ + + + + {(item: ComboboxOption) => ( + + {item.label} + + )} + + + + + + + {error &&

{error}

} +
+ ); +} +ComboboxField.displayName = "ComboboxField"; diff --git a/src/components/MultiSelect.tsx b/src/components/MultiSelect.tsx new file mode 100644 index 0000000..34095d5 --- /dev/null +++ b/src/components/MultiSelect.tsx @@ -0,0 +1,152 @@ +import { Combobox as BaseCombobox } from "@base-ui-components/react/combobox"; +import { useMemo } from "react"; +import { LuCheck, LuChevronDown } from "react-icons/lu"; +import { twMerge } from "tailwind-merge"; + +export interface MultiSelectOption { + value: string; + label: string; + disabled?: boolean; +} + +export interface MultiSelectProps { + options: MultiSelectOption[]; + selected: Set; + onChange: (selected: Set) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + variant?: "compact" | "field"; +} + +export function MultiSelect({ + options, + selected, + onChange, + label, + placeholder, + disabled, + className, + variant = "compact", +}: MultiSelectProps) { + const filter = BaseCombobox.useFilter(); + + const selectedOptions = useMemo( + () => options.filter((o) => selected.has(o.value)), + [options, selected], + ); + + const sortedItems = useMemo(() => { + const sortByLabel = (a: MultiSelectOption, b: MultiSelectOption) => + a.label.localeCompare(b.label); + const sel = options.filter((o) => selected.has(o.value)).sort(sortByLabel); + const unsel = options.filter((o) => !selected.has(o.value)).sort(sortByLabel); + return [...sel, ...unsel]; + }, [options, selected]); + + return ( + + multiple + value={selectedOptions} + onValueChange={(opts) => onChange(new Set(opts.map((o) => o.value)))} + items={sortedItems} + filter={(item, query) => filter.contains(item, query, (o) => o.label)} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + disabled={disabled} + > + {variant === "compact" ? ( + + {label && {label}} + {selected.size > 0 && ( + + {selected.size} + + )} + + + ) : ( + + + {selectedOptions.length > 0 ? ( + selectedOptions.map((o) => ( + + {o.label} + + )) + ) : ( + {label ?? "Select..."} + )} + + + + )} + + + +
+ +
+ + {(item: MultiSelectOption) => ( + + + + + + + {item.label} + + )} + + +

No results found

+
+
+
+
+ + ); +} +MultiSelect.displayName = "MultiSelect"; diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 5f07d63..49e15e7 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -47,14 +47,23 @@ SelectTrigger.displayName = "Select.Trigger"; // Value export interface SelectValueProps extends Omit { className?: string; - placeholder?: string; + prefix?: string; children?: BaseSelect.Value.Props["children"]; } -export const SelectValue = ({ className, placeholder, children, ...props }: SelectValueProps) => { +export const SelectValue = ({ className, prefix, children, ...props }: SelectValueProps) => { + if (prefix) { + return ( + + {prefix} + {children} + + ); + } + return ( - {children ?? ((value: unknown) => (value as string) || placeholder || "")} + {children} ); }; @@ -93,11 +102,23 @@ export interface SelectPositionerProps extends Omit( - ({ className, children, sideOffset = 4, ...props }, ref) => { + ( + { + className, + children, + side = "bottom", + sideOffset = 4, + alignItemWithTrigger = false, + ...props + }, + ref, + ) => { return ( @@ -120,6 +141,7 @@ export const SelectPopup = forwardRef( - + diff --git a/src/components/index.ts b/src/components/index.ts index 5f77eae..0efe898 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,8 +1,10 @@ export * from "./Button"; export * from "./Checkbox"; +export * from "./Combobox"; export * from "./Dialog"; export * from "./FormField"; export * from "./Menu"; +export * from "./MultiSelect"; export * from "./NavTabs"; export * from "./Popover"; export * from "./Progress"; diff --git a/src/lib/form/components.tsx b/src/lib/form/components.tsx index c69e1df..e79dae8 100644 --- a/src/lib/form/components.tsx +++ b/src/lib/form/components.tsx @@ -2,6 +2,17 @@ import type { InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; import { + ComboboxEmpty, + ComboboxIcon, + ComboboxInput, + ComboboxItem, + ComboboxList, + type ComboboxOption, + ComboboxPopup, + ComboboxPortal, + ComboboxPositioner, + ComboboxRoot, + ComboboxTrigger, Field, FieldControl, FieldDescription, @@ -17,6 +28,7 @@ import { SelectRoot, SelectTrigger, SelectValue, + useComboboxFilter, } from "@/components"; import { useFieldContext, useFormContext } from "./form-context"; @@ -129,7 +141,6 @@ export function SelectField({ label, description, required, - placeholder, options, disabled, className, @@ -148,7 +159,7 @@ export function SelectField({ disabled={disabled} > - + @@ -168,6 +179,78 @@ export function SelectField({ ); } +// ComboboxField - Pre-bound combobox field component +export interface ComboboxFieldProps { + label?: string; + description?: string; + required?: boolean; + placeholder?: string; + options: ComboboxOption[]; + disabled?: boolean; + className?: string; + inputClassName?: string; +} + +export function ComboboxField({ + label, + description, + required, + placeholder, + options, + disabled, + className, + inputClassName, +}: ComboboxFieldProps) { + const field = useFieldContext(); + const hasError = field.state.meta.errors.length > 0; + const filter = useComboboxFilter(); + + const selectedOption = options.find((o) => o.value === field.state.value); + + return ( + + {label && {label}} + {description && {description}} + + value={selectedOption} + onValueChange={(opt) => field.handleChange(opt?.value ?? "")} + disabled={disabled} + items={options} + filter={(item, query) => filter.contains(item, query, (o) => o.label)} + itemToStringLabel={(item) => item.label} + itemToStringValue={(item) => item.value} + > +
+ + + + +
+ + + + + {(item: ComboboxOption) => ( + + {item.label} + + )} + + + + + + + {hasError && {field.state.meta.errors.join(", ")}} +
+ ); +} + // SubmitButton - Form-aware submit button export interface SubmitButtonProps { children: React.ReactNode; diff --git a/src/lib/form/index.ts b/src/lib/form/index.ts index d1ee56d..8967fba 100644 --- a/src/lib/form/index.ts +++ b/src/lib/form/index.ts @@ -1,6 +1,6 @@ import { createFormHook, formOptions } from "@tanstack/react-form"; -import { SelectField, SubmitButton, TextareaField, TextField } from "./components"; +import { ComboboxField, SelectField, SubmitButton, TextareaField, TextField } from "./components"; import { fieldContext, formContext, useFieldContext, useFormContext } from "./form-context"; // Create the app-wide form hook with pre-bound components @@ -9,6 +9,7 @@ const { useAppForm, withForm } = createFormHook({ fieldContext, formContext, fieldComponents: { + ComboboxField, TextField, TextareaField, SelectField, @@ -20,8 +21,9 @@ const { useAppForm, withForm } = createFormHook({ // Re-export everything for convenient imports export { - formOptions, // Pre-built field components + ComboboxField, + formOptions, SelectField, SubmitButton, TextareaField, diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 6a4053b..79ac236 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -45,6 +45,9 @@ export interface InstalledMod { enabled: boolean; installedAt: string; layers: ModLayer[]; + tags: string[]; + champions: string[]; + maps: string[]; modDir: string; } @@ -128,6 +131,9 @@ export interface WorkshopProject { version: string; description: string; authors: WorkshopAuthor[]; + tags: string[]; + champions: string[]; + maps: string[]; layers: WorkshopLayer[]; thumbnailPath?: string; lastModified: string; @@ -158,6 +164,9 @@ export interface SaveProjectConfigArgs { version: string; description: string; authors: WorkshopAuthor[]; + tags: string[]; + champions: string[]; + maps: string[]; } export interface PackProjectArgs { diff --git a/src/modules/library/api/index.ts b/src/modules/library/api/index.ts index 74126cf..ea70d55 100644 --- a/src/modules/library/api/index.ts +++ b/src/modules/library/api/index.ts @@ -2,6 +2,9 @@ export { libraryKeys } from "./keys"; export { useBulkInstallMods } from "./useBulkInstallMods"; export { useCreateProfile } from "./useCreateProfile"; export { useDeleteProfile } from "./useDeleteProfile"; +export { useFilteredMods } from "./useFilteredMods"; +export type { FilterOptions } from "./useFilterOptions"; +export { useFilterOptions } from "./useFilterOptions"; export { useInstallMod } from "./useInstallMod"; export { useInstallProgress } from "./useInstallProgress"; export { useLibraryActions } from "./useLibraryActions"; diff --git a/src/modules/library/api/useFilterOptions.ts b/src/modules/library/api/useFilterOptions.ts new file mode 100644 index 0000000..0b2188b --- /dev/null +++ b/src/modules/library/api/useFilterOptions.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; + +import type { InstalledMod } from "@/lib/tauri"; + +export interface FilterOptions { + tags: string[]; + champions: string[]; + maps: string[]; +} + +export function useFilterOptions(mods: InstalledMod[]): FilterOptions { + return useMemo(() => { + const tags = new Set(); + const champions = new Set(); + const maps = new Set(); + + for (const mod of mods) { + for (const t of mod.tags) tags.add(t); + for (const c of mod.champions) champions.add(c); + for (const m of mod.maps) maps.add(m); + } + + return { + tags: [...tags].sort(), + champions: [...champions].sort(), + maps: [...maps].sort(), + }; + }, [mods]); +} diff --git a/src/modules/library/api/useFilteredMods.ts b/src/modules/library/api/useFilteredMods.ts new file mode 100644 index 0000000..9925215 --- /dev/null +++ b/src/modules/library/api/useFilteredMods.ts @@ -0,0 +1,50 @@ +import { useMemo } from "react"; + +import type { InstalledMod } from "@/lib/tauri"; +import { useLibraryFilterStore } from "@/stores"; + +export function useFilteredMods(mods: InstalledMod[], searchQuery: string): InstalledMod[] { + const { selectedTags, selectedChampions, selectedMaps, sort } = useLibraryFilterStore(); + + return useMemo(() => { + let result = mods; + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (mod) => mod.displayName.toLowerCase().includes(q) || mod.name.toLowerCase().includes(q), + ); + } + + if (selectedTags.size > 0) { + result = result.filter((mod) => mod.tags.some((t) => selectedTags.has(t))); + } + if (selectedChampions.size > 0) { + result = result.filter((mod) => mod.champions.some((c) => selectedChampions.has(c))); + } + if (selectedMaps.size > 0) { + result = result.filter((mod) => mod.maps.some((m) => selectedMaps.has(m))); + } + + if (sort.field === "priority") return result; + + const sorted = [...result]; + const dir = sort.direction === "asc" ? 1 : -1; + + sorted.sort((a, b) => { + switch (sort.field) { + case "name": + return dir * a.displayName.localeCompare(b.displayName); + case "installedAt": + return dir * (new Date(a.installedAt).getTime() - new Date(b.installedAt).getTime()); + case "enabled": + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return sorted; + }, [mods, searchQuery, selectedTags, selectedChampions, selectedMaps, sort]); +} diff --git a/src/modules/library/components/ActiveFilterChips.tsx b/src/modules/library/components/ActiveFilterChips.tsx new file mode 100644 index 0000000..2663f89 --- /dev/null +++ b/src/modules/library/components/ActiveFilterChips.tsx @@ -0,0 +1,78 @@ +import { LuX } from "react-icons/lu"; + +import { getMapLabel, getTagLabel } from "@/modules/library/utils/labels"; +import { useHasActiveFilters, useLibraryFilterStore } from "@/stores"; + +export function ActiveFilterChips() { + const { + selectedTags, + selectedChampions, + selectedMaps, + toggleTag, + toggleChampion, + toggleMap, + clearFilters, + } = useLibraryFilterStore(); + const hasActive = useHasActiveFilters(); + + if (!hasActive) return null; + + return ( +
+ {[...selectedTags].map((tag) => ( + toggleTag(tag)} + /> + ))} + {[...selectedChampions].map((champ) => ( + toggleChampion(champ)} + /> + ))} + {[...selectedMaps].map((map) => ( + toggleMap(map)} + /> + ))} + +
+ ); +} + +const COLOR_CLASSES = { + brand: "bg-brand-500/15 text-brand-300 border-brand-500/30", + emerald: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30", + sky: "bg-sky-500/15 text-sky-300 border-sky-500/30", +} as const; + +function Chip({ + label, + color, + onRemove, +}: { + label: string; + color: keyof typeof COLOR_CLASSES; + onRemove: () => void; +}) { + return ( + + {label} + + + ); +} diff --git a/src/modules/library/components/FilterBar.tsx b/src/modules/library/components/FilterBar.tsx new file mode 100644 index 0000000..09a40e9 --- /dev/null +++ b/src/modules/library/components/FilterBar.tsx @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { LuX } from "react-icons/lu"; + +import { MultiSelect, type MultiSelectOption } from "@/components"; +import type { FilterOptions } from "@/modules/library/api"; +import { + getMapLabel, + getTagLabel, + WELL_KNOWN_MAPS, + WELL_KNOWN_TAGS, +} from "@/modules/library/utils/labels"; +import { useHasActiveFilters, useLibraryFilterStore } from "@/stores"; + +function mergeOptions( + wellKnown: string[], + fromMods: string[], + getLabel: (value: string) => string, +): MultiSelectOption[] { + const seen = new Set(); + const options: MultiSelectOption[] = []; + for (const value of wellKnown) { + seen.add(value); + options.push({ value, label: getLabel(value) }); + } + for (const value of fromMods) { + if (!seen.has(value)) { + options.push({ value, label: getLabel(value) }); + } + } + return options; +} + +interface FilterBarProps { + filterOptions: FilterOptions; +} + +export function FilterBar({ filterOptions }: FilterBarProps) { + const { + selectedTags, + selectedChampions, + selectedMaps, + setTags, + setChampions, + setMaps, + clearFilters, + } = useLibraryFilterStore(); + const hasActive = useHasActiveFilters(); + + const tagOptions = useMemo( + () => mergeOptions(WELL_KNOWN_TAGS, filterOptions.tags, getTagLabel), + [filterOptions.tags], + ); + const championOptions = useMemo( + () => filterOptions.champions.map((c) => ({ value: c, label: c })), + [filterOptions.champions], + ); + const mapOptions = useMemo( + () => mergeOptions(WELL_KNOWN_MAPS, filterOptions.maps, getMapLabel), + [filterOptions.maps], + ); + + return ( +
+ + + + {hasActive && ( + + )} +
+ ); +} diff --git a/src/modules/library/components/FilterPopover.tsx b/src/modules/library/components/FilterPopover.tsx new file mode 100644 index 0000000..cd4861f --- /dev/null +++ b/src/modules/library/components/FilterPopover.tsx @@ -0,0 +1,123 @@ +import { LuFilter, LuX } from "react-icons/lu"; + +import { Checkbox, IconButton, Popover } from "@/components"; +import type { FilterOptions } from "@/modules/library/api"; +import { getMapLabel, getTagLabel } from "@/modules/library/utils/labels"; +import { useHasActiveFilters, useLibraryFilterStore } from "@/stores"; + +interface FilterPopoverProps { + filterOptions: FilterOptions; +} + +export function FilterPopover({ filterOptions }: FilterPopoverProps) { + const { + selectedTags, + selectedChampions, + selectedMaps, + toggleTag, + toggleChampion, + toggleMap, + clearFilters, + } = useLibraryFilterStore(); + const hasActive = useHasActiveFilters(); + + const hasOptions = + filterOptions.tags.length > 0 || + filterOptions.champions.length > 0 || + filterOptions.maps.length > 0; + + if (!hasOptions) return null; + + return ( + + + + {hasActive && ( + + )} + + } + variant="ghost" + size="sm" + title="Filter mods" + /> + } + /> + + + +
+ Filters + {hasActive && ( + + )} +
+ +
+ {filterOptions.tags.length > 0 && ( + + {filterOptions.tags.map((tag) => ( + toggleTag(tag)} + /> + ))} + + )} + + {filterOptions.champions.length > 0 && ( + + {filterOptions.champions.map((champ) => ( + toggleChampion(champ)} + /> + ))} + + )} + + {filterOptions.maps.length > 0 && ( + + {filterOptions.maps.map((map) => ( + toggleMap(map)} + /> + ))} + + )} +
+
+
+
+
+ ); +} + +function FilterSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/src/modules/library/components/LibraryContent.tsx b/src/modules/library/components/LibraryContent.tsx index 1db6de9..3c7ebd9 100644 --- a/src/modules/library/components/LibraryContent.tsx +++ b/src/modules/library/components/LibraryContent.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; -import { LuPlus, LuSearch, LuUpload } from "react-icons/lu"; +import { LuFilter, LuPlus, LuSearch, LuUpload } from "react-icons/lu"; import { Button } from "@/components"; import type { AppError, InstalledMod } from "@/lib/tauri"; import type { useLibraryActions } from "@/modules/library/api"; -import { useLibraryViewMode } from "@/modules/library/api"; +import { useFilteredMods, useLibraryViewMode } from "@/modules/library/api"; +import { useHasActiveFilters, useLibraryFilterStore } from "@/stores"; import { ModCard } from "./ModCard"; import { ModDetailsDialog } from "./ModDetailsDialog"; @@ -37,13 +38,13 @@ export function LibraryContent({ }: LibraryContentProps) { const { viewMode } = useLibraryViewMode(); const [detailsMod, setDetailsMod] = useState(null); - const isSearching = searchQuery.length > 0; + const filteredMods = useFilteredMods(mods, searchQuery); + const hasActiveFilters = useHasActiveFilters(); + const { sort } = useLibraryFilterStore(); - const filteredMods = mods.filter( - (mod) => - mod.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - mod.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); + const isSearching = searchQuery.length > 0; + const isPrioritySort = sort.field === "priority"; + const dndDisabled = isSearching || isPatcherActive || !isPrioritySort || hasActiveFilters; if (isLoading) { return ( @@ -64,12 +65,12 @@ export function LibraryContent({ if (filteredMods.length === 0) { return (
- +
); } - const Card = isSearching ? ModCard : SortableModCard; + const Card = dndDisabled ? ModCard : SortableModCard; return ( <> @@ -78,12 +79,12 @@ export function LibraryContent({ mods={filteredMods} viewMode={viewMode} onReorder={actions.handleReorder} - disabled={isSearching || isPatcherActive} + disabled={dndDisabled} onToggle={actions.handleToggleMod} onUninstall={actions.handleUninstallMod} onViewDetails={setDetailsMod} > -
+
{filteredMods.map((mod) => ( void; hasSearch: boolean }) { - if (hasSearch) { +function EmptyState({ + onInstall, + hasSearch, + hasFilters, +}: { + onInstall: () => void; + hasSearch: boolean; + hasFilters: boolean; +}) { + if (hasSearch || hasFilters) { return (
- + {hasFilters ? ( + + ) : ( + + )}

No mods found

-

Try adjusting your search query

+

+ {hasFilters ? "Try adjusting your filters" : "Try adjusting your search query"} +

); } diff --git a/src/modules/library/components/LibraryToolbar.tsx b/src/modules/library/components/LibraryToolbar.tsx index 350ff33..9db1cc1 100644 --- a/src/modules/library/components/LibraryToolbar.tsx +++ b/src/modules/library/components/LibraryToolbar.tsx @@ -6,6 +6,7 @@ import type { useLibraryActions } from "@/modules/library/api"; import { useLibraryViewMode } from "@/modules/library/api"; import { ProfileSelector } from "./ProfileSelector"; +import { SortDropdown } from "./SortDropdown"; interface PatcherProps { status: PatcherStatus | undefined; @@ -63,6 +64,8 @@ export function LibraryToolbar({ />
+ + {/* View toggle */}

{mod.displayName}

-

- v{mod.version} • {mod.authors.join(", ") || "Unknown author"} -

+
+

+ v{mod.version} • {mod.authors.join(", ") || "Unknown author"} +

+ +
{/* Toggle */} @@ -193,6 +185,8 @@ export function ModCard({ {mod.displayName} + + {/* Version, author, and menu on same row */}
v{mod.version} @@ -250,3 +244,33 @@ export function ModCard({
); } + +function ModPills({ mod, max, className }: { mod: InstalledMod; max: number; className?: string }) { + const pills = [ + ...mod.tags.map((t) => ({ label: getTagLabel(t), color: "brand" as const })), + ...mod.champions.map((c) => ({ label: c, color: "emerald" as const })), + ]; + if (pills.length === 0) return null; + + const visible = pills.slice(0, max); + const overflow = pills.length - max; + + const colorClasses = { + brand: "bg-brand-500/15 text-brand-400", + emerald: "bg-emerald-500/15 text-emerald-400", + } as const; + + return ( +
+ {visible.map((pill) => ( + + {pill.label} + + ))} + {overflow > 0 && +{overflow}} +
+ ); +} diff --git a/src/modules/library/components/ModDetailsDialog.tsx b/src/modules/library/components/ModDetailsDialog.tsx index 87816df..f97d722 100644 --- a/src/modules/library/components/ModDetailsDialog.tsx +++ b/src/modules/library/components/ModDetailsDialog.tsx @@ -1,9 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; -import { LuCalendar, LuFolderOpen, LuLayers, LuUser } from "react-icons/lu"; +import { LuCalendar, LuFolderOpen, LuLayers, LuMap, LuSword, LuTag, LuUser } from "react-icons/lu"; import { Button, Dialog } from "@/components"; import type { InstalledMod } from "@/lib/tauri"; import { useModThumbnail } from "@/modules/library/api/useModThumbnail"; +import { getMapLabel, getTagLabel } from "@/modules/library/utils/labels"; interface ModDetailsDialogProps { open: boolean; @@ -97,6 +98,66 @@ function ModDetailsContent({ mod }: { mod: InstalledMod }) {
)} + {/* Tags */} + {mod.tags.length > 0 && ( +
+

+ + Tags +

+
+ {mod.tags.map((tag) => ( + + {getTagLabel(tag)} + + ))} +
+
+ )} + + {/* Champions */} + {mod.champions.length > 0 && ( +
+

+ + Champions +

+
+ {mod.champions.map((champ) => ( + + {champ} + + ))} +
+
+ )} + + {/* Maps */} + {mod.maps.length > 0 && ( +
+

+ + Maps +

+
+ {mod.maps.map((map) => ( + + {getMapLabel(map)} + + ))} +
+
+ )} + {/* Layers */} {mod.layers.length > 0 && (
diff --git a/src/modules/library/components/SortDropdown.tsx b/src/modules/library/components/SortDropdown.tsx new file mode 100644 index 0000000..f172354 --- /dev/null +++ b/src/modules/library/components/SortDropdown.tsx @@ -0,0 +1,48 @@ +import { Select } from "@/components"; +import { type SortConfig, useLibraryFilterStore } from "@/stores"; + +const SORT_OPTIONS = [ + { value: "priority:desc", label: "Priority" }, + { value: "name:asc", label: "Name (A-Z)" }, + { value: "name:desc", label: "Name (Z-A)" }, + { value: "installedAt:desc", label: "Newest First" }, + { value: "installedAt:asc", label: "Oldest First" }, + { value: "enabled:asc", label: "Enabled First" }, +]; + +const LABEL_MAP = Object.fromEntries(SORT_OPTIONS.map((o) => [o.value, o.label])); + +function toValue(sort: SortConfig): string { + return `${sort.field}:${sort.direction}`; +} + +function fromValue(value: string): SortConfig { + const [field, direction] = value.split(":") as [SortConfig["field"], SortConfig["direction"]]; + return { field, direction }; +} + +export function SortDropdown() { + const { sort, setSort } = useLibraryFilterStore(); + + return ( + v && setSort(fromValue(v))}> + + + {(value: string) => LABEL_MAP[value] ?? "Sort"} + + + + + + + {SORT_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + ); +} diff --git a/src/modules/library/components/index.ts b/src/modules/library/components/index.ts index d9fd167..fd861b2 100644 --- a/src/modules/library/components/index.ts +++ b/src/modules/library/components/index.ts @@ -1,4 +1,7 @@ +export * from "./ActiveFilterChips"; export * from "./DragDropOverlay"; +export * from "./FilterBar"; +export * from "./FilterPopover"; export * from "./ImportProgressDialog"; export * from "./LibraryContent"; export * from "./LibraryToolbar"; @@ -7,3 +10,4 @@ export * from "./ModDetailsDialog"; export * from "./ProfileSelector"; export * from "./SortableModCard"; export * from "./SortableModList"; +export * from "./SortDropdown"; diff --git a/src/modules/library/index.ts b/src/modules/library/index.ts index 088895d..4ba820c 100644 --- a/src/modules/library/index.ts +++ b/src/modules/library/index.ts @@ -1,2 +1,3 @@ export * from "./api"; export * from "./components"; +export * from "./utils"; diff --git a/src/modules/library/utils/index.ts b/src/modules/library/utils/index.ts new file mode 100644 index 0000000..95f3ce2 --- /dev/null +++ b/src/modules/library/utils/index.ts @@ -0,0 +1 @@ +export * from "./labels"; diff --git a/src/modules/library/utils/labels.ts b/src/modules/library/utils/labels.ts new file mode 100644 index 0000000..54855b7 --- /dev/null +++ b/src/modules/library/utils/labels.ts @@ -0,0 +1,58 @@ +export const WELL_KNOWN_TAGS = [ + "league-of-legends", + "tft", + "champion-skin", + "map-skin", + "ward-skin", + "ui", + "hud", + "font", + "sfx", + "announcer", + "structure", + "minion", + "jungle-monster", + "misc", +]; + +export const WELL_KNOWN_MAPS = ["summoners-rift", "aram", "teamfight-tactics", "arena", "swarm"]; + +const TAG_LABELS: Record = { + "league-of-legends": "League of Legends", + tft: "TFT", + "champion-skin": "Champion Skin", + "map-skin": "Map Skin", + "ward-skin": "Ward Skin", + ui: "UI", + hud: "HUD", + font: "Font", + sfx: "SFX", + announcer: "Announcer", + structure: "Structure", + minion: "Minion", + "jungle-monster": "Jungle Monster", + misc: "Misc", +}; + +const MAP_LABELS: Record = { + "summoners-rift": "Summoner's Rift", + aram: "ARAM", + "teamfight-tactics": "Teamfight Tactics", + arena: "Arena", + swarm: "Swarm", +}; + +function kebabToTitleCase(s: string): string { + return s + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); +} + +export function getTagLabel(tag: string): string { + return TAG_LABELS[tag] ?? kebabToTitleCase(tag); +} + +export function getMapLabel(map: string): string { + return MAP_LABELS[map] ?? kebabToTitleCase(map); +} diff --git a/src/modules/workshop/components/ProjectCard.tsx b/src/modules/workshop/components/ProjectCard.tsx index 9e7f978..1d331b5 100644 --- a/src/modules/workshop/components/ProjectCard.tsx +++ b/src/modules/workshop/components/ProjectCard.tsx @@ -26,7 +26,6 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro } function handleCardClick(e: React.MouseEvent) { - // Don't trigger if clicking on menu if ((e.target as HTMLElement).closest("[data-no-click]")) { return; } @@ -59,7 +58,7 @@ export function ProjectCard({ project, viewMode, onEdit, onPack, onDelete }: Pro
{/* Actions */} -
+
e.stopPropagation()}>