From 112253e26b13b7e3a32005badb8030589e7ccaeb Mon Sep 17 00:00:00 2001 From: Krzysztof Gutkowski Date: Thu, 18 Jun 2026 17:09:00 +0200 Subject: [PATCH] Generalize UserTagPicker RFC. I'm not sure if this is overkill and it'd be easier/cleaner to just copypaste the tag picker layout to a new component, so I'm PR'ing this in for discussion. Intended to be used in a future PR allowing trusted users (and possibly other users in the future) to edit beatmap tags through web. --- resources/css/bem-index.less | 2 +- ...cker.less => beatmap-user-tag-picker.less} | 2 +- resources/js/beatmaps/search-panel.tsx | 9 ++- resources/js/beatmaps/user-tag-picker.tsx | 77 ------------------- resources/js/beatmapset-search-filters.ts | 11 ++- .../beatmap-user-tag-picker-controller.ts} | 2 +- .../js/components/beatmap-user-tag-picker.tsx | 77 +++++++++++++++++++ resources/js/osu-core.ts | 4 +- 8 files changed, 96 insertions(+), 88 deletions(-) rename resources/css/bem/{user-tag-picker.less => beatmap-user-tag-picker.less} (98%) delete mode 100644 resources/js/beatmaps/user-tag-picker.tsx rename resources/js/{beatmaps/user-tag-picker-controller.ts => components/beatmap-user-tag-picker-controller.ts} (97%) create mode 100644 resources/js/components/beatmap-user-tag-picker.tsx diff --git a/resources/css/bem-index.less b/resources/css/bem-index.less index cce8b04743a..41cc612ca2e 100644 --- a/resources/css/bem-index.less +++ b/resources/css/bem-index.less @@ -46,6 +46,7 @@ @import "bem/beatmap-discussions"; @import "bem/beatmap-discussions-header-bottom"; @import "bem/beatmap-discussions-header-top"; +@import "bem/beatmap-user-tag-picker"; @import "bem/beatmap-icon"; @import "bem/beatmap-list"; @import "bem/beatmap-list-item"; @@ -441,7 +442,6 @@ @import "bem/user-search-card"; @import "bem/user-session-list"; @import "bem/user-session-list-session"; -@import "bem/user-tag-picker"; @import "bem/user-verification"; @import "bem/user-verification-popup"; @import "bem/username-change"; diff --git a/resources/css/bem/user-tag-picker.less b/resources/css/bem/beatmap-user-tag-picker.less similarity index 98% rename from resources/css/bem/user-tag-picker.less rename to resources/css/bem/beatmap-user-tag-picker.less index 6afee1930ce..560ceee3b8c 100644 --- a/resources/css/bem/user-tag-picker.less +++ b/resources/css/bem/beatmap-user-tag-picker.less @@ -1,7 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. // See the LICENCE file in the repository root for full licence text. -.user-tag-picker { +.beatmap-user-tag-picker { @padding: 20px; @search-container-offset: 46px; // 45px search icon width + 1px search container border diff --git a/resources/js/beatmaps/search-panel.tsx b/resources/js/beatmaps/search-panel.tsx index b7b58c6ffbe..6a2ac061434 100644 --- a/resources/js/beatmaps/search-panel.tsx +++ b/resources/js/beatmaps/search-panel.tsx @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. import { FilterKey } from 'beatmapset-search-filters'; +import BeatmapUserTagPicker from 'components/beatmap-user-tag-picker'; import BeatmapsetCover from 'components/beatmapset-cover'; import PopupMenu from 'components/popup-menu'; import PopupMenuState from 'components/popup-menu-state'; @@ -16,7 +17,6 @@ import { htmlElementOrNull } from 'utils/html'; import { trans } from 'utils/lang'; import AvailableFilters, { FilterOption } from './available-filters'; import { SearchFilter } from './search-filter'; -import UserTagPicker from './user-tag-picker'; interface Props { availableFilters: AvailableFilters; @@ -217,7 +217,12 @@ export class SearchPanel extends React.Component { - {() => } + {() => ()}
diff --git a/resources/js/beatmaps/user-tag-picker.tsx b/resources/js/beatmaps/user-tag-picker.tsx deleted file mode 100644 index 7a0102b1ef4..00000000000 --- a/resources/js/beatmaps/user-tag-picker.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. -// See the LICENCE file in the repository root for full licence text. - -import { rulesetNames, rulesets } from 'interfaces/ruleset'; -import { action } from 'mobx'; -import { observer } from 'mobx-react'; -import BeatmapTag from 'models/beatmap-tag'; -import core from 'osu-core-singleton'; -import React, { useCallback, useEffect, useRef } from 'react'; -import { classWithModifiers } from 'utils/css'; -import { trans } from 'utils/lang'; -import { TagGroup } from './user-tag-picker-controller'; - -const controller = core.beatmapTagPickerController; -const beatmapsetSearchController = core.beatmapsetSearchController; - -const onChange = action((e: React.ChangeEvent) => controller.query = e.target.value); - -export default observer(function UserTagPicker() { - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - return ( -
- -
-
- {controller.groups.map((group) => )} -
-
-
- ); -}); - -const UserTagGroup = observer(function UserTagGroup({ group }: { group: TagGroup }) { - return ( - <> - {group.name} - {group.tags.map((tag) => )} - - ); -}); - -const UserTag = observer(function UserTag({ tag }: { tag: BeatmapTag }) { - const active = beatmapsetSearchController.filters.query?.toLowerCase() - .includes(tag.toQuery().toLowerCase()); - - const onClick = useCallback(() => { - if (!active) { - beatmapsetSearchController.filters.tagAdd(tag); - } else { - beatmapsetSearchController.filters.tagRemove(tag); - } - }, [tag, active]); - - const hasAllRulesets = tag.rulesetIds.length === rulesets.length; - - return (
- {tag.tagName} - - {beatmapsetSearchController.filters.mode === null && !hasAllRulesets && tag.rulesetIds.map((rulesetId) => ( - {' '} - ))} - {tag.description} - -
); -}); diff --git a/resources/js/beatmapset-search-filters.ts b/resources/js/beatmapset-search-filters.ts index 09f7ce0c602..4fee8bdbedf 100644 --- a/resources/js/beatmapset-search-filters.ts +++ b/resources/js/beatmapset-search-filters.ts @@ -132,7 +132,7 @@ export class BeatmapsetSearchFilters { } @action - tagAdd(tag: BeatmapTag) { + tagAdd = (tag: BeatmapTag) => { const currentQuery = this.query; const tagString = tag.toQuery(); @@ -144,10 +144,13 @@ export class BeatmapsetSearchFilters { : `${tagString} `; this.update('query', newQuery); - } + }; + + tagEnabled = (tag: BeatmapTag) => + (this.query ?? '').toLowerCase().includes(tag.toQuery().toLowerCase()); @action - tagRemove(tag: BeatmapTag) { + tagRemove = (tag: BeatmapTag) => { const currentQuery = this.query; if (currentQuery === null) { @@ -159,7 +162,7 @@ export class BeatmapsetSearchFilters { .replace(' ', ' ') .trim(); this.update('query', newQuery); - } + }; toKeyString() { return keyNames.map((key) => `${key}=${this.selectedValue(key)}`).join('&'); diff --git a/resources/js/beatmaps/user-tag-picker-controller.ts b/resources/js/components/beatmap-user-tag-picker-controller.ts similarity index 97% rename from resources/js/beatmaps/user-tag-picker-controller.ts rename to resources/js/components/beatmap-user-tag-picker-controller.ts index 9a2a6d7eeb3..43f201bc144 100644 --- a/resources/js/beatmaps/user-tag-picker-controller.ts +++ b/resources/js/components/beatmap-user-tag-picker-controller.ts @@ -14,7 +14,7 @@ export interface TagGroup { tags: BeatmapTag[]; } -export default class UserTagPickerController { +export default class BeatmapUserTagPickerController { @observable query: string = ''; @observable tags: BeatmapTag[] = []; diff --git a/resources/js/components/beatmap-user-tag-picker.tsx b/resources/js/components/beatmap-user-tag-picker.tsx new file mode 100644 index 00000000000..3fed2f32aab --- /dev/null +++ b/resources/js/components/beatmap-user-tag-picker.tsx @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the GNU Affero General Public License v3.0. +// See the LICENCE file in the repository root for full licence text. + +import { rulesetNames, rulesets } from 'interfaces/ruleset'; +import { action } from 'mobx'; +import { observer } from 'mobx-react'; +import BeatmapTag from 'models/beatmap-tag'; +import core from 'osu-core-singleton'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { classWithModifiers } from 'utils/css'; +import { trans } from 'utils/lang'; + +const controller = core.beatmapTagPickerController; + +const onChange = action((e: React.ChangeEvent) => controller.query = e.target.value); + +interface Props { + isTagEnabled: (tag: BeatmapTag) => boolean; + onDisabled: (tag: BeatmapTag) => void; + onEnabled: (tag: BeatmapTag) => void; + showAllRulesets: boolean; +} + +export default observer(function BeatmapUserTagPicker(props: Props) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
+ +
+
+ {controller.groups.map((group) => ( + + {group.name} + {group.tags.map((tag) => )} + + ))} +
+
+
+ ); +}); + +const UserTag = observer(function UserTag({ tag, props }: { props: Props; tag: BeatmapTag }) { + const active = props.isTagEnabled(tag); + + const onClick = useCallback(() => { + if (!active) { + props.onEnabled(tag); + } else { + props.onDisabled(tag); + } + }, [tag, active, props]); + + const hasAllRulesets = tag.rulesetIds.length === rulesets.length; + + return (
+ {tag.tagName} + + {props.showAllRulesets && !hasAllRulesets && tag.rulesetIds.map((rulesetId) => ( + {' '} + ))} + {tag.description} + +
); +}); diff --git a/resources/js/osu-core.ts b/resources/js/osu-core.ts index 8696c7003cf..b58ba0ad040 100644 --- a/resources/js/osu-core.ts +++ b/resources/js/osu-core.ts @@ -3,6 +3,7 @@ import { BeatmapsetSearchController } from 'beatmaps/beatmapset-search-controller'; import ChatWorker from 'chat/chat-worker'; +import BeatmapUserTagPickerController from 'components/beatmap-user-tag-picker-controller'; import AccountEdit from 'core/account-edit'; import AccountEditAvatar from 'core/account-edit-avatar'; import AccountEditBlocklist from 'core/account-edit-blocklist'; @@ -46,7 +47,6 @@ import NotificationsWorker from 'notifications/worker'; import SocketWorker from 'socket-worker'; import RootDataStore from 'stores/root-data-store'; import { parseJsonNullable } from 'utils/json'; -import UserTagPickerController from './beatmaps/user-tag-picker-controller'; // will this replace main.coffee eventually? export default class OsuCore { @@ -156,7 +156,7 @@ export default class OsuCore { this.windowFocusObserver = new WindowFocusObserver(); this.beatmapsetSearchController = new BeatmapsetSearchController(this.dataStore.beatmapsetSearch); - this.beatmapTagPickerController = new UserTagPickerController(); + this.beatmapTagPickerController = new BeatmapUserTagPickerController(); this.socketWorker = new SocketWorker(); this.notificationsWorker = new NotificationsWorker(this.socketWorker);