162 lines
6.7 KiB
TypeScript
162 lines
6.7 KiB
TypeScript
import React from "react";
|
|
import type { Bucket } from "@/lib/client/buckets";
|
|
|
|
type BucketCardProps = {
|
|
bucket: Bucket;
|
|
icon?: string | null;
|
|
isExpanded: boolean;
|
|
toggleExpanded: (bucketId: number) => void;
|
|
isMenuOpen: boolean;
|
|
setMenuOpenId: React.Dispatch<React.SetStateAction<number | null>>;
|
|
setConfirmDeleteId: (bucketId: number) => void;
|
|
openEdit: (bucketId: number) => void;
|
|
limit: number;
|
|
usageLabel: string;
|
|
};
|
|
|
|
export function BucketCard({
|
|
bucket,
|
|
icon,
|
|
isExpanded,
|
|
toggleExpanded,
|
|
isMenuOpen,
|
|
setMenuOpenId,
|
|
setConfirmDeleteId,
|
|
openEdit,
|
|
limit,
|
|
usageLabel,
|
|
}: BucketCardProps) {
|
|
const spent = bucket.totalUsage || 0;
|
|
const rawPercent = limit > 0 ? (spent / limit) * 100 : 0;
|
|
const progressPercent = Math.max(0, Math.min(100, rawPercent));
|
|
const progressColor = rawPercent > 100 ? "#ef4444" : rawPercent >= 80 ? "#facc15" : "#4ade80";
|
|
const ringTrackColor = "rgba(148, 163, 184, 0.25)";
|
|
|
|
return (
|
|
<div
|
|
className="w-full max-w-[360px] rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
|
onClick={() => toggleExpanded(bucket.id)}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
{limit > 0 ? (
|
|
<div className="relative h-11 w-11 shrink-0">
|
|
<div
|
|
className="absolute inset-0 rounded-full"
|
|
style={{
|
|
background: `conic-gradient(${progressColor} 0% ${progressPercent}%, ${ringTrackColor} ${progressPercent}% 100%)`,
|
|
}}
|
|
/>
|
|
<div className="absolute inset-[5px] flex items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
|
{icon || "?"}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg">
|
|
{icon || "?"}
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold">{bucket.name}</div>
|
|
{bucket.description ? (
|
|
<div className={`text-xs text-soft ${isExpanded ? "" : "truncate"}`}>
|
|
{bucket.description}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative" data-bucket-menu>
|
|
<button
|
|
type="button"
|
|
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setMenuOpenId((prev) => (prev === bucket.id ? null : bucket.id));
|
|
}}
|
|
aria-label="Bucket actions"
|
|
data-bucket-menu-button
|
|
>
|
|
...
|
|
</button>
|
|
|
|
{isMenuOpen ? (
|
|
<div className="absolute right-0 mt-2 w-40 rounded-lg border border-accent-weak bg-panel p-1 text-xs shadow-lg">
|
|
<button
|
|
type="button"
|
|
className="w-full rounded-md px-2 py-1 text-left hover:bg-accent-soft"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openEdit(bucket.id);
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="w-full rounded-md px-2 py-1 text-left text-red-200 hover:bg-red-500/10"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setConfirmDeleteId(bucket.id);
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{limit > 0 ? (
|
|
<>
|
|
{isExpanded ? (
|
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
|
<div>{usageLabel}</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{bucket.tags?.length ? (
|
|
bucket.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-[11px] text-soft">No tags</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : isExpanded ? (
|
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-soft">
|
|
{bucket.tags?.length ? (
|
|
bucket.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]"
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-[11px] text-soft">No tags</span>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default React.memo(BucketCard, (prev, next) => (
|
|
prev.bucket === next.bucket
|
|
&& prev.icon === next.icon
|
|
&& prev.isExpanded === next.isExpanded
|
|
&& prev.isMenuOpen === next.isMenuOpen
|
|
&& prev.limit === next.limit
|
|
&& prev.usageLabel === next.usageLabel
|
|
));
|