Team Notes
Team Notes is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
other
What it is
Team Notes is a React and Tailwind CSS component from HextaUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Team Notes
"use client";
import {
Edit,
Loader2,
MoreVertical,
Plus,
Search,
Sparkles,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/registry/new-york/ui/avatar";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/registry/new-york/ui/empty";
import { Field, FieldContent, FieldLabel } from "@/registry/new-york/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Textarea } from "@/registry/new-york/ui/textarea";
export interface TeamNote {
id: string;
title: string;
content: string;
author: {
id: string;
name: string;
avatar?: string;
};
tags?: string[];
aiSummary?: string;
createdAt: Date;
updatedAt: Date;
lastEditedBy?: {
id: string;
name: string;
};
participants?: {
id: string;
name: string;
avatar?: string;
}[];
}
export interface TeamNotesProps {
notes?: TeamNote[];
currentUserId?: string;
onCreate?: (data: {
title: string;
content: string;
tags?: string[];
}) => Promise<TeamNote>;
onUpdate?: (
noteId: string,
data: { title?: string; content?: string; tags?: string[] }
) => Promise<void>;
onDelete?: (noteId: string) => Promise<void>;
onSummarize?: (noteId: string) => Promise<string>;
className?: string;
showSearch?: boolean;
showTags?: boolean;
}
function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diff = now - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return formatDate(date);
}
function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
export default function TeamNotes({
notes = [],
currentUserId,
onCreate,
onUpdate,
onDelete,
onSummarize,
className,
showSearch = true,
showTags = true,
}: TeamNotesProps) {
const [searchQuery, setSearchQuery] = useState("");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingNote, setEditingNote] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isSummarizing, setIsSummarizing] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [noteData, setNoteData] = useState({
title: "",
content: "",
tags: [] as string[],
});
const filteredNotes = notes.filter((note) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
note.title.toLowerCase().includes(query) ||
note.content.toLowerCase().includes(query) ||
note.tags?.some((tag) => tag.toLowerCase().includes(query))
);
});
const handleCreate = async () => {
if (!(noteData.title.trim() && onCreate)) return;
setIsCreating(true);
try {
await onCreate(noteData);
setNoteData({ title: "", content: "", tags: [] });
setCreateDialogOpen(false);
} finally {
setIsCreating(false);
}
};
const handleSummarize = async (noteId: string) => {
if (!onSummarize) return;
setIsSummarizing(noteId);
try {
await onSummarize(noteId);
} finally {
setIsSummarizing(null);
}
};
const handleAction = async (action: () => Promise<void>, noteId: string) => {
setActionLoading(noteId);
try {
await action();
} finally {
setActionLoading(null);
}
};
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col flex-wrap gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<CardTitle>Team Notes</CardTitle>
<CardDescription>
{notes.length} note{notes.length !== 1 ? "s" : ""} shared with
your team
</CardDescription>
</div>
{onCreate && (
<Dialog
onOpenChange={setCreateDialogOpen}
open={createDialogOpen}
>
<DialogTrigger asChild>
<Button className="w-full shrink-0 md:w-auto" type="button">
<Plus className="size-4" />
New Note
</Button>
</DialogTrigger>
<DialogContent className="md:max-w-2xl">
<DialogHeader>
<DialogTitle>Create Note</DialogTitle>
<DialogDescription>
Create a new collaborative note
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="note-title">Title</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="note-title"
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) =>
setNoteData((prev) => ({
...prev,
title: e.target.value,
}))
}
placeholder="Note title…"
type="text"
value={noteData.title}
/>
</InputGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="note-content">Content</FieldLabel>
<FieldContent>
<Textarea
id="note-content"
onChange={(
e: React.ChangeEvent<HTMLTextAreaElement>
) =>
setNoteData((prev) => ({
...prev,
content: e.target.value,
}))
}
placeholder="Write your note…"
rows={8}
value={noteData.content}
/>
</FieldContent>
</Field>
</div>
<DialogFooter>
<Button
onClick={() => setCreateDialogOpen(false)}
type="button"
variant="outline"
>
Cancel
</Button>
<Button
aria-busy={isCreating}
data-loading={isCreating}
disabled={!noteData.title.trim()}
onClick={handleCreate}
type="button"
>
{isCreating ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating…
</>
) : (
"Create Note"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
{showSearch && (
<InputGroup>
<InputGroupAddon>
<Search className="size-4" />
</InputGroupAddon>
<InputGroupInput
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchQuery(e.target.value)
}
placeholder="Search notes…"
type="search"
value={searchQuery}
/>
</InputGroup>
)}
</div>
</CardHeader>
<CardContent>
{filteredNotes.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Edit className="size-6" />
</EmptyMedia>
<EmptyTitle>
{searchQuery ? "No notes found" : "No notes yet"}
</EmptyTitle>
</EmptyHeader>
</Empty>
) : (
<div className="grid gap-4 md:grid-cols-2">
{filteredNotes.map((note) => (
<div
className="group flex flex-col gap-3 rounded-lg border bg-card p-4 transition-colors hover:border-primary hover:shadow-sm"
key={note.id}
>
<div className="flex items-start justify-between gap-2">
<h3 className="wrap-break-word font-semibold text-base leading-tight">
{note.title}
</h3>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`More options for ${note.title}`}
size="icon-sm"
type="button"
variant="ghost"
>
{actionLoading === note.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreVertical className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
collisionPadding={8}
sideOffset={4}
>
{onSummarize && (
<DropdownMenuItem
disabled={isSummarizing === note.id}
onSelect={() => handleSummarize(note.id)}
>
{isSummarizing === note.id ? (
<>
<Loader2 className="size-4 animate-spin" />
Summarizing…
</>
) : (
<>
<Sparkles className="size-4" />
Summarize with AI
</>
)}
</DropdownMenuItem>
)}
{onUpdate && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setEditingNote(note.id)}
>
<Edit className="size-4" />
Edit
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() =>
handleAction(() => onDelete(note.id), note.id)
}
variant="destructive"
>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="wrap-break-word line-clamp-3 text-muted-foreground text-sm">
{note.content}
</p>
{note.aiSummary && (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="mb-2 flex items-center gap-2">
<Sparkles className="size-3 text-primary" />
<span className="font-medium text-xs">AI Summary</span>
</div>
<p className="text-muted-foreground text-xs">
{note.aiSummary}
</p>
</div>
)}
{showTags && note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{note.tags.map((tag) => (
<Badge className="text-xs" key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
alt={note.author.name}
src={note.author.avatar}
/>
<AvatarFallback className="text-xs">
{getInitials(note.author.name)}
</AvatarFallback>
</Avatar>
<span className="text-muted-foreground text-xs">
{note.author.name}
</span>
</div>
<span className="text-muted-foreground text-xs">
{formatRelativeTime(note.updatedAt)}
</span>
</div>
{note.participants && note.participants.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex -space-x-2">
{note.participants.slice(0, 3).map((participant) => (
<Avatar
className="size-6 border-2 border-background"
key={participant.id}
>
<AvatarImage
alt={participant.name}
src={participant.avatar}
/>
<AvatarFallback className="text-xs">
{getInitials(participant.name)}
</AvatarFallback>
</Avatar>
))}
</div>
{note.participants.length > 3 && (
<span className="text-muted-foreground text-xs">
+{note.participants.length - 3} more
</span>
)}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}