@danhachuel/pandanews 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +5 -3
- package/backend/app/main.py +1 -1
- package/backend/app/routers/configuracoes.py +31 -0
- package/backend/app/routers/dashboard.py +5 -0
- package/backend/app/schemas.py +1 -0
- package/backend/core/supabase_db.py +194 -0
- package/frontend/dist/assets/index-CUCWGfn9.css +1 -0
- package/frontend/dist/assets/index-WDOmiBk4.js +70 -0
- package/frontend/dist/index.html +2 -2
- package/package.json +3 -2
- package/scripts/start.js +185 -76
- package/scripts/test-start-isolation.mjs +40 -0
- package/frontend/dist/assets/index-BcgBzdzo.js +0 -70
- package/frontend/dist/assets/index-gsZzhCX8.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Todas as versões publicadas são registradas aqui antes do publish (regra em AGENTS.md). A etiqueta visual usa PATCH de dois dígitos (ex.: 0.1.00); o registry npm normaliza para a versão canônica (ex.: 0.1.0).
|
|
4
4
|
|
|
5
|
+
## 0.4.01 (2026-11-08)
|
|
6
|
+
|
|
7
|
+
- **Isolamento de processos (Windows/MobaXterm)**: o backend Python roda em grupo de processos próprio (`detached: true` → `CREATE_NEW_PROCESS_GROUP`) com `windowsHide: true` (sem janela fantasma) e `stdio` em fd do arquivo — um Ctrl+C no terminal agora derruba **apenas o launcher Node**; o backend e as gerações em andamento sobrevivem
|
|
8
|
+
- **Fim do processo órfão na porta 8000**: ao reabrir o app, o launcher detecta o backend vivo (`backend.pid` + probe de porta) e o **reaproveita**, em vez de subir uma segunda instância
|
|
9
|
+
- **Log sem terminal**: a inicialização do uvicorn e as requisições ficam registradas em `data/backend.log` mesmo com o terminal fechado (auditoria preservada)
|
|
10
|
+
- Encerramento do backend **sempre por código**: `taskkill /PID /F /T` no Windows, `kill` no grupo no Unix — sinais do terminal nunca chegam ao backend
|
|
11
|
+
- `unref()` no processo filho + watchdog de saúde (polling de porta a cada 10s; 3 falhas reerguem o backend silenciosamente)
|
|
12
|
+
- Teste de regressão `scripts/test-start-isolation.mjs` (`npm test`) roda no CI verificando as flags de isolamento — passo adicionado ao workflow de publish
|
|
13
|
+
|
|
14
|
+
## 0.4.00 (2026-10-30)
|
|
15
|
+
|
|
16
|
+
- **Supabase opcional (Fase 6)**: detecção automática de credenciais (`SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY` no ambiente ou em Configurações); teste de conexão real (`POST /configuracoes/testar-supabase`); schema SQL pronto para o SQL Editor (`GET /configuracoes/schema-supabase`); migração dos dados locais SQLite → Supabase (`POST /configuracoes/migrar-supabase`) com relatório por tabela. O app continua 100% local e funcional sem Supabase
|
|
17
|
+
- **Meta diária** (Gradiente de Meta): card "Meta de hoje" no Dashboard com "faltam X notícias para bater a meta", barra de progresso e campo configurável em Configurações → Agendamento (`meta_diaria`)
|
|
18
|
+
- **Breadcrumbs** em todas as telas internas (Lei de Jakob): Dashboard / Blogs, Dashboard / Notícias, etc.
|
|
19
|
+
- **Configurações reformulada**: seção Banco de dados com status do backend ativo (SQLite local), detecção de credenciais, teste, schema SQL e migração — sem travas de pré-registro
|
|
20
|
+
- Revisão das 7 leis de usabilidade: Fitts (CTAs ≥40px), Hick (menu 7 itens, wizard 3 passos, expanders), Zeigarnik (checklist + stepper + meta), Jakob (breadcrumbs, ações destrutivas isoladas), Gradiente de Meta (meta diária, gráfico 7 dias), von Restorff (CTA verde, badge âmbar de rascunho), Miller (blocos ≤7, datas pt-BR)
|
|
21
|
+
|
|
5
22
|
## 0.3.01 (2026-10-25)
|
|
6
23
|
|
|
7
24
|
- **Páginas legais e de verificação** em `legal/` (padrão AutoPost-Lunga, adaptado ao PandaNews): `index.html` (apresentação do app, funcionalidades e provedores de API), `terms.html` (Termos de Uso em pt-BR) e `privacy.html` (Política de Privacidade em conformidade com a LGPD — Lei 13.709/2018)
|
package/README.md
CHANGED
|
@@ -34,12 +34,14 @@ npx --yes @danhachuel/pandanews install
|
|
|
34
34
|
|
|
35
35
|
O runtime fica em `~/.pandanews` (sobrescreva com a variável de ambiente `PANDANEWS_HOME`) — **upgrades de versão não reinstalam** o que já existe: o instalador detecta Python, dependências e dados já presentes e só instala o que falta.
|
|
36
36
|
|
|
37
|
-
## Status (versão 0.
|
|
37
|
+
## Status (versão 0.4.00)
|
|
38
38
|
|
|
39
39
|
- **Pipeline completo**: coleta RSS → redação via LLM (fallback NVIDIA → Gemini) → imagem fotojornalística via Gemini Image → rascunho para revisão
|
|
40
40
|
- **Scheduler ativo**: um job por blog ativo (frequência configurável), toggle global em Automação e execução imediata (⚡ Gerar notícia agora)
|
|
41
|
-
-
|
|
42
|
-
-
|
|
41
|
+
- **Supabase opcional**: detecção automática de credenciais, teste de conexão, schema SQL e migração SQLite → Supabase — o app segue 100% local por padrão
|
|
42
|
+
- **Meta diária** no Dashboard ("faltam X notícias") e breadcrumbs em todas as telas internas
|
|
43
|
+
- Páginas legais/de verificação em `legal/` (LGPD), meta diária configurável e revisão das 7 leis de usabilidade concluídas
|
|
44
|
+
- Produto alinhado ao plano mestre: Fases 0 a 6 concluídas
|
|
43
45
|
|
|
44
46
|
- `npx @danhachuel/pandanews` instala tudo junto (Python portátil + dependências + banco local) e sobe o backend em 127.0.0.1:8000 com página em 127.0.0.1:4030 — se a porta estiver ocupada, usa a seguinte automaticamente (4031, 4032, ...)
|
|
45
47
|
- A UI React completa (Dashboard, Blogs, Notícias, Imagens, Automação, Configurações) e o pipeline de notícias chegam nas próximas versões (Fases 1 a 3 do plano — ver `CHANGELOG.md`)
|
package/backend/app/main.py
CHANGED
|
@@ -37,9 +37,40 @@ def obter_configuracoes(db: Session = Depends(get_db)) -> dict:
|
|
|
37
37
|
"supabase_service_role_key": _mask(get_config(db, "supabase_service_role_key")),
|
|
38
38
|
"scheduler_ativo": get_config(db, "scheduler_ativo", "true") == "true",
|
|
39
39
|
"intervalo_padrao_horas": int(get_config(db, "intervalo_padrao_horas", "6") or 6),
|
|
40
|
+
"meta_diaria": int(get_config(db, "meta_diaria", "3") or 3),
|
|
41
|
+
"supabase_detectado": bool(SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY)
|
|
42
|
+
or bool(get_config(db, "supabase_url") and get_config(db, "supabase_service_role_key")),
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
|
|
46
|
+
@router.post("/testar-supabase")
|
|
47
|
+
def testar_supabase() -> dict:
|
|
48
|
+
from backend.core import supabase_db
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
cred = supabase_db.obter_credenciais()
|
|
52
|
+
return {**supabase_db.testar_conexao(), "credencial_em": cred["origem"]}
|
|
53
|
+
except Exception as exc: # noqa: BLE001
|
|
54
|
+
return {"ok": False, "detail": str(exc)[:400]}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/schema-supabase")
|
|
58
|
+
def schema_supabase() -> dict:
|
|
59
|
+
from backend.core import supabase_db
|
|
60
|
+
|
|
61
|
+
return {"sql": supabase_db.SCHEMA_SQL}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.post("/migrar-supabase")
|
|
65
|
+
def migrar_supabase() -> dict:
|
|
66
|
+
from backend.core import supabase_db
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
return supabase_db.migrar_do_sqlite()
|
|
70
|
+
except Exception as exc: # noqa: BLE001
|
|
71
|
+
return {"ok": False, "detail": str(exc)[:400]}
|
|
72
|
+
|
|
73
|
+
|
|
43
74
|
@router.put("")
|
|
44
75
|
def salvar_configuracoes(payload: ConfiguracoesPatch, db: Session = Depends(get_db)) -> dict:
|
|
45
76
|
data = payload.model_dump(exclude_none=True)
|
|
@@ -45,6 +45,9 @@ def estatisticas(db: Session = Depends(get_db)) -> dict:
|
|
|
45
45
|
}
|
|
46
46
|
onboarding["concluidos"] = sum(1 for v in onboarding.values() if v)
|
|
47
47
|
|
|
48
|
+
meta = int(get_config(db, "meta_diaria", "3") or 3)
|
|
49
|
+
faltam_meta = max(0, meta - noticias_hoje)
|
|
50
|
+
|
|
48
51
|
return {
|
|
49
52
|
"blogs_total": blogs_total,
|
|
50
53
|
"blogs_ativos": blogs_ativos,
|
|
@@ -53,6 +56,8 @@ def estatisticas(db: Session = Depends(get_db)) -> dict:
|
|
|
53
56
|
"noticias_pendentes": noticias_pendentes,
|
|
54
57
|
"erros_total": erros_total,
|
|
55
58
|
"scheduler_ativo": get_config(db, "scheduler_ativo", "true") == "true",
|
|
59
|
+
"meta_diaria": meta,
|
|
60
|
+
"faltam_meta": faltam_meta,
|
|
56
61
|
"geracao_7dias": geracao,
|
|
57
62
|
"ultimos_erros": ultimos_erros,
|
|
58
63
|
"onboarding": onboarding,
|
package/backend/app/schemas.py
CHANGED
|
@@ -51,6 +51,7 @@ class ConfiguracoesPatch(BaseModel):
|
|
|
51
51
|
db_backend: Optional[str] = None
|
|
52
52
|
scheduler_ativo: Optional[bool] = None
|
|
53
53
|
intervalo_padrao_horas: Optional[int] = Field(default=None, ge=1, le=168)
|
|
54
|
+
meta_diaria: Optional[int] = Field(default=None, ge=0, le=100)
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
class LlmTestIn(BaseModel):
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Backend opcional Supabase (Fase 6).
|
|
2
|
+
|
|
3
|
+
Detecção automática de credenciais em SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY
|
|
4
|
+
(ambiente — verificação de plataformas) ou na tabela configuracoes (chaves
|
|
5
|
+
supabase_url / supabase_service_role_key). Oferece teste de conexão, schema SQL
|
|
6
|
+
para o painel do Supabase e migração dos dados locais (SQLite → Supabase).
|
|
7
|
+
Serviços opcionais nunca bloqueiam o funcionamento local do app.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import select
|
|
12
|
+
|
|
13
|
+
TABELAS_ESPERADAS = ("blogs", "noticias", "configuracoes", "logs")
|
|
14
|
+
|
|
15
|
+
SCHEMA_SQL = """-- PandaNews — schema para Supabase (execute no SQL Editor do painel)
|
|
16
|
+
create table if not exists public.blogs (
|
|
17
|
+
id bigint primary key generated always as identity,
|
|
18
|
+
nome text not null,
|
|
19
|
+
tema text not null default 'geral',
|
|
20
|
+
regiao text not null default 'Brasil',
|
|
21
|
+
fontes_rss text not null default '[]',
|
|
22
|
+
estilo_editorial text not null default '',
|
|
23
|
+
frequencia_horas integer not null default 6,
|
|
24
|
+
status text not null default 'ativo',
|
|
25
|
+
created_at timestamptz not null default now()
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
create table if not exists public.noticias (
|
|
29
|
+
id bigint primary key generated always as identity,
|
|
30
|
+
blog_id bigint not null references public.blogs(id) on delete cascade,
|
|
31
|
+
titulo text not null default '',
|
|
32
|
+
corpo text not null default '',
|
|
33
|
+
resumo text not null default '',
|
|
34
|
+
imagem_url text not null default '',
|
|
35
|
+
status text not null default 'rascunho',
|
|
36
|
+
llm_provider text not null default '',
|
|
37
|
+
fonte_url text not null default '',
|
|
38
|
+
fonte_titulo text not null default '',
|
|
39
|
+
created_at timestamptz not null default now()
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
create table if not exists public.configuracoes (
|
|
43
|
+
chave text primary key,
|
|
44
|
+
valor text not null default ''
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
create table if not exists public.logs (
|
|
48
|
+
id bigint primary key generated always as identity,
|
|
49
|
+
blog_id bigint references public.blogs(id) on delete cascade,
|
|
50
|
+
etapa text not null default '',
|
|
51
|
+
status text not null default '',
|
|
52
|
+
mensagem text not null default '',
|
|
53
|
+
created_at timestamptz not null default now()
|
|
54
|
+
);
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def obter_credenciais() -> dict:
|
|
59
|
+
"""Credenciais do Supabase: ambiente tem prioridade; senão, configuracoes."""
|
|
60
|
+
import os
|
|
61
|
+
|
|
62
|
+
url = os.environ.get("SUPABASE_URL", "")
|
|
63
|
+
key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
64
|
+
if url and key:
|
|
65
|
+
return {"url": url, "key": key, "origem": "ambiente"}
|
|
66
|
+
from backend.core.database import Configuracao, SessionLocal
|
|
67
|
+
|
|
68
|
+
db = SessionLocal()
|
|
69
|
+
try:
|
|
70
|
+
row_url = db.get(Configuracao, "supabase_url")
|
|
71
|
+
row_key = db.get(Configuracao, "supabase_service_role_key")
|
|
72
|
+
if row_url and row_url.valor and row_key and row_key.valor:
|
|
73
|
+
return {"url": row_url.valor, "key": row_key.valor, "origem": "configuracoes"}
|
|
74
|
+
finally:
|
|
75
|
+
db.close()
|
|
76
|
+
return {"url": "", "key": "", "origem": ""}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _client():
|
|
80
|
+
from supabase import create_client
|
|
81
|
+
|
|
82
|
+
cred = obter_credenciais()
|
|
83
|
+
if not (cred["url"] and cred["key"]):
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"Credenciais do Supabase não encontradas. Defina SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY "
|
|
86
|
+
"no ambiente ou informe-as em Configurações → Banco de dados."
|
|
87
|
+
)
|
|
88
|
+
return create_client(cred["url"], cred["key"])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _tabela_existe(client, tabela: str) -> bool:
|
|
92
|
+
try:
|
|
93
|
+
client.table(tabela).select("*").limit(1).execute()
|
|
94
|
+
return True
|
|
95
|
+
except Exception as exc: # noqa: BLE001
|
|
96
|
+
msg = str(exc).lower()
|
|
97
|
+
if "relation" in msg or "does not exist" in msg or "pgrst205" in msg:
|
|
98
|
+
return False
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def testar_conexao() -> dict:
|
|
103
|
+
"""Testa a conexão e a existência do schema (sem criar nada sozinho)."""
|
|
104
|
+
client = _client()
|
|
105
|
+
faltando = [t for t in TABELAS_ESPERADAS if not _tabela_existe(client, t)]
|
|
106
|
+
if faltando:
|
|
107
|
+
return {
|
|
108
|
+
"ok": False,
|
|
109
|
+
"detail": f"Conexão ok, mas faltam as tabelas: {', '.join(faltando)}. Execute o schema SQL no painel do Supabase (botão 'Ver schema SQL').",
|
|
110
|
+
"faltando": faltando,
|
|
111
|
+
}
|
|
112
|
+
return {"ok": True, "detail": "Conexão ok e schema encontrado."}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def migrar_do_sqlite() -> dict:
|
|
116
|
+
"""Copia blogs, notícias, configurações e logs do SQLite para o Supabase."""
|
|
117
|
+
from backend.core.database import Blog, Configuracao, Log, Noticia, SessionLocal
|
|
118
|
+
|
|
119
|
+
client = _client()
|
|
120
|
+
faltando = [t for t in TABELAS_ESPERADAS if not _tabela_existe(client, t)]
|
|
121
|
+
if faltando:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
f"Faltam as tabelas no Supabase: {', '.join(faltando)} — execute o schema SQL antes de migrar."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
db = SessionLocal()
|
|
127
|
+
migrados: dict[str, int] = {}
|
|
128
|
+
try:
|
|
129
|
+
blogs = db.scalars(select(Blog)).all()
|
|
130
|
+
if blogs:
|
|
131
|
+
client.table("blogs").upsert(
|
|
132
|
+
[
|
|
133
|
+
{
|
|
134
|
+
"id": b.id,
|
|
135
|
+
"nome": b.nome,
|
|
136
|
+
"tema": b.tema,
|
|
137
|
+
"regiao": b.regiao,
|
|
138
|
+
"fontes_rss": b.fontes_rss,
|
|
139
|
+
"estilo_editorial": b.estilo_editorial,
|
|
140
|
+
"frequencia_horas": b.frequencia_horas,
|
|
141
|
+
"status": b.status,
|
|
142
|
+
"created_at": b.created_at.isoformat() if b.created_at else None,
|
|
143
|
+
}
|
|
144
|
+
for b in blogs
|
|
145
|
+
]
|
|
146
|
+
).execute()
|
|
147
|
+
migrados["blogs"] = len(blogs)
|
|
148
|
+
|
|
149
|
+
noticias = db.scalars(select(Noticia)).all()
|
|
150
|
+
if noticias:
|
|
151
|
+
client.table("noticias").upsert(
|
|
152
|
+
[
|
|
153
|
+
{
|
|
154
|
+
"id": n.id,
|
|
155
|
+
"blog_id": n.blog_id,
|
|
156
|
+
"titulo": n.titulo,
|
|
157
|
+
"corpo": n.corpo,
|
|
158
|
+
"resumo": n.resumo,
|
|
159
|
+
"imagem_url": n.imagem_url,
|
|
160
|
+
"status": n.status,
|
|
161
|
+
"llm_provider": n.llm_provider,
|
|
162
|
+
"fonte_url": n.fonte_url or "",
|
|
163
|
+
"fonte_titulo": n.fonte_titulo or "",
|
|
164
|
+
"created_at": n.created_at.isoformat() if n.created_at else None,
|
|
165
|
+
}
|
|
166
|
+
for n in noticias
|
|
167
|
+
]
|
|
168
|
+
).execute()
|
|
169
|
+
migrados["noticias"] = len(noticias)
|
|
170
|
+
|
|
171
|
+
confs = db.scalars(select(Configuracao)).all()
|
|
172
|
+
if confs:
|
|
173
|
+
client.table("configuracoes").upsert([{"chave": c.chave, "valor": c.valor} for c in confs]).execute()
|
|
174
|
+
migrados["configuracoes"] = len(confs)
|
|
175
|
+
|
|
176
|
+
logs = db.scalars(select(Log).order_by(Log.created_at.desc()).limit(200)).all()
|
|
177
|
+
if logs:
|
|
178
|
+
client.table("logs").upsert(
|
|
179
|
+
[
|
|
180
|
+
{
|
|
181
|
+
"id": l.id,
|
|
182
|
+
"blog_id": l.blog_id,
|
|
183
|
+
"etapa": l.etapa,
|
|
184
|
+
"status": l.status,
|
|
185
|
+
"mensagem": l.mensagem,
|
|
186
|
+
"created_at": l.created_at.isoformat() if l.created_at else None,
|
|
187
|
+
}
|
|
188
|
+
for l in logs
|
|
189
|
+
]
|
|
190
|
+
).execute()
|
|
191
|
+
migrados["logs"] = len(logs)
|
|
192
|
+
finally:
|
|
193
|
+
db.close()
|
|
194
|
+
return {"ok": True, "migrados": migrados}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1{left:.25rem}.left-11{left:2.75rem}.left-9{left:2.25rem}.top-1{top:.25rem}.z-50{z-index:50}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-40{height:10rem}.h-44{height:11rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-auto{height:auto}.h-full{height:100%}.max-h-48{max-height:12rem}.max-h-56{max-height:14rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-16{width:4rem}.w-20{width:5rem}.w-40{width:10rem}.w-44{width:11rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-2xl{max-width:42rem}.max-w-6xl{max-width:72rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.rounded-t-md{border-top-left-radius:.375rem;border-top-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-amber-400\/40{border-color:#fbbf2466}.border-panda\/40{border-color:#22c55e66}.border-red-400\/40{border-color:#f8717166}.border-slate-100{--tw-border-opacity: 1;border-color:rgb(241 245 249 / var(--tw-border-opacity, 1))}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-100{--tw-bg-opacity: 1;background-color:rgb(254 243 199 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-black\/60{background-color:#0009}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-night{--tw-bg-opacity: 1;background-color:rgb(11 18 32 / var(--tw-bg-opacity, 1))}.bg-panda{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-panda\/10{background-color:#22c55e1a}.bg-panda\/15{background-color:#22c55e26}.bg-panda\/30{background-color:#22c55e4d}.bg-panda\/70{background-color:#22c55eb3}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-sky-100{--tw-bg-opacity: 1;background-color:rgb(224 242 254 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-300{--tw-bg-opacity: 1;background-color:rgb(203 213 225 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-2{padding-bottom:.5rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-panda{--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.text-panda-dark{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-sky-700{--tw-text-opacity: 1;color:rgb(3 105 161 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-700{--tw-text-opacity: 1;color:rgb(51 65 85 / var(--tw-text-opacity, 1))}.text-slate-800{--tw-text-opacity: 1;color:rgb(30 41 59 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.line-through{text-decoration-line:line-through}.placeholder-slate-400::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(148 163 184 / var(--tw-placeholder-opacity, 1))}.placeholder-slate-400::placeholder{--tw-placeholder-opacity: 1;color:rgb(148 163 184 / var(--tw-placeholder-opacity, 1))}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}body{--tw-bg-opacity: 1;background-color:rgb(11 18 32 / var(--tw-bg-opacity, 1));font-family:Inter,system-ui,sans-serif;--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}html:not(.dark) body{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(30 41 59 / var(--tw-text-opacity, 1))}.last\:border-0:last-child{border-width:0px}.hover\:border-slate-400:hover{--tw-border-opacity: 1;border-color:rgb(148 163 184 / var(--tw-border-opacity, 1))}.hover\:bg-panda-dark:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:bg-slate-100:hover{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.hover\:text-slate-800:hover{--tw-text-opacity: 1;color:rgb(30 41 59 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.focus\:border-panda:focus{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-amber-500\/15:is(.dark *){background-color:#f59e0b26}.dark\:bg-card:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(16 26 44 / var(--tw-bg-opacity, 1))}.dark\:bg-emerald-500\/15:is(.dark *){background-color:#10b98126}.dark\:bg-night:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(11 18 32 / var(--tw-bg-opacity, 1))}.dark\:bg-red-500\/15:is(.dark *){background-color:#ef444426}.dark\:bg-sky-500\/15:is(.dark *){background-color:#0ea5e926}.dark\:bg-slate-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(51 65 85 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-700\/60:is(.dark *){background-color:#33415599}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-panda:is(.dark *){--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-200:is(.dark *){--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-slate-500:is(.dark *){--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.dark\:text-slate-600:is(.dark *){--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.dark\:placeholder-slate-500:is(.dark *)::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.dark\:placeholder-slate-500:is(.dark *)::placeholder{--tw-placeholder-opacity: 1;color:rgb(100 116 139 / var(--tw-placeholder-opacity, 1))}.dark\:hover\:border-slate-500:hover:is(.dark *){--tw-border-opacity: 1;border-color:rgb(100 116 139 / var(--tw-border-opacity, 1))}.dark\:hover\:bg-slate-800\/60:hover:is(.dark *){background-color:#1e293b99}.dark\:hover\:text-slate-200:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}@media (min-width: 640px){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 768px){.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}
|