Atualizacao visual HGTX Commander
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
# Design System — HGTX Commander
|
||||
|
||||
> Referência técnica de tokens e padrões — light + dark.
|
||||
> **Fonte da verdade (código):** `src/index.css` + `tailwind.config.ts`.
|
||||
> Tema aplicado via `next-themes` (`class` no `<html>`, `storageKey: hgtx-commander-theme`).
|
||||
|
||||
## Cor de marca (azul HGTX)
|
||||
|
||||
Azul institucional usado em botões primários, links, anéis de foco e destaques do Commander.
|
||||
|
||||
| Papel | Light | Dark |
|
||||
|-------|-------|------|
|
||||
| **Primária** | `hsl(210 100% 33%)` · **#0054A8** | `hsl(210 90% 52%)` · **#1685F3** |
|
||||
| Hover | `hsl(210 100% 28%)` · #00478F | `hsl(210 95% 46%)` · #0675E5 |
|
||||
| Pressed | `hsl(210 100% 24%)` · #003D7A | `hsl(210 100% 40%)` · #0066CC |
|
||||
| Fundo sutil | `hsl(210 55% 96%)` · #EFF5FB | `hsl(210 50% 14%)` · #122033 |
|
||||
| Focus ring | `rgba(0, 85, 164, 0.28)` | `rgba(51, 136, 255, 0.35)` |
|
||||
|
||||
Referência histórica de marca: ~`#0055A4` / `#004080` — no código o token `--primary` (light) resolve para **#0054A8**.
|
||||
|
||||
**Tailwind / shadcn:** `bg-primary`, `text-primary`, `border-primary`, `ring-ring` (usa `--ring`, alinhado ao primário).
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Inter — Google Fonts
|
||||
|
||||
Pesos: 400, 500, 600, 700
|
||||
|
||||
Import: `https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap`
|
||||
|
||||
Token: `--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif`
|
||||
|
||||
---
|
||||
|
||||
## Cores
|
||||
|
||||
Valores em **HSL sem vírgula** (formato shadcn: `hsl(var(--token))`). Hex entre parênteses para referência visual.
|
||||
|
||||
### Primária e estados
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|
||||
|-----------|-------------------|------------------|-----------------|-----|
|
||||
| `--primary` | 210 100% 33% · #0054A8 | 210 90% 52% · #1685F3 | `bg-primary` `text-primary` | CTAs, enviar, links ativos |
|
||||
| `--primary-foreground` | 0 0% 100% · #FFFFFF | 0 0% 100% · #FFFFFF | `text-primary-foreground` | Texto sobre primário |
|
||||
| `--primary-hover` | 210 100% 28% · #00478F | 210 95% 46% · #0675E5 | `bg-primary-hover` | Hover botões primários |
|
||||
| `--primary-pressed` | 210 100% 24% · #003D7A | 210 100% 40% · #0066CC | `bg-primary-pressed` | Active / pressed |
|
||||
| `--primary-light` | 210 55% 96% · #EFF5FB | 210 50% 14% · #122033 | `bg-primary-light` | Fundo de destaque suave |
|
||||
| `--ring` | 210 100% 33% | 210 90% 52% | `ring-ring` | Focus visível (inputs) |
|
||||
| `--focus-ring` | rgba(0, 85, 164, 0.28) | rgba(51, 136, 255, 0.35) | sombra manual | Focus em componentes custom |
|
||||
|
||||
### Semânticas (feedback)
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|
||||
|-----------|-------------------|------------------|-----------------|-----|
|
||||
| `--success` | 134 61% 41% · #29A847 | 141 71% 48% · #23D160 | `bg-success` `text-success` | Online, confirmação |
|
||||
| `--success-foreground` | 0 0% 100% | 216 48% 8% | `text-success-foreground` | Texto sobre success |
|
||||
| `--destructive` | 0 84% 60% | 0 84% 60% | `bg-destructive` | Erro, excluir |
|
||||
| `--destructive-foreground` | 0 0% 100% | 0 0% 100% | `text-destructive-foreground` | Texto sobre destructive |
|
||||
| `--warning` | 38 92% 50% | 38 92% 50% | `bg-warning` `text-warning` | Alertas, badge “Pessoal” |
|
||||
| `--warning-foreground` | 209 11% 15% | 216 48% 8% | `text-warning-foreground` | Texto sobre warning |
|
||||
|
||||
### Superfícies (page, card, sidebar)
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|
||||
|-----------|-------------------|------------------|-----------------|-----|
|
||||
| `--background` | 0 0% 100% · #FFFFFF | 216 48% 8% · #0B121E | `bg-background` | Fundo principal / chat |
|
||||
| `--foreground` | 209 11% 15% · #22262A | 0 0% 100% · #FFFFFF | `text-foreground` | Texto principal |
|
||||
| `--card` | 0 0% 100% · #FFFFFF | 216 42% 11% · #101A28 | `bg-card` | Cards, modais, composer |
|
||||
| `--card-foreground` | 209 11% 15% | 0 0% 100% | `text-card-foreground` | Texto em cards |
|
||||
| `--popover` | 0 0% 100% | 216 42% 11% | `bg-popover` | Dropdowns, selects |
|
||||
| `--sidebar-background` | 210 17% 98% · #F8FAFB | 216 50% 9% · #0B1522 | `bg-sidebar` | Sidebar Commander / módulos |
|
||||
| `--sidebar-foreground` | 209 11% 15% | 0 0% 100% | `text-sidebar-foreground` | Texto na sidebar |
|
||||
| `--sidebar-border` | 220 13% 91% · #E5E7EB | 212 12% 21% · #2F363F | `border-sidebar-border` | Divisor da sidebar |
|
||||
| `--sidebar-accent` | 210 70% 94% · #E3EEFC | 210 45% 16% · #16283D | `bg-sidebar-accent` | Item de nav ativo / hover |
|
||||
| `--sidebar-accent-foreground` | 210 100% 33% | 210 85% 62% · #5BA3EB | `text-sidebar-accent-foreground` | Texto no item ativo |
|
||||
| `--muted` | 210 17% 96% · #F3F6F8 | 216 38% 13% · #15202E | `bg-muted` | Fundos secundários |
|
||||
| `--accent` | 210 70% 94% | 210 45% 16% | `bg-accent` | Hover suave, chips |
|
||||
| `--accent-foreground` | 210 100% 33% | 210 85% 62% | `text-accent-foreground` | Texto em accent |
|
||||
|
||||
### Tokens extras (chat e UI)
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Uso |
|
||||
|-----------|-------------------|------------------|-----|
|
||||
| `--bubble-bot` | 209 36% 96% | 216 36% 14% | Bolha do assistente |
|
||||
| `--nav-active` | 210 70% 94% | 210 45% 16% | Nav ativo (alias visual do accent) |
|
||||
| `--input-bg` | 0 0% 100% | 216 40% 10% | Fundo de campos (referência) |
|
||||
| `--secondary` | 210 6% 60% | 216 38% 13% | Neutro secundário (evitar texto em badge dark) |
|
||||
| `--secondary-foreground` | 209 11% 15% | 0 0% 100% | Texto sobre secondary |
|
||||
|
||||
### Bordas e inputs (shadcn)
|
||||
|
||||
No shadcn, `--input` é a **cor da borda** de Input / Select / Textarea, não o fundo do campo.
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|
||||
|-----------|-------------------|------------------|-----------------|-----|
|
||||
| `--border` | 220 13% 91% · #E5E7EB | 216 18% 26% · #36404E | `border-border` | Divisores, cards |
|
||||
| `--input` | 220 9% 83% · #D0D2D8 | 216 16% 30% · #404A59 | `border-input` | Borda de inputs |
|
||||
| `--border-subtle` | 220 13% 95% | 216 22% 20% | custom | Bordas muito suaves |
|
||||
| `--btn-secondary-border` | 220 9% 83% | 216 16% 30% | custom | Outline secundário |
|
||||
|
||||
### Texto secundário
|
||||
|
||||
| Token CSS | Light (HSL · HEX) | Dark (HSL · HEX) | Classe Tailwind | Uso |
|
||||
|-----------|-------------------|------------------|-----------------|-----|
|
||||
| `--muted-foreground` | 209 7% 46% · #6D767E | 212 9% 58% · #8B949E | `text-muted-foreground` | Subtítulos, hints |
|
||||
| `--placeholder` | 220 9% 66% | 212 10% 31% | `text-placeholder` / `placeholder:` | Placeholders |
|
||||
|
||||
### Gradientes e glow (efeitos)
|
||||
|
||||
| Token | Light | Dark |
|
||||
|-------|-------|------|
|
||||
| `--gradient-cyber` | `135deg`, hsl(210 100% 33%) → hsl(210 90% 42%) | hsl(210 90% 52%) → hsl(210 100% 40%) |
|
||||
| `--gradient-subtle` | #FFF → hsl(210 17% 98%) | hsl(216 42% 11%) → hsl(216 48% 8%) |
|
||||
| `--glow-cyan` / `--glow-blue` | sombra azul suave 12–15% opac. | sombra azul 28–35% opac. |
|
||||
|
||||
Classes utilitárias: `.gradient-text`, `.cyber-glow`, `.glass-effect` em `src/index.css`.
|
||||
|
||||
---
|
||||
|
||||
## Tipografia
|
||||
|
||||
> Mesma escala em light e dark.
|
||||
|
||||
| Nível | Tamanho | Peso | Line Height | Classe Semântica |
|
||||
|-------|---------|------|-------------|------------------|
|
||||
| Display / Commander | 32px (`--fs-display`) | 700 | 1.25 (`--lh-tight`) | text-display |
|
||||
| Heading 1 — Modal | 24px (`--fs-h1`) | 600 | 1.25 | text-h1 |
|
||||
| Heading 2 — Seção | 18px (`--fs-h2`) | 600 | 1.5 (`--lh-body`) | text-h2 |
|
||||
| Heading 3 | 15px (`--fs-h3`) | — | — | text-h3 |
|
||||
| Body | 14px (`--fs-body`) | 400 | 1.5 | text-body |
|
||||
| Small / Timestamp | 12px (`--fs-small`) | 400 | — | text-small |
|
||||
| Caption | 11px (`--fs-caption`) | 500 | — | text-caption |
|
||||
| Nav link | 13px | 500 (`--fw-medium`) | — | text-nav-link |
|
||||
| Nav brand | 15px | 700 (`--fw-bold`) | — | text-nav-brand |
|
||||
| Brand lockup (tagline) | 10px | 600 | — | text-brand-tag |
|
||||
| Brand lockup (nome) | 16px | 700 | — | text-brand-name |
|
||||
| Hero título (catálogo) | clamp(28px, 4vw, 42px) | 700 | 1.25 | text-hero |
|
||||
| Section title | 28px | 700 | — | text-section-title |
|
||||
|
||||
Tokens de peso: `--fw-regular` 400, `--fw-medium` 500, `--fw-semibold` 600, `--fw-bold` 700.
|
||||
Tokens de line-height: `--lh-tight` 1.25, `--lh-body` 1.5, `--lh-relaxed` 1.65.
|
||||
|
||||
---
|
||||
|
||||
## Espaçamento
|
||||
|
||||
Base unit: **4px**
|
||||
|
||||
| Token | Valor | Classe Semântica |
|
||||
|-------|-------|------------------|
|
||||
| --space-1 | 4px | p-1 / m-1 / gap-1 |
|
||||
| --space-2 | 8px | p-2 / m-2 / gap-2 |
|
||||
| --space-3 | 12px | p-3 / m-3 / gap-3 |
|
||||
| --space-4 | 16px | p-4 / m-4 / gap-4 |
|
||||
| --space-5 | 20px | p-5 / m-5 / gap-5 |
|
||||
| --space-6 | 24px | p-6 / m-6 / gap-6 |
|
||||
| --space-7 | 32px | p-7 / m-7 / gap-7 |
|
||||
| --space-8 | 48px | p-8 / m-8 / gap-8 |
|
||||
| --space-9 | 64px | p-9 / m-9 / gap-9 |
|
||||
| --section-y | 64px | py-section |
|
||||
| --section-x | 40px | px-section |
|
||||
| --nav-h | 56px | h-nav |
|
||||
| --container | 1200px | max-w-container |
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
| Token | Valor | Classe Semântica | Uso |
|
||||
|-------|-------|------------------|-----|
|
||||
| --radius-sm | 6px | rounded-sm | Chat items, detalhes |
|
||||
| --radius-md | 8px | rounded-md | Botões, inputs, nav items, badges |
|
||||
| --radius-lg | 12px | rounded-lg | Cards, bolhas, demo wrap |
|
||||
| --radius-xl | 16px | rounded-xl | Modal Personalidade |
|
||||
| --radius-pill | 999px | rounded-pill | Chat bar (composer) |
|
||||
| 50% | — | rounded-full | Botão send circular |
|
||||
|
||||
---
|
||||
|
||||
## Sombras
|
||||
|
||||
| Token CSS | Light | Dark | Classe Tailwind | Uso |
|
||||
|-----------|-------|------|-----------------|-----|
|
||||
| `--shadow-card` | `0 4px 12px rgba(0,0,0,0.06)` | `0 4px 16px rgba(0,0,0,0.35)` | `shadow-card` | Cards elevados |
|
||||
| `--shadow-modal` | `0 16px 40px rgba(0,0,0,0.12)` | `0 24px 48px rgba(0,0,0,0.55)` | `shadow-modal` | Modais, dialogs |
|
||||
|
||||
Glow Online (badge): `box-shadow: 0 0 8px hsl(var(--success) / 0.5)`.
|
||||
|
||||
---
|
||||
|
||||
## Componentes
|
||||
|
||||
> Use referências a tokens — valores resolvem para light/dark conforme o tema ativo.
|
||||
|
||||
### Botão Primary (HGTX — Nova Sessão / Salvar Perfil)
|
||||
```
|
||||
bg-primary hover:bg-primary-hover active:bg-primary-pressed
|
||||
text-white rounded-md px-[18px] py-[10px]
|
||||
text-[13px] font-semibold inline-flex items-center gap-2
|
||||
border-none cursor-pointer transition-[background] duration-200
|
||||
disabled:opacity-45 disabled:cursor-not-allowed
|
||||
```
|
||||
|
||||
### Botão Primary (catálogo / hero)
|
||||
```
|
||||
bg-primary hover:bg-primary-hover active:bg-primary-pressed active:translate-y-px
|
||||
text-white rounded-md px-6 py-3 text-sm font-semibold
|
||||
border-none cursor-pointer transition-all duration-200
|
||||
```
|
||||
|
||||
### Botão Outline (Nova Pasta)
|
||||
```
|
||||
bg-transparent text-primary border border-btn-secondary
|
||||
rounded-md px-[18px] py-[10px] text-[13px] font-medium
|
||||
inline-flex items-center gap-2 cursor-pointer
|
||||
hover:border-secondary transition-[border-color] duration-200
|
||||
```
|
||||
|
||||
### Botão Secondary (catálogo)
|
||||
```
|
||||
bg-transparent text-primary border border-btn-secondary
|
||||
rounded-md px-6 py-3 text-sm font-medium
|
||||
hover:border-secondary hover:bg-black/[0.03] (light) / hover:bg-white/[0.04] (dark)
|
||||
transition-all duration-200 cursor-pointer
|
||||
```
|
||||
|
||||
### Botão Ghost (Cancelar)
|
||||
```
|
||||
bg-transparent text-secondary border-none rounded-md px-[14px] py-2
|
||||
text-[13px] cursor-pointer
|
||||
hover:text-primary hover:bg-black/[0.04] (light) / hover:bg-white/[0.05] (dark)
|
||||
transition-all duration-200
|
||||
```
|
||||
|
||||
### Botão Send (circular)
|
||||
```
|
||||
w-10 h-10 rounded-full bg-primary text-white border-none
|
||||
flex items-center justify-center text-base cursor-pointer
|
||||
```
|
||||
|
||||
### Nav Sticky (catálogo DS)
|
||||
```
|
||||
sticky top-0 z-[100] h-nav flex items-center justify-between px-section
|
||||
bg-nav backdrop-blur-[12px] border-b border-divider
|
||||
```
|
||||
|
||||
### Nav CTA
|
||||
```
|
||||
bg-primary hover:bg-primary-hover text-white rounded-md
|
||||
px-[18px] py-2 text-[13px] font-semibold border-none cursor-pointer
|
||||
```
|
||||
|
||||
### Nav Item (sidebar)
|
||||
```
|
||||
flex items-center gap-3 px-3 py-2 rounded-md text-[13px] text-secondary
|
||||
```
|
||||
|
||||
### Nav Item Ativo
|
||||
```
|
||||
bg-nav-active text-primary
|
||||
```
|
||||
|
||||
### Nav Chat Item (lista conversas)
|
||||
```
|
||||
flex justify-between items-center px-3 py-2 text-xs text-secondary rounded-sm
|
||||
/* timestamp */ text-[11px] text-placeholder
|
||||
```
|
||||
|
||||
### Input / Select / Textarea
|
||||
```
|
||||
font-sans text-sm px-[14px] py-[10px] rounded-md
|
||||
border border-divider bg-input text-primary outline-none w-full
|
||||
placeholder:text-placeholder
|
||||
transition-[border-color,box-shadow] duration-200
|
||||
focus:border-primary focus:shadow-[0_0_0_3px_var(--color-focus-ring)]
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
```
|
||||
|
||||
### Textarea (modal)
|
||||
```
|
||||
[classes input] min-h-[120px] resize-y
|
||||
```
|
||||
|
||||
### Input Search (com ícone)
|
||||
```
|
||||
relative /* wrap */
|
||||
[input] pl-9
|
||||
/* ícone */ absolute left-3 top-1/2 -translate-y-1/2 text-placeholder text-sm
|
||||
```
|
||||
|
||||
### Chat Composer Bar
|
||||
```
|
||||
flex items-center gap-3 py-2 pr-2 pl-5
|
||||
border border-divider rounded-pill bg-input max-w-[560px]
|
||||
/* input interno */ flex-1 border-none bg-transparent text-sm text-primary outline-none placeholder:text-placeholder
|
||||
/* + btn-send */
|
||||
```
|
||||
|
||||
### Badge Online
|
||||
```
|
||||
inline-flex items-center gap-2 text-small font-medium text-secondary
|
||||
/* dot */ w-2 h-2 rounded-full bg-success shadow-[0_0_8px_rgba(35,209,96,0.5)]
|
||||
```
|
||||
|
||||
### Badge Self-Hosted
|
||||
```
|
||||
inline-flex items-center gap-2 text-caption text-placeholder
|
||||
px-3 py-2 border border-divider rounded-md
|
||||
```
|
||||
|
||||
### Card Modal (Personalidade)
|
||||
```
|
||||
max-w-[480px] p-6 bg-surface-card border border-divider rounded-xl shadow-modal
|
||||
```
|
||||
|
||||
### Card Modal Header
|
||||
```
|
||||
flex justify-between items-start mb-4
|
||||
/* título */ text-h2 font-semibold
|
||||
```
|
||||
|
||||
### Card Modal Close
|
||||
```
|
||||
w-8 h-8 border-none rounded-md bg-transparent text-secondary text-lg cursor-pointer
|
||||
hover:text-primary hover:bg-black/[0.05] (light) / hover:bg-white/[0.06] (dark)
|
||||
```
|
||||
|
||||
### Card Modal Actions
|
||||
```
|
||||
flex justify-end gap-3 mt-5
|
||||
/* Cancelar: btn-hgtx-ghost | Salvar: btn-hgtx-primary */
|
||||
```
|
||||
|
||||
### Bolha Bot (boas-vindas)
|
||||
```
|
||||
max-w-[420px] px-5 py-4 bg-bubble-bot rounded-lg
|
||||
text-sm text-secondary leading-relaxed
|
||||
```
|
||||
|
||||
### Layout Demo (3 colunas)
|
||||
```
|
||||
grid grid-cols-[200px_280px_1fr] border border-divider rounded-lg overflow-hidden min-h-[320px]
|
||||
/* sidebars */ bg-sidebar p-4 border-r border-divider
|
||||
/* main */ bg-page p-5 flex flex-col items-center justify-center
|
||||
```
|
||||
|
||||
### Brand Lockup (sidebar)
|
||||
```
|
||||
text-[10px] font-bold uppercase tracking-[0.12em] text-secondary mb-4
|
||||
/* nome */ block text-sm text-primary tracking-[0.06em]
|
||||
```
|
||||
|
||||
### Folder Label (sidebar)
|
||||
```
|
||||
text-xs font-medium text-secondary py-2 mt-3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tema — como aplicar
|
||||
|
||||
1. **Tokens light** em `:root` e **tokens dark** em `.dark` — ver `src/index.css`.
|
||||
2. **App:** `ThemeProvider` (`next-themes`) com `attribute="class"`, `storageKey="hgtx-commander-theme"`, temas `light` | `dark`.
|
||||
3. **Tailwind:** cores semânticas em `tailwind.config.ts` → `hsl(var(--primary))`, etc.
|
||||
|
||||
Tokens **compartilhados** (não mudam com o tema): `--fs-*`, `--space-*`, `--radius-*`, `--fw-*`, `--lh-*`, `--section-*`, `--nav-h`, `--container`.
|
||||
|
||||
Texto sobre botões primários: sempre `--primary-foreground` (`#FFFFFF`).
|
||||
|
||||
### Mapeamento rápido (legado → atual)
|
||||
|
||||
| Doc / HTML antigo | Token atual |
|
||||
|-------------------|-------------|
|
||||
| `#0061ff` / `#1a73e8` | `--primary` → #0054A8 (light) / #1685F3 (dark) |
|
||||
| `bg-page` | `bg-background` |
|
||||
| `bg-sidebar` | `bg-sidebar` (`--sidebar-background`) |
|
||||
| `text-secondary` (corpo) | `text-muted-foreground` |
|
||||
| `border-divider` | `border-border` |
|
||||
|
||||
---
|
||||
|
||||
*Atualizado para refletir `src/index.css` (HGTX Commander redesign).*
|
||||
*Para uso como referência no Cursor e agentes de código.*
|
||||
+5
-5
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HGTX Codex - AI Interface System</title>
|
||||
<meta name="description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||
<meta name="author" content="HGTX Codex" />
|
||||
<title>HGTX Commander</title>
|
||||
<meta name="description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
|
||||
<meta name="author" content="HGTX Commander" />
|
||||
|
||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
||||
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
||||
<meta property="og:title" content="HGTX Commander" />
|
||||
<meta property="og:description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="/favicon.png" />
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^12.36.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
+12
-34
@@ -2,29 +2,14 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import Index from "./pages/Index";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Redirect from "./pages/Redirect";
|
||||
import React from "react";
|
||||
import CommanderApp from "./modules/commander/App";
|
||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||
import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
|
||||
|
||||
const INTELLIGENCE_SCORE_PREFIX = "/intelligence-score";
|
||||
const FECHAMENTO_LEGACY_PREFIX = "/fechamento-hgtx";
|
||||
const FECHAMENTO_PREFIX = "/fechamento";
|
||||
|
||||
/** Redireciona URLs antigas para `/intelligence-score/...` (mesmo sufixo, query e hash). */
|
||||
function FechamentoLegacyRedirect() {
|
||||
const location = useLocation();
|
||||
const fromPrefix = location.pathname.startsWith(FECHAMENTO_LEGACY_PREFIX)
|
||||
? FECHAMENTO_LEGACY_PREFIX
|
||||
: FECHAMENTO_PREFIX;
|
||||
const tail = location.pathname.slice(fromPrefix.length);
|
||||
const to = `${INTELLIGENCE_SCORE_PREFIX}${tail}`;
|
||||
return <Navigate to={`${to}${location.search}${location.hash}`} replace />;
|
||||
}
|
||||
import IntelligenceScoreApp from "./modules/intelligence-score/App";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -35,7 +20,7 @@ const App = () => (
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark"]}
|
||||
enableSystem={false}
|
||||
storageKey="hgtx-codex-theme"
|
||||
storageKey="hgtx-commander-theme"
|
||||
enableColorScheme
|
||||
>
|
||||
<TooltipProvider>
|
||||
@@ -43,20 +28,15 @@ const App = () => (
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/redir/:gsikey"
|
||||
element={<Redirect />}
|
||||
/>
|
||||
<Route path="/redir/:gsikey" element={<Redirect />} />
|
||||
|
||||
<Route path="/commander/*" element={<CommanderApp />} />
|
||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
||||
<Route path="/intelligence-score/*" element={<FechamentoHgtxApp />} />
|
||||
<Route path="/fechamento/*" element={<FechamentoLegacyRedirect />} />
|
||||
<Route path="/fechamento-hgtx/*" element={<FechamentoLegacyRedirect />} />
|
||||
<Route path="/intelligence-score/*" element={<IntelligenceScoreApp />} />
|
||||
|
||||
<Route path="/*" element={<Index />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
<Route path="/" element={<Navigate to="/commander" replace />} />
|
||||
<Route path="/404" element={<NotFound />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
@@ -66,12 +46,10 @@ const App = () => (
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
const HGTXLogin = () =>{
|
||||
|
||||
const HGTXLogin = () => {
|
||||
React.useEffect(() => {
|
||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||
});
|
||||
|
||||
return(<></>);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ChatHeader = ({
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm px-3 md:px-6 py-3 md:py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse-glow" />
|
||||
<div className="w-2 h-2 rounded-full bg-success animate-online-pulse" />
|
||||
<span className="text-sm text-muted-foreground">Online</span>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const ChatHeader = ({
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block text-sm text-muted-foreground">
|
||||
{showModelSelector ? "Modelo de linguagem avançado" : "Sistema HGTX Codex"}
|
||||
{showModelSelector ? "Modelo de linguagem avançado" : "HGTX Commander"}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
||||
<p className="text-xs text-muted-foreground">Preenchimento automático.</p>
|
||||
{!estabelecimentoId ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-200">
|
||||
Não foi possível obter o código do estabelecimento. Abra o Fechamento HGTX a partir do Codex com o
|
||||
Não foi possível obter o código do estabelecimento. Abra o Intelligence Score a partir do Commander com o
|
||||
estabelecimento carregado no transfer.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -4,22 +4,43 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { chatService } from "@/services/chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (message: string, files?: File[]) => void;
|
||||
draftMessage?: string | null;
|
||||
draftTrigger?: number;
|
||||
onDraftApplied?: () => void;
|
||||
}
|
||||
|
||||
export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
const MOBILE_TEXTAREA_HEIGHT = 88;
|
||||
const DEFAULT_TEXTAREA_HEIGHT = 72;
|
||||
|
||||
export const ChatInput = ({
|
||||
onSendMessage,
|
||||
draftMessage,
|
||||
draftTrigger,
|
||||
onDraftApplied,
|
||||
}: ChatInputProps) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const [textareaHeight, setTextareaHeight] = useState(60);
|
||||
const [textareaHeight, setTextareaHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const dragStartY = useRef<number>(0);
|
||||
const dragStartHeight = useRef<number>(0);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
update();
|
||||
mq.addEventListener("change", update);
|
||||
return () => mq.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
setIsDragging(true);
|
||||
dragStartY.current = e.clientY;
|
||||
@@ -30,9 +51,9 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaY = dragStartY.current - e.clientY; // Inverted: moving up = positive
|
||||
const newHeight = Math.min(400, Math.max(60, dragStartHeight.current + deltaY));
|
||||
|
||||
const deltaY = dragStartY.current - e.clientY;
|
||||
const newHeight = Math.min(400, Math.max(DEFAULT_TEXTAREA_HEIGHT, dragStartHeight.current + deltaY));
|
||||
setTextareaHeight(newHeight);
|
||||
};
|
||||
|
||||
@@ -41,16 +62,30 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, textareaHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draftMessage || draftTrigger == null) return;
|
||||
|
||||
setMessage(draftMessage);
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.focus();
|
||||
const end = draftMessage.length;
|
||||
textarea.setSelectionRange(end, end);
|
||||
});
|
||||
onDraftApplied?.();
|
||||
}, [draftMessage, draftTrigger, onDraftApplied]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (message.trim() || attachedFiles.length > 0) {
|
||||
onSendMessage(message, attachedFiles);
|
||||
@@ -70,7 +105,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const newFiles = [...attachedFiles, ...files];
|
||||
|
||||
// Valida quantidade máxima de anexos (5)
|
||||
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||
toast({
|
||||
title: "Limite de anexos excedido",
|
||||
@@ -83,10 +117,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Valida tipos de arquivo e tamanho
|
||||
const validFiles: File[] = [];
|
||||
for (const file of files) {
|
||||
// Valida tamanho (max 10MB por arquivo)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({
|
||||
title: "Arquivo muito grande",
|
||||
@@ -99,7 +131,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// Valida formatos permitidos
|
||||
const allFiles = [...attachedFiles, ...validFiles];
|
||||
const validation = chatService.validateAttachments(allFiles);
|
||||
|
||||
@@ -125,96 +156,108 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
||||
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const canSend = Boolean(message.trim() || attachedFiles.length > 0);
|
||||
const textareaStyleHeight = isMobile ? MOBILE_TEXTAREA_HEIGHT : textareaHeight;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
||||
<div className="max-w-4xl mx-auto space-y-2 md:space-y-3">
|
||||
{/* Attached Files Preview */}
|
||||
<div className="w-full border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-2 px-2 md:space-y-3 md:px-4">
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 md:gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1 md:py-1.5 rounded-lg bg-muted border border-border text-xs md:text-sm"
|
||||
className="flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/80 px-3 py-1.5 text-xs md:text-sm"
|
||||
>
|
||||
<Paperclip className="w-3 h-3 text-primary" />
|
||||
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
||||
<span className="hidden md:inline text-xs text-muted-foreground">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium">{file.name}</span>
|
||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 ml-1"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeFile(index)}
|
||||
aria-label={`Remover ${file.name}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Input and Buttons */}
|
||||
<div className="flex gap-1 md:gap-2 items-end">
|
||||
<div className="flex-1 glass-effect rounded-xl overflow-hidden">
|
||||
{/* Resize Handle - Hidden on mobile */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
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"
|
||||
>
|
||||
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
style={{ height: window.innerWidth < 768 ? '80px' : `${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 md:text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="glass-effect overflow-hidden rounded-2xl border border-border/60 shadow-sm">
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"hidden h-5 w-full cursor-ns-resize items-center justify-center border-b border-border/40 transition-colors md:flex",
|
||||
isDragging ? "bg-primary/15" : "hover:bg-primary/8",
|
||||
)}
|
||||
title="Arraste para aumentar a altura"
|
||||
>
|
||||
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1 md:gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept={chatService.getAllowedFileExtensions()}
|
||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||
<div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
style={{ height: textareaStyleHeight }}
|
||||
className="min-h-0 w-full resize-none border-0 bg-transparent p-0 text-sm focus-visible:ring-0 focus-visible:ring-offset-0 md:text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border/50 bg-muted/15 px-2 py-2 md:px-3 md:py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept={chatService.getAllowedFileExtensions()}
|
||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Anexar arquivo"
|
||||
aria-label="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
{attachedFiles.length > 0 && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{attachedFiles.length} anexo{attachedFiles.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
|
||||
disabled={!message.trim() && attachedFiles.length === 0}
|
||||
disabled={!canSend}
|
||||
className="h-9 w-9 shrink-0 md:h-10 md:w-10"
|
||||
title="Enviar mensagem"
|
||||
aria-label="Enviar mensagem"
|
||||
>
|
||||
<Send className="w-4 h-4 md:w-5 md:h-5" />
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()} anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||
<p className="hidden text-center text-xs text-muted-foreground md:block">
|
||||
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()}{" "}
|
||||
anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+384
-359
@@ -1,5 +1,18 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Folder, FolderPlus, MessageSquare, MoreVertical, Trash2, Edit, FolderInput, ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
MessageSquare,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Inbox,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -11,6 +24,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -33,6 +47,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -42,7 +57,19 @@ interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
}
|
||||
|
||||
export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelectChat, currentChatId }: ChatSidebarProps) => {
|
||||
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const ChatSidebar = ({
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
currentChatId,
|
||||
}: ChatSidebarProps) => {
|
||||
const { toast } = useToast();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||
@@ -56,49 +83,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Estado carregado do banco de dados via chatService
|
||||
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||
/** Pastas fechadas por padrão; estado preservado ao recarregar lista */
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Carrega chats e pastas do banco de dados quando o componente monta
|
||||
useEffect(() => {
|
||||
loadChatsAndFolders();
|
||||
|
||||
// Escuta evento customizado para recarregar quando chat é atualizado
|
||||
const handleChatUpdate = () => {
|
||||
loadChatsAndFolders();
|
||||
};
|
||||
|
||||
window.addEventListener('chatUpdated', handleChatUpdate);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener('chatUpdated', handleChatUpdate);
|
||||
};
|
||||
window.addEventListener("chatUpdated", handleChatUpdate);
|
||||
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
|
||||
}, []);
|
||||
|
||||
const loadChatsAndFolders = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await chatService.getChatsAndFolders();
|
||||
|
||||
console.log('Dados carregados:', data);
|
||||
|
||||
setChats(data.chats);
|
||||
setFolders(data.folders);
|
||||
|
||||
// Expande todas as pastas por padrão
|
||||
setExpandedFolders(new Set(data.folders.map(f => f.id)));
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao carregar chats e pastas:', error);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
|
||||
console.error("Erro ao carregar chats e pastas:", error);
|
||||
toast({
|
||||
title: "Erro ao carregar dados",
|
||||
description: error.message || "Não foi possível carregar chats e pastas.",
|
||||
description: message,
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// Define arrays vazios em caso de erro
|
||||
setChats([]);
|
||||
setFolders([]);
|
||||
} finally {
|
||||
@@ -107,48 +121,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (newFolderName.trim()) {
|
||||
try {
|
||||
await chatService.createFolder(newFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setNewFolderName("");
|
||||
setIsCreateFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta criada",
|
||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao criar pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao criar pasta",
|
||||
description: error.message || "Não foi possível criar a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
if (!newFolderName.trim()) return;
|
||||
const name = newFolderName.trim();
|
||||
try {
|
||||
await chatService.createFolder(name);
|
||||
await loadChatsAndFolders();
|
||||
setNewFolderName("");
|
||||
setIsCreateFolderOpen(false);
|
||||
toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível criar a pasta.";
|
||||
toast({ title: "Erro ao criar pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = async () => {
|
||||
if (deletingFolder) {
|
||||
try {
|
||||
await chatService.deleteFolder(deletingFolder.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingFolder(null);
|
||||
setIsDeleteFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta excluída",
|
||||
description: "A pasta foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao excluir pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir pasta",
|
||||
description: error.message || "Não foi possível excluir a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
if (!deletingFolder) return;
|
||||
try {
|
||||
await chatService.deleteFolder(deletingFolder.id);
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(deletingFolder.id);
|
||||
return next;
|
||||
});
|
||||
await loadChatsAndFolders();
|
||||
setDeletingFolder(null);
|
||||
setIsDeleteFolderOpen(false);
|
||||
toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
|
||||
toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,79 +166,52 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleRenameFolder = async () => {
|
||||
if (renamingFolder && renamedFolderName.trim()) {
|
||||
try {
|
||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setRenamingFolder(null);
|
||||
setRenamedFolderName("");
|
||||
setIsRenameFolderOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Pasta renomeada",
|
||||
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao renomear pasta:', error);
|
||||
toast({
|
||||
title: "Erro ao renomear pasta",
|
||||
description: error.message || "Não foi possível renomear a pasta. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
if (!renamingFolder || !renamedFolderName.trim()) return;
|
||||
try {
|
||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||
await loadChatsAndFolders();
|
||||
setRenamingFolder(null);
|
||||
setRenamedFolderName("");
|
||||
setIsRenameFolderOpen(false);
|
||||
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
|
||||
toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(folderId)) {
|
||||
newExpanded.delete(folderId);
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderId)) next.delete(folderId);
|
||||
else next.add(folderId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||
try {
|
||||
await chatService.moveChatToFolder(chatId, folderId);
|
||||
await loadChatsAndFolders();
|
||||
|
||||
const folder = folders.find(f => f.id === folderId);
|
||||
toast({
|
||||
title: "Chat movido",
|
||||
description: `Movido para a pasta "${folder?.name}".`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao mover chat:', error);
|
||||
toast({
|
||||
title: "Erro ao mover chat",
|
||||
description: error.message || "Não foi possível mover o chat. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
const folder = folders.find((f) => f.id === folderId);
|
||||
toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
|
||||
toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChat = async () => {
|
||||
if (deletingChat) {
|
||||
try {
|
||||
await chatService.deleteChat(deletingChat.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingChat(null);
|
||||
setIsDeleteChatOpen(false);
|
||||
|
||||
toast({
|
||||
title: "Chat excluído",
|
||||
description: "A conversa foi excluída com sucesso.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Erro ao excluir chat:', error);
|
||||
toast({
|
||||
title: "Erro ao excluir chat",
|
||||
description: error.message || "Não foi possível excluir o chat. Tente novamente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
if (!deletingChat) return;
|
||||
try {
|
||||
await chatService.deleteChat(deletingChat.id);
|
||||
await loadChatsAndFolders();
|
||||
setDeletingChat(null);
|
||||
setIsDeleteChatOpen(false);
|
||||
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
|
||||
toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,287 +221,318 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
||||
};
|
||||
|
||||
const handleSelectChat = (chat: ChatRecord) => {
|
||||
if (onSelectChat) {
|
||||
onSelectChat(chat);
|
||||
}
|
||||
onSelectChat?.(chat);
|
||||
};
|
||||
|
||||
// Filtrar chats pela busca
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
||||
return titleMatch;
|
||||
});
|
||||
const filteredChats = chats.filter((chat) =>
|
||||
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Separar chats sem pasta (folder_id é null)
|
||||
const chatsWithoutFolder = filteredChats.filter((c) => c.folder_id === null);
|
||||
|
||||
// Agrupar chats por pasta (quando folder_id === folder.id)
|
||||
const chatsByFolder = folders.reduce((acc, folder) => {
|
||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||
return acc;
|
||||
}, {} as Record<string, ChatRecord[]>);
|
||||
const chatsByFolder = folders.reduce(
|
||||
(acc, folder) => {
|
||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ChatRecord[]>
|
||||
);
|
||||
|
||||
const visibleFolders = folders.filter(
|
||||
(folder) =>
|
||||
(chatsByFolder[folder.id]?.length ?? 0) > 0 ||
|
||||
!searchQuery.trim() ||
|
||||
folder.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="w-12 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col items-center py-4">
|
||||
<div className="flex h-full w-12 flex-col items-center border-r border-border bg-sidebar/50 py-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleCollapse}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Expandir conversas"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
||||
{/* Header with collapse button */}
|
||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
||||
<div className="flex h-full w-80 max-w-80 min-w-0 shrink-0 flex-col overflow-hidden border-r border-border bg-sidebar/40">
|
||||
{/* Cabeçalho */}
|
||||
<div className="flex min-w-0 shrink-0 items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleCollapse}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
title="Recolher"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 pb-3 border-b border-border space-y-3">
|
||||
{/* Busca + ações */}
|
||||
<div className="min-w-0 shrink-0 space-y-2 border-b border-border px-3 py-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Pesquisar conversas..."
|
||||
className="pl-9 bg-muted/50"
|
||||
className="h-9 bg-background/80 pl-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2 cyber-glow" variant="default" onClick={onNewChat}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Novo Chat
|
||||
</Button>
|
||||
|
||||
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full gap-2" size="sm">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Nova Pasta
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Nova Pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Organize suas conversas em pastas personalizadas.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="folder-name">Nome da Pasta</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button className="h-9 gap-1.5 text-sm" variant="default" onClick={onNewChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo chat
|
||||
</Button>
|
||||
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="h-9 gap-1.5 text-sm">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Pasta
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova pasta</DialogTitle>
|
||||
<DialogDescription>Organize suas conversas em pastas.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="folder-name">Nome</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Ex: Projetos, Estudos..."
|
||||
placeholder="Ex: Projetos, Clientes..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||
Criar Pasta
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Rename Folder Dialog */}
|
||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Renomear Pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
||||
<Input
|
||||
id="rename-folder-name"
|
||||
value={renamedFolderName}
|
||||
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||
placeholder="Ex: Projetos, Estudos..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||
Renomear
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Folder Alert */}
|
||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteFolder} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||
Criar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||
<DialogContent className="border-border bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Renomear pasta</DialogTitle>
|
||||
<DialogDescription>
|
||||
Novo nome para "{renamingFolder?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="rename-folder-name">Nome</Label>
|
||||
<Input
|
||||
id="rename-folder-name"
|
||||
value={renamedFolderName}
|
||||
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||
Salvar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||
<AlertDialogContent className="border-border bg-card">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A pasta "{deletingFolder?.name}" será excluída. As conversas dentro dela
|
||||
ficarão sem pasta.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteFolder}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Lista */}
|
||||
<ScrollArea className="min-h-0 w-full min-w-0 flex-1 overflow-hidden">
|
||||
<div className="box-border w-full max-w-full min-w-0 space-y-1 overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Folders */}
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
|
||||
<Folder className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-medium text-sm flex-1 text-left">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{chatsByFolder[folder.id]?.length || 0}
|
||||
</span>
|
||||
</button>
|
||||
{visibleFolders.length > 0 && (
|
||||
<section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
|
||||
{visibleFolders.map((folder) => {
|
||||
const folderChats = chatsByFolder[folder.id] ?? [];
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
<DropdownMenuItem
|
||||
className="gap-2"
|
||||
onClick={() => openRenameFolder(folder)}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
Renomear Pasta
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onClick={() => openDeleteFolder(folder)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Excluir Pasta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
return (
|
||||
<div key={folder.id} className="min-w-0">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-sidebar-accent/50"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||
!isExpanded && "-rotate-90"
|
||||
)}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-primary/90" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||
{folderChats.length}
|
||||
</span>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
<DropdownMenuItem className="gap-2" onClick={() => openRenameFolder(folder)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Renomear
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive focus:text-destructive"
|
||||
onClick={() => openDeleteFolder(folder)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Excluir pasta
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
||||
<div className="ml-6 space-y-1">
|
||||
{chatsByFolder[folder.id].map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{isExpanded && (
|
||||
<div className="min-w-0 space-y-0.5 py-0.5 pl-5">
|
||||
{folderChats.length > 0 ? (
|
||||
folderChats.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
nested
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Chats without folder */}
|
||||
{chatsWithoutFolder.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Sem Pasta
|
||||
<section
|
||||
className={cn(
|
||||
"w-full min-w-0 max-w-full overflow-hidden",
|
||||
visibleFolders.length > 0 && "mt-3 border-t border-border/40 pt-3"
|
||||
)}
|
||||
>
|
||||
<SectionLabel>Conversas</SectionLabel>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
{chatsWithoutFolder.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{chatsWithoutFolder.map((chat) => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isSelected={currentChatId === chat.id}
|
||||
onSelect={() => handleSelectChat(chat)}
|
||||
folders={folders}
|
||||
onMoveToFolder={moveToFolder}
|
||||
onDelete={() => openDeleteChat(chat)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
|
||||
<Inbox className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Nenhuma conversa</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Crie um novo chat para começar
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && chats.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhuma conversa ainda
|
||||
{!isLoading &&
|
||||
chats.length > 0 &&
|
||||
filteredChats.length === 0 &&
|
||||
searchQuery.trim() && (
|
||||
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum resultado para "{searchQuery}"
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Clique em "Novo Chat" para começar
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Delete Chat Alert */}
|
||||
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
||||
<AlertDialogContent className="border-border bg-card">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
||||
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
||||
"{deletingChat?.title}" será excluída permanentemente.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteChat} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteChat}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -543,6 +549,7 @@ interface ChatItemProps {
|
||||
folders: FolderRecord[];
|
||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||
onDelete: () => void;
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
const ChatItem = ({
|
||||
@@ -552,8 +559,8 @@ const ChatItem = ({
|
||||
folders,
|
||||
onMoveToFolder,
|
||||
onDelete,
|
||||
nested = false,
|
||||
}: ChatItemProps) => {
|
||||
// Formata timestamp relativo
|
||||
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
@@ -561,19 +568,34 @@ const ChatItem = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
|
||||
isSelected
|
||||
? "bg-sidebar-accent cyber-border"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group flex w-full max-w-full min-w-0 cursor-pointer items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 transition-colors",
|
||||
nested && "py-1",
|
||||
isSelected
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 max-w-[180px]">
|
||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
isSelected ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium leading-snug" title={chat.title}>
|
||||
{chat.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
||||
<p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
|
||||
{timeAgo}
|
||||
</p>
|
||||
</div>
|
||||
@@ -583,29 +605,32 @@ const ChatItem = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
className="h-7 w-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="Opções da conversa"
|
||||
>
|
||||
<MoreVertical className="w-3 h-3" />
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
||||
{/* Opções para mover para pastas */}
|
||||
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
className="gap-2"
|
||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
Mover para {folder.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
<DropdownMenuItem
|
||||
className="gap-2 text-destructive"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
{folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
|
||||
<>
|
||||
{folders
|
||||
.filter((f) => f.id !== chat.folder_id)
|
||||
.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
className="gap-2"
|
||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5" />
|
||||
Mover para {folder.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Excluir
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -3,6 +3,13 @@ import { ChatHeader } from "@/components/ChatHeader";
|
||||
import { ChatMessage } from "./ChatMessage";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { ChatWelcome } from "./ChatWelcome";
|
||||
import {
|
||||
createWelcomeMessage,
|
||||
isWelcomeMessage,
|
||||
withoutWelcomeMessages,
|
||||
} from "./welcome-message";
|
||||
import type { Message } from "./ChatView.types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -11,18 +18,6 @@ import { getModelId } from "@/config/models";
|
||||
// Texto padrão da personalidade (usado para novos chats)
|
||||
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
model?: string;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const ChatView = () => {
|
||||
const { toast } = useToast();
|
||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||
@@ -31,16 +26,17 @@ export const ChatView = () => {
|
||||
const [currentChatId, setCurrentChatId] = useState("0");
|
||||
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: "GPT-4o",
|
||||
},
|
||||
]);
|
||||
const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [inputDraft, setInputDraft] = useState<{
|
||||
text: string;
|
||||
at: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleSelectSuggestion = (prompt: string) => {
|
||||
setInputDraft({ text: prompt, at: Date.now() });
|
||||
};
|
||||
|
||||
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
||||
// quando enviadas via handleSendMessage
|
||||
@@ -82,14 +78,7 @@ export const ChatView = () => {
|
||||
// Reseta a personalidade para o texto padrão
|
||||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
setMessages([createWelcomeMessage()]);
|
||||
|
||||
// Dispara evento para atualizar a sidebar
|
||||
window.dispatchEvent(new Event('chatUpdated'));
|
||||
@@ -136,14 +125,7 @@ export const ChatView = () => {
|
||||
});
|
||||
|
||||
// Inicia com mensagem padrão em caso de erro
|
||||
setMessages([
|
||||
{
|
||||
id: "1",
|
||||
role: "assistant",
|
||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
||||
model: selectedModel,
|
||||
},
|
||||
]);
|
||||
setMessages([createWelcomeMessage()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -162,7 +144,7 @@ export const ChatView = () => {
|
||||
})),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, newMessage]);
|
||||
setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -234,7 +216,7 @@ export const ChatView = () => {
|
||||
return (
|
||||
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||
{/* Chat Sidebar - Hidden on mobile */}
|
||||
<div className="hidden md:block">
|
||||
<div className="hidden w-80 max-w-80 shrink-0 min-w-0 overflow-hidden md:block">
|
||||
<ChatSidebar
|
||||
isCollapsed={isChatSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||
@@ -256,15 +238,22 @@ export const ChatView = () => {
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
model={message.model}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
))}
|
||||
{messages.map((message) =>
|
||||
isWelcomeMessage(message) ? (
|
||||
<ChatWelcome
|
||||
key={message.id}
|
||||
onSelectSuggestion={handleSelectSuggestion}
|
||||
/>
|
||||
) : (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
model={message.model}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-4 py-6 px-6 bg-muted/30">
|
||||
@@ -281,7 +270,12 @@ export const ChatView = () => {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<ChatInput onSendMessage={handleSendMessage} />
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
draftMessage={inputDraft?.text}
|
||||
draftTrigger={inputDraft?.at}
|
||||
onDraftApplied={() => setInputDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
model?: string;
|
||||
attachments?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DiaTextReveal } from "@/components/ui/dia-text-reveal";
|
||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
||||
import {
|
||||
CHAT_STARTER_SUGGESTIONS,
|
||||
COMMANDER_REVEAL_COLORS,
|
||||
} from "./welcome-message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
interface ChatWelcomeProps {
|
||||
className?: string;
|
||||
onSelectSuggestion?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function ChatWelcome({ className, onSelectSuggestion }: ChatWelcomeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[min(420px,55vh)] flex-col items-center justify-center px-4 py-10 text-center md:px-6 md:py-16",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 h-1 w-12 rounded-full bg-primary/40" aria-hidden />
|
||||
|
||||
<h2 className="mb-4 text-3xl font-bold tracking-tight md:text-4xl">
|
||||
<DiaTextReveal
|
||||
text="HGTX Commander"
|
||||
colors={COMMANDER_REVEAL_COLORS}
|
||||
textColor="hsl(var(--foreground))"
|
||||
duration={1.35}
|
||||
className="text-inherit"
|
||||
/>
|
||||
</h2>
|
||||
|
||||
<p className="max-w-md text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
<DiaTextReveal
|
||||
text="Como posso ajudá-lo hoje?"
|
||||
colors={COMMANDER_REVEAL_COLORS}
|
||||
textColor="hsl(var(--muted-foreground))"
|
||||
duration={1.15}
|
||||
delay={0.4}
|
||||
className="text-inherit"
|
||||
/>
|
||||
</p>
|
||||
|
||||
{onSelectSuggestion && (
|
||||
<div className="mt-8 w-full max-w-2xl animate-fade-in space-y-3">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary/80" aria-hidden />
|
||||
<AnimatedShinyText
|
||||
className="mx-0 max-w-none text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
shimmerWidth={120}
|
||||
>
|
||||
Ideias para começar
|
||||
</AnimatedShinyText>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-stretch justify-center gap-2">
|
||||
{CHAT_STARTER_SUGGESTIONS.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSuggestion(suggestion.prompt)}
|
||||
title={suggestion.prompt}
|
||||
className={cn(
|
||||
"max-w-full rounded-full border border-border/80 bg-background/70 px-4 py-2.5 text-left text-sm font-medium text-foreground",
|
||||
"shadow-sm transition-all duration-200",
|
||||
"hover:border-primary/40 hover:bg-sidebar-accent/60 hover:shadow-md",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"active:scale-[0.98]"
|
||||
)}
|
||||
>
|
||||
<span className="line-clamp-2">{suggestion.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
Toque em uma sugestão para preencher a mensagem — você pode editar antes de enviar.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Message } from "./ChatView.types";
|
||||
|
||||
export const CHAT_WELCOME_MESSAGE_ID = "welcome";
|
||||
|
||||
/** Paleta alinhada ao azul HGTX Commander */
|
||||
export const COMMANDER_REVEAL_COLORS = [
|
||||
"#003366",
|
||||
"#0055a4",
|
||||
"#0066b3",
|
||||
"#4d9de0",
|
||||
"#a8c8f0",
|
||||
];
|
||||
|
||||
export function createWelcomeMessage(): Message {
|
||||
return {
|
||||
id: CHAT_WELCOME_MESSAGE_ID,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isWelcomeMessage(message: Message): boolean {
|
||||
return message.id === CHAT_WELCOME_MESSAGE_ID;
|
||||
}
|
||||
|
||||
export function withoutWelcomeMessages(messages: Message[]): Message[] {
|
||||
return messages.filter((m) => !isWelcomeMessage(m));
|
||||
}
|
||||
|
||||
export type ChatStarterSuggestion = {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export const CHAT_STARTER_SUGGESTIONS: ChatStarterSuggestion[] = [
|
||||
{
|
||||
id: "executive-summary",
|
||||
label: "Resumo com pendências",
|
||||
prompt:
|
||||
"Vou colar um texto ou documento abaixo. Produza um resumo executivo em tópicos, separe fatos de interpretações e liste pendências ou riscos que exijam decisão.",
|
||||
},
|
||||
{
|
||||
id: "meeting-minutes",
|
||||
label: "Ata da reunião",
|
||||
prompt:
|
||||
"Com base no conteúdo que enviarei, redija uma ata executiva: contexto em uma linha, decisões tomadas, responsáveis, prazos e pontos que ficaram em aberto.",
|
||||
},
|
||||
{
|
||||
id: "audio-structure",
|
||||
label: "Organizar transcrição",
|
||||
prompt:
|
||||
"Tenho uma transcrição de áudio para colar em seguida. Organize por tópicos cronológicos, destaque compromissos assumidos e sugira follow-ups que não foram fechados na conversa.",
|
||||
},
|
||||
{
|
||||
id: "structured-reasoning",
|
||||
label: "Linha de raciocínio",
|
||||
prompt:
|
||||
"Preciso estruturar um raciocínio sobre o tema que descreverei. Monte hipóteses, argumentos a favor e contra, e indique quais informações ainda faltam para fechar uma conclusão sólida.",
|
||||
},
|
||||
{
|
||||
id: "compare-options",
|
||||
label: "Comparar alternativas",
|
||||
prompt:
|
||||
"Vou descrever duas ou mais alternativas em seguida. Compare critérios objetivos, trade-offs e riscos de cada uma, e indique em que cenário cada opção faz mais sentido — sem favorecer uma resposta só pelo tom.",
|
||||
},
|
||||
{
|
||||
id: "professional-draft",
|
||||
label: "Comunicado profissional",
|
||||
prompt:
|
||||
"Com os pontos que enviarei abaixo, redija um e-mail ou comunicado interno: tom profissional e direto, assunto sugerido, corpo estruturado e uma ação clara no encerramento.",
|
||||
},
|
||||
];
|
||||
@@ -106,7 +106,7 @@ export function UnidadeGate({ children }: UnidadeGateProps) {
|
||||
<CardContent className="space-y-4 text-center text-muted-foreground">
|
||||
<p>
|
||||
O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
|
||||
HGTX Intelligence Score a partir do Codex com o estabelecimento carregado no transfer.
|
||||
HGTX Intelligence Score a partir do Commander com o estabelecimento carregado no transfer.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -96,9 +96,9 @@ 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";
|
||||
const LS_READER_FONT = "commander-parecer-reader-font-step";
|
||||
const LS_READER_WIDTH = "commander-parecer-reader-comfortable-width";
|
||||
const LS_MAIN_COMFORTABLE = "commander-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;
|
||||
@@ -588,7 +588,7 @@ export function ParecerJuridicoDetailView() {
|
||||
.then((res) => {
|
||||
toast.success(res.message);
|
||||
setDeleteOpen(false);
|
||||
navigate("/codex/parecer-juridico");
|
||||
navigate("/commander/parecer-juridico");
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
||||
@@ -672,7 +672,7 @@ export function ParecerJuridicoDetailView() {
|
||||
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">
|
||||
<Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -697,7 +697,7 @@ export function ParecerJuridicoDetailView() {
|
||||
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">
|
||||
<Button variant="ghost" onClick={() => navigate("/commander/parecer-juridico")} className="w-fit gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -721,7 +721,7 @@ export function ParecerJuridicoDetailView() {
|
||||
<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 flex-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/codex/parecer-juridico")} className="shrink-0">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/commander/parecer-juridico")} className="shrink-0">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function ParecerJuridicoFormView() {
|
||||
.then((data) => {
|
||||
setIsGenerating(false);
|
||||
playSuccessSound();
|
||||
navigate(`/codex/parecer-juridico/${data.id}`, {
|
||||
navigate(`/commander/parecer-juridico/${data.id}`, {
|
||||
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
||||
});
|
||||
})
|
||||
@@ -136,11 +136,11 @@ export function ParecerJuridicoFormView() {
|
||||
{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 h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
|
||||
<div className="shrink-0 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">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/commander/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">
|
||||
@@ -151,8 +151,8 @@ export function ParecerJuridicoFormView() {
|
||||
</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="flex-1 min-h-0 overflow-y-auto overscroll-y-contain p-3 md:p-6">
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
||||
<span>Título do parecer</span>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ParecerJuridicoView = () => {
|
||||
};
|
||||
|
||||
const handleEditar = (item: ParecerListItem) => {
|
||||
navigate(`/codex/parecer-juridico/${item.id}`);
|
||||
navigate(`/commander/parecer-juridico/${item.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -117,7 +117,7 @@ export const ParecerJuridicoView = () => {
|
||||
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">
|
||||
<Button onClick={() => navigate("/commander/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>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { AnimatedGradientText } from "@/components/ui/animated-gradient-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PromptAssistantButtonProps = {
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
asBadge?: boolean;
|
||||
};
|
||||
|
||||
function useAssistantPalette() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return {
|
||||
isDark,
|
||||
textFrom: isDark ? "hsl(210, 55%, 88%)" : "hsl(210, 100%, 28%)",
|
||||
textTo: isDark ? "hsl(210, 70%, 72%)" : "hsl(210, 75%, 40%)",
|
||||
};
|
||||
}
|
||||
|
||||
const shellClass = (opts: { asBadge?: boolean; disabled?: boolean; interactive?: boolean }) =>
|
||||
cn(
|
||||
"inline-flex items-center gap-2.5 rounded-full border transition-all duration-300",
|
||||
"border-primary/20 bg-gradient-to-br from-primary/[0.07] via-card to-accent/30",
|
||||
"shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_4px_16px_hsl(var(--primary)/0.06)]",
|
||||
opts.asBadge ? "px-3.5 py-1.5" : "min-h-10 px-4 py-2",
|
||||
opts.interactive &&
|
||||
!opts.disabled &&
|
||||
"hover:border-primary/35 hover:from-primary/[0.1] hover:shadow-[0_2px_8px_hsl(var(--foreground)/0.05),0_8px_24px_hsl(var(--primary)/0.12)] hover:-translate-y-px active:translate-y-0",
|
||||
opts.disabled && "opacity-60"
|
||||
);
|
||||
|
||||
export function PromptAssistantButton({
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
className,
|
||||
asBadge = false,
|
||||
}: PromptAssistantButtonProps) {
|
||||
const palette = useAssistantPalette();
|
||||
|
||||
const label = loading ? (
|
||||
<span className="text-sm font-medium text-muted-foreground">Gerando em segundo plano...</span>
|
||||
) : (
|
||||
<AnimatedGradientText
|
||||
speed={0.75}
|
||||
colorFrom={palette.textFrom}
|
||||
colorTo={palette.textTo}
|
||||
className="text-sm font-medium tracking-tight"
|
||||
>
|
||||
Assistente de criação de prompt
|
||||
</AnimatedGradientText>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary",
|
||||
asBadge ? "h-6 w-6" : "h-7 w-7"
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<Sparkles className={asBadge ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
</span>
|
||||
{label}
|
||||
</>
|
||||
);
|
||||
|
||||
if (asBadge) {
|
||||
return <span className={cn(shellClass({ asBadge: true }), className)}>{content}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={shellClass({ disabled: disabled || loading, interactive: true })}>{content}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,8 @@ 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 { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
|
||||
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePrompts } from "@/contexts/PromptsContext";
|
||||
@@ -85,7 +82,7 @@ export function PromptsFormView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (id && !prompt && prompts.length > 0) {
|
||||
navigate("/codex/prompts", { replace: true });
|
||||
navigate("/commander/prompts", { replace: true });
|
||||
}
|
||||
}, [id, prompt, prompts.length, navigate]);
|
||||
|
||||
@@ -143,7 +140,7 @@ export function PromptsFormView() {
|
||||
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");
|
||||
navigate("/commander/prompts");
|
||||
} catch (e: unknown) {
|
||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
||||
toast.error(message);
|
||||
@@ -224,7 +221,7 @@ export function PromptsFormView() {
|
||||
/>
|
||||
<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")}>
|
||||
<Button variant="ghost" size="sm" className="w-fit gap-2" onClick={() => navigate("/commander/prompts")}>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
@@ -277,27 +274,11 @@ export function PromptsFormView() {
|
||||
title="Gerando em andamento"
|
||||
/>
|
||||
)}
|
||||
<ShimmerButton
|
||||
type="button"
|
||||
<PromptAssistantButton
|
||||
onClick={() => setIsAssistantOpen(true)}
|
||||
disabled={isAssistantWorkingInBackground}
|
||||
background="hsl(var(--card))"
|
||||
shimmerColor="hsl(var(--foreground) / 0.14)"
|
||||
className={cn(
|
||||
"gap-2 px-5 py-2.5 text-sm font-medium text-card-foreground border-border shadow-md hover:shadow-lg transition-shadow",
|
||||
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
{isAssistantWorkingInBackground ? (
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
Gerando em segundo plano...
|
||||
</span>
|
||||
) : (
|
||||
<SparklesText sparklesCount={5} className="text-sm font-medium text-card-foreground">
|
||||
✨ Assistente de Criação de Prompt
|
||||
</SparklesText>
|
||||
)}
|
||||
</ShimmerButton>
|
||||
loading={isAssistantWorkingInBackground}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -327,21 +308,10 @@ export function PromptsFormView() {
|
||||
)}
|
||||
|
||||
<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">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col gap-6 overflow-hidden rounded-xl border border-border/80 bg-card p-8 shadow-modal">
|
||||
<DialogHeader className="space-y-4">
|
||||
<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>
|
||||
<PromptAssistantButton asBadge />
|
||||
</div>
|
||||
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
||||
<DialogDescription className="text-base text-muted-foreground">
|
||||
@@ -400,7 +370,7 @@ export function PromptsFormView() {
|
||||
</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}>
|
||||
<Button variant="outline" onClick={() => setIsAssistantOpen(false)} disabled={isAssistantLoading}>
|
||||
Fechar
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -127,7 +127,7 @@ export const PromptsView = () => {
|
||||
const openEditFromDetails = () => {
|
||||
if (!selectedPrompt) return;
|
||||
setIsDetailsDialogOpen(false);
|
||||
navigate(`/codex/prompts/${selectedPrompt.id}`);
|
||||
navigate(`/commander/prompts/${selectedPrompt.id}`);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (prompt: Prompt) => {
|
||||
@@ -168,7 +168,7 @@ export const PromptsView = () => {
|
||||
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">
|
||||
<Button onClick={() => navigate("/commander/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>
|
||||
@@ -231,7 +231,7 @@ export const PromptsView = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<Button onClick={() => navigate("/codex/prompts/novo")}>
|
||||
<Button onClick={() => navigate("/commander/prompts/novo")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Novo prompt
|
||||
</Button>
|
||||
@@ -287,7 +287,7 @@ export const PromptsView = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
|
||||
onClick={() => navigate(`/commander/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"
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ComponentPropsWithoutRef } from "react"
|
||||
import { type ComponentPropsWithoutRef, type CSSProperties } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
|
||||
speed?: number
|
||||
colorFrom?: string
|
||||
colorTo?: string
|
||||
@@ -12,23 +12,24 @@ export function AnimatedGradientText({
|
||||
children,
|
||||
className,
|
||||
speed = 1,
|
||||
colorFrom = "#ffaa40",
|
||||
colorTo = "#9c40ff",
|
||||
colorFrom = "#0055a4",
|
||||
colorTo = "#0d8ecf",
|
||||
style,
|
||||
...props
|
||||
}: AnimatedGradientTextProps) {
|
||||
const bgSize = `${speed * 300}%`
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("animated-gradient-text inline-block font-semibold", className)}
|
||||
style={
|
||||
{
|
||||
"--bg-size": `${speed * 300}%`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
} as React.CSSProperties
|
||||
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
|
||||
backgroundSize: `${bgSize} 100%`,
|
||||
animation: "gradient-text-shift 12s ease-in-out infinite",
|
||||
...style,
|
||||
} as 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}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
|
||||
const BAND_HALF = 17
|
||||
const SWEEP_START = -BAND_HALF
|
||||
const SWEEP_END = 100 + BAND_HALF
|
||||
|
||||
const sweepEase = (t: number) =>
|
||||
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
||||
const bandStart = pos - BAND_HALF
|
||||
const bandEnd = pos + BAND_HALF
|
||||
|
||||
if (bandStart >= 100) {
|
||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`
|
||||
}
|
||||
const n = colors.length
|
||||
const parts: string[] = []
|
||||
|
||||
if (bandStart > 0)
|
||||
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
|
||||
|
||||
colors.forEach((c, i) => {
|
||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
|
||||
parts.push(`${c} ${pct.toFixed(2)}%`)
|
||||
})
|
||||
|
||||
if (bandEnd < 100)
|
||||
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
}
|
||||
|
||||
function measureWidths(el: HTMLElement, texts: string[]) {
|
||||
const ghost = el.cloneNode() as HTMLElement
|
||||
Object.assign(ghost.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
width: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
})
|
||||
el.parentElement!.appendChild(ghost)
|
||||
const widths = texts.map((t) => {
|
||||
ghost.textContent = t
|
||||
return ghost.getBoundingClientRect().width
|
||||
})
|
||||
ghost.remove()
|
||||
return widths
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link DiaTextReveal}.
|
||||
*/
|
||||
export interface DiaTextRevealProps extends Omit<
|
||||
HTMLMotionProps<"span">,
|
||||
"ref" | "children" | "style" | "animate" | "transition" | "color"
|
||||
> {
|
||||
/**
|
||||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
||||
*/
|
||||
text: string | string[]
|
||||
/**
|
||||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
||||
*/
|
||||
colors?: string[]
|
||||
/**
|
||||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
||||
* @defaultValue `"var(--foreground)"`
|
||||
*/
|
||||
textColor?: string
|
||||
/**
|
||||
* Duration of one sweep pass, in seconds.
|
||||
* @defaultValue `1.5`
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Delay before the sweep starts, in seconds.
|
||||
* @defaultValue `0`
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
repeat?: boolean
|
||||
/**
|
||||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
||||
* @defaultValue `0.5`
|
||||
*/
|
||||
repeatDelay?: number
|
||||
/**
|
||||
* If `true`, the animation starts only after the element enters the viewport.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
once?: boolean
|
||||
/**
|
||||
* Additional class names for the animated `span` (e.g. typography utilities).
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
}
|
||||
|
||||
export function DiaTextReveal({
|
||||
text,
|
||||
colors = DEFAULT_COLORS,
|
||||
textColor = "var(--foreground)",
|
||||
duration = 1.5,
|
||||
delay = 0,
|
||||
repeat = false,
|
||||
repeatDelay = 0.5,
|
||||
startOnView = true,
|
||||
once = true,
|
||||
className,
|
||||
fixedWidth = false,
|
||||
...props
|
||||
}: DiaTextRevealProps) {
|
||||
const texts = Array.isArray(text) ? text : [text]
|
||||
const isMulti = texts.length > 1
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const spanRef = useRef<HTMLSpanElement>(null)
|
||||
const optsRef = useRef({
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
})
|
||||
optsRef.current = {
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
}
|
||||
|
||||
const indexRef = useRef(0)
|
||||
const hasPlayedRef = useRef(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const playRef = useRef<() => void>(null!)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
|
||||
useEffect(() => {
|
||||
const el = spanRef.current
|
||||
if (!el || !isMulti) return
|
||||
setMeasuredWidths(measureWidths(el, texts))
|
||||
}, [Array.isArray(text) ? text.join("\0") : text])
|
||||
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
|
||||
const controls = animate(sweepPos, SWEEP_END, {
|
||||
duration,
|
||||
delay,
|
||||
ease: sweepEase,
|
||||
onComplete() {
|
||||
if (!repeat) return
|
||||
timerRef.current = setTimeout(() => {
|
||||
const next = (indexRef.current + 1) % texts.length
|
||||
indexRef.current = next
|
||||
setActiveIndex(next)
|
||||
playRef.current()
|
||||
}, repeatDelay * 1000)
|
||||
},
|
||||
})
|
||||
|
||||
stopRef.current = () => controls.stop()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
sweepPos.set(SWEEP_END)
|
||||
return
|
||||
}
|
||||
if (startOnView && !isInView) return
|
||||
if (once && hasPlayedRef.current) return
|
||||
hasPlayedRef.current = true
|
||||
playRef.current()
|
||||
|
||||
return () => {
|
||||
stopRef.current?.()
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={spanRef}
|
||||
className={cn("align-bottom leading-[100%] text-inherit", className)}
|
||||
style={{
|
||||
transform: "translateY(-2px)",
|
||||
color: "transparent",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundImage,
|
||||
...(isMulti && {
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "text-center",
|
||||
...(fixedW != null && { width: fixedW }),
|
||||
}),
|
||||
}}
|
||||
animate={animatedW != null ? { width: animatedW } : undefined}
|
||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||||
{...props}
|
||||
>
|
||||
{texts[activeIndex]}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,9 @@ const ScrollArea = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block [&>div]:min-w-0 [&>div]:w-full [&>div]:max-w-full">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
@@ -36,3 +38,4 @@ const ScrollBar = React.forwardRef<
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
|
||||
+181
-71
@@ -6,62 +6,123 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* HGTX Codex - Light Theme */
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
/* ─── HGTX Commander — Design System Tokens (compartilhados) ─── */
|
||||
|
||||
/* Tipografia */
|
||||
--fs-display: 32px;
|
||||
--fs-h1: 24px;
|
||||
--fs-h2: 18px;
|
||||
--fs-h3: 15px;
|
||||
--fs-body: 14px;
|
||||
--fs-small: 12px;
|
||||
--fs-caption: 11px;
|
||||
--lh-tight: 1.25;
|
||||
--lh-body: 1.5;
|
||||
--lh-relaxed: 1.65;
|
||||
--fw-regular: 400;
|
||||
--fw-medium: 500;
|
||||
--fw-semibold: 600;
|
||||
--fw-bold: 700;
|
||||
|
||||
/* Espaçamento */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-7: 32px;
|
||||
--space-8: 48px;
|
||||
--space-9: 64px;
|
||||
--section-y: 64px;
|
||||
--section-x: 40px;
|
||||
--nav-h: 56px;
|
||||
--container: 1200px;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ─── Light Theme ─── */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 209 11% 15%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--card-foreground: 209 11% 15%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--popover-foreground: 209 11% 15%;
|
||||
|
||||
--primary: 190 100% 45%;
|
||||
/* Azul primário vivo (referência ~#004080 / #0055a4) */
|
||||
--primary: 210 100% 33%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-hover: 210 100% 28%;
|
||||
--primary-pressed: 210 100% 24%;
|
||||
--primary-light: 210 55% 96%;
|
||||
|
||||
--secondary: 210 100% 45%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
/* Secondary neutro para botões */
|
||||
--secondary: 210 6% 60%;
|
||||
--secondary-foreground: 209 11% 15%;
|
||||
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
/* Muted — bg suave */
|
||||
--muted: 210 17% 96%;
|
||||
--muted-foreground: 209 7% 46%;
|
||||
|
||||
--accent: 190 100% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
/* Accent = fundo nav ativo */
|
||||
--accent: 210 70% 94%;
|
||||
--accent-foreground: 210 100% 33%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 71% 45%;
|
||||
/* Sucesso: #28a745 */
|
||||
--success: 134 61% 41%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 222 47% 11%;
|
||||
--warning-foreground: 209 11% 15%;
|
||||
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 190 100% 45%;
|
||||
/* Borda: #e5e7eb */
|
||||
--border: 220 13% 91%;
|
||||
/* --input no shadcn = cor da BORDA de Input/Select/Textarea */
|
||||
--input: 220 9% 83%;
|
||||
--ring: 210 100% 33%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Sidebar specific - Light */
|
||||
--sidebar-background: 210 40% 99%;
|
||||
--sidebar-foreground: 222 47% 11%;
|
||||
--sidebar-primary: 190 100% 45%;
|
||||
/* Sidebar — Light */
|
||||
--sidebar-background: 210 17% 98%;
|
||||
--sidebar-foreground: 209 11% 15%;
|
||||
--sidebar-primary: 210 100% 33%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 40% 95%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 88%;
|
||||
--sidebar-ring: 190 100% 45%;
|
||||
--sidebar-accent: 210 70% 94%;
|
||||
--sidebar-accent-foreground: 210 100% 33%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 210 100% 33%;
|
||||
|
||||
/* Custom gradients - Light */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 45%) 0%, hsl(210 100% 45%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(210 40% 95%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.15);
|
||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.12);
|
||||
/* Tokens extras — Light */
|
||||
--bubble-bot: 209 36% 96%;
|
||||
--nav-active: 210 70% 94%;
|
||||
--input-bg: 0 0% 100%;
|
||||
--placeholder: 220 9% 66%;
|
||||
--btn-secondary-border: 220 9% 83%;
|
||||
--border-subtle: 220 13% 95%;
|
||||
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||
--shadow-modal: 0 16px 40px rgba(0, 0, 0, 0.12);
|
||||
--focus-ring: rgba(0, 85, 164, 0.28);
|
||||
|
||||
/* Gradientes — Light */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(210 100% 33%) 0%, hsl(210 90% 42%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(0 0% 100%) 0%, hsl(210 17% 98%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(210 100% 33% / 0.15);
|
||||
--glow-blue: 0 0 30px hsl(210 90% 42% / 0.12);
|
||||
|
||||
/* Animation speeds */
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-bounce: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
@@ -70,62 +131,85 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* HGTX Codex - Dark Theme */
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 200 100% 95%;
|
||||
/* ─── Dark Theme ─── */
|
||||
/* Fundo principal = mesma família da sidebar (216 50% 9%), levemente mais profundo */
|
||||
--background: 216 48% 8%;
|
||||
--foreground: 0 0% 100%;
|
||||
|
||||
--card: 222 40% 8%;
|
||||
--card-foreground: 200 100% 95%;
|
||||
/* Cards/painéis um tom acima do fundo para hierarquia sutil */
|
||||
--card: 216 42% 11%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
|
||||
--popover: 222 45% 10%;
|
||||
--popover-foreground: 200 100% 95%;
|
||||
--popover: 216 42% 11%;
|
||||
--popover-foreground: 0 0% 100%;
|
||||
|
||||
--primary: 190 100% 50%;
|
||||
--primary-foreground: 222 47% 5%;
|
||||
/* Azul primário dark — vibrante em fundo escuro */
|
||||
--primary: 210 90% 52%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-hover: 210 95% 46%;
|
||||
--primary-pressed: 210 100% 40%;
|
||||
--primary-light: 210 50% 14%;
|
||||
|
||||
--secondary: 210 100% 50%;
|
||||
--secondary-foreground: 222 47% 5%;
|
||||
--secondary: 216 38% 13%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
|
||||
--muted: 222 30% 15%;
|
||||
--muted-foreground: 200 20% 65%;
|
||||
--muted: 216 38% 13%;
|
||||
--muted-foreground: 212 9% 58%;
|
||||
|
||||
--accent: 190 100% 60%;
|
||||
--accent-foreground: 222 47% 5%;
|
||||
/* Accent dark */
|
||||
--accent: 210 45% 16%;
|
||||
--accent-foreground: 210 85% 62%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 200 100% 95%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 71% 45%;
|
||||
--success-foreground: 222 47% 5%;
|
||||
/* Sucesso dark: #23d160 */
|
||||
--success: 141 71% 48%;
|
||||
--success-foreground: 216 48% 8%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 222 47% 5%;
|
||||
--warning-foreground: 216 48% 8%;
|
||||
|
||||
--border: 222 30% 18%;
|
||||
--input: 222 30% 18%;
|
||||
--ring: 190 100% 50%;
|
||||
/* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
|
||||
--border: 216 18% 26%;
|
||||
/* --input no shadcn = cor da BORDA de Input/Select/Textarea (não o fundo) */
|
||||
--input: 216 16% 30%;
|
||||
--ring: 210 90% 52%;
|
||||
|
||||
/* Sidebar specific - Dark */
|
||||
--sidebar-background: 222 45% 7%;
|
||||
--sidebar-foreground: 200 100% 90%;
|
||||
--sidebar-primary: 190 100% 50%;
|
||||
--sidebar-primary-foreground: 222 47% 5%;
|
||||
--sidebar-accent: 222 35% 12%;
|
||||
--sidebar-accent-foreground: 190 100% 60%;
|
||||
--sidebar-border: 222 30% 15%;
|
||||
--sidebar-ring: 190 100% 50%;
|
||||
/* Sidebar — Dark */
|
||||
--sidebar-background: 216 50% 9%;
|
||||
--sidebar-foreground: 0 0% 100%;
|
||||
--sidebar-primary: 210 90% 52%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 210 45% 16%;
|
||||
--sidebar-accent-foreground: 210 85% 62%;
|
||||
--sidebar-border: 212 12% 21%;
|
||||
--sidebar-ring: 210 90% 52%;
|
||||
|
||||
/* Tokens extras — Dark */
|
||||
--bubble-bot: 216 36% 14%;
|
||||
--nav-active: 210 45% 16%;
|
||||
--input-bg: 216 40% 10%;
|
||||
--placeholder: 212 10% 31%;
|
||||
--btn-secondary-border: 216 16% 30%;
|
||||
--border-subtle: 216 22% 20%;
|
||||
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
--shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.55);
|
||||
--focus-ring: rgba(51, 136, 255, 0.35);
|
||||
|
||||
/* Gradientes — Dark */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(210 90% 52%) 0%, hsl(210 100% 40%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(216 42% 11%) 0%, hsl(216 48% 8%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(210 90% 52% / 0.35);
|
||||
--glow-blue: 0 0 30px hsl(210 100% 40% / 0.28);
|
||||
|
||||
/* Custom gradients - Dark */
|
||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 50%) 0%, hsl(210 100% 50%) 100%);
|
||||
--gradient-subtle: linear-gradient(180deg, hsl(222 45% 8%) 0%, hsl(222 47% 5%) 100%);
|
||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.3);
|
||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.25);
|
||||
--color-1: oklch(66.2% 0.225 25.9);
|
||||
--color-2: oklch(60.4% 0.26 302);
|
||||
--color-3: oklch(69.6% 0.165 251);
|
||||
--color-4: oklch(80.2% 0.134 225);
|
||||
--color-5: oklch(90.7% 0.231 133);
|
||||
}
|
||||
|
||||
.theme {
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
--animate-gradient: gradient 8s linear infinite;
|
||||
@@ -144,12 +228,21 @@
|
||||
@apply border-border transition-colors;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
#root {
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -179,15 +272,22 @@
|
||||
|
||||
.cyber-border {
|
||||
@apply border border-primary/30 rounded-lg;
|
||||
box-shadow: 0 0 20px hsl(190 100% 50% / 0.1);
|
||||
box-shadow: 0 0 20px hsl(210 100% 33% / 0.1);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
@apply bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent;
|
||||
@apply bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.animated-gradient-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
@apply bg-card rounded-xl p-5 border border-border shadow-sm transition-all duration-300 hover:shadow-md hover:border-primary/30;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -246,6 +346,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-text-shift {
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@keyframes shiny-text {
|
||||
0%, 90%, 100% {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import React from "react";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
import { PromptsProvider } from "@/contexts/PromptsContext";
|
||||
import { MainLayout } from "./components/layout/MainLayout";
|
||||
import { ChatView } from "@/components/chat/ChatView";
|
||||
import { ImageView } from "@/components/images/ImageView";
|
||||
import { TranscriptionView } from "@/components/audio/TranscriptionView";
|
||||
import { GenerationView } from "@/components/audio/GenerationView";
|
||||
import { PromptsView } from "@/components/prompts/PromptsView";
|
||||
import { PromptsFormView } from "@/components/prompts/PromptsFormView";
|
||||
import { AreasView } from "@/components/areas/AreasView";
|
||||
import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
|
||||
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
|
||||
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
|
||||
|
||||
const CommanderApp = () => {
|
||||
React.useEffect(() => {
|
||||
if (!GlobalFunctions.isUsuarioLogado()) {
|
||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PromptsProvider>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="bate-papo" replace />} />
|
||||
<Route path="bate-papo" element={<ChatView />} />
|
||||
<Route path="imagens" element={<ImageView />} />
|
||||
<Route path="transcricao-audio" element={<TranscriptionView />} />
|
||||
<Route path="geracao-audio" element={<GenerationView />} />
|
||||
<Route path="prompts" element={<PromptsView />} />
|
||||
<Route path="prompts/novo" element={<PromptsFormView />} />
|
||||
<Route path="prompts/:id" element={<PromptsFormView />} />
|
||||
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
|
||||
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
|
||||
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
|
||||
<Route path="areas" element={<AreasView />} />
|
||||
<Route path="*" element={<Navigate to="bate-papo" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</PromptsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommanderApp;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CommanderLogoProps {
|
||||
className?: string;
|
||||
/** Classes do container (expandido: proporção 3:2 da arte; recolhido: quadrado) */
|
||||
heightClass?: string;
|
||||
}
|
||||
|
||||
export const CommanderLogo = ({
|
||||
className,
|
||||
heightClass = "h-36 w-full",
|
||||
}: CommanderLogoProps) => {
|
||||
const imgClass = cn(
|
||||
"absolute inset-0 h-full w-full object-contain object-left",
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full shrink-0", heightClass)}>
|
||||
<img
|
||||
src="/hgtx-commander-light.png"
|
||||
alt="HGTX Commander"
|
||||
className={cn(imgClass, "dark:hidden")}
|
||||
/>
|
||||
<img
|
||||
src="/hgtx-commander-dark.png"
|
||||
alt="HGTX Commander"
|
||||
className={cn(imgClass, "hidden dark:block")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
MessageSquare,
|
||||
Image,
|
||||
AudioLines,
|
||||
Mic,
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Bot,
|
||||
FileText,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
import { CommanderLogo } from "./CommanderLogo";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NavItem = {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
};
|
||||
|
||||
const navSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: "I.A",
|
||||
items: [
|
||||
{ path: "bate-papo", label: "Bate-papo", icon: MessageSquare },
|
||||
{ path: "imagens", label: "Imagens", icon: Image },
|
||||
{ path: "transcricao-audio", label: "Transcrição de Áudio", icon: AudioLines },
|
||||
{ path: "geracao-audio", label: "Geração de Áudio", icon: Mic },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Parecer Jurídico",
|
||||
items: [
|
||||
{ path: "areas", label: "Áreas", icon: Layers },
|
||||
{ path: "parecer-juridico", label: "Parecer Jurídico", icon: Bot },
|
||||
{ path: "prompts", label: "Prompts", icon: FileText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const navItems = navSections.flatMap((section) => section.items);
|
||||
|
||||
const navLinkClass = (isActive: boolean, collapsed?: boolean) =>
|
||||
cn(
|
||||
"flex items-center rounded-lg text-sm font-medium transition-all duration-200 w-full",
|
||||
collapsed
|
||||
? "justify-center p-3"
|
||||
: "gap-3 px-3 py-2.5",
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground"
|
||||
);
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MainLayout = ({ children }: MainLayoutProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-background overflow-hidden">
|
||||
{/* Sidebar desktop expandido */}
|
||||
{!collapsed ? (
|
||||
<aside className="hidden md:flex md:w-72 bg-sidebar border-r border-sidebar-border flex-col">
|
||||
{/* Logo + recolher lado a lado */}
|
||||
<div className="flex items-center gap-2 border-b border-sidebar-border px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CommanderLogo />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title="Recolher menu"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navegação */}
|
||||
<nav className="flex-1 space-y-3 overflow-y-auto px-3 py-4">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.title} className="space-y-1">
|
||||
<p className="px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">
|
||||
{section.title}
|
||||
</p>
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "bate-papo"}
|
||||
className={({ isActive }) => navLinkClass(isActive)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
|
||||
{/* Rodapé */}
|
||||
<div className="p-4 border-t border-sidebar-border space-y-2">
|
||||
<div className="flex items-center justify-between px-3">
|
||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Button
|
||||
onClick={GlobalFunctions.handleNavigateBack}
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
/* Sidebar desktop recolhido */
|
||||
<aside className="hidden md:flex md:w-16 bg-sidebar border-r border-sidebar-border flex-col">
|
||||
<div className="p-3 border-b border-sidebar-border space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="w-full text-muted-foreground hover:text-foreground"
|
||||
title="Expandir"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex justify-center">
|
||||
<CommanderLogo heightClass="h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-2 py-2 space-y-0.5">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "bate-papo"}
|
||||
className={({ isActive }) => navLinkClass(isActive, true)}
|
||||
title={item.label}
|
||||
>
|
||||
<item.icon className="w-5 h-5 shrink-0" />
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
|
||||
<div className="p-3 border-t border-sidebar-border space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-full text-sidebar-foreground hover:bg-sidebar-accent/60"
|
||||
title="Voltar"
|
||||
onClick={GlobalFunctions.handleNavigateBack}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Conteúdo principal */}
|
||||
<main className="flex flex-1 flex-col min-h-0 overflow-hidden w-full">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* Navegação mobile (bottom bar) */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-sidebar border-t border-sidebar-border flex items-center justify-around py-2 z-50">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
end={item.path === "bate-papo"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex flex-col items-center gap-1 px-3 py-2 rounded-lg transition-all",
|
||||
isActive ? "text-primary" : "text-sidebar-foreground/70"
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon className={cn("w-5 h-5", isActive && "text-primary")} />
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DollarSign,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
@@ -27,12 +28,12 @@ export function AppSidebar() {
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center cyber-glow">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Bot className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in">
|
||||
<h1 className="text-sidebar-foreground font-semibold text-lg">AI Agent</h1>
|
||||
<h1 className="text-sidebar-foreground font-bold text-h2">AI Agent</h1>
|
||||
<p className="text-muted-foreground text-xs">Painel Admin</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -61,16 +62,36 @@ export function AppSidebar() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Collapse button - desktop only */}
|
||||
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto space-y-2 border-t border-sidebar-border",
|
||||
collapsed ? "p-3" : "p-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3",
|
||||
collapsed && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="nav-item flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 w-full justify-center lg:justify-start text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
|
||||
className="nav-item hidden w-full items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-sidebar-accent/60 hover:text-foreground lg:flex lg:justify-start"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
|
||||
collapsed && "rotate-180"
|
||||
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||
collapsed && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Recolher</span>}
|
||||
|
||||
@@ -674,7 +674,7 @@ export default function Financas() {
|
||||
variant="outline"
|
||||
className={
|
||||
expense.tipo === "corporativo"
|
||||
? "bg-secondary/10 text-secondary border-secondary/20"
|
||||
? "bg-secondary/10 text-secondary border-secondary/20 dark:bg-white/10 dark:text-white dark:border-white/25"
|
||||
: "bg-warning/10 text-warning border-warning/20"
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
||||
import DashboardPontos from "@/modules/fechamento-hgtx/pages/DashboardPontos";
|
||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
||||
import BancoPontosExtrato from "@/modules/fechamento-hgtx/pages/BancoPontosExtrato";
|
||||
import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
||||
import MeuPerfil from "@/modules/fechamento-hgtx/pages/MeuPerfil";
|
||||
import MeuFechamento from "@/modules/fechamento-hgtx/pages/MeuFechamento";
|
||||
import MeuFechamentoBancoPontos from "@/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos";
|
||||
import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
||||
import { MainLayout } from "@/modules/intelligence-score/components/layout/MainLayout";
|
||||
import DashboardPontos from "@/modules/intelligence-score/pages/DashboardPontos";
|
||||
import Fechamentos from "@/modules/intelligence-score/pages/Fechamentos";
|
||||
import CompetenciaFechamentos from "@/modules/intelligence-score/pages/CompetenciaFechamentos";
|
||||
import FechamentoDetalhes from "@/modules/intelligence-score/pages/FechamentoDetalhes";
|
||||
import BancoPontos from "@/modules/intelligence-score/pages/BancoPontos";
|
||||
import BancoPontosExtrato from "@/modules/intelligence-score/pages/BancoPontosExtrato";
|
||||
import Parceiros from "@/modules/intelligence-score/pages/Parceiros";
|
||||
import Usuarios from "@/modules/intelligence-score/pages/Usuarios";
|
||||
import Configuracoes from "@/modules/intelligence-score/pages/Configuracoes";
|
||||
import MeuPerfil from "@/modules/intelligence-score/pages/MeuPerfil";
|
||||
import MeuFechamento from "@/modules/intelligence-score/pages/MeuFechamento";
|
||||
import MeuFechamentoBancoPontos from "@/modules/intelligence-score/pages/MeuFechamentoBancoPontos";
|
||||
import NotFound from "@/modules/intelligence-score/pages/NotFound";
|
||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
import { AuthGate } from "@/components/auth/AuthGate";
|
||||
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
|
||||
@@ -43,7 +43,7 @@ function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element })
|
||||
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
|
||||
}
|
||||
|
||||
const FechamentoHgtxApp = () => {
|
||||
const IntelligenceScoreApp = () => {
|
||||
return (
|
||||
<AuthAccessProvider>
|
||||
<AuthGate>
|
||||
@@ -135,4 +135,5 @@ const FechamentoHgtxApp = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default FechamentoHgtxApp;
|
||||
export default IntelligenceScoreApp;
|
||||
|
||||
+21
-5
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||
@@ -113,11 +114,11 @@ export function AppSidebar() {
|
||||
{!collapsed ? (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 animate-fade-in">
|
||||
<h1 className="truncate text-lg font-semibold text-sidebar-foreground">HGTX Intelligence Score</h1>
|
||||
<h1 className="truncate text-h2 font-bold text-sidebar-foreground">HGTX Intelligence Score</h1>
|
||||
<p className="text-xs text-muted-foreground">{painelLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,7 +147,7 @@ export function AppSidebar() {
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 cyber-glow">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<ClipboardList className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</>
|
||||
@@ -191,7 +192,22 @@ export function AppSidebar() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto border-t border-sidebar-border p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto space-y-2 border-t border-sidebar-border",
|
||||
collapsed ? "p-3" : "p-4",
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-3">
|
||||
<span className="text-sm font-medium text-sidebar-foreground">Tema</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -200,7 +216,7 @@ export function AppSidebar() {
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full gap-2 text-sidebar-foreground hover:bg-sidebar-accent hover:text-foreground",
|
||||
"w-full gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
collapsed ? "justify-center px-0" : "justify-start",
|
||||
)}
|
||||
title="Voltar ao CORE HGTX"
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { AppSidebar } from "@/modules/fechamento-hgtx/components/layout/AppSidebar";
|
||||
import { AppSidebar } from "@/modules/intelligence-score/components/layout/AppSidebar";
|
||||
|
||||
type MainLayoutProps = {
|
||||
children: ReactNode;
|
||||
@@ -15,3 +15,4 @@ export function MainLayout({ children }: MainLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -432,7 +432,7 @@ export default function Configuracoes() {
|
||||
</p>
|
||||
) : !codigoEstabelecimento ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Abra o módulo pelo Codex com o estabelecimento no transfer para exibir o código e cadastrar a
|
||||
Abra o módulo pelo Commander com o estabelecimento no transfer para exibir o código e cadastrar a
|
||||
unidade.
|
||||
</p>
|
||||
) : (
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { ChatView } from "@/components/chat/ChatView";
|
||||
import { ImageView } from "@/components/images/ImageView";
|
||||
import { TranscriptionView } from "@/components/audio/TranscriptionView";
|
||||
import { GenerationView } from "@/components/audio/GenerationView";
|
||||
import { PromptsView } from "@/components/prompts/PromptsView";
|
||||
import { PromptsFormView } from "../components/prompts/PromptsFormView";
|
||||
import { AreasView } from "@/components/areas/AreasView";
|
||||
import { ParecerJuridicoView } from "@/components/parecer-juridico/ParecerJuridicoView";
|
||||
import { ParecerJuridicoFormView } from "@/components/parecer-juridico/ParecerJuridicoFormView";
|
||||
import { ParecerJuridicoDetailView } from "@/components/parecer-juridico/ParecerJuridicoDetailView";
|
||||
import { PromptsProvider } from "@/contexts/PromptsContext";
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, Outlet } from "react-router-dom";
|
||||
import React from "react";
|
||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||
|
||||
type TabType = "chat" | "images" | "transcription" | "generation" | "prompts" | "parecerJuridico" | "areas";
|
||||
|
||||
const PATH_TO_TAB: Record<string, TabType> = {
|
||||
"/codex/bate-papo": "chat",
|
||||
"/codex/imagens": "images",
|
||||
"/codex/transcricao-audio": "transcription",
|
||||
"/codex/geracao-audio": "generation",
|
||||
"/codex/parecer-juridico": "parecerJuridico",
|
||||
"/codex/areas": "areas",
|
||||
};
|
||||
|
||||
function getActiveTabFromPath(pathname: string): TabType {
|
||||
if (pathname.includes("/prompts")) return "prompts";
|
||||
if (pathname.includes("/parecer-juridico")) return "parecerJuridico";
|
||||
const tab = PATH_TO_TAB[pathname];
|
||||
if (tab) return tab;
|
||||
return "chat";
|
||||
}
|
||||
|
||||
const TAB_TO_PATH: Record<TabType, string> = {
|
||||
chat: "/codex/bate-papo",
|
||||
images: "/codex/imagens",
|
||||
transcription: "/codex/transcricao-audio",
|
||||
generation: "/codex/geracao-audio",
|
||||
prompts: "/codex/prompts",
|
||||
parecerJuridico: "/codex/parecer-juridico",
|
||||
areas: "/codex/areas",
|
||||
};
|
||||
|
||||
function CodexLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const activeTab = getActiveTabFromPath(location.pathname);
|
||||
|
||||
const onTabChange = (tab: TabType) => {
|
||||
navigate(TAB_TO_PATH[tab]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout activeTab={activeTab} onTabChange={onTabChange}>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const Index = () => {
|
||||
React.useEffect(() => {
|
||||
if (!GlobalFunctions.isUsuarioLogado()) window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="" element={<Navigate to="/404" replace />} />
|
||||
<Route path="codex" element={<PromptsProvider><CodexLayout /></PromptsProvider>}>
|
||||
<Route index element={<Navigate to="bate-papo" replace />} />
|
||||
<Route path="bate-papo" element={<ChatView />} />
|
||||
<Route path="imagens" element={<ImageView />} />
|
||||
<Route path="transcricao-audio" element={<TranscriptionView />} />
|
||||
<Route path="geracao-audio" element={<GenerationView />} />
|
||||
<Route path="parecer-juridico" element={<ParecerJuridicoView />} />
|
||||
<Route path="parecer-juridico/novo" element={<ParecerJuridicoFormView />} />
|
||||
<Route path="parecer-juridico/:id" element={<ParecerJuridicoDetailView />} />
|
||||
<Route path="prompts" element={<PromptsView />} />
|
||||
<Route path="prompts/novo" element={<PromptsFormView />} />
|
||||
<Route path="prompts/:id" element={<PromptsFormView />} />
|
||||
<Route path="areas" element={<AreasView />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
+43
-7
@@ -25,6 +25,9 @@ export default {
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
hover: "hsl(var(--primary-hover))",
|
||||
pressed: "hsl(var(--primary-pressed))",
|
||||
light: "hsl(var(--primary-light))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
@@ -68,11 +71,33 @@ export default {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
"bubble-bot": "hsl(var(--bubble-bot))",
|
||||
"nav-active": "hsl(var(--nav-active))",
|
||||
"input-bg": "hsl(var(--input-bg))",
|
||||
placeholder: "hsl(var(--placeholder))",
|
||||
"btn-secondary-border": "hsl(var(--btn-secondary-border))",
|
||||
"border-subtle": "hsl(var(--border-subtle))",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
sm: "var(--radius-sm)",
|
||||
md: "var(--radius-md)",
|
||||
lg: "var(--radius-lg)",
|
||||
xl: "var(--radius-xl)",
|
||||
"2xl": "20px",
|
||||
pill: "var(--radius-pill)",
|
||||
},
|
||||
boxShadow: {
|
||||
card: "var(--shadow-card)",
|
||||
modal: "var(--shadow-modal)",
|
||||
},
|
||||
fontSize: {
|
||||
display: ["32px", { lineHeight: "1.25", fontWeight: "700" }],
|
||||
h1: ["24px", { lineHeight: "1.25", fontWeight: "600" }],
|
||||
h2: ["18px", { lineHeight: "1.5", fontWeight: "600" }],
|
||||
h3: ["15px", { lineHeight: "1.5" }],
|
||||
body: ["14px", { lineHeight: "1.5" }],
|
||||
small: ["12px", { lineHeight: "1.5" }],
|
||||
caption: ["11px", { lineHeight: "1.5" }],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
@@ -84,15 +109,25 @@ export default {
|
||||
to: { height: "0" },
|
||||
},
|
||||
"pulse-glow": {
|
||||
"0%, 100%": {
|
||||
boxShadow: "0 0 20px hsl(190 100% 50% / 0.3)",
|
||||
"0%, 100%": {
|
||||
boxShadow: "0 0 20px hsl(210 100% 33% / 0.3)",
|
||||
transform: "scale(1)"
|
||||
},
|
||||
"50%": {
|
||||
boxShadow: "0 0 40px hsl(190 100% 50% / 0.5)",
|
||||
"50%": {
|
||||
boxShadow: "0 0 40px hsl(210 100% 33% / 0.5)",
|
||||
transform: "scale(1.02)"
|
||||
},
|
||||
},
|
||||
"online-pulse": {
|
||||
"0%, 100%": {
|
||||
boxShadow: "0 0 0 0 hsl(134 61% 41% / 0.7)",
|
||||
opacity: "1",
|
||||
},
|
||||
"50%": {
|
||||
boxShadow: "0 0 0 4px hsl(134 61% 41% / 0)",
|
||||
opacity: "0.85",
|
||||
},
|
||||
},
|
||||
"slide-in-right": {
|
||||
"0%": { transform: "translateX(100%)", opacity: "0" },
|
||||
"100%": { transform: "translateX(0)", opacity: "1" },
|
||||
@@ -106,6 +141,7 @@ export default {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"pulse-glow": "pulse-glow 2s ease-in-out infinite",
|
||||
"online-pulse": "online-pulse 1.5s ease-in-out infinite",
|
||||
"slide-in-right": "slide-in-right 0.3s ease-out",
|
||||
"fade-in": "fade-in 0.3s ease-out",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user