ubiquitous-invention/apps/web/components/whiteboard/shapes/project-card.tsx

162 lines
4.8 KiB
TypeScript
Raw Normal View History

"use client";
import { FolderKanban, ListChecks, Users } from "lucide-react";
import {
BaseBoxShapeUtil,
HTMLContainer,
T,
type TLBaseShape,
toDomPrecision,
} from "tldraw";
import { cn } from "@/lib/utils";
export type ProjectCardShape = TLBaseShape<
"project-card",
{
w: number;
h: number;
objectId: string;
title: string;
taskCount: number;
memberCount: number;
progress: number;
}
>;
function clampProgress(n: number): number {
if (Number.isNaN(n)) return 0;
return Math.min(100, Math.max(0, n));
}
function ProjectCardBody({ shape }: { shape: ProjectCardShape }) {
const { title, taskCount, memberCount, progress } = shape.props;
const pct = clampProgress(progress);
return (
<HTMLContainer>
<div
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] shadow-md",
)}
style={{ fontSize: "11px", lineHeight: 1.35 }}
>
<div
className="h-1 w-full shrink-0"
style={{
background: "linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 100%)",
}}
aria-hidden
/>
<div className="flex min-h-0 flex-1 flex-col px-3 pb-2 pt-2.5">
<div className="flex min-w-0 items-start gap-2">
<FolderKanban
className="mt-0.5 size-[18px] shrink-0 text-[hsl(var(--primary))]"
strokeWidth={2}
aria-hidden
/>
<div className="min-w-0 flex-1 text-[14px] font-bold leading-tight tracking-tight">
{title || "Untitled project"}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="flex items-center gap-1.5 rounded-lg bg-[hsl(var(--muted)/0.5)] px-2 py-1.5 text-[hsl(var(--muted-foreground))]">
<ListChecks className="size-3.5 shrink-0 text-[hsl(var(--primary))]" aria-hidden />
<span className="font-medium text-[hsl(var(--card-foreground))]">{taskCount}</span>
<span className="truncate">tasks</span>
</div>
<div className="flex items-center gap-1.5 rounded-lg bg-[hsl(var(--muted)/0.5)] px-2 py-1.5 text-[hsl(var(--muted-foreground))]">
<Users className="size-3.5 shrink-0 text-[hsl(var(--teal))]" aria-hidden />
<span className="font-medium text-[hsl(var(--card-foreground))]">{memberCount}</span>
<span className="truncate">members</span>
</div>
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[10px] text-[hsl(var(--muted-foreground))]">
<span>Progress</span>
<span className="font-semibold tabular-nums text-[hsl(var(--card-foreground))]">{pct}%</span>
</div>
<div
className="h-2 w-full overflow-hidden rounded-full bg-[hsl(var(--muted))]"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="h-full rounded-full transition-[width]"
style={{
width: `${pct}%`,
background: "linear-gradient(90deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 100%)",
}}
/>
</div>
</div>
</div>
<div className="border-t border-[hsl(var(--border)/0.65)] bg-[hsl(var(--muted)/0.35)] px-3 py-1 text-[9px] font-medium uppercase tracking-wider text-[hsl(var(--muted-foreground))]">
Project
</div>
</div>
</HTMLContainer>
);
}
export class ProjectCardShapeUtil extends BaseBoxShapeUtil<any> {
static override type = "project-card";
static override props = {
w: T.number,
h: T.number,
objectId: T.string,
title: T.string,
taskCount: T.number,
memberCount: T.number,
progress: T.number,
};
override getDefaultProps(): ProjectCardShape["props"] {
return {
w: 260,
h: 180,
objectId: "",
title: "Project",
taskCount: 0,
memberCount: 0,
progress: 0,
};
}
override getAriaDescriptor(shape: ProjectCardShape) {
return shape.props.title;
}
override component(shape: ProjectCardShape) {
return <ProjectCardBody shape={shape} />;
}
override indicator(shape: ProjectCardShape) {
return (
<rect
width={toDomPrecision(shape.props.w)}
height={toDomPrecision(shape.props.h)}
rx={10}
ry={10}
/>
);
}
override useLegacyIndicator() {
return false;
}
override getIndicatorPath(shape: ProjectCardShape) {
const p = new Path2D();
p.roundRect(0, 0, shape.props.w, shape.props.h, 10);
return p;
}
}