diff --git a/public/images/aoe4-maps/ascension.png b/public/images/aoe4-maps/ascension.png new file mode 100644 index 0000000..5057d6a Binary files /dev/null and b/public/images/aoe4-maps/ascension.png differ diff --git a/public/images/aoe4-maps/fangs.png b/public/images/aoe4-maps/fangs.png new file mode 100644 index 0000000..dfcddc8 Binary files /dev/null and b/public/images/aoe4-maps/fangs.png differ diff --git a/public/images/aoe4-maps/snake-river.png b/public/images/aoe4-maps/snake-river.png new file mode 100644 index 0000000..0d5fc89 Binary files /dev/null and b/public/images/aoe4-maps/snake-river.png differ diff --git a/public/images/aoe4-maps/west-lake.png b/public/images/aoe4-maps/west-lake.png new file mode 100644 index 0000000..a61384f Binary files /dev/null and b/public/images/aoe4-maps/west-lake.png differ diff --git a/src/components/PresetEditor/PresetEditorCivSelection.tsx b/src/components/PresetEditor/PresetEditorCivSelection.tsx index b91aa89..3a68af6 100644 --- a/src/components/PresetEditor/PresetEditorCivSelection.tsx +++ b/src/components/PresetEditor/PresetEditorCivSelection.tsx @@ -15,7 +15,49 @@ interface Props extends WithTranslation { onPresetDraftOptionsChange: (value: DraftOption[]) => ISetEditorDraftOptions, } -class PresetEditorCivSelection extends React.Component { +type SortOrder = 'default' | 'az' | 'za'; + +interface State { + searchQuery: string; + sortOrder: SortOrder; + deselectedTags: string[]; + tagDropdownOpen: boolean; +} + +class PresetEditorCivSelection extends React.Component { + + private static readonly SORT_STORAGE_KEY = 'presetEditor.sortOrder'; + private dropdownRef = React.createRef(); + + constructor(props: Props) { + super(props); + const stored = localStorage.getItem(PresetEditorCivSelection.SORT_STORAGE_KEY) as SortOrder | null; + this.state = {searchQuery: '', sortOrder: stored ?? 'default', deselectedTags: [], tagDropdownOpen: false}; + this.handleOutsideClick = this.handleOutsideClick.bind(this); + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleOutsideClick); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleOutsideClick); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + if (prevProps.availableOptions !== this.props.availableOptions) { + this.setState({searchQuery: '', deselectedTags: [], tagDropdownOpen: false}); + } + if (prevState.sortOrder !== this.state.sortOrder) { + localStorage.setItem(PresetEditorCivSelection.SORT_STORAGE_KEY, this.state.sortOrder); + } + } + + private handleOutsideClick(e: MouseEvent) { + if (this.state.tagDropdownOpen && this.dropdownRef.current && !this.dropdownRef.current.contains(e.target as Node)) { + this.setState({tagDropdownOpen: false}); + } + } public render() { if (this.props.preset === null || this.props.preset === undefined) { @@ -24,15 +66,101 @@ class PresetEditorCivSelection extends React.Component { const presetOptions = this.props.preset.options; - const civs = this.props.availableOptions.map((value: DraftOption, index: number) => + const allTags = [...new Set(this.props.availableOptions.reduce((acc, opt) => acc.concat(opt.tags), []))].sort(); + + const query = this.state.searchQuery.toLowerCase(); + let filtered = [...this.props.availableOptions]; + + if (query) { + filtered = filtered.filter(opt => opt.name.toLowerCase().includes(query)); + } + if (this.state.deselectedTags.length > 0) { + filtered = filtered.filter(opt => + opt.tags.length === 0 || opt.tags.some(tag => !this.state.deselectedTags.includes(tag)) + ); + } + + if (this.state.sortOrder === 'az') { + filtered.sort((a, b) => a.name.localeCompare(b.name)); + } else if (this.state.sortOrder === 'za') { + filtered.sort((a, b) => b.name.localeCompare(a.name)); + } + + const civs = filtered.map((value: DraftOption, index: number) => ); return ( -
- {civs} +
+
+
+ this.setState({searchQuery: e.target.value})} + /> + {this.state.searchQuery && ( + this.setState({searchQuery: ''})}> + × + + )} +
+
+ +
+ {allTags.length > 0 && ( +
+
+ +
+
+
+ {allTags.map(tag => { + const checked = !this.state.deselectedTags.includes(tag); + return ( + + ); + })} +
+
+
+ )} +
+
+ {civs} +
); } diff --git a/src/models/Aoe4Civilisation.ts b/src/models/Aoe4Civilisation.ts index 9512c75..ace19f3 100644 --- a/src/models/Aoe4Civilisation.ts +++ b/src/models/Aoe4Civilisation.ts @@ -23,11 +23,40 @@ enum Name { MACEDONIAN_DYNASTY = "aoe4.MacedonianDynasty", SENGOKU_DAIMYO = "aoe4.SengokuDaimyo", TUGHLAQ_DYNASTY = "aoe4.TughlaqDynasty", - JIN_DYNASTY = "aoe4.JinDynasty", + JIN_DYNASTY = "aoe4.JinDynasty", } class Aoe4Civilisation extends DraftOption { + // Add tags here as you go — any civ not listed defaults to no tags. + // MUST stay before the static civ instances (initialization order). + public static readonly TAGS: Partial> = { + // [Name.ENGLISH]: ['Beginner Friendly', 'Defensive'], + [Name.RUS]: ['Release'], + [Name.HOLY_ROMAN_EMPIRE]: ['Release'], + [Name.CHINESE]: ['Release'], + [Name.ENGLISH]: ['Release'], + [Name.DELHI_SULTANATE]: ['Release'], + [Name.MONGOLS]: ['Release'], + [Name.ABBASID_DYNASTY]: ['Release'], + [Name.FRENCH]: ['Release'], + [Name.OTTOMANS]: ['Anniversary Edition'], + [Name.MALIANS]: ['Anniversary Edition'], + [Name.BYZANTINES]: ['The Sultans Ascend'], + [Name.JAPANESE]: ['The Sultans Ascend'], + [Name.AYYUBIDS]: ['The Sultans Ascend'], + [Name.ZHUXILEGACY]: ['The Sultans Ascend'], + [Name.JEANNEDARC]: ['The Sultans Ascend'], + [Name.ORDEROFTHEDRAGON]: ['The Sultans Ascend'], + [Name.HOUSE_OF_LANCASTER]: ['Knights of Cross and Rose'], + [Name.KNIGHTS_TEMPLAR]: ['Knights of Cross and Rose'], + [Name.GOLDEN_HORDE]: ['Dynasties of the East'], + [Name.MACEDONIAN_DYNASTY]: ['Dynasties of the East'], + [Name.SENGOKU_DAIMYO]: ['Dynasties of the East'], + [Name.TUGHLAQ_DYNASTY]: ['Dynasties of the East'], + [Name.JIN_DYNASTY]: ['Yue Fei\'s Legacy'], + }; + public static readonly RUS: Aoe4Civilisation = new Aoe4Civilisation(Name.RUS); public static readonly HOLY_ROMAN_EMPIRE: Aoe4Civilisation = new Aoe4Civilisation(Name.HOLY_ROMAN_EMPIRE); public static readonly CHINESE: Aoe4Civilisation = new Aoe4Civilisation(Name.CHINESE); @@ -84,7 +113,7 @@ class Aoe4Civilisation extends DraftOption { public static readonly ALL_ACTIVE = Aoe4Civilisation.ALL private constructor(name: Name) { - super(name, name, Aoe4Civilisation.defaultImageUrlsForCivilisation(name)); + super(name, name, Aoe4Civilisation.defaultImageUrlsForCivilisation(name), 'civs.', 'default', Aoe4Civilisation?.TAGS[name] ?? []); } public static defaultImageUrlsForCivilisation(name: string) { @@ -99,4 +128,4 @@ class Aoe4Civilisation extends DraftOption { } export default Aoe4Civilisation; -export {Name}; +export { Name }; diff --git a/src/models/Aoe4Map.ts b/src/models/Aoe4Map.ts index 0be1dea..df33675 100644 --- a/src/models/Aoe4Map.ts +++ b/src/models/Aoe4Map.ts @@ -9,6 +9,7 @@ class Aoe4Map extends DraftOption { new Aoe4Map("Altai"), new Aoe4Map("Ancient Spires"), new Aoe4Map("Archipelago"), + new Aoe4Map("Ascension"), new Aoe4Map("Atacama"), new Aoe4Map("Atoll"), new Aoe4Map("Baldland"), @@ -42,6 +43,7 @@ class Aoe4Map extends DraftOption { new Aoe4Map("Enlightened Horizon"), new Aoe4Map("Eroded"), new Aoe4Map("Excavation"), + new Aoe4Map("Fangs"), new Aoe4Map("Firefly Fjords"), new Aoe4Map("Flankwoods"), new Aoe4Map("Floodplain"), @@ -116,6 +118,7 @@ class Aoe4Map extends DraftOption { new Aoe4Map("Silk Road"), new Aoe4Map("Sixfold Crossing"), new Aoe4Map("Skargard"), + new Aoe4Map("Snake River"), new Aoe4Map("Socotra"), new Aoe4Map("Sol and Luna"), new Aoe4Map("Sunkenlands"), @@ -131,6 +134,7 @@ class Aoe4Map extends DraftOption { new Aoe4Map("Water Drake"), new Aoe4Map("Waterholes"), new Aoe4Map("Waterlanes"), + new Aoe4Map("West Lake"), new Aoe4Map("Wetlands"), new Aoe4Map("Wilderness"), new Aoe4Map("Wolf Hill"), diff --git a/src/models/DraftOption.ts b/src/models/DraftOption.ts index af821f5..22bd90d 100644 --- a/src/models/DraftOption.ts +++ b/src/models/DraftOption.ts @@ -37,14 +37,16 @@ class DraftOption { public readonly imageUrls: ImageUrls; public readonly i18nPrefix: string; public readonly category: string; + public readonly tags: string[]; - constructor(id: string, name: string = id, imageUrls: ImageUrls = DraftOption.defaultImageUrlsForCivilisation(id), i18nPrefix = 'civs.', category = 'default') { + constructor(id: string, name: string = id, imageUrls: ImageUrls = DraftOption.defaultImageUrlsForCivilisation(id), i18nPrefix = 'civs.', category = 'default', tags: string[] = []) { this.id = id; this.name = name; this.imageUrls = imageUrls; this.i18nPrefix = i18nPrefix; this.category = category; + this.tags = tags; } public static defaultImageUrlsForCivilisation(name: string): ImageUrls { @@ -74,7 +76,7 @@ class DraftOption { Assert.isImageUrlsOrUndefined(draftOption.imageUrls); Assert.isOptionalString(draftOption.i18nPrefix); Assert.isOptionalString(draftOption.category); - retval.push(new DraftOption(draftOption.id, draftOption.name, draftOption.imageUrls, draftOption.i18nPrefix, draftOption.category)); + retval.push(new DraftOption(draftOption.id, draftOption.name, draftOption.imageUrls, draftOption.i18nPrefix, draftOption.category, draftOption.tags ?? [])); } return retval; }