<commit_before>// Copyright (c) ppy Pty Ltd <<EMAIL>>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

import BeatmapsPopup from 'beatmapset-panel/beatmaps-popup';
import BeatmapsetCover from 'components/beatmapset-cover';
import { CircularProgress } from 'components/circular-progress';
import BeatmapJson from 'interfaces/beatmap-json';
import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
import BeatmapsetJson, { BeatmapsetStatus } from 'interfaces/beatmapset-json';
import GameMode from 'interfaces/game-mode';
import { route } from 'laroute';
import { sum, values } from 'lodash';
import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import core from 'osu-core-singleton';
import * as React from 'react';
import { Transition } from 'react-transition-group';
import { getArtist, getDiffColour, getTitle, group as groupBeatmaps } from 'utils/beatmap-helper';
import { showVisual, toggleFavourite } from 'utils/beatmapset-helper';
import { classWithModifiers } from 'utils/css';
import { formatNumber, formatNumberSuffixed } from 'utils/html';
import { beatmapsetDownloadDirect } from 'utils/url';
import StringWithComponent from './string-with-component';
import TimeWithTooltip from './time-with-tooltip';
import { UserLink } from './user-link';

export const beatmapsetCardSizes = ['normal', 'extra'] as const;
export type BeatmapsetCardSize = typeof beatmapsetCardSizes[number];

interface Props {
  beatmapset: BeatmapsetExtendedJson;
}

const beatmapsPopupTransitionDuration = 150;

const displayDateMap: Record<BeatmapsetStatus, 'last_updated' | 'ranked_date'> = {
  approved: 'ranked_date',
  graveyard: 'last_updated',
  loved: 'ranked_date',
  pending: 'last_updated',
  qualified: 'ranked_date',
  ranked: 'ranked_date',
  wip: 'last_updated',
};

const BeatmapDot = observer(({ beatmap }: { beatmap: BeatmapJson }) => (
  <div
    className='beatmapset-panel__beatmap-dot'
    style={{
      '--bg': getDiffColour(beatmap.difficulty_rating),
    } as React.CSSProperties}
  />
));

const BeatmapDots = observer(({ compact, beatmaps, mode }: { beatmaps: BeatmapJson[]; compact: boolean; mode: GameMode }) => (
  <div className='beatmapset-panel__extra-item beatmapset-panel__extra-item--dots'>
    <div className='beatmapset-panel__beatmap-icon'>
      <i className={`fal fa-extra-mode-${mode}`} />
    </div>
    {compact ? (
      <div className='beatmapset-panel__beatmap-count'>
        {beatmaps.length}
      </div>
    ) : (
      beatmaps.map((beatmap) => <BeatmapDot key={beatmap.id} beatmap={beatmap} />)
    )}
  </div>
));

const BeatmapsetBadge = ({ type }: { type: string }) => (
  <span className={`beatmapset-badge beatmapset-badge--${type} beatmapset-badge--panel`}>
    {osu.trans(`beatmapsets.${type.replace(/-/g, '_')}_badge.label`)}
  </span>
);

const MapperLink = observer(({ beatmapset }: { beatmapset: BeatmapsetJson }) => (
  <UserLink
    className='beatmapset-panel__mapper-link u-hover'
    user={{ id: beatmapset.user_id, username: beatmapset.creator }}
  />
));

const PlayIcon = ({ icon, titleVariant }: { icon: string; titleVariant: string }) => (
  <div
    className='beatmapset-panel__play-icon'
    title={osu.trans(`beatmapsets.show.info.${titleVariant}`)}
  >
    <i className={icon} />
  </div>
);

interface StatsItemProps {
  icon: string;
  title: string;
  type: string;
  value: number;
}

const StatsItem = ({ icon, title, type, value }: StatsItemProps) => (
  <div
    className={`beatmapset-panel__stats-item beatmapset-panel__stats-item--${type}`}
    title={title}
  >
    <span className='beatmapset-panel__stats-item-icon'>
      <i className={`fa-fw ${icon}`} />
    </span>
    <span>{formatNumberSuffixed(value, undefined, { maximumFractionDigits: 1, minimumFractionDigits: 0 })}</span>
  </div>
);

@observer
export default class BeatmapsetPanel extends React.Component<Props> {
  @observable private beatmapsPopupHover = false;
  private beatmapsPopupRef = React.createRef<BeatmapsPopup>();
  private blockRef = React.createRef<HTMLDivElement>();
  @observable private mobileExpanded = false;
  private timeouts: Partial<Record<string, number>> = {};

  @computed
  private get beatmapDotsCompact() {
    return this.props.beatmapset.beatmaps != null && this.props.beatmapset.beatmaps.length > 12;
  }

  @computed
  private get displayDate() {
    const attribute = displayDateMap[this.props.beatmapset.status];

    const ret = this.props.beatmapset[attribute];

    if (ret == null) {
      throw Error('trying to display null date');
    }

    return ret;
  }

  @computed
  private get downloadLink() {
    if (core.currentUser == null) {
      return { title: osu.trans('beatmapsets.show.details.logged-out') };
    }

    if (this.props.beatmapset.availability?.download_disabled) {
      return { title: osu.trans('beatmapsets.availability.disabled') };
    }

    let type = core.userPreferences.get('beatmapset_download');
    if (type === 'direct' && !core.currentUser.is_supporter) {
      type = 'all';
    }

    let url: string;
    let titleVariant: string;

    if (type === 'direct') {
      url = beatmapsetDownloadDirect(this.props.beatmapset.id);
      titleVariant = 'direct';
    } else {
      const params: Record<string, string|number> = {
        beatmapset: this.props.beatmapset.id,
      };

      if (this.props.beatmapset.video) {
        if (type === 'no_video') {
          params.noVideo = 1;
          titleVariant = 'no_video';
        } else {
          titleVariant = 'video';
        }
      } else {
        titleVariant = 'all';
      }

      url = route('beatmapsets.download', params);
    }

    return {
      title: osu.trans(`beatmapsets.panel.download.${titleVariant}`),
      url,
    };
  }

  @computed
  private get favourite() {
    return this.props.beatmapset.has_favourited
      ? {
        icon: 'fas fa-heart',
        toggleTitleVariant: 'unfavourite',
      }
      : {
        icon: 'far fa-heart',
        toggleTitleVariant: 'favourite',
      };
  }

  @computed
  private get groupedBeatmaps() {
    return groupBeatmaps(this.props.beatmapset.beatmaps);
  }

  @computed
  private get isBeatmapsPopupVisible() {
    return this.beatmapsPopupHover || this.mobileExpanded;
  }

  @computed
  private get nominations() {
    if (this.props.beatmapset.nominations_summary != null) {
      return this.props.beatmapset.nominations_summary;
    }

    if (this.props.beatmapset.nominations != null) {
      if (this.props.beatmapset.nominations.legacy_mode) {
        return this.props.beatmapset.nominations;
      }

      return {
        current: sum(values(this.props.beatmapset.nominations.current)),
        required: sum(values(this.props.beatmapset.nominations.required)),
      };
    }
  }

  @computed
  private get showHypeCounts() {
    return this.props.beatmapset.hype != null;
  }

  @computed
  private get showVisual() {
    return showVisual(this.props.beatmapset);
  }

  @computed
  private get url() {
    return route('beatmapsets.show', { beatmapset: this.props.beatmapset.id});
  }

  constructor(props: Props) {
    super(props);

    makeObservable(this);
  }

  componentWillUnmount() {
    $(document).off('click', this.onDocumentClick);
    Object.values(this.timeouts).forEach((timeout) => {
      window.clearTimeout(timeout);
    });
  }

  render() {
    let blockClass = classWithModifiers('beatmapset-panel', {
      'beatmaps-popup-visible': this.isBeatmapsPopupVisible,
      'mobile-expanded': this.mobileExpanded,
      [`size-${core.userPreferences.get('beatmapset_card_size')}`]: true,
      'with-hype-counts': this.showHypeCounts,
    });
    if (this.showVisual) {
      blockClass += ' js-audio--player';
    }

    return (
      <div
        ref={this.blockRef}
        className={blockClass}
        data-audio-url={this.props.beatmapset.preview_url}
        onMouseLeave={this.beatmapsPopupHide}
        style={{
          '--beatmaps-popup-transition-duration': `${beatmapsPopupTransitionDuration}ms`,
        } as React.CSSProperties}
      >
        {this.renderBeatmapsPopup()}
        {this.renderCover()}
        <div className='beatmapset-panel__content'>
          {this.renderPlayArea()}
          {this.renderInfoArea()}
          {this.renderMenuArea()}
        </div>
        <button className='beatmapset-panel__mobile-expand' onClick={this.onMobileExpandToggleClick} type='button'>
          <span className={`fas fa-angle-${this.mobileExpanded ? 'up' : 'down'}`} />
        </button>
      </div>
    );
  }

  private beatmapsPopupDelayedHide = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    if (!this.beatmapsPopupHover) return;

    this.timeouts.beatmapsPopup = window.setTimeout(action(() => {
      this.beatmapsPopupHover = false;
    }), 500);
  };

  private beatmapsPopupDelayedShow = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    if (this.beatmapsPopupHover) return;

    this.timeouts.beatmapsPopup = window.setTimeout(action(() => {
      this.beatmapsPopupHover = true;
    }), 100);
  };

  @action
  private beatmapsPopupHide = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    this.beatmapsPopupHover = false;
  };

  @action
  private beatmapsPopupKeep = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    this.beatmapsPopupHover = true;
  };

  private onBeatmapsPopupEnter = () => {
    this.beatmapsPopupKeep();
  };

  private onBeatmapsPopupLeave = () => {
    this.beatmapsPopupDelayedHide();
  };

  @action
  private onDocumentClick = (e: JQuery.ClickEvent) => {
    // only for shrinking
    if (!this.mobileExpanded) return;
    // clicking on anything on the panel itself is handled by the relevant element
    if (this.blockRef.current?.contains(e.target)) return;
    // same thing but for beatmaps popup
    if (this.beatmapsPopupRef.current?.contentRef.current?.contains(e.target)) return;

    $(document).off('click', this.onDocumentClick);
    this.mobileExpanded = false;
  };

  private onExtraRowEnter = () => {<commit_msg>another handler update to ClickEvent type<commit_after>// Copyright (c) ppy Pty Ltd <<EMAIL>>. Licensed under the GNU Affero General Public License v3.0.
// See the LICENCE file in the repository root for full licence text.

import BeatmapsPopup from 'beatmapset-panel/beatmaps-popup';
import BeatmapsetCover from 'components/beatmapset-cover';
import { CircularProgress } from 'components/circular-progress';
import BeatmapJson from 'interfaces/beatmap-json';
import BeatmapsetExtendedJson from 'interfaces/beatmapset-extended-json';
import BeatmapsetJson, { BeatmapsetStatus } from 'interfaces/beatmapset-json';
import GameMode from 'interfaces/game-mode';
import { route } from 'laroute';
import { sum, values } from 'lodash';
import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import core from 'osu-core-singleton';
import * as React from 'react';
import { Transition } from 'react-transition-group';
import { getArtist, getDiffColour, getTitle, group as groupBeatmaps } from 'utils/beatmap-helper';
import { showVisual, toggleFavourite } from 'utils/beatmapset-helper';
import { classWithModifiers } from 'utils/css';
import { formatNumber, formatNumberSuffixed } from 'utils/html';
import { beatmapsetDownloadDirect } from 'utils/url';
import StringWithComponent from './string-with-component';
import TimeWithTooltip from './time-with-tooltip';
import { UserLink } from './user-link';

export const beatmapsetCardSizes = ['normal', 'extra'] as const;
export type BeatmapsetCardSize = typeof beatmapsetCardSizes[number];

interface Props {
  beatmapset: BeatmapsetExtendedJson;
}

const beatmapsPopupTransitionDuration = 150;

const displayDateMap: Record<BeatmapsetStatus, 'last_updated' | 'ranked_date'> = {
  approved: 'ranked_date',
  graveyard: 'last_updated',
  loved: 'ranked_date',
  pending: 'last_updated',
  qualified: 'ranked_date',
  ranked: 'ranked_date',
  wip: 'last_updated',
};

const BeatmapDot = observer(({ beatmap }: { beatmap: BeatmapJson }) => (
  <div
    className='beatmapset-panel__beatmap-dot'
    style={{
      '--bg': getDiffColour(beatmap.difficulty_rating),
    } as React.CSSProperties}
  />
));

const BeatmapDots = observer(({ compact, beatmaps, mode }: { beatmaps: BeatmapJson[]; compact: boolean; mode: GameMode }) => (
  <div className='beatmapset-panel__extra-item beatmapset-panel__extra-item--dots'>
    <div className='beatmapset-panel__beatmap-icon'>
      <i className={`fal fa-extra-mode-${mode}`} />
    </div>
    {compact ? (
      <div className='beatmapset-panel__beatmap-count'>
        {beatmaps.length}
      </div>
    ) : (
      beatmaps.map((beatmap) => <BeatmapDot key={beatmap.id} beatmap={beatmap} />)
    )}
  </div>
));

const BeatmapsetBadge = ({ type }: { type: string }) => (
  <span className={`beatmapset-badge beatmapset-badge--${type} beatmapset-badge--panel`}>
    {osu.trans(`beatmapsets.${type.replace(/-/g, '_')}_badge.label`)}
  </span>
);

const MapperLink = observer(({ beatmapset }: { beatmapset: BeatmapsetJson }) => (
  <UserLink
    className='beatmapset-panel__mapper-link u-hover'
    user={{ id: beatmapset.user_id, username: beatmapset.creator }}
  />
));

const PlayIcon = ({ icon, titleVariant }: { icon: string; titleVariant: string }) => (
  <div
    className='beatmapset-panel__play-icon'
    title={osu.trans(`beatmapsets.show.info.${titleVariant}`)}
  >
    <i className={icon} />
  </div>
);

interface StatsItemProps {
  icon: string;
  title: string;
  type: string;
  value: number;
}

const StatsItem = ({ icon, title, type, value }: StatsItemProps) => (
  <div
    className={`beatmapset-panel__stats-item beatmapset-panel__stats-item--${type}`}
    title={title}
  >
    <span className='beatmapset-panel__stats-item-icon'>
      <i className={`fa-fw ${icon}`} />
    </span>
    <span>{formatNumberSuffixed(value, undefined, { maximumFractionDigits: 1, minimumFractionDigits: 0 })}</span>
  </div>
);

@observer
export default class BeatmapsetPanel extends React.Component<Props> {
  @observable private beatmapsPopupHover = false;
  private beatmapsPopupRef = React.createRef<BeatmapsPopup>();
  private blockRef = React.createRef<HTMLDivElement>();
  @observable private mobileExpanded = false;
  private timeouts: Partial<Record<string, number>> = {};

  @computed
  private get beatmapDotsCompact() {
    return this.props.beatmapset.beatmaps != null && this.props.beatmapset.beatmaps.length > 12;
  }

  @computed
  private get displayDate() {
    const attribute = displayDateMap[this.props.beatmapset.status];

    const ret = this.props.beatmapset[attribute];

    if (ret == null) {
      throw Error('trying to display null date');
    }

    return ret;
  }

  @computed
  private get downloadLink() {
    if (core.currentUser == null) {
      return { title: osu.trans('beatmapsets.show.details.logged-out') };
    }

    if (this.props.beatmapset.availability?.download_disabled) {
      return { title: osu.trans('beatmapsets.availability.disabled') };
    }

    let type = core.userPreferences.get('beatmapset_download');
    if (type === 'direct' && !core.currentUser.is_supporter) {
      type = 'all';
    }

    let url: string;
    let titleVariant: string;

    if (type === 'direct') {
      url = beatmapsetDownloadDirect(this.props.beatmapset.id);
      titleVariant = 'direct';
    } else {
      const params: Record<string, string|number> = {
        beatmapset: this.props.beatmapset.id,
      };

      if (this.props.beatmapset.video) {
        if (type === 'no_video') {
          params.noVideo = 1;
          titleVariant = 'no_video';
        } else {
          titleVariant = 'video';
        }
      } else {
        titleVariant = 'all';
      }

      url = route('beatmapsets.download', params);
    }

    return {
      title: osu.trans(`beatmapsets.panel.download.${titleVariant}`),
      url,
    };
  }

  @computed
  private get favourite() {
    return this.props.beatmapset.has_favourited
      ? {
        icon: 'fas fa-heart',
        toggleTitleVariant: 'unfavourite',
      }
      : {
        icon: 'far fa-heart',
        toggleTitleVariant: 'favourite',
      };
  }

  @computed
  private get groupedBeatmaps() {
    return groupBeatmaps(this.props.beatmapset.beatmaps);
  }

  @computed
  private get isBeatmapsPopupVisible() {
    return this.beatmapsPopupHover || this.mobileExpanded;
  }

  @computed
  private get nominations() {
    if (this.props.beatmapset.nominations_summary != null) {
      return this.props.beatmapset.nominations_summary;
    }

    if (this.props.beatmapset.nominations != null) {
      if (this.props.beatmapset.nominations.legacy_mode) {
        return this.props.beatmapset.nominations;
      }

      return {
        current: sum(values(this.props.beatmapset.nominations.current)),
        required: sum(values(this.props.beatmapset.nominations.required)),
      };
    }
  }

  @computed
  private get showHypeCounts() {
    return this.props.beatmapset.hype != null;
  }

  @computed
  private get showVisual() {
    return showVisual(this.props.beatmapset);
  }

  @computed
  private get url() {
    return route('beatmapsets.show', { beatmapset: this.props.beatmapset.id});
  }

  constructor(props: Props) {
    super(props);

    makeObservable(this);
  }

  componentWillUnmount() {
    $(document).off('click', this.onDocumentClick);
    Object.values(this.timeouts).forEach((timeout) => {
      window.clearTimeout(timeout);
    });
  }

  render() {
    let blockClass = classWithModifiers('beatmapset-panel', {
      'beatmaps-popup-visible': this.isBeatmapsPopupVisible,
      'mobile-expanded': this.mobileExpanded,
      [`size-${core.userPreferences.get('beatmapset_card_size')}`]: true,
      'with-hype-counts': this.showHypeCounts,
    });
    if (this.showVisual) {
      blockClass += ' js-audio--player';
    }

    return (
      <div
        ref={this.blockRef}
        className={blockClass}
        data-audio-url={this.props.beatmapset.preview_url}
        onMouseLeave={this.beatmapsPopupHide}
        style={{
          '--beatmaps-popup-transition-duration': `${beatmapsPopupTransitionDuration}ms`,
        } as React.CSSProperties}
      >
        {this.renderBeatmapsPopup()}
        {this.renderCover()}
        <div className='beatmapset-panel__content'>
          {this.renderPlayArea()}
          {this.renderInfoArea()}
          {this.renderMenuArea()}
        </div>
        <button className='beatmapset-panel__mobile-expand' onClick={this.onMobileExpandToggleClick} type='button'>
          <span className={`fas fa-angle-${this.mobileExpanded ? 'up' : 'down'}`} />
        </button>
      </div>
    );
  }

  private beatmapsPopupDelayedHide = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    if (!this.beatmapsPopupHover) return;

    this.timeouts.beatmapsPopup = window.setTimeout(action(() => {
      this.beatmapsPopupHover = false;
    }), 500);
  };

  private beatmapsPopupDelayedShow = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    if (this.beatmapsPopupHover) return;

    this.timeouts.beatmapsPopup = window.setTimeout(action(() => {
      this.beatmapsPopupHover = true;
    }), 100);
  };

  @action
  private beatmapsPopupHide = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    this.beatmapsPopupHover = false;
  };

  @action
  private beatmapsPopupKeep = () => {
    window.clearTimeout(this.timeouts.beatmapsPopup);

    this.beatmapsPopupHover = true;
  };

  private onBeatmapsPopupEnter = () => {
    this.beatmapsPopupKeep();
  };

  private onBeatmapsPopupLeave = () => {
    this.beatmapsPopupDelayedHide();
  };

  @action
  private onDocumentClick = (e: JQuery.ClickEvent<Document, unknown, Document, Document | HTMLElement>) => {
    // only for shrinking
    if (!this.mobileExpanded) return;
    // clicking on anything on the panel itself is handled by the relevant element
    if (this.blockRef.current?.contains(e.target)) return;
    // same thing but for beatmaps popup
    if (this.beatmapsPopupRef.current?.contentRef.current?.contains(e.target)) return;

    $(document).off('click', this.onDocumentClick);
    this.mobileExpanded = false;
  };

  private onExtraRowEnter = () => {
{'fraction_non_alphanumeric': 0.13845327079974595, 'fraction_numerical': 0.0015633396844008012, 'mean_word_length': 3.507817661308082, 'pattern_counts': {'":': 0, '<': 80, '<?xml version=': 0, '>': 118, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 14, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '930809', 'n_tokens_mistral': 6574, 'n_tokens_neox': 6511, 'n_words': 1239}