182 lines
7.0 KiB
TypeScript
182 lines
7.0 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import type { Bucket } from "@/lib/client/buckets";
|
|
|
|
type BucketCardProps = {
|
|
bucket: Bucket;
|
|
icon?: string | null;
|
|
openEdit: (bucketId: number) => void;
|
|
limit: number;
|
|
usageLabel: string;
|
|
};
|
|
|
|
const TAG_GAP_PX = 8;
|
|
const TAG_CLASS = "shrink-0 rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-[11px]";
|
|
const TAG_MORE_CLASS = "shrink-0 rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-[11px] text-soft";
|
|
|
|
function BucketTagsRow({ tags }: { tags: string[] }) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const moreRef = useRef<HTMLSpanElement | null>(null);
|
|
const tagRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
|
const [visibleCount, setVisibleCount] = useState(tags.length);
|
|
|
|
useEffect(() => {
|
|
tagRefs.current = tagRefs.current.slice(0, tags.length);
|
|
setVisibleCount(tags.length);
|
|
}, [tags]);
|
|
|
|
useEffect(() => {
|
|
if (!tags.length) return;
|
|
|
|
function recomputeVisibleCount() {
|
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
|
const widths = tags.map((_, index) => tagRefs.current[index]?.offsetWidth ?? 0);
|
|
const moreProbe = moreRef.current;
|
|
|
|
if (!containerWidth || !moreProbe || widths.some(width => !width)) {
|
|
setVisibleCount(tags.length);
|
|
return;
|
|
}
|
|
|
|
const totalTagsWidth = widths.reduce((sum, width) => sum + width, 0) + TAG_GAP_PX * Math.max(widths.length - 1, 0);
|
|
if (totalTagsWidth <= containerWidth) {
|
|
setVisibleCount(tags.length);
|
|
return;
|
|
}
|
|
|
|
let nextVisibleCount = 0;
|
|
let usedWidth = 0;
|
|
|
|
for (let index = 0; index < widths.length; index += 1) {
|
|
usedWidth += index === 0 ? widths[index] : TAG_GAP_PX + widths[index];
|
|
const remaining = widths.length - (index + 1);
|
|
if (remaining <= 0) {
|
|
nextVisibleCount = widths.length;
|
|
break;
|
|
}
|
|
|
|
moreProbe.textContent = `${remaining} more...`;
|
|
const moreWidth = moreProbe.offsetWidth;
|
|
const totalWithMore = usedWidth + TAG_GAP_PX + moreWidth;
|
|
if (totalWithMore <= containerWidth) nextVisibleCount = index + 1;
|
|
else break;
|
|
}
|
|
|
|
setVisibleCount(nextVisibleCount);
|
|
}
|
|
|
|
recomputeVisibleCount();
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const observer = new ResizeObserver(recomputeVisibleCount);
|
|
if (containerRef.current) observer.observe(containerRef.current);
|
|
return () => observer.disconnect();
|
|
}
|
|
|
|
window.addEventListener("resize", recomputeVisibleCount);
|
|
return () => window.removeEventListener("resize", recomputeVisibleCount);
|
|
}, [tags]);
|
|
|
|
if (!tags.length) {
|
|
return <span className="text-[11px] text-soft">No tags</span>;
|
|
}
|
|
|
|
const visibleTags = tags.slice(0, visibleCount);
|
|
const hasOverflow = visibleCount < tags.length;
|
|
const remainingCount = tags.length - visibleCount;
|
|
|
|
return (
|
|
<div className="relative w-full">
|
|
<div ref={containerRef} className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
|
{visibleTags.map((tag, index) => (
|
|
<span key={`${tag}-${index}`} className={TAG_CLASS}>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
{hasOverflow ? <span className={TAG_MORE_CLASS}>{remainingCount} more...</span> : null}
|
|
</div>
|
|
<div className="pointer-events-none absolute left-0 top-0 -z-10 opacity-0" aria-hidden="true">
|
|
{tags.map((tag, index) => (
|
|
<span
|
|
key={`${tag}-${index}`}
|
|
ref={element => {
|
|
tagRefs.current[index] = element;
|
|
}}
|
|
className={`${TAG_CLASS} inline-block`}
|
|
>
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
<span ref={moreRef} className={`${TAG_MORE_CLASS} inline-block`}>
|
|
{tags.length} more...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BucketCard({
|
|
bucket,
|
|
icon,
|
|
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 cursor-pointer rounded-lg border border-accent-weak bg-panel px-3 py-3 transition hover:border-accent"
|
|
onClick={() => openEdit(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">
|
|
{bucket.description}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 space-y-2 text-xs text-soft">
|
|
{limit > 0 ? <div>{usageLabel}</div> : null}
|
|
<div className="flex min-w-0 items-center">
|
|
<BucketTagsRow tags={bucket.tags || []} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default React.memo(BucketCard, (prev, next) => (
|
|
prev.bucket === next.bucket
|
|
&& prev.icon === next.icon
|
|
&& prev.limit === next.limit
|
|
&& prev.usageLabel === next.usageLabel
|
|
));
|