mirror of
https://github.com/flarum/core.git
synced 2025-08-06 08:27:42 +02: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:
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user