RankingList.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. 'use client';
  2. import type { CSSProperties } from 'react';
  3. import Link from 'next/link';
  4. import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
  5. import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
  6. import MemberGradeBadge from './MemberGradeBadge';
  7. import type { RankingEntry, RankingType } from '@/types/response/ranking';
  8. interface Props {
  9. entries: RankingEntry[];
  10. type: RankingType;
  11. viewerMemberID?: number|null;
  12. }
  13. function formatAmount(amount: number): string {
  14. if (amount >= 100000000) {
  15. return `${(amount / 100000000).toFixed(1)}억`;
  16. }
  17. if (amount >= 10000) {
  18. return `${(amount / 10000).toFixed(1)}만`;
  19. }
  20. return amount.toLocaleString('ko-KR');
  21. }
  22. function formatDuration(sec: number): string {
  23. if (sec <= 0) {
  24. return '-';
  25. }
  26. const hours = Math.floor(sec / 3600);
  27. if (hours >= 10000) {
  28. return `${Math.floor(hours / 10000)}만h`;
  29. }
  30. if (hours >= 1000) {
  31. return `${(hours / 1000).toFixed(1)}K h`;
  32. }
  33. return `${hours}h`;
  34. }
  35. function RankChange({ change }: { change: number|null }) {
  36. if (change === null || change === undefined) {
  37. return <span className="ranking__change ranking__change--new">NEW</span>;
  38. }
  39. if (change > 0) {
  40. return (
  41. <span className="ranking__change ranking__change--up">
  42. <ArrowUp size={12} />{change}
  43. </span>
  44. );
  45. }
  46. if (change < 0) {
  47. return (
  48. <span className="ranking__change ranking__change--down">
  49. <ArrowDown size={12} />{Math.abs(change)}
  50. </span>
  51. );
  52. }
  53. return (
  54. <span className="ranking__change ranking__change--same">
  55. <Minus size={12} />
  56. </span>
  57. );
  58. }
  59. export default function RankingList({ entries, type, viewerMemberID }: Props)
  60. {
  61. if (entries.length === 0) {
  62. return <p className="ranking__empty">집계된 순위가 없습니다.</p>;
  63. }
  64. const topScore = entries[0]?.score ?? entries[0]?.moneyAmount ?? 1;
  65. return (
  66. <div className="ranking__list">
  67. <div className="ranking__list-header">
  68. <span className='text-center'>순위</span>
  69. <span className='text-center'>변동</span>
  70. <span className='ps-2'>회원</span>
  71. <span className='text-center'>진행도</span>
  72. <span className='text-center'>금액</span>
  73. {type === 'donor' ? null : <span className='text-center'>방송</span>}
  74. </div>
  75. {entries.map((entry) => {
  76. const isMine = viewerMemberID != null && viewerMemberID === entry.memberID;
  77. const progress = topScore > 0 ? Math.min(100, (entry.moneyAmount / topScore) * 100) : 0;
  78. const targetHref = entry.isAnonymous ? '#' : `/channel/${entry.memberID}`;
  79. return (
  80. <div key={`${entry.memberID}-${entry.rank}`} className={`ranking__row ${isMine ? 'ranking__row--mine' : ''}`}>
  81. <div className="ranking__row-rank">#{entry.rank}</div>
  82. <div className="ranking__row-change">
  83. <RankChange change={entry.rankChange} />
  84. </div>
  85. <div className="ranking__row-member">
  86. <Link href={targetHref} className="ranking__row-member-link">
  87. <Avatar className="h-8 w-8">
  88. {(entry.channelThumb ?? entry.memberThumb) ? <AvatarImage src={(entry.channelThumb ?? entry.memberThumb)!} alt={entry.memberName} /> : null}
  89. <AvatarFallback>{entry.memberName?.[0] ?? '?'}</AvatarFallback>
  90. </Avatar>
  91. <span className="ranking__row-name">
  92. <span className="ranking__row-name-main">
  93. {entry.memberName}
  94. <MemberGradeBadge grade={entry.memberGrade} />
  95. </span>
  96. {entry.channelName && entry.channelName !== entry.memberName ? (
  97. <small className="ranking__row-channel">{entry.channelName}</small>
  98. ) : null}
  99. </span>
  100. </Link>
  101. </div>
  102. <div className="ranking__row-progress">
  103. <div className="ranking__row-progress-bar" style={{ '--progress': `${progress}%` } as CSSProperties}>
  104. <span />
  105. </div>
  106. </div>
  107. <div className="ranking__row-amount">
  108. {formatAmount(entry.moneyAmount)}
  109. {type === 'donor' ? 'P' : '원'}
  110. </div>
  111. {type !== 'donor' ? (
  112. <div className="ranking__row-broadcast">
  113. <span>{formatDuration(entry.broadcastSec)}</span>
  114. <small>{formatAmount(entry.viewCount)} 조회</small>
  115. </div>
  116. ) : null}
  117. </div>
  118. );
  119. })}
  120. </div>
  121. );
  122. }