diff --git a/src/App.tsx b/src/App.tsx index d5f4ec3..b809005 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,14 @@ const queryClient = new QueryClient(); const App = () => ( - + diff --git a/src/components/parecer-juridico/ParecerJuridicoDetailView.tsx b/src/components/parecer-juridico/ParecerJuridicoDetailView.tsx index 0ecb75e..7860ad3 100644 --- a/src/components/parecer-juridico/ParecerJuridicoDetailView.tsx +++ b/src/components/parecer-juridico/ParecerJuridicoDetailView.tsx @@ -1,7 +1,11 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useParams, useLocation, useNavigate } from "react-router-dom"; import { + AlignJustify, ArrowLeft, + BookOpen, + ChevronsLeft, + ChevronsRight, Copy, Download, FileDown, @@ -9,8 +13,15 @@ import { History, Info, Loader2, + Maximize2, MessageSquare, + Minimize2, + Minus, + Pause, Pencil, + Play, + Plus, + RotateCcw, Send, Trash2, } from "lucide-react"; @@ -46,6 +57,7 @@ import { parecerService, type ParecerChatHistoricoItem, type ParecerDetalhe, typ import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen"; import { playSuccessSound } from "@/utils/sound"; import { toast } from "sonner"; +import { cn } from "@/lib/utils"; function formatarDataDetalhe(iso: string): string { if (!iso) return "—"; @@ -84,6 +96,60 @@ interface ChatMessage { tipo_resposta?: "chat" | "agente" | null; } +const LS_READER_FONT = "codex-parecer-reader-font-step"; +const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width"; +const LS_MAIN_COMFORTABLE = "codex-parecer-main-comfortable-width"; + +/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */ +const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const; + +const READER_AUTO_SCROLL_LEVEL_LABELS = ["Lento", "Médio-lento", "Médio", "Médio-rápido", "Rápido"] as const; + +const READER_FONT_DEFAULT = 1; + +function readStoredReaderFontStep(): number { + if (typeof window === "undefined") return READER_FONT_DEFAULT; + try { + const raw = window.localStorage.getItem(LS_READER_FONT); + if (raw == null) return READER_FONT_DEFAULT; + const n = Number.parseInt(raw, 10); + if (Number.isNaN(n)) return READER_FONT_DEFAULT; + return Math.min(4, Math.max(0, n)); + } catch { + return READER_FONT_DEFAULT; + } +} + +function readStoredReaderComfortableWidth(): boolean { + if (typeof window === "undefined") return true; + try { + const raw = window.localStorage.getItem(LS_READER_WIDTH); + if (raw == null) return true; + return raw === "1" || raw === "true"; + } catch { + return true; + } +} + +function readStoredMainComfortableWidth(): boolean { + if (typeof window === "undefined") return false; + try { + const raw = window.localStorage.getItem(LS_MAIN_COMFORTABLE); + if (raw == null) return false; + return raw === "1" || raw === "true"; + } catch { + return false; + } +} + +/** Índice 0–4 mapeado para classes `prose-*` do plugin Typography. */ +const READER_PROSE_SCALE = ["prose-sm", "prose-base", "prose-lg", "prose-xl", "prose-2xl"] as const; + +const READER_PROSE_COMMON = + "prose-neutral dark:prose-invert w-full text-foreground prose-headings:font-semibold prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5"; + +const READER_PANEL_FULLSCREEN_ID = "parecer-reader-panel"; + export function ParecerJuridicoDetailView() { const { id } = useParams<{ id: string }>(); const location = useLocation(); @@ -114,6 +180,24 @@ export function ParecerJuridicoDetailView() { const [historicoLoading, setHistoricoLoading] = useState(false); const [historicoItem, setHistoricoItem] = useState(null); const [chatInput, setChatInput] = useState(""); + /** Painel lateral de chat: recolhido dá mais área à leitura do parecer. */ + const [chatPanelExpanded, setChatPanelExpanded] = useState(true); + const [readerModeOpen, setReaderModeOpen] = useState(false); + const [readerFontStep, setReaderFontStep] = useState(readStoredReaderFontStep); + const [readerComfortableWidth, setReaderComfortableWidth] = useState(readStoredReaderComfortableWidth); + const [mainComfortableWidth, setMainComfortableWidth] = useState(readStoredMainComfortableWidth); + const [readerAutoScrollPlaying, setReaderAutoScrollPlaying] = useState(false); + /** Nível 1–5 (x1…x5) para velocidade do auto-scroll. */ + const [readerAutoScrollSpeedLevel, setReaderAutoScrollSpeedLevel] = useState(3); + const readerPanelRef = useRef(null); + const [readerIsFullscreen, setReaderIsFullscreen] = useState(false); + const readerScrollRef = useRef(null); + const readerAutoScrollRafRef = useRef(0); + const readerAutoScrollLastTsRef = useRef(0); + const readerAutoScrollProgrammaticRef = useRef(false); + const readerAutoScrollPlayingRef = useRef(false); + /** Acumula frações de px para velocidades baixas não ficarem presas em scrollTop inteiro. */ + const readerAutoScrollCarryRef = useRef(0); const chatInputRef = useRef(null); const [textareaHeight, setTextareaHeight] = useState(60); const [isDragging, setIsDragging] = useState(false); @@ -140,6 +224,24 @@ export function ParecerJuridicoDetailView() { setEditOpen(true); }, [aplicarDadosAtuaisNoFormularioEdicao]); + const syncReaderFullscreen = useCallback(() => { + setReaderIsFullscreen(document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID); + }, []); + + const toggleReaderFullscreen = useCallback(async () => { + const el = readerPanelRef.current; + if (!el) return; + try { + if (document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID) { + await document.exitFullscreen(); + } else { + await el.requestFullscreen(); + } + } catch { + toast.error("Tela cheia não disponível neste navegador ou foi bloqueada."); + } + }, []); + const MIN_INPUT_HEIGHT = 60; const MAX_INPUT_HEIGHT = 400; @@ -256,6 +358,139 @@ export function ParecerJuridicoDetailView() { return () => cancelAnimationFrame(t); }, [messages]); + useEffect(() => { + try { + window.localStorage.setItem(LS_READER_FONT, String(readerFontStep)); + } catch { + /* ignore */ + } + }, [readerFontStep]); + + useEffect(() => { + try { + window.localStorage.setItem(LS_READER_WIDTH, readerComfortableWidth ? "1" : "0"); + } catch { + /* ignore */ + } + }, [readerComfortableWidth]); + + useEffect(() => { + try { + window.localStorage.setItem(LS_MAIN_COMFORTABLE, mainComfortableWidth ? "1" : "0"); + } catch { + /* ignore */ + } + }, [mainComfortableWidth]); + + useEffect(() => { + readerAutoScrollPlayingRef.current = readerAutoScrollPlaying; + }, [readerAutoScrollPlaying]); + + useEffect(() => { + document.addEventListener("fullscreenchange", syncReaderFullscreen); + return () => document.removeEventListener("fullscreenchange", syncReaderFullscreen); + }, [syncReaderFullscreen]); + + useEffect(() => { + readerAutoScrollCarryRef.current = 0; + }, [readerAutoScrollSpeedLevel]); + + useEffect(() => { + if (!readerModeOpen) { + if (document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID) { + void document.exitFullscreen().catch(() => {}); + } + setReaderIsFullscreen(false); + setReaderAutoScrollPlaying(false); + readerAutoScrollPlayingRef.current = false; + readerAutoScrollLastTsRef.current = 0; + readerAutoScrollCarryRef.current = 0; + if (readerAutoScrollRafRef.current) { + cancelAnimationFrame(readerAutoScrollRafRef.current); + readerAutoScrollRafRef.current = 0; + } + } + }, [readerModeOpen]); + + useEffect(() => { + if (!readerAutoScrollPlaying) readerAutoScrollCarryRef.current = 0; + }, [readerAutoScrollPlaying]); + + useEffect(() => { + const el = readerScrollRef.current; + if (!el || !readerModeOpen) return; + const pause = () => setReaderAutoScrollPlaying(false); + const onScroll = () => { + if (readerAutoScrollProgrammaticRef.current) return; + pause(); + }; + const onWheel = () => pause(); + const onTouchMove = () => pause(); + el.addEventListener("scroll", onScroll, { passive: true }); + el.addEventListener("wheel", onWheel, { passive: true }); + el.addEventListener("touchmove", onTouchMove, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + el.removeEventListener("wheel", onWheel); + el.removeEventListener("touchmove", onTouchMove); + }; + }, [readerModeOpen]); + + useEffect(() => { + if (!readerModeOpen || !readerAutoScrollPlaying) { + if (readerAutoScrollRafRef.current) { + cancelAnimationFrame(readerAutoScrollRafRef.current); + readerAutoScrollRafRef.current = 0; + } + readerAutoScrollLastTsRef.current = 0; + return; + } + const el = readerScrollRef.current; + if (!el) return; + + const levelIdx = Math.min(4, Math.max(0, readerAutoScrollSpeedLevel - 1)); + const speedPxPerSec = READER_AUTO_SCROLL_SPEEDS[levelIdx] ?? READER_AUTO_SCROLL_SPEEDS[2]; + + const tick = (ts: number) => { + const scrollEl = readerScrollRef.current; + if (!scrollEl || !readerAutoScrollPlayingRef.current) return; + + if (!readerAutoScrollLastTsRef.current) readerAutoScrollLastTsRef.current = ts; + const dt = Math.min(80, ts - readerAutoScrollLastTsRef.current) / 1000; + readerAutoScrollLastTsRef.current = ts; + + readerAutoScrollCarryRef.current += speedPxPerSec * dt; + const pixels = Math.floor(readerAutoScrollCarryRef.current); + readerAutoScrollCarryRef.current -= pixels; + if (pixels > 0) { + readerAutoScrollProgrammaticRef.current = true; + scrollEl.scrollTop += pixels; + requestAnimationFrame(() => { + readerAutoScrollProgrammaticRef.current = false; + }); + } + + const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight; + if (maxScroll <= 0 || scrollEl.scrollTop >= maxScroll - 1) { + setReaderAutoScrollPlaying(false); + return; + } + + readerAutoScrollRafRef.current = requestAnimationFrame(tick); + }; + + readerAutoScrollLastTsRef.current = 0; + readerAutoScrollRafRef.current = requestAnimationFrame(tick); + + return () => { + if (readerAutoScrollRafRef.current) { + cancelAnimationFrame(readerAutoScrollRafRef.current); + readerAutoScrollRafRef.current = 0; + } + readerAutoScrollLastTsRef.current = 0; + }; + }, [readerModeOpen, readerAutoScrollPlaying, readerAutoScrollSpeedLevel]); + const titulo = parecer?.titulo ?? preview?.titulo ?? "Parecer"; const conteudoMarkdown = parecer?.conteudo_gerado ?? conteudoAtualizadoChat ?? preview?.conteudoMarkdown ?? ""; @@ -506,6 +741,33 @@ export function ParecerJuridicoDetailView() { )}
+ {!chatPanelExpanded && ( + + )} + + + + Letra menor + + + + {readerFontStep + 1}/5 + + + + + + + Letra maior + + + + + + + + Tamanho padrão + + +
+
+ + +
+ + + + + + {readerIsFullscreen ? "Sair da tela cheia" : "Tela cheia"} + + + + + +
+ {conteudoMarkdown.trim() ? ( +
+ {conteudoMarkdown} +
+ ) : ( +

Ainda não há conteúdo do parecer.

+ )} +
+ +
+
+ Auto-scroll + +
+ + + + + + Velocidade menor (x1 = mais lento) + + +
+ + x{readerAutoScrollSpeedLevel} + + + {READER_AUTO_SCROLL_LEVEL_LABELS[readerAutoScrollSpeedLevel - 1]} + +
+ + + + + + Velocidade maior (x5 = mais rápido) + + +
+ {readerAutoScrollPlaying && ( + Ligado + )} +
+
+ +
+ + + @@ -831,10 +1296,31 @@ export function ParecerJuridicoDetailView() {
-
+
-
+ {conteudoMarkdown.trim() ? ( +
+
+ + + +
+
+ ) : null} +
{conteudoMarkdown.trim() ? ( {conteudoMarkdown} ) : ( @@ -845,10 +1331,34 @@ export function ParecerJuridicoDetailView() {
- + ) : ( + <> +
+ +
+
+ + + + + Abrir chat + + +
+ + )}
diff --git a/src/components/parecer-juridico/ParecerJuridicoFormView.tsx b/src/components/parecer-juridico/ParecerJuridicoFormView.tsx index 90a325c..80d01ef 100644 --- a/src/components/parecer-juridico/ParecerJuridicoFormView.tsx +++ b/src/components/parecer-juridico/ParecerJuridicoFormView.tsx @@ -93,8 +93,7 @@ export function ParecerJuridicoFormView() { !isGenerating && tituloParecer.trim().length > 0 && areaId.trim().length > 0 && - conteudoPrompt.trim().length > 0 && - instrucao.trim().length > 0; + conteudoPrompt.trim().length > 0; const handleGerarParecer = () => { const titulo = tituloParecer.trim(); @@ -102,7 +101,7 @@ export function ParecerJuridicoFormView() { const prompt = conteudoPrompt.trim(); const instr = instrucao.trim(); - if (!titulo || !area || !prompt || !instr) { + if (!titulo || !area || !prompt) { toast.error("Preencha os campos obrigatórios antes de gerar."); return; } @@ -114,7 +113,7 @@ export function ParecerJuridicoFormView() { area_id: area, prompt_id: promptId.trim() || undefined, prompt, - instrucao: instr, + instrucao: instr || undefined, anexo: anexo ?? undefined, }) .then((data) => { @@ -254,21 +253,13 @@ export function ParecerJuridicoFormView() {
- +