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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>HGTX Codex - AI Interface System</title>
|
<title>HGTX Commander</title>
|
||||||
<meta name="description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
<meta name="description" content="HGTX Commander — Interface AI avançada: Chat, Imagens, Áudio, Prompts e Pareceres Jurídicos." />
|
||||||
<meta name="author" content="HGTX Codex" />
|
<meta name="author" content="HGTX Commander" />
|
||||||
|
|
||||||
<meta property="og:title" content="HGTX Codex - AI Interface System" />
|
<meta property="og:title" content="HGTX Commander" />
|
||||||
<meta property="og:description" content="Sistema moderno de interface conversacional com IA - Chat, Geração de Imagens e Processamento de Áudio" />
|
<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:type" content="website" />
|
||||||
<meta property="og:image" content="/favicon.png" />
|
<meta property="og:image" content="/favicon.png" />
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"motion": "^12.36.0",
|
"motion": "^12.38.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.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 { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 { ThemeProvider } from "next-themes";
|
||||||
import Index from "./pages/Index";
|
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import Redirect from "./pages/Redirect";
|
import Redirect from "./pages/Redirect";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import CommanderApp from "./modules/commander/App";
|
||||||
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
import IntelligenceIAApp from "./modules/intelligence-ia/App";
|
||||||
import FechamentoHgtxApp from "./modules/fechamento-hgtx/App";
|
import IntelligenceScoreApp from "./modules/intelligence-score/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 />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -35,7 +20,7 @@ const App = () => (
|
|||||||
defaultTheme="light"
|
defaultTheme="light"
|
||||||
themes={["light", "dark"]}
|
themes={["light", "dark"]}
|
||||||
enableSystem={false}
|
enableSystem={false}
|
||||||
storageKey="hgtx-codex-theme"
|
storageKey="hgtx-commander-theme"
|
||||||
enableColorScheme
|
enableColorScheme
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -43,20 +28,15 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/redir/:gsikey" element={<Redirect />} />
|
||||||
path="/redir/:gsikey"
|
|
||||||
element={<Redirect />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<Route path="/commander/*" element={<CommanderApp />} />
|
||||||
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
<Route path="/intelligence-ia/*" element={<IntelligenceIAApp />} />
|
||||||
<Route path="/intelligence-score/*" element={<FechamentoHgtxApp />} />
|
<Route path="/intelligence-score/*" element={<IntelligenceScoreApp />} />
|
||||||
<Route path="/fechamento/*" element={<FechamentoLegacyRedirect />} />
|
|
||||||
<Route path="/fechamento-hgtx/*" element={<FechamentoLegacyRedirect />} />
|
|
||||||
|
|
||||||
<Route path="/*" element={<Index />} />
|
<Route path="/" element={<Navigate to="/commander" replace />} />
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
<Route path="/404" element={<NotFound />} />
|
<Route path="/404" element={<NotFound />} />
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -66,12 +46,10 @@ const App = () => (
|
|||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
const HGTXLogin = () => {
|
||||||
const HGTXLogin = () =>{
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
window.location.replace(import.meta.env.VITE_BASE_URL_HGTX_CORE);
|
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">
|
<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 md:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span className="text-sm text-muted-foreground">Online</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ export const ChatHeader = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function NoAccessScreen({ reason, errorMessage }: NoAccessScreenProps) {
|
|||||||
<p className="text-xs text-muted-foreground">Preenchimento automático.</p>
|
<p className="text-xs text-muted-foreground">Preenchimento automático.</p>
|
||||||
{!estabelecimentoId ? (
|
{!estabelecimentoId ? (
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-200">
|
<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.
|
estabelecimento carregado no transfer.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,22 +4,43 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { chatService } from "@/services/chat";
|
import { chatService } from "@/services/chat";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSendMessage: (message: string, files?: File[]) => void;
|
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 [message, setMessage] = useState("");
|
||||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||||
const [textareaHeight, setTextareaHeight] = useState(60);
|
const [textareaHeight, setTextareaHeight] = useState(DEFAULT_TEXTAREA_HEIGHT);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const dragStartY = useRef<number>(0);
|
const dragStartY = useRef<number>(0);
|
||||||
const dragStartHeight = useRef<number>(0);
|
const dragStartHeight = useRef<number>(0);
|
||||||
const { toast } = useToast();
|
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) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
dragStartY.current = e.clientY;
|
dragStartY.current = e.clientY;
|
||||||
@@ -31,8 +52,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
const deltaY = dragStartY.current - e.clientY; // Inverted: moving up = positive
|
const deltaY = dragStartY.current - e.clientY;
|
||||||
const newHeight = Math.min(400, Math.max(60, dragStartHeight.current + deltaY));
|
const newHeight = Math.min(400, Math.max(DEFAULT_TEXTAREA_HEIGHT, dragStartHeight.current + deltaY));
|
||||||
setTextareaHeight(newHeight);
|
setTextareaHeight(newHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,16 +62,30 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [isDragging, textareaHeight]);
|
}, [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 = () => {
|
const handleSend = () => {
|
||||||
if (message.trim() || attachedFiles.length > 0) {
|
if (message.trim() || attachedFiles.length > 0) {
|
||||||
onSendMessage(message, attachedFiles);
|
onSendMessage(message, attachedFiles);
|
||||||
@@ -70,7 +105,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const newFiles = [...attachedFiles, ...files];
|
const newFiles = [...attachedFiles, ...files];
|
||||||
|
|
||||||
// Valida quantidade máxima de anexos (5)
|
|
||||||
if (newFiles.length > chatService.getMaxAttachments()) {
|
if (newFiles.length > chatService.getMaxAttachments()) {
|
||||||
toast({
|
toast({
|
||||||
title: "Limite de anexos excedido",
|
title: "Limite de anexos excedido",
|
||||||
@@ -83,10 +117,8 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valida tipos de arquivo e tamanho
|
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Valida tamanho (max 10MB por arquivo)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
toast({
|
toast({
|
||||||
title: "Arquivo muito grande",
|
title: "Arquivo muito grande",
|
||||||
@@ -99,7 +131,6 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
validFiles.push(file);
|
validFiles.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valida formatos permitidos
|
|
||||||
const allFiles = [...attachedFiles, ...validFiles];
|
const allFiles = [...attachedFiles, ...validFiles];
|
||||||
const validation = chatService.validateAttachments(allFiles);
|
const validation = chatService.validateAttachments(allFiles);
|
||||||
|
|
||||||
@@ -125,65 +156,64 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
setAttachedFiles(attachedFiles.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canSend = Boolean(message.trim() || attachedFiles.length > 0);
|
||||||
|
const textareaStyleHeight = isMobile ? MOBILE_TEXTAREA_HEIGHT : textareaHeight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card/50 backdrop-blur-sm p-2 md:p-4">
|
<div className="w-full 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">
|
<div className="mx-auto w-full max-w-4xl space-y-2 px-2 md:space-y-3 md:px-4">
|
||||||
{/* Attached Files Preview */}
|
|
||||||
{attachedFiles.length > 0 && (
|
{attachedFiles.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 md:gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{attachedFiles.map((file, index) => (
|
{attachedFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
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" />
|
<Paperclip className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||||
<span className="font-medium truncate max-w-[100px] md:max-w-none">{file.name}</span>
|
<span className="truncate font-medium">{file.name}</span>
|
||||||
<span className="hidden md:inline text-xs text-muted-foreground">
|
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
({(file.size / 1024).toFixed(1)} KB)
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4 ml-1"
|
className="h-6 w-6 shrink-0"
|
||||||
onClick={() => removeFile(index)}
|
onClick={() => removeFile(index)}
|
||||||
|
aria-label={`Remover ${file.name}`}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message Input and Buttons */}
|
<div className="glass-effect overflow-hidden rounded-2xl border border-border/60 shadow-sm">
|
||||||
<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
|
<div
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
className={`hidden md:flex w-full h-6 items-center justify-center cursor-ns-resize hover:bg-primary/10 transition-colors ${
|
className={cn(
|
||||||
isDragging ? 'bg-primary/20' : ''
|
"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 cima para aumentar"
|
)}
|
||||||
|
title="Arraste para aumentar a altura"
|
||||||
>
|
>
|
||||||
<GripHorizontal className="w-5 h-5 text-muted-foreground" />
|
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Textarea */}
|
<div className="px-3 pb-1 pt-3 md:px-4 md:pt-3">
|
||||||
<div className="px-2 md:px-3 pb-2 md:pb-3 pt-2 md:pt-0">
|
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Digite sua mensagem..."
|
placeholder="Digite sua mensagem..."
|
||||||
style={{ height: window.innerWidth < 768 ? '80px' : `${textareaHeight}px` }}
|
style={{ height: textareaStyleHeight }}
|
||||||
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"
|
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
<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 gap-1 md:gap-2">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -194,27 +224,40 @@ export const ChatInput = ({ onSendMessage }: ChatInputProps) => {
|
|||||||
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
title={`Formatos aceitos: ${chatService.getAllowedFileTypesLabel()}`}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 md:h-11 md:w-11 glass-effect"
|
className="h-9 w-9 shrink-0 text-muted-foreground hover:bg-background/80 hover:text-foreground"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
title="Anexar arquivo"
|
title="Anexar arquivo"
|
||||||
|
aria-label="Anexar arquivo"
|
||||||
>
|
>
|
||||||
<Paperclip className="w-4 h-4 md:w-5 md:h-5" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{attachedFiles.length} anexo{attachedFiles.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 md:h-11 md:w-11 cyber-glow"
|
disabled={!canSend}
|
||||||
disabled={!message.trim() && attachedFiles.length === 0}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="hidden md:block text-xs text-muted-foreground text-center">
|
<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()}
|
Pressione Enter para enviar, Shift + Enter para nova linha • Máx. {chatService.getMaxAttachments()}{" "}
|
||||||
|
anexos • Formatos: {chatService.getAllowedFileTypesLabel()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+256
-231
@@ -1,5 +1,18 @@
|
|||||||
import { useState, useEffect } from "react";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -11,6 +24,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +47,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
@@ -42,7 +57,19 @@ interface ChatSidebarProps {
|
|||||||
currentChatId?: string;
|
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 { toast } = useToast();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
@@ -56,49 +83,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
const [deletingChat, setDeletingChat] = useState<ChatRecord | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Estado carregado do banco de dados via chatService
|
|
||||||
const [chats, setChats] = useState<ChatRecord[]>([]);
|
const [chats, setChats] = useState<ChatRecord[]>([]);
|
||||||
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
const [folders, setFolders] = useState<FolderRecord[]>([]);
|
||||||
|
/** Pastas fechadas por padrão; estado preservado ao recarregar lista */
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Carrega chats e pastas do banco de dados quando o componente monta
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChatsAndFolders();
|
loadChatsAndFolders();
|
||||||
|
|
||||||
// Escuta evento customizado para recarregar quando chat é atualizado
|
|
||||||
const handleChatUpdate = () => {
|
const handleChatUpdate = () => {
|
||||||
loadChatsAndFolders();
|
loadChatsAndFolders();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('chatUpdated', handleChatUpdate);
|
window.addEventListener("chatUpdated", handleChatUpdate);
|
||||||
|
return () => window.removeEventListener("chatUpdated", handleChatUpdate);
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('chatUpdated', handleChatUpdate);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadChatsAndFolders = async () => {
|
const loadChatsAndFolders = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await chatService.getChatsAndFolders();
|
const data = await chatService.getChatsAndFolders();
|
||||||
|
|
||||||
console.log('Dados carregados:', data);
|
|
||||||
|
|
||||||
setChats(data.chats);
|
setChats(data.chats);
|
||||||
setFolders(data.folders);
|
setFolders(data.folders);
|
||||||
|
} catch (error: unknown) {
|
||||||
// Expande todas as pastas por padrão
|
const message = error instanceof Error ? error.message : "Não foi possível carregar chats e pastas.";
|
||||||
setExpandedFolders(new Set(data.folders.map(f => f.id)));
|
console.error("Erro ao carregar chats e pastas:", error);
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Erro ao carregar chats e pastas:', error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Erro ao carregar dados",
|
title: "Erro ao carregar dados",
|
||||||
description: error.message || "Não foi possível carregar chats e pastas.",
|
description: message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define arrays vazios em caso de erro
|
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,48 +121,36 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (newFolderName.trim()) {
|
if (!newFolderName.trim()) return;
|
||||||
|
const name = newFolderName.trim();
|
||||||
try {
|
try {
|
||||||
await chatService.createFolder(newFolderName);
|
await chatService.createFolder(name);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setNewFolderName("");
|
setNewFolderName("");
|
||||||
setIsCreateFolderOpen(false);
|
setIsCreateFolderOpen(false);
|
||||||
|
toast({ title: "Pasta criada", description: `Pasta "${name}" criada com sucesso.` });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Pasta criada",
|
const message = error instanceof Error ? error.message : "Não foi possível criar a pasta.";
|
||||||
description: `Pasta "${newFolderName}" criada com sucesso.`,
|
toast({ title: "Erro ao criar pasta", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteFolder = async () => {
|
const handleDeleteFolder = async () => {
|
||||||
if (deletingFolder) {
|
if (!deletingFolder) return;
|
||||||
try {
|
try {
|
||||||
await chatService.deleteFolder(deletingFolder.id);
|
await chatService.deleteFolder(deletingFolder.id);
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(deletingFolder.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setDeletingFolder(null);
|
setDeletingFolder(null);
|
||||||
setIsDeleteFolderOpen(false);
|
setIsDeleteFolderOpen(false);
|
||||||
|
toast({ title: "Pasta excluída", description: "A pasta foi excluída com sucesso." });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Pasta excluída",
|
const message = error instanceof Error ? error.message : "Não foi possível excluir a pasta.";
|
||||||
description: "A pasta foi excluída com sucesso.",
|
toast({ title: "Erro ao excluir pasta", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,79 +166,52 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameFolder = async () => {
|
const handleRenameFolder = async () => {
|
||||||
if (renamingFolder && renamedFolderName.trim()) {
|
if (!renamingFolder || !renamedFolderName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
await chatService.renameFolder(renamingFolder.id, renamedFolderName);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setRenamingFolder(null);
|
setRenamingFolder(null);
|
||||||
setRenamedFolderName("");
|
setRenamedFolderName("");
|
||||||
setIsRenameFolderOpen(false);
|
setIsRenameFolderOpen(false);
|
||||||
|
toast({ title: "Pasta renomeada", description: `Pasta renomeada para "${renamedFolderName}".` });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Pasta renomeada",
|
const message = error instanceof Error ? error.message : "Não foi possível renomear a pasta.";
|
||||||
description: `Pasta renomeada para "${renamedFolderName}" com sucesso.`,
|
toast({ title: "Erro ao renomear pasta", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFolder = (folderId: string) => {
|
const toggleFolder = (folderId: string) => {
|
||||||
const newExpanded = new Set(expandedFolders);
|
setExpandedFolders((prev) => {
|
||||||
if (newExpanded.has(folderId)) {
|
const next = new Set(prev);
|
||||||
newExpanded.delete(folderId);
|
if (next.has(folderId)) next.delete(folderId);
|
||||||
} else {
|
else next.add(folderId);
|
||||||
newExpanded.add(folderId);
|
return next;
|
||||||
}
|
});
|
||||||
setExpandedFolders(newExpanded);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveToFolder = async (chatId: string, folderId: string) => {
|
const moveToFolder = async (chatId: string, folderId: string) => {
|
||||||
try {
|
try {
|
||||||
await chatService.moveChatToFolder(chatId, folderId);
|
await chatService.moveChatToFolder(chatId, folderId);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
|
const folder = folders.find((f) => f.id === folderId);
|
||||||
const folder = folders.find(f => f.id === folderId);
|
toast({ title: "Chat movido", description: `Movido para "${folder?.name}".` });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Chat movido",
|
const message = error instanceof Error ? error.message : "Não foi possível mover o chat.";
|
||||||
description: `Movido para a pasta "${folder?.name}".`,
|
toast({ title: "Erro ao mover chat", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} 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 handleDeleteChat = async () => {
|
const handleDeleteChat = async () => {
|
||||||
if (deletingChat) {
|
if (!deletingChat) return;
|
||||||
try {
|
try {
|
||||||
await chatService.deleteChat(deletingChat.id);
|
await chatService.deleteChat(deletingChat.id);
|
||||||
await loadChatsAndFolders();
|
await loadChatsAndFolders();
|
||||||
setDeletingChat(null);
|
setDeletingChat(null);
|
||||||
setIsDeleteChatOpen(false);
|
setIsDeleteChatOpen(false);
|
||||||
|
toast({ title: "Chat excluído", description: "A conversa foi excluída com sucesso." });
|
||||||
toast({
|
} catch (error: unknown) {
|
||||||
title: "Chat excluído",
|
const message = error instanceof Error ? error.message : "Não foi possível excluir o chat.";
|
||||||
description: "A conversa foi excluída com sucesso.",
|
toast({ title: "Erro ao excluir chat", description: message, variant: "destructive" });
|
||||||
});
|
|
||||||
} 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,223 +221,233 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectChat = (chat: ChatRecord) => {
|
const handleSelectChat = (chat: ChatRecord) => {
|
||||||
if (onSelectChat) {
|
onSelectChat?.(chat);
|
||||||
onSelectChat(chat);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtrar chats pela busca
|
const filteredChats = chats.filter((chat) =>
|
||||||
const filteredChats = chats.filter((chat) => {
|
chat.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
const searchLower = searchQuery.toLowerCase();
|
);
|
||||||
const titleMatch = chat.title.toLowerCase().includes(searchLower);
|
|
||||||
return titleMatch;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Separar chats sem pasta (folder_id é null)
|
|
||||||
const chatsWithoutFolder = filteredChats.filter((c) => c.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(
|
||||||
const chatsByFolder = folders.reduce((acc, folder) => {
|
(acc, folder) => {
|
||||||
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
acc[folder.id] = filteredChats.filter((c) => c.folder_id === folder.id);
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, ChatRecord[]>);
|
},
|
||||||
|
{} 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) {
|
if (isCollapsed) {
|
||||||
return (
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title="Expandir conversas"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 h-full border-r border-border bg-card/30 backdrop-blur-sm flex flex-col">
|
<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">
|
||||||
{/* Header with collapse button */}
|
{/* Cabeçalho */}
|
||||||
<div className="px-4 pt-4 pb-2 flex items-center justify-between">
|
<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>
|
<h3 className="text-sm font-semibold text-foreground">Conversas</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onToggleCollapse}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Busca + ações */}
|
||||||
<div className="px-4 pb-3 border-b border-border space-y-3">
|
<div className="min-w-0 shrink-0 space-y-2 border-b border-border px-3 py-3">
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Pesquisar conversas..."
|
placeholder="Pesquisar conversas..."
|
||||||
className="pl-9 bg-muted/50"
|
className="h-9 bg-background/80 pl-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full gap-2 cyber-glow" variant="default" onClick={onNewChat}>
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Plus className="w-4 h-4" />
|
<Button className="h-9 gap-1.5 text-sm" variant="default" onClick={onNewChat}>
|
||||||
Novo Chat
|
<Plus className="h-4 w-4" />
|
||||||
|
Novo chat
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
<Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="w-full gap-2" size="sm">
|
<Button variant="outline" className="h-9 gap-1.5 text-sm">
|
||||||
<FolderPlus className="w-4 h-4" />
|
<FolderPlus className="h-4 w-4" />
|
||||||
Nova Pasta
|
Pasta
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
<DialogContent className="border-border bg-card">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Criar Nova Pasta</DialogTitle>
|
<DialogTitle>Nova pasta</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Organize suas conversas em pastas.</DialogDescription>
|
||||||
Organize suas conversas em pastas personalizadas.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-2 py-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="folder-name">Nome</Label>
|
||||||
<Label htmlFor="folder-name">Nome da Pasta</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="folder-name"
|
id="folder-name"
|
||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="Ex: Projetos, Estudos..."
|
placeholder="Ex: Projetos, Clientes..."
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
|
<Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
<Button onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
|
||||||
Criar Pasta
|
Criar
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Rename Folder Dialog */}
|
|
||||||
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
<Dialog open={isRenameFolderOpen} onOpenChange={setIsRenameFolderOpen}>
|
||||||
<DialogContent className="glass-effect bg-card border-border z-50">
|
<DialogContent className="border-border bg-card">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Renomear Pasta</DialogTitle>
|
<DialogTitle>Renomear pasta</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Digite o novo nome para a pasta "{renamingFolder?.name}".
|
Novo nome para "{renamingFolder?.name}".
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-2 py-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="rename-folder-name">Nome</Label>
|
||||||
<Label htmlFor="rename-folder-name">Novo Nome</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="rename-folder-name"
|
id="rename-folder-name"
|
||||||
value={renamedFolderName}
|
value={renamedFolderName}
|
||||||
onChange={(e) => setRenamedFolderName(e.target.value)}
|
onChange={(e) => setRenamedFolderName(e.target.value)}
|
||||||
placeholder="Ex: Projetos, Estudos..."
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
onKeyDown={(e) => e.key === "Enter" && handleRenameFolder()}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
<Button variant="outline" onClick={() => setIsRenameFolderOpen(false)}>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
<Button onClick={handleRenameFolder} disabled={!renamedFolderName.trim()}>
|
||||||
Renomear
|
Salvar
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Folder Alert */}
|
|
||||||
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
<AlertDialog open={isDeleteFolderOpen} onOpenChange={setIsDeleteFolderOpen}>
|
||||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
<AlertDialogContent className="border-border bg-card">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Excluir Pasta?</AlertDialogTitle>
|
<AlertDialogTitle>Excluir pasta?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Esta ação não pode ser desfeita. A pasta "{deletingFolder?.name}" será excluída.
|
A pasta "{deletingFolder?.name}" será excluída. As conversas dentro dela
|
||||||
|
ficarão sem pasta.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDeleteFolder} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction
|
||||||
|
onClick={handleDeleteFolder}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
Excluir
|
Excluir
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Conversations List */}
|
{/* Lista */}
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="min-h-0 w-full min-w-0 flex-1 overflow-hidden">
|
||||||
<div className="p-2 space-y-1">
|
<div className="box-border w-full max-w-full min-w-0 space-y-1 overflow-x-hidden p-2">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="w-8 h-8 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
<div className="h-7 w-7 animate-spin rounded-full border-2 border-primary/20 border-t-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Folders */}
|
{visibleFolders.length > 0 && (
|
||||||
{folders.map((folder) => (
|
<section className="w-full min-w-0 max-w-full space-y-0.5 overflow-hidden">
|
||||||
<div key={folder.id} className="space-y-1">
|
{visibleFolders.map((folder) => {
|
||||||
<div className="flex items-center gap-1">
|
const folderChats = chatsByFolder[folder.id] ?? [];
|
||||||
|
const isExpanded = expandedFolders.has(folder.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={folder.id} className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => toggleFolder(folder.id)}
|
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"
|
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}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-secondary flex items-center justify-center flex-shrink-0">
|
<ChevronDown
|
||||||
<Folder className="w-4 h-4 text-white" />
|
className={cn(
|
||||||
</div>
|
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||||||
<span className="font-medium text-sm flex-1 text-left">
|
!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}
|
{folder.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="shrink-0 pr-0.5 text-[11px] tabular-nums text-muted-foreground">
|
||||||
{chatsByFolder[folder.id]?.length || 0}
|
{folderChats.length}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreVertical className="w-3 h-3" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem className="gap-2" onClick={() => openRenameFolder(folder)}>
|
||||||
className="gap-2"
|
<Edit className="h-3.5 w-3.5" />
|
||||||
onClick={() => openRenameFolder(folder)}
|
Renomear
|
||||||
>
|
|
||||||
<Edit className="w-3 h-3" />
|
|
||||||
Renomear Pasta
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="gap-2 text-destructive"
|
className="gap-2 text-destructive focus:text-destructive"
|
||||||
onClick={() => openDeleteFolder(folder)}
|
onClick={() => openDeleteFolder(folder)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
Excluir Pasta
|
Excluir pasta
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedFolders.has(folder.id) && chatsByFolder[folder.id]?.length > 0 && (
|
{isExpanded && (
|
||||||
<div className="ml-6 space-y-1">
|
<div className="min-w-0 space-y-0.5 py-0.5 pl-5">
|
||||||
{chatsByFolder[folder.id].map((chat) => (
|
{folderChats.length > 0 ? (
|
||||||
|
folderChats.map((chat) => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
chat={chat}
|
chat={chat}
|
||||||
@@ -471,19 +456,29 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
folders={folders}
|
folders={folders}
|
||||||
onMoveToFolder={moveToFolder}
|
onMoveToFolder={moveToFolder}
|
||||||
onDelete={() => openDeleteChat(chat)}
|
onDelete={() => openDeleteChat(chat)}
|
||||||
|
nested
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-2 py-1 text-xs text-muted-foreground">Pasta vazia</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chats without folder */}
|
|
||||||
{chatsWithoutFolder.length > 0 && (
|
{chatsWithoutFolder.length > 0 && (
|
||||||
<div className="space-y-1">
|
<section
|
||||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
className={cn(
|
||||||
Sem Pasta
|
"w-full min-w-0 max-w-full overflow-hidden",
|
||||||
</div>
|
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) => (
|
{chatsWithoutFolder.map((chat) => (
|
||||||
<ChatItem
|
<ChatItem
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
@@ -496,37 +491,48 @@ export const ChatSidebar = ({ isCollapsed, onToggleCollapse, onNewChat, onSelect
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!isLoading && chats.length === 0 && (
|
{!isLoading && chats.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
|
||||||
<MessageSquare className="w-12 h-12 text-muted-foreground opacity-50 mb-3" />
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted/60">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Inbox className="h-6 w-6 text-muted-foreground" />
|
||||||
Nenhuma conversa ainda
|
</div>
|
||||||
</p>
|
<p className="text-sm font-medium text-foreground">Nenhuma conversa</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Clique em "Novo Chat" para começar
|
Crie um novo chat para começar
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Delete Chat Alert */}
|
|
||||||
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
<AlertDialog open={isDeleteChatOpen} onOpenChange={setIsDeleteChatOpen}>
|
||||||
<AlertDialogContent className="glass-effect bg-card border-border z-50">
|
<AlertDialogContent className="border-border bg-card">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Excluir Conversa?</AlertDialogTitle>
|
<AlertDialogTitle>Excluir conversa?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Esta ação não pode ser desfeita. A conversa "{deletingChat?.title}" será permanentemente excluída.
|
"{deletingChat?.title}" será excluída permanentemente.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
<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
|
Excluir
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -543,6 +549,7 @@ interface ChatItemProps {
|
|||||||
folders: FolderRecord[];
|
folders: FolderRecord[];
|
||||||
onMoveToFolder: (chatId: string, folderId: string) => void;
|
onMoveToFolder: (chatId: string, folderId: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
nested?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChatItem = ({
|
const ChatItem = ({
|
||||||
@@ -552,8 +559,8 @@ const ChatItem = ({
|
|||||||
folders,
|
folders,
|
||||||
onMoveToFolder,
|
onMoveToFolder,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
nested = false,
|
||||||
}: ChatItemProps) => {
|
}: ChatItemProps) => {
|
||||||
// Formata timestamp relativo
|
|
||||||
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
const timeAgo = formatDistanceToNow(new Date(chat.updated_at), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: ptBR,
|
locale: ptBR,
|
||||||
@@ -561,19 +568,34 @@ const ChatItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all w-full ${
|
role="button"
|
||||||
isSelected
|
tabIndex={0}
|
||||||
? "bg-sidebar-accent cyber-border"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
}`}
|
|
||||||
onClick={onSelect}
|
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" />
|
<MessageSquare
|
||||||
<div className="flex-1 min-w-0 max-w-[180px]">
|
className={cn(
|
||||||
<p className="text-sm font-medium truncate" title={chat.title}>
|
"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}
|
{chat.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate" title={timeAgo}>
|
<p className="truncate text-[11px] text-muted-foreground" title={timeAgo}>
|
||||||
{timeAgo}
|
{timeAgo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -583,29 +605,32 @@ const ChatItem = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="glass-effect bg-popover border-border z-50">
|
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||||
{/* Opções para mover para pastas */}
|
{folders.filter((f) => f.id !== chat.folder_id).length > 0 && (
|
||||||
{folders.filter(f => f.id !== chat.folder_id).map((folder) => (
|
<>
|
||||||
|
{folders
|
||||||
|
.filter((f) => f.id !== chat.folder_id)
|
||||||
|
.map((folder) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
onClick={() => onMoveToFolder(chat.id, folder.id)}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="h-3.5 w-3.5" />
|
||||||
Mover para {folder.name}
|
Mover para {folder.name}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
</>
|
||||||
className="gap-2 text-destructive"
|
)}
|
||||||
onClick={onDelete}
|
<DropdownMenuItem className="gap-2 text-destructive focus:text-destructive" onClick={onDelete}>
|
||||||
>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
Excluir
|
Excluir
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { ChatHeader } from "@/components/ChatHeader";
|
|||||||
import { ChatMessage } from "./ChatMessage";
|
import { ChatMessage } from "./ChatMessage";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput } from "./ChatInput";
|
||||||
import { ChatSidebar } from "./ChatSidebar";
|
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 { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
import { chatService, ChatRecord, MessageRecord } from "@/services/chat";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -11,18 +18,6 @@ import { getModelId } from "@/config/models";
|
|||||||
// Texto padrão da personalidade (usado para novos chats)
|
// Texto padrão da personalidade (usado para novos chats)
|
||||||
const DEFAULT_SYSTEM_PROMPT = "Você é um assistente útil e prestativo.";
|
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 = () => {
|
export const ChatView = () => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
const [isChatSidebarCollapsed, setIsChatSidebarCollapsed] = useState(false);
|
||||||
@@ -31,16 +26,17 @@ export const ChatView = () => {
|
|||||||
const [currentChatId, setCurrentChatId] = useState("0");
|
const [currentChatId, setCurrentChatId] = useState("0");
|
||||||
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
// Inicia com texto padrão - será atualizado ao carregar chat existente
|
||||||
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
const [systemPrompt, setSystemPrompt] = useState(DEFAULT_SYSTEM_PROMPT);
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const [messages, setMessages] = useState<Message[]>([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: "GPT-4o",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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
|
// NOTA: Salvamento automático desabilitado - mensagens já são salvas na API
|
||||||
// quando enviadas via handleSendMessage
|
// quando enviadas via handleSendMessage
|
||||||
@@ -82,14 +78,7 @@ export const ChatView = () => {
|
|||||||
// Reseta a personalidade para o texto padrão
|
// Reseta a personalidade para o texto padrão
|
||||||
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
setSystemPrompt(DEFAULT_SYSTEM_PROMPT);
|
||||||
|
|
||||||
setMessages([
|
setMessages([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: selectedModel,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Dispara evento para atualizar a sidebar
|
// Dispara evento para atualizar a sidebar
|
||||||
window.dispatchEvent(new Event('chatUpdated'));
|
window.dispatchEvent(new Event('chatUpdated'));
|
||||||
@@ -136,14 +125,7 @@ export const ChatView = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Inicia com mensagem padrão em caso de erro
|
// Inicia com mensagem padrão em caso de erro
|
||||||
setMessages([
|
setMessages([createWelcomeMessage()]);
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
role: "assistant",
|
|
||||||
content: "Olá! Sou o assistente HGTX Codex. Como posso ajudá-lo hoje?",
|
|
||||||
model: selectedModel,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -162,7 +144,7 @@ export const ChatView = () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages((prev) => [...prev, newMessage]);
|
setMessages((prev) => [...withoutWelcomeMessages(prev), newMessage]);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -234,7 +216,7 @@ export const ChatView = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full pb-16 md:pb-0">
|
<div className="flex h-full w-full pb-16 md:pb-0">
|
||||||
{/* Chat Sidebar - Hidden on mobile */}
|
{/* 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
|
<ChatSidebar
|
||||||
isCollapsed={isChatSidebarCollapsed}
|
isCollapsed={isChatSidebarCollapsed}
|
||||||
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
onToggleCollapse={() => setIsChatSidebarCollapsed(!isChatSidebarCollapsed)}
|
||||||
@@ -256,7 +238,13 @@ export const ChatView = () => {
|
|||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
<div className="max-w-4xl mx-auto px-2 md:px-4">
|
||||||
{messages.map((message) => (
|
{messages.map((message) =>
|
||||||
|
isWelcomeMessage(message) ? (
|
||||||
|
<ChatWelcome
|
||||||
|
key={message.id}
|
||||||
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={message.id}
|
key={message.id}
|
||||||
role={message.role}
|
role={message.role}
|
||||||
@@ -264,7 +252,8 @@ export const ChatView = () => {
|
|||||||
model={message.model}
|
model={message.model}
|
||||||
attachments={message.attachments}
|
attachments={message.attachments}
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex gap-4 py-6 px-6 bg-muted/30">
|
<div className="flex gap-4 py-6 px-6 bg-muted/30">
|
||||||
@@ -281,7 +270,12 @@ export const ChatView = () => {
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<ChatInput onSendMessage={handleSendMessage} />
|
<ChatInput
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
draftMessage={inputDraft?.text}
|
||||||
|
draftTrigger={inputDraft?.at}
|
||||||
|
onDraftApplied={() => setInputDraft(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<CardContent className="space-y-4 text-center text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
O código do estabelecimento não foi enviado pelo ambiente (TransferArea). Abra o módulo
|
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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ interface ChatMessage {
|
|||||||
tipo_resposta?: "chat" | "agente" | null;
|
tipo_resposta?: "chat" | "agente" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LS_READER_FONT = "codex-parecer-reader-font-step";
|
const LS_READER_FONT = "commander-parecer-reader-font-step";
|
||||||
const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width";
|
const LS_READER_WIDTH = "commander-parecer-reader-comfortable-width";
|
||||||
const LS_MAIN_COMFORTABLE = "codex-parecer-main-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). */
|
/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */
|
||||||
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
|
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
|
||||||
@@ -588,7 +588,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
toast.success(res.message);
|
toast.success(res.message);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
navigate("/codex/parecer-juridico");
|
navigate("/commander/parecer-juridico");
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
toast.error(e?.message ?? "Erro ao excluir parecer.");
|
||||||
@@ -672,7 +672,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
if (!id?.trim()) {
|
if (!id?.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background p-6">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -697,7 +697,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
if (error && !parecer && !hasPreview) {
|
if (error && !parecer && !hasPreview) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background p-6">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -721,7 +721,7 @@ export function ParecerJuridicoDetailView() {
|
|||||||
<div className="flex flex-col h-full bg-background pb-16 md:pb-0">
|
<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">
|
<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">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
<h1 className="text-lg md:text-xl font-bold truncate flex-1">{titulo}</h1>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export function ParecerJuridicoFormView() {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
playSuccessSound();
|
playSuccessSound();
|
||||||
navigate(`/codex/parecer-juridico/${data.id}`, {
|
navigate(`/commander/parecer-juridico/${data.id}`, {
|
||||||
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
state: { titulo: data.titulo, conteudoMarkdown: data.conteudo_gerado ?? "" },
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -136,11 +136,11 @@ export function ParecerJuridicoFormView() {
|
|||||||
{isGenerating && (
|
{isGenerating && (
|
||||||
<ParecerJuridicoGeneratingScreen message="A geração pode levar de 5 a 8 minutos. Mantenha esta tela aberta até finalizar." />
|
<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="flex flex-col h-full min-h-0 overflow-hidden bg-background pb-16 md:pb-0">
|
||||||
<div className="p-3 md:p-6 border-b border-border">
|
<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 flex-col md:flex-row items-start md:items-center justify-between gap-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl md:text-3xl font-bold text-foreground flex items-center gap-2">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-3 md:p-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">
|
<div className="max-w-4xl mx-auto flex flex-col gap-6 pb-10">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
<Label htmlFor="titulo-parecer" className="flex flex-wrap items-baseline gap-1">
|
||||||
<span>Título do parecer</span>
|
<span>Título do parecer</span>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const ParecerJuridicoView = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditar = (item: ParecerListItem) => {
|
const handleEditar = (item: ParecerListItem) => {
|
||||||
navigate(`/codex/parecer-juridico/${item.id}`);
|
navigate(`/commander/parecer-juridico/${item.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,7 +117,7 @@ export const ParecerJuridicoView = () => {
|
|||||||
Parecer Jurídico
|
Parecer Jurídico
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<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" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="hidden sm:inline">Novo Parecer</span>
|
<span className="hidden sm:inline">Novo Parecer</span>
|
||||||
<span className="sm:hidden">Novo</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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
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 { InteractiveHoverButton } from "@/components/ui/interactive-hover-button";
|
||||||
import { BorderBeam } from "@/components/ui/border-beam";
|
import { PromptAssistantButton } from "@/components/prompts/PromptAssistantButton";
|
||||||
import { AnimatedShinyText } from "@/components/ui/animated-shiny-text";
|
|
||||||
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
import { Confetti, type ConfettiRef } from "@/components/ui/confetti";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { usePrompts } from "@/contexts/PromptsContext";
|
import { usePrompts } from "@/contexts/PromptsContext";
|
||||||
@@ -85,7 +82,7 @@ export function PromptsFormView() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id && !prompt && prompts.length > 0) {
|
if (id && !prompt && prompts.length > 0) {
|
||||||
navigate("/codex/prompts", { replace: true });
|
navigate("/commander/prompts", { replace: true });
|
||||||
}
|
}
|
||||||
}, [id, prompt, prompts.length, navigate]);
|
}, [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 ?? "" }]);
|
setPrompts((prev) => [...prev, { id: res.id, titulo: res.titulo, area: finalAreaName, area_id: finalAreaId, descricao: finalDescricao || undefined, conteudo: res.conteudo ?? "" }]);
|
||||||
toast.success("Prompt criado.");
|
toast.success("Prompt criado.");
|
||||||
}
|
}
|
||||||
navigate("/codex/prompts");
|
navigate("/commander/prompts");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
const message = e && typeof e === "object" && "message" in e ? (e as { message: string }).message : "Erro ao salvar.";
|
||||||
toast.error(message);
|
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="border-b border-border p-3 md:p-6 flex-shrink-0">
|
||||||
<div className="flex flex-col gap-3">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Voltar
|
Voltar
|
||||||
</Button>
|
</Button>
|
||||||
@@ -277,27 +274,11 @@ export function PromptsFormView() {
|
|||||||
title="Gerando em andamento"
|
title="Gerando em andamento"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ShimmerButton
|
<PromptAssistantButton
|
||||||
type="button"
|
|
||||||
onClick={() => setIsAssistantOpen(true)}
|
onClick={() => setIsAssistantOpen(true)}
|
||||||
disabled={isAssistantWorkingInBackground}
|
disabled={isAssistantWorkingInBackground}
|
||||||
background="hsl(var(--card))"
|
loading={isAssistantWorkingInBackground}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -327,21 +308,10 @@ export function PromptsFormView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={isAssistantOpen} onOpenChange={setIsAssistantOpen}>
|
<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">
|
<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">
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-[inherit] pointer-events-none">
|
<DialogHeader className="space-y-4">
|
||||||
<BorderBeam duration={8} size={100} colorFrom="#a78bfa" colorTo="#06b6d4" />
|
|
||||||
</div>
|
|
||||||
<DialogHeader className="space-y-4 relative z-10">
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div
|
<PromptAssistantButton asBadge />
|
||||||
className={cn(
|
|
||||||
"inline-flex w-fit rounded-full border border-black/5 bg-neutral-100 text-base transition-all ease-in hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-2 transition ease-out hover:text-neutral-600 hover:duration-300 hover:dark:text-neutral-400">
|
|
||||||
<span>✨ Assistente de Criação de Prompt</span>
|
|
||||||
</AnimatedShinyText>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
<DialogTitle className="sr-only">Assistente de Criação de Prompt</DialogTitle>
|
||||||
<DialogDescription className="text-base text-muted-foreground">
|
<DialogDescription className="text-base text-muted-foreground">
|
||||||
@@ -400,7 +370,7 @@ export function PromptsFormView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-3 pt-4 relative z-10">
|
<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
|
Fechar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const PromptsView = () => {
|
|||||||
const openEditFromDetails = () => {
|
const openEditFromDetails = () => {
|
||||||
if (!selectedPrompt) return;
|
if (!selectedPrompt) return;
|
||||||
setIsDetailsDialogOpen(false);
|
setIsDetailsDialogOpen(false);
|
||||||
navigate(`/codex/prompts/${selectedPrompt.id}`);
|
navigate(`/commander/prompts/${selectedPrompt.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteDialog = (prompt: Prompt) => {
|
const openDeleteDialog = (prompt: Prompt) => {
|
||||||
@@ -168,7 +168,7 @@ export const PromptsView = () => {
|
|||||||
Prompts
|
Prompts
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<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" />
|
<Plus className="w-3 h-3 md:w-4 md:h-4" />
|
||||||
<span className="hidden sm:inline">Novo prompt</span>
|
<span className="hidden sm:inline">Novo prompt</span>
|
||||||
<span className="sm:hidden">Novo</span>
|
<span className="sm:hidden">Novo</span>
|
||||||
@@ -231,7 +231,7 @@ export const PromptsView = () => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
<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" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Novo prompt
|
Novo prompt
|
||||||
</Button>
|
</Button>
|
||||||
@@ -287,7 +287,7 @@ export const PromptsView = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(`/codex/prompts/${prompt.id}`)}
|
onClick={() => navigate(`/commander/prompts/${prompt.id}`)}
|
||||||
title="Editar"
|
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"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"div"> {
|
export interface AnimatedGradientTextProps extends ComponentPropsWithoutRef<"span"> {
|
||||||
speed?: number
|
speed?: number
|
||||||
colorFrom?: string
|
colorFrom?: string
|
||||||
colorTo?: string
|
colorTo?: string
|
||||||
@@ -12,23 +12,24 @@ export function AnimatedGradientText({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
speed = 1,
|
speed = 1,
|
||||||
colorFrom = "#ffaa40",
|
colorFrom = "#0055a4",
|
||||||
colorTo = "#9c40ff",
|
colorTo = "#0d8ecf",
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
}: AnimatedGradientTextProps) {
|
}: AnimatedGradientTextProps) {
|
||||||
|
const bgSize = `${speed * 300}%`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
className={cn("animated-gradient-text inline-block font-semibold", className)}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--bg-size": `${speed * 300}%`,
|
backgroundImage: `linear-gradient(90deg, ${colorFrom}, ${colorTo}, ${colorFrom})`,
|
||||||
"--color-from": colorFrom,
|
backgroundSize: `${bgSize} 100%`,
|
||||||
"--color-to": colorTo,
|
animation: "gradient-text-shift 12s ease-in-out infinite",
|
||||||
} as React.CSSProperties
|
...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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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>
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
<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 />
|
<ScrollBar />
|
||||||
<ScrollAreaPrimitive.Corner />
|
<ScrollAreaPrimitive.Corner />
|
||||||
</ScrollAreaPrimitive.Root>
|
</ScrollAreaPrimitive.Root>
|
||||||
@@ -36,3 +38,4 @@ const ScrollBar = React.forwardRef<
|
|||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar };
|
export { ScrollArea, ScrollBar };
|
||||||
|
|
||||||
|
|||||||
+181
-71
@@ -6,62 +6,123 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* HGTX Codex - Light Theme */
|
/* ─── HGTX Commander — Design System Tokens (compartilhados) ─── */
|
||||||
--background: 210 40% 98%;
|
|
||||||
--foreground: 222 47% 11%;
|
/* 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: 0 0% 100%;
|
||||||
--card-foreground: 222 47% 11%;
|
--card-foreground: 209 11% 15%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--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-foreground: 0 0% 100%;
|
||||||
|
--primary-hover: 210 100% 28%;
|
||||||
|
--primary-pressed: 210 100% 24%;
|
||||||
|
--primary-light: 210 55% 96%;
|
||||||
|
|
||||||
--secondary: 210 100% 45%;
|
/* Secondary neutro para botões */
|
||||||
--secondary-foreground: 0 0% 100%;
|
--secondary: 210 6% 60%;
|
||||||
|
--secondary-foreground: 209 11% 15%;
|
||||||
|
|
||||||
--muted: 210 40% 96%;
|
/* Muted — bg suave */
|
||||||
--muted-foreground: 215 16% 47%;
|
--muted: 210 17% 96%;
|
||||||
|
--muted-foreground: 209 7% 46%;
|
||||||
|
|
||||||
--accent: 190 100% 50%;
|
/* Accent = fundo nav ativo */
|
||||||
--accent-foreground: 0 0% 100%;
|
--accent: 210 70% 94%;
|
||||||
|
--accent-foreground: 210 100% 33%;
|
||||||
|
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--success: 142 71% 45%;
|
/* Sucesso: #28a745 */
|
||||||
|
--success: 134 61% 41%;
|
||||||
--success-foreground: 0 0% 100%;
|
--success-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 50%;
|
||||||
--warning-foreground: 222 47% 11%;
|
--warning-foreground: 209 11% 15%;
|
||||||
|
|
||||||
--border: 214 32% 91%;
|
/* Borda: #e5e7eb */
|
||||||
--input: 214 32% 91%;
|
--border: 220 13% 91%;
|
||||||
--ring: 190 100% 45%;
|
/* --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 — Light */
|
||||||
--sidebar-background: 210 40% 99%;
|
--sidebar-background: 210 17% 98%;
|
||||||
--sidebar-foreground: 222 47% 11%;
|
--sidebar-foreground: 209 11% 15%;
|
||||||
--sidebar-primary: 190 100% 45%;
|
--sidebar-primary: 210 100% 33%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 210 40% 95%;
|
--sidebar-accent: 210 70% 94%;
|
||||||
--sidebar-accent-foreground: 222 47% 11%;
|
--sidebar-accent-foreground: 210 100% 33%;
|
||||||
--sidebar-border: 214 32% 88%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 190 100% 45%;
|
--sidebar-ring: 210 100% 33%;
|
||||||
|
|
||||||
/* Custom gradients - Light */
|
/* Tokens extras — Light */
|
||||||
--gradient-cyber: linear-gradient(135deg, hsl(190 100% 45%) 0%, hsl(210 100% 45%) 100%);
|
--bubble-bot: 209 36% 96%;
|
||||||
--gradient-subtle: linear-gradient(180deg, hsl(210 40% 98%) 0%, hsl(210 40% 95%) 100%);
|
--nav-active: 210 70% 94%;
|
||||||
--glow-cyan: 0 0 40px hsl(190 100% 50% / 0.15);
|
--input-bg: 0 0% 100%;
|
||||||
--glow-blue: 0 0 30px hsl(210 100% 50% / 0.12);
|
--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-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);
|
--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-1: oklch(66.2% 0.225 25.9);
|
||||||
--color-2: oklch(60.4% 0.26 302);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--color-3: oklch(69.6% 0.165 251);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
@@ -70,62 +131,85 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* HGTX Codex - Dark Theme */
|
/* ─── Dark Theme ─── */
|
||||||
--background: 222 47% 5%;
|
/* Fundo principal = mesma família da sidebar (216 50% 9%), levemente mais profundo */
|
||||||
--foreground: 200 100% 95%;
|
--background: 216 48% 8%;
|
||||||
|
--foreground: 0 0% 100%;
|
||||||
|
|
||||||
--card: 222 40% 8%;
|
/* Cards/painéis um tom acima do fundo para hierarquia sutil */
|
||||||
--card-foreground: 200 100% 95%;
|
--card: 216 42% 11%;
|
||||||
|
--card-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--popover: 222 45% 10%;
|
--popover: 216 42% 11%;
|
||||||
--popover-foreground: 200 100% 95%;
|
--popover-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--primary: 190 100% 50%;
|
/* Azul primário dark — vibrante em fundo escuro */
|
||||||
--primary-foreground: 222 47% 5%;
|
--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: 216 38% 13%;
|
||||||
--secondary-foreground: 222 47% 5%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--muted: 222 30% 15%;
|
--muted: 216 38% 13%;
|
||||||
--muted-foreground: 200 20% 65%;
|
--muted-foreground: 212 9% 58%;
|
||||||
|
|
||||||
--accent: 190 100% 60%;
|
/* Accent dark */
|
||||||
--accent-foreground: 222 47% 5%;
|
--accent: 210 45% 16%;
|
||||||
|
--accent-foreground: 210 85% 62%;
|
||||||
|
|
||||||
--destructive: 0 84% 60%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 200 100% 95%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--success: 142 71% 45%;
|
/* Sucesso dark: #23d160 */
|
||||||
--success-foreground: 222 47% 5%;
|
--success: 141 71% 48%;
|
||||||
|
--success-foreground: 216 48% 8%;
|
||||||
|
|
||||||
--warning: 38 92% 50%;
|
--warning: 38 92% 50%;
|
||||||
--warning-foreground: 222 47% 5%;
|
--warning-foreground: 216 48% 8%;
|
||||||
|
|
||||||
--border: 222 30% 18%;
|
/* Bordas dark — tom suave (evita “borda preta” em inputs/selects) */
|
||||||
--input: 222 30% 18%;
|
--border: 216 18% 26%;
|
||||||
--ring: 190 100% 50%;
|
/* --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 — Dark */
|
||||||
--sidebar-background: 222 45% 7%;
|
--sidebar-background: 216 50% 9%;
|
||||||
--sidebar-foreground: 200 100% 90%;
|
--sidebar-foreground: 0 0% 100%;
|
||||||
--sidebar-primary: 190 100% 50%;
|
--sidebar-primary: 210 90% 52%;
|
||||||
--sidebar-primary-foreground: 222 47% 5%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 222 35% 12%;
|
--sidebar-accent: 210 45% 16%;
|
||||||
--sidebar-accent-foreground: 190 100% 60%;
|
--sidebar-accent-foreground: 210 85% 62%;
|
||||||
--sidebar-border: 222 30% 15%;
|
--sidebar-border: 212 12% 21%;
|
||||||
--sidebar-ring: 190 100% 50%;
|
--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-1: oklch(66.2% 0.225 25.9);
|
||||||
--color-2: oklch(60.4% 0.26 302);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--color-3: oklch(69.6% 0.165 251);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--color-4: oklch(80.2% 0.134 225);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--color-5: oklch(90.7% 0.231 133);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme {
|
.theme {
|
||||||
--animate-shiny-text: shiny-text 8s infinite;
|
--animate-shiny-text: shiny-text 8s infinite;
|
||||||
--animate-gradient: gradient 8s linear infinite;
|
--animate-gradient: gradient 8s linear infinite;
|
||||||
@@ -144,12 +228,21 @@
|
|||||||
@apply border-border transition-colors;
|
@apply border-border transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground font-sans antialiased;
|
@apply bg-background text-foreground font-sans antialiased;
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
#root {
|
||||||
|
@apply min-h-0;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
@@ -179,15 +272,22 @@
|
|||||||
|
|
||||||
.cyber-border {
|
.cyber-border {
|
||||||
@apply border border-primary/30 rounded-lg;
|
@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 {
|
.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 {
|
.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;
|
@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 {
|
.nav-item {
|
||||||
@@ -246,6 +346,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-text-shift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@keyframes shiny-text {
|
@keyframes shiny-text {
|
||||||
0%, 90%, 100% {
|
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,
|
DollarSign,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -27,12 +28,12 @@ export function AppSidebar() {
|
|||||||
<>
|
<>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 px-4 py-6 border-b border-sidebar-border">
|
<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" />
|
<Bot className="w-5 h-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="animate-fade-in">
|
<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>
|
<p className="text-muted-foreground text-xs">Painel Admin</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -61,16 +62,36 @@ export function AppSidebar() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse button - desktop only */}
|
<div
|
||||||
<div className="hidden lg:block px-3 py-4 border-t border-sidebar-border">
|
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
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
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
|
<ChevronLeft
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-5 h-5 transition-transform duration-300 flex-shrink-0",
|
"h-5 w-5 flex-shrink-0 transition-transform duration-300",
|
||||||
collapsed && "rotate-180"
|
collapsed && "rotate-180",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{!collapsed && <span>Recolher</span>}
|
{!collapsed && <span>Recolher</span>}
|
||||||
|
|||||||
@@ -674,7 +674,7 @@ export default function Financas() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={
|
className={
|
||||||
expense.tipo === "corporativo"
|
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"
|
: "bg-warning/10 text-warning border-warning/20"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { MainLayout } from "@/modules/fechamento-hgtx/components/layout/MainLayout";
|
import { MainLayout } from "@/modules/intelligence-score/components/layout/MainLayout";
|
||||||
import DashboardPontos from "@/modules/fechamento-hgtx/pages/DashboardPontos";
|
import DashboardPontos from "@/modules/intelligence-score/pages/DashboardPontos";
|
||||||
import Fechamentos from "@/modules/fechamento-hgtx/pages/Fechamentos";
|
import Fechamentos from "@/modules/intelligence-score/pages/Fechamentos";
|
||||||
import CompetenciaFechamentos from "@/modules/fechamento-hgtx/pages/CompetenciaFechamentos";
|
import CompetenciaFechamentos from "@/modules/intelligence-score/pages/CompetenciaFechamentos";
|
||||||
import FechamentoDetalhes from "@/modules/fechamento-hgtx/pages/FechamentoDetalhes";
|
import FechamentoDetalhes from "@/modules/intelligence-score/pages/FechamentoDetalhes";
|
||||||
import BancoPontos from "@/modules/fechamento-hgtx/pages/BancoPontos";
|
import BancoPontos from "@/modules/intelligence-score/pages/BancoPontos";
|
||||||
import BancoPontosExtrato from "@/modules/fechamento-hgtx/pages/BancoPontosExtrato";
|
import BancoPontosExtrato from "@/modules/intelligence-score/pages/BancoPontosExtrato";
|
||||||
import Parceiros from "@/modules/fechamento-hgtx/pages/Parceiros";
|
import Parceiros from "@/modules/intelligence-score/pages/Parceiros";
|
||||||
import Usuarios from "@/modules/fechamento-hgtx/pages/Usuarios";
|
import Usuarios from "@/modules/intelligence-score/pages/Usuarios";
|
||||||
import Configuracoes from "@/modules/fechamento-hgtx/pages/Configuracoes";
|
import Configuracoes from "@/modules/intelligence-score/pages/Configuracoes";
|
||||||
import MeuPerfil from "@/modules/fechamento-hgtx/pages/MeuPerfil";
|
import MeuPerfil from "@/modules/intelligence-score/pages/MeuPerfil";
|
||||||
import MeuFechamento from "@/modules/fechamento-hgtx/pages/MeuFechamento";
|
import MeuFechamento from "@/modules/intelligence-score/pages/MeuFechamento";
|
||||||
import MeuFechamentoBancoPontos from "@/modules/fechamento-hgtx/pages/MeuFechamentoBancoPontos";
|
import MeuFechamentoBancoPontos from "@/modules/intelligence-score/pages/MeuFechamentoBancoPontos";
|
||||||
import NotFound from "@/modules/fechamento-hgtx/pages/NotFound";
|
import NotFound from "@/modules/intelligence-score/pages/NotFound";
|
||||||
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
import { AuthAccessProvider, useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
import { AuthGate } from "@/components/auth/AuthGate";
|
import { AuthGate } from "@/components/auth/AuthGate";
|
||||||
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
|
import { UnidadeGate } from "@/components/fechamento/UnidadeGate";
|
||||||
@@ -43,7 +43,7 @@ function RequireFechamentoDetalheRoute({ children }: { children: JSX.Element })
|
|||||||
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
|
return <Navigate to="/intelligence-score/meu-fechamento" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FechamentoHgtxApp = () => {
|
const IntelligenceScoreApp = () => {
|
||||||
return (
|
return (
|
||||||
<AuthAccessProvider>
|
<AuthAccessProvider>
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
@@ -135,4 +135,5 @@ const FechamentoHgtxApp = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default FechamentoHgtxApp;
|
export default IntelligenceScoreApp;
|
||||||
|
|
||||||
+21
-5
@@ -16,6 +16,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { GlobalFunctions } from "@/GlobalFunctions";
|
import { GlobalFunctions } from "@/GlobalFunctions";
|
||||||
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
import { useAuthAccess } from "@/contexts/AuthAccessContext";
|
||||||
@@ -113,11 +114,11 @@ export function AppSidebar() {
|
|||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<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" />
|
<ClipboardList className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 animate-fade-in">
|
<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>
|
<p className="text-xs text-muted-foreground">{painelLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +147,7 @@ export function AppSidebar() {
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</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" />
|
<ClipboardList className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -191,7 +192,22 @@ export function AppSidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -200,7 +216,7 @@ export function AppSidebar() {
|
|||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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",
|
collapsed ? "justify-center px-0" : "justify-start",
|
||||||
)}
|
)}
|
||||||
title="Voltar ao CORE HGTX"
|
title="Voltar ao CORE HGTX"
|
||||||
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { AppSidebar } from "@/modules/fechamento-hgtx/components/layout/AppSidebar";
|
import { AppSidebar } from "@/modules/intelligence-score/components/layout/AppSidebar";
|
||||||
|
|
||||||
type MainLayoutProps = {
|
type MainLayoutProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -15,3 +15,4 @@ export function MainLayout({ children }: MainLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -432,7 +432,7 @@ export default function Configuracoes() {
|
|||||||
</p>
|
</p>
|
||||||
) : !codigoEstabelecimento ? (
|
) : !codigoEstabelecimento ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<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.
|
unidade.
|
||||||
</p>
|
</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;
|
|
||||||
+41
-5
@@ -25,6 +25,9 @@ export default {
|
|||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
hover: "hsl(var(--primary-hover))",
|
||||||
|
pressed: "hsl(var(--primary-pressed))",
|
||||||
|
light: "hsl(var(--primary-light))",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
@@ -68,11 +71,33 @@ export default {
|
|||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
ring: "hsl(var(--sidebar-ring))",
|
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: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
sm: "var(--radius-sm)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "var(--radius-md)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
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: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
@@ -85,14 +110,24 @@ export default {
|
|||||||
},
|
},
|
||||||
"pulse-glow": {
|
"pulse-glow": {
|
||||||
"0%, 100%": {
|
"0%, 100%": {
|
||||||
boxShadow: "0 0 20px hsl(190 100% 50% / 0.3)",
|
boxShadow: "0 0 20px hsl(210 100% 33% / 0.3)",
|
||||||
transform: "scale(1)"
|
transform: "scale(1)"
|
||||||
},
|
},
|
||||||
"50%": {
|
"50%": {
|
||||||
boxShadow: "0 0 40px hsl(190 100% 50% / 0.5)",
|
boxShadow: "0 0 40px hsl(210 100% 33% / 0.5)",
|
||||||
transform: "scale(1.02)"
|
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": {
|
"slide-in-right": {
|
||||||
"0%": { transform: "translateX(100%)", opacity: "0" },
|
"0%": { transform: "translateX(100%)", opacity: "0" },
|
||||||
"100%": { transform: "translateX(0)", opacity: "1" },
|
"100%": { transform: "translateX(0)", opacity: "1" },
|
||||||
@@ -106,6 +141,7 @@ export default {
|
|||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
"pulse-glow": "pulse-glow 2s ease-in-out infinite",
|
"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",
|
"slide-in-right": "slide-in-right 0.3s ease-out",
|
||||||
"fade-in": "fade-in 0.3s ease-out",
|
"fade-in": "fade-in 0.3s ease-out",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user