diff --git a/components/MentionList/index.tsx b/components/MentionList/index.tsx index 499fb89a..e90d6336 100644 --- a/components/MentionList/index.tsx +++ b/components/MentionList/index.tsx @@ -11,7 +11,7 @@ import classNames from 'classnames' import styles from './index.module.scss' -type Props = Pick +type Props = Pick export type MentionRef = { onKeyDown: (props: { event: KeyboardEvent }) => boolean @@ -113,7 +113,9 @@ export const MentionList = forwardRef( )) ) : (
- {t('search.errors.no_results_generic')} + {props.query.length < 3 + ? t('search.errors.type') + : t('search.errors.no_results_generic')}
)} diff --git a/components/auth/AccountModal/index.tsx b/components/auth/AccountModal/index.tsx index 48b50dc2..1cfa9cb1 100644 --- a/components/auth/AccountModal/index.tsx +++ b/components/auth/AccountModal/index.tsx @@ -277,8 +277,8 @@ const AccountModal = React.forwardRef( {}} onEscapeKeyDown={onEscapeKeyDown} > diff --git a/components/auth/LoginModal/index.tsx b/components/auth/LoginModal/index.tsx index 618d36d0..5c2d8671 100644 --- a/components/auth/LoginModal/index.tsx +++ b/components/auth/LoginModal/index.tsx @@ -218,7 +218,7 @@ const LoginModal = (props: Props) => { diff --git a/components/auth/SignupModal/index.tsx b/components/auth/SignupModal/index.tsx index a4597d7e..7b57c82d 100644 --- a/components/auth/SignupModal/index.tsx +++ b/components/auth/SignupModal/index.tsx @@ -298,7 +298,7 @@ const SignupModal = (props: Props) => { diff --git a/components/character/CharacterConflictModal/index.tsx b/components/character/CharacterConflictModal/index.tsx index 5ad5acbb..b366ec5c 100644 --- a/components/character/CharacterConflictModal/index.tsx +++ b/components/character/CharacterConflictModal/index.tsx @@ -77,7 +77,7 @@ const CharacterConflictModal = (props: Props) => { event.preventDefault()} onEscapeKeyDown={close} > diff --git a/components/character/CharacterModal/index.tsx b/components/character/CharacterModal/index.tsx index f42b3114..6a170d6d 100644 --- a/components/character/CharacterModal/index.tsx +++ b/components/character/CharacterModal/index.tsx @@ -354,8 +354,8 @@ const CharacterModal = ({ {children} event.preventDefault()} onEscapeKeyDown={() => {}} > diff --git a/components/common/DialogContent/index.tsx b/components/common/DialogContent/index.tsx index 44823b3b..001f5426 100644 --- a/components/common/DialogContent/index.tsx +++ b/components/common/DialogContent/index.tsx @@ -12,15 +12,15 @@ interface Props HTMLDivElement > { wrapperClassName?: string - headerref?: React.RefObject - footerref?: React.RefObject + headerRef?: React.RefObject + footerRef?: React.RefObject scrollable?: boolean onEscapeKeyDown: (event: KeyboardEvent) => void onOpenAutoFocus: (event: Event) => void } const DialogContent = React.forwardRef(function Dialog( - { scrollable, children, ...props }, + { scrollable, wrapperClassName, headerRef, footerRef, children, ...props }, forwardedRef ) { // Classes @@ -37,12 +37,12 @@ const DialogContent = React.forwardRef(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(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(function Dialog( { [styles.dialog]: true, }, - props.wrapperClassName - ?.split(' ') - .map((className) => styles[className]) + wrapperClassName?.split(' ').map((className) => styles[className]) )} > , + 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 ( + + + + ) +} + +export default MentionTableField diff --git a/components/common/MentionTypeahead/index.module.scss b/components/common/MentionTypeahead/index.module.scss new file mode 100644 index 00000000..7dde22be --- /dev/null +++ b/components/common/MentionTypeahead/index.module.scss @@ -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); + } + } + } +} diff --git a/components/common/MentionTypeahead/index.tsx b/components/common/MentionTypeahead/index.tsx new file mode 100644 index 00000000..424f69f8 --- /dev/null +++ b/components/common/MentionTypeahead/index.tsx @@ -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 + > { + 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([]) + + 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 ( + + {results.map((option, index) => ( + + {renderMenuItemChild(option)} + + ))} + + ) + } + + function renderMenuItemChild(option: Option) { + const item = option as MentionItem + return ( +
+
+ {item.name[locale]} +
+ {item.name[locale]} +
+ ) + } + + function renderToken(option: Option, props: RenderTokenProps) { + const item = option as MentionItem + const { labelKey, ...tokenProps } = props + return ( + + {item.name[locale]} + + ) + } + + return ( + (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 diff --git a/components/common/TableField/index.module.scss b/components/common/TableField/index.module.scss index e48b5328..8945c3cd 100644 --- a/components/common/TableField/index.module.scss +++ b/components/common/TableField/index.module.scss @@ -44,6 +44,10 @@ } } + &.mention { + grid-template-columns: 1fr 1fr; + } + .left { align-items: center; display: flex; diff --git a/components/filters/FilterModal/index.module.scss b/components/filters/FilterModal/index.module.scss index c1a55e87..237ff075 100644 --- a/components/filters/FilterModal/index.module.scss +++ b/components/filters/FilterModal/index.module.scss @@ -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; @@ -34,11 +50,16 @@ .fields { display: flex; flex-direction: column; - gap: $unit-2x; - padding: 0 $unit-4x; + gap: $unit-4x; + section { + display: flex; + flex-direction: column; + gap: $unit-2x; + padding: 0 $unit-4x; - @include breakpoint(phone) { - gap: $unit-4x; - margin-bottom: $unit * 24; + @include breakpoint(phone) { + gap: $unit-4x; + margin-bottom: $unit * 24; + } } } diff --git a/components/filters/FilterModal/index.tsx b/components/filters/FilterModal/index.tsx index ce73f588..1ca28cd6 100644 --- a/components/filters/FilterModal/index.tsx +++ b/components/filters/FilterModal/index.tsx @@ -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([]) + const [exclusions, setExclusions] = useState([]) const [filterSet, setFilterSet] = useState({}) // 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 = ( + + ) + + const exclusionField = ( + + ) + const filterNotice = () => { if (props.persistFilters) return null return ( @@ -404,25 +448,39 @@ const FilterModal = (props: Props) => {
{filterNotice()} - {chargeAttackField()} - {fullAutoField()} - {autoGuardField()} - {/* {maxButtonsField()} */} - {/* {maxTurnsField()} */} - {minCharactersField()} - {minSummonsField()} - {minWeaponsField()} - {nameQualityField()} - {userQualityField()} - {originalOnlyField()} +
+
+

{t('modals.filters.headers.items.name')}

+

{t('modals.filters.headers.items.description')}

+
+ {inclusionField} + {exclusionField} +
+
+
+

{t('modals.filters.headers.details.name')}

+

{t('modals.filters.headers.details.description')}

+
+ {chargeAttackField()} + {fullAutoField()} + {autoGuardField()} + {/* {maxButtonsField()} */} + {/* {maxTurnsField()} */} + {minCharactersField()} + {minSummonsField()} + {minWeaponsField()} + {nameQualityField()} + {userQualityField()} + {originalOnlyField()} +
{ {props.children} { event.preventDefault()} onEscapeKeyDown={close} > diff --git a/components/weapon/WeaponModal/index.tsx b/components/weapon/WeaponModal/index.tsx index 5502168e..13d63335 100644 --- a/components/weapon/WeaponModal/index.tsx +++ b/components/weapon/WeaponModal/index.tsx @@ -469,8 +469,8 @@ const WeaponModal = ({ {children} event.preventDefault()} onEscapeKeyDown={onEscapeKeyDown} > diff --git a/extensions/CustomSuggestion/findSuggestionMatch.tsx b/extensions/CustomSuggestion/findSuggestionMatch.tsx new file mode 100644 index 00000000..85bdcb80 --- /dev/null +++ b/extensions/CustomSuggestion/findSuggestionMatch.tsx @@ -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 +} diff --git a/extensions/CustomSuggestion/index.tsx b/extensions/CustomSuggestion/index.tsx new file mode 100644 index 00000000..914c2bb1 --- /dev/null +++ b/extensions/CustomSuggestion/index.tsx @@ -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 { + 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 + render?: () => { + onBeforeStart?: (props: SuggestionProps) => void + onStart?: (props: SuggestionProps) => void + onBeforeUpdate?: (props: SuggestionProps) => void + onUpdate?: (props: SuggestionProps) => void + onExit?: (props: SuggestionProps) => void + onFocus?: (props: SuggestionProps) => void + onKeyDown?: (props: SuggestionKeyDownProps) => boolean + } + allow?: (props: { + editor: Editor + state: EditorState + range: Range + }) => boolean +} + +export interface SuggestionProps { + 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({ + pluginKey = SuggestionPluginKey, + editor, + char = '@', + allowSpaces = true, + allowedPrefixes = [' '], + startOfLine = false, + decorationTag = 'span', + decorationClass = 'suggestion', + command = () => null, + items = () => [], + render = () => ({}), + allow = () => true, +}: SuggestionOptions) { + let props: SuggestionProps | undefined + const renderer = render?.() + + const plugin: Plugin = 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 we’ll 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 +} diff --git a/extensions/NoNewLine/index.tsx b/extensions/NoNewLine/index.tsx new file mode 100644 index 00000000..f4358851 --- /dev/null +++ b/extensions/NoNewLine/index.tsx @@ -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 diff --git a/package-lock.json b/package-lock.json index bf7ee3bf..ed9c6e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index eeea6e24..0064359b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/_app.tsx b/pages/_app.tsx index d64c8bf9..1762afa9 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -158,9 +158,7 @@ function MyApp({ Component, pageProps }: AppProps) { {!appState.version ? ( serverUnavailable() ) : ( -
- -
+ )} diff --git a/pages/teams.tsx b/pages/teams.tsx index cd21d70a..e6392606 100644 --- a/pages/teams.tsx +++ b/pages/teams.tsx @@ -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 = ({ 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 diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 529fe5b0..d40113bc 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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 user profiles and in your saved teams 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...", diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json index 8c9fe7e4..c4ae05b7 100644 --- a/public/locales/ja/common.json +++ b/public/locales/ja/common.json @@ -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": "フィルターは保存した編成ユーザープロフィールには保存されません", "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": "武器を検索...", diff --git a/styles/themes.scss b/styles/themes.scss index 5f256bd6..39034582 100644 --- a/styles/themes.scss +++ b/styles/themes.scss @@ -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}; diff --git a/styles/variables.scss b/styles/variables.scss index 8877117a..7e91b7d5 100644 --- a/styles/variables.scss +++ b/styles/variables.scss @@ -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; diff --git a/tsconfig.json b/tsconfig.json index 08859ee8..8c6bf634 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "baseUrl": ".", - "target": "es5", + "target": "es2015", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/types/FilterSet.d.ts b/types/FilterSet.d.ts index fc42195d..98bd1b02 100644 --- a/types/FilterSet.d.ts +++ b/types/FilterSet.d.ts @@ -13,4 +13,6 @@ interface FilterSet { name_quality?: boolean user_quality?: boolean original?: boolean + includes?: MentionItem[] + excludes?: MentionItem[] } diff --git a/types/MentionItem.d.ts b/types/MentionItem.d.ts new file mode 100644 index 00000000..a431e1de --- /dev/null +++ b/types/MentionItem.d.ts @@ -0,0 +1,10 @@ +interface MentionItem { + name: { + [key: string]: string + en: string + ja: string + } + type: string + granblue_id: string + element: GranblueElement +} diff --git a/utils/api.tsx b/utils/api.tsx index 63ac5ba4..e5e01ca1 100644 --- a/utils/api.tsx +++ b/utils/api.tsx @@ -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 } }) diff --git a/utils/convertAdvancedFilters.tsx b/utils/convertAdvancedFilters.tsx new file mode 100644 index 00000000..3fc4540f --- /dev/null +++ b/utils/convertAdvancedFilters.tsx @@ -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(',') +} diff --git a/utils/mentionSuggestions.tsx b/utils/mentionSuggestions.tsx index 18e4b8a9..8bc075c1 100644 --- a/utils/mentionSuggestions.tsx +++ b/utils/mentionSuggestions.tsx @@ -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 => { +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 = { + items: async ({ query, editor }): Promise => { 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 | 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 }