594 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			594 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
import { useCallback, useState, useRef } from 'react';
 | 
						|
 | 
						|
import {
 | 
						|
  defineMessages,
 | 
						|
  useIntl,
 | 
						|
  FormattedMessage,
 | 
						|
  FormattedList,
 | 
						|
} from 'react-intl';
 | 
						|
 | 
						|
import classNames from 'classnames';
 | 
						|
import { useHistory } from 'react-router-dom';
 | 
						|
 | 
						|
import { isFulfilled } from '@reduxjs/toolkit';
 | 
						|
 | 
						|
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
 | 
						|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
 | 
						|
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
 | 
						|
import {
 | 
						|
  clickSearchResult,
 | 
						|
  forgetSearchResult,
 | 
						|
  openURL,
 | 
						|
} from 'flavours/glitch/actions/search';
 | 
						|
import { Icon } from 'flavours/glitch/components/icon';
 | 
						|
import { useIdentity } from 'flavours/glitch/identity_context';
 | 
						|
import { domain, searchEnabled } from 'flavours/glitch/initial_state';
 | 
						|
import type { RecentSearch, SearchType } from 'flavours/glitch/models/search';
 | 
						|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
 | 
						|
import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
 | 
						|
 | 
						|
const messages = defineMessages({
 | 
						|
  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
 | 
						|
  placeholderSignedIn: {
 | 
						|
    id: 'search.search_or_paste',
 | 
						|
    defaultMessage: 'Search or paste URL',
 | 
						|
  },
 | 
						|
});
 | 
						|
 | 
						|
const labelForRecentSearch = (search: RecentSearch) => {
 | 
						|
  switch (search.type) {
 | 
						|
    case 'account':
 | 
						|
      return `@${search.q}`;
 | 
						|
    case 'hashtag':
 | 
						|
      return `#${search.q}`;
 | 
						|
    default:
 | 
						|
      return search.q;
 | 
						|
  }
 | 
						|
};
 | 
						|
 | 
						|
const unfocus = () => {
 | 
						|
  document.querySelector('.ui')?.parentElement?.focus();
 | 
						|
};
 | 
						|
 | 
						|
interface SearchOption {
 | 
						|
  key: string;
 | 
						|
  label: React.ReactNode;
 | 
						|
  action: (e: React.MouseEvent | React.KeyboardEvent) => void;
 | 
						|
  forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
 | 
						|
}
 | 
						|
 | 
						|
export const Search: React.FC<{
 | 
						|
  singleColumn: boolean;
 | 
						|
  initialValue?: string;
 | 
						|
}> = ({ singleColumn, initialValue }) => {
 | 
						|
  const intl = useIntl();
 | 
						|
  const recent = useAppSelector((state) => state.search.recent);
 | 
						|
  const { signedIn } = useIdentity();
 | 
						|
  const dispatch = useAppDispatch();
 | 
						|
  const history = useHistory();
 | 
						|
  const searchInputRef = useRef<HTMLInputElement>(null);
 | 
						|
  const [value, setValue] = useState(initialValue ?? '');
 | 
						|
  const hasValue = value.length > 0;
 | 
						|
  const [expanded, setExpanded] = useState(false);
 | 
						|
  const [selectedOption, setSelectedOption] = useState(-1);
 | 
						|
  const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
 | 
						|
  const searchOptions: SearchOption[] = [];
 | 
						|
 | 
						|
  if (searchEnabled) {
 | 
						|
    searchOptions.push(
 | 
						|
      {
 | 
						|
        key: 'prompt-has',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>has:</mark>{' '}
 | 
						|
            <FormattedList
 | 
						|
              type='disjunction'
 | 
						|
              value={['media', 'poll', 'embed']}
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('has:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-is',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>is:</mark>{' '}
 | 
						|
            <FormattedList type='disjunction' value={['reply', 'sensitive']} />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('is:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-language',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>language:</mark>{' '}
 | 
						|
            <FormattedMessage
 | 
						|
              id='search_popout.language_code'
 | 
						|
              defaultMessage='ISO language code'
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('language:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-from',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>from:</mark>{' '}
 | 
						|
            <FormattedMessage id='search_popout.user' defaultMessage='user' />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('from:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-before',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>before:</mark>{' '}
 | 
						|
            <FormattedMessage
 | 
						|
              id='search_popout.specific_date'
 | 
						|
              defaultMessage='specific date'
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('before:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-during',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>during:</mark>{' '}
 | 
						|
            <FormattedMessage
 | 
						|
              id='search_popout.specific_date'
 | 
						|
              defaultMessage='specific date'
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('during:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-after',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>after:</mark>{' '}
 | 
						|
            <FormattedMessage
 | 
						|
              id='search_popout.specific_date'
 | 
						|
              defaultMessage='specific date'
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('after:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
      {
 | 
						|
        key: 'prompt-in',
 | 
						|
        label: (
 | 
						|
          <>
 | 
						|
            <mark>in:</mark>{' '}
 | 
						|
            <FormattedList
 | 
						|
              type='disjunction'
 | 
						|
              value={['all', 'library', 'public']}
 | 
						|
            />
 | 
						|
          </>
 | 
						|
        ),
 | 
						|
        action: (e) => {
 | 
						|
          e.preventDefault();
 | 
						|
          insertText('in:');
 | 
						|
        },
 | 
						|
      },
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  const recentOptions: SearchOption[] = recent.map((search) => ({
 | 
						|
    key: `${search.type}/${search.q}`,
 | 
						|
    label: labelForRecentSearch(search),
 | 
						|
    action: () => {
 | 
						|
      setValue(search.q);
 | 
						|
 | 
						|
      if (search.type === 'account') {
 | 
						|
        history.push(`/@${search.q}`);
 | 
						|
      } else if (search.type === 'hashtag') {
 | 
						|
        history.push(`/tags/${search.q}`);
 | 
						|
      } else {
 | 
						|
        const queryParams = new URLSearchParams({ q: search.q });
 | 
						|
        if (search.type) queryParams.set('type', search.type);
 | 
						|
        history.push({ pathname: '/search', search: queryParams.toString() });
 | 
						|
      }
 | 
						|
 | 
						|
      unfocus();
 | 
						|
    },
 | 
						|
    forget: (e) => {
 | 
						|
      e.stopPropagation();
 | 
						|
      void dispatch(forgetSearchResult(search.q));
 | 
						|
    },
 | 
						|
  }));
 | 
						|
 | 
						|
  const navigableOptions = hasValue
 | 
						|
    ? quickActions.concat(searchOptions)
 | 
						|
    : recentOptions.concat(quickActions, searchOptions);
 | 
						|
 | 
						|
  const insertText = (text: string) => {
 | 
						|
    setValue((currentValue) => {
 | 
						|
      if (currentValue === '') {
 | 
						|
        return text;
 | 
						|
      } else if (currentValue.endsWith(' ')) {
 | 
						|
        return `${currentValue}${text}`;
 | 
						|
      } else {
 | 
						|
        return `${currentValue} ${text}`;
 | 
						|
      }
 | 
						|
    });
 | 
						|
  };
 | 
						|
 | 
						|
  const submit = useCallback(
 | 
						|
    (q: string, type?: SearchType) => {
 | 
						|
      void dispatch(clickSearchResult({ q, type }));
 | 
						|
      const queryParams = new URLSearchParams({ q });
 | 
						|
      if (type) queryParams.set('type', type);
 | 
						|
      history.push({ pathname: '/search', search: queryParams.toString() });
 | 
						|
      unfocus();
 | 
						|
    },
 | 
						|
    [dispatch, history],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleChange = useCallback(
 | 
						|
    ({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
 | 
						|
      setValue(value);
 | 
						|
 | 
						|
      const trimmedValue = value.trim();
 | 
						|
      const newQuickActions = [];
 | 
						|
 | 
						|
      if (trimmedValue.length > 0) {
 | 
						|
        const couldBeURL =
 | 
						|
          trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
 | 
						|
 | 
						|
        if (couldBeURL) {
 | 
						|
          newQuickActions.push({
 | 
						|
            key: 'open-url',
 | 
						|
            label: (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search.quick_action.open_url'
 | 
						|
                defaultMessage='Open URL in Mastodon'
 | 
						|
              />
 | 
						|
            ),
 | 
						|
            action: async () => {
 | 
						|
              const result = await dispatch(openURL({ url: trimmedValue }));
 | 
						|
 | 
						|
              if (isFulfilled(result)) {
 | 
						|
                if (result.payload.accounts[0]) {
 | 
						|
                  history.push(`/@${result.payload.accounts[0].acct}`);
 | 
						|
                } else if (result.payload.statuses[0]) {
 | 
						|
                  history.push(
 | 
						|
                    `/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
 | 
						|
                  );
 | 
						|
                }
 | 
						|
              }
 | 
						|
 | 
						|
              unfocus();
 | 
						|
            },
 | 
						|
          });
 | 
						|
        }
 | 
						|
 | 
						|
        const couldBeHashtag =
 | 
						|
          (trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
 | 
						|
          trimmedValue.match(HASHTAG_REGEX);
 | 
						|
 | 
						|
        if (couldBeHashtag) {
 | 
						|
          newQuickActions.push({
 | 
						|
            key: 'go-to-hashtag',
 | 
						|
            label: (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search.quick_action.go_to_hashtag'
 | 
						|
                defaultMessage='Go to hashtag {x}'
 | 
						|
                values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
 | 
						|
              />
 | 
						|
            ),
 | 
						|
            action: () => {
 | 
						|
              const query = trimmedValue.replace(/^#/, '');
 | 
						|
              history.push(`/tags/${query}`);
 | 
						|
              void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
 | 
						|
              unfocus();
 | 
						|
            },
 | 
						|
          });
 | 
						|
        }
 | 
						|
 | 
						|
        const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
 | 
						|
 | 
						|
        if (couldBeUsername) {
 | 
						|
          newQuickActions.push({
 | 
						|
            key: 'go-to-account',
 | 
						|
            label: (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search.quick_action.go_to_account'
 | 
						|
                defaultMessage='Go to profile {x}'
 | 
						|
                values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
 | 
						|
              />
 | 
						|
            ),
 | 
						|
            action: () => {
 | 
						|
              const query = trimmedValue.replace(/^@/, '');
 | 
						|
              history.push(`/@${query}`);
 | 
						|
              void dispatch(clickSearchResult({ q: query, type: 'account' }));
 | 
						|
              unfocus();
 | 
						|
            },
 | 
						|
          });
 | 
						|
        }
 | 
						|
 | 
						|
        const couldBeStatusSearch = searchEnabled;
 | 
						|
 | 
						|
        if (couldBeStatusSearch && signedIn) {
 | 
						|
          newQuickActions.push({
 | 
						|
            key: 'status-search',
 | 
						|
            label: (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search.quick_action.status_search'
 | 
						|
                defaultMessage='Posts matching {x}'
 | 
						|
                values={{ x: <mark>{trimmedValue}</mark> }}
 | 
						|
              />
 | 
						|
            ),
 | 
						|
            action: () => {
 | 
						|
              submit(trimmedValue, 'statuses');
 | 
						|
            },
 | 
						|
          });
 | 
						|
        }
 | 
						|
 | 
						|
        newQuickActions.push({
 | 
						|
          key: 'account-search',
 | 
						|
          label: (
 | 
						|
            <FormattedMessage
 | 
						|
              id='search.quick_action.account_search'
 | 
						|
              defaultMessage='Profiles matching {x}'
 | 
						|
              values={{ x: <mark>{trimmedValue}</mark> }}
 | 
						|
            />
 | 
						|
          ),
 | 
						|
          action: () => {
 | 
						|
            submit(trimmedValue, 'accounts');
 | 
						|
          },
 | 
						|
        });
 | 
						|
      }
 | 
						|
 | 
						|
      setQuickActions(newQuickActions);
 | 
						|
    },
 | 
						|
    [dispatch, history, signedIn, setValue, setQuickActions, submit],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleClear = useCallback(() => {
 | 
						|
    setValue('');
 | 
						|
    setQuickActions([]);
 | 
						|
    setSelectedOption(-1);
 | 
						|
  }, [setValue, setQuickActions, setSelectedOption]);
 | 
						|
 | 
						|
  const handleKeyDown = useCallback(
 | 
						|
    (e: React.KeyboardEvent) => {
 | 
						|
      switch (e.key) {
 | 
						|
        case 'Escape':
 | 
						|
          e.preventDefault();
 | 
						|
          unfocus();
 | 
						|
 | 
						|
          break;
 | 
						|
        case 'ArrowDown':
 | 
						|
          e.preventDefault();
 | 
						|
 | 
						|
          if (navigableOptions.length > 0) {
 | 
						|
            setSelectedOption(
 | 
						|
              Math.min(selectedOption + 1, navigableOptions.length - 1),
 | 
						|
            );
 | 
						|
          }
 | 
						|
 | 
						|
          break;
 | 
						|
        case 'ArrowUp':
 | 
						|
          e.preventDefault();
 | 
						|
 | 
						|
          if (navigableOptions.length > 0) {
 | 
						|
            setSelectedOption(Math.max(selectedOption - 1, -1));
 | 
						|
          }
 | 
						|
 | 
						|
          break;
 | 
						|
        case 'Enter':
 | 
						|
          e.preventDefault();
 | 
						|
 | 
						|
          if (selectedOption === -1) {
 | 
						|
            submit(value);
 | 
						|
          } else if (navigableOptions.length > 0) {
 | 
						|
            navigableOptions[selectedOption]?.action(e);
 | 
						|
          }
 | 
						|
 | 
						|
          break;
 | 
						|
        case 'Delete':
 | 
						|
          if (selectedOption > -1 && navigableOptions.length > 0) {
 | 
						|
            const search = navigableOptions[selectedOption];
 | 
						|
 | 
						|
            if (typeof search?.forget === 'function') {
 | 
						|
              e.preventDefault();
 | 
						|
              search.forget(e);
 | 
						|
            }
 | 
						|
          }
 | 
						|
 | 
						|
          break;
 | 
						|
      }
 | 
						|
    },
 | 
						|
    [navigableOptions, value, selectedOption, setSelectedOption, submit],
 | 
						|
  );
 | 
						|
 | 
						|
  const handleFocus = useCallback(() => {
 | 
						|
    setExpanded(true);
 | 
						|
    setSelectedOption(-1);
 | 
						|
 | 
						|
    if (searchInputRef.current && !singleColumn) {
 | 
						|
      const { left, right } = searchInputRef.current.getBoundingClientRect();
 | 
						|
 | 
						|
      if (
 | 
						|
        left < 0 ||
 | 
						|
        right > (window.innerWidth || document.documentElement.clientWidth)
 | 
						|
      ) {
 | 
						|
        searchInputRef.current.scrollIntoView();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }, [setExpanded, setSelectedOption, singleColumn]);
 | 
						|
 | 
						|
  const handleBlur = useCallback(() => {
 | 
						|
    setExpanded(false);
 | 
						|
    setSelectedOption(-1);
 | 
						|
  }, [setExpanded, setSelectedOption]);
 | 
						|
 | 
						|
  return (
 | 
						|
    <form className={classNames('search', { active: expanded })}>
 | 
						|
      <input
 | 
						|
        ref={searchInputRef}
 | 
						|
        className='search__input'
 | 
						|
        type='text'
 | 
						|
        placeholder={intl.formatMessage(
 | 
						|
          signedIn ? messages.placeholderSignedIn : messages.placeholder,
 | 
						|
        )}
 | 
						|
        aria-label={intl.formatMessage(
 | 
						|
          signedIn ? messages.placeholderSignedIn : messages.placeholder,
 | 
						|
        )}
 | 
						|
        value={value}
 | 
						|
        onChange={handleChange}
 | 
						|
        onKeyDown={handleKeyDown}
 | 
						|
        onFocus={handleFocus}
 | 
						|
        onBlur={handleBlur}
 | 
						|
      />
 | 
						|
 | 
						|
      <button type='button' className='search__icon' onClick={handleClear}>
 | 
						|
        <Icon
 | 
						|
          id='search'
 | 
						|
          icon={SearchIcon}
 | 
						|
          className={hasValue ? '' : 'active'}
 | 
						|
        />
 | 
						|
        <Icon
 | 
						|
          id='times-circle'
 | 
						|
          icon={CancelIcon}
 | 
						|
          className={hasValue ? 'active' : ''}
 | 
						|
          aria-label={intl.formatMessage(messages.placeholder)}
 | 
						|
        />
 | 
						|
      </button>
 | 
						|
 | 
						|
      <div className='search__popout'>
 | 
						|
        {!hasValue && (
 | 
						|
          <>
 | 
						|
            <h4>
 | 
						|
              <FormattedMessage
 | 
						|
                id='search_popout.recent'
 | 
						|
                defaultMessage='Recent searches'
 | 
						|
              />
 | 
						|
            </h4>
 | 
						|
 | 
						|
            <div className='search__popout__menu'>
 | 
						|
              {recentOptions.length > 0 ? (
 | 
						|
                recentOptions.map(({ label, key, action, forget }, i) => (
 | 
						|
                  <button
 | 
						|
                    key={key}
 | 
						|
                    onMouseDown={action}
 | 
						|
                    className={classNames(
 | 
						|
                      'search__popout__menu__item search__popout__menu__item--flex',
 | 
						|
                      { selected: selectedOption === i },
 | 
						|
                    )}
 | 
						|
                  >
 | 
						|
                    <span>{label}</span>
 | 
						|
                    <button className='icon-button' onMouseDown={forget}>
 | 
						|
                      <Icon id='times' icon={CloseIcon} />
 | 
						|
                    </button>
 | 
						|
                  </button>
 | 
						|
                ))
 | 
						|
              ) : (
 | 
						|
                <div className='search__popout__menu__message'>
 | 
						|
                  <FormattedMessage
 | 
						|
                    id='search.no_recent_searches'
 | 
						|
                    defaultMessage='No recent searches'
 | 
						|
                  />
 | 
						|
                </div>
 | 
						|
              )}
 | 
						|
            </div>
 | 
						|
          </>
 | 
						|
        )}
 | 
						|
 | 
						|
        {quickActions.length > 0 && (
 | 
						|
          <>
 | 
						|
            <h4>
 | 
						|
              <FormattedMessage
 | 
						|
                id='search_popout.quick_actions'
 | 
						|
                defaultMessage='Quick actions'
 | 
						|
              />
 | 
						|
            </h4>
 | 
						|
 | 
						|
            <div className='search__popout__menu'>
 | 
						|
              {quickActions.map(({ key, label, action }, i) => (
 | 
						|
                <button
 | 
						|
                  key={key}
 | 
						|
                  onMouseDown={action}
 | 
						|
                  className={classNames('search__popout__menu__item', {
 | 
						|
                    selected: selectedOption === i,
 | 
						|
                  })}
 | 
						|
                >
 | 
						|
                  {label}
 | 
						|
                </button>
 | 
						|
              ))}
 | 
						|
            </div>
 | 
						|
          </>
 | 
						|
        )}
 | 
						|
 | 
						|
        <h4>
 | 
						|
          <FormattedMessage
 | 
						|
            id='search_popout.options'
 | 
						|
            defaultMessage='Search options'
 | 
						|
          />
 | 
						|
        </h4>
 | 
						|
 | 
						|
        {searchEnabled && signedIn ? (
 | 
						|
          <div className='search__popout__menu'>
 | 
						|
            {searchOptions.map(({ key, label, action }, i) => (
 | 
						|
              <button
 | 
						|
                key={key}
 | 
						|
                onMouseDown={action}
 | 
						|
                className={classNames('search__popout__menu__item', {
 | 
						|
                  selected:
 | 
						|
                    selectedOption ===
 | 
						|
                    (quickActions.length || recent.length) + i,
 | 
						|
                })}
 | 
						|
              >
 | 
						|
                {label}
 | 
						|
              </button>
 | 
						|
            ))}
 | 
						|
          </div>
 | 
						|
        ) : (
 | 
						|
          <div className='search__popout__menu__message'>
 | 
						|
            {searchEnabled ? (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search_popout.full_text_search_logged_out_message'
 | 
						|
                defaultMessage='Only available when logged in.'
 | 
						|
              />
 | 
						|
            ) : (
 | 
						|
              <FormattedMessage
 | 
						|
                id='search_popout.full_text_search_disabled_message'
 | 
						|
                defaultMessage='Not available on {domain}.'
 | 
						|
                values={{ domain }}
 | 
						|
              />
 | 
						|
            )}
 | 
						|
          </div>
 | 
						|
        )}
 | 
						|
      </div>
 | 
						|
    </form>
 | 
						|
  );
 | 
						|
};
 |