181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { Calendar, User } from "lucide-react";
|
||
|
|
import {
|
||
|
|
BaseBoxShapeUtil,
|
||
|
|
HTMLContainer,
|
||
|
|
T,
|
||
|
|
type TLBaseShape,
|
||
|
|
toDomPrecision,
|
||
|
|
} from "tldraw";
|
||
|
|
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
export type TaskCardShape = TLBaseShape<
|
||
|
|
"task-card",
|
||
|
|
{
|
||
|
|
w: number;
|
||
|
|
h: number;
|
||
|
|
objectId: string;
|
||
|
|
title: string;
|
||
|
|
status: "open" | "in_progress" | "done" | "closed";
|
||
|
|
priority: string;
|
||
|
|
assignees: string;
|
||
|
|
dueDate: string;
|
||
|
|
}
|
||
|
|
>;
|
||
|
|
|
||
|
|
const STATUS_DOT: Record<TaskCardShape["props"]["status"], string> = {
|
||
|
|
open: "bg-zinc-400 dark:bg-zinc-500",
|
||
|
|
in_progress: "bg-blue-500 dark:bg-blue-400",
|
||
|
|
done: "bg-emerald-500 dark:bg-emerald-400",
|
||
|
|
closed: "bg-slate-500 dark:bg-slate-400",
|
||
|
|
};
|
||
|
|
|
||
|
|
function priorityClass(priority: string): string {
|
||
|
|
const p = priority.toLowerCase();
|
||
|
|
if (p.includes("high")) return "bg-red-500/15 text-red-700 ring-1 ring-red-500/30 dark:text-red-300";
|
||
|
|
if (p.includes("low")) return "bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-500/30 dark:text-emerald-300";
|
||
|
|
return "bg-amber-500/15 text-amber-800 ring-1 ring-amber-500/30 dark:text-amber-200";
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialsFromName(name: string): string {
|
||
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||
|
|
if (parts.length === 0) return "?";
|
||
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
function TaskCardBody({ shape }: { shape: TaskCardShape }) {
|
||
|
|
const { title, status, priority, assignees, dueDate } = shape.props;
|
||
|
|
const names = assignees
|
||
|
|
.split(",")
|
||
|
|
.map((s) => s.trim())
|
||
|
|
.filter(Boolean)
|
||
|
|
.slice(0, 2);
|
||
|
|
|
||
|
|
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-sm",
|
||
|
|
"ring-1 ring-[hsl(var(--primary)/0.12)]",
|
||
|
|
)}
|
||
|
|
style={{ fontSize: "11px", lineHeight: 1.35 }}
|
||
|
|
>
|
||
|
|
<div className="flex min-h-0 flex-1 flex-col px-2.5 pb-1.5 pt-2">
|
||
|
|
<div className="flex min-w-0 items-start gap-2">
|
||
|
|
<span
|
||
|
|
className={cn("mt-1.5 size-2 shrink-0 rounded-full", STATUS_DOT[status])}
|
||
|
|
title={status.replace("_", " ")}
|
||
|
|
aria-hidden
|
||
|
|
/>
|
||
|
|
<div className="min-w-0 flex-1 font-semibold leading-tight tracking-tight text-[13px]">
|
||
|
|
{title || "Untitled task"}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||
|
|
<span
|
||
|
|
className={cn(
|
||
|
|
"inline-flex max-w-full truncate rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||
|
|
priorityClass(priority),
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{priority || "—"}
|
||
|
|
</span>
|
||
|
|
{names.length > 0 ? (
|
||
|
|
<div className="flex items-center gap-0.5">
|
||
|
|
{names.map((name, i) => (
|
||
|
|
<span
|
||
|
|
key={`${name}-${i}`}
|
||
|
|
className="flex size-6 items-center justify-center rounded-full bg-[hsl(var(--muted))] text-[9px] font-semibold text-[hsl(var(--muted-foreground))] ring-1 ring-[hsl(var(--border))]"
|
||
|
|
title={name}
|
||
|
|
>
|
||
|
|
{initialsFromName(name)}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-[hsl(var(--muted-foreground))]">
|
||
|
|
<User className="size-3 shrink-0 opacity-70" aria-hidden />
|
||
|
|
<span>Unassigned</span>
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{dueDate ? (
|
||
|
|
<div className="mt-auto flex items-center gap-1 pt-2 text-[10px] text-[hsl(var(--muted-foreground))]">
|
||
|
|
<Calendar className="size-3 shrink-0 opacity-80" aria-hidden />
|
||
|
|
<span className="truncate">{dueDate}</span>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="mt-auto pt-2 text-[10px] text-[hsl(var(--muted-foreground)/0.6)]">No due date</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="border-t border-[hsl(var(--border)/0.65)] bg-[hsl(var(--muted)/0.35)] px-2.5 py-1 text-[9px] font-medium uppercase tracking-wider text-[hsl(var(--muted-foreground))]">
|
||
|
|
Task
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</HTMLContainer>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export class TaskCardShapeUtil extends BaseBoxShapeUtil<any> {
|
||
|
|
static override type = "task-card";
|
||
|
|
|
||
|
|
static override props = {
|
||
|
|
w: T.number,
|
||
|
|
h: T.number,
|
||
|
|
objectId: T.string,
|
||
|
|
title: T.string,
|
||
|
|
status: T.literalEnum("open", "in_progress", "done", "closed"),
|
||
|
|
priority: T.string,
|
||
|
|
assignees: T.string,
|
||
|
|
dueDate: T.string,
|
||
|
|
};
|
||
|
|
|
||
|
|
override getDefaultProps(): TaskCardShape["props"] {
|
||
|
|
return {
|
||
|
|
w: 240,
|
||
|
|
h: 160,
|
||
|
|
objectId: "",
|
||
|
|
title: "Task",
|
||
|
|
status: "open",
|
||
|
|
priority: "Medium",
|
||
|
|
assignees: "",
|
||
|
|
dueDate: "",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
override getAriaDescriptor(shape: TaskCardShape) {
|
||
|
|
return shape.props.title;
|
||
|
|
}
|
||
|
|
|
||
|
|
override component(shape: TaskCardShape) {
|
||
|
|
return <TaskCardBody shape={shape} />;
|
||
|
|
}
|
||
|
|
|
||
|
|
override indicator(shape: TaskCardShape) {
|
||
|
|
return (
|
||
|
|
<rect
|
||
|
|
width={toDomPrecision(shape.props.w)}
|
||
|
|
height={toDomPrecision(shape.props.h)}
|
||
|
|
rx={10}
|
||
|
|
ry={10}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
override useLegacyIndicator() {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
override getIndicatorPath(shape: TaskCardShape) {
|
||
|
|
const p = new Path2D();
|
||
|
|
p.roundRect(0, 0, shape.props.w, shape.props.h, 10);
|
||
|
|
return p;
|
||
|
|
}
|
||
|
|
}
|