mirror of
https://github.com/flarum/framework.git
synced 2024-11-22 10:33:18 +08:00
feat: search UI/UX revamp (#3941)
* feat: first iteration * chore: tweak * feat: second iteration * chore: incorrect code organization * feat: gambit input suggestions * feat: gambit keyboard navigation * chore: bugs * feat: negative gambits * feat: improve gambit highlighting * refactor: localize gambits * feat: negative and positive gambit buttons * fix: permissions * chore: wat * per: lazy load search modal * fix: extensibility and bug fixes * fix: bugs * feat: reusable autocomplete dropdown * chore: format * fix: tag filter
This commit is contained in:
parent
fb1703cd9b
commit
3a34136e36
|
@ -2,6 +2,7 @@ import { extend } from 'flarum/common/extend';
|
|||
import TextEditorButton from 'flarum/common/components/TextEditorButton';
|
||||
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
|
||||
import Tooltip from 'flarum/common/components/Tooltip';
|
||||
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
|
||||
|
||||
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
|
||||
import getEmojiIconCode from './helpers/getEmojiIconCode';
|
||||
|
@ -40,15 +41,7 @@ export default function addComposerAutocomplete() {
|
|||
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
|
||||
const emojiKeys = Object.keys(emojiMap);
|
||||
|
||||
let relEmojiStart;
|
||||
let absEmojiStart;
|
||||
let typed;
|
||||
|
||||
const applySuggestion = (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(absEmojiStart - 1, replacement + ' ');
|
||||
|
||||
this.emojiDropdown.hide();
|
||||
};
|
||||
const autocompleteReader = new AutocompleteReader(':');
|
||||
|
||||
params.inputListeners.push(() => {
|
||||
const selection = this.attrs.composer.editor.getSelectionRange();
|
||||
|
@ -57,29 +50,20 @@ export default function addComposerAutocomplete() {
|
|||
|
||||
if (selection[1] - cursor > 0) return;
|
||||
|
||||
// Search backwards from the cursor for an ':' symbol. If we find
|
||||
// one and followed by a whitespace, we will want to show the
|
||||
// autocomplete dropdown!
|
||||
const lastChunk = this.attrs.composer.editor.getLastNChars(15);
|
||||
absEmojiStart = 0;
|
||||
for (let i = lastChunk.length - 1; i >= 0; i--) {
|
||||
const character = lastChunk.substr(i, 1);
|
||||
// check what user typed, emoji names only contains alphanumeric,
|
||||
// underline, '+' and '-'
|
||||
if (!/[a-z0-9]|\+|\-|_|\:/.test(character)) break;
|
||||
// make sure ':' preceded by a whitespace or newline
|
||||
if (character === ':' && (i == 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
|
||||
relEmojiStart = i + 1;
|
||||
absEmojiStart = cursor - lastChunk.length + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const autocompleting = autocompleteReader.check(lastChunk, cursor, /[a-z0-9]|\+|\-|_|\:/);
|
||||
|
||||
this.emojiDropdown.hide();
|
||||
this.emojiDropdown.active = false;
|
||||
|
||||
if (absEmojiStart) {
|
||||
typed = lastChunk.substring(relEmojiStart).toLowerCase();
|
||||
if (autocompleting) {
|
||||
const typed = autocompleting.typed;
|
||||
const emojiDropdown = this.emojiDropdown;
|
||||
|
||||
const applySuggestion = (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
|
||||
this.emojiDropdown.hide();
|
||||
};
|
||||
|
||||
const makeSuggestion = function ({ emoji, name, code }) {
|
||||
return (
|
||||
|
@ -88,7 +72,7 @@ export default function addComposerAutocomplete() {
|
|||
key={emoji}
|
||||
onclick={() => applySuggestion(emoji)}
|
||||
onmouseenter={function () {
|
||||
this.emojiDropdown.setIndex($(this).parent().index() - 1);
|
||||
emojiDropdown.setIndex($(this).parent().index() - 1);
|
||||
}}
|
||||
>
|
||||
<img alt={emoji} className="emoji" draggable="false" loading="lazy" src={`${cdn}72x72/${code}.png`} title={name} />
|
||||
|
@ -152,7 +136,7 @@ export default function addComposerAutocomplete() {
|
|||
m.render(this.$('.ComposerBody-emojiDropdownContainer')[0], this.emojiDropdown.render());
|
||||
|
||||
this.emojiDropdown.show();
|
||||
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absEmojiStart);
|
||||
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
|
||||
const width = this.emojiDropdown.$().outerWidth();
|
||||
const height = this.emojiDropdown.$().outerHeight();
|
||||
const parent = this.emojiDropdown.$().offsetParent();
|
||||
|
|
|
@ -1,23 +1,12 @@
|
|||
import IGambit from 'flarum/common/query/IGambit';
|
||||
import { BooleanGambit } from 'flarum/common/query/IGambit';
|
||||
import app from 'flarum/common/app';
|
||||
|
||||
export default class LockedGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'is:locked';
|
||||
}
|
||||
|
||||
toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'locked';
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
export default class LockedGambit extends BooleanGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('flarum-lock.lib.gambits.discussions.locked.key', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'locked';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:locked`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,3 +35,12 @@ flarum-lock:
|
|||
# These translations are used in the Settings page.
|
||||
settings:
|
||||
notify_discussion_locked_label: Someone locks a discussion I started
|
||||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
discussions:
|
||||
locked:
|
||||
key: locked
|
||||
|
|
|
@ -2,6 +2,8 @@ import app from 'flarum/forum/app';
|
|||
import { extend } from 'flarum/common/extend';
|
||||
import TextEditorButton from 'flarum/common/components/TextEditorButton';
|
||||
import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable';
|
||||
import AutocompleteReader from 'flarum/common/utils/AutocompleteReader';
|
||||
import { throttle } from 'flarum/common/utils/throttleDebounce';
|
||||
|
||||
import AutocompleteDropdown from './fragments/AutocompleteDropdown';
|
||||
import MentionableModels from './mentionables/MentionableModels';
|
||||
|
@ -9,6 +11,7 @@ import MentionableModels from './mentionables/MentionableModels';
|
|||
export default function addComposerAutocomplete() {
|
||||
extend('flarum/common/components/TextEditor', 'onbuild', function () {
|
||||
this.mentionsDropdown = new AutocompleteDropdown();
|
||||
this.searchMentions = throttle(250, (mentionables, buildSuggestions) => mentionables.search().then(buildSuggestions));
|
||||
const $editor = this.$('.TextEditor-editor').wrap('<div class="ComposerBody-mentionsWrapper"></div>');
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
|
@ -24,21 +27,8 @@ export default function addComposerAutocomplete() {
|
|||
});
|
||||
|
||||
extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) {
|
||||
let relMentionStart;
|
||||
let absMentionStart;
|
||||
let matchTyped;
|
||||
|
||||
let mentionables = new MentionableModels({
|
||||
onmouseenter: function () {
|
||||
this.mentionsDropdown.setIndex($(this).parent().index());
|
||||
},
|
||||
onclick: (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(absMentionStart - 1, replacement + ' ');
|
||||
|
||||
this.mentionsDropdown.hide();
|
||||
},
|
||||
});
|
||||
|
||||
const suggestionsInputListener = () => {
|
||||
const selection = this.attrs.composer.editor.getSelectionRange();
|
||||
|
||||
|
@ -46,30 +36,27 @@ export default function addComposerAutocomplete() {
|
|||
|
||||
if (selection[1] - cursor > 0) return;
|
||||
|
||||
// Search backwards from the cursor for a mention triggering symbol. If we find one,
|
||||
// we will want to show the correct autocomplete dropdown!
|
||||
// Check classes implementing the IMentionableModel interface to see triggering symbols.
|
||||
const lastChunk = this.attrs.composer.editor.getLastNChars(30);
|
||||
absMentionStart = 0;
|
||||
let activeFormat = null;
|
||||
for (let i = lastChunk.length - 1; i >= 0; i--) {
|
||||
const character = lastChunk.substr(i, 1);
|
||||
activeFormat = app.mentionFormats.get(character);
|
||||
const autocompleteReader = new AutocompleteReader((character) => !!(activeFormat = app.mentionFormats.get(character)));
|
||||
const autocompleting = autocompleteReader.check(this.attrs.composer.editor.getLastNChars(30), cursor, /\S+/);
|
||||
|
||||
if (activeFormat && (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1)))) {
|
||||
relMentionStart = i + 1;
|
||||
absMentionStart = cursor - lastChunk.length + i + 1;
|
||||
mentionables.init(activeFormat.makeMentionables());
|
||||
break;
|
||||
}
|
||||
}
|
||||
const mentionsDropdown = this.mentionsDropdown;
|
||||
let mentionables = new MentionableModels({
|
||||
onmouseenter: function () {
|
||||
mentionsDropdown.setIndex($(this).parent().index());
|
||||
},
|
||||
onclick: (replacement) => {
|
||||
this.attrs.composer.editor.replaceBeforeCursor(autocompleting.absoluteStart - 1, replacement + ' ');
|
||||
this.mentionsDropdown.hide();
|
||||
},
|
||||
});
|
||||
|
||||
this.mentionsDropdown.hide();
|
||||
this.mentionsDropdown.active = false;
|
||||
|
||||
if (absMentionStart) {
|
||||
const typed = lastChunk.substring(relMentionStart).toLowerCase();
|
||||
matchTyped = activeFormat.queryFromTyped(typed);
|
||||
if (autocompleting) {
|
||||
mentionables.init(activeFormat.makeMentionables());
|
||||
matchTyped = activeFormat.queryFromTyped(autocompleting.typed);
|
||||
|
||||
if (!matchTyped) return;
|
||||
|
||||
|
@ -85,7 +72,7 @@ export default function addComposerAutocomplete() {
|
|||
m.render(this.$('.ComposerBody-mentionsDropdownContainer')[0], this.mentionsDropdown.render());
|
||||
|
||||
this.mentionsDropdown.show();
|
||||
const coordinates = this.attrs.composer.editor.getCaretCoordinates(absMentionStart);
|
||||
const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart);
|
||||
const width = this.mentionsDropdown.$().outerWidth();
|
||||
const height = this.mentionsDropdown.$().outerHeight();
|
||||
const parent = this.mentionsDropdown.$().offsetParent();
|
||||
|
@ -118,7 +105,7 @@ export default function addComposerAutocomplete() {
|
|||
this.mentionsDropdown.setIndex(0);
|
||||
this.mentionsDropdown.$().scrollTop(0);
|
||||
|
||||
mentionables.search()?.then(buildSuggestions);
|
||||
this.searchMentions(mentionables, buildSuggestions);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import type MentionableModel from './MentionableModel';
|
|||
import type Model from 'flarum/common/Model';
|
||||
import type Mithril from 'mithril';
|
||||
import MentionsDropdownItem from '../components/MentionsDropdownItem';
|
||||
import { throttle } from 'flarum/common/utils/throttleDebounce';
|
||||
|
||||
export default class MentionableModels {
|
||||
protected mentionables?: MentionableModel[];
|
||||
|
@ -33,7 +32,7 @@ export default class MentionableModels {
|
|||
* Don't send API calls searching for models until at least 2 characters have been typed.
|
||||
* This focuses the mention results on models already loaded.
|
||||
*/
|
||||
public readonly search = throttle(250, async (): Promise<void> => {
|
||||
public readonly search = async (): Promise<void> => {
|
||||
if (!this.typed || this.typed.length <= 1) return;
|
||||
|
||||
const typedLower = this.typed.toLowerCase();
|
||||
|
@ -51,7 +50,7 @@ export default class MentionableModels {
|
|||
this.searched.push(typedLower);
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
public matches(mentionable: MentionableModel, model: Model): boolean {
|
||||
return mentionable.matches(model, this.typed?.toLowerCase() || '');
|
||||
|
|
|
@ -1,23 +1,12 @@
|
|||
import IGambit from 'flarum/common/query/IGambit';
|
||||
import { BooleanGambit } from 'flarum/common/query/IGambit';
|
||||
import app from 'flarum/common/app';
|
||||
|
||||
export default class StickyGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'is:sticky';
|
||||
}
|
||||
|
||||
toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'sticky';
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
export default class StickyGambit extends BooleanGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('flarum-sticky.lib.gambits.discussions.sticky.key', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'sticky';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:sticky`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,15 @@ flarum-sticky:
|
|||
# REUSED TRANSLATIONS - These keys should not be used directly in code!
|
||||
##
|
||||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
discussions:
|
||||
sticky:
|
||||
key: sticky
|
||||
|
||||
# Translations in this namespace are referenced by two or more unique keys.
|
||||
ref:
|
||||
sticky: Sticky
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
import IGambit from 'flarum/common/query/IGambit';
|
||||
import app from 'flarum/common/app';
|
||||
import { BooleanGambit } from 'flarum/common/query/IGambit';
|
||||
|
||||
export default class SubscriptionGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'is:(follow|ignor)(?:ing|ed)';
|
||||
export default class SubscriptionGambit extends BooleanGambit {
|
||||
key(): string[] {
|
||||
return [
|
||||
app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.following_key', {}, true),
|
||||
app.translator.trans('flarum-subscriptions.lib.gambits.discussions.subscription.ignoring_key', {}, true),
|
||||
];
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const type = matches[1] === 'follow' ? 'following' : 'ignoring';
|
||||
const key = (negate ? '-' : '') + this.filterKey();
|
||||
|
||||
return {
|
||||
subscription: type,
|
||||
[key]: matches[1],
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -20,4 +24,8 @@ export default class SubscriptionGambit implements IGambit {
|
|||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:${value}`;
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return !!app.session.user;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import GlobalSearchState from 'flarum/forum/states/GlobalSearchState';
|
|||
export default function addSubscriptionFilter() {
|
||||
extend(IndexSidebar.prototype, 'navItems', function (items) {
|
||||
if (app.session.user) {
|
||||
const params = app.search.stickyParams();
|
||||
const params = app.search.state.stickyParams();
|
||||
|
||||
items.add(
|
||||
'following',
|
||||
|
|
|
@ -75,6 +75,16 @@ flarum-subscriptions:
|
|||
# REUSED TRANSLATIONS - These keys should not be used directly in code!
|
||||
##
|
||||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
discussions:
|
||||
subscription:
|
||||
following_key: following
|
||||
ignoring_key: ignoring
|
||||
|
||||
# Translations in this namespace are referenced by two or more unique keys.
|
||||
ref:
|
||||
follow: Follow
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
use Flarum\Api\Serializer\BasicUserSerializer;
|
||||
use Flarum\Api\Serializer\ForumSerializer;
|
||||
use Flarum\Api\Serializer\UserSerializer;
|
||||
use Flarum\Extend;
|
||||
use Flarum\Search\Database\DatabaseSearchDriver;
|
||||
|
@ -41,6 +42,11 @@ return [
|
|||
(new Extend\ApiSerializer(UserSerializer::class))
|
||||
->attributes(AddUserSuspendAttributes::class),
|
||||
|
||||
(new Extend\ApiSerializer(ForumSerializer::class))
|
||||
->attribute('canSuspendUsers', function (ForumSerializer $serializer) {
|
||||
return $serializer->getActor()->hasPermission('user.suspend');
|
||||
}),
|
||||
|
||||
new Extend\Locales(__DIR__.'/locale'),
|
||||
|
||||
(new Extend\Notification())
|
||||
|
|
7
extensions/suspend/js/src/@types/shims.d.ts
vendored
Normal file
7
extensions/suspend/js/src/@types/shims.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
import 'flarum/common/models/User';
|
||||
|
||||
declare module 'flarum/common/models/User' {
|
||||
export default interface User {
|
||||
canSuspend: () => boolean;
|
||||
}
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
import Extend from 'flarum/common/extenders';
|
||||
import SuspendedGambit from './query/users/SuspendedGambit';
|
||||
import User from 'flarum/common/models/User';
|
||||
|
||||
// prettier-ignore
|
||||
export default [
|
||||
new Extend.Search() //
|
||||
new Extend.Search()
|
||||
.gambit('users', SuspendedGambit),
|
||||
|
||||
new Extend.Model(User)
|
||||
.attribute<boolean>('canSuspend'),
|
||||
];
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import IGambit from 'flarum/common/query/IGambit';
|
||||
import app from 'flarum/common/app';
|
||||
import { BooleanGambit } from 'flarum/common/query/IGambit';
|
||||
|
||||
export default class SuspendedGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'is:suspended';
|
||||
}
|
||||
|
||||
toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'suspended';
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
export default class SuspendedGambit extends BooleanGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('flarum-suspend.lib.gambits.users.suspended.key', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'suspended';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:suspended`;
|
||||
enabled(): boolean {
|
||||
return !!app.session.user && app.forum.attribute<boolean>('canSuspendUsers');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ export default [
|
|||
...commonExtend,
|
||||
|
||||
new Extend.Model(User)
|
||||
.attribute<boolean>('canSuspend')
|
||||
.attribute<Date | null | undefined, string | null | undefined>('suspendedUntil', Model.transformDate)
|
||||
.attribute<string | null | undefined>('suspendReason')
|
||||
.attribute<string | null | undefined>('suspendMessage'),
|
||||
|
|
|
@ -71,3 +71,12 @@ flarum-suspend:
|
|||
{forum_url}
|
||||
html:
|
||||
body: "You have been unsuspended. You can head back to [{forumTitle}]({forum_url}) when you are ready."
|
||||
|
||||
# Translations in this namespace are used by the forum and admin interfaces.
|
||||
lib:
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
users:
|
||||
suspended:
|
||||
key: suspended
|
||||
|
|
|
@ -1,23 +1,38 @@
|
|||
import IGambit from 'flarum/common/query/IGambit';
|
||||
import app from 'flarum/common/app';
|
||||
import { KeyValueGambit } from 'flarum/common/query/IGambit';
|
||||
|
||||
export default class TagGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'tag:(.+)';
|
||||
export default class TagGambit extends KeyValueGambit {
|
||||
predicates = true;
|
||||
|
||||
key(): string {
|
||||
return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.key', {}, true);
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'tag';
|
||||
|
||||
return {
|
||||
[key]: matches[1].split(','),
|
||||
};
|
||||
hint(): string {
|
||||
return app.translator.trans('flarum-tags.lib.gambits.discussions.tag.hint', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'tag';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}tag:${value}`;
|
||||
gambitValueToFilterValue(value: string): string[] {
|
||||
return [value];
|
||||
}
|
||||
|
||||
fromFilter(value: any, negate: boolean): string {
|
||||
let gambits = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
gambits = value.map((value) => this.fromFilter(value.toString(), negate));
|
||||
} else {
|
||||
return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`;
|
||||
}
|
||||
|
||||
return gambits.join(' ');
|
||||
}
|
||||
|
||||
filterValueToGambitValue(value: string): string {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ export default function addTagFilter() {
|
|||
return this.currentActiveTag;
|
||||
}
|
||||
|
||||
const slug = this.search.params().tags;
|
||||
const slug = this.search.state.params().tags;
|
||||
let tag = null;
|
||||
|
||||
if (slug) {
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function addTagList() {
|
|||
|
||||
items.add('separator', <Separator />, -12);
|
||||
|
||||
const params = app.search.stickyParams();
|
||||
const params = app.search.state.stickyParams();
|
||||
const tags = app.store.all('tags');
|
||||
const currentTag = app.currentTag();
|
||||
|
||||
|
|
|
@ -107,6 +107,13 @@ flarum-tags:
|
|||
# This translation is displayed in place of the name of a tag that's been deleted.
|
||||
deleted_tag_text: Deleted
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
discussions:
|
||||
tag:
|
||||
key: tag
|
||||
hint: name of a tag, or comma-separated list of tag names, or "untagged"
|
||||
|
||||
# These translations are used in the tag selection modal.
|
||||
tag_selection_modal:
|
||||
bypass_requirements: Bypass tag requirements
|
||||
|
|
|
@ -43,30 +43,36 @@ class TagFilter implements FilterInterface
|
|||
|
||||
protected function constrain(Builder $query, string|array $rawSlugs, bool $negate, User $actor): void
|
||||
{
|
||||
$slugs = $this->asStringArray($rawSlugs);
|
||||
$rawSlugs = (array) $rawSlugs;
|
||||
|
||||
$query->where(function (Builder $query) use ($slugs, $negate, $actor) {
|
||||
foreach ($slugs as $slug) {
|
||||
if ($slug === 'untagged') {
|
||||
$query->whereIn('discussions.id', function (Builder $query) {
|
||||
$query->select('discussion_id')
|
||||
->from('discussion_tag');
|
||||
}, 'or', ! $negate);
|
||||
} else {
|
||||
// @TODO: grab all IDs first instead of multiple queries.
|
||||
try {
|
||||
$id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id;
|
||||
} catch (ModelNotFoundException) {
|
||||
$id = null;
|
||||
$inputSlugs = $this->asStringArray($rawSlugs);
|
||||
|
||||
foreach ($inputSlugs as $orSlugs) {
|
||||
$slugs = explode(',', $orSlugs);
|
||||
|
||||
$query->where(function (Builder $query) use ($slugs, $negate, $actor) {
|
||||
foreach ($slugs as $slug) {
|
||||
if ($slug === 'untagged') {
|
||||
$query->whereIn('discussions.id', function (Builder $query) {
|
||||
$query->select('discussion_id')
|
||||
->from('discussion_tag');
|
||||
}, 'or', ! $negate);
|
||||
} else {
|
||||
// @TODO: grab all IDs first instead of multiple queries.
|
||||
try {
|
||||
$id = $this->slugger->forResource(Tag::class)->fromSlug($slug, $actor)->id;
|
||||
} catch (ModelNotFoundException) {
|
||||
$id = null;
|
||||
}
|
||||
|
||||
$query->whereIn('discussions.id', function (Builder $query) use ($id) {
|
||||
$query->select('discussion_id')
|
||||
->from('discussion_tag')
|
||||
->where('tag_id', $id);
|
||||
}, 'or', $negate);
|
||||
}
|
||||
|
||||
$query->whereIn('discussions.id', function (Builder $query) use ($id) {
|
||||
$query->select('discussion_id')
|
||||
->from('discussion_tag')
|
||||
->where('tag_id', $id);
|
||||
}, 'or', $negate);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ declare module '@askvortsov/rich-icu-message-formatter' {
|
|||
type IRichHandler = (tag: any, values: IValues, contents: string) => any;
|
||||
|
||||
type ValueOrArray<T> = T | ValueOrArray<T>[];
|
||||
type NestedStringArray = ValueOrArray<string>;
|
||||
export type NestedStringArray = ValueOrArray<string>;
|
||||
|
||||
export class RichMessageFormatter {
|
||||
locale: string | null;
|
||||
|
|
|
@ -6,6 +6,8 @@ import Navigation from '../common/components/Navigation';
|
|||
import AdminNav from './components/AdminNav';
|
||||
import ExtensionData from './utils/ExtensionData';
|
||||
import IHistory from '../common/IHistory';
|
||||
import SearchManager from '../common/SearchManager';
|
||||
import SearchState from '../common/states/SearchState';
|
||||
|
||||
export type Extension = {
|
||||
id: string;
|
||||
|
@ -66,6 +68,8 @@ export default class AdminApplication extends Application {
|
|||
home: () => {},
|
||||
};
|
||||
|
||||
search: SearchManager<SearchState> = new SearchManager(new SearchState());
|
||||
|
||||
/**
|
||||
* Settings are serialized to the admin dashboard as strings.
|
||||
* Additional encoding/decoding is possible, but must take
|
||||
|
|
|
@ -6,6 +6,8 @@ import SelectDropdown from '../../common/components/SelectDropdown';
|
|||
import getCategorizedExtensions from '../utils/getCategorizedExtensions';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import Input from '../../common/components/Input';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
|
||||
export default class AdminNav extends Component {
|
||||
oninit(vnode) {
|
||||
|
@ -122,14 +124,13 @@ export default class AdminNav extends Component {
|
|||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
<input
|
||||
className="FormControl SearchBar"
|
||||
bidi={this.query}
|
||||
type="search"
|
||||
placeholder={app.translator.trans('core.admin.nav.search_placeholder')}
|
||||
/>
|
||||
</div>,
|
||||
<Input
|
||||
type="search"
|
||||
className="SearchBar"
|
||||
stream={this.query}
|
||||
clearable={true}
|
||||
placeholder={extractText(app.translator.trans('core.admin.nav.search_placeholder'))}
|
||||
/>,
|
||||
0
|
||||
);
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ import AdminPage from './AdminPage';
|
|||
import { debounce } from '../../common/utils/throttleDebounce';
|
||||
import CreateUserModal from './CreateUserModal';
|
||||
import Icon from '../../common/components/Icon';
|
||||
import Input from '../../common/components/Input';
|
||||
import GambitsAutocompleteDropdown from '../../common/components/GambitsAutocompleteDropdown';
|
||||
|
||||
type ColumnData = {
|
||||
/**
|
||||
|
@ -234,20 +236,24 @@ export default class UserListPage extends AdminPage {
|
|||
headerItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const onchange = (value: string) => {
|
||||
this.isLoadingPage = true;
|
||||
this.query = value;
|
||||
this.throttledSearch();
|
||||
};
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
<input
|
||||
className="FormControl SearchBar"
|
||||
<GambitsAutocompleteDropdown resource="users" query={this.query} onchange={onchange}>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
|
||||
oninput={(e: InputEvent) => {
|
||||
this.isLoadingPage = true;
|
||||
this.query = (e?.target as HTMLInputElement)?.value;
|
||||
this.throttledSearch();
|
||||
}}
|
||||
clearable={true}
|
||||
loading={this.isLoadingPage}
|
||||
value={this.query}
|
||||
onchange={onchange}
|
||||
/>
|
||||
</div>,
|
||||
</GambitsAutocompleteDropdown>,
|
||||
100
|
||||
);
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ import fireApplicationError from './helpers/fireApplicationError';
|
|||
import IHistory from './IHistory';
|
||||
import IExtender from './extenders/IExtender';
|
||||
import AccessToken from './models/AccessToken';
|
||||
import SearchManager from './SearchManager';
|
||||
|
||||
export type FlarumScreens = 'phone' | 'tablet' | 'desktop' | 'desktop-hd';
|
||||
|
||||
|
@ -184,6 +185,8 @@ export default class Application {
|
|||
notifications: Notification,
|
||||
});
|
||||
|
||||
search!: SearchManager;
|
||||
|
||||
/**
|
||||
* A local cache that can be used to store data at the application level, so
|
||||
* that is persists between different routes.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import IGambit from './query/IGambit';
|
||||
import type IGambit from './query/IGambit';
|
||||
import AuthorGambit from './query/discussions/AuthorGambit';
|
||||
import CreatedGambit from './query/discussions/CreatedGambit';
|
||||
import HiddenGambit from './query/discussions/HiddenGambit';
|
||||
|
@ -19,15 +19,29 @@ export default class GambitManager {
|
|||
};
|
||||
|
||||
public apply(type: string, filter: Record<string, any>): Record<string, any> {
|
||||
const gambits = this.gambits[type] || [];
|
||||
filter.q = this.match(type, filter.q, (gambit, matches, negate) => {
|
||||
const additions = gambit.toFilter(matches, negate);
|
||||
|
||||
if (gambits.length === 0) return filter;
|
||||
Object.keys(additions).forEach((key) => {
|
||||
if (key in filter && gambit.predicates && Array.isArray(additions[key])) {
|
||||
filter[key] = filter[key].concat(additions[key]);
|
||||
} else {
|
||||
filter[key] = additions[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const bits: string[] = filter.q.split(' ');
|
||||
return filter;
|
||||
}
|
||||
|
||||
for (const gambitClass of gambits) {
|
||||
const gambit = new gambitClass();
|
||||
public match(type: string, query: string, onmatch: (gambit: IGambit, matches: string[], negate: boolean, bit: string) => void): string {
|
||||
const gambits = this.for(type).filter((gambit) => gambit.enabled());
|
||||
|
||||
if (gambits.length === 0) return query;
|
||||
|
||||
const bits: string[] = query.split(' ');
|
||||
|
||||
for (const gambit of gambits) {
|
||||
for (const bit of bits) {
|
||||
const pattern = `^(-?)${gambit.pattern()}$`;
|
||||
let matches = bit.match(pattern);
|
||||
|
@ -37,26 +51,25 @@ export default class GambitManager {
|
|||
|
||||
matches.splice(1, 1);
|
||||
|
||||
Object.assign(filter, gambit.toFilter(matches, negate));
|
||||
onmatch(gambit, matches, negate, bit);
|
||||
|
||||
filter.q = filter.q.replace(bit, '');
|
||||
query = query.replace(bit, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filter.q = filter.q.trim().replace(/\s+/g, ' ');
|
||||
query = query.trim().replace(/\s+/g, ' ');
|
||||
|
||||
return filter;
|
||||
return query;
|
||||
}
|
||||
|
||||
public from(type: string, q: string, filter: Record<string, any>): string {
|
||||
const gambits = this.gambits[type] || [];
|
||||
const gambits = this.for(type);
|
||||
|
||||
if (gambits.length === 0) return q;
|
||||
|
||||
Object.keys(filter).forEach((key) => {
|
||||
for (const gambitClass of gambits) {
|
||||
const gambit = new gambitClass();
|
||||
for (const gambit of gambits) {
|
||||
const negate = key[0] === '-';
|
||||
|
||||
if (negate) key = key.substring(1);
|
||||
|
@ -69,4 +82,8 @@ export default class GambitManager {
|
|||
|
||||
return q;
|
||||
}
|
||||
|
||||
for(type: string): Array<IGambit> {
|
||||
return (this.gambits[type] || []).map((gambitClass) => new gambitClass());
|
||||
}
|
||||
}
|
||||
|
|
25
framework/core/js/src/common/SearchManager.ts
Normal file
25
framework/core/js/src/common/SearchManager.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import SearchState from './states/SearchState';
|
||||
import GambitManager from './GambitManager';
|
||||
|
||||
export default class SearchManager<State extends SearchState = SearchState> {
|
||||
/**
|
||||
* The minimum query length before sources are searched.
|
||||
*/
|
||||
public static MIN_SEARCH_LEN = 3;
|
||||
|
||||
/**
|
||||
* An object which stores previously searched queries and provides convenient
|
||||
* tools for retrieving and managing search values.
|
||||
*/
|
||||
public state: State;
|
||||
|
||||
/**
|
||||
* The gambit manager that will convert search query gambits
|
||||
* into API filters.
|
||||
*/
|
||||
public gambits = new GambitManager();
|
||||
|
||||
constructor(state: State) {
|
||||
this.state = state;
|
||||
}
|
||||
}
|
|
@ -89,12 +89,6 @@ export default class Store {
|
|||
*/
|
||||
models: Record<string, { new (): Model }>;
|
||||
|
||||
/**
|
||||
* The gambit manager that will convert search query gambits
|
||||
* into API filters.
|
||||
*/
|
||||
gambits = new GambitManager();
|
||||
|
||||
constructor(models: Record<string, { new (): Model }>) {
|
||||
this.models = models;
|
||||
}
|
||||
|
@ -186,7 +180,7 @@ export default class Store {
|
|||
}
|
||||
|
||||
if ('filter' in params && params?.filter?.q) {
|
||||
params.filter = this.gambits.apply(type, params.filter);
|
||||
params.filter = app.search.gambits.apply(type, params.filter);
|
||||
}
|
||||
|
||||
return app
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { RichMessageFormatter, mithrilRichHandler } from '@askvortsov/rich-icu-message-formatter';
|
||||
import { RichMessageFormatter, mithrilRichHandler, NestedStringArray } from '@askvortsov/rich-icu-message-formatter';
|
||||
import { pluralTypeHandler, selectTypeHandler } from '@ultraq/icu-message-formatter';
|
||||
import username from './helpers/username';
|
||||
import User from './models/User';
|
||||
import extract from './utils/extract';
|
||||
import extractText from './utils/extractText';
|
||||
|
||||
type Translations = Record<string, string>;
|
||||
type TranslatorParameters = Record<string, unknown>;
|
||||
|
@ -69,12 +70,20 @@ export default class Translator {
|
|||
return parameters;
|
||||
}
|
||||
|
||||
trans(id: string, parameters: TranslatorParameters = {}) {
|
||||
trans(id: string, parameters: TranslatorParameters): NestedStringArray;
|
||||
trans(id: string, parameters: TranslatorParameters, extract: false): NestedStringArray;
|
||||
trans(id: string, parameters: TranslatorParameters, extract: true): string;
|
||||
trans(id: string): NestedStringArray | string;
|
||||
trans(id: string, parameters: TranslatorParameters = {}, extract = false) {
|
||||
const translation = this.translations[id];
|
||||
|
||||
if (translation) {
|
||||
parameters = this.preprocessParameters(parameters);
|
||||
return this.formatter.rich(translation, parameters);
|
||||
const locale = this.formatter.rich(translation, parameters);
|
||||
|
||||
if (extract) return extractText(locale);
|
||||
|
||||
return locale;
|
||||
}
|
||||
|
||||
return id;
|
||||
|
|
|
@ -5,7 +5,10 @@ import './states/PaginatedListState';
|
|||
import './states/AlertManagerState';
|
||||
import './states/ModalManagerState';
|
||||
import './states/PageState';
|
||||
import './states/SearchState';
|
||||
|
||||
import './utils/AutocompleteReader';
|
||||
import './utils/GambitsAutocomplete';
|
||||
import './utils/isObject';
|
||||
import './utils/mixin';
|
||||
import './utils/insertText';
|
||||
|
@ -50,6 +53,7 @@ import './components/LoadingIndicator';
|
|||
import './components/Placeholder';
|
||||
import './components/Separator';
|
||||
import './components/Dropdown';
|
||||
import './components/InfoTile';
|
||||
import './components/DetailedDropdownItem';
|
||||
import './components/SplitDropdown';
|
||||
import './components/RequestErrorModal';
|
||||
|
@ -70,6 +74,8 @@ import './components/GroupBadge';
|
|||
import './components/TextEditor';
|
||||
import './components/TextEditorButton';
|
||||
import './components/Tooltip';
|
||||
import './components/AutocompleteDropdown';
|
||||
import './components/GambitsAutocompleteDropdown';
|
||||
|
||||
import './helpers/fullTime';
|
||||
import './components/Avatar';
|
||||
|
@ -81,6 +87,8 @@ import './helpers/userOnline';
|
|||
import './helpers/listItems';
|
||||
import './helpers/textContrastClass';
|
||||
|
||||
import './query/IGambit';
|
||||
|
||||
import './resolvers/DefaultResolver';
|
||||
|
||||
import './Component';
|
||||
|
|
201
framework/core/js/src/common/components/AutocompleteDropdown.tsx
Normal file
201
framework/core/js/src/common/components/AutocompleteDropdown.tsx
Normal file
|
@ -0,0 +1,201 @@
|
|||
import Component, { type ComponentAttrs } from '../Component';
|
||||
import KeyboardNavigatable from '../utils/KeyboardNavigatable';
|
||||
import type Mithril from 'mithril';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
export interface AutocompleteDropdownAttrs extends ComponentAttrs {
|
||||
query: string;
|
||||
onchange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable component that wraps around an input element and displays a list
|
||||
* of suggestions based on the input's value.
|
||||
* Must be extended and the `suggestions` method implemented.
|
||||
*/
|
||||
export default abstract class AutocompleteDropdown<
|
||||
CustomAttrs extends AutocompleteDropdownAttrs = AutocompleteDropdownAttrs
|
||||
> extends Component<CustomAttrs> {
|
||||
/**
|
||||
* The index of the currently-selected <li> in the results list. This can be
|
||||
* a unique string (to account for the fact that an item's position may jump
|
||||
* around as new results load), but otherwise it will be numeric (the
|
||||
* sequential position within the list).
|
||||
*/
|
||||
protected index: number = 0;
|
||||
|
||||
protected navigator!: KeyboardNavigatable;
|
||||
|
||||
private updateMaxHeightHandler?: () => void;
|
||||
|
||||
/**
|
||||
* Whether the input has focus.
|
||||
*/
|
||||
protected hasFocus = false;
|
||||
|
||||
abstract suggestions(): JSX.Element[];
|
||||
|
||||
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
|
||||
const suggestions = this.suggestions();
|
||||
const shouldShowSuggestions = !!suggestions.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classList('AutocompleteDropdown', {
|
||||
focused: this.hasFocus,
|
||||
open: shouldShowSuggestions && this.hasFocus,
|
||||
})}
|
||||
>
|
||||
{vnode.children}
|
||||
<ul
|
||||
className="Dropdown-menu Dropdown-suggestions"
|
||||
aria-hidden={!shouldShowSuggestions || undefined}
|
||||
aria-live={shouldShowSuggestions ? 'polite' : undefined}
|
||||
>
|
||||
{suggestions}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
updateMaxHeight() {
|
||||
// Since extensions might add elements above the search box on mobile,
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight = window.innerHeight - this.element.querySelector('.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
||||
|
||||
this.element.querySelector<HTMLElement>('.Dropdown-suggestions')?.style?.setProperty('max-height', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
this.updateMaxHeight();
|
||||
}
|
||||
|
||||
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
const component = this;
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
this.$('.Dropdown-suggestions')
|
||||
.on('mousedown', (e) => e.preventDefault())
|
||||
// Whenever the mouse is hovered over a search result, highlight it.
|
||||
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
|
||||
component.setIndex(component.selectableItems().index(this));
|
||||
});
|
||||
|
||||
const $input = this.inputElement();
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
this.navigator
|
||||
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
|
||||
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
|
||||
.onSelect(this.selectSuggestion.bind(this), true)
|
||||
.bindTo($input);
|
||||
|
||||
$input
|
||||
.on('focus', function () {
|
||||
component.hasFocus = true;
|
||||
m.redraw();
|
||||
|
||||
$(this)
|
||||
.one('mouseup', (e) => e.preventDefault())
|
||||
.trigger('select');
|
||||
})
|
||||
.on('blur', function () {
|
||||
component.hasFocus = false;
|
||||
m.redraw();
|
||||
});
|
||||
|
||||
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
|
||||
window.addEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.onremove(vnode);
|
||||
|
||||
if (this.updateMaxHeightHandler) {
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
}
|
||||
|
||||
selectableItems(): JQuery {
|
||||
return this.$('.Dropdown-suggestions > li:not(.Dropdown-header)');
|
||||
}
|
||||
|
||||
inputElement(): JQuery<HTMLInputElement> {
|
||||
return this.$('input') as JQuery<HTMLInputElement>;
|
||||
}
|
||||
|
||||
selectSuggestion() {
|
||||
this.getItem(this.index).find('button')[0].click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of the currently selected item.
|
||||
* Returns zero if not found.
|
||||
*/
|
||||
getCurrentNumericIndex(): number {
|
||||
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <li> in the search results with the given index (numeric or named).
|
||||
*/
|
||||
getItem(index: number): JQuery {
|
||||
const $items = this.selectableItems();
|
||||
let $item = $items.filter(`[data-index="${index}"]`);
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently-selected search result item to the one with the given
|
||||
* index.
|
||||
*/
|
||||
setIndex(index: number, scrollToItem: boolean = false) {
|
||||
const $items = this.selectableItems();
|
||||
const $dropdown = $items.parent();
|
||||
|
||||
let fixedIndex = index;
|
||||
if (index < 0) {
|
||||
fixedIndex = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
fixedIndex = 0;
|
||||
}
|
||||
|
||||
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
||||
|
||||
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop()!;
|
||||
const dropdownTop = $dropdown.offset()!.top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
|
||||
const itemTop = $item.offset()!.top;
|
||||
const itemBottom = itemTop + $item.outerHeight()!;
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({ scrollTop }, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import type Mithril from 'mithril';
|
||||
import AutocompleteDropdown, { type AutocompleteDropdownAttrs } from './AutocompleteDropdown';
|
||||
import GambitsAutocomplete from '../utils/GambitsAutocomplete';
|
||||
|
||||
export interface GambitsAutocompleteDropdownAttrs extends AutocompleteDropdownAttrs {
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is an autocomplete component not related to the SearchModal forum components.
|
||||
* It is a standalone component that can be reused for search inputs of any other types
|
||||
* of resources. It will display a dropdown menu under the input with gambit suggestions
|
||||
* similar to the SearchModal component.
|
||||
*/
|
||||
export default class GambitsAutocompleteDropdown<
|
||||
CustomAttrs extends GambitsAutocompleteDropdownAttrs = GambitsAutocompleteDropdownAttrs
|
||||
> extends AutocompleteDropdown<CustomAttrs> {
|
||||
protected gambitsAutocomplete!: GambitsAutocomplete;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
this.gambitsAutocomplete = new GambitsAutocomplete(this.attrs.resource, () => this.inputElement(), this.attrs.onchange, this.attrs.onchange);
|
||||
}
|
||||
|
||||
suggestions(): JSX.Element[] {
|
||||
return this.gambitsAutocomplete.suggestions(this.attrs.query);
|
||||
}
|
||||
}
|
31
framework/core/js/src/common/components/InfoTile.tsx
Normal file
31
framework/core/js/src/common/components/InfoTile.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import Component from '../Component';
|
||||
import type { ComponentAttrs } from '../Component';
|
||||
import type Mithril from 'mithril';
|
||||
import Icon from './Icon';
|
||||
import classList from '../utils/classList';
|
||||
|
||||
export interface IInfoTileAttrs extends ComponentAttrs {
|
||||
icon?: string;
|
||||
iconElement?: Mithril.Children;
|
||||
}
|
||||
|
||||
export default class InfoTile<CustomAttrs extends IInfoTileAttrs = IInfoTileAttrs> extends Component<CustomAttrs> {
|
||||
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
|
||||
const { icon, className, ...attrs } = vnode.attrs;
|
||||
|
||||
return (
|
||||
<div className={classList('InfoTile', className)} {...attrs}>
|
||||
{this.icon()}
|
||||
<div className="InfoTile-text">{vnode.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
icon(): Mithril.Children {
|
||||
if (this.attrs.iconElement) return this.attrs.iconElement;
|
||||
|
||||
if (!this.attrs.icon) return null;
|
||||
|
||||
return <Icon name={classList(this.attrs.icon, 'InfoTile-icon')} />;
|
||||
}
|
||||
}
|
97
framework/core/js/src/common/components/Input.tsx
Normal file
97
framework/core/js/src/common/components/Input.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import app from '../../forum/app';
|
||||
import Component from '../Component';
|
||||
import Icon from './Icon';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
import classList from '../utils/classList';
|
||||
import Button from './Button';
|
||||
import Stream from '../utils/Stream';
|
||||
import type { ComponentAttrs } from '../Component';
|
||||
import type Mithril from 'mithril';
|
||||
|
||||
export interface IInputAttrs extends ComponentAttrs {
|
||||
className?: string;
|
||||
prefixIcon?: string;
|
||||
clearable?: boolean;
|
||||
clearLabel?: string;
|
||||
loading?: boolean;
|
||||
inputClassName?: string;
|
||||
onchange?: (value: string) => void;
|
||||
value?: string;
|
||||
stream?: Stream<string>;
|
||||
type?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
renderInput?: (attrs: any) => Mithril.Children;
|
||||
inputAttrs?: {
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
export default class Input<CustomAttrs extends IInputAttrs = IInputAttrs> extends Component<CustomAttrs> {
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
}
|
||||
|
||||
view(vnode: Mithril.Vnode<CustomAttrs, this>): Mithril.Children {
|
||||
const { className: inputClassName, ...inputAttrs } = this.attrs.inputAttrs || {};
|
||||
|
||||
const value = this.attrs.value || this.attrs.stream?.() || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classList('Input', this.attrs.className, {
|
||||
'Input--withPrefix': this.attrs.prefixIcon,
|
||||
'Input--withClear': this.attrs.clearable,
|
||||
})}
|
||||
>
|
||||
{this.attrs.prefixIcon && <Icon name={classList(this.attrs.prefixIcon, 'Input-prefix-icon')} />}
|
||||
{this.input({ inputClassName, value, inputAttrs })}
|
||||
{this.attrs.loading && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
|
||||
{this.attrs.clearable && value && !this.attrs.loading && (
|
||||
<Button
|
||||
className="Input-clear Button Button--icon Button--link"
|
||||
onclick={this.clear.bind(this)}
|
||||
aria-label={this.attrs.clearLabel || app.translator.trans('core.lib.input.clear_button')}
|
||||
type="button"
|
||||
icon="fas fa-times-circle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
input({ inputClassName, value, inputAttrs }: any) {
|
||||
const attrs = {
|
||||
className: classList('FormControl', inputClassName),
|
||||
type: this.attrs.type || 'text',
|
||||
value: value,
|
||||
oninput: (e: InputEvent) => this.onchange?.((e.target as HTMLInputElement).value),
|
||||
'aria-label': this.attrs.ariaLabel,
|
||||
placeholder: this.attrs.placeholder,
|
||||
readonly: this.attrs.readonly || undefined,
|
||||
disabled: this.attrs.disabled || undefined,
|
||||
...inputAttrs,
|
||||
};
|
||||
|
||||
if (this.attrs.renderInput) {
|
||||
return this.attrs.renderInput(attrs);
|
||||
}
|
||||
|
||||
return <input {...attrs} />;
|
||||
}
|
||||
|
||||
onchange(value: string) {
|
||||
if (this.attrs.stream) {
|
||||
this.attrs.stream(value);
|
||||
} else {
|
||||
this.attrs.onchange?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.onchange('');
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ export default class Search implements IExtender {
|
|||
protected gambits: Record<string, Array<new () => IGambit>> = {};
|
||||
|
||||
public gambit(modelType: string, gambit: new () => IGambit): this {
|
||||
this.gambits[modelType] = this.gambits[modelType] || [];
|
||||
this.gambits[modelType] ||= [];
|
||||
this.gambits[modelType].push(gambit);
|
||||
|
||||
return this;
|
||||
|
@ -16,8 +16,8 @@ export default class Search implements IExtender {
|
|||
extend(app: Application, extension: IExtensionModule): void {
|
||||
for (const [modelType, gambits] of Object.entries(this.gambits)) {
|
||||
for (const gambit of gambits) {
|
||||
app.store.gambits.gambits[modelType] = app.store.gambits.gambits[modelType] || [];
|
||||
app.store.gambits.gambits[modelType].push(gambit);
|
||||
app.search.gambits.gambits[modelType] ||= [];
|
||||
app.search.gambits.gambits[modelType].push(gambit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,171 @@
|
|||
export default interface IGambit {
|
||||
import app from '../app';
|
||||
|
||||
export default interface IGambit<Type extends GambitType = GambitType> {
|
||||
type: GambitType;
|
||||
|
||||
/**
|
||||
* This is the regular expression pattern that will be used to match the gambit.
|
||||
* The pattern language can be localized. for example, the pattern for the
|
||||
* author gambit is `author:(.+)` in English, but `auteur:(.+)` in
|
||||
* French.
|
||||
*/
|
||||
pattern(): string;
|
||||
|
||||
/**
|
||||
* This is the method to transform a gambit into a filter format.
|
||||
*/
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any>;
|
||||
|
||||
/**
|
||||
* This is the server standardised filter key for this gambit.
|
||||
* The filter key must not be localized.
|
||||
*/
|
||||
filterKey(): string;
|
||||
fromFilter(value: string, negate: boolean): string;
|
||||
|
||||
/**
|
||||
* This is the method to transform a filter into a gambit format.
|
||||
* The gambit format can be localized.
|
||||
*/
|
||||
fromFilter(value: any, negate: boolean): string;
|
||||
|
||||
/**
|
||||
* This returns information about how the gambit is structured for the UI.
|
||||
* Use localized values.
|
||||
*/
|
||||
suggestion(): Type extends GambitType.KeyValue ? KeyValueGambitSuggestion : GroupedGambitSuggestion;
|
||||
|
||||
/**
|
||||
* Whether this gambit can use logical operators.
|
||||
* For example, the tag gambit can be used as such:
|
||||
* `tag:foo,bar tag:baz` which translates to `(foo OR bar) AND baz`.
|
||||
*
|
||||
* The info allows generation of the correct filtering format, which would be
|
||||
* ```
|
||||
* {
|
||||
* tag: [
|
||||
* 'foo,bar', // OR because of the comma.
|
||||
* 'baz', // AND because it's a separate item.
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The backend filter must be able to handle this format.
|
||||
* Checkout the TagGambit and TagFilter classes for an example.
|
||||
*/
|
||||
predicates: boolean;
|
||||
|
||||
/**
|
||||
* Whether this gambit can be used by the actor.
|
||||
*/
|
||||
enabled(): boolean;
|
||||
}
|
||||
|
||||
export enum GambitType {
|
||||
KeyValue = 'key:value',
|
||||
Grouped = 'grouped',
|
||||
}
|
||||
|
||||
export type KeyValueGambitSuggestion = {
|
||||
key: string;
|
||||
hint: string;
|
||||
};
|
||||
|
||||
export type GroupedGambitSuggestion = {
|
||||
group: 'is' | 'has' | string;
|
||||
key: string | string[];
|
||||
};
|
||||
|
||||
export abstract class BooleanGambit implements IGambit<GambitType.Grouped> {
|
||||
type = GambitType.Grouped;
|
||||
predicates = false;
|
||||
|
||||
abstract key(): string | string[];
|
||||
abstract filterKey(): string;
|
||||
|
||||
pattern(): string {
|
||||
const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true);
|
||||
let key = this.key();
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
key = key.join('|');
|
||||
}
|
||||
|
||||
return `${is}:(${key})`;
|
||||
}
|
||||
|
||||
toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + this.filterKey();
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
const is = app.translator.trans('core.lib.gambits.boolean_key', {}, true);
|
||||
const key = this.key();
|
||||
|
||||
return `${negate ? '-' : ''}${is}:${key}`;
|
||||
}
|
||||
|
||||
suggestion() {
|
||||
return {
|
||||
group: app.translator.trans('core.lib.gambits.boolean_key', {}, true),
|
||||
key: this.key(),
|
||||
};
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class KeyValueGambit implements IGambit<GambitType.KeyValue> {
|
||||
type = GambitType.KeyValue;
|
||||
predicates = false;
|
||||
|
||||
abstract key(): string;
|
||||
abstract hint(): string;
|
||||
abstract filterKey(): string;
|
||||
|
||||
valuePattern(): string {
|
||||
return '(.+)';
|
||||
}
|
||||
|
||||
gambitValueToFilterValue(value: string): string | number | boolean | Array<any> {
|
||||
return value;
|
||||
}
|
||||
|
||||
filterValueToGambitValue(value: any): string {
|
||||
return Array.isArray(value) ? value.join(',') : value.toString();
|
||||
}
|
||||
|
||||
pattern(): string {
|
||||
const key = this.key();
|
||||
|
||||
return `${key}:` + this.valuePattern();
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + this.filterKey();
|
||||
|
||||
return {
|
||||
[key]: this.gambitValueToFilterValue(matches[1]),
|
||||
};
|
||||
}
|
||||
|
||||
fromFilter(value: any, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}${this.key()}:${this.filterValueToGambitValue(value)}`;
|
||||
}
|
||||
|
||||
suggestion() {
|
||||
return {
|
||||
key: this.key(),
|
||||
hint: this.hint(),
|
||||
};
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { KeyValueGambit } from '../IGambit';
|
||||
|
||||
export default class AuthorGambit implements IGambit {
|
||||
public pattern(): string {
|
||||
return 'author:(.+)';
|
||||
export default class AuthorGambit extends KeyValueGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.author.key', {}, true);
|
||||
}
|
||||
|
||||
public toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'author';
|
||||
|
||||
return {
|
||||
[key]: matches[1].split(','),
|
||||
};
|
||||
hint(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.author.hint', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'author';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}author:${value}`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { KeyValueGambit } from '../IGambit';
|
||||
|
||||
export default class CreatedGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'created:(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)';
|
||||
export default class CreatedGambit extends KeyValueGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.created.key', {}, true);
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'created';
|
||||
hint(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.created.hint', {}, true);
|
||||
}
|
||||
|
||||
return {
|
||||
[key]: matches[1],
|
||||
};
|
||||
valuePattern(): string {
|
||||
return '(\\d{4}\\-\\d\\d\\-\\d\\d(?:\\.\\.(\\d{4}\\-\\d\\d\\-\\d\\d))?)';
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'created';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}created:${value}`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { BooleanGambit } from '../IGambit';
|
||||
|
||||
export default class HiddenGambit implements IGambit {
|
||||
public pattern(): string {
|
||||
return 'is:hidden';
|
||||
}
|
||||
|
||||
public toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'hidden';
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
export default class HiddenGambit extends BooleanGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.hidden.key', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:hidden`;
|
||||
enabled(): boolean {
|
||||
return !!app.session.user;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { BooleanGambit } from '../IGambit';
|
||||
|
||||
export default class UnreadGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'is:unread';
|
||||
}
|
||||
|
||||
toFilter(_matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'unread';
|
||||
|
||||
return {
|
||||
[key]: true,
|
||||
};
|
||||
export default class UnreadGambit extends BooleanGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.discussions.unread.key', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'unread';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}is:unread`;
|
||||
enabled(): boolean {
|
||||
return !!app.session.user;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,20 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { KeyValueGambit } from '../IGambit';
|
||||
|
||||
export default class EmailGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'email:(.+)';
|
||||
export default class EmailGambit extends KeyValueGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.users.email.key', {}, true);
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'email';
|
||||
|
||||
return {
|
||||
[key]: matches[1],
|
||||
};
|
||||
hint(): string {
|
||||
return app.translator.trans('core.lib.gambits.users.email.hint', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'email';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}email:${value}`;
|
||||
enabled(): boolean {
|
||||
return !!(app.session.user && app.forum.attribute<boolean>('canEditUserCredentials'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import IGambit from '../IGambit';
|
||||
import app from '../../app';
|
||||
import { KeyValueGambit } from '../IGambit';
|
||||
|
||||
export default class GroupGambit implements IGambit {
|
||||
pattern(): string {
|
||||
return 'group:(.+)';
|
||||
export default class GroupGambit extends KeyValueGambit {
|
||||
key(): string {
|
||||
return app.translator.trans('core.lib.gambits.users.group.key', {}, true);
|
||||
}
|
||||
|
||||
toFilter(matches: string[], negate: boolean): Record<string, any> {
|
||||
const key = (negate ? '-' : '') + 'group';
|
||||
|
||||
return {
|
||||
[key]: matches[1].split(','),
|
||||
};
|
||||
hint(): string {
|
||||
return app.translator.trans('core.lib.gambits.users.group.hint', {}, true);
|
||||
}
|
||||
|
||||
filterKey(): string {
|
||||
return 'group';
|
||||
}
|
||||
|
||||
fromFilter(value: string, negate: boolean): string {
|
||||
return `${negate ? '-' : ''}group:${value}`;
|
||||
}
|
||||
}
|
||||
|
|
51
framework/core/js/src/common/utils/AutocompleteReader.ts
Normal file
51
framework/core/js/src/common/utils/AutocompleteReader.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
export default class AutocompleteReader {
|
||||
public readonly symbol: string | ((character: string) => boolean) | null;
|
||||
public relativeStart: number = 0;
|
||||
public absoluteStart: number = 0;
|
||||
|
||||
constructor(symbol: string | ((character: string) => boolean) | null) {
|
||||
this.symbol = symbol;
|
||||
}
|
||||
|
||||
check(lastChunk: string, cursor: number, validBit: RegExp | null = null): AutocompleteCheck | null {
|
||||
this.absoluteStart = 0;
|
||||
|
||||
// Search backwards from the cursor for a symbol. If we find
|
||||
// one and followed by a whitespace, we will want to show the
|
||||
// autocomplete dropdown!
|
||||
for (let i = lastChunk.length - 1; i >= 0; i--) {
|
||||
const character = lastChunk.substr(i, 1);
|
||||
|
||||
// check what the user typed is valid.
|
||||
if (validBit && !validBit?.test(character)) return null;
|
||||
|
||||
// check if the character is the symbol we are looking for.
|
||||
if (this.symbol) {
|
||||
const symbol = typeof this.symbol === 'string' ? (character: string) => character === this.symbol : this.symbol;
|
||||
if (!symbol(character)) continue;
|
||||
}
|
||||
|
||||
// make sure the symbol preceded by a whitespace or newline
|
||||
if (i === 0 || /\s/.test(lastChunk.substr(i - 1, 1))) {
|
||||
this.relativeStart = i + (this.symbol ? 1 : 0);
|
||||
this.absoluteStart = cursor - lastChunk.length + i + (this.symbol ? 1 : 0);
|
||||
|
||||
return {
|
||||
symbol: this.symbol && character,
|
||||
relativeStart: this.relativeStart,
|
||||
absoluteStart: this.absoluteStart,
|
||||
typed: lastChunk.substring(this.relativeStart).toLowerCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type AutocompleteCheck = {
|
||||
symbol: string | null;
|
||||
relativeStart: number;
|
||||
absoluteStart: number;
|
||||
typed: string;
|
||||
};
|
174
framework/core/js/src/common/utils/GambitsAutocomplete.tsx
Normal file
174
framework/core/js/src/common/utils/GambitsAutocomplete.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
import app from '../app';
|
||||
import { GambitType, type GroupedGambitSuggestion, type KeyValueGambitSuggestion } from '../query/IGambit';
|
||||
import type IGambit from '../query/IGambit';
|
||||
import AutocompleteReader, { type AutocompleteCheck } from '../utils/AutocompleteReader';
|
||||
import Button from '../components/Button';
|
||||
|
||||
export default class GambitsAutocomplete {
|
||||
protected query = '';
|
||||
|
||||
constructor(
|
||||
public resource: string,
|
||||
public jqueryInput: () => JQuery<HTMLInputElement>,
|
||||
public onchange: (value: string) => void,
|
||||
public afterSuggest: (value: string) => void
|
||||
) {}
|
||||
|
||||
suggestions(query: string): JSX.Element[] {
|
||||
const gambits = app.search.gambits.for(this.resource).filter((gambit) => gambit.enabled());
|
||||
this.query = query;
|
||||
|
||||
// We group the boolean gambits together to produce an initial item of
|
||||
// is:unread,sticky,locked, etc.
|
||||
const groupedGambits: IGambit<GambitType.Grouped>[] = gambits.filter((gambit) => gambit.type === GambitType.Grouped);
|
||||
const keyValueGambits: IGambit<GambitType.KeyValue>[] = gambits.filter((gambit) => gambit.type !== GambitType.Grouped);
|
||||
|
||||
const uniqueGroups: string[] = [];
|
||||
for (const gambit of groupedGambits) {
|
||||
if (uniqueGroups.includes(gambit.suggestion().group)) continue;
|
||||
uniqueGroups.push(gambit.suggestion().group);
|
||||
}
|
||||
|
||||
const instancePerGroup: IGambit<GambitType.Grouped>[] = [];
|
||||
for (const group of uniqueGroups) {
|
||||
instancePerGroup.push({
|
||||
type: GambitType.Grouped,
|
||||
suggestion: () => ({
|
||||
group,
|
||||
key: groupedGambits
|
||||
.filter((gambit) => gambit.suggestion().group === group)
|
||||
.map((gambit) => {
|
||||
const key = gambit.suggestion().key;
|
||||
|
||||
return key instanceof Array ? key.join(', ') : key;
|
||||
})
|
||||
.join(', '),
|
||||
}),
|
||||
pattern: () => '',
|
||||
filterKey: () => '',
|
||||
toFilter: () => [],
|
||||
fromFilter: () => '',
|
||||
predicates: false,
|
||||
enabled: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
const autocompleteReader = new AutocompleteReader(null);
|
||||
|
||||
const cursorPosition = this.jqueryInput().prop('selectionStart') || query.length;
|
||||
const lastChunk = query.slice(0, cursorPosition);
|
||||
const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/);
|
||||
|
||||
let typed = autocomplete?.typed || '';
|
||||
|
||||
// Negative gambits are a thing ;)
|
||||
const negative = typed.startsWith('-');
|
||||
if (negative) {
|
||||
typed = typed.slice(1);
|
||||
}
|
||||
|
||||
// if the query ends with 'is:' we will only list keys from that group.
|
||||
if (typed.endsWith(':')) {
|
||||
const gambitKey = typed.replace(/:$/, '') || null;
|
||||
const gambitQuery = typed.split(':').pop() || '';
|
||||
|
||||
if (gambitKey) {
|
||||
const specificGambitSuggestions = this.specificGambitSuggestions(gambitKey, gambitQuery, uniqueGroups, groupedGambits, autocomplete!);
|
||||
|
||||
if (specificGambitSuggestions) {
|
||||
return specificGambitSuggestions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is all the gambit suggestions.
|
||||
return [...instancePerGroup, ...keyValueGambits]
|
||||
.filter(
|
||||
(gambit) =>
|
||||
!autocomplete ||
|
||||
new RegExp(typed).test(
|
||||
gambit.type === GambitType.Grouped ? (gambit.suggestion() as GroupedGambitSuggestion).group : (gambit.suggestion().key as string)
|
||||
)
|
||||
)
|
||||
.map((gambit) => {
|
||||
const suggestion = gambit.suggestion();
|
||||
const key = gambit.type === GambitType.Grouped ? (suggestion as GroupedGambitSuggestion).group : (suggestion.key as string);
|
||||
const hint =
|
||||
gambit.type === GambitType.Grouped ? (suggestion as KeyValueGambitSuggestion).key : (suggestion as KeyValueGambitSuggestion).hint;
|
||||
|
||||
return this.gambitSuggestion(key, hint, (negated: boolean | undefined) =>
|
||||
this.suggest(((!!negated && '-') || '') + key + ':', typed || '', (autocomplete?.relativeStart ?? cursorPosition) + Number(negative))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
specificGambitSuggestions(
|
||||
gambitKey: string,
|
||||
gambitQuery: string,
|
||||
uniqueGroups: string[],
|
||||
groupedGambits: IGambit<GambitType.Grouped>[],
|
||||
autocomplete: AutocompleteCheck
|
||||
): JSX.Element[] | null {
|
||||
if (uniqueGroups.includes(gambitKey)) {
|
||||
return groupedGambits
|
||||
.filter((gambit) => gambit.suggestion().group === gambitKey)
|
||||
.flatMap((gambit): string[] =>
|
||||
gambit.suggestion().key instanceof Array ? (gambit.suggestion().key as string[]) : [gambit.suggestion().key as string]
|
||||
)
|
||||
.filter((key) => !gambitQuery || key.toLowerCase().startsWith(gambitQuery))
|
||||
.map((gambit) =>
|
||||
this.gambitSuggestion(gambit, null, () => this.suggest(gambit, gambitQuery, autocomplete.relativeStart + autocomplete.typed.length))
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
gambitSuggestion(key: string, value: string | null, suggest: (negated?: boolean) => void): JSX.Element {
|
||||
return (
|
||||
<li>
|
||||
<span className="Dropdown-item GambitsAutocomplete-gambit">
|
||||
<button type="button" className="Button--ua-reset" onclick={() => suggest()}>
|
||||
<span className="GambitsAutocomplete-gambit-key">
|
||||
{key}
|
||||
{!!value && ':'}
|
||||
</span>
|
||||
{!!value && <span className="GambitsAutocomplete-gambit-value">{value}</span>}
|
||||
</button>
|
||||
{!!value && (
|
||||
<span className="GambitsAutocomplete-gambit-actions">
|
||||
<Button
|
||||
class="Button Button--icon"
|
||||
onclick={() => suggest()}
|
||||
icon="fas fa-plus"
|
||||
aria-label={app.translator.trans('core.forum.search.gambit_plus_button_a11y_label')}
|
||||
/>
|
||||
<Button
|
||||
class="Button Button--icon"
|
||||
onclick={() => suggest(true)}
|
||||
icon="fas fa-minus"
|
||||
aria-label={app.translator.trans('core.forum.search.gambit_minus_button_a11y_label')}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
suggest(text: string, fromTyped: string, start: number) {
|
||||
const $input = this.jqueryInput();
|
||||
|
||||
const query = this.query;
|
||||
const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length);
|
||||
|
||||
this.onchange(replaced);
|
||||
$input[0].focus();
|
||||
setTimeout(() => {
|
||||
$input[0].setSelectionRange(start + text.length, start + text.length);
|
||||
m.redraw();
|
||||
}, 50);
|
||||
|
||||
this.afterSuggest(replaced);
|
||||
}
|
||||
}
|
|
@ -38,12 +38,7 @@ export default class KeyboardNavigatable {
|
|||
* This will be triggered by the Up key.
|
||||
*/
|
||||
onUp(callback: KeyboardEventHandler): KeyboardNavigatable {
|
||||
this.callbacks.set(Keys.ArrowUp, (e) => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
});
|
||||
|
||||
return this;
|
||||
return this.onDirection(callback, Keys.ArrowUp);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -52,7 +47,29 @@ export default class KeyboardNavigatable {
|
|||
* This will be triggered by the Down key.
|
||||
*/
|
||||
onDown(callback: KeyboardEventHandler): KeyboardNavigatable {
|
||||
this.callbacks.set(Keys.ArrowDown, (e) => {
|
||||
return this.onDirection(callback, Keys.ArrowDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when navigating leftwards.
|
||||
*
|
||||
* This will be triggered by the Left key.
|
||||
*/
|
||||
onLeft(callback: KeyboardEventHandler): KeyboardNavigatable {
|
||||
return this.onDirection(callback, Keys.ArrowLeft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a callback to be executed when navigating rightwards.
|
||||
*
|
||||
* This will be triggered by the Right key.
|
||||
*/
|
||||
onRight(callback: KeyboardEventHandler): KeyboardNavigatable {
|
||||
return this.onDirection(callback, Keys.ArrowRight);
|
||||
}
|
||||
|
||||
onDirection(callback: KeyboardEventHandler, direction: Keys): KeyboardNavigatable {
|
||||
this.callbacks.set(direction, (e) => {
|
||||
e.preventDefault();
|
||||
callback(e);
|
||||
});
|
||||
|
|
|
@ -25,6 +25,7 @@ import type PostModel from '../common/models/Post';
|
|||
import extractText from '../common/utils/extractText';
|
||||
import Notices from './components/Notices';
|
||||
import Footer from './components/Footer';
|
||||
import SearchManager from '../common/SearchManager';
|
||||
|
||||
export interface ForumApplicationData extends ApplicationData {}
|
||||
|
||||
|
@ -61,10 +62,9 @@ export default class ForumApplication extends Application {
|
|||
notifications: NotificationListState = new NotificationListState();
|
||||
|
||||
/**
|
||||
* An object which stores previously searched queries and provides convenient
|
||||
* tools for retrieving and managing search values.
|
||||
* An object which stores the global search state and manages search capabilities.
|
||||
*/
|
||||
search: GlobalSearchState = new GlobalSearchState();
|
||||
search: SearchManager<GlobalSearchState> = new SearchManager(new GlobalSearchState());
|
||||
|
||||
/**
|
||||
* An object which controls the state of the composer.
|
||||
|
|
|
@ -54,7 +54,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
|
|||
|
||||
elementAttrs() {
|
||||
return {
|
||||
className: classList('DiscussionListItem', {
|
||||
className: classList('DiscussionListItem', this.attrs.className, {
|
||||
active: this.active(),
|
||||
'DiscussionListItem--hidden': this.attrs.discussion.isHidden(),
|
||||
Slidable: 'ontouchstart' in window,
|
||||
|
@ -163,7 +163,7 @@ export default class DiscussionListItem<CustomAttrs extends IDiscussionListItemA
|
|||
badgesView(): Mithril.Children {
|
||||
const discussion = this.attrs.discussion;
|
||||
|
||||
return <ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>;
|
||||
return <ul className="DiscussionListItem-badges badges badges--packed">{listItems(discussion.badges().toArray())}</ul>;
|
||||
}
|
||||
|
||||
mainView(): Mithril.Children {
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import app from '../../forum/app';
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import app from '../app';
|
||||
import LinkButton from '../../common/components/LinkButton';
|
||||
import Link from '../../common/components/Link';
|
||||
import { SearchSource } from './Search';
|
||||
import type Mithril from 'mithril';
|
||||
import Discussion from '../../common/models/Discussion';
|
||||
import type Discussion from '../../common/models/Discussion';
|
||||
import type { SearchSource } from './Search';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import MinimalDiscussionListItem from './MinimalDiscussionListItem';
|
||||
|
||||
/**
|
||||
* The `DiscussionsSearchSource` finds and displays discussion search results in
|
||||
|
@ -13,15 +13,25 @@ import Discussion from '../../common/models/Discussion';
|
|||
export default class DiscussionsSearchSource implements SearchSource {
|
||||
protected results = new Map<string, Discussion[]>();
|
||||
|
||||
async search(query: string): Promise<void> {
|
||||
public resource: string = 'discussions';
|
||||
|
||||
title(): string {
|
||||
return extractText(app.translator.trans('core.lib.search_source.discussions.heading'));
|
||||
}
|
||||
|
||||
isCached(query: string): boolean {
|
||||
return this.results.has(query.toLowerCase());
|
||||
}
|
||||
|
||||
async search(query: string, limit: number): Promise<void> {
|
||||
query = query.toLowerCase();
|
||||
|
||||
this.results.set(query, []);
|
||||
|
||||
const params = {
|
||||
filter: { q: query },
|
||||
page: { limit: 3 },
|
||||
include: 'mostRelevantPost',
|
||||
page: { limit },
|
||||
include: 'mostRelevantPost,user,firstPost,tags',
|
||||
};
|
||||
|
||||
return app.store.find<Discussion[]>('discussions', params).then((results) => {
|
||||
|
@ -33,34 +43,34 @@ export default class DiscussionsSearchSource implements SearchSource {
|
|||
view(query: string): Array<Mithril.Vnode> {
|
||||
query = query.toLowerCase();
|
||||
|
||||
const results = (this.results.get(query) || []).map((discussion) => {
|
||||
const mostRelevantPost = discussion.mostRelevantPost();
|
||||
|
||||
return (this.results.get(query) || []).map((discussion) => {
|
||||
return (
|
||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()}>
|
||||
<Link href={app.route.discussion(discussion, (mostRelevantPost && mostRelevantPost.number()) || 0)}>
|
||||
<div className="DiscussionSearchResult-title">{highlight(discussion.title(), query)}</div>
|
||||
{!!mostRelevantPost && (
|
||||
<div className="DiscussionSearchResult-excerpt">{highlight(mostRelevantPost.contentPlain() ?? '', query, 100)}</div>
|
||||
)}
|
||||
</Link>
|
||||
<li className="DiscussionSearchResult" data-index={'discussions' + discussion.id()} data-id={discussion.id()}>
|
||||
<MinimalDiscussionListItem discussion={discussion} params={{ q: query }} />
|
||||
</li>
|
||||
);
|
||||
}) as Array<Mithril.Vnode>;
|
||||
}
|
||||
|
||||
const filter = app.store.gambits.apply('discussions', { q: query });
|
||||
fullPage(query: string): Mithril.Vnode {
|
||||
const filter = app.search.gambits.apply('discussions', { q: query });
|
||||
const q = filter.q || null;
|
||||
|
||||
delete filter.q;
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.discussions_heading')}</li>,
|
||||
return (
|
||||
<li>
|
||||
<LinkButton icon="fas fa-search" href={app.route('index', { q, filter })}>
|
||||
{app.translator.trans('core.forum.search.all_discussions_button', { query })}
|
||||
{app.translator.trans('core.lib.search_source.discussions.all_button', { query })}
|
||||
</LinkButton>
|
||||
</li>,
|
||||
...results,
|
||||
];
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
gotoItem(id: string): string | null {
|
||||
const discussion = app.store.getById<Discussion>('discussions', id);
|
||||
|
||||
if (!discussion) return null;
|
||||
|
||||
return app.route.discussion(discussion);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ export default class HeaderSecondary extends Component {
|
|||
items() {
|
||||
const items = new ItemList();
|
||||
|
||||
items.add('search', <Search state={app.search} />, 30);
|
||||
items.add('search', <Search state={app.search.state} />, 30);
|
||||
|
||||
if (app.forum.attribute('showLanguageSelector') && Object.keys(app.data.locales).length > 1) {
|
||||
const locales = [];
|
||||
|
|
|
@ -41,7 +41,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
|||
app.discussions.clear();
|
||||
}
|
||||
|
||||
app.discussions.refreshParams(app.search.params(), (m.route.param('page') && Number(m.route.param('page'))) || 1);
|
||||
app.discussions.refreshParams(app.search.state.params(), (m.route.param('page') && Number(m.route.param('page'))) || 1);
|
||||
|
||||
app.history.push('index', extractText(app.translator.trans('core.forum.header.back_to_index_tooltip')));
|
||||
|
||||
|
@ -154,15 +154,15 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
|||
'sort',
|
||||
<Dropdown
|
||||
buttonClassName="Button"
|
||||
label={sortOptions[app.search.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]}
|
||||
label={sortOptions[app.search.state.params().sort] || Object.keys(sortMap).map((key) => sortOptions[key])[0]}
|
||||
accessibleToggleLabel={app.translator.trans('core.forum.index_sort.toggle_dropdown_accessible_label')}
|
||||
>
|
||||
{Object.keys(sortOptions).map((value) => {
|
||||
const label = sortOptions[value];
|
||||
const active = (app.search.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
const active = (app.search.state.params().sort || Object.keys(sortMap)[0]) === value;
|
||||
|
||||
return (
|
||||
<Button icon={active ? 'fas fa-check' : true} onclick={app.search.changeSort.bind(app.search, value)} active={active}>
|
||||
<Button icon={active ? 'fas fa-check' : true} onclick={() => app.search.state.changeSort(value)} active={active}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
@ -184,6 +184,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
|||
'refresh',
|
||||
<Button
|
||||
title={app.translator.trans('core.forum.index.refresh_tooltip')}
|
||||
aria-label={app.translator.trans('core.forum.index.refresh_tooltip')}
|
||||
icon="fas fa-sync"
|
||||
className="Button Button--icon"
|
||||
onclick={() => {
|
||||
|
@ -201,6 +202,7 @@ export default class IndexPage<CustomAttrs extends IIndexPageAttrs = IIndexPageA
|
|||
'markAllAsRead',
|
||||
<Button
|
||||
title={app.translator.trans('core.forum.index.mark_all_as_read_tooltip')}
|
||||
aria-label={app.translator.trans('core.forum.index.mark_all_as_read_tooltip')}
|
||||
icon="fas fa-check"
|
||||
className="Button Button--icon"
|
||||
onclick={this.markAllAsRead.bind(this)}
|
||||
|
|
|
@ -65,7 +65,7 @@ export default class IndexSidebar<CustomAttrs extends IndexSidebarAttrs = IndexS
|
|||
*/
|
||||
navItems() {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
const params = app.search.stickyParams();
|
||||
const params = app.search.state.stickyParams();
|
||||
|
||||
items.add(
|
||||
'allDiscussions',
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import DiscussionListItem, { IDiscussionListItemAttrs } from './DiscussionListItem';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import Mithril from 'mithril';
|
||||
import Link from '../../common/components/Link';
|
||||
import app from '../app';
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
import classList from '../../common/utils/classList';
|
||||
|
||||
export default class MinimalDiscussionListItem extends DiscussionListItem<IDiscussionListItemAttrs> {
|
||||
elementAttrs() {
|
||||
const attrs = super.elementAttrs();
|
||||
|
||||
attrs.className = classList(attrs.className, 'MinimalDiscussionListItem');
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
viewItems(): ItemList<Mithril.Children> {
|
||||
return super.viewItems().remove('controls').remove('slidableUnderneath');
|
||||
}
|
||||
|
||||
contentItems(): ItemList<Mithril.Children> {
|
||||
return super.contentItems().remove('stats');
|
||||
}
|
||||
|
||||
authorItems(): ItemList<Mithril.Children> {
|
||||
return super.authorItems().remove('badges');
|
||||
}
|
||||
|
||||
mainView(): Mithril.Children {
|
||||
const discussion = this.attrs.discussion;
|
||||
const jumpTo = this.getJumpTo();
|
||||
|
||||
return (
|
||||
<Link href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
|
||||
<h2 className="DiscussionListItem-title">
|
||||
{this.badgesView()}
|
||||
<div>{highlight(discussion.title(), this.highlightRegExp)}</div>
|
||||
</h2>
|
||||
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -38,7 +38,7 @@ export default class PostUser extends Component {
|
|||
{username(user)}
|
||||
</Link>
|
||||
</h3>
|
||||
<ul className="PostUser-badges badges">{listItems(user.badges().toArray())}</ul>
|
||||
<ul className="PostUser-badges badges badges--packed">{listItems(user.badges().toArray())}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export default class ReplyPlaceholder extends Component {
|
|||
<header className="Post-header">
|
||||
<div className="PostUser">
|
||||
<h3 className="PostUser-name">{username(app.session.user)}</h3>
|
||||
<ul className="PostUser-badges badges">{listItems(app.session.user.badges().toArray())}</ul>
|
||||
<ul className="PostUser-badges badges badges--packed">{listItems(app.session.user.badges().toArray())}</ul>
|
||||
</div>
|
||||
</header>
|
||||
<div className="Post-body">
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import app from '../../forum/app';
|
||||
import Component, { ComponentAttrs } from '../../common/Component';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import classList from '../../common/utils/classList';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
|
||||
import SearchState from '../states/SearchState';
|
||||
import Input from '../../common/components/Input';
|
||||
import SearchState from '../../common/states/SearchState';
|
||||
import SearchModal from './SearchModal';
|
||||
import type Mithril from 'mithril';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import DiscussionsSearchSource from './DiscussionsSearchSource';
|
||||
import UsersSearchSource from './UsersSearchSource';
|
||||
import type Mithril from 'mithril';
|
||||
import Icon from '../../common/components/Icon';
|
||||
|
||||
export interface SearchAttrs extends ComponentAttrs {
|
||||
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
|
||||
state: SearchState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `SearchSource` interface defines a section of search results in the
|
||||
|
@ -22,22 +25,42 @@ import Icon from '../../common/components/Icon';
|
|||
* putting together the output from the `view` method of each source.
|
||||
*/
|
||||
export interface SearchSource {
|
||||
/**
|
||||
* The resource type that this search source is responsible for.
|
||||
*/
|
||||
resource: string;
|
||||
|
||||
/**
|
||||
* Get the title for this search source.
|
||||
*/
|
||||
title(): string;
|
||||
|
||||
/**
|
||||
* Check if a query has been cached for this search source.
|
||||
*/
|
||||
isCached(query: string): boolean;
|
||||
|
||||
/**
|
||||
* Make a request to get results for the given query.
|
||||
* The results will be updated internally in the search source, not exposed.
|
||||
*/
|
||||
search(query: string): Promise<void>;
|
||||
search(query: string, limit: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get an array of virtual <li>s that list the search results for the given
|
||||
* query.
|
||||
*/
|
||||
view(query: string): Array<Mithril.Vnode>;
|
||||
}
|
||||
|
||||
export interface SearchAttrs extends ComponentAttrs {
|
||||
/** The type of alert this is. Will be used to give the alert a class name of `Alert--{type}`. */
|
||||
state: SearchState;
|
||||
/**
|
||||
* Get a list item for the full search results page.
|
||||
*/
|
||||
fullPage(query: string): Mithril.Vnode | null;
|
||||
|
||||
/**
|
||||
* Get to the result item page. Only called if each list item has a data-id.
|
||||
*/
|
||||
gotoItem(id: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -53,45 +76,11 @@ export interface SearchAttrs extends ComponentAttrs {
|
|||
* - state: SearchState instance.
|
||||
*/
|
||||
export default class Search<T extends SearchAttrs = SearchAttrs> extends Component<T, SearchState> {
|
||||
/**
|
||||
* The minimum query length before sources are searched.
|
||||
*/
|
||||
protected static MIN_SEARCH_LEN = 3;
|
||||
|
||||
/**
|
||||
* The instance of `SearchState` for this component.
|
||||
*/
|
||||
protected searchState!: SearchState;
|
||||
|
||||
/**
|
||||
* Whether or not the search input has focus.
|
||||
*/
|
||||
protected hasFocus = false;
|
||||
|
||||
/**
|
||||
* An array of SearchSources.
|
||||
*/
|
||||
protected sources?: SearchSource[];
|
||||
|
||||
/**
|
||||
* The number of sources that are still loading results.
|
||||
*/
|
||||
protected loadingSources = 0;
|
||||
|
||||
/**
|
||||
* The index of the currently-selected <li> in the results list. This can be
|
||||
* a unique string (to account for the fact that an item's position may jump
|
||||
* around as new results load), but otherwise it will be numeric (the
|
||||
* sequential position within the list).
|
||||
*/
|
||||
protected index: number = 0;
|
||||
|
||||
protected navigator!: KeyboardNavigatable;
|
||||
|
||||
protected searchTimeout?: number;
|
||||
|
||||
private updateMaxHeightHandler?: () => void;
|
||||
|
||||
oninit(vnode: Mithril.Vnode<T, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
|
@ -99,270 +88,53 @@ export default class Search<T extends SearchAttrs = SearchAttrs> extends Compone
|
|||
}
|
||||
|
||||
view() {
|
||||
const currentSearch = this.searchState.getInitialSearch();
|
||||
|
||||
// Initialize search sources in the view rather than the constructor so
|
||||
// that we have access to app.forum.
|
||||
if (!this.sources) this.sources = this.sourceItems().toArray();
|
||||
|
||||
// Hide the search view if no sources were loaded
|
||||
if (!this.sources.length) return <div></div>;
|
||||
if (this.sourceItems().isEmpty()) return <div></div>;
|
||||
|
||||
const searchLabel = extractText(app.translator.trans('core.forum.header.search_placeholder'));
|
||||
|
||||
const isActive = !!currentSearch;
|
||||
const shouldShowResults = !!(this.searchState.getValue() && this.hasFocus);
|
||||
const shouldShowClearButton = !!(!this.loadingSources && this.searchState.getValue());
|
||||
|
||||
return (
|
||||
<div
|
||||
role="search"
|
||||
aria-label={app.translator.trans('core.forum.header.search_role_label')}
|
||||
className={classList('Search', {
|
||||
open: this.searchState.getValue() && this.hasFocus,
|
||||
focused: this.hasFocus,
|
||||
active: isActive,
|
||||
loading: !!this.loadingSources,
|
||||
})}
|
||||
>
|
||||
<div className="Search-input">
|
||||
<Icon name="fas fa-search Search-input-icon" />
|
||||
<input
|
||||
aria-label={searchLabel}
|
||||
className="FormControl"
|
||||
type="search"
|
||||
placeholder={searchLabel}
|
||||
value={this.searchState.getValue()}
|
||||
oninput={(e: InputEvent) => this.searchState.setValue((e?.target as HTMLInputElement)?.value)}
|
||||
onfocus={() => (this.hasFocus = true)}
|
||||
onblur={() => (this.hasFocus = false)}
|
||||
/>
|
||||
{!!this.loadingSources && <LoadingIndicator size="small" display="inline" containerClassName="Button Button--icon Button--link" />}
|
||||
{shouldShowClearButton && (
|
||||
<button
|
||||
className="Search-clear Button Button--icon Button--link"
|
||||
onclick={this.clear.bind(this)}
|
||||
aria-label={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
|
||||
type="button"
|
||||
>
|
||||
<Icon name={'fas fa-times-circle'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ul
|
||||
className="Dropdown-menu Search-results"
|
||||
aria-hidden={!shouldShowResults || undefined}
|
||||
aria-live={shouldShowResults ? 'polite' : undefined}
|
||||
>
|
||||
{shouldShowResults && this.sources.map((source) => source.view(this.searchState.getValue()))}
|
||||
</ul>
|
||||
<div role="search" className="Search" aria-label={app.translator.trans('core.forum.header.search_role_label')}>
|
||||
<Input
|
||||
type="search"
|
||||
className="Search-input"
|
||||
clearable={this.searchState.getValue()}
|
||||
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
|
||||
prefixIcon="fas fa-search"
|
||||
aria-label={searchLabel}
|
||||
readonly={true}
|
||||
placeholder={searchLabel}
|
||||
value={this.searchState.getValue()}
|
||||
onchange={(value: string) => {
|
||||
if (!value) this.searchState.clear();
|
||||
else this.searchState.setValue(value);
|
||||
}}
|
||||
inputAttrs={{
|
||||
onfocus: () =>
|
||||
setTimeout(() => {
|
||||
this.$('input').blur() &&
|
||||
app.modal.show(() => import('./SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() });
|
||||
}, 150),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
updateMaxHeight() {
|
||||
// Since extensions might add elements above the search box on mobile,
|
||||
// we need to calculate and set the max height dynamically.
|
||||
const resultsElementMargin = 14;
|
||||
const maxHeight =
|
||||
window.innerHeight - this.element.querySelector('.Search-input>.FormControl')!.getBoundingClientRect().bottom - resultsElementMargin;
|
||||
|
||||
this.element.querySelector<HTMLElement>('.Search-results')?.style?.setProperty('max-height', `${maxHeight}px`);
|
||||
}
|
||||
|
||||
onupdate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
// If there are no sources, the search view is not shown.
|
||||
if (!this.sources?.length) return;
|
||||
|
||||
this.updateMaxHeight();
|
||||
}
|
||||
|
||||
oncreate(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// If there are no sources, we shouldn't initialize logic for
|
||||
// search elements, as they will not be shown.
|
||||
if (!this.sources?.length) return;
|
||||
|
||||
const search = this;
|
||||
const state = this.searchState;
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
this.$('.Search-results')
|
||||
.on('mousedown', (e) => e.preventDefault())
|
||||
.on('click', () => this.$('input').trigger('blur'))
|
||||
|
||||
// Whenever the mouse is hovered over a search result, highlight it.
|
||||
.on('mouseenter', '> li:not(.Dropdown-header)', function () {
|
||||
search.setIndex(search.selectableItems().index(this));
|
||||
});
|
||||
|
||||
const $input = this.$('input') as JQuery<HTMLInputElement>;
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
this.navigator
|
||||
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
|
||||
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
|
||||
.onSelect(this.selectResult.bind(this), true)
|
||||
.onCancel(this.clear.bind(this))
|
||||
.bindTo($input);
|
||||
|
||||
// Handle input key events on the search input, triggering results to load.
|
||||
$input
|
||||
.on('input focus', function () {
|
||||
const query = this.value.toLowerCase();
|
||||
|
||||
if (!query) return;
|
||||
|
||||
if (search.searchTimeout) clearTimeout(search.searchTimeout);
|
||||
search.searchTimeout = window.setTimeout(() => {
|
||||
if (state.isCached(query)) return;
|
||||
|
||||
if (query.length >= (search.constructor as typeof Search).MIN_SEARCH_LEN) {
|
||||
search.sources?.map((source) => {
|
||||
if (!source.search) return;
|
||||
|
||||
search.loadingSources++;
|
||||
|
||||
source.search(query).then(() => {
|
||||
search.loadingSources = Math.max(0, search.loadingSources - 1);
|
||||
m.redraw();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
state.cache(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
})
|
||||
|
||||
.on('focus', function () {
|
||||
$(this)
|
||||
.one('mouseup', (e) => e.preventDefault())
|
||||
.trigger('select');
|
||||
});
|
||||
|
||||
this.updateMaxHeightHandler = this.updateMaxHeight.bind(this);
|
||||
window.addEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
|
||||
onremove(vnode: Mithril.VnodeDOM<T, this>) {
|
||||
super.onremove(vnode);
|
||||
|
||||
if (this.updateMaxHeightHandler) {
|
||||
window.removeEventListener('resize', this.updateMaxHeightHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the currently selected search result and close the list.
|
||||
*/
|
||||
selectResult() {
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
|
||||
this.loadingSources = 0;
|
||||
|
||||
const selectedUrl = this.getItem(this.index).find('a').attr('href');
|
||||
if (this.searchState.getValue() && selectedUrl) {
|
||||
m.route.set(selectedUrl);
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
this.$('input').blur();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search
|
||||
*/
|
||||
clear() {
|
||||
this.searchState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an item list of SearchSources.
|
||||
* A list of search sources that can be used to query for search results.
|
||||
*/
|
||||
sourceItems(): ItemList<SearchSource> {
|
||||
const items = new ItemList<SearchSource>();
|
||||
|
||||
if (app.forum.attribute('canViewForum')) items.add('discussions', new DiscussionsSearchSource());
|
||||
if (app.forum.attribute('canSearchUsers')) items.add('users', new UsersSearchSource());
|
||||
if (app.forum.attribute('canViewForum')) {
|
||||
items.add('discussions', new DiscussionsSearchSource());
|
||||
}
|
||||
|
||||
if (app.forum.attribute('canSearchUsers')) {
|
||||
items.add('users', new UsersSearchSource());
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the search result items that are selectable.
|
||||
*/
|
||||
selectableItems(): JQuery {
|
||||
return this.$('.Search-results > li:not(.Dropdown-header)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of the currently selected search result item.
|
||||
* Returns zero if not found.
|
||||
*/
|
||||
getCurrentNumericIndex(): number {
|
||||
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <li> in the search results with the given index (numeric or named).
|
||||
*/
|
||||
getItem(index: number): JQuery {
|
||||
const $items = this.selectableItems();
|
||||
let $item = $items.filter(`[data-index="${index}"]`);
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently-selected search result item to the one with the given
|
||||
* index.
|
||||
*/
|
||||
setIndex(index: number, scrollToItem: boolean = false) {
|
||||
const $items = this.selectableItems();
|
||||
const $dropdown = $items.parent();
|
||||
|
||||
let fixedIndex = index;
|
||||
if (index < 0) {
|
||||
fixedIndex = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
fixedIndex = 0;
|
||||
}
|
||||
|
||||
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
||||
|
||||
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
|
||||
|
||||
if (scrollToItem) {
|
||||
const dropdownScroll = $dropdown.scrollTop()!;
|
||||
const dropdownTop = $dropdown.offset()!.top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
|
||||
const itemTop = $item.offset()!.top;
|
||||
const itemBottom = itemTop + $item.outerHeight()!;
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({ scrollTop }, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
435
framework/core/js/src/forum/components/SearchModal.tsx
Normal file
435
framework/core/js/src/forum/components/SearchModal.tsx
Normal file
|
@ -0,0 +1,435 @@
|
|||
import app from '../app';
|
||||
import type { IFormModalAttrs } from '../../common/components/FormModal';
|
||||
import FormModal from '../../common/components/FormModal';
|
||||
import type Mithril from 'mithril';
|
||||
import type SearchState from '../../common/states/SearchState';
|
||||
import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable';
|
||||
import SearchManager from '../../common/SearchManager';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import Input from '../../common/components/Input';
|
||||
import Button from '../../common/components/Button';
|
||||
import Stream from '../../common/utils/Stream';
|
||||
import InfoTile from '../../common/components/InfoTile';
|
||||
import LoadingIndicator from '../../common/components/LoadingIndicator';
|
||||
import type { SearchSource } from './Search';
|
||||
import type IGambit from '../../common/query/IGambit';
|
||||
import ItemList from '../../common/utils/ItemList';
|
||||
import GambitsAutocomplete from '../../common/utils/GambitsAutocomplete';
|
||||
|
||||
export interface ISearchModalAttrs extends IFormModalAttrs {
|
||||
onchange: (value: string) => void;
|
||||
searchState: SearchState;
|
||||
sources: SearchSource[];
|
||||
}
|
||||
|
||||
export default class SearchModal<CustomAttrs extends ISearchModalAttrs = ISearchModalAttrs> extends FormModal<CustomAttrs> {
|
||||
public static LIMIT = 6;
|
||||
|
||||
protected searchState!: SearchState;
|
||||
|
||||
protected query!: Stream<string>;
|
||||
|
||||
/**
|
||||
* An array of SearchSources.
|
||||
*/
|
||||
protected sources!: SearchSource[];
|
||||
|
||||
/**
|
||||
* The key of the currently-active search source.
|
||||
*/
|
||||
protected activeSource!: Stream<SearchSource>;
|
||||
|
||||
/**
|
||||
* The sources that are still loading results.
|
||||
*/
|
||||
protected loadingSources: string[] = [];
|
||||
|
||||
/**
|
||||
* The index of the currently-selected <li> in the results list. This can be
|
||||
* a unique string (to account for the fact that an item's position may jump
|
||||
* around as new results load), but otherwise it will be numeric (the
|
||||
* sequential position within the list).
|
||||
*/
|
||||
protected index: number = 0;
|
||||
|
||||
protected navigator!: KeyboardNavigatable;
|
||||
|
||||
protected searchTimeout?: number;
|
||||
|
||||
protected inputScroll = Stream(0);
|
||||
|
||||
protected gambitsAutocomplete: Record<string, GambitsAutocomplete> = {};
|
||||
|
||||
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||
super.oninit(vnode);
|
||||
|
||||
this.searchState = this.attrs.searchState;
|
||||
this.sources = this.attrs.sources;
|
||||
this.query = Stream(this.searchState.getValue() || '');
|
||||
}
|
||||
|
||||
title(): Mithril.Children {
|
||||
return app.translator.trans('core.forum.search.title');
|
||||
}
|
||||
|
||||
className(): string {
|
||||
return 'SearchModal Modal--flat';
|
||||
}
|
||||
|
||||
content(): Mithril.Children {
|
||||
// Initialize the active source.
|
||||
if (!this.activeSource) this.activeSource = Stream(this.sources[0]);
|
||||
|
||||
this.gambitsAutocomplete[this.activeSource().resource] ||= new GambitsAutocomplete(
|
||||
this.activeSource().resource,
|
||||
() => this.inputElement(),
|
||||
this.query,
|
||||
(value: string) => this.search(value)
|
||||
);
|
||||
|
||||
const searchLabel = extractText(app.translator.trans('core.forum.search.placeholder'));
|
||||
|
||||
return (
|
||||
<div className="Modal-body SearchModal-body">
|
||||
<div className="SearchModal-form">
|
||||
<Input
|
||||
key="search"
|
||||
type="search"
|
||||
loading={!!this.loadingSources.length}
|
||||
clearable={true}
|
||||
clearLabel={app.translator.trans('core.forum.header.search_clear_button_accessible_label')}
|
||||
prefixIcon="fas fa-search"
|
||||
aria-label={searchLabel}
|
||||
placeholder={searchLabel}
|
||||
value={this.query()}
|
||||
onchange={(value: string) => {
|
||||
this.query(value);
|
||||
this.inputScroll(this.inputElement()[0]?.scrollLeft ?? 0);
|
||||
}}
|
||||
inputAttrs={{ className: 'SearchModal-input' }}
|
||||
renderInput={(attrs: any) => (
|
||||
<>
|
||||
<input {...attrs} onscroll={(e: Event) => this.inputScroll((e.target as HTMLInputElement).scrollLeft)} />
|
||||
<div className="SearchModal-visual-wrapper">
|
||||
<div className="SearchModal-visual-input" style={{ left: '-' + this.inputScroll() + 'px' }}>
|
||||
{this.gambifyInput()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{this.tabs()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
tabs(): JSX.Element {
|
||||
return (
|
||||
<div className="SearchModal-tabs">
|
||||
<div className="SearchModal-tabs-nav">{this.tabItems().toArray()}</div>
|
||||
<div className="SearchModal-tabs-content">{this.activeTabItems().toArray()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
tabItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
this.sources?.map((source, index) =>
|
||||
items.add(
|
||||
source.resource,
|
||||
<Button className="Button Button--link" active={this.activeSource() === source} onclick={() => this.switchSource(source)}>
|
||||
{source.title()}
|
||||
</Button>,
|
||||
100 - index
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
activeTabItems(): ItemList<Mithril.Children> {
|
||||
const items = new ItemList<Mithril.Children>();
|
||||
|
||||
const loading = this.loadingSources.includes(this.activeSource().resource);
|
||||
const shouldShowResults = !!this.query() && !loading;
|
||||
const gambits = this.gambits();
|
||||
const fullPageLink = this.activeSource().fullPage(this.query());
|
||||
const results = this.activeSource()?.view(this.query());
|
||||
|
||||
if (shouldShowResults && fullPageLink) {
|
||||
items.add(
|
||||
'fullPageLink',
|
||||
<div className="SearchModal-section">
|
||||
<hr className="Modal-divider" />
|
||||
<ul className="Dropdown-menu SearchModal-fullPage">{fullPageLink}</ul>
|
||||
</div>,
|
||||
80
|
||||
);
|
||||
}
|
||||
|
||||
if (!!gambits.length) {
|
||||
items.add(
|
||||
'gambits',
|
||||
<div className="SearchModal-section">
|
||||
<hr className="Modal-divider" />
|
||||
<ul className="Dropdown-menu SearchModal-options" aria-live={gambits.length ? 'polite' : undefined}>
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.options_heading')}</li>
|
||||
{gambits}
|
||||
</ul>
|
||||
</div>,
|
||||
60
|
||||
);
|
||||
}
|
||||
|
||||
items.add(
|
||||
'results',
|
||||
<div className="SearchModal-section">
|
||||
<hr className="Modal-divider" />
|
||||
<ul className="Dropdown-menu SearchModal-results" aria-live={shouldShowResults ? 'polite' : undefined}>
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.preview_heading')}</li>
|
||||
{!shouldShowResults && (
|
||||
<li className="Dropdown-message">
|
||||
<InfoTile icon="fas fa-search">{app.translator.trans('core.forum.search.no_search_text')}</InfoTile>
|
||||
</li>
|
||||
)}
|
||||
{shouldShowResults && results}
|
||||
{shouldShowResults && !results?.length && (
|
||||
<li className="Dropdown-message">
|
||||
<InfoTile icon="far fa-tired">{app.translator.trans('core.forum.search.no_results_text')}</InfoTile>
|
||||
</li>
|
||||
)}
|
||||
{loading && (
|
||||
<li className="Dropdown-message">
|
||||
<LoadingIndicator />
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
40
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
switchSource(source: SearchSource) {
|
||||
if (this.activeSource() !== source) {
|
||||
this.activeSource(source);
|
||||
this.search(this.query());
|
||||
this.inputElement().focus();
|
||||
m.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
gambits(): JSX.Element[] {
|
||||
return this.gambitsAutocomplete[this.activeSource().resource].suggestions(this.query());
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a simple search text to wrap valid gambits in a mark tag.
|
||||
* @example `lorem ipsum is:unread dolor` => `lorem ipsum <mark>is:unread</mark> dolor`
|
||||
*/
|
||||
gambifyInput(): Mithril.Children {
|
||||
const query = this.query();
|
||||
let marked = query;
|
||||
|
||||
app.search.gambits.match(this.activeSource().resource, query, (gambit: IGambit, matches: string[], negate: boolean, bit: string) => {
|
||||
marked = marked.replace(bit, `<mark>${bit}</mark>`);
|
||||
});
|
||||
|
||||
const jsx: Mithril.ChildArray = [];
|
||||
marked.split(/(<mark>.*?<\/mark>)/).forEach((chunk) => {
|
||||
if (chunk.startsWith('<mark>')) {
|
||||
jsx.push(<mark>{chunk.replace(/<\/?mark>/g, '')}</mark>);
|
||||
} else {
|
||||
jsx.push(chunk);
|
||||
}
|
||||
});
|
||||
|
||||
return jsx;
|
||||
}
|
||||
|
||||
onupdate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.onupdate(vnode);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
const component = this;
|
||||
this.$('.Dropdown-menu')
|
||||
// Whenever the mouse is hovered over a search result, highlight it.
|
||||
.on('mouseenter', '> li:not(.Dropdown-header):not(.Dropdown-message)', function () {
|
||||
component.setIndex(component.selectableItems().index(this));
|
||||
});
|
||||
|
||||
// If there are no sources, the search view is not shown.
|
||||
if (!this.sources?.length) return;
|
||||
}
|
||||
|
||||
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
super.oncreate(vnode);
|
||||
|
||||
// If there are no sources, we shouldn't initialize logic for
|
||||
// search elements, as they will not be shown.
|
||||
if (!this.sources?.length) return;
|
||||
|
||||
const search = this.search.bind(this);
|
||||
|
||||
// Highlight the item that is currently selected.
|
||||
this.setIndex(this.getCurrentNumericIndex());
|
||||
|
||||
const $input = this.inputElement() as JQuery<HTMLInputElement>;
|
||||
|
||||
this.navigator = new KeyboardNavigatable();
|
||||
this.navigator
|
||||
.onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true))
|
||||
.onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true))
|
||||
.onSelect(this.selectResult.bind(this), true)
|
||||
.onCancel(this.clear.bind(this))
|
||||
.bindTo($input);
|
||||
|
||||
// Handle input key events on the search input, triggering results to load.
|
||||
$input.on('input focus', function () {
|
||||
search(this.value.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
onremove(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
|
||||
this.searchState.setValue(this.query());
|
||||
super.onremove(vnode);
|
||||
}
|
||||
|
||||
search(query: string) {
|
||||
if (!query) return;
|
||||
|
||||
const source = this.activeSource();
|
||||
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
|
||||
this.searchTimeout = window.setTimeout(() => {
|
||||
if (source.isCached(query)) return;
|
||||
|
||||
if (query.length >= SearchManager.MIN_SEARCH_LEN) {
|
||||
if (!source.search) return;
|
||||
|
||||
this.loadingSources.push(source.resource);
|
||||
|
||||
source.search(query, SearchModal.LIMIT).then(() => {
|
||||
this.loadingSources = this.loadingSources.filter((resource) => resource !== source.resource);
|
||||
m.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
this.searchState.cache(query);
|
||||
m.redraw();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the currently selected search result and close the list.
|
||||
*/
|
||||
selectResult() {
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
|
||||
this.loadingSources = [];
|
||||
|
||||
const item = this.getItem(this.index);
|
||||
const isResult = !!item.attr('data-id');
|
||||
let selectedUrl = null;
|
||||
|
||||
if (isResult) {
|
||||
const id = item.attr('data-id');
|
||||
selectedUrl = id && this.activeSource().gotoItem(id as string);
|
||||
} else if (item.find('a').length) {
|
||||
selectedUrl = item.find('a').attr('href');
|
||||
}
|
||||
|
||||
const query = this.query();
|
||||
|
||||
if (query && selectedUrl) {
|
||||
m.route.set(selectedUrl);
|
||||
} else {
|
||||
item.find('button')[0].click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the search
|
||||
*/
|
||||
clear() {
|
||||
this.query('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the search result items that are selectable.
|
||||
*/
|
||||
selectableItems(): JQuery {
|
||||
return this.$('.Dropdown-menu > li:not(.Dropdown-header):not(.Dropdown-message)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position of the currently selected search result item.
|
||||
* Returns zero if not found.
|
||||
*/
|
||||
getCurrentNumericIndex(): number {
|
||||
return Math.max(0, this.selectableItems().index(this.getItem(this.index)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the <li> in the search results with the given index (numeric or named).
|
||||
*/
|
||||
getItem(index: number): JQuery {
|
||||
const $items = this.selectableItems();
|
||||
let $item = $items.filter(`[data-index="${index}"]`);
|
||||
|
||||
if (!$item.length) {
|
||||
$item = $items.eq(index);
|
||||
}
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently-selected search result item to the one with the given
|
||||
* index.
|
||||
*/
|
||||
setIndex(index: number, scrollToItem: boolean = false) {
|
||||
const $items = this.selectableItems();
|
||||
const $dropdown = $items.parent();
|
||||
|
||||
let fixedIndex = index;
|
||||
if (index < 0) {
|
||||
fixedIndex = $items.length - 1;
|
||||
} else if (index >= $items.length) {
|
||||
fixedIndex = 0;
|
||||
}
|
||||
|
||||
const $item = $items.removeClass('active').eq(fixedIndex).addClass('active');
|
||||
|
||||
this.index = parseInt($item.attr('data-index') as string) || fixedIndex;
|
||||
|
||||
if (scrollToItem && $dropdown) {
|
||||
const dropdownScroll = $dropdown.scrollTop()!;
|
||||
const dropdownTop = $dropdown.offset()!.top;
|
||||
const dropdownBottom = dropdownTop + $dropdown.outerHeight()!;
|
||||
const itemTop = $item.offset()!.top;
|
||||
const itemBottom = itemTop + $item.outerHeight()!;
|
||||
|
||||
let scrollTop;
|
||||
if (itemTop < dropdownTop) {
|
||||
scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10);
|
||||
} else if (itemBottom > dropdownBottom) {
|
||||
scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10);
|
||||
}
|
||||
|
||||
if (typeof scrollTop !== 'undefined') {
|
||||
$dropdown.stop(true).animate({ scrollTop }, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inputElement(): JQuery<HTMLInputElement> {
|
||||
return this.$('.SearchModal-input') as JQuery<HTMLInputElement>;
|
||||
}
|
||||
}
|
|
@ -1,12 +1,14 @@
|
|||
import type Mithril from 'mithril';
|
||||
|
||||
import app from '../../forum/app';
|
||||
import app from '../app';
|
||||
import highlight from '../../common/helpers/highlight';
|
||||
import username from '../../common/helpers/username';
|
||||
import Link from '../../common/components/Link';
|
||||
import { SearchSource } from './Search';
|
||||
import User from '../../common/models/User';
|
||||
import type User from '../../common/models/User';
|
||||
import Avatar from '../../common/components/Avatar';
|
||||
import type { SearchSource } from './Search';
|
||||
import extractText from '../../common/utils/extractText';
|
||||
import listItems from '../../common/helpers/listItems';
|
||||
|
||||
/**
|
||||
* The `UsersSearchSource` finds and displays user search results in the search
|
||||
|
@ -15,11 +17,21 @@ import Avatar from '../../common/components/Avatar';
|
|||
export default class UsersSearchResults implements SearchSource {
|
||||
protected results = new Map<string, User[]>();
|
||||
|
||||
async search(query: string): Promise<void> {
|
||||
public resource: string = 'users';
|
||||
|
||||
title(): string {
|
||||
return extractText(app.translator.trans('core.lib.search_source.users.heading'));
|
||||
}
|
||||
|
||||
isCached(query: string): boolean {
|
||||
return this.results.has(query.toLowerCase());
|
||||
}
|
||||
|
||||
async search(query: string, limit: number): Promise<void> {
|
||||
return app.store
|
||||
.find<User[]>('users', {
|
||||
filter: { q: query },
|
||||
page: { limit: 5 },
|
||||
page: { limit },
|
||||
})
|
||||
.then((results) => {
|
||||
this.results.set(query, results);
|
||||
|
@ -41,20 +53,32 @@ export default class UsersSearchResults implements SearchSource {
|
|||
|
||||
if (!results.length) return [];
|
||||
|
||||
return [
|
||||
<li className="Dropdown-header">{app.translator.trans('core.forum.search.users_heading')}</li>,
|
||||
...results.map((user) => {
|
||||
const name = username(user, (name: string) => highlight(name, query));
|
||||
return results.map((user) => {
|
||||
const name = username(user, (name: string) => highlight(name, query));
|
||||
|
||||
return (
|
||||
<li className="UserSearchResult" data-index={'users' + user.id()}>
|
||||
<Link href={app.route.user(user)}>
|
||||
<Avatar user={user} />
|
||||
return (
|
||||
<li className="UserSearchResult" data-index={'users' + user.id()} data-id={user.id()}>
|
||||
<Link href={app.route.user(user)}>
|
||||
<Avatar user={user} />
|
||||
<div className="UserSearchResult-name">
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}),
|
||||
];
|
||||
<div className="badges badges--packed">{listItems(user.badges().toArray())}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fullPage(query: string): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
gotoItem(id: string): string | null {
|
||||
const user = app.store.getById<User>('users', id);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return app.route.user(user);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,6 @@ import './states/DiscussionListState';
|
|||
import './states/GlobalSearchState';
|
||||
import './states/NotificationListState';
|
||||
import './states/PostStreamState';
|
||||
import './states/SearchState';
|
||||
import './components/AffixedSidebar';
|
||||
import './components/DiscussionPage';
|
||||
import './components/DiscussionListPane';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import app from '../../forum/app';
|
||||
import setRouteWithForcedRefresh from '../../common/utils/setRouteWithForcedRefresh';
|
||||
import SearchState from './SearchState';
|
||||
import SearchState from '../../common/states/SearchState';
|
||||
|
||||
type SearchParams = Record<string, string>;
|
||||
|
||||
|
@ -43,7 +43,7 @@ export default class GlobalSearchState extends SearchState {
|
|||
const q = this.params().q || '';
|
||||
const filter = this.params().filter || {};
|
||||
|
||||
return app.store.gambits.from('users', app.store.gambits.from('discussions', q, filter), filter).trim();
|
||||
return app.search.gambits.from('users', app.search.gambits.from('discussions', q, filter), filter).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -146,7 +146,6 @@
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
.Search-input,
|
||||
.SearchBar {
|
||||
max-width: 215px;
|
||||
margin: 0 auto;
|
||||
|
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
&-header {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
&-totalUsers {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
|
|
|
@ -31,6 +31,21 @@
|
|||
&, > li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&--packed {
|
||||
--packing-space: 10px;
|
||||
padding-inline-start: var(--packing-space);
|
||||
|
||||
.Badge {
|
||||
margin-left: calc(~"0px - var(--packing-space)");
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Badge--hidden {
|
||||
|
|
|
@ -208,7 +208,7 @@
|
|||
border-radius: 18px;
|
||||
|
||||
.Avatar {
|
||||
margin: -2px 5px -2px -6px;
|
||||
margin: -2px 0 -2px -6px;
|
||||
.Avatar--size(24px);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
}
|
||||
}
|
||||
&.active {
|
||||
> a, > button {
|
||||
> a, > button, > .Dropdown-item {
|
||||
background: var(--control-bg);
|
||||
}
|
||||
}
|
||||
|
@ -230,3 +230,45 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.AutocompleteDropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.GambitsAutocomplete {
|
||||
&-gambit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> button {
|
||||
flex-grow: 1;
|
||||
cursor: pointer;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 15px;
|
||||
margin: -8px 0 -8px -15px;
|
||||
}
|
||||
&-key {
|
||||
font-weight: bold;
|
||||
}
|
||||
&-value {
|
||||
color: var(--control-color);
|
||||
}
|
||||
&-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
color: var(--control-color);
|
||||
visibility: hidden;
|
||||
margin-inline-end: -14px;
|
||||
|
||||
.Button {
|
||||
margin: -8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li.active .GambitsAutocomplete-gambit-actions {
|
||||
visibility: visible;
|
||||
}
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
padding: 8px 13px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--control-color);
|
||||
background-color: var(--control-bg);
|
||||
color: var(--form-control-color, var(--control-color));
|
||||
background-color: var(--form-control-bg, var(--control-bg));
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition);
|
||||
|
|
14
framework/core/less/common/InfoTile.less
Normal file
14
framework/core/less/common/InfoTile.less
Normal file
|
@ -0,0 +1,14 @@
|
|||
.InfoTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 24px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--control-color);
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
|
||||
.icon {
|
||||
color: var(--control-muted-color);
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
77
framework/core/less/common/Input.less
Normal file
77
framework/core/less/common/Input.less
Normal file
|
@ -0,0 +1,77 @@
|
|||
.Input {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--muted-color);
|
||||
|
||||
&-prefix-icon {
|
||||
margin-right: -36px;
|
||||
width: 36px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.FormControl {
|
||||
transition: var(--transition), width 0.4s;
|
||||
box-sizing: inherit !important;
|
||||
|
||||
&[readonly] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--withPrefix {
|
||||
.FormControl {
|
||||
padding-left: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&-clear {
|
||||
// It looks very weird due to the padding given to the button..
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// ...so we display the ring around the icon inside the button, with an offset
|
||||
.add-keyboard-focus-ring-nearby("> *");
|
||||
.add-keyboard-focus-ring-nearby-offset("> *", 4px);
|
||||
}
|
||||
|
||||
&--withClear {
|
||||
// TODO v2.0 check if this is supported by Firefox,
|
||||
// if so, consider switching to it.
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.FormControl {
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Button, &-prefix-icon {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.Button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
margin-left: -36px;
|
||||
width: 36px !important;
|
||||
|
||||
&.LoadingIndicator {
|
||||
width: var(--size) !important;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -94,8 +94,8 @@
|
|||
color: var(--control-color);
|
||||
|
||||
.FormControl {
|
||||
background-color: var(--body-bg);
|
||||
color: var(--text-color);
|
||||
--form-control-bg: var(--body-bg);
|
||||
--form-control-color: var(--text-color);
|
||||
}
|
||||
|
||||
.Form--centered {
|
||||
|
@ -117,6 +117,12 @@
|
|||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.Modal-divider {
|
||||
border-width: 1px;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
|
||||
.Modal--inverted {
|
||||
.Modal-header {
|
||||
background-color: var(--control-bg);
|
||||
|
@ -128,6 +134,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
.Modal--flat {
|
||||
.Modal-header {
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
padding: 18px 18px 0 18px;
|
||||
color: var(--control-color);
|
||||
}
|
||||
.Modal-body {
|
||||
background-color: transparent;
|
||||
color: unset;
|
||||
padding: 18px 16px;
|
||||
|
||||
.FormControl:not(:focus) {
|
||||
--form-control-bg: var(--control-bg);
|
||||
--form-control-color: var(--control-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone {
|
||||
.ModalManager {
|
||||
position: fixed;
|
||||
|
|
|
@ -1,105 +1,146 @@
|
|||
.Search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// TODO v2.0 check if this is supported by Firefox,
|
||||
// if so, consider switching to it.
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
.DiscussionSearchResult {
|
||||
> .DiscussionListItem {
|
||||
--discussion-list-item-bg-hover: var(--control-bg);
|
||||
margin: 0 -16px;
|
||||
border-radius: 0;
|
||||
|
||||
> .DiscussionListItem-content {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-clear {
|
||||
// It looks very weird due to the padding given to the button..
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// ...so we display the ring around the icon inside the button, with an offset
|
||||
.add-keyboard-focus-ring-nearby("> *");
|
||||
.add-keyboard-focus-ring-nearby-offset("> *", 4px);
|
||||
&.active > .DiscussionListItem {
|
||||
background-color: var(--discussion-list-item-bg-hover);
|
||||
}
|
||||
}
|
||||
@media @tablet-up {
|
||||
.Search {
|
||||
transition: margin-left 0.4s;
|
||||
|
||||
&.focused {
|
||||
margin-left: -400px;
|
||||
.UserSearchResult {
|
||||
margin: 0 -16px;
|
||||
|
||||
input,
|
||||
.Search-results {
|
||||
width: 400px;
|
||||
.badges {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
> a {
|
||||
gap: 6px;
|
||||
padding: 12px 15px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.Avatar {
|
||||
--size: 36px;
|
||||
}
|
||||
.username {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchModal {
|
||||
&-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
&-tabs {
|
||||
&-nav + .Modal-divider {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&-nav {
|
||||
margin-bottom: -1px;
|
||||
display: flex;
|
||||
column-gap: 4px;
|
||||
padding: 0 14px;
|
||||
|
||||
> .Button {
|
||||
border-radius: 0;
|
||||
font-size: 15px;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 2px solid;
|
||||
border-color: transparent;
|
||||
|
||||
&[active] {
|
||||
--button-color: var(--text-color);
|
||||
--link-color: var(--text-color);
|
||||
border-color: var(--primary-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
.Dropdown--expanded();
|
||||
|
||||
.Dropdown-header {
|
||||
color: var(--muted-more-color);
|
||||
}
|
||||
|
||||
> .SearchModal-section:first-of-type .Modal-divider {
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.Search-results {
|
||||
overflow: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
|
||||
@media @phone {
|
||||
left: 0;
|
||||
&-input {
|
||||
background: transparent !important;
|
||||
height: 42px;
|
||||
border-color: var(--form-control-color);
|
||||
}
|
||||
|
||||
> li > a {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
mark {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Search-input {
|
||||
overflow: hidden;
|
||||
color: var(--muted-color);
|
||||
|
||||
&-icon {
|
||||
margin-right: -36px;
|
||||
width: 36px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
padding: 8px 0;
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
input {
|
||||
width: 225px;
|
||||
padding-left: 32px;
|
||||
padding-right: 32px;
|
||||
transition: var(--transition), width 0.4s;
|
||||
box-sizing: inherit !important;
|
||||
}
|
||||
|
||||
.LoadingIndicator-container {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.Button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
margin-left: -36px;
|
||||
width: 36px !important;
|
||||
|
||||
&.LoadingIndicator {
|
||||
width: var(--size) !important;
|
||||
&-results {
|
||||
mark {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
color: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.Badge {
|
||||
--packing-space: 8px;
|
||||
--size: 18px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-fullPage .LinkButton {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.Dropdown-menu>li>a, .Dropdown-menu>li>button, .Dropdown-menu>li>span {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.Input {
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.DiscussionSearchResult-excerpt {
|
||||
margin-top: 3px;
|
||||
color: var(--muted-color);
|
||||
font-size: 11px;
|
||||
.SearchModal-visual-wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin-left: 34px;
|
||||
margin-right: 32px;
|
||||
line-height: 42px;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.UserSearchResult .Avatar {
|
||||
.Avatar--size(24px);
|
||||
margin: -2px 10px -2px 0;
|
||||
|
||||
.SearchModal-visual-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.SearchModal-visual-input mark {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--control-bg-shaded);
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
@import "EditUserModal";
|
||||
@import "Form";
|
||||
@import "FormControl";
|
||||
@import "Input";
|
||||
@import "InfoTile";
|
||||
@import "LoadingIndicator";
|
||||
@import "Modal";
|
||||
@import "Navigation";
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
--control-danger-bg: @control-danger-bg;
|
||||
--control-danger-color: @control-danger-color;
|
||||
--control-body-bg-mix: mix(@control-bg, @body-bg, 50%);
|
||||
--control-muted-color: lighten(@control-color, 40%);
|
||||
|
||||
--error-color: @error-color;
|
||||
|
||||
|
|
|
@ -37,12 +37,6 @@
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: -2px;
|
||||
|
||||
.Badge {
|
||||
margin-left: -10px;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
.DiscussionListItem-title {
|
||||
margin: 0 0 3px;
|
||||
|
@ -115,6 +109,41 @@
|
|||
}
|
||||
}
|
||||
|
||||
.MinimalDiscussionListItem {
|
||||
.DiscussionListItem-info .item-excerpt {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.DiscussionListItem-badges {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
inset: unset;
|
||||
width: auto;
|
||||
text-align: start;
|
||||
padding-inline-start: 8px;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.DiscussionListItem-title {
|
||||
display: flex;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
|
||||
> div {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (any-hover: none) {
|
||||
.DiscussionListItem-controls > .Dropdown-toggle {
|
||||
display: none;
|
||||
|
|
|
@ -84,12 +84,6 @@
|
|||
text-align: right;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
|
||||
.Badge {
|
||||
margin-left: -10px;
|
||||
position: relative;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.Post-body {
|
||||
|
@ -400,6 +394,9 @@
|
|||
display: grid;
|
||||
grid-template-columns: var(--avatar-column-width) 1fr;
|
||||
}
|
||||
.Post-main {
|
||||
min-width: 0;
|
||||
}
|
||||
.CommentPost:not(.Post--hidden), .ReplyPlaceholder {
|
||||
min-height: 64px + 40px; // avatar height + padding
|
||||
}
|
||||
|
|
|
@ -519,11 +519,16 @@ core:
|
|||
submit_button: => core.ref.rename
|
||||
title: Rename Discussion
|
||||
|
||||
# These translations are used by the search results dropdown list.
|
||||
# These translations are used by the search modal.
|
||||
search:
|
||||
all_discussions_button: 'Search all discussions for "{query}"'
|
||||
discussions_heading: => core.ref.discussions
|
||||
users_heading: => core.ref.users
|
||||
gambit_plus_button_a11y_label: Add a positive filter
|
||||
gambit_minus_button_a11y_label: Add a negative filter
|
||||
title: Search
|
||||
no_results_text: It looks like there are no results here.
|
||||
no_search_text: You have not searched for anything yet.
|
||||
options_heading: Search options
|
||||
placeholder: Search...
|
||||
preview_heading: Search preview
|
||||
|
||||
# These translations are used in the Security page.
|
||||
security:
|
||||
|
@ -667,6 +672,10 @@ core:
|
|||
rate_limit_exceeded_message: You're going a little too quickly. Please try again in a few seconds.
|
||||
render_failed_message: Sorry, we encountered an error while displaying this content. If you're a user, please try again later. If you're an administrator, take a look in your Flarum log files for more information.
|
||||
|
||||
# These translations are used in the input component.
|
||||
input:
|
||||
clear_button: Clear input
|
||||
|
||||
# These translations are used in the loading indicator component.
|
||||
loading_indicator:
|
||||
accessible_label: => core.ref.loading
|
||||
|
@ -689,6 +698,36 @@ core:
|
|||
kilo_text: K
|
||||
mega_text: M
|
||||
|
||||
# These translations are used by search sources.
|
||||
search_source:
|
||||
discussions:
|
||||
all_button: 'Search all discussions for "{query}"'
|
||||
heading: => core.ref.discussions
|
||||
users:
|
||||
heading: => core.ref.users
|
||||
|
||||
# These translations are used by gambits. Gambit keys must be in snake_case, no spaces.
|
||||
gambits:
|
||||
boolean_key: is
|
||||
discussions:
|
||||
author:
|
||||
key: author
|
||||
hint: username or comma-separated list of usernames
|
||||
created:
|
||||
key: created
|
||||
hint: 2020-12-31 or 2020-12-31..2021-09-30
|
||||
hidden:
|
||||
key: hidden
|
||||
unread:
|
||||
key: unread
|
||||
users:
|
||||
email:
|
||||
key: email
|
||||
hint: example@machine.local
|
||||
group:
|
||||
key: group
|
||||
hint: singular or plural group names
|
||||
|
||||
# These translations are used to punctuate a series of items.
|
||||
series:
|
||||
glue_text: ", "
|
||||
|
|
|
@ -89,6 +89,7 @@ class ForumSerializer extends AbstractSerializer
|
|||
'canSearchUsers' => $this->actor->can('searchUsers'),
|
||||
'canCreateAccessToken' => $this->actor->can('createAccessToken'),
|
||||
'canModerateAccessTokens' => $this->actor->can('moderateAccessTokens'),
|
||||
'canEditUserCredentials' => $this->actor->hasPermission('user.editCredentials'),
|
||||
'assetsBaseUrl' => rtrim($this->assetsFilesystem->url(''), '/'),
|
||||
'jsChunksBaseUrl' => $this->assetsFilesystem->url('js'),
|
||||
];
|
||||
|
|
|
@ -29,7 +29,7 @@ class EmailFilter implements FilterInterface
|
|||
|
||||
public function filter(SearchState $state, string|array $value, bool $negate): void
|
||||
{
|
||||
if (! $state->getActor()->hasPermission('user.edit')) {
|
||||
if (! $state->getActor()->hasPermission('user.editCredentials')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -91,10 +91,10 @@ function addAutoExports(source, pathToModule, moduleName) {
|
|||
}
|
||||
|
||||
// 2.3. Finally, we check for all named exports
|
||||
// these can be `export function|class|.. Name ..`
|
||||
// these can be `export function|class|enum|.. Name ..`
|
||||
// or `export { ... };
|
||||
{
|
||||
const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)];
|
||||
const matches = [...source.matchAll(/export\s+?(?:\* as|function|{\s*([A-z0-9, ]+)+\s?}|const|let|abstract\s?|class)+?\s?([A-Za-z_]*)?/gm)];
|
||||
|
||||
if (matches.length) {
|
||||
const map = matches.reduce((map, match) => {
|
||||
|
|
Loading…
Reference in New Issue
Block a user