Add support for including/excluding items from team filtering (#363)

This PR adds support for including/excluding specific items from team
filtering. Users can use the filter modal to only show teams that
include specific items, only show teams that _don't_ include specific
items, or combine the two to create a very powerful filter.
This commit is contained in:
Justin Edmund 2023-08-21 20:01:11 -07:00 committed by GitHub
parent 99c7eb73c1
commit a4e4328329
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1372 additions and 62 deletions

View file

@ -11,7 +11,7 @@ import classNames from 'classnames'
import styles from './index.module.scss'
type Props = Pick<SuggestionProps, 'items' | 'command'>
type Props = Pick<SuggestionProps, 'items' | 'command' | 'query'>
export type MentionRef = {
onKeyDown: (props: { event: KeyboardEvent }) => boolean
@ -113,7 +113,9 @@ export const MentionList = forwardRef<MentionRef, Props>(
))
) : (
<div className={styles.noResult}>
{t('search.errors.no_results_generic')}
{props.query.length < 3
? t('search.errors.type')
: t('search.errors.no_results_generic')}
</div>
)}
</div>

View file

@ -277,8 +277,8 @@ const AccountModal = React.forwardRef<HTMLDivElement, Props>(
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="Account"
headerref={headerRef}
footerref={footerRef}
headerRef={headerRef}
footerRef={footerRef}
onOpenAutoFocus={(event: Event) => {}}
onEscapeKeyDown={onEscapeKeyDown}
>

View file

@ -218,7 +218,7 @@ const LoginModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="login"
footerref={footerRef}
footerRef={footerRef}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>

View file

@ -298,7 +298,7 @@ const SignupModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="signup"
footerref={footerRef}
footerRef={footerRef}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>

View file

@ -77,7 +77,7 @@ const CharacterConflictModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="conflict"
footerref={footerRef}
footerRef={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={close}
>

View file

@ -354,8 +354,8 @@ const CharacterModal = ({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="character"
headerref={headerRef}
footerref={footerRef}
headerRef={headerRef}
footerRef={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={() => {}}
>

View file

@ -12,15 +12,15 @@ interface Props
HTMLDivElement
> {
wrapperClassName?: string
headerref?: React.RefObject<HTMLDivElement>
footerref?: React.RefObject<HTMLDivElement>
headerRef?: React.RefObject<HTMLDivElement>
footerRef?: React.RefObject<HTMLDivElement>
scrollable?: boolean
onEscapeKeyDown: (event: KeyboardEvent) => void
onOpenAutoFocus: (event: Event) => void
}
const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
{ scrollable, children, ...props },
{ scrollable, wrapperClassName, headerRef, footerRef, children, ...props },
forwardedRef
) {
// Classes
@ -37,12 +37,12 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
const scrollHeight = event.currentTarget.scrollHeight
const clientHeight = event.currentTarget.clientHeight
if (props.headerref && props.headerref.current)
manipulateHeaderShadow(props.headerref.current, scrollTop)
if (headerRef && headerRef.current)
manipulateHeaderShadow(headerRef.current, scrollTop)
if (props.footerref && props.footerref.current)
if (footerRef && footerRef.current)
manipulateFooterShadow(
props.footerref.current,
footerRef.current,
scrollTop,
scrollHeight,
clientHeight
@ -94,7 +94,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
const calculateFooterShadow = debounce(() => {
const boxShadowBase = '0 -2px 8px'
const scrollable = document.querySelector(`.${styles.scrollable}`)
const footer = props.footerref
const footer = footerRef
if (footer && footer.current) {
if (scrollable && scrollable.clientHeight >= scrollable.scrollHeight) {
@ -133,9 +133,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, Props>(function Dialog(
{
[styles.dialog]: true,
},
props.wrapperClassName
?.split(' ')
.map((className) => styles[className])
wrapperClassName?.split(' ').map((className) => styles[className])
)}
>
<DialogPrimitive.Content

View file

@ -10,6 +10,7 @@
padding: ($unit * 1.5) ($unit * $multiplier) $unit-3x;
position: sticky;
transition: box-shadow 0.1s ease-out, border-top 0.1s ease-out;
z-index: 10;
.left,
.right {

View file

@ -0,0 +1,45 @@
import TableField from '~components/common/TableField'
import MentionTypeahead from '../MentionTypeahead'
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string
description?: string
placeholder?: string
inclusions: MentionItem[]
exclusions: MentionItem[]
onUpdate: (content: MentionItem[]) => void
}
const MentionTableField = ({
label,
description,
placeholder,
inclusions,
exclusions,
...props
}: Props) => {
return (
<TableField
{...props}
name={props.name || ''}
description={description}
className="mention"
label={label}
>
<MentionTypeahead
label={label}
description={description}
placeholder={placeholder}
inclusions={inclusions}
exclusions={exclusions}
onUpdate={props.onUpdate}
/>
</TableField>
)
}
export default MentionTableField

View file

@ -0,0 +1,358 @@
.menu {
background: #fff;
border-radius: $item-corner;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
color: rgba(0, 0, 0, 0.8);
overflow: scroll;
padding: $unit-half;
pointer-events: all;
position: relative;
width: 200px;
z-index: 999;
:global(.dropdown-item.disabled) {
align-items: center;
color: var(--text-tertiary);
display: flex;
font-size: $font-small;
min-height: $unit-5x;
padding-left: $unit;
}
a:hover {
text-decoration: none !important;
}
[aria-selected='true'] .item {
background: var(--menu-bg-item-hover);
color: var(--text-primary);
}
.item {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: $item-corner-small;
color: var(--text-tertiary);
font-size: $font-small;
font-weight: $medium;
display: flex;
gap: $unit;
margin: 0;
padding: $unit-half $unit;
text-align: left;
&:hover,
&.selected {
background: var(--menu-bg-item-hover);
color: var(--text-primary);
}
.job {
display: flex;
align-items: center;
justify-content: center;
width: $unit-4x;
height: $unit-4x;
img {
width: $unit-3x;
height: auto;
}
}
img {
border-radius: $item-corner-small;
width: $unit-4x;
height: $unit-4x;
}
}
}
.typeahead {
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
display: block;
flex-grow: 1;
font-size: $font-regular;
white-space: pre-wrap;
max-width: 240px;
width: 100%;
:global(.rbt-input) {
background-color: var(--input-bound-bg);
border-radius: $input-corner;
color: var(--text-primary);
padding: ($unit * 1.5) $unit-2x;
min-height: 26px;
&:hover {
background-color: var(--input-bound-bg-hover);
}
&:focus,
&:global(.focus) {
outline: 2px solid #275dc5;
}
}
:global(.rbt-input-wrapper) {
align-items: center;
display: flex;
flex-wrap: wrap;
min-height: 30px;
margin-bottom: -4px;
margin-top: -1px;
overflow: hidden;
:global(.rbt-input-hint) {
color: var(--text-tertiary) !important;
}
}
}
.token {
border-radius: $item-corner-small;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--null-shadow);
background: var(--null-bg);
color: var(--text-primary);
display: inline-flex;
align-items: center;
font-weight: $medium;
font-size: 15px;
padding: 1px $unit-half 1px $unit;
margin: $unit-fourth;
transition: all 0.1s ease-out;
white-space: nowrap;
overflow: hidden;
:global(.rbt-token-label) {
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
}
:global(.rbt-token-remove-button) {
background: none;
border: none;
font-weight: $bold;
&:hover {
cursor: pointer;
}
:global(.visually-hidden) {
display: none;
}
}
$outline: 2px solid var(--transparent-stroke);
&:hover {
background: var(--null-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--null-shadow-hover);
text-decoration: none;
}
&[data-element='fire'] {
background: var(--fire-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--fire-shadow);
color: var(--fire-text);
:global(.rbt-token-remove-button) {
color: var(--fire-text);
&:hover {
color: var(--fire-text-hover);
}
}
&:hover {
background: var(--fire-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--fire-shadow-hover);
color: var(--fire-text-hover);
}
&:focus {
background: var(--fire-text);
box-shadow: none;
color: var(--fire-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--fire-bg);
}
}
}
&[data-element='water'] {
background: var(--water-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--water-shadow);
color: var(--water-text);
:global(.rbt-token-remove-button) {
color: var(--water-text);
&:hover {
color: var(--water-text-hover);
}
}
&:hover {
background: var(--water-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--water-shadow-hover);
color: var(--water-text-hover);
}
&:focus {
background: var(--water-text);
box-shadow: none;
color: var(--water-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--water-bg);
}
}
}
&[data-element='earth'] {
background: var(--earth-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--earth-shadow);
color: var(--earth-text);
:global(.rbt-token-remove-button) {
color: var(--earth-text);
&:hover {
color: var(--earth-text-hover);
}
}
&:hover {
background: var(--earth-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--earth-shadow-hover);
color: var(--earth-text-hover);
}
&:focus {
background: var(--earth-text);
box-shadow: none;
color: var(--earth-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--earth-bg);
}
}
}
&[data-element='wind'] {
background: var(--wind-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--wind-shadow);
color: var(--wind-text);
:global(.rbt-token-remove-button) {
color: var(--wind-text);
&:hover {
color: var(--wind-text-hover);
}
}
&:hover {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--wind-shadow-hover);
color: var(--wind-text-hover);
}
&:focus {
background: var(--wind-text);
box-shadow: none;
color: var(--wind-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--wind-bg);
}
}
}
&[data-element='dark'] {
background: var(--dark-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--dark-shadow);
color: var(--dark-text);
:global(.rbt-token-remove-button) {
color: var(--dark-text);
&:hover {
color: var(--dark-text-hover);
}
}
&:hover {
background: var(--dark-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--dark-shadow-hover);
color: var(--dark-text-hover);
}
&:focus {
background: var(--dark-text);
box-shadow: none;
color: var(--dark-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--dark-bg);
}
}
}
&[data-element='light'] {
background: var(--light-bg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--light-shadow);
color: var(--light-text);
:global(.rbt-token-remove-button) {
color: var(--light-text);
&:hover {
color: var(--light-text-hover);
}
}
&:hover {
background: var(--light-bg-hover);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25),
0 1px 0px var(--light-shadow-hover);
color: var(--light-text-hover);
}
&:focus {
background: var(--light-text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 1px 0px var(--light-shadow-hover);
color: var(--light-bg);
outline: $outline;
:global(.rbt-token-remove-button) {
color: var(--light-bg);
}
}
}
}

View file

@ -0,0 +1,186 @@
import { useState } from 'react'
import { getCookie } from 'cookies-next'
import { useTranslation } from 'next-i18next'
import type {
Option,
RenderTokenProps,
} from 'react-bootstrap-typeahead/types/types'
import {
AsyncTypeahead,
Menu,
MenuItem,
RenderMenuProps,
Token,
} from 'react-bootstrap-typeahead'
import api from '~utils/api'
import { numberToElement } from '~utils/elements'
import styles from './index.module.scss'
interface Props
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string
description?: string
placeholder?: string
inclusions: MentionItem[]
exclusions: MentionItem[]
onUpdate: (content: MentionItem[]) => void
}
interface RawSearchResponse {
searchable_type: string
granblue_id: string
name_en: string
name_jp: string
element: number
}
const MentionTypeahead = ({
label,
description,
placeholder,
inclusions,
exclusions,
...props
}: Props) => {
const { t } = useTranslation('common')
const locale = getCookie('NEXT_LOCALE')
? (getCookie('NEXT_LOCALE') as string)
: 'en'
const [isLoading, setIsLoading] = useState(false)
const [options, setOptions] = useState<Option[]>([])
async function handleSearch(query: string) {
setIsLoading(true)
const exclude = transformIntoString([...inclusions, ...exclusions])
const response = await api.searchAll(query, exclude, locale)
const results = response.data.results
setIsLoading(false)
setOptions(mapResults(results))
}
function transformIntoMentionItem(object: RawSearchResponse) {
const result: MentionItem = {
name: {
en: object.name_en,
ja: object.name_jp,
},
type: object.searchable_type.toLowerCase(),
granblue_id: object.granblue_id,
element: numberToElement(object.element),
}
return result
}
function transformIntoString(list: MentionItem[]) {
return list.map((item) => item.granblue_id)
}
function mapResults(results: RawSearchResponse[]) {
return results
.map((rawObject: RawSearchResponse) => {
const object = transformIntoMentionItem(rawObject)
return {
granblue_id: object.granblue_id,
element: object.element,
type: object.type,
name: {
en: object.name.en,
ja: object.name.ja,
},
}
})
.slice(0, 5)
}
function renderMenu(results: Option[], menuProps: RenderMenuProps) {
return (
<Menu
{...menuProps}
className={styles.menu}
emptyLabel={t('modals.filters.prompts.not_found')}
>
{results.map((option, index) => (
<MenuItem key={index} option={option} position={index}>
{renderMenuItemChild(option)}
</MenuItem>
))}
</Menu>
)
}
function renderMenuItemChild(option: Option) {
const item = option as MentionItem
return (
<div className={styles.item}>
<div className={styles[item.type]}>
<img
alt={item.name[locale]}
src={
item.type === 'character'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}_01.jpg`
: item.type === 'job'
? `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/job-icons/${item.granblue_id}.png`
: `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/${item.type}-square/${item.granblue_id}.jpg`
}
/>
</div>
<span>{item.name[locale]}</span>
</div>
)
}
function renderToken(option: Option, props: RenderTokenProps) {
const item = option as MentionItem
const { labelKey, ...tokenProps } = props
return (
<Token
{...tokenProps}
className={styles.token}
data-element={item.element.slug}
data-type={item.type}
option={option}
>
{item.name[locale]}
</Token>
)
}
return (
<AsyncTypeahead
multiple
className={styles.typeahead}
id={label}
align="left"
isLoading={isLoading}
labelKey={(option) => (option as MentionItem).name[locale]}
defaultSelected={inclusions}
filterBy={() => true}
minLength={3}
onSearch={handleSearch}
options={options}
useCache={false}
placeholder={placeholder}
positionFixed={true}
promptText={t('modals.filters.prompts.type')}
searchText={t('modals.filters.prompts.searching')}
renderMenu={renderMenu}
renderMenuItemChildren={renderMenuItemChild}
renderToken={renderToken}
highlightOnlyResult={false}
onChange={(selected) => props.onUpdate(selected as MentionItem[])}
/>
)
}
export default MentionTypeahead

View file

@ -44,6 +44,10 @@
}
}
&.mention {
grid-template-columns: 1fr 1fr;
}
.left {
align-items: center;
display: flex;

View file

@ -14,6 +14,22 @@
}
}
.header {
h3 {
font-size: $font-medium;
font-weight: $bold;
}
p {
font-size: $font-small;
}
&.border {
border-top: 1px solid var(--text-tertiary);
padding-top: $unit-2x;
}
}
.notice {
background-color: var(--notice-bg);
border-radius: $input-corner;
@ -32,6 +48,10 @@
}
.fields {
display: flex;
flex-direction: column;
gap: $unit-4x;
section {
display: flex;
flex-direction: column;
gap: $unit-2x;
@ -42,3 +62,4 @@
margin-bottom: $unit * 24;
}
}
}

View file

@ -18,6 +18,8 @@ import SelectItem from '~components/common/SelectItem'
import type { DialogProps } from '@radix-ui/react-dialog'
import styles from './index.module.scss'
import MentionTableField from '~components/common/MentionTableField'
import classNames from 'classnames'
interface Props extends DialogProps {
defaultFilterSet: FilterSet
@ -47,6 +49,8 @@ const FilterModal = (props: Props) => {
const [chargeAttackOpen, setChargeAttackOpen] = useState(false)
const [fullAutoOpen, setFullAutoOpen] = useState(false)
const [autoGuardOpen, setAutoGuardOpen] = useState(false)
const [inclusions, setInclusions] = useState<MentionItem[]>([])
const [exclusions, setExclusions] = useState<MentionItem[]>([])
const [filterSet, setFilterSet] = useState<FilterSet>({})
// Filter states
@ -82,11 +86,17 @@ const FilterModal = (props: Props) => {
// Hooks
useEffect(() => {
if (props.open !== undefined) setOpen(props.open)
if (props.open !== undefined) {
setOpen(props.open)
// When should we reset the filter state?
}
})
useEffect(() => {
setFilterSet(props.filterSet)
setInclusions(props.filterSet.includes || [])
setExclusions(props.filterSet.excludes || [])
}, [props.filterSet])
useEffect(() => {
@ -127,6 +137,9 @@ const FilterModal = (props: Props) => {
if (maxButtonsCount) filters.button_count = maxButtonsCount
if (maxTurnsCount) filters.turn_count = maxTurnsCount
if (inclusions.length > 0) filters.includes = inclusions
if (exclusions.length > 0) filters.excludes = exclusions
if (props.persistFilters) {
setCookie('filters', filters, { path: '/' })
}
@ -384,6 +397,37 @@ const FilterModal = (props: Props) => {
/>
)
// Inclusions and exclusions
function storeInclusions(value: MentionItem[]) {
setInclusions(value)
}
function storeExclusions(value: MentionItem[]) {
setExclusions(value)
}
const inclusionField = (
<MentionTableField
name="inclusion"
inclusions={inclusions}
exclusions={exclusions}
placeholder={t('modals.filters.placeholders.inclusion')}
label={t('modals.filters.labels.inclusion')}
onUpdate={storeInclusions}
/>
)
const exclusionField = (
<MentionTableField
name="exclusion"
inclusions={exclusions}
exclusions={inclusions}
placeholder={t('modals.filters.placeholders.exclusion')}
label={t('modals.filters.labels.exclusion')}
onUpdate={storeExclusions}
/>
)
const filterNotice = () => {
if (props.persistFilters) return null
return (
@ -404,14 +448,27 @@ const FilterModal = (props: Props) => {
<DialogContent
className="filter"
wrapperClassName="filter"
headerref={headerRef}
footerref={footerRef}
headerRef={headerRef}
footerRef={footerRef}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}
>
<DialogHeader title={t('modals.filters.title')} />
<div className={styles.fields}>
{filterNotice()}
<section>
<div className={styles.header}>
<h3>{t('modals.filters.headers.items.name')}</h3>
<p>{t('modals.filters.headers.items.description')}</p>
</div>
{inclusionField}
{exclusionField}
</section>
<section>
<div className={styles.header}>
<h3>{t('modals.filters.headers.details.name')}</h3>
<p>{t('modals.filters.headers.details.description')}</p>
</div>
{chargeAttackField()}
{fullAutoField()}
{autoGuardField()}
@ -423,6 +480,7 @@ const FilterModal = (props: Props) => {
{nameQualityField()}
{userQualityField()}
{originalOnlyField()}
</section>
</div>
<DialogFooter
ref={footerRef}

View file

@ -412,7 +412,7 @@ const SearchModal = (props: Props) => {
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent
className="search"
headerref={headerRef}
headerRef={headerRef}
scrollable={false}
onEscapeKeyDown={onEscapeKeyDown}
onOpenAutoFocus={onOpenAutoFocus}

View file

@ -71,7 +71,7 @@ const WeaponConflictModal = (props: Props) => {
<Dialog open={open} onOpenChange={openChange}>
<DialogContent
className="conflict"
footerref={footerRef}
footerRef={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={close}
>

View file

@ -469,8 +469,8 @@ const WeaponModal = ({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="Weapon"
headerref={headerRef}
footerref={footerRef}
headerRef={headerRef}
footerRef={footerRef}
onOpenAutoFocus={(event) => event.preventDefault()}
onEscapeKeyDown={onEscapeKeyDown}
>

View file

@ -0,0 +1,80 @@
import { escapeForRegEx, Range } from '@tiptap/core'
import { ResolvedPos } from '@tiptap/pm/model'
export interface Trigger {
char: string
allowSpaces: boolean
allowedPrefixes: string[] | null
startOfLine: boolean
$position: ResolvedPos
}
export type SuggestionMatch = {
range: Range
query: string
text: string
} | null
export function findSuggestionMatch(config: Trigger): SuggestionMatch {
const { char, allowSpaces, allowedPrefixes, startOfLine, $position } = config
const escapedChar = escapeForRegEx(char)
const suffix = new RegExp(`\\s${escapedChar}$`)
const prefix = startOfLine ? '^' : ''
// const regexp = allowSpaces
// ? new RegExp(`${prefix}${escapedChar}.*?(?=\\s${escapedChar}|$)`, 'gm')
// : new RegExp(`${prefix}(?:^)?${escapedChar}[^\\s${escapedChar}]*`, 'gm')
const regexp = new RegExp(`^(.*)$`, 'gm')
const text = $position.nodeBefore?.isText && $position.nodeBefore.text
if (!text) {
return null
}
const textFrom = $position.pos - text.length
const match = Array.from(text.matchAll(regexp)).pop()
if (!match || match.input === undefined || match.index === undefined) {
return null
}
// JavaScript doesn't have lookbehinds. This hacks a check that first character
// is a space or the start of the line
const matchPrefix = match.input.slice(
Math.max(0, match.index - 1),
match.index
)
const matchPrefixIsAllowed = new RegExp(
`^[${allowedPrefixes?.join('')}\0]?$`
).test(matchPrefix)
if (allowedPrefixes !== null && !matchPrefixIsAllowed) {
return null
}
// The absolute position of the match in the document
const from = textFrom + match.index
let to = from + match[0].length
// Edge case handling; if spaces are allowed and we're directly in between
// two triggers
if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) {
match[0] += ' '
to += 1
}
// If the $position is located within the matched substring, return that range
if (from < $position.pos && to >= $position.pos) {
return {
range: {
from,
to,
},
query: match[0],
text: match[0],
}
}
return null
}

View file

@ -0,0 +1,291 @@
import { Editor, Range } from '@tiptap/core'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import { findSuggestionMatch } from './findSuggestionMatch'
export interface SuggestionOptions<I = any> {
pluginKey?: PluginKey
editor: Editor
char?: string
allowSpaces?: boolean
allowedPrefixes?: string[] | null
startOfLine?: boolean
decorationTag?: string
decorationClass?: string
command?: (props: { editor: Editor; range: Range; props: I }) => void
items?: (props: { query: string; editor: Editor }) => I[] | Promise<I[]>
render?: () => {
onBeforeStart?: (props: SuggestionProps<I>) => void
onStart?: (props: SuggestionProps<I>) => void
onBeforeUpdate?: (props: SuggestionProps<I>) => void
onUpdate?: (props: SuggestionProps<I>) => void
onExit?: (props: SuggestionProps<I>) => void
onFocus?: (props: SuggestionProps<I>) => void
onKeyDown?: (props: SuggestionKeyDownProps) => boolean
}
allow?: (props: {
editor: Editor
state: EditorState
range: Range
}) => boolean
}
export interface SuggestionProps<I = any> {
editor: Editor
range: Range
query: string
text: string
items: I[]
command: (props: I) => void
decorationNode: Element | null
clientRect?: (() => DOMRect) | null
}
export interface SuggestionKeyDownProps {
view: EditorView
event: KeyboardEvent
range: Range
}
export const SuggestionPluginKey = new PluginKey('suggestion')
export function CustomSuggestion<I = any>({
pluginKey = SuggestionPluginKey,
editor,
char = '@',
allowSpaces = true,
allowedPrefixes = [' '],
startOfLine = false,
decorationTag = 'span',
decorationClass = 'suggestion',
command = () => null,
items = () => [],
render = () => ({}),
allow = () => true,
}: SuggestionOptions<I>) {
let props: SuggestionProps<I> | undefined
const renderer = render?.()
const plugin: Plugin<any> = new Plugin({
key: pluginKey,
view() {
return {
update: async (view, prevState) => {
// This should be this.key?
const prev = pluginKey.getState(prevState)
const next = pluginKey.getState(view.state)
// See how the state changed
const moved =
prev.active && next.active && prev.range.from !== next.range.from
const started = !prev.active && next.active
const stopped = prev.active && !next.active
const changed = !started && !stopped && prev.query !== next.query
const handleStart = started || moved
const handleChange = changed && !moved
const handleExit = stopped || moved
const handleFocused = view.hasFocus()
console.log(handleFocused)
// Cancel when suggestion isn't active
if (!handleStart && !handleChange && !handleExit && !handleFocused) {
return
}
const state = handleExit && !handleStart ? prev : next
const decorationNode = view.dom.querySelector(
`[data-decoration-id="${state.decorationId}"]`
)
props = {
editor,
range: state.range,
query: state.query,
text: state.text,
items: [],
command: (commandProps) => {
command({
editor,
range: state.range,
props: commandProps,
})
},
decorationNode,
// virtual node for popper.js or tippy.js
// this can be used for building popups without a DOM node
clientRect: decorationNode
? () => {
// because of `items` can be asynchrounous well search for the current decoration node
const { decorationId } = pluginKey.getState(editor.state) // eslint-disable-line
const currentDecorationNode = view.dom.querySelector(
`[data-decoration-id="${decorationId}"]`
)
// @ts-ignore-error
return currentDecorationNode.getBoundingClientRect()
}
: null,
}
if (handleFocused) {
console.log('Handling focus')
}
if (handleStart) {
renderer?.onBeforeStart?.(props)
}
if (handleChange) {
renderer?.onBeforeUpdate?.(props)
}
if (handleChange || handleStart) {
props.items = await items({
editor,
query: state.query,
})
}
if (handleExit) {
renderer?.onExit?.(props)
}
if (handleChange) {
renderer?.onUpdate?.(props)
}
if (handleStart) {
renderer?.onStart?.(props)
}
},
destroy: () => {
if (!props) {
return
}
renderer?.onExit?.(props)
},
}
},
state: {
// Initialize the plugin's internal state.
init() {
const state: {
active: boolean
range: Range
query: null | string
text: null | string
composing: boolean
decorationId?: string | null
} = {
active: false,
range: {
from: 0,
to: 0,
},
query: null,
text: null,
composing: false,
}
return state
},
// Apply changes to the plugin state from a view transaction.
apply(transaction, prev, oldState, state) {
const { isEditable } = editor
const { composing } = editor.view
const { selection } = transaction
const { empty, from } = selection
const next = { ...prev }
next.composing = composing
// We can only be suggesting if the view is editable, and:
// * there is no selection, or
// * a composition is active (see: https://github.com/ueberdosis/tiptap/issues/1449)
if (isEditable && (empty || editor.view.composing)) {
// Reset active state if we just left the previous suggestion range
if (
(from < prev.range.from || from > prev.range.to) &&
!composing &&
!prev.composing
) {
next.active = false
}
// Try to match against where our cursor currently is
const match = findSuggestionMatch({
char,
allowSpaces,
allowedPrefixes,
startOfLine,
$position: selection.$from,
})
const decorationId = `id_${Math.floor(Math.random() * 0xffffffff)}`
// If we found a match, update the current state to show it
if (match && allow({ editor, state, range: match.range })) {
next.active = true
next.decorationId = prev.decorationId
? prev.decorationId
: decorationId
next.range = match.range
next.query = match.query
next.text = match.text
} else {
next.active = false
}
} else {
next.active = false
}
// Make sure to empty the range if suggestion is inactive
if (!next.active) {
next.decorationId = null
next.range = { from: 0, to: 0 }
next.query = null
next.text = null
}
return next
},
},
props: {
// Call the keydown hook if suggestion is active.
handleKeyDown(view, event) {
const { active, range } = plugin.getState(view.state)
if (!active) {
return false
}
return renderer?.onKeyDown?.({ view, event, range }) || false
},
// Setup decorator on the currently active suggestion.
decorations(state) {
const { active, range, decorationId } = plugin.getState(state)
if (!active) {
return null
}
return DecorationSet.create(state.doc, [
Decoration.inline(range.from, range.to, {
nodeName: decorationTag,
class: decorationClass,
'data-decoration-id': decorationId,
}),
])
},
},
})
return plugin
}

View file

@ -0,0 +1,26 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
const NoNewLine = Extension.create({
name: 'no_new_line',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('eventHandler'),
props: {
handleKeyDown: (view, event) => {
if (event.key === 'Enter' && !event.shiftKey) {
console.log('enter pressed')
return true
}
},
// … and many, many more.
// Here is the full list: https://prosemirror.net/docs/ref/#view.EditorProps
},
}),
]
},
})
export default NoNewLine

137
package-lock.json generated
View file

@ -31,6 +31,7 @@
"@tiptap/react": "^2.0.3",
"@tiptap/starter-kit": "^2.0.3",
"@tiptap/suggestion": "2.0.0-beta.91",
"@types/react-bootstrap-typeahead": "^5.1.9",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cmdk": "^0.2.0",
@ -53,6 +54,7 @@
"next-usequerystate": "^1.7.0",
"pluralize": "^8.0.0",
"react": "^18.2.0",
"react-bootstrap-typeahead": "^6.2.3",
"react-dom": "^18.2.0",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
@ -4486,6 +4488,17 @@
"type-fest": "^2.19.0"
}
},
"node_modules/@restart/hooks": {
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz",
"integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.0.tgz",
@ -7890,6 +7903,14 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-bootstrap-typeahead": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/@types/react-bootstrap-typeahead/-/react-bootstrap-typeahead-5.1.9.tgz",
"integrity": "sha512-i9/oXJb9EOtotvpzlS8+06HN1s/YCprQKMCJxToOZoTLogyi5dR4hp3PqLCPUL3ciB17CS/4zWE9qfaCF1vWLA==",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-dom": {
"version": "17.0.20",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.20.tgz",
@ -7989,6 +8010,11 @@
"integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
"dev": true
},
"node_modules/@types/warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
"integrity": "sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA=="
},
"node_modules/@types/yargs": {
"version": "17.0.24",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz",
@ -10368,6 +10394,11 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -11076,7 +11107,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"engines": {
"node": ">=6"
}
@ -11206,6 +11236,15 @@
"utila": "~0.4"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -18645,6 +18684,29 @@
"node": ">=0.10.0"
}
},
"node_modules/react-bootstrap-typeahead": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/react-bootstrap-typeahead/-/react-bootstrap-typeahead-6.2.3.tgz",
"integrity": "sha512-Ge2au2WxR8CWsAH3GbKsaJpIEV2OMKum2Ov7/kuVMBlHNKwsJc2ULJIjk3yZMoTvvfOzOnYDScaWrQwzPc5c/A==",
"dependencies": {
"@babel/runtime": "^7.14.6",
"@popperjs/core": "^2.10.2",
"@restart/hooks": "^0.4.0",
"classnames": "^2.2.0",
"fast-deep-equal": "^3.1.1",
"invariant": "^2.2.1",
"lodash.debounce": "^4.0.8",
"prop-types": "^15.5.8",
"react-overlays": "^5.2.0",
"react-popper": "^2.2.5",
"scroll-into-view-if-needed": "^2.2.20",
"warning": "^4.0.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
@ -18727,6 +18789,11 @@
"integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==",
"dev": true
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-i18next": {
"version": "11.18.6",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.6.tgz",
@ -18774,6 +18841,11 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-linkify": {
"version": "1.0.0-alpha",
"resolved": "https://registry.npmjs.org/react-linkify/-/react-linkify-1.0.0-alpha.tgz",
@ -18792,6 +18864,39 @@
"react-dom": ">=16.0.8"
}
},
"node_modules/react-overlays": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-overlays/-/react-overlays-5.2.1.tgz",
"integrity": "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA==",
"dependencies": {
"@babel/runtime": "^7.13.8",
"@popperjs/core": "^2.11.6",
"@restart/hooks": "^0.4.7",
"@types/warning": "^3.0.0",
"dom-helpers": "^5.2.0",
"prop-types": "^15.7.2",
"uncontrollable": "^7.2.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-popper": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz",
"integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==",
"dependencies": {
"react-fast-compare": "^3.0.1",
"warning": "^4.0.2"
},
"peerDependencies": {
"@popperjs/core": "^2.0.0",
"react": "^16.8.0 || ^17 || ^18",
"react-dom": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@ -19746,6 +19851,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -21243,6 +21356,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/uncontrollable": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
"invariant": "^2.2.4",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": ">=15.0.0"
}
},
"node_modules/unfetch": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",
@ -21761,6 +21888,14 @@
"makeerror": "1.0.12"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",

View file

@ -38,6 +38,7 @@
"@tiptap/react": "^2.0.3",
"@tiptap/starter-kit": "^2.0.3",
"@tiptap/suggestion": "2.0.0-beta.91",
"@types/react-bootstrap-typeahead": "^5.1.9",
"axios": "^0.25.0",
"classnames": "^2.3.1",
"cmdk": "^0.2.0",
@ -60,6 +61,7 @@
"next-usequerystate": "^1.7.0",
"pluralize": "^8.0.0",
"react": "^18.2.0",
"react-bootstrap-typeahead": "^6.2.3",
"react-dom": "^18.2.0",
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",

View file

@ -158,9 +158,7 @@ function MyApp({ Component, pageProps }: AppProps) {
{!appState.version ? (
serverUnavailable()
) : (
<main>
<Component {...pageProps} />
</main>
)}
</Layout>
<Viewport className="ToastViewport" />

View file

@ -16,6 +16,7 @@ import { appState } from '~utils/appState'
import { defaultFilterset } from '~utils/defaultFilters'
import { elements, allElement } from '~data/elements'
import { emptyPaginationObject } from '~utils/emptyStates'
import { convertAdvancedFilters } from '~utils/convertAdvancedFilters'
import ErrorSection from '~components/ErrorSection'
import GridRep from '~components/GridRep'
@ -157,7 +158,7 @@ const TeamsRoute: React.FC<Props> = ({
raid: raid === 'all' ? undefined : raid,
recency: recency !== -1 ? recency : undefined,
page: currentPage,
...advancedFilters,
...convertAdvancedFilters(advancedFilters),
}
Object.keys(filters).forEach(
@ -393,7 +394,7 @@ export const getServerSideProps = async ({ req, res, locale, query }: { req: Nex
// Create filter object
const filters: FilterObject = extractFilters(query, raidGroups)
const params = {
params: { ...filters, ...advancedFilters },
params: { ...filters, ...convertAdvancedFilters(advancedFilters) },
}
// Set up empty variables

View file

@ -252,6 +252,16 @@
},
"filters": {
"title": "Advanced filters",
"headers": {
"items": {
"name": "Filter items",
"description": "Show or hide teams that have specific characters, weapons, or summons"
},
"details": {
"name": "Filter details",
"description": "Filter teams by various properties, like full auto or button presses"
}
},
"labels": {
"charge_attack": "Charge Attack",
"full_auto": "Full Auto",
@ -263,7 +273,9 @@
"min_weapons": "Minimum number of weapons",
"name_quality": "Hide untitled teams",
"user_quality": "Hide anonymous users",
"original_only": "Hide remixed teams"
"original_only": "Hide remixed teams",
"inclusion": "Show teams with these items",
"exclusion": "Hide teams with these items"
},
"notice": "Filters set on <strong>user profiles</strong> and in <strong>your saved teams</strong> will not be saved",
"options": {
@ -271,6 +283,15 @@
"off": "Off",
"on_and_off": "On and Off"
},
"placeholders": {
"inclusion": "Teams that have...",
"exclusion": "Teams that don't have..."
},
"prompts": {
"type": "Start typing...",
"searching": "Searching...",
"not_found": "No results found"
},
"buttons": {
"confirm": "Save filters",
"clear": "Reset to default"
@ -488,7 +509,8 @@
"min_length": "Type at least 3 characters",
"no_results": "No results found for '{{query}}'",
"no_results_generic": "No results found",
"end_results": "No more results"
"end_results": "No more results",
"type": "Keep typing..."
},
"placeholders": {
"weapon": "Search for a weapon...",

View file

@ -249,6 +249,16 @@
},
"filters": {
"title": "フィルター設定",
"headers": {
"items": {
"name": "アイテムをフィルター",
"description": "キャラクター・武器・召喚石が編成に包含・除外されているかに基づいて表示する"
},
"details": {
"name": "詳細をフィルター",
"description": "フルオートやポチなどの編成詳細に基づいて表示する"
}
},
"labels": {
"charge_attack": "奥義",
"full_auto": "フルオート",
@ -260,7 +270,9 @@
"min_weapons": "最小武器数",
"name_quality": "無題の編成なし",
"user_quality": "無名のユーザーなし",
"original_only": "リミックスなし"
"original_only": "リミックスなし",
"inclusion": "包含されているアイテム",
"exclusion": "除外されているアイテムめn"
},
"notice": "フィルターは<strong>保存した編成</strong>と<strong>ユーザープロフィール</strong>には保存されません",
"options": {
@ -268,6 +280,15 @@
"off": "OFF",
"on_and_off": "両方"
},
"placeholders": {
"inclusion": "〇〇が入っている",
"exclusion": "〇〇が入っていない"
},
"prompts": {
"type": "入力してください",
"searching": "検索中...",
"not_found": "見つかりませんでした"
},
"buttons": {
"confirm": "フィルターを保存する",
"clear": "保存したフィルターをリセット"
@ -486,7 +507,8 @@
"min_length": "3文字以上を入力してください",
"no_results": "'{{query}}'の検索結果が見つかりませんでした",
"no_results_generic": "検索結果が見つかりませんでした",
"end_results": "検索結果これ以上ありません"
"end_results": "検索結果これ以上ありません",
"type": "もっと入力してください"
},
"placeholders": {
"weapon": "武器を検索...",

View file

@ -30,6 +30,8 @@
--anonymous-bg: #{$anonymous--bg--light};
--placeholder-bg: #{$grey-80};
--transparent-stroke: #{$transparent--stroke--light};
// Light - Units
--unit-bg: #{$unit--bg--light};
--unit-bg-hover: #{$unit--bg--light--hover};
@ -252,6 +254,8 @@
--anonymous-bg: #{$anonymous--bg--dark};
--placeholder-bg: #{$grey-40};
--transparent-stroke: #{$transparent--stroke--dark};
// Dark - Units
--unit-bg: #{$unit--bg--dark};
--unit-bg-hover: #{$unit--bg--dark--hover};

View file

@ -166,6 +166,9 @@ $dark-bg-00: #ba63d8;
$dark-bg-10: #de7bff;
$dark-bg-20: #f2cdff;
$transparent--stroke--light: rgba(0, 0, 0, 0.9);
$transparent--stroke--dark: rgba(255, 255, 255, 0.35);
$page--bg--light: $grey-90;
$page--bg--dark: $grey-15;

View file

@ -1,7 +1,7 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"target": "es2015",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,

View file

@ -13,4 +13,6 @@ interface FilterSet {
name_quality?: boolean
user_quality?: boolean
original?: boolean
includes?: MentionItem[]
excludes?: MentionItem[]
}

10
types/MentionItem.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
interface MentionItem {
name: {
[key: string]: string
en: string
ja: string
}
type: string
granblue_id: string
element: GranblueElement
}

View file

@ -70,11 +70,15 @@ class Api {
})
}
searchAll(query: string, locale: string) {
searchAll(query: string, exclude: string[], locale: string) {
const resourceUrl = `${this.url}/search`
// Also send list of Granblue IDs
// so the backend can exclude opposites and duplicates
// Maybe store them in state???
return axios.post(`${resourceUrl}`, {
search: {
query: query,
exclude: exclude,
locale: locale
}
})

View file

@ -0,0 +1,21 @@
import cloneDeep from 'lodash.clonedeep'
export function convertAdvancedFilters(filters: FilterSet) {
let copy = cloneDeep(filters)
const includes = filterString(filters.includes || [])
const excludes = filterString(filters.excludes || [])
delete copy.includes
delete copy.excludes
return {
...copy,
includes,
excludes,
}
}
export function filterString(list: MentionItem[]) {
return list.map((item) => item.granblue_id).join(',')
}

View file

@ -1,6 +1,5 @@
import type { JSONContent } from '@tiptap/core'
import { ReactRenderer } from '@tiptap/react'
import { MentionOptions } from '@tiptap/extension-mention'
import { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'
import tippy, { Instance as TippyInstance } from 'tippy.js'
import { getCookie } from 'cookies-next'
@ -11,7 +10,7 @@ import {
} from '~components/MentionList'
import api from '~utils/api'
import { numberToElement } from '~utils/elements'
import { get } from 'http'
import { SuggestionOptions } from '~extensions/CustomSuggestion'
interface RawSearchResponse {
searchable_type: string
@ -45,12 +44,22 @@ function transform(object: RawSearchResponse) {
return result
}
export const mentionSuggestionOptions: MentionOptions['suggestion'] = {
items: async ({ query }): Promise<MentionSuggestion[]> => {
function parseMentions(data: JSONContent) {
const mentions: string[] = (data.content || []).flatMap(parseMentions)
if (data.type === 'mention') {
const granblueId = data.attrs?.id.granblue_id
mentions.push(granblueId)
}
return [...new Set(mentions)]
}
export const mentionSuggestionOptions: Omit<SuggestionOptions, 'editor'> = {
items: async ({ query, editor }): Promise<MentionSuggestion[]> => {
const locale = getCookie('NEXT_LOCALE')
? (getCookie('NEXT_LOCALE') as string)
: 'en'
const response = await api.searchAll(query, locale)
const response = await api.searchAll(query, [], locale)
const results = response.data.results
return results
@ -72,7 +81,6 @@ export const mentionSuggestionOptions: MentionOptions['suggestion'] = {
render: () => {
let component: ReactRenderer<MentionRef> | undefined
let popup: TippyInstance | undefined
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
@ -80,6 +88,10 @@ export const mentionSuggestionOptions: MentionOptions['suggestion'] = {
editor: props.editor,
})
console.log('in onStart')
const rect = props.clientRect?.()
console.log(rect)
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
@ -105,6 +117,10 @@ export const mentionSuggestionOptions: MentionOptions['suggestion'] = {
return true
}
if (props.event.key === 'Tab') {
popup?.hide()
}
if (!component?.ref) {
return false
}