neet/refined-itsukara-link

View on GitHub
packages/@neet/vschedule-client/src/components/ui/Entry/Entry.tsx

Summary

Maintainability
B
4 hrs
Test Coverage
import classNames from 'classnames';
import dayjs from 'dayjs';
import Image, { ImageProps } from 'next/future/image';
import type { ReactNode } from 'react';
import { useState } from 'react';
import { H } from 'react-headings';

import { Badge } from '../Badge';
import { Typography } from '../Typography';

export type EntryVariant = 'flat' | 'shade';
type EmbedType = 'always' | 'interaction' | 'never';
export type EntryLayout = 'horizontal' | 'vertical';

interface BaseEntryProps {
  readonly layout: EntryLayout;
  readonly variant: EntryVariant;
}

const thumbnailClass = (layout: EntryLayout): string =>
  classNames(
    layout === 'horizontal' && 'w-40',
    'relative',
    'rounded',
    'aspect-video',
    'bg-gray-200',
    'dark:bg-neutral-800',
    'overflow-hidden',
  );

interface LoadingEntryProps extends BaseEntryProps {
  readonly loading: true;
}

const LoadingEntry = (props: LoadingEntryProps): JSX.Element => {
  const { layout } = props;

  return (
    <div
      className={classNames(
        'animate-pulse',
        layout === 'horizontal' && ['flex', 'space-x-4', 'items-center'],
      )}
    >
      <div className="grow-0 shrink-0">
        <div className={thumbnailClass(layout)} />
      </div>

      <div className="flex-1">
        <div className="h-5 w-2/3 my-1 bg-gray-200 dark:bg-neutral-800 rounded" />
        <div className="h-3 w-full mb-1 bg-gray-200 dark:bg-neutral-800 rounded" />
        <div className="h-3 w-full mb-1 bg-gray-200 dark:bg-neutral-800 rounded" />
      </div>
    </div>
  );
};

type ReadyEntryProps = BaseEntryProps &
  Readonly<JSX.IntrinsicElements['a']> & {
    readonly title: string;
    readonly url: string;
    readonly author: string;
    readonly description: string;
    readonly thumbnail: {
      readonly url: string;
      readonly alt: string;
      readonly blurDataUrl?: string;
    };
    readonly date: Readonly<Date>;
    readonly active: boolean;
    readonly tag?: string;
    readonly embed?: ReactNode;
    readonly embedType?: EmbedType;
    readonly loading: false;
    readonly pinned: boolean;
  };

const ReadyEntry = (props: ReadyEntryProps): JSX.Element => {
  const {
    variant,
    layout,
    url,
    thumbnail,
    active,
    title,
    author,
    tag,
    description,
    embed,
    embedType,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    loading,
    pinned,
    date,
    className,
    ...rest
  } = props;

  const formattedDate = dayjs(date.toISOString());
  const [interacting, setInteraction] = useState(false);

  const showEmbed =
    embed != null &&
    ((embedType === 'interaction' && interacting) || embedType === 'always');

  const blurProps: Partial<ImageProps> =
    thumbnail.blurDataUrl != null
      ? { blurDataURL: thumbnail.blurDataUrl, placeholder: 'blur' }
      : {};

  return (
    <a
      href={url}
      target="_blank"
      rel="noreferrer noopener"
      onMouseOver={() => void setInteraction(true)}
      onMouseLeave={() => void setInteraction(false)}
      onFocus={() => void setInteraction(true)}
      onBlur={() => void setInteraction(false)}
      className={classNames(
        'group',
        layout === 'horizontal' && ['flex', 'space-x-4', 'items-center'],
        layout === 'vertical' && 'space-y-2',
        className,
      )}
      {...rest}
    >
      <div className="relative">
        <div
          className={classNames(
            thumbnailClass(layout),
            variant === 'shade' && 'shadow border dark:border-neutral-700',
            variant === 'flat' &&
              'border border-gray-200 dark:border-neutral-700',
          )}
        >
          {showEmbed ? (
            embed
          ) : (
            <Image
              loading="lazy"
              fill
              src={thumbnail.url}
              alt={thumbnail.alt}
              {...blurProps}
            />
          )}
        </div>

        {active && (
          <div className="absolute top-2 right-2">
            <Badge variant="ping">配信中</Badge>
          </div>
        )}

        {!active && pinned && (
          <div className="absolute top-2 right-2">
            <Badge>注目の配信</Badge>
          </div>
        )}
      </div>

      <div className={'space-y-1'}>
        <Typography
          as={H}
          weight="semibold"
          className={classNames(
            'group-hover:text-primary-600',
            'dark:group-hover:text-primary-400',
            'ease-out',
            'transition-colors',
            'line-clamp-2',
          )}
        >
          {title}
        </Typography>

        <Typography as="p" variant="wash" className="line-clamp-2 break-all">
          {description}
        </Typography>

        <Typography
          as="dl"
          size="sm"
          variant="wash"
          className={classNames('flex')}
        >
          <dt className="sr-only">ライバー</dt>
          <dd className="mr-2">{author}</dd>

          <span aria-hidden className="mr-2">
            •
          </span>

          <dt className="sr-only">開始時刻</dt>
          <dd className="mr-2">
            <time dateTime={formattedDate.toISOString()}>
              {dayjs(formattedDate).fromNow()}
            </time>
          </dd>

          {tag != null && (
            <>
              <span aria-hidden className="mr-2">
                •
              </span>
              <dt className="sr-only">タグ</dt>
              <dd className="mr-2">{tag}</dd>
            </>
          )}
        </Typography>
      </div>
    </a>
  );
};

export type EntryProps = LoadingEntryProps | ReadyEntryProps;

export const Entry = (props: EntryProps): JSX.Element => {
  if (props.loading) {
    return <LoadingEntry {...props} />;
  }

  return <ReadyEntry {...props} />;
};

Entry.defaultProps = {
  loading: false,
  variant: 'shade',
  embed: null,
  embedType: 'interaction',
  pinned: false,
  layout: 'vertical',
};