preact/hooks#useRef JavaScript Examples

The following examples show how to use preact/hooks#useRef. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: DeoptTables.js    From v8-deopt-viewer with MIT License 6 votes vote down vote up
/**
 * @param {boolean} selected
 * @returns {import("preact").RefObject<HTMLDivElement>}
 */
function useScrollIntoView(selected) {
	/** @type {import("preact").RefObject<HTMLDivElement>} */
	const ref = useRef(null);
	useEffect(() => {
		if (selected) {
			// TODO: Why doesn't the smooth behavior always work? It seems that only
			// the first or last call to scrollIntoView with behavior smooth works?
			ref.current.scrollIntoView({ block: "center" });
		}
	}, [selected]);

	return selected ? ref : null;
}
Example #2
Source File: index.js    From duolingo-solution-viewer with MIT License 6 votes vote down vote up
useLocalStorageList = (key, stateSet, initialValue) => {
  const isInitialized = useRef(false);
  const [ storedState, storeState ] = useLocalStorage(key, initialValue);

  const { state, prevState, nextState, prev, next } = useStateList(
    stateSet,
    stateSet.indexOf(storedState) === -1 ? initialValue : storedState
  );

  useEffect(() => {
    // Skip the first call to prevent overriding the stored state with a temporary default state.
    if (isInitialized.current) {
      storeState(state)
    } else {
      isInitialized.current = true;
    }
  }, [ state, storeState ]);

  return { state, prevState, nextState, prev, next };
}
Example #3
Source File: index.js    From duolingo-solution-viewer with MIT License 6 votes vote down vote up
useThrottledCallback = (callback, delay = 200, args) => {
  const timeout = useRef();

  return useCallback(() => {
    if (!timeout.current) {
      callback(...args);
      timeout.current = setTimeout(() => (timeout.current = null), delay);
    }
  }, args.concat(callback, delay)); // eslint-disable-line react-hooks/exhaustive-deps
}
Example #4
Source File: CodePanel.js    From v8-deopt-viewer with MIT License 5 votes vote down vote up
/**
 * @typedef CodePanelProps
 * @property {import("../").FileV8DeoptInfoWithSources} fileDeoptInfo
 * @property {number} fileId
 * @property {import('./CodeSettings').CodeSettingsState} settings
 * @param {CodePanelProps} props
 */
export function CodePanel({ fileDeoptInfo, fileId, settings }) {
	if (fileDeoptInfo.srcError) {
		return <CodeError srcError={fileDeoptInfo.srcError} />;
	} else if (!fileDeoptInfo.src) {
		return <CodeError srcError="No sources for the file were found." />;
	}

	const lang = determineLanguage(fileDeoptInfo.srcPath);

	const state = useAppState();
	const selectedLine = state.selectedPosition?.line;

	/**
	 * @typedef {Map<string, import('../utils/deoptMarkers').Marker>} MarkerMap
	 * @type {[MarkerMap, import('preact/hooks').StateUpdater<MarkerMap>]}
	 */
	const [markers, setMarkers] = useState(null);

	/** @type {import('preact').RefObject<HTMLElement>} */
	const codeRef = useRef(null);
	useLayoutEffect(() => {
		// Saved the new markers so we can select them when CodePanelContext changes
		const markers = addDeoptMarkers(codeRef.current, fileId, fileDeoptInfo);
		setMarkers(new Map(markers.map((marker) => [marker.id, marker])));
	}, [fileId, fileDeoptInfo]);

	useEffect(() => {
		if (state.prevSelectedEntry) {
			markers
				.get(getMarkerId(state.prevSelectedEntry))
				?.classList.remove(active);
		}

		/** @type {ScrollIntoViewOptions} */
		const scrollIntoViewOpts = { block: "center", behavior: "smooth" };
		if (state.selectedEntry) {
			const target = markers.get(getMarkerId(state.selectedEntry));
			target.classList.add(active);
			// TODO: Why doesn't the smooth behavior always work? It seems that only
			// the first or last call to scrollIntoView with behavior smooth works?
			target.scrollIntoView(scrollIntoViewOpts);
		} else if (state.selectedPosition) {
			const lineSelector = `.line-numbers-rows > span:nth-child(${state.selectedPosition.line})`;
			document.querySelector(lineSelector)?.scrollIntoView(scrollIntoViewOpts);
		}

		// TODO: Figure out how to scroll line number into view when
		// selectedPosition is set but selectedMarkerId is not
	}, [state]);

	return (
		<div
			class={[
				codePanel,
				(settings.showLowSevs && showLowSevsClass) || null,
			].join(" ")}
		>
			<PrismCode
				src={fileDeoptInfo.src}
				lang={lang}
				class={(!settings.hideLineNums && "line-numbers") || null}
				ref={codeRef}
			>
				<LineNumbers selectedLine={selectedLine} contents={fileDeoptInfo.src} />
			</PrismCode>
		</div>
	);
}
Example #5
Source File: Dropdown.js    From duolingo-solution-viewer with MIT License 5 votes vote down vote up
Dropdown = forwardRef(
  (
    {
      context = CONTEXT_CHALLENGE,
      getOptionKey = ((option, index) => index),
      renderOption = (option => <Item {...option} context={context} />),
      options = [],
      onSelect = noop,
      onClose = noop,
    },
    ref
  ) => {
    const wrapper = useRef();
    const content = useRef();
    const portalContainer = usePortalContainer();

    const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

    // Positions the content at the right spot, and closes the dropdown on any scroll or resize event.
    useEffect(() => {
      if (wrapper.current && content.current) {
        const { left: wrapperLeft, top: wrapperTop } = wrapper.current.getBoundingClientRect();

        const itemsWidth = content.current.clientWidth;
        const itemsBaseLeft = wrapperLeft - Math.ceil(itemsWidth / 2);
        const itemsMinLeft = 10;
        const itemsMaxLeft = document.body.clientWidth - itemsWidth - itemsMinLeft;
        const itemsLeft = Math.max(itemsMinLeft, Math.min(itemsBaseLeft, itemsMaxLeft));

        content.current.style.setProperty('top', `${wrapperTop}px`);
        content.current.style.setProperty('left', `${itemsLeft}px`);
        content.current.style.setProperty('visibility', 'visible', 'important');

        const scrollableAncestors = getAncestorsWithScrollOverflow(wrapper.current);

        window.addEventListener('resize', onClose);
        scrollableAncestors.forEach(it.addEventListener('scroll', onClose));

        return () => {
          window.removeEventListener('resize', onClose);
          scrollableAncestors.forEach(it.removeEventListener('scroll', onClose));
        }
      }
    }, [ onClose, wrapper, content ]);

    // Renders a single option.
    const renderOptionItem = (option, index) => {
      const key = getOptionKey(option, index);

      const onClick = event => {
        discardEvent(event);
        onSelect(key);
      };

      return (
        <div key={key} onClick={onClick} className={getElementClassNames(ITEM_WRAPPER)}>
          {renderOption(option)}
        </div>
      );
    };

    return (
      <div ref={wrapper} className={getElementClassNames(WRAPPER)}>
        {createPortal(
          <div ref={useMergeRefs([ ref, content ])} className={getElementClassNames(CONTENT)}>
            <div className={getElementClassNames(ITEMS)}>
              {options.map(renderOptionItem)}
            </div>
          </div>,
          portalContainer
        )}
        {/* Keep the arrow within the DOM hierarchy so that it follows the content. */}
        <div className={getElementClassNames(ARROW)}>
          <div className={getElementClassNames(ARROW_ICON)} />
        </div>
      </div>
    );
  }
)
Example #6
Source File: FilterInput.js    From duolingo-solution-viewer with MIT License 5 votes vote down vote up
FilterSetting = ({ context, setting: { key, values }, currentFilter, onUpdate }) => {
  const menu = useRef();
  const wrapper = useRef();
  const [ isMenuDisplayed, setIsMenuDisplayed ] = useState(false);

  const currentValue = values.find(it.value === currentFilter[key]);

  const onCloseMenu = () => setIsMenuDisplayed(false);

  const onSelect = value => {
    onCloseMenu();
    onUpdate({ ...currentFilter, [key]: value });
  };

  const onToggleMenu = event => {
    discardEvent(event);
    setIsMenuDisplayed(!isMenuDisplayed);
  };

  useClickAway([ wrapper, menu ], onCloseMenu);

  const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

  return (
    <Localizer>
      <div
        ref={wrapper}
        onClick={onToggleMenu}
        title={<Text id={currentValue.labelId}>{currentValue.defaultLabel}</Text>}
        className={getElementClassNames(FILTER_SETTING)}
      >
        <FontAwesomeIcon
          icon={currentValue.icon}
          size="xs"
          fixedWidth
          className={getElementClassNames(FILTER_SETTING_ICON)}
        />
        {isMenuDisplayed && (
          <div>
            <Dropdown
              ref={menu}
              context={context}
              options={values}
              getOptionKey={({ value }) => value}
              onSelect={onSelect}
              onClose={onCloseMenu}
            />
          </div>
        )}
      </div>
    </Localizer>
  );
}
Example #7
Source File: Item.js    From vegemite with MIT License 5 votes vote down vote up
// ---

export default function (props) {
	const { id, title, completed } = props;

	const editor = useRef(null);
	const [editing, setEditing] = useState(false);

	useEffect(() => {
		if (editing) {
			editor.current.value = title;
			editor.current.focus();
		}
	}, [editing]);

	const classname = [
		editing && 'editing',
		completed && 'completed',
	].filter(Boolean).join(' ');

	function onToggle() {
		todomvc.dispatch('todo:toggle', id);
	}

	function onDestroy() {
		todomvc.dispatch('todo:del', id);
	}

	function onDblClick() {
		setEditing(true);
	}

	function onblur(ev) {
		let value = ev.target.value.trim();
		if (value.length > 0) {
			todomvc.dispatch('todo:put', { id, value });
		}
		ev.target.value = null;
		setEditing(false);
	}

	function onkeydown(ev) {
		if (ev.which === 27) {
			ev.target.value = null;
			setEditing(false);
		} else if (ev.which === 13) {
			onblur(ev);
		}
	}

	return (
		<li class={classname}>
			<div class="view">
				<input type="checkbox" class="toggle" checked={completed} onchange={onToggle} />
				<label onDblClick={onDblClick}>{title}</label>
				<button class="destroy" onclick={onDestroy} />
			</div>
			{ editing && <input ref={editor} class="edit" onblur={onblur} onkeydown={onkeydown} /> }
		</li>
	);
}
Example #8
Source File: Form.jsx    From todo-pwa with MIT License 5 votes vote down vote up
Form = ({ className = '', itemsAdd }) => {
  const input = useRef(null);
  const [value, setValue] = useState(
    new URL(window.location).searchParams.get('title') || ''
  );

  useEffect(() => {
    // The shareTargetAPI creates a get Request that looks like this:
    // /preact/?title={title}&text={text}&url={url}
    const params = new URL(window.location).searchParams;
    const v = [
      ...(params.get('title') ? [params.get('title')] : []),
      ...(params.get('text') ? [params.get('text')] : []),
      ...(params.get('url') ? [params.get('url')] : []),
    ];

    setValue(v.join(' - '));
  }, []);

  return (
    <div className={className}>
      <form
        className="flex items-stretch"
        autocomplete="off"
        onSubmit={e => {
          e.preventDefault();
          if (value !== '') {
            itemsAdd(value);
            setValue('');
            input.current.focus();
          }
        }}
      >
        <input
          type="text"
          name="title"
          id="title"
          className="appearance-none border rounded rounded-r-none w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
          ref={input}
          value={value}
          onKeyUp={e => setValue(e.target.value)}
          autocomplete="off"
        />
        <button
          type="submit"
          className="font-bold rounded rounded-l-none text-white px-4 hover:bg-indigo-700 bg-indigo-800 text-center no-underline block focus:shadow-outline focus:outline-none"
        >
          Add
        </button>
      </form>
      <ContactPicker value={value} setValue={setValue} className="w-full" />
    </div>
  );
}
Example #9
Source File: ChallengeSolutions.js    From duolingo-solution-viewer with MIT License 4 votes vote down vote up
ChallengeSolutions =
  ({
     context = CONTEXT_CHALLENGE,
     statement = '',
     solutions = [],
     matchingData = {},
     userReference = '',
     onUserReferenceUpdate = noop,
     isUserReferenceEditable = true,
     scrollOffsetGetter = (() => 0),
   }) => {
    const [ isLoading, setIsLoading ] = useState(false);
    const [ currentSolutions, setCurrentSolutions ] = useState(solutions);
    const [ currentUserReference, setCurrentUserReference ] = useState(userReference);
    const [ isUserReferencePinned, setIsUserReferencedPinned ] = useLocalStorage('user_reference_pinned', false);

    // Updates the user reference and waits for a new list of solutions.
    const updateUserReference = useCallback(newReference => {
      setIsLoading(true);
      setCurrentUserReference(newReference);

      Promise.resolve(onUserReferenceUpdate(newReference))
        .then(solutions => {
          if (isArray(solutions)) {
            setCurrentSolutions(solutions)
          } else {
            setCurrentUserReference(currentUserReference);
          }
        }).catch(() => (
          setCurrentUserReference(currentUserReference)
        )).then(() => {
          setIsLoading(false);
        });
    }, [
      onUserReferenceUpdate,
      setIsLoading,
      setCurrentSolutions,
      currentUserReference,
      setCurrentUserReference,
    ]);

    const listWrapper = useRef();
    const referenceWrapper = useRef();

    const fullScrollOffsetGetter = useCallback(() => (
      10
      + scrollOffsetGetter()
      + (isUserReferencePinned && referenceWrapper.current?.offsetHeight || 0)
    ), [ scrollOffsetGetter, isUserReferencePinned, referenceWrapper ]);

    // Scrolls to the top of the solution list whenever it changes.
    const onSolutionListChange = useCallback(() => {
      listWrapper.current
      && scrollElementIntoParentView(listWrapper.current, fullScrollOffsetGetter(), 'smooth');
    }, [ // eslint-disable-line react-hooks/exhaustive-deps
      listWrapper,
      fullScrollOffsetGetter,
      currentSolutions,
    ]);

    const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

    if (0 === currentSolutions.length) {
      return null;
    }

    return (
      <IntlProvider scope="challenge">
        {('' !== statement) && (
          <Fragment>
            <h3>
              <Text id="statement">Statement:</Text>
            </h3>
            <p>{statement}</p>
          </Fragment>
        )}

        <div
          ref={referenceWrapper}
          className={getElementClassNames([
            REFERENCE_WRAPPER,
            isUserReferencePinned && REFERENCE_WRAPPER__PINNED
          ])}
        >
          <UserReference
            context={context}
            reference={currentUserReference}
            onUpdate={updateUserReference}
            isEditable={isUserReferenceEditable && !isLoading}
          />
          {(CONTEXT_CHALLENGE === context)
          && (
            <IntlProvider scope="user_reference">
              <Localizer>
                <div
                  onClick={() => setIsUserReferencedPinned(!isUserReferencePinned)}
                  title={(
                    <Text id={isUserReferencePinned ? 'unpin' : 'pin'}>
                      {isUserReferencePinned ? 'Unpin' : 'Pin'}
                    </Text>
                  )}
                  className={getElementClassNames([
                    PIN_BUTTON,
                    isUserReferencePinned && PIN_BUTTON__PINNED
                  ])}
                >
                  <FontAwesomeIcon
                    icon={[ 'far', 'thumbtack' ]}
                    className={getElementClassNames(PIN_BUTTON_ICON)}
                  />
                </div>
              </Localizer>
            </IntlProvider>
          )}
        </div>

        <div>
          {isLoading
            ? (
              <div className={getElementClassNames(LOADER)}>
                <Loader />
              </div>
            ) : (
              <SolutionList
                ref={listWrapper}
                context={context}
                solutions={currentSolutions}
                matchingData={matchingData}
                onPageChange={onSolutionListChange}
                scrollOffsetGetter={fullScrollOffsetGetter}
              />
            )}
        </div>
      </IntlProvider>
    );
  }
Example #10
Source File: FilterInput.js    From duolingo-solution-viewer with MIT License 4 votes vote down vote up
FilterInput =
  ({
     context = CONTEXT_CHALLENGE,
     matchMode = STRING_MATCH_MODE_WORDS,
     matchingData = {},
     filters = [],
     minQueryLength = 2,
     onChange = noop,
     onFocus = noop,
     onBlur = noop,
   }) => {
    const {
      words: suggestableWords = [],
      locale = '',
      matchingOptions = {},
    } = matchingData;

    const suggestions = useMemo(() => (
      (suggestableWords || []).map((name, id) => ({
        id,
        name,
        searchable: normalizeString(name, false, true),
      }))
    ), [ suggestableWords ]);

    // Extracts a word filter from a user query.
    const parseWordFilter = useCallback(query => {
      const [ , sign = '', start = '', base = '', end = '' ] = /^([-+]?)([*=]?)(.+?)(\*?)$/ug.exec(query) || [];

      const word = getStringMatchableWords(base, locale, matchingOptions)[0] || '';
      const matchType = MATCH_TYPE_MAP[matchMode][start]?.[end] || MATCH_TYPE_MAP[matchMode][''][end];
      const isExcluded = sign === '-';

      return { word, matchType, isExcluded };
    }, [ matchMode, locale, matchingOptions ]);

    // Filters the words that should be proposed to the user based on the current query.
    const filterSuggestions = useCallback((query, suggestions) => {
      if (0 === suggestions.length) {
        return [];
      }

      const { word } = parseWordFilter(query);

      if (word.length < minQueryLength) {
        return [];
      }

      // The underlying library used to remove diacritics is based on a fixed list of characters,
      // which lacks some cases such as "ạ" or "ả".
      return {
        options: matchSorter(
          suggestions,
          normalizeString(word, false, true),
          {
            keepDiacritics: true,
            keys: [ 'searchable' ],
          }
        ),
        highlightedQuery: word,
      };
    }, [ minQueryLength, parseWordFilter ]);

    const tagsInput = useRef();

    const blurTagsInput = useCallback(
      () => tagsInput.current && setTimeout(() => tagsInput.current.blur()),
      [ tagsInput ]
    );

    const onAddFilter = useCallback(({ id = null, name }, query) => {
      let filter;

      if (null !== id) {
        const { matchType, isExcluded } = parseWordFilter(query);
        filter = { word: name, matchType, isExcluded };
      } else {
        filter = parseWordFilter(name);
      }

      onChange([ ...filters.filter(it.word !== filter.word), filter ]);

      blurTagsInput();
    }, [ filters, onChange, parseWordFilter, blurTagsInput ]);

    const onUpdateFilter = useCallback((index, filter) => {
      if (filters[index]) {
        const updated = filters.slice();
        updated.splice(index, 1, filter);
        onChange(updated);
      }
    }, [ filters, onChange ]);

    const onDeleteFilter = useCallback(index => {
      if (filters[index]) {
        const updated = filters.slice();
        updated.splice(index, 1);
        onChange(updated);
      }
    }, [ filters, onChange ]);

    // Use a portal for the sizer, in case the input is rendered inside a hidden container.
    const sizerContainer = usePortalContainer();

    const onKeyDown = event => {
      // Stop propagation of "keydown" events for the search input,
      // to prevent Duolingo from handling them when the word bank is active (and calling preventDefault()).
      event.stopPropagation();
      ('Escape' === event.key) && blurTagsInput();
    }

    useKey('f', event => {
      if (
        tagsInput.current
        && !event.ctrlKey
        && !event.metaKey
        && !isAnyInputFocused()
      ) {
        discardEvent(event);
        setTimeout(() => tagsInput.current.focus({ preventScroll: true }));
      }
    });

    const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

    return (
      <IntlProvider scope="word_filter">
        <Localizer>
          <ReactTags
            ref={tagsInput}
            id={`${EXTENSION_CODE}-word-filter-tag`}
            tags={filters}
            suggestions={suggestions}
            suggestionsTransform={filterSuggestions}
            allowNew={true}
            delimiters={[ 'Enter', ' ', ',', ';' ]}
            minQueryLength={minQueryLength}
            onAddition={onAddFilter}
            onUpdate={onUpdateFilter}
            onDelete={onDeleteFilter}
            onKeyDown={onKeyDown}
            onFocus={onFocus}
            onBlur={onBlur}
            placeholderText={<Text id="add_filter">Add a filter</Text>}
            removeButtonText={<Text id="click_to_remove_filter">Click to remove filter</Text>}
            tagComponent={withForcedProps(Filter, { context, matchMode })}
            suggestionsComponent={withContext(SuggestionsDropdown, context)}
            autoresizePortal={sizerContainer}
            classNames={{
              root: getElementClassNames(WRAPPER),
              rootFocused: getElementClassNames(WRAPPER__ACTIVE),
              selected: getElementClassNames(FILTER_WRAPPER),
              selectedTag: getElementClassNames(FILTER),
              selectedTagName: getElementClassNames(FILTER_WORD),
              search: getElementClassNames(SEARCH_WRAPPER),
              searchInput: getElementClassNames(SEARCH_INPUT),
              suggestions: getElementClassNames(SUGGESTIONS),
              suggestion: getElementClassNames(SUGGESTION),
              suggestionActive: getElementClassNames(SUGGESTION__ACTIVE),
            }}
          />
        </Localizer>
      </IntlProvider>
    );
  }
Example #11
Source File: Modal.js    From duolingo-solution-viewer with MIT License 4 votes vote down vote up
Modal =
  ({
     children,
     opened = true,
     onAfterOpen = noop,
     onAfterClose = noop,
     onRequestClose = noop,
   }) => {
    const [ modalState, modalStateRef, setModalState ] = useStateRef(STATE_CLOSED);

    const {
      state: modalSize,
      nextState: nextModalSize,
      next: setNextModalSize,
    } = useLocalStorageList(
      'modal-size',
      Object.keys(MODAL_SIZES),
      MODAL_SIZE_DEFAULT
    );

    const contentWrapper = useRef();
    const openedTimeout = useRef(null);

    // Opens the modal with an effect similar to Duolingo's.
    const openModal = useCallback(() => {
      setModalState(STATE_WILL_OPEN);

      setTimeout(() => setModalState(STATE_OPENING), 1);

      openedTimeout.current = setTimeout(() => {
        setModalState(STATE_OPENED);
        setTimeout(() => onAfterOpen());
        contentWrapper.current?.focus();
      }, 300);
    }, [ onAfterOpen, setModalState, openedTimeout ]);

    // Closes the modal with an effect similar to Duolingo's.
    const closeModal = useCallback(() => {
      setModalState(STATE_CLOSING);

      setTimeout(() => {
        setModalState(STATE_CLOSED);
        setTimeout(() => onAfterClose());
      }, 300);

      openedTimeout.current && clearTimeout(openedTimeout.current);
    }, [ onAfterClose, setModalState, openedTimeout ]);

    // Opens / closes the modal when requested.
    useEffect(() => {
      const isCurrentlyOpened = [ STATE_WILL_OPEN, STATE_OPENING, STATE_OPENED ].indexOf(modalState) >= 0;

      if (opened && !isCurrentlyOpened) {
        openModal();
      } else if (!opened && isCurrentlyOpened) {
        closeModal();
      }
    }, [ opened, modalState, openModal, closeModal ]);

    // Closes the modal when the "Escape" key is pressed.
    useEffect(() => {
      const handleKeyDown = event => {
        if (!isAnyInputFocused()) {
          if ('Escape' === event.key) {
            onRequestClose();
            discardEvent(event);
          } else if ('Enter' === event.key) {
            if (modalStateRef.current === STATE_OPENED) {
              discardEvent(event);
            }
          }
        }
      };

      document.addEventListener('keydown', handleKeyDown, true);

      return () => document.removeEventListener('keydown', handleKeyDown);
    }, [ onRequestClose ]);

    const { modalSizeTitle } = useText({
      modalSizeTitle: (
        <Text id={`modal.${MODAL_SIZES[nextModalSize].actionTitleId}`}>
          {MODAL_SIZES[nextModalSize].defaultActionTitle}
        </Text>
      )
    });

    const closeIconUrl = useImageCdnUrl(CLOSE_ICON_CDN_PATH);
    const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ modalState, modalSize ]);

    if (STATE_CLOSED === modalState) {
      return null;
    }

    return (
      <IntlProvider scope="modal">
        <div onClick={onRequestClose} className={getElementClassNames(OVERLAY)}>
          <div role="dialog" tabIndex="-1" onClick={discardEvent} className={getElementClassNames(WRAPPER)}>
            <div onClick={onRequestClose} className={getElementClassNames(CLOSE_BUTTON)}>
              <Localizer>
                <img
                  src={closeIconUrl}
                  alt={<Text id="close">Close</Text>}
                  title={<Text id="close">Close</Text>}
                />
              </Localizer>
            </div>

            <div title={modalSizeTitle} onClick={setNextModalSize} className={getElementClassNames(SIZE_BUTTON)}>
              {MODAL_SIZES[nextModalSize].actionLabel}
            </div>

            <div ref={contentWrapper} tabIndex="0" className={getElementClassNames(CONTENT)}>
              {children}
            </div>
          </div>
        </div>
      </IntlProvider>
    );
  }
Example #12
Source File: SolutionList.js    From duolingo-solution-viewer with MIT License 4 votes vote down vote up
SolutionList =
  forwardRef(
    (
      {
        context = CONTEXT_CHALLENGE,
        solutions = [],
        matchingData = {},
        onPageChange = noop,
        scrollOffsetGetter = (() => 0),
      },
      listRef
    ) => {
      const isScoreAvailable = useMemo(() => {
        return solutions.some('score' in it);
      }, [ solutions ]);

      const sortTypes = getAvailableSortTypes(isScoreAvailable);

      const {
        state: sortType,
        nextState: nextSortType,
        next: setNextSortType,
      } = useLocalStorageList(
        'sort-type',
        sortTypes,
        sortTypes[0]
      );

      const {
        state: sortDirection,
        nextState: nextSortDirection,
        next: setNextSortDirection,
      } = useLocalStorageList(
        'sort-direction',
        Object.keys(SORT_DIRECTIONS),
        SORT_DIRECTION_DESC
      );

      const isFilterWordBased = !!matchingData.words;

      // Sort the solutions.

      const sortedSolutions = useMemo(() => (
        solutions.slice()
          .sort(
            SORT_TYPE_SIMILARITY === sortType
              ? (SORT_DIRECTION_ASC === sortDirection ? invertComparison : identity)(Solution.compareByScore)
              : (SORT_DIRECTION_ASC === sortDirection ? identity : invertComparison)(Solution.compareByReference)
          )
      ), [ solutions, sortType, sortDirection ]);

      // Filter the solutions.

      const filterCache = useRef({}).current;
      const [ filters, filtersRef, setFilters ] = useStateRef([]);

      const filteredSolutions = useMemo(
        () => (
          isFilterWordBased
            ? filterSolutionsUsingWords
            : filterSolutionsUsingSummaries
        )(sortedSolutions, filters, filterCache),
        [ sortedSolutions, filters, filterCache, isFilterWordBased ]
      );

      // Paginate and render the current solutions.

      const [ rawPage, setRawPage ] = useState(1);
      const shouldTriggerPageChange = useRef(false);
      const [ pageSize, setRawPageSize ] = useLocalStorage('page_size', DEFAULT_PAGE_SIZE);

      const page = (PAGE_SIZE_ALL === pageSize)
        ? 1
        : Math.min(rawPage, Math.ceil(filteredSolutions.length / pageSize));

      const setPage = useCallback(page => {
        setRawPage(page);
        shouldTriggerPageChange.current = true;
      }, [ setRawPage ]);

      const setPageSize = useCallback(size => {
        setRawPageSize(size);

        if (PAGE_SIZE_ALL === size) {
          setRawPage(1);
        } else {
          // Update the current page to keep the same solution at the top of the list.
          const sizeValue = Number(size);

          if (PAGE_SIZES.indexOf(sizeValue) === -1) {
            return;
          }

          const oldSize = (PAGE_SIZE_ALL === pageSize)
            ? filteredSolutions.length
            : Math.min(pageSize, filteredSolutions.length);

          setRawPage(Math.ceil(((page - 1) * oldSize + 1) / sizeValue));
        }

        shouldTriggerPageChange.current = true;
      }, [ page, pageSize, filteredSolutions.length, setRawPageSize ]);

      const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

      const solutionItems = useMemo(() => {
        const renderSolutionItem = solution => (
          <li className={getElementClassNames(SOLUTION)}>
            {Solution.getReaderFriendlySummary(solution)}
          </li>
        );

        const pageSolutions = (PAGE_SIZE_ALL === pageSize)
          ? filteredSolutions
          : filteredSolutions.slice((page - 1) * pageSize, page * pageSize);

        return pageSolutions.map(renderSolutionItem);
      }, [ page, pageSize, filteredSolutions, getElementClassNames ]);

      // Triggers the "page change" callback asynchronously,
      // to make sure it is run only when the changes have been applied to the UI.
      useEffect(() => {
        if (shouldTriggerPageChange.current) {
          setTimeout(onPageChange());
          shouldTriggerPageChange.current = false;
        }
      }, [ solutionItems, onPageChange, shouldTriggerPageChange ]);

      const filterWrapperRef = useRef();

      // Scrolls the filter input into view when it is focused.
      const onFilterFocus = useCallback(() => {
        filterWrapperRef.current
        && scrollElementIntoParentView(filterWrapperRef.current, scrollOffsetGetter(), 'smooth');
      }, [ scrollOffsetGetter, filterWrapperRef ]);

      // Focuses the solution list when the filter input loses focus, to ensure that the list is scrollable again.
      const onFilterBlur = useCallback(() => listRef.current?.closest('[tabindex]')?.focus(), [ listRef ]);

      // Detects selected words, and proposes new filter options when relevant.
      const [ selectedWord, setSelectedWord ] = useState(null);

      useEffect(() => {
        // Detect when the left button is released to only propose suggestions when a selection has been committed.
        const onMouseUp = event => {
          if (listRef.current && (event.button === 0)) {
            const selection = document.getSelection();

            if (
              selection.anchorNode
              && (selection.anchorNode === selection.focusNode)
              && listRef.current.contains(selection.anchorNode)
              && (selection.anchorNode.parentNode.nodeName === 'LI')
            ) {
              // We are only interested in single-word selections.
              const words = Solution.getStringMatchableWords(
                selection.toString().trim(),
                matchingData.locale,
                matchingData.matchingOptions
              );

              if (1 === words.length) {
                const selectedText = !isFilterWordBased
                  ? selection.toString()
                  : getWordAt(
                    selection.anchorNode.wholeText,
                    Math.floor((selection.anchorOffset + selection.focusOffset) / 2)
                  );

                const [ word = '' ] = Solution.getStringMatchableWords(
                  selectedText,
                  matchingData.locale,
                  matchingData.matchingOptions
                );

                if (
                  (!isFilterWordBased || (word.length > 1))
                  && !(filtersRef.current || []).some(it.word === word)
                ) {
                  const bbox = selection.getRangeAt(0).getBoundingClientRect();
                  const offsetParent = getFixedElementPositioningParent(listRef.current);

                  if (offsetParent) {
                    const parentBbox = offsetParent.getBoundingClientRect();
                    bbox.x -= parentBbox.x;
                    bbox.y -= parentBbox.y;
                  }

                  setSelectedWord({
                    word,
                    bbox: {
                      left: `${Math.floor(bbox.x)}px`,
                      top: `${Math.floor(bbox.y)}px`,
                      width: `${Math.ceil(bbox.width)}px`,
                      height: `${Math.ceil(bbox.height)}px`,
                    },
                  });

                  return;
                }
              }
            }
          }

          // Delay hiding the actions dropdown to let "click" events be triggered normally.
          setTimeout(() => setSelectedWord(null));
        };

        // Detect change events to ensure that suggestions are hidden when the selection is canceled.
        const onSelectionChange = () => {
          const selection = document.getSelection();

          if (!selection || ('None' === selection.type)) {
            setSelectedWord(null);
          }
        };

        document.addEventListener('mouseup', onMouseUp);
        document.addEventListener('selectionchange', onSelectionChange);

        return () => {
          document.removeEventListener('mouseup', onMouseUp);
          document.removeEventListener('selectionchange', onSelectionChange);
        }
      });

      if (0 === solutions.length) {
        return null;
      }

      return (
        <IntlProvider scope="solution_list">
          <div>
            <h3 ref={filterWrapperRef} className={getElementClassNames(TITLE)}>
              <span className={getElementClassNames(TITLE_TEXT)}>
                <Text id="filter">Filter:</Text>
              </span>

              <FilterInput
                context={context}
                matchMode={isFilterWordBased ? STRING_MATCH_MODE_WORDS : STRING_MATCH_MODE_GLOBAL}
                matchingData={matchingData}
                minQueryLength={isFilterWordBased ? 2 : 1}
                filters={filters}
                onChange={setFilters}
                onFocus={onFilterFocus}
                onBlur={onFilterBlur}
              />
            </h3>

            <div ref={listRef}>
              <h3 className={getElementClassNames(TITLE)}>
                <span className={getElementClassNames(TITLE_TEXT)}>
                  <Text id="correct_solutions">Correct solutions:</Text>
                </span>

                <ListSortLinks
                  context={context}
                  availableSortTypes={sortTypes}
                  sortType={sortType}
                  nextSortType={nextSortType}
                  sortDirection={sortDirection}
                  nextSortDirection={nextSortDirection}
                  onSortTypeToggle={() => setNextSortType()}
                  onSortDirectionToggle={() => setNextSortDirection()}
                />
              </h3>

              {(0 === filteredSolutions.length)
                ? (
                  <div className={getElementClassNames(EMPTY_LIST)}>
                    <Text id="no_matching_solution">There is no matching solution.</Text>
                  </div>
                ) : (
                  <Fragment>
                    <ul>{solutionItems}</ul>

                    {selectedWord && (
                      <SelectedWordActions
                        {...selectedWord}
                        context={context}
                        matchType={isFilterWordBased ? STRING_MATCH_TYPE_EXACT : STRING_MATCH_TYPE_ANYWHERE}
                        onAddFilter={setFilters([ ...filters, _ ])}
                      />
                    )}

                    <ListPagination
                      context={context}
                      solutionCount={filteredSolutions.length}
                      page={page}
                      pageSize={pageSize}
                      onPageChange={setPage}
                      onPageSizeChange={setPageSize}
                    />
                  </Fragment>
                )}
            </div>
          </div>
        </IntlProvider>
      );
    }
  )
Example #13
Source File: UserReference.js    From duolingo-solution-viewer with MIT License 4 votes vote down vote up
UserReference =
  ({
     context = CONTEXT_CHALLENGE,
     reference = '',
     isEditable = true,
     onUpdate = noop,
   }) => {
    const editInput = useRef();
    const [ isEditing, setIsEditing ] = useState(false);

    const commitEdit = useCallback(event => {
      discardEvent(event);

      if (editInput.current) {
        const newReference = String(editInput.current.value || '').trim();

        if (('' !== newReference) && (newReference !== reference)) {
          onUpdate(newReference);
        }
      }

      setIsEditing(false);
    }, [ reference, onUpdate, setIsEditing ]);

    const rollbackEdit = useCallback(event => {
      discardEvent(event);
      setIsEditing(false);
    }, [ setIsEditing ]);

    const onEditKeyDown = useCallback(event => {
      if ('Enter' === event.key) {
        commitEdit(event);
      } else if ('Escape' === event.key) {
        rollbackEdit(event);
      }
    }, [ commitEdit, rollbackEdit ]);

    // Focuses the input when we just have switched to edit mode.
    useEffect(() => {
      if (editInput.current) {
        setTimeout(() => {
          if (document.activeElement !== editInput.current.focused) {
            const length = editInput.current.value.length;
            editInput.current.focus();
            // Place the cursor at the end of the text.
            editInput.current.setSelectionRange(length + 1, length + 1);
          }
        });
      }
    }, [ isEditing, editInput ]);

    const [ Wrapper, Title, Value, EditWrapper ] = (CONTEXT_CHALLENGE === context)
      ? [ 'div', 'h3', 'p', 'p' ]
      : [ 'h2', 'span', 'span', Fragment ];

    const getElementClassNames = useStyles(CLASS_NAMES, STYLE_SHEETS, [ context ]);

    let buttonInlineStyles = {};
    let additionalButtonClass = null;

    if (CONTEXT_FORUM === context) {
      buttonInlineStyles = getForumNewPostButtonsInlineStyles();

      if (null === buttonInlineStyles) {
        const inlineStyles = getForumFollowButtonInlineStyles();

        buttonInlineStyles = {
          [COMMIT_BUTTON]: inlineStyles,
          [ROLLBACK_BUTTON]: inlineStyles,
        };

        additionalButtonClass = FALLBACK_BUTTON;
      }
    }

    const valueKeys = [
      VALUE,
      isEditable && EDITABLE_VALUE,
      ('' === reference) && EMPTY_VALUE,
    ].filter(Boolean);

    return (
      <IntlProvider scope="user_reference">
        <Wrapper className={getElementClassNames(WRAPPER)}>
          <Title className={getElementClassNames(TITLE)}>
            <Text id="your_reference">Your reference:</Text>
          </Title>
          {!isEditing
            ? ( // Not editing.
              <Value onClick={() => isEditable && setIsEditing(true)} className={getElementClassNames(valueKeys)}>
                {('' !== reference) ? reference : <Text id="none">None yet</Text>}
              </Value>
            ) : ( // Editing.
              <EditWrapper>
                <textarea
                  ref={editInput}
                  defaultValue={reference}
                  dir="auto"
                  onKeyDown={onEditKeyDown}
                  className={getElementClassNames(EDIT_FIELD)}
                />

                <button
                  onClick={commitEdit}
                  style={buttonInlineStyles[COMMIT_BUTTON] || ''}
                  className={getElementClassNames([ BUTTON, COMMIT_BUTTON, additionalButtonClass ])}
                >
                  <Text id="update">Update</Text>
                </button>

                <span className={getElementClassNames(BUTTON_SPACER)}>
                  <button
                    onClick={rollbackEdit}
                    style={buttonInlineStyles[ROLLBACK_BUTTON] || ''}
                    className={getElementClassNames([ BUTTON, ROLLBACK_BUTTON, additionalButtonClass ])}
                  >
                    <Text id="cancel">Cancel</Text>
                  </button>
                </span>
              </EditWrapper>
            )}
        </Wrapper>
      </IntlProvider>
    );
  }
Example #14
Source File: graph.js    From rctf with BSD 3-Clause "New" or "Revised" License 4 votes vote down vote up
function Graph ({ graphData, classes }) {
  const svgRef = useRef(null)
  const [width, setWidth] = useState(window.innerWidth)
  const updateWidth = useCallback(() => {
    if (svgRef.current === null) return
    setWidth(svgRef.current.getBoundingClientRect().width)
  }, [])

  const [tooltipData, setTooltipData] = useState({
    x: 0,
    y: 0,
    content: ''
  })

  useLayoutEffect(() => {
    updateWidth()
  }, [updateWidth])
  useEffect(() => {
    function handleResize () {
      updateWidth()
    }
    window.addEventListener('resize', handleResize)
    return () => window.removeEventListener('resize', handleResize)
  }, [updateWidth])

  const { polylines, labels } = useMemo(() => {
    if (!graphData || graphData.length === 0) {
      return {
        polylines: [],
        labels: []
      }
    }
    const minX = config.startTime
    const maxX = Math.min(Date.now(), config.endTime)
    let maxY = 0
    graphData.graph.forEach((user) => {
      user.points.forEach((point) => {
        if (point.score > maxY) {
          maxY = point.score
        }
      })
    })
    const labels = getXLabels({ minX, maxX, width })
    const polylines = graphData.graph.map((user) => pointsToPolyline({
      points: user.points,
      id: user.id,
      name: user.name,
      currentScore: user.points[0].score,
      maxX,
      minX,
      maxY,
      width
    }))
    return { polylines, labels }
  }, [graphData, width])

  const handleTooltipIn = useCallback((content) => () => {
    setTooltipData(d => ({
      ...d,
      content
    }))
  }, [])

  const handleTooltipMove = useCallback((evt) => {
    setTooltipData(d => ({
      ...d,
      x: evt.clientX,
      y: evt.clientY
    }))
  }, [])

  const handleTooltipOut = useCallback(() => {
    setTooltipData(d => ({
      ...d,
      content: ''
    }))
  }, [])

  if (graphData === null) {
    return null
  }

  return (
    <div class={`frame ${classes.root}`}>
      <div class='frame__body'>
        <svg ref={svgRef} viewBox={`${-stroke - axis} ${-stroke} ${width + stroke * 2 + axis} ${height + stroke * 2 + axis + axisGap}`}>
          <Fragment>
            {polylines.map(({ points, color, name, currentScore }, i) => (
              <GraphLine
                key={i}
                stroke={color}
                points={points}
                name={name}
                currentScore={currentScore}
                onMouseMove={handleTooltipMove}
                onMouseOut={handleTooltipOut}
                onTooltipIn={handleTooltipIn}
              />
            ))}
          </Fragment>
          <Fragment>
            {labels.map((label, i) => (
              <text x={label.x} y={height + axis + axisGap} key={i} fill='#fff'>{label.label}</text>
            ))}
          </Fragment>
          <line
            x1={-axisGap}
            y1={height + axisGap}
            x2={width}
            y2={height + axisGap}
            stroke='var(--cirrus-bg)'
            stroke-linecap='round'
            stroke-width={stroke}
          />
          <line
            x1={-axisGap}
            y1='0'
            x2={-axisGap}
            y2={height + axisGap}
            stroke='var(--cirrus-bg)'
            stroke-linecap='round'
            stroke-width={stroke}
          />
        </svg>
      </div>
      {tooltipData.content && (
        <div
          class={classes.tooltip}
          style={{
            transform: `translate(${tooltipData.x}px, ${tooltipData.y}px)`
          }}
        >
          {tooltipData.content}
        </div>
      )}
    </div>
  )
}
Example #15
Source File: problem.js    From rctf with BSD 3-Clause "New" or "Revised" License 4 votes vote down vote up
Problem = ({ classes, problem, solved, setSolved }) => {
  const { toast } = useToast()

  const hasDownloads = problem.files.length !== 0

  const [error, setError] = useState(undefined)
  const hasError = error !== undefined

  const [value, setValue] = useState('')
  const handleInputChange = useCallback(e => setValue(e.target.value), [])

  const handleSubmit = useCallback(e => {
    e.preventDefault()

    submitFlag(problem.id, value.trim())
      .then(({ error }) => {
        if (error === undefined) {
          toast({ body: 'Flag successfully submitted!' })

          setSolved(problem.id)
        } else {
          toast({ body: error, type: 'error' })
          setError(error)
        }
      })
  }, [toast, setSolved, problem, value])

  const [solves, setSolves] = useState(null)
  const [solvesPending, setSolvesPending] = useState(false)
  const [solvesPage, setSolvesPage] = useState(1)
  const modalBodyRef = useRef(null)
  const handleSetSolvesPage = useCallback(async (newPage) => {
    const { kind, message, data } = await getSolves({
      challId: problem.id,
      limit: solvesPageSize,
      offset: (newPage - 1) * solvesPageSize
    })
    if (kind !== 'goodChallengeSolves') {
      toast({ body: message, type: 'error' })
      return
    }
    setSolves(data.solves)
    setSolvesPage(newPage)
    modalBodyRef.current.scrollTop = 0
  }, [problem.id, toast])
  const onSolvesClick = useCallback(async (e) => {
    e.preventDefault()
    if (solvesPending) {
      return
    }
    setSolvesPending(true)
    const { kind, message, data } = await getSolves({
      challId: problem.id,
      limit: solvesPageSize,
      offset: 0
    })
    setSolvesPending(false)
    if (kind !== 'goodChallengeSolves') {
      toast({ body: message, type: 'error' })
      return
    }
    setSolves(data.solves)
    setSolvesPage(1)
  }, [problem.id, toast, solvesPending])
  const onSolvesClose = useCallback(() => setSolves(null), [])

  return (
    <div class={`frame ${classes.frame}`}>
      <div class='frame__body'>
        <div class='row u-no-padding'>
          <div class='col-6 u-no-padding'>
            <div class='frame__title title'>{problem.category}/{problem.name}</div>
            <div class='frame__subtitle u-no-margin'>{problem.author}</div>
          </div>
          <div class='col-6 u-no-padding u-text-right'>
            <a
              class={`${classes.points} ${solvesPending ? classes.solvesPending : ''}`}
              onClick={onSolvesClick}>
              {problem.solves}
              {problem.solves === 1 ? ' solve / ' : ' solves / '}
              {problem.points}
              {problem.points === 1 ? ' point' : ' points'}
            </a>
          </div>
        </div>

        <div class='content-no-padding u-center'><div class={`divider ${classes.divider}`} /></div>

        <div class={`${classes.description} frame__subtitle`}>
          <Markdown content={problem.description} components={markdownComponents} />
        </div>
        <form class='form-section' onSubmit={handleSubmit}>
          <div class='form-group'>
            <input
              autocomplete='off'
              autocorrect='off'
              class={`form-group-input input-small ${classes.input} ${hasError ? 'input-error' : ''} ${solved ? 'input-success' : ''}`}
              placeholder={`Flag${solved ? ' (solved)' : ''}`}
              value={value}
              onChange={handleInputChange}
            />
            <button class={`form-group-btn btn-small ${classes.submit}`}>Submit</button>
          </div>
        </form>

        {
          hasDownloads &&
            <div>
              <p class='frame__subtitle u-no-margin'>Downloads</p>
              <div class='tag-container'>
                {
                  problem.files.map(file => {
                    return (
                      <div class={`tag ${classes.tag}`} key={file.url}>
                        <a native download href={`${file.url}`}>
                          {file.name}
                        </a>
                      </div>
                    )
                  })
                }

              </div>
            </div>
        }
      </div>
      <SolvesDialog
        solves={solves}
        challName={problem.name}
        solveCount={problem.solves}
        pageSize={solvesPageSize}
        page={solvesPage}
        setPage={handleSetSolvesPage}
        onClose={onSolvesClose}
        modalBodyRef={modalBodyRef}
      />
    </div>
  )
}
Example #16
Source File: scoreboard.js    From rctf with BSD 3-Clause "New" or "Revised" License 4 votes vote down vote up
Scoreboard = withStyles({
  frame: {
    paddingBottom: '1.5em',
    paddingTop: '2.125em',
    background: '#222',
    '& .frame__subtitle': {
      color: '#fff'
    },
    '& button, & select, & option': {
      background: '#111',
      color: '#fff'
    }
  },
  tableFrame: {
    paddingTop: '1.5em'
  },
  selected: {
    backgroundColor: 'rgba(216,216,216,.07)',
    '&:hover': {
      backgroundColor: 'rgba(216,216,216,.20) !important'
    }
  },
  table: {
    tableLayout: 'fixed',
    '& tbody td': {
      overflow: 'hidden',
      whiteSpace: 'nowrap'
    }
  }
}, ({ classes }) => {
  const loggedIn = useMemo(() => localStorage.getItem('token') !== null, [])
  const scoreboardPageState = useMemo(() => {
    const localStorageState = JSON.parse(localStorage.getItem('scoreboardPageState') || '{}')

    const queryParams = new URLSearchParams(location.search)
    const queryState = {}
    if (queryParams.has('page')) {
      const page = parseInt(queryParams.get('page'))
      if (!isNaN(page)) {
        queryState.page = page
      }
    }
    if (queryParams.has('pageSize')) {
      const pageSize = parseInt(queryParams.get('pageSize'))
      if (!isNaN(pageSize)) {
        queryState.pageSize = pageSize
      }
    }
    if (queryParams.has('division')) {
      queryState.division = queryParams.get('division')
    }

    return { ...localStorageState, ...queryState }
  }, [])
  const [profile, setProfile] = useState(null)
  const [pageSize, _setPageSize] = useState(scoreboardPageState.pageSize || 100)
  const [scores, setScores] = useState([])
  const [graphData, setGraphData] = useState(null)
  const [division, _setDivision] = useState(scoreboardPageState.division || 'all')
  const [page, setPage] = useState(scoreboardPageState.page || 1)
  const [totalItems, setTotalItems] = useState(0)
  const [scoreLoadState, setScoreLoadState] = useState(loadStates.pending)
  const [graphLoadState, setGraphLoadState] = useState(loadStates.pending)
  const selfRow = useRef()
  const { toast } = useToast()

  const setDivision = useCallback((newDivision) => {
    _setDivision(newDivision)
    setPage(1)
  }, [_setDivision, setPage])
  const setPageSize = useCallback((newPageSize) => {
    _setPageSize(newPageSize)
    // Try to switch to the page containing the teams that were previously
    // at the top of the current page
    setPage(Math.floor((page - 1) * pageSize / newPageSize) + 1)
  }, [pageSize, _setPageSize, page, setPage])

  useEffect(() => {
    localStorage.setItem('scoreboardPageState', JSON.stringify({ pageSize, division }))
  }, [pageSize, division])
  useEffect(() => {
    if (page !== 1 || location.search !== '') {
      history.replaceState({}, '', `?page=${page}&division=${encodeURIComponent(division)}&pageSize=${pageSize}`)
    }
  }, [pageSize, division, page])

  const divisionChangeHandler = useCallback((e) => setDivision(e.target.value), [setDivision])
  const pageSizeChangeHandler = useCallback((e) => setPageSize(e.target.value), [setPageSize])

  useEffect(() => { document.title = `Scoreboard | ${config.ctfName}` }, [])
  useEffect(() => {
    if (loggedIn) {
      privateProfile()
        .then(({ data, error }) => {
          if (error) {
            toast({ body: error, type: 'error' })
          }
          setProfile(data)
        })
    }
  }, [loggedIn, toast])

  useEffect(() => {
    (async () => {
      const _division = division === 'all' ? undefined : division
      const { kind, data } = await getScoreboard({
        division: _division,
        offset: (page - 1) * pageSize,
        limit: pageSize
      })
      setScoreLoadState(kind === 'badNotStarted' ? loadStates.notStarted : loadStates.loaded)
      if (kind !== 'goodLeaderboard') {
        return
      }
      setScores(data.leaderboard.map((entry, i) => ({
        ...entry,
        rank: i + 1 + (page - 1) * pageSize
      })))
      setTotalItems(data.total)
    })()
  }, [division, page, pageSize])

  useEffect(() => {
    (async () => {
      const _division = division === 'all' ? undefined : division
      const { kind, data } = await getGraph({ division: _division })
      setGraphLoadState(kind === 'badNotStarted' ? loadStates.notStarted : loadStates.loaded)
      if (kind !== 'goodLeaderboard') {
        return
      }
      setGraphData(data)
    })()
  }, [division])

  const isUserOnCurrentScoreboard = (
    loggedIn &&
    profile !== null &&
    profile.globalPlace !== null &&
    (division === 'all' || Number.parseInt(division) === profile.division)
  )
  const isSelfVisible = useMemo(() => {
    if (profile == null) return false
    let isSelfVisible = false
    // TODO: maybe avoiding iterating over scores again?
    scores.forEach(({ id }) => {
      if (id === profile.id) {
        isSelfVisible = true
      }
    })
    return isSelfVisible
  }, [profile, scores])
  const scrollToSelf = useCallback(() => {
    selfRow.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
  }, [selfRow])
  const [needsScrollToSelf, setNeedsScrollToSelf] = useState(false)
  const goToSelfPage = useCallback(() => {
    if (!isUserOnCurrentScoreboard) return
    let place
    if (division === 'all') {
      place = profile.globalPlace
    } else {
      place = profile.divisionPlace
    }
    setPage(Math.floor((place - 1) / pageSize) + 1)

    if (isSelfVisible) {
      scrollToSelf()
    } else {
      setNeedsScrollToSelf(true)
    }
  }, [profile, setPage, pageSize, division, isUserOnCurrentScoreboard, isSelfVisible, scrollToSelf])
  useEffect(() => {
    if (needsScrollToSelf) {
      if (isSelfVisible) {
        scrollToSelf()
        setNeedsScrollToSelf(false)
      }
    }
  }, [isSelfVisible, needsScrollToSelf, scrollToSelf])

  if (scoreLoadState === loadStates.pending || graphLoadState === loadStates.pending) {
    return null
  }

  if (scoreLoadState === loadStates.notStarted || graphLoadState === loadStates.notStarted) {
    return <NotStarted />
  }

  return (
    <div class='row u-center' style='align-items: initial !important'>
      <div class='col-12 u-center'>
        <div class='col-8'>
          <Graph graphData={graphData} />
        </div>
      </div>
      <div class='col-3'>
        <div class={`frame ${classes.frame}`}>
          <div class='frame__body'>
            <div class='frame__subtitle'>Filter by division</div>
            <div class='input-control'>
              <select required class='select' name='division' value={division} onChange={divisionChangeHandler}>
                <option value='all' selected>All</option>
                {
                  Object.entries(config.divisions).map(([code, name]) => {
                    return <option key={code} value={code}>{name}</option>
                  })
                }
              </select>
            </div>
            <div class='frame__subtitle'>Teams per page</div>
            <div class='input-control'>
              <select required class='select' name='pagesize' value={pageSize} onChange={pageSizeChangeHandler}>
                { PAGESIZE_OPTIONS.map(sz => <option value={sz}>{sz}</option>) }
              </select>
            </div>
            { loggedIn &&
              <div class='btn-container u-center'>
                <button disabled={!isUserOnCurrentScoreboard} onClick={goToSelfPage}>
                  Go to my team
                </button>
              </div>
            }
          </div>
        </div>
      </div>
      <div class='col-6'>
        <div class={`frame ${classes.frame} ${classes.tableFrame}`}>
          <div class='frame__body'>
            <table class={`table small ${classes.table}`}>
              <thead>
                <tr>
                  <th style='width: 3.5em'>#</th>
                  <th>Team</th>
                  <th style='width: 5em'>Points</th>
                </tr>
              </thead>
              <tbody>
                { scores.map(({ id, name, score, rank }) => {
                  const isSelf = profile != null && profile.id === id

                  return (
                    <tr key={id}
                      class={isSelf ? classes.selected : ''}
                      ref={isSelf ? selfRow : null}
                    >
                      <td>{rank}</td>
                      <td>
                        <a href={`/profile/${id}`}>{name}</a>
                      </td>
                      <td>{score}</td>
                    </tr>
                  )
                }) }
              </tbody>
            </table>
          </div>
          { totalItems > pageSize &&
            <Pagination
              {...{ totalItems, pageSize, page, setPage }}
              numVisiblePages={9}
            />
          }
        </div>
      </div>
    </div>
  )
})