| "use client"; |
|
|
| import { useEffect, useMemo, useState } from "react"; |
| import { formatResetTime, getRemainingPercentage } from "./utils"; |
|
|
| const PAGE_SIZE = 10; |
|
|
| |
| |
| |
| function formatResetTimeDisplay(resetTime) { |
| if (!resetTime) return null; |
|
|
| try { |
| const date = new Date(resetTime); |
| const now = new Date(); |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); |
| const tomorrow = new Date(today); |
| tomorrow.setDate(tomorrow.getDate() + 1); |
|
|
| let dayStr = ""; |
| if (date >= today && date < tomorrow) { |
| dayStr = "Today"; |
| } else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) { |
| dayStr = "Tomorrow"; |
| } else { |
| dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); |
| } |
|
|
| const timeStr = date.toLocaleTimeString("en-US", { |
| hour: "numeric", |
| minute: "2-digit", |
| hour12: true, |
| }); |
|
|
| return `${dayStr}, ${timeStr}`; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| function getColorClasses(remainingPercentage) { |
| if (remainingPercentage > 70) { |
| return { |
| text: "text-green-600 dark:text-green-400", |
| bg: "bg-green-500", |
| bgLight: "bg-green-500/10", |
| emoji: "🟢", |
| }; |
| } |
|
|
| if (remainingPercentage >= 30) { |
| return { |
| text: "text-yellow-600 dark:text-yellow-400", |
| bg: "bg-yellow-500", |
| bgLight: "bg-yellow-500/10", |
| emoji: "🟡", |
| }; |
| } |
|
|
| return { |
| text: "text-red-600 dark:text-red-400", |
| bg: "bg-red-500", |
| bgLight: "bg-red-500/10", |
| emoji: "🔴", |
| }; |
| } |
|
|
| function sortQuotas(quotas, sortMode) { |
| if (sortMode === "remaining-asc") { |
| return [...quotas].sort((a, b) => a.remaining - b.remaining || a.name.localeCompare(b.name)); |
| } |
|
|
| if (sortMode === "remaining-desc") { |
| return [...quotas].sort((a, b) => b.remaining - a.remaining || a.name.localeCompare(b.name)); |
| } |
|
|
| return quotas; |
| } |
|
|
| |
| |
| |
| export default function QuotaTable({ |
| quotas = [], |
| compact = false, |
| sortMode = "default", |
| showSortLabel = false, |
| }) { |
| const [page, setPage] = useState(1); |
|
|
| const normalizedQuotas = useMemo( |
| () => quotas.map((quota, index) => ({ |
| ...quota, |
| index, |
| remaining: getRemainingPercentage(quota), |
| })), |
| [quotas], |
| ); |
|
|
| const sortedQuotas = useMemo( |
| () => sortQuotas(normalizedQuotas, sortMode), |
| [normalizedQuotas, sortMode], |
| ); |
|
|
| const totalPages = Math.max(1, Math.ceil(sortedQuotas.length / PAGE_SIZE)); |
|
|
| useEffect(() => { |
| setPage(1); |
| }, [sortMode, quotas]); |
|
|
| useEffect(() => { |
| setPage((currentPage) => Math.min(currentPage, totalPages)); |
| }, [totalPages]); |
|
|
| if (!quotas || quotas.length === 0) { |
| return null; |
| } |
|
|
| const currentPageRows = sortedQuotas.slice( |
| (page - 1) * PAGE_SIZE, |
| page * PAGE_SIZE, |
| ); |
| const pageStart = sortedQuotas.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1; |
| const pageEnd = Math.min(page * PAGE_SIZE, sortedQuotas.length); |
|
|
| const cellPad = compact ? "py-1 px-1.5" : "py-2 px-3"; |
| const nameText = compact ? "text-[11px]" : "text-sm"; |
| const resetPrimary = compact ? "text-[11px]" : "text-sm"; |
| const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs"; |
| const sortLabel = "Sorted by account remaining"; |
|
|
| return ( |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between gap-2"> |
| <div className="text-[10px] text-text-muted"> |
| {sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""} |
| </div> |
| {showSortLabel && ( |
| <div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1 text-[10px] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"> |
| {sortLabel} |
| </div> |
| )} |
| </div> |
| |
| <div className="overflow-x-auto"> |
| <table className="w-full table-fixed text-left"> |
| <tbody> |
| {currentPageRows.map((quota) => { |
| const colors = getColorClasses(quota.remaining); |
| const countdown = formatResetTime(quota.resetAt); |
| const resetDisplay = formatResetTimeDisplay(quota.resetAt); |
| |
| return ( |
| <tr |
| key={`${quota.name}-${quota.index}`} |
| className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" |
| > |
| <td className={`${cellPad} w-[30%]`}> |
| <div className="flex items-center gap-1.5 min-w-0"> |
| <span className="text-[10px] shrink-0">{colors.emoji}</span> |
| <span className={`${nameText} font-medium text-text-primary truncate`}> |
| {quota.name} |
| </span> |
| </div> |
| </td> |
| |
| <td className={`${cellPad} w-[45%]`}> |
| <div className={compact ? "space-y-1" : "space-y-1.5"}> |
| <div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${ |
| quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent" |
| }`}> |
| <div |
| className={`h-full transition-all duration-300 ${colors.bg}`} |
| style={{ width: `${Math.min(quota.remaining, 100)}%` }} |
| /> |
| </div> |
| |
| <div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}> |
| <span className="text-text-muted"> |
| {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} |
| </span> |
| <span className={`font-medium ${colors.text}`}> |
| {quota.remaining}% |
| </span> |
| </div> |
| </div> |
| </td> |
| |
| <td className={`${cellPad} w-[25%]`}> |
| {countdown !== "-" || resetDisplay ? ( |
| compact ? ( |
| <div |
| className={`${resetPrimary} text-text-primary font-medium truncate`} |
| title={resetDisplay || ""} |
| > |
| {countdown !== "-" ? `in ${countdown}` : resetDisplay} |
| </div> |
| ) : ( |
| <div className="space-y-0.5"> |
| {countdown !== "-" && ( |
| <div className={`${resetPrimary} text-text-primary font-medium`}> |
| in {countdown} |
| </div> |
| )} |
| {resetDisplay && ( |
| <div className={`${resetSecondary} text-text-muted`}> |
| {resetDisplay} |
| </div> |
| )} |
| </div> |
| ) |
| ) : ( |
| <div className={`${resetPrimary} text-text-muted italic`}>N/A</div> |
| )} |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| </div> |
| |
| {totalPages > 1 && ( |
| <div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1.5 dark:border-white/10 dark:bg-white/[0.03]"> |
| <div className="flex items-center justify-between gap-2 text-[10px] text-text-muted"> |
| <span> |
| Showing {pageStart}-{pageEnd} of {sortedQuotas.length} |
| </span> |
| <span> |
| Page {page} / {totalPages} |
| </span> |
| </div> |
| <div className="mt-1.5 flex items-center justify-end gap-1"> |
| <button |
| type="button" |
| onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))} |
| disabled={page === 1} |
| className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| > |
| Prev |
| </button> |
| <button |
| type="button" |
| onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))} |
| disabled={page === totalPages} |
| className="flex h-6 items-center rounded-md border border-black/10 px-2 text-[10px] text-text-primary transition-colors hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:hover:bg-white/5" |
| > |
| Next |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|