Nova versao codex Parecer, atualizacao completa
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain } from "lucide-react";
|
||||
import { MessageSquare, Image, AudioLines, Mic, ArrowLeft, Plus, ChevronLeft, ChevronRight, Bot, Brain, FileText, Layers } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
@@ -7,11 +7,11 @@ import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent") => void;
|
||||
activeTab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas";
|
||||
onTabChange: (tab: "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas") => void;
|
||||
}
|
||||
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent";
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "bots" | "agent" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
@@ -23,6 +23,9 @@ export const Layout = ({ children, activeTab, onTabChange }: LayoutProps) => {
|
||||
{ id: "generation" as TabType, label: "Geração de Áudio", icon: Mic },
|
||||
//{ id: "bots" as TabType, label: "Bots", icon: Bot },
|
||||
{ id: "agent" as TabType, label: "Agente de Parecer", icon: Brain },
|
||||
{ id: "prompts" as TabType, label: "Prompts", icon: FileText },
|
||||
{ id: "parecerJuridico" as TabType, label: "Parecer Jurídico", icon: Bot },
|
||||
{ id: "areas" as TabType, label: "Áreas", icon: Layers },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { Layers, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import { areasService, type AreaItem } from "@/services/areas";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function AreasView() {
|
||||
const { areas, setAreas, areaDescriptions, setAreaDescriptions, setPrompts } = usePrompts();
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [selectedAreaItem, setSelectedAreaItem] = useState<AreaItem | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newDescription, setNewDescription] = useState("");
|
||||
const [isCreatingArea, setIsCreatingArea] = useState(false);
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isDeletingArea, setIsDeletingArea] = useState(false);
|
||||
|
||||
const selectedArea = selectedAreaItem?.nome ?? null;
|
||||
|
||||
const [filterNome, setFilterNome] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const [areasList, setAreasList] = useState<AreaItem[]>([]);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
|
||||
const totalPages = Math.max(1, totalPaginas);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingList(true);
|
||||
areasService.listar({
|
||||
nome: filterNome.trim() || undefined,
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setAreasList(res.data ?? []);
|
||||
setTotalRegistros(res.total_registros ?? 0);
|
||||
setTotalPaginas(res.total_paginas ?? 1);
|
||||
setAreas((prev) => [...new Set([...prev, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
|
||||
setAreaDescriptions((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])),
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar áreas.";
|
||||
toast.error(msg);
|
||||
setAreasList([]);
|
||||
setTotalRegistros(0);
|
||||
setTotalPaginas(1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingList(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [filterNome, currentPage, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterNome]);
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newName.trim();
|
||||
const description = newDescription.trim();
|
||||
if (!name) return;
|
||||
if (areas.includes(name)) {
|
||||
toast.error("Já existe uma área com esse nome.");
|
||||
return;
|
||||
}
|
||||
setIsCreatingArea(true);
|
||||
try {
|
||||
await areasService.criar(name, description);
|
||||
setAreas((prev) => [...prev, name].sort((a, b) => a.localeCompare(b)));
|
||||
setAreaDescriptions((prev) => ({ ...prev, [name]: description }));
|
||||
setNewName("");
|
||||
setNewDescription("");
|
||||
setIsCreateOpen(false);
|
||||
toast.success("Área criada.");
|
||||
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
|
||||
.then((res) => {
|
||||
setAreasList(res.data ?? []);
|
||||
setTotalRegistros(res.total_registros ?? 0);
|
||||
setTotalPaginas(res.total_paginas ?? 1);
|
||||
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
|
||||
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao criar área.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsCreatingArea(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item: AreaItem) => {
|
||||
setSelectedAreaItem(item);
|
||||
setEditName(item.nome);
|
||||
setEditDescription(item.descricao ?? areaDescriptions[item.nome] ?? "");
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const refetchList = () => {
|
||||
areasService.listar({ nome: filterNome.trim() || undefined, page: currentPage, per_page: itemsPerPage })
|
||||
.then((res) => {
|
||||
setAreasList(res.data ?? []);
|
||||
setTotalRegistros(res.total_registros ?? 0);
|
||||
setTotalPaginas(res.total_paginas ?? 1);
|
||||
setAreas((p) => [...new Set([...p, ...(res.data ?? []).map((a) => a.nome)])].sort((a, b) => a.localeCompare(b)));
|
||||
setAreaDescriptions((p) => ({ ...p, ...Object.fromEntries((res.data ?? []).map((a) => [a.nome, a.descricao ?? ""])) }));
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!selectedAreaItem || !editName.trim()) {
|
||||
setIsEditOpen(false);
|
||||
return;
|
||||
}
|
||||
const name = editName.trim();
|
||||
const description = editDescription.trim();
|
||||
if (name !== selectedAreaItem.nome && areas.includes(name)) {
|
||||
toast.error("Já existe uma área com esse nome.");
|
||||
return;
|
||||
}
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
await areasService.editar(selectedAreaItem.id, name, description);
|
||||
const oldName = selectedAreaItem.nome;
|
||||
if (name !== oldName) {
|
||||
setAreas((prev) => prev.map((a) => (a === oldName ? name : a)).sort((a, b) => a.localeCompare(b)));
|
||||
setPrompts((prev) => prev.map((p) => (p.area === oldName ? { ...p, area: name } : p)));
|
||||
}
|
||||
setAreaDescriptions((prev) => {
|
||||
const next = { ...prev };
|
||||
if (oldName in next) delete next[oldName];
|
||||
next[name] = description;
|
||||
return next;
|
||||
});
|
||||
setSelectedAreaItem(null);
|
||||
setIsEditOpen(false);
|
||||
toast.success("Área atualizada.");
|
||||
refetchList();
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao editar área.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDelete = (item: AreaItem) => {
|
||||
setSelectedAreaItem(item);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAreaItem || totalRegistros <= 1) return;
|
||||
setIsDeletingArea(true);
|
||||
try {
|
||||
await areasService.deletar(selectedAreaItem.id);
|
||||
const fallback = areasList.find((a) => a.id !== selectedAreaItem!.id)?.nome ?? areas.find((a) => a !== selectedAreaItem!.nome);
|
||||
if (fallback) {
|
||||
setPrompts((prev) =>
|
||||
prev.map((p) => (p.area === selectedAreaItem.nome ? { ...p, area: fallback } : p))
|
||||
);
|
||||
}
|
||||
setAreas((prev) => prev.filter((a) => a !== selectedAreaItem.nome));
|
||||
setAreaDescriptions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedAreaItem.nome];
|
||||
return next;
|
||||
});
|
||||
setSelectedAreaItem(null);
|
||||
setIsDeleteOpen(false);
|
||||
toast.success("Área excluída.");
|
||||
refetchList();
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao excluir área.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsDeletingArea(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterNome("");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<div className="p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 md:w-6 md:h-6" />
|
||||
Áreas
|
||||
</h1>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Nova área</span>
|
||||
<span className="sm:hidden">Novo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{(loadingList || totalRegistros > 0) && (
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
||||
<Input
|
||||
placeholder="Buscar áreas..."
|
||||
value={filterNome}
|
||||
onChange={(e) => setFilterNome(e.target.value)}
|
||||
className="w-full md:max-w-sm text-sm"
|
||||
/>
|
||||
{filterNome.trim() && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 justify-between md:justify-start">
|
||||
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-16 md:w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loadingList && areasList.length === 0 && totalRegistros === 0 ? (
|
||||
<Card className="max-w-2xl mx-auto mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="w-5 h-5" />
|
||||
Nenhuma área cadastrada
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Crie a primeira área para usar nos prompts (ex.: Bate-papo, Imagens).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nova área
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Área</TableHead>
|
||||
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingList ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground py-8">
|
||||
Carregando...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
areasList.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-medium text-xs md:text-sm">{item.nome}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(item)}
|
||||
title="Editar"
|
||||
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-foreground"
|
||||
>
|
||||
<Edit className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openDelete(item)}
|
||||
title="Excluir"
|
||||
disabled={totalRegistros <= 1}
|
||||
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-100 dark:hover:bg-slate-700 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!loadingList && totalRegistros > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
|
||||
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
|
||||
Mostrando {totalRegistros} {totalRegistros === 1 ? "área" : "áreas"}
|
||||
</p>
|
||||
<div className="flex gap-1 md:gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
<span className="sm:hidden">Ant</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Próxima</span>
|
||||
<span className="sm:hidden">Prox</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Novo */}
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova área</DialogTitle>
|
||||
<DialogDescription>Informe o nome e, se quiser, a descrição da área. Ela poderá ser usada nos prompts.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="new-area">Nome</Label>
|
||||
<Input
|
||||
id="new-area"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Ex: Suporte"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-area-desc">Descrição</Label>
|
||||
<Textarea
|
||||
id="new-area-desc"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder="Ex: Área para prompts de atendimento"
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreatingArea}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!newName.trim() || areas.includes(newName.trim()) || isCreatingArea}>
|
||||
{isCreatingArea ? "Criando..." : "Criar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Editar */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar área</DialogTitle>
|
||||
<DialogDescription>Altere o nome e a descrição da área. Os prompts vinculados serão atualizados.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div>
|
||||
<Label htmlFor="edit-area">Nome</Label>
|
||||
<Input
|
||||
id="edit-area"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSaveEdit()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-area-desc">Descrição</Label>
|
||||
<Textarea
|
||||
id="edit-area-desc"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Ex: Área para prompts de atendimento"
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(false)} disabled={isSavingEdit}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSaveEdit} disabled={!editName.trim() || isSavingEdit}>
|
||||
{isSavingEdit ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal Excluir */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir área</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deseja excluir a área "{selectedArea}"? Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingArea} className="bg-white text-slate-900 hover:bg-slate-100 hover:text-slate-900">
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={totalRegistros <= 1 || isDeletingArea}
|
||||
className={(totalRegistros <= 1 || isDeletingArea) ? "opacity-50 cursor-not-allowed" : "bg-destructive text-destructive-foreground hover:bg-destructive/90"}
|
||||
>
|
||||
{isDeletingArea ? "Excluindo..." : "Excluir"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,705 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useParams, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Download,
|
||||
FileDown,
|
||||
GripHorizontal,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import { parecerService, type ParecerChatHistoricoItem, type ParecerDetalhe } from "@/services/parecerApi";
|
||||
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
|
||||
import { playSuccessSound } from "@/utils/sound";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function formatarDataDetalhe(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso.replace(" ", "T")).toLocaleString("pt-BR");
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
interface ParecerPreviewState {
|
||||
titulo?: string;
|
||||
conteudoMarkdown?: string;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at?: string;
|
||||
user_email?: string;
|
||||
}
|
||||
|
||||
export function ParecerJuridicoDetailView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const preview = (location.state as ParecerPreviewState | undefined) ?? undefined;
|
||||
|
||||
const [parecer, setParecer] = useState<ParecerDetalhe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [exportingDocx, setExportingDocx] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { areaItems } = usePrompts();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [chatLoading, setChatLoading] = useState(true);
|
||||
const [chatProcessing, setChatProcessing] = useState(false);
|
||||
const [conteudoAtualizadoChat, setConteudoAtualizadoChat] = useState<string | null>(null);
|
||||
const [historicoOpen, setHistoricoOpen] = useState(false);
|
||||
const [historicoLoading, setHistoricoLoading] = useState(false);
|
||||
const [historicoItem, setHistoricoItem] = useState<ParecerChatHistoricoItem | null>(null);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const chatInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [textareaHeight, setTextareaHeight] = useState(60);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartY = useRef(0);
|
||||
const dragStartHeight = useRef(0);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const MIN_INPUT_HEIGHT = 60;
|
||||
const MAX_INPUT_HEIGHT = 400;
|
||||
|
||||
const hasPreview = Boolean(preview?.conteudoMarkdown?.trim() || preview?.titulo);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id?.trim()) {
|
||||
setError("ID inválido");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
parecerService
|
||||
.buscarPorId(id)
|
||||
.then(setParecer)
|
||||
.catch((e) => {
|
||||
const msg = e?.message ?? "Erro ao carregar parecer";
|
||||
if (!hasPreview) {
|
||||
setError(msg);
|
||||
toast.error(msg);
|
||||
} else {
|
||||
toast.error(msg);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const carregarChat = useCallback(
|
||||
async (silent = false) => {
|
||||
if (!id?.trim()) return;
|
||||
if (!silent) setChatLoading(true);
|
||||
try {
|
||||
const rows = await parecerService.listarChat(id);
|
||||
const sorted = [...rows].sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at.replace(" ", "T")).getTime() -
|
||||
new Date(b.created_at.replace(" ", "T")).getTime()
|
||||
);
|
||||
setMessages(
|
||||
sorted.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
user_email: m.user_email,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
if (!silent) toast.error("Não foi possível carregar o histórico do chat.");
|
||||
if (!silent) setMessages([]);
|
||||
} finally {
|
||||
if (!silent) setChatLoading(false);
|
||||
}
|
||||
},
|
||||
[id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
carregarChat(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- recarregar só ao mudar parecer
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
setConteudoAtualizadoChat(null);
|
||||
}, [id]);
|
||||
|
||||
const handleResizeMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
dragStartHeight.current = textareaHeight;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = dragStartY.current - e.clientY;
|
||||
const newHeight = Math.min(MAX_INPUT_HEIGHT, Math.max(MIN_INPUT_HEIGHT, dragStartHeight.current + deltaY));
|
||||
setTextareaHeight(newHeight);
|
||||
};
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = chatInputRef.current;
|
||||
if (!el) return;
|
||||
const need = el.scrollHeight;
|
||||
if (need > textareaHeight) {
|
||||
setTextareaHeight((h) => Math.min(MAX_INPUT_HEIGHT, Math.max(h, need)));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatInput]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
const t = requestAnimationFrame(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
});
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, [messages]);
|
||||
|
||||
const titulo = parecer?.titulo ?? preview?.titulo ?? "Parecer";
|
||||
const conteudoMarkdown =
|
||||
parecer?.conteudo_gerado ?? conteudoAtualizadoChat ?? preview?.conteudoMarkdown ?? "";
|
||||
|
||||
const handleExport = async () => {
|
||||
const fileName = titulo.slice(0, 80).replace(/\s+/g, "-").replace(/[^\w-]/g, "") || "parecer";
|
||||
const text = conteudoMarkdown;
|
||||
setExportingDocx(true);
|
||||
try {
|
||||
const res = await fetch("https://codex-api.hgtx.com.br/api/Document/generate-docx", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text, fileName }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
throw new Error(errText || `Erro ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get("Content-Disposition");
|
||||
const match = disposition?.match(/filename\*?=(?:UTF-8'')?"?([^";\n]+)"?/i) || disposition?.match(/filename="?([^";\n]+)"?/i);
|
||||
const downloadName = match ? match[1].trim() : `${fileName}.docx`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = downloadName.endsWith(".docx") ? downloadName : `${downloadName}.docx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Documento DOCX gerado e baixado.");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Erro ao gerar DOCX.");
|
||||
} finally {
|
||||
setExportingDocx(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcluirParecer = () => {
|
||||
if (!id?.trim()) return;
|
||||
setDeleting(true);
|
||||
parecerService
|
||||
.excluir(id)
|
||||
.then((res) => {
|
||||
toast.success(res.message);
|
||||
setDeleteOpen(false);
|
||||
navigate("/codex/parecer-juridico");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
||||
})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
|
||||
const abrirHistoricoResposta = (messageId: string) => {
|
||||
setHistoricoOpen(true);
|
||||
setHistoricoLoading(true);
|
||||
setHistoricoItem(null);
|
||||
parecerService
|
||||
.buscarHistoricoChatMensagem(messageId)
|
||||
.then(setHistoricoItem)
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao carregar histórico.");
|
||||
setHistoricoOpen(false);
|
||||
})
|
||||
.finally(() => setHistoricoLoading(false));
|
||||
};
|
||||
|
||||
const copiarTexto = async (texto: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(texto);
|
||||
toast.success(`${label} copiado.`);
|
||||
} catch {
|
||||
toast.error("Não foi possível copiar.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = () => {
|
||||
const text = chatInput.trim();
|
||||
if (!text || !id?.trim() || chatProcessing) return;
|
||||
const tinhaParecer = parecer != null;
|
||||
setChatInput("");
|
||||
setChatProcessing(true);
|
||||
parecerService
|
||||
.enviarMensagemChat(id, text)
|
||||
.then(async (res) => {
|
||||
if (tinhaParecer) {
|
||||
setParecer((p) => (p ? { ...p, conteudo_gerado: res.conteudo_gerado } : p));
|
||||
} else {
|
||||
setConteudoAtualizadoChat(res.conteudo_gerado);
|
||||
}
|
||||
await carregarChat(true);
|
||||
playSuccessSound();
|
||||
toast.success("Parecer atualizado conforme sua solicitação.");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao enviar mensagem.");
|
||||
})
|
||||
.finally(() => setChatProcessing(false));
|
||||
};
|
||||
|
||||
if (!id?.trim()) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background p-6">
|
||||
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Card className="max-w-md mt-8 mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>ID inválido</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && !hasPreview && !parecer) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background items-center justify-center gap-4 p-6">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Carregando parecer...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !parecer && !hasPreview) {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background p-6">
|
||||
<Button variant="ghost" onClick={() => navigate("/codex/parecer-juridico")} className="w-fit gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<Card className="max-w-md mt-8 mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Não foi possível carregar o parecer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{chatProcessing && (
|
||||
<ParecerJuridicoGeneratingScreen message="Processando sua solicitação no chat. Isso pode levar vários minutos. Mantenha esta tela aberta até finalizar." />
|
||||
)}
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<header className="shrink-0 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 md:p-6 border-b border-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg md:text-xl font-bold truncate">Parecer: {titulo}</h1>
|
||||
{loading && hasPreview && (
|
||||
<Loader2 className="w-4 h-4 shrink-0 animate-spin text-muted-foreground" aria-label="Sincronizando" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={!parecer}
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
Detalhes
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 text-destructive border-destructive/50 hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={!parecer || !id}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Excluir parecer
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-2"
|
||||
disabled={exportingDocx || !conteudoMarkdown.trim()}
|
||||
>
|
||||
{exportingDocx ? <Loader2 className="w-4 h-4 animate-spin" /> : <FileDown className="w-4 h-4" />}
|
||||
{exportingDocx ? "Exportando..." : "Exportar parecer"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-4xl w-[calc(100vw-2rem)] max-h-[90vh] flex flex-col gap-0 p-0 sm:max-w-4xl">
|
||||
<DialogHeader className="px-6 pt-6 pb-2">
|
||||
<DialogTitle>Detalhes do parecer</DialogTitle>
|
||||
<DialogDescription className="sr-only">Resumo do parecer</DialogDescription>
|
||||
</DialogHeader>
|
||||
{parecer && (
|
||||
<ScrollArea className="max-h-[min(70vh,640px)] px-6 pb-6">
|
||||
<div className="space-y-5 text-sm pr-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-muted-foreground text-xs">Título</Label>
|
||||
<p className="mt-1 font-medium text-base">{parecer.titulo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Status</Label>
|
||||
<p className="mt-1 capitalize">{parecer.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Área</Label>
|
||||
<p className="mt-1">
|
||||
{areaItems.find((a) => a.id === parecer.area_id)?.nome ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-muted-foreground text-xs">Usuário</Label>
|
||||
<p className="mt-1 break-all">{parecer.user_email || parecer.criado_por || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Criado em</Label>
|
||||
<p className="mt-1">{formatarDataDetalhe(parecer.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Atualizado em</Label>
|
||||
<p className="mt-1">{formatarDataDetalhe(parecer.updated_at)}</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label className="text-muted-foreground text-xs">Anexo</Label>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<p className="text-foreground break-all">
|
||||
{parecer.anexo_nome ?? (parecer.anexo_url ? "Arquivo anexado" : "—")}
|
||||
</p>
|
||||
{parecer.anexo_url ? (
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit shrink-0 gap-2" asChild>
|
||||
<a href={parecer.anexo_url} target="_blank" rel="noreferrer" download>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Prompt utilizado</Label>
|
||||
<div className="mt-2 rounded-md border bg-muted/40 p-4 max-h-56 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{parecer.prompt_conteudo || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={(open) => !deleting && setDeleteOpen(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir parecer</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<span className="block">
|
||||
Tem certeza que deseja excluir o parecer <strong className="text-foreground">"{titulo}"</strong>?
|
||||
</span>
|
||||
<span className="block font-medium text-destructive">
|
||||
Esta ação é irreversível. O parecer será removido permanentemente.
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>Cancelar</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={handleExcluirParecer}
|
||||
className="gap-2"
|
||||
>
|
||||
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{deleting ? "Excluindo..." : "Excluir definitivamente"}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog
|
||||
open={historicoOpen}
|
||||
onOpenChange={(open) => {
|
||||
setHistoricoOpen(open);
|
||||
if (!open) {
|
||||
setHistoricoItem(null);
|
||||
setHistoricoLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[min(96vw,1400px)] w-full max-h-[92vh] h-[85dvh] sm:h-[82vh] flex flex-col gap-0 p-0 overflow-hidden sm:max-w-[min(96vw,1400px)]">
|
||||
<DialogHeader className="px-6 pt-6 pb-3 shrink-0 border-b">
|
||||
<DialogTitle>Histórico da alteração</DialogTitle>
|
||||
<DialogDescription>
|
||||
{historicoItem?.created_at
|
||||
? `Registro em ${formatarDataDetalhe(historicoItem.created_at)}`
|
||||
: "Compare o parecer antes e depois desta resposta do assistente."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{historicoLoading && (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
<span>Carregando histórico...</span>
|
||||
</div>
|
||||
)}
|
||||
{!historicoLoading && historicoItem && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 flex-1 min-h-0 gap-0 lg:divide-x border-t overflow-hidden lg:grid-rows-1">
|
||||
<div className="flex flex-col min-h-0 min-w-0 p-4 lg:h-full lg:min-h-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-2 shrink-0">
|
||||
<h3 className="text-sm font-semibold">Parecer anterior</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 shrink-0"
|
||||
onClick={() => copiarTexto(historicoItem.parecer_anterior ?? "", "Parecer anterior")}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-md border bg-muted/20 max-h-[38vh] lg:max-h-none touch-pan-y">
|
||||
<div className="p-4 prose prose-sm prose-neutral dark:prose-invert max-w-none text-sm pb-8">
|
||||
<ReactMarkdown>{historicoItem.parecer_anterior || "—"}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col min-h-0 min-w-0 p-4 lg:h-full lg:min-h-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-2 shrink-0">
|
||||
<h3 className="text-sm font-semibold text-emerald-700 dark:text-emerald-400">Parecer atual</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 shrink-0"
|
||||
onClick={() => copiarTexto(historicoItem.parecer_atual ?? "", "Parecer atual")}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-md border border-emerald-500/30 bg-emerald-500/5 max-h-[38vh] lg:max-h-none touch-pan-y">
|
||||
<div className="p-4 prose prose-sm prose-neutral dark:prose-invert max-w-none text-sm pb-8">
|
||||
<ReactMarkdown>{historicoItem.parecer_atual || "—"}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex-1 flex flex-col md:flex-row min-h-0">
|
||||
<section className="flex-1 min-w-0 flex flex-col border-b md:border-b-0 md:border-r border-border bg-white">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 md:p-6 bg-white min-h-full">
|
||||
<article className="prose prose-sm prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5">
|
||||
{conteudoMarkdown.trim() ? (
|
||||
<ReactMarkdown>{conteudoMarkdown}</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">Ainda não há conteúdo do parecer.</p>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</section>
|
||||
|
||||
<aside className="w-full md:w-[min(100%,360px)] shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-border bg-muted/30 max-h-[45vh] md:max-h-none">
|
||||
<div className="p-3 border-b border-border flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">Conversar sobre o parecer</h2>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-3 min-h-0">
|
||||
<div className="space-y-4">
|
||||
{chatLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Carregando histórico...
|
||||
</div>
|
||||
)}
|
||||
{!chatLoading &&
|
||||
messages.map((msg) => {
|
||||
const remetente =
|
||||
msg.role === "assistant"
|
||||
? "Assistente (IA)"
|
||||
: msg.user_email?.trim() || "Usuário não informado";
|
||||
return (
|
||||
<div key={msg.id} className={`flex flex-col gap-1 ${msg.role === "user" ? "items-end" : "items-start"}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm whitespace-pre-wrap break-words ${
|
||||
msg.role === "user"
|
||||
? "bg-primary text-primary-foreground rounded-br-md"
|
||||
: "bg-card border border-border text-card-foreground rounded-bl-md"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-1 ${msg.role === "user" ? "flex-row-reverse justify-end" : "flex-row"}`}
|
||||
>
|
||||
{msg.created_at && (
|
||||
<span className="text-[10px] text-muted-foreground px-0.5">
|
||||
{formatarDataDetalhe(msg.created_at)}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-full p-0.5 text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Quem enviou esta mensagem"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p className="text-xs">
|
||||
<span className="font-medium text-foreground">Enviado por:</span>
|
||||
<br />
|
||||
{remetente}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{msg.role === "assistant" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => abrirHistoricoResposta(msg.id)}
|
||||
className="inline-flex items-center gap-0.5 rounded-md border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
|
||||
aria-label="Ver histórico antes e depois desta resposta"
|
||||
>
|
||||
<History className="h-3 w-3 shrink-0" />
|
||||
Histórico
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Parecer anterior e atual desta alteração</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
); })}
|
||||
<div ref={chatEndRef} aria-hidden className="h-0 w-full shrink-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="p-3 border-t border-border space-y-2">
|
||||
<div className="flex gap-1 md:gap-2 items-end">
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||
<div
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${isDragging ? "bg-primary/20" : ""}`}
|
||||
title="Arraste para cima para aumentar a altura"
|
||||
>
|
||||
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
||||
<Textarea
|
||||
ref={chatInputRef}
|
||||
placeholder="Solicitar alteração ou pergunta..."
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
style={{ height: `${textareaHeight}px` }}
|
||||
className="resize-none border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 w-full p-0 overflow-y-auto text-sm min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!chatInput.trim() || chatProcessing}
|
||||
className="shrink-0 h-10 w-10 md:h-11 md:w-11"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Sparkles, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
||||
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
|
||||
import { areasService, type AreaItem } from "@/services/areas";
|
||||
import { promptsService, type PromptItem } from "@/services/promptsApi";
|
||||
import { parecerService } from "@/services/parecerApi";
|
||||
import { playSuccessSound } from "@/utils/sound";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ParecerJuridicoFormView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
|
||||
const [areasLoading, setAreasLoading] = useState(true);
|
||||
const [areasError, setAreasError] = useState(false);
|
||||
|
||||
const [tituloParecer, setTituloParecer] = useState("");
|
||||
const [areaId, setAreaId] = useState("");
|
||||
const [promptId, setPromptId] = useState("");
|
||||
const [conteudoPrompt, setConteudoPrompt] = useState("");
|
||||
const [instrucao, setInstrucao] = useState("");
|
||||
const [anexo, setAnexo] = useState<File | null>(null);
|
||||
const anexoInputRef = useRef<HTMLInputElement>(null);
|
||||
const [promptsDaArea, setPromptsDaArea] = useState<PromptItem[]>([]);
|
||||
const [loadingPrompts, setLoadingPrompts] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAreasLoading(true);
|
||||
setAreasError(false);
|
||||
areasService.listarTotal()
|
||||
.then((data) => setAreaItems(data))
|
||||
.catch(() => setAreasError(true))
|
||||
.finally(() => setAreasLoading(false));
|
||||
}, []);
|
||||
|
||||
const ANEXO_ACCEPT = ".pdf,.txt";
|
||||
|
||||
const handleAnexoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
setAnexo(null);
|
||||
return;
|
||||
}
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowed = ["pdf", "txt"];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
toast.error("Formato não permitido. Use PDF ou TXT.");
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
setAnexo(file);
|
||||
};
|
||||
|
||||
const removerAnexo = () => {
|
||||
setAnexo(null);
|
||||
if (anexoInputRef.current) anexoInputRef.current.value = "";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!areaId.trim()) {
|
||||
setPromptsDaArea([]);
|
||||
setPromptId("");
|
||||
setConteudoPrompt("");
|
||||
return;
|
||||
}
|
||||
setPromptId("");
|
||||
setConteudoPrompt("");
|
||||
setLoadingPrompts(true);
|
||||
promptsService.listarPorArea(areaId)
|
||||
.then((res) => setPromptsDaArea(res.data ?? []))
|
||||
.catch(() => {
|
||||
toast.error("Erro ao carregar prompts da área.");
|
||||
setPromptsDaArea([]);
|
||||
})
|
||||
.finally(() => setLoadingPrompts(false));
|
||||
}, [areaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!promptId.trim() || promptsDaArea.length === 0) return;
|
||||
const prompt = promptsDaArea.find((p) => p.id === promptId);
|
||||
if (prompt) setConteudoPrompt(prompt.conteudo ?? "");
|
||||
}, [promptId, promptsDaArea]);
|
||||
|
||||
const canSubmit =
|
||||
!isGenerating &&
|
||||
tituloParecer.trim().length > 0 &&
|
||||
areaId.trim().length > 0 &&
|
||||
conteudoPrompt.trim().length > 0 &&
|
||||
instrucao.trim().length > 0;
|
||||
|
||||
const handleGerarParecer = () => {
|
||||
const titulo = tituloParecer.trim();
|
||||
const area = areaId.trim();
|
||||
const prompt = conteudoPrompt.trim();
|
||||
const instr = instrucao.trim();
|
||||
|
||||
if (!titulo || !area || !prompt || !instr) {
|
||||
toast.error("Preencha os campos obrigatórios antes de gerar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
parecerService.gerar({
|
||||
titulo,
|
||||
area_id: area,
|
||||
prompt_id: promptId.trim() || undefined,
|
||||
prompt,
|
||||
instrucao: instr,
|
||||
anexo: anexo ?? undefined,
|
||||
})
|
||||
.then((data) => {
|
||||
setIsGenerating(false);
|
||||
playSuccessSound();
|
||||
navigate(`/codex/parecer-juridico/${data.id}`, {
|
||||
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsGenerating(false);
|
||||
toast.error(err?.message ?? "Erro ao gerar parecer.");
|
||||
});
|
||||
};
|
||||
|
||||
const descricaoPromptSelecionado = promptId ? promptsDaArea.find((p) => p.id === promptId)?.descricao?.trim() : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGenerating && (
|
||||
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
|
||||
)}
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<div className="p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 md:w-6 md:h-6" />
|
||||
Novo Parecer
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo-parecer">Título do parecer</Label>
|
||||
<Input
|
||||
id="titulo-parecer"
|
||||
value={tituloParecer}
|
||||
onChange={(e) => setTituloParecer(e.target.value)}
|
||||
placeholder="Ex.: Parecer sobre contrato de prestação de serviços"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="area">Área</Label>
|
||||
<Select value={areaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
|
||||
<SelectTrigger id="area" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
areasLoading ? "Carregando áreas..." : areasError ? "Erro ao carregar áreas" : "Selecione a área"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Selecione a área</SelectItem>
|
||||
{areaItems.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{areasError && (
|
||||
<p className="text-sm text-destructive">Não foi possível carregar as áreas. Verifique sua conexão e tente novamente.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prompt">Prompt</Label>
|
||||
<Select
|
||||
value={promptId || "none"}
|
||||
onValueChange={(v) => setPromptId(v === "none" ? "" : v)}
|
||||
disabled={!areaId.trim() || loadingPrompts}
|
||||
>
|
||||
<SelectTrigger id="prompt" className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingPrompts ? "Carregando..." : promptsDaArea.length === 0 && areaId.trim() ? "Nenhum prompt" : "Selecione o prompt"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Selecione o prompt</SelectItem>
|
||||
{promptsDaArea.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.titulo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{areaId.trim() && !loadingPrompts && promptsDaArea.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Não há prompts para essa área.</p>
|
||||
)}
|
||||
{descricaoPromptSelecionado && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{descricaoPromptSelecionado}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conteudo-prompt">Conteúdo do prompt (editável)</Label>
|
||||
<Textarea
|
||||
id="conteudo-prompt"
|
||||
value={conteudoPrompt}
|
||||
onChange={(e) => setConteudoPrompt(e.target.value)}
|
||||
placeholder="Selecione um prompt acima ou edite o conteúdo aqui. Este valor será enviado na geração do parecer."
|
||||
className="min-h-[200px] resize-y w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instrucao">Instrução</Label>
|
||||
<Textarea
|
||||
id="instrucao"
|
||||
value={instrucao}
|
||||
onChange={(e) => setInstrucao(e.target.value)}
|
||||
placeholder="Instruções adicionais para a geração do parecer..."
|
||||
className="min-h-[100px] resize-y w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="anexo">Anexo (máx. 1 arquivo)</Label>
|
||||
<p className="text-xs text-muted-foreground">Formatos permitidos: PDF, TXT.</p>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex items-center gap-2 w-full min-h-10 rounded-md border border-input bg-background px-3 py-2">
|
||||
<input
|
||||
ref={anexoInputRef}
|
||||
id="anexo"
|
||||
type="file"
|
||||
accept={ANEXO_ACCEPT}
|
||||
onChange={handleAnexoChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="anexo"
|
||||
className="cursor-pointer shrink-0 rounded-md border-0 bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Escolher arquivo
|
||||
</Label>
|
||||
<span className="text-sm text-muted-foreground truncate flex-1 min-w-0">
|
||||
{anexo ? anexo.name : "Nenhum arquivo escolhido"}
|
||||
</span>
|
||||
{anexo && (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={removerAnexo} className="h-8 w-8 shrink-0" title="Remover anexo">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<InteractiveHoverButton
|
||||
type="button"
|
||||
onClick={handleGerarParecer}
|
||||
disabled={!canSubmit}
|
||||
className="disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
Gerar Parecer
|
||||
</InteractiveHoverButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Globe,
|
||||
BookOpen,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
import { AnimatedBeam } from "@/components/ui/animated-beam";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const GERAR_PARECER_STEPS = [
|
||||
"Pesquisando fontes jurídicas confiáveis...",
|
||||
"Analisando documentação e anexos...",
|
||||
"Consultando jurisprudência e decisões correlatas...",
|
||||
"Verificando legislação vigente e súmulas...",
|
||||
"Identificando fundamentação legal aplicável...",
|
||||
"Estruturando argumentação...",
|
||||
"Elaborando conclusões...",
|
||||
"Revisando citações e referências...",
|
||||
"Consolidando fundamentos e redigindo parecer...",
|
||||
"Estruturando parecer...",
|
||||
] as const;
|
||||
|
||||
const STEP_COUNT = GERAR_PARECER_STEPS.length;
|
||||
const STEP_INTERVAL_MS = 1500;
|
||||
|
||||
export interface ParecerJuridicoGeneratingScreenProps {
|
||||
progress?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function visibleLineCountFromProgress(progress: number): number {
|
||||
if (progress <= 0) return 1;
|
||||
const phase = (progress / 100) * STEP_COUNT;
|
||||
const stepInCycle = Math.floor(phase) % STEP_COUNT;
|
||||
return Math.min(stepInCycle + 1, STEP_COUNT);
|
||||
}
|
||||
|
||||
const BeamCircle = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ className?: string; children?: React.ReactNode }
|
||||
>(({ className, children }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
BeamCircle.displayName = "BeamCircle";
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("size-6", className)}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ParecerJuridicoGeneratingScreen({
|
||||
progress,
|
||||
message = "Assim que finalizar, o modal será fechado automaticamente.",
|
||||
}: ParecerJuridicoGeneratingScreenProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sparklesRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLDivElement>(null);
|
||||
const globeRef = useRef<HTMLDivElement>(null);
|
||||
const centerRef = useRef<HTMLDivElement>(null);
|
||||
const googleRef = useRef<HTMLDivElement>(null);
|
||||
const bookRef = useRef<HTMLDivElement>(null);
|
||||
const dbRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loopStep, setLoopStep] = useState(0);
|
||||
useEffect(() => {
|
||||
if (progress !== undefined) return;
|
||||
const id = setInterval(() => {
|
||||
setLoopStep((s) => (s + 1) % STEP_COUNT);
|
||||
}, STEP_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [progress]);
|
||||
|
||||
const visibleCount =
|
||||
progress !== undefined
|
||||
? visibleLineCountFromProgress(progress)
|
||||
: loopStep + 1;
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideClose
|
||||
overlayClassName="bg-black/50"
|
||||
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
|
||||
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
|
||||
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
codex parecer — gerando
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
|
||||
<AnimatedStep delay={0} className="text-muted-foreground">
|
||||
> codex parecer --generate
|
||||
</AnimatedStep>
|
||||
{GERAR_PARECER_STEPS.slice(0, visibleCount).map((text, i) => (
|
||||
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
|
||||
{i < visibleCount - 1 ? (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
✔ {text}
|
||||
</span>
|
||||
) : (
|
||||
<TypingAnimation
|
||||
key={`typing-${visibleCount}`}
|
||||
startOnView={false}
|
||||
showCursor
|
||||
cursorStyle="block"
|
||||
className="text-green-600 dark:text-green-400"
|
||||
>
|
||||
{`✔ ${text}`}
|
||||
</TypingAnimation>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Terminal>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{message ?? "A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar."}
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={sparklesRef}>
|
||||
<Sparkles className="size-6 text-amber-400" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={fileRef}>
|
||||
<FileText className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={globeRef}>
|
||||
<Globe className="size-6 text-sky-400" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
|
||||
<Bot className="size-8 text-slate-700" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={googleRef}>
|
||||
<GoogleIcon className="size-6" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={bookRef}>
|
||||
<BookOpen className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={dbRef}>
|
||||
<Database className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={sparklesRef}
|
||||
toRef={centerRef}
|
||||
curvature={-75}
|
||||
endYOffset={-10}
|
||||
gradientStartColor="#f59e0b"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={fileRef}
|
||||
toRef={centerRef}
|
||||
curvature={-75}
|
||||
endYOffset={-10}
|
||||
reverse
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={globeRef}
|
||||
toRef={centerRef}
|
||||
gradientStartColor="#0ea5e9"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={googleRef}
|
||||
toRef={centerRef}
|
||||
reverse
|
||||
gradientStartColor="#64748b"
|
||||
gradientStopColor="#94a3b8"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={bookRef}
|
||||
toRef={centerRef}
|
||||
curvature={75}
|
||||
endYOffset={10}
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={dbRef}
|
||||
toRef={centerRef}
|
||||
curvature={75}
|
||||
endYOffset={10}
|
||||
reverse
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Sparkles, Edit, Plus, Bot } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import { parecerService, type ParecerListItem, type ParecerStatus } from "@/services/parecerApi";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_OPTIONS: { value: "" | ParecerStatus; label: string }[] = [
|
||||
{ value: "", label: "Todos os status" },
|
||||
{ value: "processando", label: "Processando" },
|
||||
{ value: "concluido", label: "Concluído" },
|
||||
{ value: "erro", label: "Erro" },
|
||||
];
|
||||
|
||||
function formatarData(createdAt: string): string {
|
||||
if (!createdAt) return "—";
|
||||
try {
|
||||
const d = new Date(createdAt.replace(" ", "T"));
|
||||
return d.toLocaleDateString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
function statusEstilo(status: string): { dot: string; text: string; label: string } {
|
||||
const s = (status ?? "").toLowerCase();
|
||||
if (s === "concluido") return { dot: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400", label: "Concluído" };
|
||||
if (s === "processando") return { dot: "bg-amber-500", text: "text-amber-600 dark:text-amber-400", label: "Processando" };
|
||||
if (s === "erro") return { dot: "bg-red-500", text: "text-red-600 dark:text-red-400", label: "Erro" };
|
||||
return { dot: "bg-muted-foreground/50", text: "text-muted-foreground", label: status || "—" };
|
||||
}
|
||||
|
||||
export const ParecerJuridicoView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { areaItems } = usePrompts();
|
||||
|
||||
const [data, setData] = useState<ParecerListItem[]>([]);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||
const [paginaAtual, setPaginaAtual] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [filterTitulo, setFilterTitulo] = useState("");
|
||||
const [filterAreaId, setFilterAreaId] = useState<string>("");
|
||||
const [filterStatus, setFilterStatus] = useState<"" | ParecerStatus>("");
|
||||
|
||||
const hasActiveFilters = filterTitulo.trim() !== "" || filterAreaId !== "" || filterStatus !== "";
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterTitulo("");
|
||||
setFilterAreaId("");
|
||||
setFilterStatus("");
|
||||
setPaginaAtual(1);
|
||||
};
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
parecerService
|
||||
.listar({
|
||||
titulo: filterTitulo.trim() || undefined,
|
||||
area_id: filterAreaId.trim() || undefined,
|
||||
status: filterStatus || undefined,
|
||||
page: paginaAtual,
|
||||
per_page: perPage,
|
||||
})
|
||||
.then((res) => {
|
||||
setData(res.data ?? []);
|
||||
setTotalRegistros(res.total_registros ?? 0);
|
||||
setTotalPaginas(Math.max(1, res.total_paginas ?? 1));
|
||||
setPaginaAtual(res.pagina_atual ?? 1);
|
||||
setPerPage(res.per_page ?? 10);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err?.message ?? "Erro ao carregar pareceres.");
|
||||
setData([]);
|
||||
setTotalRegistros(0);
|
||||
setTotalPaginas(1);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [paginaAtual, perPage, filterTitulo, filterAreaId, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setPerPage(Number(value));
|
||||
setPaginaAtual(1);
|
||||
};
|
||||
|
||||
const handleEditar = (item: ParecerListItem) => {
|
||||
navigate(`/codex/parecer-juridico/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<div className="p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<Bot className="w-5 h-5 md:w-6 md:h-6" />
|
||||
Parecer Jurídico
|
||||
</h1>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button onClick={() => navigate("/codex/parecer-juridico/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Novo Parecer</span>
|
||||
<span className="sm:hidden">Novo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4 flex-wrap">
|
||||
<Input
|
||||
placeholder="Buscar por título..."
|
||||
value={filterTitulo}
|
||||
onChange={(e) => { setFilterTitulo(e.target.value); setPaginaAtual(1); }}
|
||||
className="w-full md:max-w-sm text-sm"
|
||||
/>
|
||||
<Select value={filterAreaId || "all"} onValueChange={(v) => { setFilterAreaId(v === "all" ? "" : v); setPaginaAtual(1); }}>
|
||||
<SelectTrigger className="w-full md:w-[180px] text-sm">
|
||||
<SelectValue placeholder="Todas as áreas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as áreas</SelectItem>
|
||||
{areaItems.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filterStatus || "all"}
|
||||
onValueChange={(v) => { setFilterStatus((v === "all" ? "" : v) as "" | ParecerStatus); setPaginaAtual(1); }}
|
||||
>
|
||||
<SelectTrigger className="w-full md:w-[160px] text-sm">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value || "all"} value={opt.value || "all"}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 justify-between md:justify-start">
|
||||
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens por página:</span>
|
||||
<Select value={perPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-16 md:w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{loading ? (
|
||||
<Card className="max-w-2xl mx-auto mt-12">
|
||||
<CardContent className="py-10 text-center text-muted-foreground">
|
||||
Carregando pareceres...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : data.length === 0 && !hasActiveFilters ? (
|
||||
<Card className="max-w-2xl mx-auto mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Nenhum parecer encontrado
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Os pareceres jurídicos gerados aparecerão aqui quando disponíveis.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[100px] font-semibold text-xs md:text-sm">Data</TableHead>
|
||||
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
|
||||
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
|
||||
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Status</TableHead>
|
||||
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Criado por</TableHead>
|
||||
<TableHead className="text-center font-semibold text-xs md:text-sm w-[80px]">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-10 text-base">
|
||||
Nenhum resultado para os filtros aplicados.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((item) => {
|
||||
const status = statusEstilo(item.status);
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-xs md:text-sm">{formatarData(item.created_at)}</TableCell>
|
||||
<TableCell className="font-medium text-xs md:text-sm">{item.titulo}</TableCell>
|
||||
<TableCell className="text-xs md:text-sm">{item.area_nome}</TableCell>
|
||||
<TableCell className="text-xs md:text-sm">
|
||||
<span className={`inline-flex items-center gap-2 ${status.text}`}>
|
||||
<span className={`size-2 rounded-full shrink-0 ${status.dot}`} aria-hidden />
|
||||
{status.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs md:text-sm">{item.criado_por ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditar(item)}
|
||||
title="Editar"
|
||||
className="h-7 w-7 md:h-9 md:w-9"
|
||||
>
|
||||
<Edit className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
); })
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalRegistros > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
|
||||
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
|
||||
Mostrando {data.length} de {totalRegistros} {totalRegistros === 1 ? "parecer" : "pareceres"}
|
||||
</p>
|
||||
<div className="flex gap-1 md:gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPaginaAtual((p) => Math.max(1, p - 1))}
|
||||
disabled={paginaAtual <= 1}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
<span className="sm:hidden">Ant</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPaginas, 5) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPaginas <= 5) page = i + 1;
|
||||
else if (paginaAtual <= 3) page = i + 1;
|
||||
else if (paginaAtual >= totalPaginas - 2) page = totalPaginas - 4 + i;
|
||||
else page = paginaAtual - 2 + i;
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={paginaAtual === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPaginaAtual(page)}
|
||||
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPaginaAtual((p) => Math.min(totalPaginas, p + 1))}
|
||||
disabled={paginaAtual >= totalPaginas}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Próxima</span>
|
||||
<span className="sm:hidden">Prox</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,248 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Sparkles,
|
||||
FileText,
|
||||
Wand2,
|
||||
SlidersHorizontal,
|
||||
ListChecks,
|
||||
MessageSquareText,
|
||||
} from "lucide-react";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Terminal, AnimatedStep } from "@/components/ui/terminal";
|
||||
import { TypingAnimation } from "@/components/ui/typing-animation";
|
||||
import { AnimatedBeam } from "@/components/ui/animated-beam";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PROMPT_CREATE_STEPS = [
|
||||
"Interpretando instruções e contexto...",
|
||||
"Definindo objetivo, público e tom...",
|
||||
"Organizando estrutura do prompt (seções e regras)...",
|
||||
"Especificando formato de saída e restrições...",
|
||||
"Adicionando critérios de qualidade e validações...",
|
||||
"Incluindo exemplos e casos de borda...",
|
||||
"Revisando clareza e consistência...",
|
||||
"Finalizando prompt...",
|
||||
] as const;
|
||||
|
||||
const PROMPT_REFINE_STEPS = [
|
||||
"Lendo o prompt atual...",
|
||||
"Identificando ambiguidades e pontos fracos...",
|
||||
"Removendo redundâncias e ruído...",
|
||||
"Aprimorando instruções e critérios de sucesso...",
|
||||
"Ajustando tom, voz e consistência...",
|
||||
"Fortalecendo formato de saída e validações...",
|
||||
"Adicionando exemplos e casos de borda...",
|
||||
"Consolidando melhorias e finalizando...",
|
||||
] as const;
|
||||
|
||||
const STEP_INTERVAL_MS = 1500;
|
||||
|
||||
export type PromptGeneratingMode = "create" | "refine";
|
||||
|
||||
export interface PromptGeneratingScreenProps {
|
||||
mode: PromptGeneratingMode;
|
||||
progress?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function visibleLineCount(progress: number, stepCount: number): number {
|
||||
if (progress <= 0) return 1;
|
||||
const phase = (progress / 100) * stepCount;
|
||||
const stepInCycle = Math.floor(phase) % stepCount;
|
||||
return Math.min(stepInCycle + 1, stepCount);
|
||||
}
|
||||
|
||||
const BeamCircle = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ className?: string; children?: React.ReactNode }
|
||||
>(({ className, children }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-10 flex size-12 items-center justify-center rounded-full border-2 border-slate-300 bg-slate-100 text-slate-700 shadow-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
BeamCircle.displayName = "BeamCircle";
|
||||
|
||||
export function PromptGeneratingScreen({
|
||||
mode,
|
||||
progress,
|
||||
message = "Assim que finalizar, o modal será fechado automaticamente.",
|
||||
}: PromptGeneratingScreenProps) {
|
||||
const steps = mode === "refine" ? PROMPT_REFINE_STEPS : PROMPT_CREATE_STEPS;
|
||||
const stepCount = steps.length;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sparklesRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLDivElement>(null);
|
||||
const slidersRef = useRef<HTMLDivElement>(null);
|
||||
const centerRef = useRef<HTMLDivElement>(null);
|
||||
const wandRef = useRef<HTMLDivElement>(null);
|
||||
const checklistRef = useRef<HTMLDivElement>(null);
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loopStep, setLoopStep] = useState(0);
|
||||
useEffect(() => {
|
||||
if (progress !== undefined) return;
|
||||
const id = setInterval(() => {
|
||||
setLoopStep((s) => (s + 1) % stepCount);
|
||||
}, STEP_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [progress, stepCount]);
|
||||
|
||||
const visibleCount =
|
||||
progress !== undefined
|
||||
? visibleLineCount(progress, stepCount)
|
||||
: loopStep + 1;
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideClose
|
||||
overlayClassName="bg-black/50"
|
||||
className="max-w-2xl gap-0 overflow-hidden bg-white p-0 sm:rounded-xl"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-5">
|
||||
<div className="flex items-center gap-3 rounded-t-lg border border-b-0 border-border bg-zinc-100 px-3 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="size-3 rounded-full bg-[#ff5f57]" aria-hidden />
|
||||
<span className="size-3 rounded-full bg-[#febc2e]" aria-hidden />
|
||||
<span className="size-3 rounded-full bg-[#28c840]" aria-hidden />
|
||||
</div>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
codex prompt — {mode === "refine" ? "melhorando" : "gerando"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Terminal className="min-h-[200px] space-y-2 rounded-t-none border-t-0 bg-white p-4 font-mono text-sm">
|
||||
<AnimatedStep delay={0} className="text-muted-foreground">
|
||||
{`> codex prompt --${mode === "refine" ? "refine" : "create"}`}
|
||||
</AnimatedStep>
|
||||
{steps.slice(0, visibleCount).map((text, i) => (
|
||||
<div key={`${visibleCount}-${i}-${text}`} className="flex items-center gap-0">
|
||||
{i < visibleCount - 1 ? (
|
||||
<span className="text-sky-600 dark:text-sky-400">
|
||||
✔ {text}
|
||||
</span>
|
||||
) : (
|
||||
<TypingAnimation
|
||||
key={`typing-${visibleCount}`}
|
||||
startOnView={false}
|
||||
showCursor
|
||||
cursorStyle="block"
|
||||
className="text-sky-600 dark:text-sky-400"
|
||||
>
|
||||
{`✔ ${text}`}
|
||||
</TypingAnimation>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Terminal>
|
||||
|
||||
{message && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-6">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-[260px] w-full items-center justify-center overflow-hidden"
|
||||
>
|
||||
<div className="flex size-full max-h-[220px] max-w-lg flex-col items-stretch justify-between">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={sparklesRef}>
|
||||
<Sparkles className="size-6 text-sky-500" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={fileRef}>
|
||||
<FileText className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={slidersRef}>
|
||||
<SlidersHorizontal className="size-6 text-indigo-500" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={centerRef} className="size-14 border-slate-400 bg-slate-200">
|
||||
<Bot className="size-8 text-slate-700" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={wandRef}>
|
||||
<Wand2 className="size-6 text-amber-500" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<BeamCircle ref={checklistRef}>
|
||||
<ListChecks className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
<BeamCircle ref={chatRef}>
|
||||
<MessageSquareText className="size-6 text-slate-600" />
|
||||
</BeamCircle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={sparklesRef}
|
||||
toRef={centerRef}
|
||||
curvature={-75}
|
||||
endYOffset={-10}
|
||||
gradientStartColor="#0ea5e9"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={fileRef}
|
||||
toRef={centerRef}
|
||||
curvature={-75}
|
||||
endYOffset={-10}
|
||||
reverse
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={slidersRef}
|
||||
toRef={centerRef}
|
||||
gradientStartColor="#6366f1"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={wandRef}
|
||||
toRef={centerRef}
|
||||
reverse
|
||||
gradientStartColor="#f59e0b"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={checklistRef}
|
||||
toRef={centerRef}
|
||||
curvature={75}
|
||||
endYOffset={10}
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={chatRef}
|
||||
toRef={centerRef}
|
||||
curvature={75}
|
||||
endYOffset={10}
|
||||
reverse
|
||||
gradientStartColor="#94a3b8"
|
||||
gradientStopColor="#64748b"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import React, { useState, useEffect, useRef, forwardRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, ArrowRight, Sparkles, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ShimmerButton } from "@/components/ui/shimmer-button";
|
||||
import { SparklesText } from "@/components/ui/sparkles-text";
|
||||
import { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
||||
import { BorderBeam } from "@/components/ui/border-beam";
|
||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
||||
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import { areasService, type AreaItem } from "@/services/areas";
|
||||
import { PromptGeneratingScreen } from "@/components/prompts/PromptGeneratingScreen";
|
||||
import { assistentePromptsService } from "@/services/assistentePrompts";
|
||||
import { promptsService } from "@/services/promptsApi";
|
||||
import { toast } from "sonner";
|
||||
import { playSuccessSound } from "@/utils/sound";
|
||||
|
||||
export function PromptsFormView() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { prompts, setPrompts } = usePrompts();
|
||||
const isEdit = Boolean(id);
|
||||
|
||||
const prompt = id ? prompts.find((p) => p.id === id) : null;
|
||||
|
||||
const [areaItems, setAreaItems] = useState<AreaItem[]>([]);
|
||||
const [areasLoading, setAreasLoading] = useState(true);
|
||||
|
||||
const [titulo, setTitulo] = useState("");
|
||||
const [descricao, setDescricao] = useState("");
|
||||
const [areaId, setAreaId] = useState("");
|
||||
const [conteudo, setConteudo] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
|
||||
const [assistantMode, setAssistantMode] = useState<"create" | "refine">("create");
|
||||
const [assistantInstructions, setAssistantInstructions] = useState("");
|
||||
const [isAssistantLoading, setIsAssistantLoading] = useState(false);
|
||||
const [isAssistantWorkingInBackground, setIsAssistantWorkingInBackground] = useState(false);
|
||||
const [showGeneratingModal, setShowGeneratingModal] = useState(false);
|
||||
|
||||
const confettiRef = useRef<ConfettiRef>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const hasContent = conteudo.trim().length > 0;
|
||||
|
||||
const effectiveAreaId = areaId && areaItems.some((a) => a.id === areaId) ? areaId : (areaItems[0]?.id ?? "");
|
||||
const effectiveAreaName = areaItems.find((a) => a.id === effectiveAreaId)?.nome ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setAreasLoading(true);
|
||||
areasService.listarTotal()
|
||||
.then((data) => setAreaItems(data))
|
||||
.catch(() => setAreaItems([]))
|
||||
.finally(() => setAreasLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prompt) {
|
||||
setTitulo(prompt.titulo);
|
||||
setDescricao(prompt.descricao ?? "");
|
||||
setAreaId(prompt.area_id ?? "");
|
||||
setConteudo(prompt.conteudo ?? "");
|
||||
} else if (!id) {
|
||||
setTitulo("");
|
||||
setDescricao("");
|
||||
setAreaId("");
|
||||
setConteudo("");
|
||||
}
|
||||
}, [prompt, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id && areaItems.length > 0 && !areaId) {
|
||||
setAreaId(areaItems[0].id);
|
||||
}
|
||||
}, [id, areaItems, areaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && !prompt && prompts.length > 0) {
|
||||
navigate("/codex/prompts", { replace: true });
|
||||
}
|
||||
}, [id, prompt, prompts.length, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conteudo.trim() && assistantMode === "refine") {
|
||||
setAssistantMode("create");
|
||||
}
|
||||
}, [conteudo, assistantMode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const finalTitulo = titulo.trim();
|
||||
const finalAreaId = effectiveAreaId;
|
||||
const finalConteudo = conteudo.trim();
|
||||
if (!finalTitulo || !finalAreaId) {
|
||||
toast.error("Preencha o título e a área.");
|
||||
return;
|
||||
}
|
||||
if (!finalConteudo) {
|
||||
toast.error("Preencha o conteúdo do prompt.");
|
||||
return;
|
||||
}
|
||||
const finalAreaName = areaItems.find((a) => a.id === finalAreaId)?.nome ?? "";
|
||||
const finalDescricao = descricao.trim();
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (isEdit && prompt) {
|
||||
const res = await promptsService.editar(prompt.id, {
|
||||
titulo: finalTitulo,
|
||||
descricao: finalDescricao,
|
||||
area_id: finalAreaId,
|
||||
conteudo: finalConteudo,
|
||||
});
|
||||
setPrompts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === prompt.id
|
||||
? { ...p, id: res.id, titulo: res.titulo, area: finalAreaName, area_id: res.area_id, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }
|
||||
: p
|
||||
)
|
||||
);
|
||||
toast.success("Prompt atualizado.");
|
||||
} else {
|
||||
const res = await promptsService.criar({
|
||||
titulo: finalTitulo,
|
||||
descricao: finalDescricao,
|
||||
area_id: finalAreaId,
|
||||
conteudo: finalConteudo,
|
||||
});
|
||||
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
|
||||
toast.success("Prompt criado.");
|
||||
}
|
||||
navigate("/codex/prompts");
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantSubmit = () => {
|
||||
if (!assistantInstructions.trim()) {
|
||||
toast.error("Preencha as instruções.");
|
||||
return;
|
||||
}
|
||||
if (assistantMode === "refine" && !conteudo.trim()) {
|
||||
toast.error("Não há conteúdo para melhorar.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAssistantOpen(false);
|
||||
setShowGeneratingModal(true);
|
||||
setIsAssistantWorkingInBackground(true);
|
||||
setIsAssistantLoading(true);
|
||||
|
||||
const ac = new AbortController();
|
||||
abortControllerRef.current = ac;
|
||||
const signal = ac.signal;
|
||||
|
||||
const onSuccess = (text: string) => {
|
||||
setConteudo(text);
|
||||
confettiRef.current?.fire({});
|
||||
playSuccessSound();
|
||||
const msg =
|
||||
assistantMode === "create" ? "Modelo inicial gerado!" : "Conteúdo atualizado!";
|
||||
toast.success(msg, {
|
||||
className: "bg-green-600 border-green-700 text-white",
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = () => {
|
||||
setShowGeneratingModal(false);
|
||||
setIsAssistantLoading(false);
|
||||
setIsAssistantWorkingInBackground(false);
|
||||
abortControllerRef.current = null;
|
||||
};
|
||||
|
||||
const onError = (e: unknown) => {
|
||||
const isAborted =
|
||||
(e && typeof e === "object" && "code" in e && (e as { code: string }).code === "ERR_CANCELED") ||
|
||||
(e && typeof e === "object" && "name" in e && (e as { name: string }).name === "AbortError");
|
||||
if (!isAborted) {
|
||||
const message =
|
||||
e && typeof e === "object" && "message" in e
|
||||
? (e as { message: string }).message
|
||||
: "Erro ao processar.";
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
if (assistantMode === "create") {
|
||||
assistentePromptsService.gerar(assistantInstructions, signal)
|
||||
.then(onSuccess)
|
||||
.catch(onError)
|
||||
.finally(onFinish);
|
||||
} else {
|
||||
assistentePromptsService.refinar(conteudo, assistantInstructions, signal)
|
||||
.then(onSuccess)
|
||||
.catch(onError)
|
||||
.finally(onFinish);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden pb-16 md:pb-0 relative">
|
||||
<Confetti
|
||||
ref={confettiRef}
|
||||
manualstart
|
||||
className="absolute inset-0 size-full pointer-events-none z-50"
|
||||
/>
|
||||
<div className="border-b border-border p-3 md:p-6 flex-shrink-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/codex/prompts")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-[200px] space-y-2">
|
||||
<Label htmlFor="form-titulo">Título</Label>
|
||||
<Input
|
||||
id="form-titulo"
|
||||
value={titulo}
|
||||
onChange={(e) => setTitulo(e.target.value)}
|
||||
placeholder="Ex: Prompt para análise de documentos"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-[220px] space-y-2">
|
||||
<Label htmlFor="form-area">Área</Label>
|
||||
<Select value={effectiveAreaId || "none"} onValueChange={(v) => setAreaId(v === "none" ? "" : v)} disabled={areasLoading}>
|
||||
<SelectTrigger id="form-area">
|
||||
<SelectValue placeholder={areasLoading ? "Carregando áreas..." : "Selecione a área"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Selecione a área</SelectItem>
|
||||
{areaItems.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="form-descricao">Descrição <span className="text-muted-foreground font-normal">(opcional)</span></Label>
|
||||
<Input
|
||||
id="form-descricao"
|
||||
value={descricao}
|
||||
onChange={(e) => setDescricao(e.target.value)}
|
||||
placeholder="Ex: Breve descrição do prompt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 p-3 md:p-6 gap-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<Label htmlFor="form-conteudo">Conteúdo do Prompt</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAssistantWorkingInBackground && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full bg-primary animate-pulse-glow shrink-0"
|
||||
title="Gerando em andamento"
|
||||
/>
|
||||
)}
|
||||
<ShimmerButton
|
||||
type="button"
|
||||
onClick={() => setIsAssistantOpen(true)}
|
||||
disabled={isAssistantWorkingInBackground}
|
||||
background="rgb(255,255,255)"
|
||||
shimmerColor="rgba(0,0,0,0.06)"
|
||||
className={cn(
|
||||
"gap-2 px-5 py-2.5 text-sm font-medium text-neutral-900 border border-neutral-200 shadow-md hover:shadow-lg transition-shadow dark:border-neutral-300",
|
||||
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{isAssistantWorkingInBackground ? (
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
Gerando em segundo plano...
|
||||
</span>
|
||||
) : (
|
||||
<SparklesText sparklesCount={5} className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
✨ Assistente de Criação de Prompt
|
||||
</SparklesText>
|
||||
)}
|
||||
</ShimmerButton>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id="form-conteudo"
|
||||
value={conteudo}
|
||||
onChange={(e) => setConteudo(e.target.value)}
|
||||
placeholder="Digite o conteúdo do prompt..."
|
||||
className="flex-1 min-h-[200px] resize-none p-3"
|
||||
/>
|
||||
<div className="flex justify-end pt-2">
|
||||
<InteractiveHoverButton
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!titulo.trim() || !effectiveAreaId || !conteudo.trim() || isSaving}
|
||||
className="disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
{isSaving ? "Salvando..." : isEdit ? "Salvar alterações" : "Criar prompt"}
|
||||
</InteractiveHoverButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showGeneratingModal && (
|
||||
<PromptGeneratingScreen
|
||||
mode={assistantMode}
|
||||
message="Está em processamento. Aguarde que em breve o prompt ficará pronto. O modal será fechado assim que finalizar."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-8 gap-6 overflow-hidden rounded-xl border-border/80 bg-gradient-to-b from-background to-muted/20">
|
||||
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none">
|
||||
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
|
||||
</div>
|
||||
<DialogHeader className="space-y-4 relative z-10">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex w-fit rounded-full border border-black/5 bg-neutral-100 text-base transition-all ease-in hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-2 transition ease-out hover:text-neutral-600 hover:duration-300 hover:dark:text-neutral-400">
|
||||
<span>✨ Assistente de Criação de Prompt</span>
|
||||
</AnimatedShinyText>
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
||||
<DialogDescription className="text-base text-muted-foreground">
|
||||
Escolha criar um novo prompt do zero ou melhorar o conteúdo atual. Preencha as instruções e o assistente gerará o texto.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-8 overflow-y-auto flex-1 min-h-0 px-1 pt-1 pb-2 pr-3 relative z-10">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">O que deseja fazer?</Label>
|
||||
<RadioGroup
|
||||
value={assistantMode}
|
||||
onValueChange={(v) => setAssistantMode(v as "create" | "refine")}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem value="create" id="assistant-create" />
|
||||
<Label htmlFor="assistant-create" className="font-normal cursor-pointer">
|
||||
Criar novo
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<RadioGroupItem
|
||||
value="refine"
|
||||
id="assistant-refine"
|
||||
disabled={!hasContent}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="assistant-refine"
|
||||
className={`font-normal ${!hasContent ? "cursor-not-allowed text-muted-foreground" : "cursor-pointer"}`}
|
||||
>
|
||||
Melhorar atual
|
||||
{!hasContent && " (preencha o conteúdo do prompt antes)"}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="assistant-instructions" className="text-base font-medium">
|
||||
Instruções
|
||||
</Label>
|
||||
<Textarea
|
||||
id="assistant-instructions"
|
||||
value={assistantInstructions}
|
||||
onChange={(e) => setAssistantInstructions(e.target.value)}
|
||||
placeholder={
|
||||
assistantMode === "create"
|
||||
? "Ex: Um prompt que resuma reuniões em tópicos e ações..."
|
||||
: "Ex: Tornar mais conciso, adicionar seção de exemplos..."
|
||||
}
|
||||
rows={8}
|
||||
className="resize-none text-base py-4 px-4 min-h-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3 pt-4 relative z-10">
|
||||
<Button variant="outline" className="hover:bg-slate-100 hover:text-slate-800 dark:hover:bg-slate-800" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssistantSubmit}
|
||||
disabled={
|
||||
!assistantInstructions.trim() ||
|
||||
isAssistantLoading ||
|
||||
(assistantMode === "refine" && !hasContent)
|
||||
}
|
||||
className="gap-2"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 shrink-0" />
|
||||
{assistantMode === "create" ? "Gerar" : "Melhorar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileText, Plus, Eye, Edit, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
import type { Prompt } from "@/contexts/PromptsContext";
|
||||
import { promptsService } from "@/services/promptsApi";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string; area_id: string; descricao?: string; conteudo?: string }): Prompt {
|
||||
return {
|
||||
id: item.id,
|
||||
titulo: item.titulo,
|
||||
area: item.area_nome,
|
||||
area_id: item.area_id,
|
||||
descricao: item.descricao ?? undefined,
|
||||
conteudo: item.conteudo ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export const PromptsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { prompts, setPrompts, areaItems } = usePrompts();
|
||||
|
||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false);
|
||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const [filterNome, setFilterNome] = useState("");
|
||||
const [filterAreaId, setFilterAreaId] = useState<string>("");
|
||||
const [promptsList, setPromptsList] = useState<Prompt[]>([]);
|
||||
const [totalRegistros, setTotalRegistros] = useState(0);
|
||||
const [totalPaginas, setTotalPaginas] = useState(1);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
|
||||
const totalPages = Math.max(1, totalPaginas);
|
||||
|
||||
const selectedAreaItem = useMemo(
|
||||
() => (selectedPrompt ? areaItems.find((a) => a.nome === selectedPrompt.area || a.id === selectedPrompt.area_id) ?? null : null),
|
||||
[selectedPrompt, areaItems]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingList(true);
|
||||
promptsService.listar({
|
||||
titulo: filterNome.trim() || undefined,
|
||||
area_id: filterAreaId.trim() || undefined,
|
||||
page: currentPage,
|
||||
per_page: itemsPerPage,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
const list = (res.data ?? []).map(apiItemToPrompt);
|
||||
setPromptsList(list);
|
||||
setTotalRegistros(res.total_registros ?? 0);
|
||||
setTotalPaginas(res.total_paginas ?? 1);
|
||||
setPrompts((prev) => {
|
||||
const byId = new Map(prev.map((p) => [p.id, p]));
|
||||
list.forEach((p) => byId.set(p.id, p));
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
const msg = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao carregar prompts.";
|
||||
toast.error(msg);
|
||||
setPromptsList([]);
|
||||
setTotalRegistros(0);
|
||||
setTotalPaginas(1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingList(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [filterNome, filterAreaId, currentPage, itemsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filterNome, filterAreaId]);
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterNome("");
|
||||
setFilterAreaId("");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const hasActiveFilters = filterNome.trim() !== "" || filterAreaId !== "";
|
||||
|
||||
const handleItemsPerPageChange = (value: string) => {
|
||||
setItemsPerPage(Number(value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const openDetailsDialog = (prompt: Prompt) => {
|
||||
setSelectedPrompt(prompt);
|
||||
setIsDetailsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditFromDetails = () => {
|
||||
if (!selectedPrompt) return;
|
||||
setIsDetailsDialogOpen(false);
|
||||
navigate(`/codex/prompts/${selectedPrompt.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
||||
<div className="p-3 md:p-6 border-b border-border">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 md:w-6 md:h-6" />
|
||||
Prompts
|
||||
</h1>
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button onClick={() => navigate("/codex/prompts/novo")} className="gap-1 md:gap-2 flex-1 md:flex-none text-xs md:text-sm">
|
||||
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">Novo prompt</span>
|
||||
<span className="sm:hidden">Novo</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-2 md:gap-4">
|
||||
<Input
|
||||
placeholder="Buscar prompts..."
|
||||
value={filterNome}
|
||||
onChange={(e) => setFilterNome(e.target.value)}
|
||||
className="w-full md:max-w-sm text-sm"
|
||||
/>
|
||||
<Select value={filterAreaId || "all"} onValueChange={(v) => setFilterAreaId(v === "all" ? "" : v)}>
|
||||
<SelectTrigger className="w-full md:w-[180px] text-sm">
|
||||
<SelectValue placeholder="Todas as áreas" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as áreas</SelectItem>
|
||||
{areaItems.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 justify-between md:justify-start">
|
||||
<span className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">Itens:</span>
|
||||
<Select value={itemsPerPage.toString()} onValueChange={handleItemsPerPageChange}>
|
||||
<SelectTrigger className="w-16 md:w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-3 md:p-6">
|
||||
{!loadingList && totalRegistros === 0 && !hasActiveFilters ? (
|
||||
<Card className="max-w-2xl mx-auto mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
Nenhum prompt cadastrado
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Crie seu primeiro prompt para organizar por título e área.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => navigate("/codex/prompts/novo")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo prompt
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
|
||||
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
|
||||
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingList ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground py-8">
|
||||
Carregando...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : promptsList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground py-10 text-base">
|
||||
Não há resultados.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
promptsList.map((prompt) => (
|
||||
<TableRow key={prompt.id}>
|
||||
<TableCell className="font-medium text-xs md:text-sm">{prompt.titulo}</TableCell>
|
||||
<TableCell className="text-xs md:text-sm">{prompt.area}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openDetailsDialog(prompt)}
|
||||
title="Detalhes"
|
||||
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
||||
>
|
||||
<Eye className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
|
||||
title="Editar"
|
||||
className="h-7 w-7 md:h-9 md:w-9 hover:bg-slate-300 dark:hover:bg-slate-700 hover:text-foreground"
|
||||
>
|
||||
<Edit className="w-3 h-3 md:w-4 md:h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!loadingList && totalRegistros > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 mt-4">
|
||||
<p className="text-xs md:text-sm text-muted-foreground text-center sm:text-left">
|
||||
Mostrando {totalRegistros} {totalRegistros === 1 ? "prompt" : "prompts"}
|
||||
</p>
|
||||
<div className="flex gap-1 md:gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Anterior</span>
|
||||
<span className="sm:hidden">Ant</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number;
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1;
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1;
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i;
|
||||
} else {
|
||||
page = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="w-8 h-8 md:w-10 md:h-9 p-0 text-xs md:text-sm"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="text-xs md:text-sm px-2 md:px-4"
|
||||
>
|
||||
<span className="hidden sm:inline">Próxima</span>
|
||||
<span className="sm:hidden">Prox</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Detalhes */}
|
||||
<Dialog open={isDetailsDialogOpen} onOpenChange={setIsDetailsDialogOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detalhes do Prompt</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informações do prompt selecionado
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedPrompt && (
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 p-3">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Título</Label>
|
||||
<p className="font-medium mt-1">{selectedPrompt.titulo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Área</Label>
|
||||
<p className="mt-1 font-medium">{selectedPrompt.area}</p>
|
||||
{selectedAreaItem?.descricao?.trim() && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{selectedAreaItem.descricao}</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedPrompt.conteudo != null && selectedPrompt.conteudo !== "" && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">Conteúdo</Label>
|
||||
<div className="mt-1 rounded-md border bg-muted/30 p-4 max-h-[50vh] overflow-y-auto">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{selectedPrompt.conteudo}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (selectedPrompt?.conteudo != null) {
|
||||
await navigator.clipboard.writeText(selectedPrompt.conteudo);
|
||||
toast.success("Prompt copiado para a área de transferência.");
|
||||
} else {
|
||||
toast.error("Nenhum conteúdo para copiar.");
|
||||
}
|
||||
}}
|
||||
disabled={!selectedPrompt?.conteudo?.trim()}
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Copiar prompt
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setIsDetailsDialogOpen(false)}>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button onClick={openEditFromDetails}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { RefObject, useEffect, useId, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedBeamProps {
|
||||
className?: string
|
||||
containerRef: RefObject<HTMLElement | null> // Container ref
|
||||
fromRef: RefObject<HTMLElement | null>
|
||||
toRef: RefObject<HTMLElement | null>
|
||||
curvature?: number
|
||||
reverse?: boolean
|
||||
pathColor?: string
|
||||
pathWidth?: number
|
||||
pathOpacity?: number
|
||||
gradientStartColor?: string
|
||||
gradientStopColor?: string
|
||||
delay?: number
|
||||
duration?: number
|
||||
startXOffset?: number
|
||||
startYOffset?: number
|
||||
endXOffset?: number
|
||||
endYOffset?: number
|
||||
}
|
||||
|
||||
export const AnimatedBeam: React.FC<AnimatedBeamProps> = ({
|
||||
className,
|
||||
containerRef,
|
||||
fromRef,
|
||||
toRef,
|
||||
curvature = 0,
|
||||
reverse = false, // Include the reverse prop
|
||||
duration = Math.random() * 3 + 4,
|
||||
delay = 0,
|
||||
pathColor = "gray",
|
||||
pathWidth = 2,
|
||||
pathOpacity = 0.2,
|
||||
gradientStartColor = "#ffaa40",
|
||||
gradientStopColor = "#9c40ff",
|
||||
startXOffset = 0,
|
||||
startYOffset = 0,
|
||||
endXOffset = 0,
|
||||
endYOffset = 0,
|
||||
}) => {
|
||||
const id = useId()
|
||||
const [pathD, setPathD] = useState("")
|
||||
const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 })
|
||||
|
||||
// Calculate the gradient coordinates based on the reverse prop
|
||||
const gradientCoordinates = reverse
|
||||
? {
|
||||
x1: ["90%", "-10%"],
|
||||
x2: ["100%", "0%"],
|
||||
y1: ["0%", "0%"],
|
||||
y2: ["0%", "0%"],
|
||||
}
|
||||
: {
|
||||
x1: ["10%", "110%"],
|
||||
x2: ["0%", "100%"],
|
||||
y1: ["0%", "0%"],
|
||||
y2: ["0%", "0%"],
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const updatePath = () => {
|
||||
if (containerRef.current && fromRef.current && toRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const rectA = fromRef.current.getBoundingClientRect()
|
||||
const rectB = toRef.current.getBoundingClientRect()
|
||||
|
||||
const svgWidth = containerRect.width
|
||||
const svgHeight = containerRect.height
|
||||
setSvgDimensions({ width: svgWidth, height: svgHeight })
|
||||
|
||||
const startX =
|
||||
rectA.left - containerRect.left + rectA.width / 2 + startXOffset
|
||||
const startY =
|
||||
rectA.top - containerRect.top + rectA.height / 2 + startYOffset
|
||||
const endX =
|
||||
rectB.left - containerRect.left + rectB.width / 2 + endXOffset
|
||||
const endY =
|
||||
rectB.top - containerRect.top + rectB.height / 2 + endYOffset
|
||||
|
||||
const controlY = startY - curvature
|
||||
const d = `M ${startX},${startY} Q ${
|
||||
(startX + endX) / 2
|
||||
},${controlY} ${endX},${endY}`
|
||||
setPathD(d)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ResizeObserver
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updatePath()
|
||||
})
|
||||
|
||||
// Observe the container element
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current)
|
||||
}
|
||||
|
||||
// Call the updatePath initially to set the initial path
|
||||
updatePath()
|
||||
|
||||
// Clean up the observer on component unmount
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [
|
||||
containerRef,
|
||||
fromRef,
|
||||
toRef,
|
||||
curvature,
|
||||
startXOffset,
|
||||
startYOffset,
|
||||
endXOffset,
|
||||
endYOffset,
|
||||
])
|
||||
|
||||
return (
|
||||
<svg
|
||||
fill="none"
|
||||
width={svgDimensions.width}
|
||||
height={svgDimensions.height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-0 left-0 transform-gpu stroke-2",
|
||||
className
|
||||
)}
|
||||
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
||||
>
|
||||
<path
|
||||
d={pathD}
|
||||
stroke={pathColor}
|
||||
strokeWidth={pathWidth}
|
||||
strokeOpacity={pathOpacity}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
strokeWidth={pathWidth}
|
||||
stroke={`url(#${id})`}
|
||||
strokeOpacity="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<motion.linearGradient
|
||||
className="transform-gpu"
|
||||
id={id}
|
||||
gradientUnits={"userSpaceOnUse"}
|
||||
initial={{
|
||||
x1: "0%",
|
||||
x2: "0%",
|
||||
y1: "0%",
|
||||
y2: "0%",
|
||||
}}
|
||||
animate={{
|
||||
x1: gradientCoordinates.x1,
|
||||
x2: gradientCoordinates.x2,
|
||||
y1: gradientCoordinates.y1,
|
||||
y2: gradientCoordinates.y2,
|
||||
}}
|
||||
transition={{
|
||||
delay,
|
||||
duration,
|
||||
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0,
|
||||
}}
|
||||
>
|
||||
<stop stopColor={gradientStartColor} stopOpacity="0"></stop>
|
||||
<stop stopColor={gradientStartColor}></stop>
|
||||
<stop offset="32.5%" stopColor={gradientStopColor}></stop>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={gradientStopColor}
|
||||
stopOpacity="0"
|
||||
></stop>
|
||||
</motion.linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ComponentPropsWithoutRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
}
|
||||
|
||||
export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--bg-size": `${speed * 300}%`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
`animate-gradient inline bg-linear-to-r from-(--color-from) via-(--color-to) to-(--color-from) bg-size-[var(--bg-size)_100%] bg-clip-text text-transparent`,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedGridPatternProps extends ComponentPropsWithoutRef<"svg"> {
|
||||
width?: number
|
||||
height?: number
|
||||
x?: number
|
||||
y?: number
|
||||
strokeDasharray?: number
|
||||
numSquares?: number
|
||||
maxOpacity?: number
|
||||
duration?: number
|
||||
repeatDelay?: number
|
||||
}
|
||||
|
||||
type Square = {
|
||||
id: number
|
||||
pos: [number, number]
|
||||
iteration: number
|
||||
}
|
||||
|
||||
export function AnimatedGridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = 0,
|
||||
numSquares = 50,
|
||||
className,
|
||||
maxOpacity = 0.5,
|
||||
duration = 4,
|
||||
repeatDelay = 0.5,
|
||||
...props
|
||||
}: AnimatedGridPatternProps) {
|
||||
const id = useId()
|
||||
const containerRef = useRef<SVGSVGElement | null>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
const [squares, setSquares] = useState<Array<Square>>([])
|
||||
|
||||
const getPos = useCallback((): [number, number] => {
|
||||
return [
|
||||
Math.floor((Math.random() * dimensions.width) / width),
|
||||
Math.floor((Math.random() * dimensions.height) / height),
|
||||
]
|
||||
}, [dimensions.height, dimensions.width, height, width])
|
||||
|
||||
const generateSquares = useCallback(
|
||||
(count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
pos: getPos(),
|
||||
iteration: 0,
|
||||
}))
|
||||
},
|
||||
[getPos]
|
||||
)
|
||||
|
||||
const updateSquarePosition = useCallback(
|
||||
(squareId: number) => {
|
||||
setSquares((currentSquares) => {
|
||||
const current = currentSquares[squareId]
|
||||
if (!current || current.id !== squareId) return currentSquares
|
||||
|
||||
const nextSquares = currentSquares.slice()
|
||||
nextSquares[squareId] = {
|
||||
...current,
|
||||
pos: getPos(),
|
||||
iteration: current.iteration + 1,
|
||||
}
|
||||
|
||||
return nextSquares
|
||||
})
|
||||
},
|
||||
[getPos]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (dimensions.width && dimensions.height) {
|
||||
setSquares(generateSquares(numSquares))
|
||||
}
|
||||
}, [dimensions.width, dimensions.height, generateSquares, numSquares])
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
if (element) {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setDimensions((currentDimensions) => {
|
||||
const nextWidth = entry.contentRect.width
|
||||
const nextHeight = entry.contentRect.height
|
||||
if (
|
||||
currentDimensions.width === nextWidth &&
|
||||
currentDimensions.height === nextHeight
|
||||
) {
|
||||
return currentDimensions
|
||||
}
|
||||
return { width: nextWidth, height: nextHeight }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(element)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-gray-400/30 stroke-gray-400/30",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill={`url(#${id})`} />
|
||||
<svg x={x} y={y} className="overflow-visible">
|
||||
{squares.map(({ pos: [squareX, squareY], id, iteration }, index) => (
|
||||
<motion.rect
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: maxOpacity }}
|
||||
transition={{
|
||||
duration,
|
||||
repeat: 1,
|
||||
delay: index * 0.1,
|
||||
repeatType: "reverse",
|
||||
repeatDelay,
|
||||
}}
|
||||
onAnimationComplete={() => updateSquarePosition(id)}
|
||||
key={`${id}-${iteration}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={squareX * width + 1}
|
||||
y={squareY * height + 1}
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
type CSSProperties,
|
||||
type FC,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
|
||||
shimmerWidth?: number
|
||||
}
|
||||
|
||||
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
|
||||
children,
|
||||
className,
|
||||
shimmerWidth = 100,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--shiny-width": `${shimmerWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
|
||||
|
||||
// Shine effect
|
||||
"animate-shiny-text bg-size-[var(--shiny-width)_100%] bg-clip-text bg-position-[0_0] bg-no-repeat [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
|
||||
|
||||
// Shine gradient
|
||||
"bg-linear-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { motion, MotionStyle, Transition } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface BorderBeamProps {
|
||||
/**
|
||||
* The size of the border beam.
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* The duration of the border beam.
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* The delay of the border beam.
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* The color of the border beam from.
|
||||
*/
|
||||
colorFrom?: string
|
||||
/**
|
||||
* The color of the border beam to.
|
||||
*/
|
||||
colorTo?: string
|
||||
/**
|
||||
* The motion transition of the border beam.
|
||||
*/
|
||||
transition?: Transition
|
||||
/**
|
||||
* The class name of the border beam.
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* The style of the border beam.
|
||||
*/
|
||||
style?: React.CSSProperties
|
||||
/**
|
||||
* Whether to reverse the animation direction.
|
||||
*/
|
||||
reverse?: boolean
|
||||
/**
|
||||
* The initial offset position (0-100).
|
||||
*/
|
||||
initialOffset?: number
|
||||
/**
|
||||
* The border width of the beam.
|
||||
*/
|
||||
borderWidth?: number
|
||||
}
|
||||
|
||||
export const BorderBeam = ({
|
||||
className,
|
||||
size = 50,
|
||||
delay = 0,
|
||||
duration = 6,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
transition,
|
||||
style,
|
||||
reverse = false,
|
||||
initialOffset = 0,
|
||||
borderWidth = 1,
|
||||
}: BorderBeamProps) => {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent mask-[linear-gradient(transparent,transparent),linear-gradient(#000,#000)] mask-intersect [mask-clip:padding-box,border-box]"
|
||||
style={
|
||||
{
|
||||
"--border-beam-width": `${borderWidth}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute aspect-square",
|
||||
"bg-linear-to-l from-(--color-from) via-(--color-to) to-transparent",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
width: size,
|
||||
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
...style,
|
||||
} as MotionStyle
|
||||
}
|
||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||
animate={{
|
||||
offsetDistance: reverse
|
||||
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
|
||||
: [`${initialOffset}%`, `${100 + initialOffset}%`],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
duration,
|
||||
delay: -delay,
|
||||
...transition,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import type {
|
||||
GlobalOptions as ConfettiGlobalOptions,
|
||||
CreateTypes as ConfettiInstance,
|
||||
Options as ConfettiOptions,
|
||||
} from "canvas-confetti";
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Api = {
|
||||
fire: (options?: ConfettiOptions) => void;
|
||||
};
|
||||
|
||||
type Props = React.ComponentPropsWithRef<"canvas"> & {
|
||||
options?: ConfettiOptions;
|
||||
globalOptions?: ConfettiGlobalOptions;
|
||||
manualstart?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export type ConfettiRef = Api | null;
|
||||
|
||||
const ConfettiContext = createContext<Api>({} as Api);
|
||||
|
||||
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
||||
const {
|
||||
options,
|
||||
globalOptions = { resize: true, useWorker: true },
|
||||
manualstart = false,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const instanceRef = useRef<ConfettiInstance | null>(null);
|
||||
|
||||
const canvasRef = useCallback(
|
||||
(node: HTMLCanvasElement) => {
|
||||
if (node !== null) {
|
||||
if (instanceRef.current) return;
|
||||
instanceRef.current = confetti.create(node, {
|
||||
...globalOptions,
|
||||
resize: true,
|
||||
});
|
||||
} else {
|
||||
if (instanceRef.current) {
|
||||
instanceRef.current.reset();
|
||||
instanceRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[globalOptions]
|
||||
);
|
||||
|
||||
const fire = useCallback(
|
||||
async (opts = {}) => {
|
||||
try {
|
||||
await instanceRef.current?.({ ...options, ...opts });
|
||||
} catch (error) {
|
||||
console.error("Confetti error:", error);
|
||||
}
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const api = useMemo(
|
||||
() => ({
|
||||
fire,
|
||||
}),
|
||||
[fire]
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => api, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualstart) {
|
||||
(async () => {
|
||||
try {
|
||||
await fire();
|
||||
} catch (error) {
|
||||
console.error("Confetti effect error:", error);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [manualstart, fire]);
|
||||
|
||||
return (
|
||||
<ConfettiContext.Provider value={api}>
|
||||
<canvas ref={canvasRef} {...rest} />
|
||||
{children}
|
||||
</ConfettiContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ConfettiComponent.displayName = "Confetti";
|
||||
|
||||
export const Confetti = ConfettiComponent;
|
||||
|
||||
interface ConfettiButtonProps extends React.ComponentProps<"button"> {
|
||||
options?: ConfettiOptions &
|
||||
ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
|
||||
}
|
||||
|
||||
const ConfettiButtonComponent = ({
|
||||
options,
|
||||
children,
|
||||
...props
|
||||
}: ConfettiButtonProps) => {
|
||||
const handleClick = async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
await confetti({
|
||||
...options,
|
||||
origin: {
|
||||
x: x / window.innerWidth,
|
||||
y: y / window.innerHeight,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Confetti button error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ConfettiButtonComponent.displayName = "ConfettiButton";
|
||||
|
||||
export const ConfettiButton = ConfettiButtonComponent;
|
||||
@@ -27,12 +27,18 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
hideClose?: boolean;
|
||||
overlayClassName?: string;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideClose, overlayClassName, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -42,10 +48,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function InteractiveHoverButton({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group bg-background relative w-auto cursor-pointer overflow-hidden rounded-full border p-2 px-6 text-center font-semibold",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="bg-primary h-2 w-2 rounded-full transition-all duration-300 group-hover:scale-[100.8]"></div>
|
||||
<span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-primary-foreground absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
|
||||
<span>{children}</span>
|
||||
<ArrowRight />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface NeonColorsProps {
|
||||
firstColor: string
|
||||
secondColor: string
|
||||
}
|
||||
|
||||
interface NeonGradientCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the card
|
||||
* */
|
||||
as?: ReactElement
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the card
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The children of the card
|
||||
* */
|
||||
children?: ReactNode
|
||||
|
||||
/**
|
||||
* @default 5
|
||||
* @type number
|
||||
* @description
|
||||
* The size of the border in pixels
|
||||
* */
|
||||
borderSize?: number
|
||||
|
||||
/**
|
||||
* @default 20
|
||||
* @type number
|
||||
* @description
|
||||
* The size of the radius in pixels
|
||||
* */
|
||||
borderRadius?: number
|
||||
|
||||
/**
|
||||
* @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the neon gradient
|
||||
* */
|
||||
neonColors?: NeonColorsProps
|
||||
}
|
||||
|
||||
export const NeonGradientCard: React.FC<NeonGradientCardProps> = ({
|
||||
className,
|
||||
children,
|
||||
borderSize = 2,
|
||||
borderRadius = 20,
|
||||
neonColors = {
|
||||
firstColor: "#ff00aa",
|
||||
secondColor: "#00FFF1",
|
||||
},
|
||||
...props
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
}
|
||||
}
|
||||
|
||||
updateDimensions()
|
||||
window.addEventListener("resize", updateDimensions)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateDimensions)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const { offsetWidth, offsetHeight } = containerRef.current
|
||||
setDimensions({ width: offsetWidth, height: offsetHeight })
|
||||
}
|
||||
}, [children])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={
|
||||
{
|
||||
"--border-size": `${borderSize}px`,
|
||||
"--border-radius": `${borderRadius}px`,
|
||||
"--neon-first-color": neonColors.firstColor,
|
||||
"--neon-second-color": neonColors.secondColor,
|
||||
"--card-width": `${dimensions.width}px`,
|
||||
"--card-height": `${dimensions.height}px`,
|
||||
"--card-content-radius": `${borderRadius - borderSize}px`,
|
||||
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
|
||||
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
|
||||
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
|
||||
"--after-blur": `${dimensions.width / 3}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"relative z-10 size-full rounded-(--border-radius)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-full min-h-[inherit] rounded-(--card-content-radius) bg-gray-100 p-6",
|
||||
"before:absolute before:-top-(--border-size) before:-left-(--border-size) before:-z-10 before:block",
|
||||
"before:h-(--pseudo-element-height) before:w-(--pseudo-element-width) before:rounded-(--border-radius) before:content-['']",
|
||||
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-size-[100%_200%]",
|
||||
"before:animate-background-position-spin",
|
||||
"after:absolute after:-top-(--border-size) after:-left-(--border-size) after:-z-10 after:block",
|
||||
"after:h-(--pseudo-element-height) after:w-(--pseudo-element-width) after:rounded-(--border-radius) after:blur-(--after-blur) after:content-['']",
|
||||
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-size-[100%_200%] after:opacity-80",
|
||||
"after:animate-background-position-spin",
|
||||
"dark:bg-neutral-900",
|
||||
"wrap-break-word"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const rainbowButtonVariants = cva(
|
||||
cn(
|
||||
"relative cursor-pointer group transition-all animate-rainbow",
|
||||
"inline-flex items-center justify-center gap-2 shrink-0",
|
||||
"rounded-sm outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
|
||||
"text-sm font-medium whitespace-nowrap",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0"
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-0 bg-[linear-gradient(#121213,#121213),linear-gradient(#121213_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-primary-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] [border:calc(0.125rem)_solid_transparent] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#fff,#fff),linear-gradient(#fff_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||
outline:
|
||||
"border border-input border-b-transparent bg-[linear-gradient(#ffffff,#ffffff),linear-gradient(#ffffff_50%,rgba(18,18,19,0.6)_80%,rgba(18,18,19,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] bg-[length:200%] text-accent-foreground [background-clip:padding-box,border-box,border-box] [background-origin:border-box] before:absolute before:bottom-[-20%] before:left-1/2 before:z-0 before:h-1/5 before:w-3/5 before:-translate-x-1/2 before:animate-rainbow before:bg-[linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))] before:[filter:blur(0.75rem)] dark:bg-[linear-gradient(#0a0a0a,#0a0a0a),linear-gradient(#0a0a0a_50%,rgba(255,255,255,0.6)_80%,rgba(0,0,0,0)),linear-gradient(90deg,var(--color-1),var(--color-5),var(--color-3),var(--color-4),var(--color-2))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-xl px-3 text-xs",
|
||||
lg: "h-11 rounded-xl px-8",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface RainbowButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof rainbowButtonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const RainbowButton = React.forwardRef<HTMLButtonElement, RainbowButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(rainbowButtonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RainbowButton.displayName = "RainbowButton"
|
||||
|
||||
export { RainbowButton, rainbowButtonVariants, type RainbowButtonProps }
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { MouseEvent, useEffect, useState } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface RippleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
rippleColor?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export const RippleButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
RippleButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
rippleColor = "#ffffff",
|
||||
duration = "600ms",
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [buttonRipples, setButtonRipples] = useState<
|
||||
Array<{ x: number; y: number; size: number; key: number }>
|
||||
>([])
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
createRipple(event)
|
||||
onClick?.(event)
|
||||
}
|
||||
|
||||
const createRipple = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
const button = event.currentTarget
|
||||
const rect = button.getBoundingClientRect()
|
||||
const size = Math.max(rect.width, rect.height)
|
||||
const x = event.clientX - rect.left - size / 2
|
||||
const y = event.clientY - rect.top - size / 2
|
||||
|
||||
const newRipple = { x, y, size, key: Date.now() }
|
||||
setButtonRipples((prevRipples) => [...prevRipples, newRipple])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (buttonRipples.length > 0) {
|
||||
const lastRipple = buttonRipples[buttonRipples.length - 1]
|
||||
timeout = setTimeout(() => {
|
||||
setButtonRipples((prevRipples) =>
|
||||
prevRipples.filter((ripple) => ripple.key !== lastRipple.key)
|
||||
)
|
||||
}, parseInt(duration))
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [buttonRipples, duration])
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"bg-background text-primary relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 px-4 py-2 text-center",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10">{children}</div>
|
||||
<span className="pointer-events-none absolute inset-0">
|
||||
{buttonRipples.map((ripple) => (
|
||||
<span
|
||||
className="animate-rippling bg-background absolute rounded-full opacity-30"
|
||||
key={ripple.key}
|
||||
style={
|
||||
{
|
||||
width: `${ripple.size}px`,
|
||||
height: `${ripple.size}px`,
|
||||
top: `${ripple.y}px`,
|
||||
left: `${ripple.x}px`,
|
||||
backgroundColor: rippleColor,
|
||||
transform: `scale(0)`,
|
||||
"--duration": duration,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RippleButton.displayName = "RippleButton"
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface RippleProps extends ComponentPropsWithoutRef<"div"> {
|
||||
mainCircleSize?: number
|
||||
mainCircleOpacity?: number
|
||||
numCircles?: number
|
||||
}
|
||||
|
||||
export const Ripple = React.memo(function Ripple({
|
||||
mainCircleSize = 210,
|
||||
mainCircleOpacity = 0.24,
|
||||
numCircles = 8,
|
||||
className,
|
||||
...props
|
||||
}: RippleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 mask-[linear-gradient(to_bottom,white,transparent)] select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Array.from({ length: numCircles }, (_, i) => {
|
||||
const size = mainCircleSize + i * 70
|
||||
const opacity = mainCircleOpacity - i * 0.03
|
||||
const animationDelay = `${i * 0.06}s`
|
||||
const borderStyle = "solid"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`animate-ripple bg-foreground/25 absolute rounded-full border shadow-xl`}
|
||||
style={
|
||||
{
|
||||
"--i": i,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
opacity,
|
||||
animationDelay,
|
||||
borderStyle,
|
||||
borderWidth: "1px",
|
||||
borderColor: `var(--foreground)`,
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%) scale(1)",
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Ripple.displayName = "Ripple"
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { type ComponentPropsWithoutRef, type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
shimmerColor?: string
|
||||
shimmerSize?: string
|
||||
borderRadius?: string
|
||||
shimmerDuration?: string
|
||||
background?: string
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const ShimmerButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
ShimmerButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
shimmerColor = "#ffffff",
|
||||
shimmerSize = "0.05em",
|
||||
shimmerDuration = "3s",
|
||||
borderRadius = "100px",
|
||||
background = "rgba(0, 0, 0, 1)",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
style={
|
||||
{
|
||||
"--spread": "90deg",
|
||||
"--shimmer-color": shimmerColor,
|
||||
"--radius": borderRadius,
|
||||
"--speed": shimmerDuration,
|
||||
"--cut": shimmerSize,
|
||||
"--bg": background,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)]",
|
||||
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{/* spark container */}
|
||||
<div
|
||||
className={cn(
|
||||
"-z-30 blur-[2px]",
|
||||
"@container-[size] absolute inset-0 overflow-visible"
|
||||
)}
|
||||
>
|
||||
{/* spark */}
|
||||
<div className="animate-shimmer-slide absolute inset-0 aspect-[1] h-[100cqh] rounded-none [mask:none]">
|
||||
{/* spark before */}
|
||||
<div className="animate-spin-around absolute -inset-full w-auto [translate:0_0] rotate-0 [background:conic-gradient(from_calc(270deg-(var(--spread)*0.5)),transparent_0,var(--shimmer-color)_var(--spread),transparent_var(--spread))]" />
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
|
||||
{/* Highlight */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 size-full",
|
||||
|
||||
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]",
|
||||
|
||||
// transition
|
||||
"transform-gpu transition-all duration-300 ease-in-out",
|
||||
|
||||
// on hover
|
||||
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
|
||||
|
||||
// on click
|
||||
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-(--cut) -z-20 [border-radius:var(--radius)] [background:var(--bg)]"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ShimmerButton.displayName = "ShimmerButton"
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CSSProperties, ReactElement, useEffect, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Sparkle {
|
||||
id: string
|
||||
x: string
|
||||
y: string
|
||||
color: string
|
||||
delay: number
|
||||
scale: number
|
||||
lifespan: number
|
||||
}
|
||||
|
||||
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
|
||||
return (
|
||||
<motion.svg
|
||||
key={id}
|
||||
className="pointer-events-none absolute z-20"
|
||||
initial={{ opacity: 0, left: x, top: y }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, scale, 0],
|
||||
rotate: [75, 120, 150],
|
||||
}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, delay }}
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
>
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={color}
|
||||
/>
|
||||
</motion.svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface SparklesTextProps {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the text
|
||||
* */
|
||||
as?: ReactElement
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the text
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* @required
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The content to be displayed
|
||||
* */
|
||||
children: React.ReactNode
|
||||
|
||||
/**
|
||||
* @default 10
|
||||
* @type number
|
||||
* @description
|
||||
* The count of sparkles
|
||||
* */
|
||||
sparklesCount?: number
|
||||
|
||||
/**
|
||||
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the sparkles
|
||||
* */
|
||||
colors?: {
|
||||
first: string
|
||||
second: string
|
||||
}
|
||||
}
|
||||
|
||||
export const SparklesText: React.FC<SparklesTextProps> = ({
|
||||
children,
|
||||
colors = { first: "#9E7AFF", second: "#FE8BBB" },
|
||||
className,
|
||||
sparklesCount = 10,
|
||||
...props
|
||||
}) => {
|
||||
const [sparkles, setSparkles] = useState<Sparkle[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const generateStar = (): Sparkle => {
|
||||
const starX = `${Math.random() * 100}%`
|
||||
const starY = `${Math.random() * 100}%`
|
||||
const color = Math.random() > 0.5 ? colors.first : colors.second
|
||||
const delay = Math.random() * 2
|
||||
const scale = Math.random() * 1 + 0.3
|
||||
const lifespan = Math.random() * 10 + 5
|
||||
const id = `${starX}-${starY}-${Date.now()}`
|
||||
return { id, x: starX, y: starY, color, delay, scale, lifespan }
|
||||
}
|
||||
|
||||
const initializeStars = () => {
|
||||
const newSparkles = Array.from({ length: sparklesCount }, generateStar)
|
||||
setSparkles(newSparkles)
|
||||
}
|
||||
|
||||
const updateStars = () => {
|
||||
setSparkles((currentSparkles) =>
|
||||
currentSparkles.map((star) => {
|
||||
if (star.lifespan <= 0) {
|
||||
return generateStar()
|
||||
} else {
|
||||
return { ...star, lifespan: star.lifespan - 0.1 }
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
initializeStars()
|
||||
const interval = setInterval(updateStars, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [colors.first, colors.second, sparklesCount])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("text-6xl font-bold", className)}
|
||||
{...props}
|
||||
style={
|
||||
{
|
||||
"--sparkles-first-color": `${colors.first}`,
|
||||
"--sparkles-second-color": `${colors.second}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="relative inline-block">
|
||||
{sparkles.map((sparkle) => (
|
||||
<Sparkle key={sparkle.id} {...sparkle} />
|
||||
))}
|
||||
<strong>{children}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Container com estilo de terminal: fundo escuro, borda, fonte monospace.
|
||||
*/
|
||||
const Terminal = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-zinc-900/95 font-mono text-sm shadow-xl",
|
||||
"p-4 text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
Terminal.displayName = "Terminal";
|
||||
|
||||
interface AnimatedStepProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
delay?: number;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linha animada (fade-in + slide up) para uso dentro do Terminal.
|
||||
* Usado para exibir passos em sequência.
|
||||
*/
|
||||
function AnimatedStep({
|
||||
delay = 0,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AnimatedStepProps) {
|
||||
return (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay }}
|
||||
className={cn("block", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { Terminal, AnimatedStep };
|
||||
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type RefAttributes,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type DOMMotionComponents,
|
||||
type HTMLMotionProps,
|
||||
type MotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const motionElements = {
|
||||
article: motion.article,
|
||||
div: motion.div,
|
||||
h1: motion.h1,
|
||||
h2: motion.h2,
|
||||
h3: motion.h3,
|
||||
h4: motion.h4,
|
||||
h5: motion.h5,
|
||||
h6: motion.h6,
|
||||
li: motion.li,
|
||||
p: motion.p,
|
||||
section: motion.section,
|
||||
span: motion.span,
|
||||
} as const
|
||||
|
||||
type MotionElementType = Extract<
|
||||
keyof DOMMotionComponents,
|
||||
keyof typeof motionElements
|
||||
>
|
||||
type TypingAnimationMotionComponent = ComponentType<
|
||||
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
||||
>
|
||||
|
||||
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
||||
children?: string
|
||||
words?: string[]
|
||||
className?: string
|
||||
duration?: number
|
||||
typeSpeed?: number
|
||||
deleteSpeed?: number
|
||||
delay?: number
|
||||
pauseDelay?: number
|
||||
loop?: boolean
|
||||
as?: MotionElementType
|
||||
startOnView?: boolean
|
||||
showCursor?: boolean
|
||||
blinkCursor?: boolean
|
||||
cursorStyle?: "line" | "block" | "underscore"
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
words,
|
||||
className,
|
||||
duration = 100,
|
||||
typeSpeed,
|
||||
deleteSpeed,
|
||||
delay = 0,
|
||||
pauseDelay = 1000,
|
||||
loop = false,
|
||||
as: Component = "span",
|
||||
startOnView = true,
|
||||
showCursor = true,
|
||||
blinkCursor = true,
|
||||
cursorStyle = "line",
|
||||
...props
|
||||
}: TypingAnimationProps) {
|
||||
const MotionComponent = motionElements[
|
||||
Component
|
||||
] as TypingAnimationMotionComponent
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("")
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
const isInView = useInView(elementRef as RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
})
|
||||
|
||||
const wordsToAnimate = useMemo(
|
||||
() => words ?? (children ? [children] : []),
|
||||
[words, children]
|
||||
)
|
||||
const hasMultipleWords = wordsToAnimate.length > 1
|
||||
|
||||
const typingSpeed = typeSpeed ?? duration
|
||||
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
|
||||
|
||||
const shouldStart = startOnView ? isInView : true
|
||||
const animationSourceKey = useMemo(
|
||||
() => (words ? words.join("\u0000") : (children ?? "")),
|
||||
[words, children]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedText("")
|
||||
setCurrentWordIndex(0)
|
||||
setCurrentCharIndex(0)
|
||||
setPhase("typing")
|
||||
}, [animationSourceKey])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (shouldStart && wordsToAnimate.length > 0) {
|
||||
const timeoutDelay =
|
||||
delay > 0 && displayedText === ""
|
||||
? delay
|
||||
: phase === "typing"
|
||||
? typingSpeed
|
||||
: phase === "deleting"
|
||||
? deletingSpeed
|
||||
: pauseDelay
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
const currentWord = wordsToAnimate[currentWordIndex] || ""
|
||||
const graphemes = Array.from(currentWord)
|
||||
|
||||
switch (phase) {
|
||||
case "typing":
|
||||
if (currentCharIndex < graphemes.length) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex + 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex + 1)
|
||||
} else {
|
||||
if (hasMultipleWords || loop) {
|
||||
const isLastWord =
|
||||
currentWordIndex === wordsToAnimate.length - 1
|
||||
if (!isLastWord || loop) {
|
||||
setPhase("pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "pause":
|
||||
setPhase("deleting")
|
||||
break
|
||||
|
||||
case "deleting":
|
||||
if (currentCharIndex > 0) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex - 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex - 1)
|
||||
} else {
|
||||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||
setCurrentWordIndex(nextIndex)
|
||||
setPhase("typing")
|
||||
}
|
||||
break
|
||||
}
|
||||
}, timeoutDelay)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
shouldStart,
|
||||
phase,
|
||||
currentCharIndex,
|
||||
currentWordIndex,
|
||||
displayedText,
|
||||
wordsToAnimate,
|
||||
hasMultipleWords,
|
||||
loop,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDelay,
|
||||
delay,
|
||||
])
|
||||
|
||||
const currentWordGraphemes = Array.from(
|
||||
wordsToAnimate[currentWordIndex] || ""
|
||||
)
|
||||
const isComplete =
|
||||
!loop &&
|
||||
currentWordIndex === wordsToAnimate.length - 1 &&
|
||||
currentCharIndex >= currentWordGraphemes.length &&
|
||||
phase !== "deleting"
|
||||
|
||||
const shouldShowCursor =
|
||||
showCursor &&
|
||||
!isComplete &&
|
||||
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||||
|
||||
const getCursorChar = () => {
|
||||
switch (cursorStyle) {
|
||||
case "block":
|
||||
return "▌"
|
||||
case "underscore":
|
||||
return "_"
|
||||
case "line":
|
||||
default:
|
||||
return "|"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"leading-20 tracking-[-0.02em]",
|
||||
Component === "span" && "inline-block",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
{shouldShowCursor && (
|
||||
<span
|
||||
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
|
||||
>
|
||||
{getCursorChar()}
|
||||
</span>
|
||||
)}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { HTMLAttributes, useCallback, useMemo } from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface WarpBackgroundProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode
|
||||
perspective?: number
|
||||
beamsPerSide?: number
|
||||
beamSize?: number
|
||||
beamDelayMax?: number
|
||||
beamDelayMin?: number
|
||||
beamDuration?: number
|
||||
gridColor?: string
|
||||
}
|
||||
|
||||
const Beam = ({
|
||||
width,
|
||||
x,
|
||||
delay,
|
||||
duration,
|
||||
}: {
|
||||
width: string | number
|
||||
x: string | number
|
||||
delay: number
|
||||
duration: number
|
||||
}) => {
|
||||
const hue = Math.floor(Math.random() * 360)
|
||||
const ar = Math.floor(Math.random() * 10) + 1
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={
|
||||
{
|
||||
"--x": `${x}`,
|
||||
"--width": `${width}`,
|
||||
"--aspect-ratio": `${ar}`,
|
||||
"--background": `linear-gradient(hsl(${hue} 80% 60%), transparent)`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`absolute top-0 left-(--x) aspect-[1/var(--aspect-ratio)] w-(--width) [background:var(--background)]`}
|
||||
initial={{ y: "100cqmax", x: "-50%" }}
|
||||
animate={{ y: "-100%", x: "-50%" }}
|
||||
transition={{
|
||||
duration,
|
||||
delay,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const WarpBackground: React.FC<WarpBackgroundProps> = ({
|
||||
children,
|
||||
perspective = 100,
|
||||
className,
|
||||
beamsPerSide = 3,
|
||||
beamSize = 5,
|
||||
beamDelayMax = 3,
|
||||
beamDelayMin = 0,
|
||||
beamDuration = 3,
|
||||
gridColor = "var(--border)",
|
||||
...props
|
||||
}) => {
|
||||
const generateBeams = useCallback(() => {
|
||||
const beams = []
|
||||
const cellsPerSide = Math.floor(100 / beamSize)
|
||||
const step = cellsPerSide / beamsPerSide
|
||||
|
||||
for (let i = 0; i < beamsPerSide; i++) {
|
||||
const x = Math.floor(i * step)
|
||||
const delay = Math.random() * (beamDelayMax - beamDelayMin) + beamDelayMin
|
||||
beams.push({ x, delay })
|
||||
}
|
||||
return beams
|
||||
}, [beamsPerSide, beamSize, beamDelayMax, beamDelayMin])
|
||||
|
||||
const topBeams = useMemo(() => generateBeams(), [generateBeams])
|
||||
const rightBeams = useMemo(() => generateBeams(), [generateBeams])
|
||||
const bottomBeams = useMemo(() => generateBeams(), [generateBeams])
|
||||
const leftBeams = useMemo(() => generateBeams(), [generateBeams])
|
||||
|
||||
return (
|
||||
<div className={cn("relative rounded border p-20", className)} {...props}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--perspective": `${perspective}px`,
|
||||
"--grid-color": gridColor,
|
||||
"--beam-size": `${beamSize}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={
|
||||
"@container-[size] pointer-events-none absolute top-0 left-0 size-full overflow-hidden [clipPath:inset(0)] perspective-(--perspective) transform-3d"
|
||||
}
|
||||
>
|
||||
{/* top side */}
|
||||
<div className="@container absolute z-20 h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
|
||||
{topBeams.map((beam, index) => (
|
||||
<Beam
|
||||
key={`top-${index}`}
|
||||
width={`${beamSize}%`}
|
||||
x={`${beam.x * beamSize}%`}
|
||||
delay={beam.delay}
|
||||
duration={beamDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* bottom side */}
|
||||
<div className="@container absolute top-full h-[100cqmax] w-[100cqi] origin-[50%_0%] transform-[rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
|
||||
{bottomBeams.map((beam, index) => (
|
||||
<Beam
|
||||
key={`bottom-${index}`}
|
||||
width={`${beamSize}%`}
|
||||
x={`${beam.x * beamSize}%`}
|
||||
delay={beam.delay}
|
||||
duration={beamDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* left side */}
|
||||
<div className="@container absolute top-0 left-0 h-[100cqmax] w-[100cqh] origin-[0%_0%] transform-[rotate(90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
|
||||
{leftBeams.map((beam, index) => (
|
||||
<Beam
|
||||
key={`left-${index}`}
|
||||
width={`${beamSize}%`}
|
||||
x={`${beam.x * beamSize}%`}
|
||||
delay={beam.delay}
|
||||
duration={beamDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* right side */}
|
||||
<div className="@container absolute top-0 right-0 h-[100cqmax] w-[100cqh] origin-[100%_0%] transform-[rotate(-90deg)_rotateX(-90deg)] bg-size-[var(--beam-size)_var(--beam-size)] [background:linear-gradient(var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_-0.5px_/var(--beam-size)_var(--beam-size),linear-gradient(90deg,var(--grid-color)_0_1px,transparent_1px_var(--beam-size))_50%_50%_/var(--beam-size)_var(--beam-size)] transform-3d">
|
||||
{rightBeams.map((beam, index) => (
|
||||
<Beam
|
||||
key={`right-${index}`}
|
||||
width={`${beamSize}%`}
|
||||
x={`${beam.x * beamSize}%`}
|
||||
delay={beam.delay}
|
||||
duration={beamDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user