From 21186c9270f8d70a30ebc052b2ff7158a2601492 Mon Sep 17 00:00:00 2001 From: Jose Henrique Date: Thu, 11 Jun 2026 20:26:08 -0300 Subject: [PATCH] mudancinhas --- AGENTS.md | 11 +- GUIDELINES-ESTILO.md | 665 ++++++++++++++++++ Mindforge.API/Dockerfile | 6 +- Mindforge.API/Providers/OpenAIApiProvider.cs | 12 +- Mindforge.API/Services/GiteaService.cs | 12 +- Mindforge.Web/index.html | 2 +- Mindforge.Web/package-lock.json | 7 - Mindforge.Web/package.json | 1 - .../src/components/SpacedReviewComponent.tsx | 43 -- docker-bake.hcl | 46 ++ project-context.md | 10 +- re-build.ps1 | 182 +++++ re-deploy.ps1 | 12 - 13 files changed, 927 insertions(+), 82 deletions(-) create mode 100644 GUIDELINES-ESTILO.md create mode 100644 docker-bake.hcl create mode 100644 re-build.ps1 delete mode 100644 re-deploy.ps1 diff --git a/AGENTS.md b/AGENTS.md index f1a8e4d..276ef11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,15 @@ Leia o arquivo `project-context.md`, pois ele é a principal fonte de verdade do - Sempre que houver alteração em arquitetura, endpoints, contratos de API, variáveis de ambiente, UI/UX, fluxos de produto ou decisões técnicas, atualize o `project-context.md` na mesma entrega. ## Especificações de UI -- Toda a interface precisa ter aparência de vidro, com um fundo não tão escuro. -- Os botões precisam parecer modernos, no estilo iOS. -- Todos os campos de texto, exibições e conteúdos devem ter uma tipografia moderna. -- Todos os textos precisam estar em português brasileiro. +- Toda a interface precisa ser implementada com código pronto para produção. +- Não serão tolerados bugs na interface, muito menos problemas em CSS. +- Siga TODAS as guidelines descritas no arquivo `GUIDELINES-ESTILO.md` +- Se há alguma coisa que não ficou clara, pergunte. ## Testes - Não deve haver nenhuma forma de teste de integração, nem teste unitário, nem qualquer outro tipo de teste. - O único tipo de validação que deve ser feito é compilar o projeto - basta verificar se ele compila. - Toda a validação será feita por um humano depois. + +## Compilação +- Execute `.\re-build.ps1` para compilar o projeto. Os projetos serão compilados no Docker. diff --git a/GUIDELINES-ESTILO.md b/GUIDELINES-ESTILO.md new file mode 100644 index 0000000..366d346 --- /dev/null +++ b/GUIDELINES-ESTILO.md @@ -0,0 +1,665 @@ +# Diretrizes de Estilo do Mindforge + +Diretrizes visuais da página de revisão de flashcards: usabilidade moderna com toques acadêmicos vintage — mesa de estudo pessoal, quente, tátil e iluminada. + +--- + +## 1. Identidade do Produto + +- **Nome**: Mindforge +- **Conceito**: Estrutura moderna (grade limpa, vidro translúcido, cartões arredondados) + dicas vintage (papel, serifa, cores pergaminho, carimbo, ficha pautada). +- **Tom**: Ambiente de biblioteca com legibilidade de aplicativo moderno. Sempre priorizar foco e UX para estudo. + +--- + +## 2. Princípios de Design + +1. **Claro e quente**: Fundos creme/pergaminho/dourado/azul desbotado. Sem tema escuro. +2. **Vintage ao redor, legível no centro**: Serifa na marca e cabeçalhos; sem serifa no conteúdo do flashcard. +3. **Sem animações contínuas**: Progresso estático, sem pulsos, sem cintilações. Movimento apenas para feedback direto (virar, carimbar, celebrar). +4. **Hierarquia forte**: Flashcard é o ponto focal. Sidebar, topbar e painéis são suporte visual mais leve. +5. **Tátil e limpo**: Texturas de papel, bordas tracejadas, sombras suaves. Evitar grunge pesado ou baixo contraste. + +--- + +## 3. Variáveis CSS + +```css +:root { + --bg: #fbf3df; + --bg-soft: #fff9ea; + --paper: #fffaf0; + --paper-deep: #f2dfb3; + --ink: #2b241c; + --muted: #7b6a50; + --line: rgba(82, 60, 28, 0.18); + + --blue: #3f7cac; + --blue-deep: #255f8d; + --green: #4f8f5a; + --green-deep: #32683b; + --red: #b75b4d; + --red-deep: #8d3c32; + --gold: #c79539; + --violet: #7e65a8; + + --shadow: 0 28px 70px rgba(58, 42, 17, 0.18); + --card-shadow: 0 30px 90px rgba(76, 48, 12, 0.20); + + --radius-xl: 32px; + --radius-lg: 22px; + --radius-md: 16px; + + --ease: cubic-bezier(.2,.75,.2,1); +} +``` + +| Token | Valor | Função | +| --- | --- | --- | +| `--bg` | `#fbf3df` | Fundo geral | +| `--bg-soft` | `#fff9ea` | Superfícies claras | +| `--paper` | `#fffaf0` | Face do cartão | +| `--paper-deep` | `#f2dfb3` | Pergaminho profundo | +| `--ink` | `#2b241c` | Texto principal | +| `--muted` | `#7b6a50` | Texto secundário | +| `--line` | `rgba(82, 60, 28, 0.18)` | Bordas/divisores | +| `--blue` / `--blue-deep` | `#3f7cac` / `#255f8d` | Destaque, progresso, nota | +| `--green` / `--green-deep` | `#4f8f5a` / `#32683b` | Correto, status ativo | +| `--red` / `--red-deep` | `#b75b4d` / `#8d3c32` | Incorreto, difícil | +| `--gold` | `#c79539` | Destaque vintage, médio | +| `--violet` | `#7e65a8` | Celebração/destaque secundário | + +Sombras são marrom-quentes (nunca preto neutro). Raio de borda: `--radius-xl` (contêineres grandes), `--radius-lg` (cartões médios), `--radius-md` (itens compactos). Easing: `cubic-bezier(.2,.75,.2,1)`. + +--- + +## 4. Estilo Global + +```css +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: + radial-gradient(circle at top left, rgba(255, 221, 146, .8), transparent 35%), + radial-gradient(circle at 85% 15%, rgba(99, 153, 188, .22), transparent 34%), + linear-gradient(135deg, #fff8e6 0%, #f9edcc 50%, #fbf5e7 100%); + overflow-x: hidden; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(92, 68, 30, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(92, 68, 30, 0.035) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: linear-gradient(to bottom, rgba(0,0,0,.65), rgba(0,0,0,.12)); +} +``` + +--- + +## 5. Tipografia + +- **UI global**: `Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` +- **Marca e cabeçalhos**: `Georgia, "Times New Roman", serif` +- **Conteúdo do flashcard (pergunta/resposta)**: `"Segoe UI", Inter, Avenir, system-ui, -apple-system, BlinkMacSystemFont, sans-serif` + +Cabeçalho da pergunta: `font-size: clamp(28px, 4.5vw, 46px)`, `line-height: 1.16`, `letter-spacing: -.035em`, `font-weight: 850`. +Cabeçalho da resposta: `font-size: clamp(24px, 3.7vw, 36px)`, `line-height: 1.24`. +Texto de apoio: `max-width: 590px`, `color: #66543d`, `font-size: 17px`, `line-height: 1.68`. + +Metadados (rótulos, tags, estatísticas): `text-transform: uppercase`, `letter-spacing: .10em a .14em`, `font-weight: 900 a 950`. + +--- + +## 6. Layout + +```css +.app { + min-height: 100vh; + display: grid; + grid-template-columns: 288px minmax(0, 1fr); +} + +.main { + min-width: 0; + padding: 22px 28px 38px; +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 24px; + align-items: start; +} +``` + +--- + +## 7. Barra Lateral + +```css +.sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 24px 18px; + background: + linear-gradient(180deg, rgba(255, 249, 234, .9), rgba(245, 226, 183, .84)), + repeating-linear-gradient(45deg, rgba(113, 74, 18, .04) 0 1px, transparent 1px 8px); + border-right: 1px solid var(--line); + box-shadow: 16px 0 40px rgba(80, 54, 18, .08); + z-index: 2; +} +``` + +### Marca + +```css +.brand { display: flex; align-items: center; gap: 14px; padding: 12px 12px 24px; } + +.brand-mark { + width: 46px; height: 46px; + display: grid; place-items: center; + border-radius: 15px; + background: linear-gradient(135deg, #f2cf82, #fff2c8); + border: 1px solid rgba(105, 73, 21, .18); + box-shadow: inset 0 1px 0 rgba(255,255,255,.7), 0 10px 22px rgba(129, 86, 27, .17); + font-family: Georgia, serif; font-size: 22px; font-weight: 800; color: #51340d; +} + +.brand h1 { font-family: Georgia, "Times New Roman", serif; font-size: 22px; letter-spacing: -.03em; } +``` + +### Navegação + +```css +.nav-section-title { + margin: 20px 12px 10px; + color: var(--muted); font-size: 11px; font-weight: 900; + letter-spacing: .14em; text-transform: uppercase; +} + +.nav-item { + position: relative; width: 100%; + display: flex; align-items: center; gap: 12px; + padding: 13px 13px; margin: 4px 0; + border: 1px solid transparent; border-radius: 16px; + color: #4b3b27; text-decoration: none; + font-weight: 800; font-size: 14px; + transition: .25s var(--ease); cursor: pointer; +} + +.nav-item:hover { transform: translateX(4px); background: rgba(255,255,255,.45); border-color: var(--line); } + +.nav-item.active { + background: #fff7df; border-color: rgba(129, 86, 27, .16); + box-shadow: 0 12px 28px rgba(104, 65, 14, .10); +} + +.nav-icon { + width: 30px; height: 30px; + display: grid; place-items: center; + border-radius: 11px; + background: rgba(255,255,255,.64); + border: 1px solid rgba(80, 54, 18, .10); +} +``` + +### Cartão do baralho e progresso + +```css +.deck-card { + margin-top: 24px; padding: 16px; + border-radius: 22px; + background: linear-gradient(145deg, rgba(255,255,255,.62), rgba(255,238,193,.70)); + border: 1px solid rgba(105, 73, 21, .16); + box-shadow: 0 18px 40px rgba(82, 55, 18, .10); +} + +.mini-progress { + height: 10px; border-radius: 999px; overflow: hidden; + background: rgba(95, 72, 35, .14); +} +.mini-progress span { width: 54%; background: linear-gradient(90deg, var(--blue), #79a9c8, var(--gold)); } +``` + +--- + +## 8. Barra Superior + +```css +.topbar { + min-height: 72px; + display: flex; align-items: center; justify-content: space-between; gap: 18px; + padding: 14px 18px; margin-bottom: 24px; + background: rgba(255, 250, 239, .72); + border: 1px solid rgba(104, 69, 22, .13); border-radius: 26px; + backdrop-filter: blur(18px); + box-shadow: 0 18px 50px rgba(86, 57, 17, .08); +} + +.eyebrow { + display: inline-flex; align-items: center; gap: 8px; + margin-bottom: 4px; + color: var(--blue-deep); font-size: 12px; font-weight: 950; + letter-spacing: .14em; text-transform: uppercase; +} + +.pulse-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--green); + box-shadow: 0 0 0 4px rgba(79, 143, 90, .16); + /* ESTÁTICO - nunca pulsar */ +} + +.topbar h2 { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: clamp(24px, 3vw, 34px); letter-spacing: -.04em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.chip, .icon-button { + min-height: 42px; + display: inline-flex; align-items: center; justify-content: center; gap: 9px; + padding: 0 14px; border-radius: 999px; + border: 1px solid rgba(96, 67, 28, .15); + background: rgba(255,255,255,.62); + color: #4b3b27; font-weight: 850; font-size: 13px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.72); +} + +.icon-button:hover { transform: translateY(-2px) rotate(-2deg); box-shadow: 0 10px 20px rgba(86, 57, 17, .12); } +``` + +--- + +## 9. Painel de Revisão + +```css +.review-panel { + position: relative; + min-height: 650px; + padding: clamp(18px, 3vw, 34px); + border-radius: var(--radius-xl); + background: + linear-gradient(145deg, rgba(255,255,255,.54), rgba(255,240,202,.46)), + radial-gradient(circle at 15% 20%, rgba(255, 201, 101, .25), transparent 35%), + radial-gradient(circle at 90% 10%, rgba(63, 124, 172, .16), transparent 32%); + border: 1px solid rgba(104, 69, 22, .13); + box-shadow: var(--shadow); + overflow: hidden; +} + +.review-panel::before { + width: 220px; height: 220px; + right: -92px; top: 100px; + background: rgba(63, 124, 172, .13); + /* forma decorativa - nunca cobrir conteúdo */ +} + +.review-panel::after { + width: 180px; height: 180px; + left: -70px; bottom: 60px; + background: rgba(199, 149, 57, .18); +} + +.review-panel::before, .review-panel::after { + position: absolute; border-radius: 999px; pointer-events: none; filter: blur(2px); opacity: .5; +} + +.session-header { + position: relative; z-index: 1; + display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; + margin-bottom: 24px; +} + +.session-title h3 { font-family: Georgia, serif; font-size: clamp(24px, 3vw, 36px); letter-spacing: -.04em; } +.session-title p { max-width: 660px; color: var(--muted); font-size: 15px; line-height: 1.55; } + +.score-pill { + min-width: 76px; padding: 11px 12px; border-radius: 18px; + background: rgba(255,255,255,.55); border: 1px solid rgba(100, 65, 18, .12); + text-align: center; box-shadow: inset 0 1px 0 rgba(255,255,255,.72); +} +.score-pill b { display: block; font-size: 19px; } +.score-pill span { color: var(--muted); font-size: 10px; font-weight: 950; letter-spacing: .12em; text-transform: uppercase; } +``` + +--- + +## 10. Flashcard + +```css +.stage { + position: relative; z-index: 1; + display: grid; place-items: center; + min-height: 390px; + perspective: 1400px; + padding: 22px 0; +} + +.flashcard { + position: relative; + width: min(680px, 100%); min-height: 355px; + cursor: pointer; + transform-style: preserve-3d; + transition: transform .78s var(--ease), filter .35s var(--ease); + outline: none; +} + +.flashcard:hover .card-face { border-color: rgba(63, 124, 172, .28); } +.flashcard.is-flipped { transform: rotateY(180deg); } +.flashcard.is-reviewed { animation: cardExit .58s var(--ease); } + +.card-face { + position: absolute; inset: 0; min-height: 355px; + display: flex; flex-direction: column; justify-content: space-between; + padding: clamp(24px, 5vw, 42px); + border-radius: 30px; + backface-visibility: hidden; + background: + linear-gradient(90deg, rgba(168, 111, 36, .09) 0 1px, transparent 1px 22px), + linear-gradient(rgba(168, 111, 36, .08) 0 1px, transparent 1px 30px), + linear-gradient(145deg, #fffaf0, #f5dfaa); + border: 1px solid rgba(82, 54, 17, .18); + box-shadow: var(--card-shadow), inset 0 0 0 8px rgba(255,255,255,.24); + overflow: hidden; +} + +/* Borda tracejada de arquivo */ +.card-face::before { + content: ""; position: absolute; inset: 18px; + border: 1px dashed rgba(82, 54, 17, .18); border-radius: 22px; + pointer-events: none; +} + +/* Brilho decorativo */ +.card-face::after { + content: ""; position: absolute; + width: 140px; height: 140px; + right: -46px; bottom: -50px; border-radius: 50%; + background: radial-gradient(circle, rgba(199,149,57,.25), transparent 66%); + pointer-events: none; +} + +/* Verso: mais frio/azulado para diferenciar */ +.card-back { + transform: rotateY(180deg); + background: + linear-gradient(90deg, rgba(63,124,172,.08) 0 1px, transparent 1px 22px), + linear-gradient(rgba(63,124,172,.07) 0 1px, transparent 1px 30px), + linear-gradient(145deg, #fffaf1, #dfeef2); +} + +.card-meta { + position: relative; z-index: 1; + display: flex; justify-content: space-between; gap: 12px; align-items: center; + color: var(--muted); font-size: 12px; font-weight: 950; + letter-spacing: .12em; text-transform: uppercase; +} + +.tag { + display: inline-flex; align-items: center; gap: 8px; + padding: 9px 12px; border-radius: 999px; + background: rgba(255,255,255,.54); border: 1px solid rgba(82, 54, 17, .12); +} + +.card-question, .card-answer { + position: relative; z-index: 1; + display: grid; align-content: center; gap: 12px; + min-height: 190px; +} + +.card-footer { + position: relative; z-index: 1; + display: flex; justify-content: space-between; gap: 14px; align-items: center; + color: var(--muted); font-size: 13px; font-weight: 800; +} + +.spacebar { + padding: 4px 9px; border-radius: 8px; + color: #4f3a1d; background: rgba(255,255,255,.58); + border: 1px solid rgba(82, 54, 17, .14); + box-shadow: inset 0 -2px 0 rgba(82, 54, 17, .08); + font-size: 11px; font-weight: 950; letter-spacing: .08em; text-transform: uppercase; +} + +@keyframes cardExit { + 0% { transform: translateX(0) rotateY(180deg) rotateZ(0); opacity: 1; } + 45% { transform: translateX(28px) rotateY(180deg) rotateZ(2deg); opacity: .9; } + 100% { transform: translateX(-32px) rotateY(180deg) rotateZ(-2deg); opacity: 0; } +} +``` + +--- + +## 11. Controles de Revisão + +```css +.controls { + position: relative; z-index: 1; + display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; + opacity: .36; transform: translateY(10px); + pointer-events: none; + transition: .35s var(--ease); +} + +.controls.ready { opacity: 1; transform: translateY(0); pointer-events: auto; } + +.review-button { + position: relative; + min-width: 170px; min-height: 60px; + display: inline-flex; align-items: center; justify-content: center; gap: 10px; + border: 0; border-radius: 20px; + color: white; font-weight: 950; font-size: 16px; cursor: pointer; + box-shadow: 0 16px 34px rgba(63, 44, 20, .18), inset 0 1px 0 rgba(255,255,255,.32); + overflow: hidden; + transition: .25s var(--ease); +} + +.review-button.correct { background: linear-gradient(135deg, var(--green), var(--green-deep)); } +.review-button.wrong { background: linear-gradient(135deg, var(--red), var(--red-deep)); } + +.review-button:hover { transform: translateY(-4px); box-shadow: 0 22px 42px rgba(63, 44, 20, .22), inset 0 1px 0 rgba(255,255,255,.32); } + +/* Brilho no hover */ +.review-button::before { + content: ""; position: absolute; inset: -90% -40%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent); + transform: rotate(20deg) translateX(-80%); + transition: .55s var(--ease); +} +.review-button:hover::before { transform: rotate(20deg) translateX(80%); } +``` + +--- + +## 12. Carimbo de Feedback + +```css +.stamp { + position: absolute; right: 38px; top: 36px; z-index: 4; + padding: 12px 18px; + border: 4px double currentColor; border-radius: 10px; + font-family: Georgia, serif; font-size: 26px; font-weight: 900; + letter-spacing: .08em; text-transform: uppercase; + opacity: 0; transform: rotate(-12deg) scale(1.3); + pointer-events: none; +} +.stamp.correct { color: var(--green-deep); } +.stamp.wrong { color: var(--red-deep); } + +.stamp.show { animation: stampIn .7s var(--ease); } + +@keyframes stampIn { + 0% { opacity: 0; transform: rotate(-18deg) scale(1.8); } + 38% { opacity: 1; transform: rotate(-10deg) scale(.9); } + 58% { transform: rotate(-12deg) scale(1.04); } + 100% { opacity: 0; transform: rotate(-12deg) scale(1); } +} +``` + +--- + +## 13. Painel Lateral Direito + +```css +.side-panel { display: grid; gap: 18px; } + +.panel-card { + padding: 20px; border-radius: 26px; + background: rgba(255, 250, 239, .68); + border: 1px solid rgba(104, 69, 22, .13); + box-shadow: 0 18px 48px rgba(86, 57, 17, .09); + backdrop-filter: blur(16px); +} + +.panel-card h3 { margin: 0 0 14px; font-family: Georgia, serif; font-size: 22px; letter-spacing: -.03em; } + +.stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } + +.stat { + padding: 14px; border-radius: 18px; + background: rgba(255,255,255,.52); border: 1px solid rgba(82, 54, 17, .10); +} +.stat b { display: block; font-size: 24px; letter-spacing: -.04em; } +.stat span { color: var(--muted); font-size: 11px; font-weight: 950; letter-spacing: .10em; text-transform: uppercase; } + +.track { + height: 14px; border-radius: 999px; overflow: hidden; + background: rgba(80, 54, 18, .12); +} +.track span { display: block; width: 42%; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--red), var(--gold), var(--green)); } + +.queue { display: grid; gap: 10px; } + +.queue-item { + display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; + gap: 10px; align-items: center; padding: 10px; + border-radius: 16px; + background: rgba(255,255,255,.48); border: 1px solid rgba(82, 54, 17, .10); +} +.queue-item strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.queue-number { + width: 38px; height: 38px; + display: grid; place-items: center; + border-radius: 13px; + background: #fff5d8; border: 1px solid rgba(82, 54, 17, .12); + color: #74531c; font-family: Georgia, serif; font-weight: 900; +} + +.difficulty { width: 11px; height: 11px; border-radius: 50%; background: var(--gold); box-shadow: 0 0 0 5px rgba(199, 149, 57, .14); } +.difficulty.easy { background: var(--green); box-shadow: 0 0 0 5px rgba(79, 143, 90, .14); } +.difficulty.hard { background: var(--red); box-shadow: 0 0 0 5px rgba(183, 91, 77, .14); } + +.note { + position: relative; + padding: 18px 18px 18px 22px; border-radius: 22px; + background: linear-gradient(145deg, rgba(255, 246, 211, .9), rgba(255,255,255,.52)); + border: 1px solid rgba(104, 69, 22, .13); + color: #5e4a2f; font-size: 14px; line-height: 1.55; +} +.note::before { + content: ""; position: absolute; + left: 10px; top: 16px; bottom: 16px; width: 4px; border-radius: 999px; + background: var(--blue); +} +``` + +--- + +## 14. Animações + +### Permitidas (curtas, acionadas pelo usuário) + +| Animação | Gatilho | Duração | +| --- | --- | --- | +| Virada do flashcard (`rotateY(180deg)`) | Clique/Espaço/Enter | `.78s` | +| Saída do cartão (`cardExit`) | Marcar Correto/Incorreto | `.58s` | +| Carimbo (`stampIn`) | Marcar Correto/Incorreto | `.7s` | +| Brilho do botão (hover) | Hover | `.55s` | +| Confete (`confettiFly`) | Marcar Correto | `850ms` | +| Revelação dos controles (`.controls.ready`) | Revelação da resposta | `.35s` | + +### Proibidas + +- Pulsos infinitos (pontos de status, progresso) +- Barras de progresso animadas ou cintilantes +- Movimento de fundo contínuo +- Hover que transforme `.flashcard` conflitando com `rotateY` +- Movimento decorativo durante leitura + +--- + +## 15. Responsivo + +### Tablet (`max-width: 1120px`) + +```css +@media (max-width: 1120px) { + .app { grid-template-columns: 84px minmax(0, 1fr); } + .sidebar { padding: 20px 10px; } + .brand { justify-content: center; padding: 8px 0 18px; } + .brand div:not(.brand-mark), .nav-section-title, .nav-text, .deck-card { display: none; } + .nav-item { justify-content: center; padding: 12px; } + .content-grid { grid-template-columns: 1fr; } + .side-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +``` + +### Mobile (`max-width: 760px`) + +```css +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; display: flex; align-items: center; gap: 8px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); } + .brand { min-width: 64px; padding: 0; } + .nav-item { min-width: 56px; } + .main { padding: 16px; } + .topbar { align-items: flex-start; flex-direction: column; } + .topbar h2 { white-space: normal; } + .session-header { flex-direction: column; } + .score-pills { justify-content: flex-start; } + .review-panel { min-height: auto; } + .stage { min-height: 420px; } + .flashcard, .card-face { min-height: 380px; } + .side-panel { grid-template-columns: 1fr; } + .review-button { width: 100%; } +} +``` + +--- + +## 16. Interação + +- Revelar cartão: clique no flashcard, `Espaço` ou `Enter`. Adiciona `.is-flipped` e `.controls.ready`. +- Botões inativos antes da revelação. Após: `Correto` (gradiente verde, carimbo verde + confete), `Incorreto` (gradiente vermelho, carimbo vermelho). +- Atalhos: `Espaço` (revelar), `C` (correto), `W` (incorreto). + +--- + +## 17. Acessibilidade + +- Texto principal em tinta escura quente (`--ink`), nunca preto puro. +- Conteúdo do flashcard sempre sem serifa legível. +- Sem animações em loop. Movimento apenas sob ação do usuário. +- Flashcard alcançável por teclado. +- Flashcard é o ponto focal; painéis secundários usam menor contraste e fontes menores. + +--- + +## 18. O que Fazer e Não Fazer + +**Fazer**: creme quente, pergaminho, dourado, azul atenuado; página iluminada; serifa na marca e cabeçalhos; sem serifa no flashcard; linhas de papel e bordas tracejadas; progresso estático; animações curtas para feedback; espaçamento generoso. + +**Não fazer**: tema escuro; pulsos ou cintilações infinitas; animar barras de progresso; hover no `.flashcard` que conflite com `rotateY`; fontes decorativas no conteúdo; opacidade alta na textura; sidebar/topbar mais fortes que o cartão; cores fora da paleta. diff --git a/Mindforge.API/Dockerfile b/Mindforge.API/Dockerfile index 93042f2..b8ea99d 100644 --- a/Mindforge.API/Dockerfile +++ b/Mindforge.API/Dockerfile @@ -5,9 +5,11 @@ ARG TARGETOS WORKDIR /app COPY Mindforge.API.csproj ./ -RUN dotnet restore -a $TARGETARCH +RUN if [ "$TARGETARCH" = "amd64" ]; then DOTNET_ARCH="x64"; else DOTNET_ARCH="$TARGETARCH"; fi && \ + dotnet restore -a "$DOTNET_ARCH" COPY . . -RUN dotnet publish -c Release -a $TARGETARCH --no-restore -o /app/publish +RUN if [ "$TARGETARCH" = "amd64" ]; then DOTNET_ARCH="x64"; else DOTNET_ARCH="$TARGETARCH"; fi && \ + dotnet publish -c Release -a "$DOTNET_ARCH" --no-restore -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:9.0-alpine WORKDIR /app diff --git a/Mindforge.API/Providers/OpenAIApiProvider.cs b/Mindforge.API/Providers/OpenAIApiProvider.cs index d98bf61..1179ccc 100644 --- a/Mindforge.API/Providers/OpenAIApiProvider.cs +++ b/Mindforge.API/Providers/OpenAIApiProvider.cs @@ -10,14 +10,13 @@ namespace Mindforge.API.Providers { public class OpenAIApiProvider : ILlmApiProvider { - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; - public OpenAIApiProvider(HttpClient httpClient, IConfiguration configuration, ILogger logger) + public OpenAIApiProvider(IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { - _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(5); + _httpClientFactory = httpClientFactory; _configuration = configuration; _logger = logger; } @@ -52,6 +51,9 @@ namespace Mindforge.API.Providers Exception? lastErr = null; + using var httpClient = _httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromMinutes(5); + for (int i = 0; i < 5; i++) { using var request = new HttpRequestMessage(HttpMethod.Post, url); @@ -60,7 +62,7 @@ namespace Mindforge.API.Providers try { - var response = await _httpClient.SendAsync(request); + var response = await httpClient.SendAsync(request); var responseBody = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) diff --git a/Mindforge.API/Services/GiteaService.cs b/Mindforge.API/Services/GiteaService.cs index b4e51bc..65a8e22 100644 --- a/Mindforge.API/Services/GiteaService.cs +++ b/Mindforge.API/Services/GiteaService.cs @@ -9,7 +9,7 @@ namespace Mindforge.API.Services { public class GiteaService : IGiteaService { - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; private readonly string _baseUrl = string.Empty; private readonly string _owner = string.Empty; private readonly string _repo = string.Empty; @@ -21,9 +21,9 @@ namespace Mindforge.API.Services PropertyNameCaseInsensitive = true }; - public GiteaService(HttpClient httpClient, IConfiguration configuration) + public GiteaService(IHttpClientFactory httpClientFactory, IConfiguration configuration) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; var repoUrl = configuration["GITEA_REPO_URL"]; var token = configuration["GITEA_ACCESS_TOKEN"]; @@ -78,7 +78,8 @@ namespace Mindforge.API.Services $"{_baseUrl}/api/v1/repos/{_owner}/{_repo}/raw/{path}?ref=master"); request.Headers.Add("Authorization", $"token {_token}"); - var response = await _httpClient.SendAsync(request); + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.SendAsync(request); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) throw new UserException($"File not found in repository: {path}"); @@ -90,9 +91,10 @@ namespace Mindforge.API.Services { EnsureConfigured(); + var httpClient = _httpClientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, $"{_baseUrl}{endpoint}"); request.Headers.Add("Authorization", $"token {_token}"); - var response = await _httpClient.SendAsync(request); + var response = await httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } diff --git a/Mindforge.Web/index.html b/Mindforge.Web/index.html index d0f0e28..1aedf9e 100644 --- a/Mindforge.Web/index.html +++ b/Mindforge.Web/index.html @@ -1,5 +1,5 @@ - + diff --git a/Mindforge.Web/package-lock.json b/Mindforge.Web/package-lock.json index 685766c..a65dfd4 100644 --- a/Mindforge.Web/package-lock.json +++ b/Mindforge.Web/package-lock.json @@ -8,7 +8,6 @@ "name": "mindforge-web", "version": "0.0.0", "dependencies": { - "@types/marked": "^5.0.2", "diff": "^8.0.3", "marked": "^17.0.4", "preact": "^10.29.0" @@ -849,12 +848,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "24.12.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", diff --git a/Mindforge.Web/package.json b/Mindforge.Web/package.json index 149523e..e94f835 100644 --- a/Mindforge.Web/package.json +++ b/Mindforge.Web/package.json @@ -9,7 +9,6 @@ "preview": "vite preview" }, "dependencies": { - "@types/marked": "^5.0.2", "diff": "^8.0.3", "marked": "^17.0.4", "preact": "^10.29.0" diff --git a/Mindforge.Web/src/components/SpacedReviewComponent.tsx b/Mindforge.Web/src/components/SpacedReviewComponent.tsx index 3edeb60..1921407 100644 --- a/Mindforge.Web/src/components/SpacedReviewComponent.tsx +++ b/Mindforge.Web/src/components/SpacedReviewComponent.tsx @@ -313,49 +313,6 @@ export function SpacedReviewComponent() { } }; - const startSession = async () => { - if (selectedStatuses.length === 0) { - setError('Selecione ao menos um status para iniciar a revisao.'); - return; - } - - if (selectedLibraryIds.length === 0) { - setError('Selecione ao menos um arquivo para iniciar a revisao.'); - return; - } - - if (selectedRagLibraries.length === 0) { - setError('Nenhum arquivo encontrado com os filtros atuais. Ajuste os status ou os arquivos selecionados.'); - return; - } - - setStartingSession(true); - setError(null); - - try { - const ragByLibraryId = new Map(selectedRagLibraries.map((library) => [library.libraryId, library])); - const libraryIds = Array.from(new Set(selectedRagLibraries.map((library) => library.libraryId))); - const response = await MindforgeApiService.createFlashcardReviewSession({ libraryIds }); - const allowedLibraryIds = new Set(libraryIds); - const filteredCards = response.cards.filter((card) => allowedLibraryIds.has(card.libraryId)); - const orderedCards = orderCardsForSession(filteredCards, ragByLibraryId); - - if (orderedCards.length === 0) { - setError('Os filtros selecionados nao retornaram cards para revisar.'); - return; - } - - setSessionLibraries(selectedRagLibraries); - setSessionCards(orderedCards); - setCurrentIndex(0); - setShowAnswer(false); - } catch (err: any) { - setError(err?.message || 'Falha ao iniciar revisao espacada.'); - } finally { - setStartingSession(false); - } - }; - const endSession = () => { setSessionCards([]); setSessionLibraries([]); diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..7bdc866 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,46 @@ +group "default" { + targets = ["web", "api", "cronjob"] +} + +group "release" { + targets = ["web-release", "api-release", "cronjob-release"] +} + +target "web" { + context = "./Mindforge.Web" + dockerfile = "Dockerfile" + tags = ["git.ivanch.me/ivanch/mindforge-web:latest"] + args = { + VITE_API_BASE_URL = "http://api.mindforge.haven" + } +} + +target "web-release" { + inherits = ["web"] + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=registry"] +} + +target "api" { + context = "./Mindforge.API" + dockerfile = "Dockerfile" + tags = ["git.ivanch.me/ivanch/mindforge-api:latest"] +} + +target "api-release" { + inherits = ["api"] + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=registry"] +} + +target "cronjob" { + context = "./mindforge.cronjob" + dockerfile = "Dockerfile" + tags = ["git.ivanch.me/ivanch/mindforge-cronjob:latest"] +} + +target "cronjob-release" { + inherits = ["cronjob"] + platforms = ["linux/amd64", "linux/arm64"] + output = ["type=registry"] +} diff --git a/project-context.md b/project-context.md index cc0ce4b..67c8660 100644 --- a/project-context.md +++ b/project-context.md @@ -18,6 +18,8 @@ A interface é em **português brasileiro** e possui um tema escuro com efeito v | **TypeScript** | ~5.9.3 | Tipagem estática | | **marked** | ^17.0.4 | Renderização Markdown -> HTML | | **diff** | ^8.0.3 | Diff de texto (word-level) | + +> Nota: `marked` v17+ inclui tipos TypeScript nativos. `@types/marked` foi removido. `@types/diff` v7 permanece em devDependencies como fallback de tipos. | **Google Fonts (Lato)** | 300/400/700 | Tipografia da interface | ### Backend API (Mindforge.API) @@ -130,7 +132,7 @@ Formas de requisição principais: ### Backend (C#/.NET 9) - **Namespaces**: `Mindforge.API.Controllers`, `Mindforge.API.Services`, etc. - **Interfaces**: Prefixo `I` (ex.: `IFileService`, `ILlmApiProvider`). -- **DI**: Todos os serviços registrados como `Scoped` em `Program.cs`. +- **DI**: Todos os serviços registrados como `Scoped` em `Program.cs`. `HttpClient` obtido via `IHttpClientFactory` (não injetado diretamente). - **Controllers**: Atributo `[Route("api/v1/...")]`. Métodos retornam `IActionResult`. - **Tratamento de erros**: `ExceptionHandlingMiddleware` captura `UserException` (400) e exceções genéricas (500). @@ -153,9 +155,13 @@ Formas de requisição principais: --font-main: 'Lato', sans-serif; ``` -### Git +### Git e Scripts de Build/Deploy - Branches: `main` (produção) - CI/CD: Gitea Actions dispara no push para `main`, faz build com Docker Buildx multi-arch, envia para o Gitea Container Registry e faz o deploy no K8s. +- Build Local: `re-build.ps1` detecta Docker local; se ausente, envia o projeto via SSH para `iris.haven` e executa `docker buildx bake --load` remotamente. Se Docker CLI presente, usa `DOCKER_HOST` para fallback remoto. +- Deploy Local: `re-deploy.ps1` executa o build das imagens e aplica as atualizações no cluster Kubernetes. +- API Dockerfile: mapeia `TARGETARCH=amd64` para `x64` (arquitetura esperada pelo .NET). + --- diff --git a/re-build.ps1 b/re-build.ps1 new file mode 100644 index 0000000..ffeaa3d --- /dev/null +++ b/re-build.ps1 @@ -0,0 +1,182 @@ +# Configura para interromper a execucao em caso de qualquer erro +$ErrorActionPreference = "Stop" + +$remoteHost = "root@iris.haven" +$remoteBuildDir = "/tmp/mindforge-build" + +# --- Funcoes auxiliares --- + +function Test-SshConnection { + param([string]$HostName) + try { + $null = ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes $HostName "echo ok" 2>$null + return $true + } catch { + return $false + } +} + +function Invoke-RemoteBuild { + Write-Host "Preparando build remoto no host $remoteHost..." -ForegroundColor Cyan + + # Cria diretorio temporario no remoto + ssh -o StrictHostKeyChecking=no $remoteHost "rm -rf $remoteBuildDir && mkdir -p $remoteBuildDir" + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha ao criar diretorio remoto." + exit 1 + } + + # Compacta o projeto (excluindo artefatos de build e arquivos desnecessarios) + Write-Host "Compactando arquivos do projeto..." + $tempTar = "$env:TEMP\mindforge-build.tar.gz" + $excludes = @( + "--exclude=.git", + "--exclude=node_modules", + "--exclude=bin", + "--exclude=obj", + "--exclude=.vs", + "--exclude=.vscode", + "--exclude=.cache", + "--exclude=dist", + "--exclude=.idea", + "--exclude=*.user", + "--exclude=appsettings.Development.json" + ) + & tar czf $tempTar $excludes -C (Get-Location) . + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha ao compactar arquivos do projeto." + exit 1 + } + + # Envia o arquivo compactado para o host remoto + Write-Host "Enviando arquivos do projeto para o host remoto..." + scp -O -o StrictHostKeyChecking=no $tempTar "${remoteHost}:${remoteBuildDir}/project.tar.gz" + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha ao enviar arquivos para o host remoto." + Remove-Item -Force $tempTar -ErrorAction SilentlyContinue + exit 1 + } + + # Remove o arquivo temporario local + Remove-Item -Force $tempTar -ErrorAction SilentlyContinue + + # Extrai no host remoto + Write-Host "Extraindo arquivos no host remoto..." + ssh -o StrictHostKeyChecking=no $remoteHost "cd $remoteBuildDir && tar xzf project.tar.gz && rm project.tar.gz" + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha ao extrair arquivos no host remoto." + ssh -o StrictHostKeyChecking=no $remoteHost "rm -rf $remoteBuildDir" + exit 1 + } + + # Garante que o builder buildx existe no remoto + Write-Host "Verificando builder Buildx no remoto..." + ssh -o StrictHostKeyChecking=no $remoteHost "docker buildx inspect mindforge-builder 2>/dev/null || docker buildx create --name mindforge-builder --use" + + # Executa o build no remoto + Write-Host "Compilando as imagens Docker no host remoto..." + ssh -o StrictHostKeyChecking=no $remoteHost "cd $remoteBuildDir && docker buildx bake --load" + + if ($LASTEXITCODE -ne 0) { + Write-Error "Falha na compilacao remota." + # Limpa diretorio remoto mesmo em caso de falha + ssh -o StrictHostKeyChecking=no $remoteHost "rm -rf $remoteBuildDir" + exit 1 + } + + # Limpa diretorio remoto + ssh -o StrictHostKeyChecking=no $remoteHost "rm -rf $remoteBuildDir" +} + +# --- Logica principal --- + +# Verifica se o executavel 'docker' esta disponivel no PATH +$dockerCliAvailable = $false +if (Get-Command "docker" -ErrorAction SilentlyContinue) { + $dockerCliAvailable = $true +} + +if ($dockerCliAvailable) { + # --- Modo local (Docker CLI presente) --- + + # Habilita o BuildKit para compilacoes com 'docker build' padrao + $env:DOCKER_BUILDKIT = "1" + + # Testa se o daemon do Docker local esta rodando + $localDaemonRunning = $false + try { + $null = docker version --format '{{.Server.Version}}' 2>$null + $localDaemonRunning = $true + } catch { + $localDaemonRunning = $false + } + + if (-not $localDaemonRunning) { + Write-Host "O daemon local do Docker nao esta rodando. Redirecionando comandos para o host remoto $remoteHost..." + $env:DOCKER_HOST = "ssh://$remoteHost" + + # Testa a conexao com o host remoto + try { + $null = docker version --format '{{.Server.Version}}' 2>$null + Write-Host "Conexao com o host remoto estabelecida com sucesso!" + } catch { + Write-Error "Nao foi possivel conectar ao Docker local nem ao host remoto $remoteHost. Certifique-se de que o Docker Desktop local esta iniciado ou que o servidor remoto esta acessivel." + exit 1 + } + } + + # Detecta se o Docker Buildx esta disponivel + $buildxAvailable = $false + try { + $null = docker buildx version 2>$null + $buildxAvailable = $true + } catch { + $buildxAvailable = $false + } + + if ($buildxAvailable) { + # Garante que o builder padrao do buildx existe e esta ativo + try { + $null = docker buildx inspect mindforge-builder 2>$null + Write-Host "Usando builder 'mindforge-builder' existente..." + } catch { + Write-Host "Criando e configurando builder 'mindforge-builder'..." + docker buildx create --name mindforge-builder --use + } + + # Compila as imagens usando o docker buildx bake + Write-Host "Compilando as imagens Docker usando o Buildx..." + docker buildx bake --load + } else { + # Fallback: build tradicional sem buildx + $env:DOCKER_BUILDKIT = "0" + Write-Warning "O plugin Docker Buildx nao foi encontrado. Executando build padrao (single-arch) local para cada servico..." + + # Detecta a arquitetura do sistema para passar como build-arg + $sysArch = "$env:PROCESSOR_ARCHITECTURE".ToLower() + $dockerArch = if ($sysArch -eq "amd64") { "amd64" } else { $sysArch } + $buildArgs = "--build-arg TARGETARCH=$dockerArch --build-arg TARGETOS=linux --build-arg BUILDPLATFORM=linux/$dockerArch" + + Write-Host "Compilando Mindforge.Web..." + docker build -t git.ivanch.me/ivanch/mindforge-web:latest ./Mindforge.Web --build-arg VITE_API_BASE_URL=http://api.mindforge.haven + + Write-Host "Compilando Mindforge.API..." + docker build -t git.ivanch.me/ivanch/mindforge-api:latest ./Mindforge.API $buildArgs + + Write-Host "Compilando mindforge.cronjob..." + docker build -t git.ivanch.me/ivanch/mindforge-cronjob:latest ./mindforge.cronjob $buildArgs + } +} else { + # --- Modo remoto (Docker CLI ausente localmente) --- + Write-Host "Docker CLI nao encontrado localmente." -ForegroundColor Yellow + + if (Test-SshConnection $remoteHost) { + Write-Host "Conectando ao host remoto $remoteHost para build..." + Invoke-RemoteBuild + } else { + Write-Error "Docker nao encontrado localmente e host remoto $remoteHost inacessivel. Instale o Docker ou verifique a conectividade SSH." + exit 1 + } +} + +Write-Host "Compilacao concluida com sucesso." -ForegroundColor Green diff --git a/re-deploy.ps1 b/re-deploy.ps1 deleted file mode 100644 index 4646b8a..0000000 --- a/re-deploy.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -# Exit on any error -$ErrorActionPreference = "Stop" - -docker login git.ivanch.me - -Write-Host "Building and pushing Docker images..." -docker buildx bake --set '*.platform=linux/amd64,linux/arm64' --push - -Write-Host "Build and push completed successfully." - -kubectl delete -f ./deploy/deployment.yaml -kubectl apply -f ./deploy/deployment.yaml \ No newline at end of file