Todos os componentes

Team Notes

Team Notes é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

HextaUI MIT other

O que é

Team Notes é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Não precisa de pacotes além do que o projeto já tem

Licença

Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Team Notes

registry/new-york/blocks/team/team-notes.tsx
"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>
  );
}

Componentes parecidos