improving ui quality
All checks were successful
Mindforge Web Build and Deploy (internal) / Build Mindforge Web Image (push) Successful in 3m18s
Mindforge Web Build and Deploy (internal) / Deploy Mindforge Web (internal) (push) Successful in 58s

This commit is contained in:
2026-06-14 11:26:24 -03:00
parent 83867e4255
commit 031cbb7f42
13 changed files with 1212 additions and 1269 deletions

View File

@@ -12,7 +12,7 @@ const minAmount = 10;
const maxAmount = 50; const maxAmount = 50;
function difficultyLabel(difficulty: string) { function difficultyLabel(difficulty: string) {
return difficulty === 'Medium' ? 'Medio' : 'Facil'; return difficulty === 'Medium' ? 'Médio' : 'Fácil';
} }
export function FlashcardComponent() { export function FlashcardComponent() {
@@ -25,7 +25,7 @@ export function FlashcardComponent() {
const handleGenerate = async () => { const handleGenerate = async () => {
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
setError('Selecione pelo menos um arquivo do repositorio para gerar os flashcards.'); setError('Selecione pelo menos um arquivo do repositório para gerar os flashcards.');
return; return;
} }
@@ -50,22 +50,21 @@ export function FlashcardComponent() {
return ( return (
<div className="flashcard-container"> <div className="flashcard-container">
<h2 className="flashcard-title">Gerador de Flashcards</h2> <h2 className="flashcard-title">Gerador de Flashcards</h2>
<p className="flashcard-subtitle">Selecione os arquivos do repositorio para gerar bibliotecas de flashcards.</p> <p className="flashcard-subtitle">Selecione os arquivos do repositório para gerar bibliotecas de flashcards.</p>
<div className="flashcard-form"> <div className="flashcard-form">
<div className="input-group"> <div className="input-group">
<label>Arquivos do Repositorio</label> <label>Arquivos do Repositório</label>
<FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} /> <FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
{selectedPaths.length > 0 && ( {selectedPaths.length > 0 && (
<div className="selection-meta"> <div className="selection-meta">
{selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado {selectedPaths.length === 1 ? '1 arquivo selecionado' : `${selectedPaths.length} arquivos selecionados`}
{selectedPaths.length !== 1 ? 's' : ''}
</div> </div>
)} )}
</div> </div>
<div className="input-group"> <div className="input-group">
<label>Quantidade por Arquivo ({minAmount} - {maxAmount})</label> <label>Quantidade por arquivo ({minAmount} - {maxAmount})</label>
<div className="slider-wrapper"> <div className="slider-wrapper">
<input <input
type="range" type="range"
@@ -92,7 +91,7 @@ export function FlashcardComponent() {
onChange={() => setDifficulty('Easy')} onChange={() => setDifficulty('Easy')}
/> />
<label htmlFor="difficulty-easy" className="radio-label" title="Perguntas mais diretas e objetivas"> <label htmlFor="difficulty-easy" className="radio-label" title="Perguntas mais diretas e objetivas">
Facil Fácil
</label> </label>
</div> </div>
<div className="radio-item"> <div className="radio-item">
@@ -104,8 +103,8 @@ export function FlashcardComponent() {
checked={difficulty === 'Medium'} checked={difficulty === 'Medium'}
onChange={() => setDifficulty('Medium')} onChange={() => setDifficulty('Medium')}
/> />
<label htmlFor="difficulty-medium" className="radio-label" title="Perguntas de nivel intermediario"> <label htmlFor="difficulty-medium" className="radio-label" title="Perguntas de nível intermediário">
Medio Médio
</label> </label>
</div> </div>
</div> </div>
@@ -133,10 +132,10 @@ export function FlashcardComponent() {
<article key={library.id} className="flashcard-result-item"> <article key={library.id} className="flashcard-result-item">
<div className="flashcard-result-header"> <div className="flashcard-result-header">
<strong>{library.fileName}</strong> <strong>{library.fileName}</strong>
<span>{library.cardCount} cards</span> <span>{library.cardCount} cartões</span>
</div> </div>
<div className="flashcard-result-meta"> <div className="flashcard-result-meta">
<span>Materia: {library.subject}</span> <span>Matéria: {library.subject}</span>
<span>Dificuldade: {difficultyLabel(library.difficulty)}</span> <span>Dificuldade: {difficultyLabel(library.difficulty)}</span>
</div> </div>
</article> </article>

View File

@@ -29,7 +29,6 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
/* Library Selection */
.review-select-panel { .review-select-panel {
background: rgba(255, 250, 239, .68); background: rgba(255, 250, 239, .68);
border: 1px solid rgba(104, 69, 22, .13); border: 1px solid rgba(104, 69, 22, .13);
@@ -109,548 +108,8 @@
justify-content: flex-end; justify-content: flex-end;
} }
/* Session Panel - Content Grid */
.content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 24px;
align-items: start;
}
/* Review Panel (main flashcard area) */
.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,
.review-panel::after {
content: "";
position: absolute;
border-radius: 999px;
pointer-events: none;
filter: blur(2px);
opacity: .5;
}
.review-panel::before {
width: 220px;
height: 220px;
right: -92px;
top: 100px;
background: rgba(63, 124, 172, .13);
}
.review-panel::after {
width: 180px;
height: 180px;
left: -70px;
bottom: 60px;
background: rgba(199, 149, 57, .18);
}
.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 {
margin: 0;
font-family: Georgia, serif;
font-size: clamp(24px, 3vw, 36px);
letter-spacing: -.04em;
color: var(--ink);
}
.session-title p {
max-width: 660px;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
margin: 4px 0 0;
}
.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;
color: var(--ink);
}
.score-pill span {
color: var(--muted);
font-size: 10px;
font-weight: 950;
letter-spacing: .12em;
text-transform: uppercase;
}
/* Stage (flashcard container) */
.stage {
position: relative;
z-index: 1;
display: grid;
place-items: center;
min-height: 390px;
perspective: 1400px;
padding: 22px 0;
}
/* Flashcard */
.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;
}
.card-face::before {
content: "";
position: absolute;
inset: 18px;
border: 1px dashed rgba(82, 54, 17, .18);
border-radius: 22px;
pointer-events: none;
}
.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;
}
.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;
color: var(--ink);
font-family: "Segoe UI", Inter, Avenir, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: clamp(17px, 2.2vw, 20px);
line-height: 1.55;
}
.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;
}
/* Controls */
.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);
margin-top: 20px;
}
.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-family: inherit;
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);
}
.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%);
}
.review-button:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
}
/* Stamp */
.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);
}
/* Side Panel */
.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;
color: var(--ink);
}
.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;
color: var(--ink);
}
.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);
margin-top: 8px;
}
.track span {
display: block;
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;
color: var(--ink);
font-size: 13px;
}
.queue-item span {
color: var(--muted);
font-size: 11px;
}
.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;
font-size: 14px;
}
/* Confetti Canvas */
.confetti-canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
/* Keyframes */
@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; }
}
@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); }
}
/* Session End */
.session-end {
text-align: center;
padding: 3rem 1rem;
color: var(--muted);
}
.session-end h3 {
font-family: Georgia, serif;
font-size: 28px;
color: var(--ink);
margin: 0 0 0.5rem;
}
/* Navigation buttons row */
.review-nav-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
justify-content: center;
}
@media (max-width: 1120px) {
.content-grid {
grid-template-columns: 1fr;
}
.side-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.session-header { .review-actions {
flex-direction: column; justify-content: stretch;
}
.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%;
}
.controls {
flex-direction: column;
align-items: stretch;
} }
} }

View File

@@ -1,10 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { import {
MindforgeApiService, MindforgeApiService,
type FlashcardCard, type FlashcardCard,
type FlashcardLibrarySummary, type FlashcardLibrarySummary,
} from '../services/MindforgeApiService'; } from '../services/MindforgeApiService';
import { Button } from './Button'; import { Button } from './Button';
import {
FlashcardStudySession,
type FlashcardStudySessionLibraryMeta,
} from './FlashcardStudySession';
import './FlashcardReviewComponent.css'; import './FlashcardReviewComponent.css';
function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) { function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) {
@@ -21,7 +25,7 @@ function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) {
} }
function difficultyLabel(difficulty: string) { function difficultyLabel(difficulty: string) {
return difficulty === 'Medium' ? 'Medio' : 'Facil'; return difficulty === 'Medium' ? 'Médio' : 'Fácil';
} }
function shuffleCards(cards: FlashcardCard[]) { function shuffleCards(cards: FlashcardCard[]) {
@@ -33,86 +37,6 @@ function shuffleCards(cards: FlashcardCard[]) {
return shuffled; return shuffled;
} }
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
color: string;
size: number;
life: number;
maxLife: number;
rotation: number;
rotationSpeed: number;
}
function fireConfetti(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const c = ctx;
const w = canvas.width = window.innerWidth;
const h = canvas.height = window.innerHeight;
const colors = ['#4f8f5a', '#3f7cac', '#c79539', '#7e65a8', '#f2dfb3', '#b75b4d'];
const particles: ConfettiParticle[] = [];
for (let i = 0; i < 120; i++) {
particles.push({
x: Math.random() * w,
y: -20 - Math.random() * h * 0.5,
vx: (Math.random() - 0.5) * 6,
vy: Math.random() * 5 + 2,
color: colors[Math.floor(Math.random() * colors.length)],
size: Math.random() * 8 + 4,
life: 0,
maxLife: 80 + Math.random() * 60,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
});
}
let animating = true;
function animate() {
if (!animating) return;
c.clearRect(0, 0, w, h);
let alive = 0;
for (const p of particles) {
p.life++;
if (p.life >= p.maxLife) continue;
alive++;
p.x += p.vx;
p.y += p.vy;
p.vy += 0.08;
p.vx *= 0.995;
p.rotation += p.rotationSpeed;
const alpha = 1 - p.life / p.maxLife;
c.save();
c.globalAlpha = alpha;
c.translate(p.x, p.y);
c.rotate(p.rotation);
c.fillStyle = p.color;
c.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
c.restore();
}
if (alive > 0) {
requestAnimationFrame(animate);
} else {
c.clearRect(0, 0, w, h);
animating = false;
}
}
requestAnimationFrame(animate);
return () => {
animating = false;
};
}
export function FlashcardReviewComponent() { export function FlashcardReviewComponent() {
const [libraries, setLibraries] = useState<FlashcardLibrarySummary[]>([]); const [libraries, setLibraries] = useState<FlashcardLibrarySummary[]>([]);
const [selectedLibraryIds, setSelectedLibraryIds] = useState<number[]>([]); const [selectedLibraryIds, setSelectedLibraryIds] = useState<number[]>([]);
@@ -120,16 +44,6 @@ export function FlashcardReviewComponent() {
const [loadingSession, setLoadingSession] = useState(false); const [loadingSession, setLoadingSession] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]); const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [submittingAnswer, setSubmittingAnswer] = useState(false);
const [cardExiting, setCardExiting] = useState(false);
const [stampState, setStampState] = useState<'correct' | 'wrong' | null>(null);
const [flipped, setFlipped] = useState(false);
const [sessionAnswers, setSessionAnswers] = useState<Record<number, boolean>>({});
const confettiRef = useRef<HTMLCanvasElement>(null);
const flashcardRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -160,46 +74,21 @@ export function FlashcardReviewComponent() {
}; };
}, []); }, []);
useEffect(() => {
if (showAnswer && flipped) return;
if (!showAnswer && !flipped) return;
setFlipped(showAnswer);
}, [showAnswer, flipped]);
const groupedLibraries = useMemo(() => groupLibrariesBySubject(libraries), [libraries]); const groupedLibraries = useMemo(() => groupLibrariesBySubject(libraries), [libraries]);
const libraryById = useMemo(() => { const libraryMetaById = useMemo(() => {
return new Map(libraries.map((library) => [library.id, library])); return new Map<number, FlashcardStudySessionLibraryMeta>(
libraries.map((library) => [
library.id,
{
fileName: library.fileName,
subject: library.subject || 'Geral',
difficultyLabel: difficultyLabel(library.difficulty),
},
]),
);
}, [libraries]); }, [libraries]);
const currentCard = sessionCards[currentIndex];
const progressPercent = sessionCards.length > 0
? ((currentIndex + 1) / sessionCards.length) * 100
: 0;
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (sessionCards.length === 0 || !currentCard) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) return;
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
if (!showAnswer) {
setShowAnswer(true);
}
} else if (e.code === 'KeyC' && showAnswer && !submittingAnswer) {
e.preventDefault();
registerReviewAnswer(true);
} else if (e.code === 'KeyW' && showAnswer && !submittingAnswer) {
e.preventDefault();
registerReviewAnswer(false);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [sessionCards.length, showAnswer, submittingAnswer, currentCard]);
const toggleLibrary = (libraryId: number) => { const toggleLibrary = (libraryId: number) => {
if (selectedLibraryIds.includes(libraryId)) { if (selectedLibraryIds.includes(libraryId)) {
setSelectedLibraryIds(selectedLibraryIds.filter((id) => id !== libraryId)); setSelectedLibraryIds(selectedLibraryIds.filter((id) => id !== libraryId));
@@ -210,27 +99,28 @@ export function FlashcardReviewComponent() {
const startReviewSession = async () => { const startReviewSession = async () => {
if (selectedLibraryIds.length === 0) { if (selectedLibraryIds.length === 0) {
setError('Selecione ao menos uma biblioteca para iniciar a revisao.'); setError('Selecione ao menos uma biblioteca para iniciar a revisão.');
return; return;
} }
setLoadingSession(true); setLoadingSession(true);
setError(null); setError(null);
setSessionCards([]);
try { try {
const response = await MindforgeApiService.createFlashcardReviewSession({ const response = await MindforgeApiService.createFlashcardReviewSession({
libraryIds: selectedLibraryIds, libraryIds: selectedLibraryIds,
}); });
const shuffledCards = shuffleCards(response.cards);
setSessionCards(shuffleCards(response.cards)); if (shuffledCards.length === 0) {
setCurrentIndex(0); setError('As bibliotecas selecionadas não possuem cartões para revisar.');
setShowAnswer(false); return;
setFlipped(false); }
setStampState(null);
setCardExiting(false); setSessionCards(shuffledCards);
setSessionAnswers({});
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Falha ao iniciar sessao de revisao.'); setError(err?.message || 'Falha ao iniciar sessão de revisão.');
} finally { } finally {
setLoadingSession(false); setLoadingSession(false);
} }
@@ -238,87 +128,20 @@ export function FlashcardReviewComponent() {
const endSession = () => { const endSession = () => {
setSessionCards([]); setSessionCards([]);
setCurrentIndex(0);
setShowAnswer(false);
setSubmittingAnswer(false);
setFlipped(false);
setStampState(null);
setCardExiting(false);
setSessionAnswers({});
}; };
const goToPrevious = () => { const recordReviewAnswer = async (card: FlashcardCard, correct: boolean) => {
if (currentIndex === 0) return;
setCurrentIndex(currentIndex - 1);
setShowAnswer(false);
setFlipped(false);
setStampState(null);
};
const advanceCard = () => {
if (currentIndex >= sessionCards.length - 1) {
endSession();
return;
}
setCurrentIndex(currentIndex + 1);
setShowAnswer(false);
setFlipped(false);
setStampState(null);
};
const registerReviewAnswer = async (correct: boolean) => {
if (!currentCard) return;
setSubmittingAnswer(true);
setError(null); setError(null);
if (correct) {
setStampState('correct');
if (confettiRef.current) {
fireConfetti(confettiRef.current);
}
} else {
setStampState('wrong');
}
setCardExiting(true);
setTimeout(() => {
setCardExiting(false);
setStampState(null);
}, 600);
try {
await MindforgeApiService.recordFlashcardReviewAnswer({ await MindforgeApiService.recordFlashcardReviewAnswer({
cardId: currentCard.id, cardId: card.id,
correct, correct,
}); });
setSessionAnswers((currentAnswers) => ({
...currentAnswers,
[currentCard.id]: correct,
}));
setTimeout(() => {
advanceCard();
setSubmittingAnswer(false);
}, 580);
} catch (err: any) {
setError(err?.message || 'Falha ao registrar resposta da revisao.');
setSubmittingAnswer(false);
setCardExiting(false);
setStampState(null);
}
}; };
const correctCount = Object.values(sessionAnswers).filter(Boolean).length;
const remainingCount = sessionCards.length - currentIndex;
return ( return (
<div className="review-container"> <div className="review-container">
<canvas ref={confettiRef} class="confetti-canvas" /> <h2 className="review-title">Revisão de Flashcards</h2>
<h2 className="review-title">Revisao Flashcards</h2> <p className="review-subtitle">Escolha as bibliotecas para estudar e inicie uma sessão de revisão.</p>
<p className="review-subtitle">Escolha as bibliotecas para estudar e inicie uma sessao de revisao.</p>
{error && <div className="review-error">{error}</div>} {error && <div className="review-error">{error}</div>}
@@ -326,7 +149,7 @@ export function FlashcardReviewComponent() {
<div className="review-select-panel"> <div className="review-select-panel">
{loadingLibraries && <p className="review-state">Carregando bibliotecas...</p>} {loadingLibraries && <p className="review-state">Carregando bibliotecas...</p>}
{!loadingLibraries && libraries.length === 0 && ( {!loadingLibraries && libraries.length === 0 && (
<p className="review-state">Nenhuma biblioteca encontrada. Gere flashcards para comecar.</p> <p className="review-state">Nenhuma biblioteca encontrada. Gere flashcards para começar.</p>
)} )}
{!loadingLibraries && libraries.length > 0 && ( {!loadingLibraries && libraries.length > 0 && (
@@ -345,7 +168,7 @@ export function FlashcardReviewComponent() {
<div className="review-library-texts"> <div className="review-library-texts">
<strong>{library.fileName}</strong> <strong>{library.fileName}</strong>
<span> <span>
{library.cardCount} cards - {difficultyLabel(library.difficulty)} {library.cardCount} cartões - {difficultyLabel(library.difficulty)}
</span> </span>
</div> </div>
</label> </label>
@@ -358,144 +181,19 @@ export function FlashcardReviewComponent() {
<div className="review-actions"> <div className="review-actions">
<Button variant="primary" onClick={startReviewSession} disabled={loadingSession || selectedLibraryIds.length === 0}> <Button variant="primary" onClick={startReviewSession} disabled={loadingSession || selectedLibraryIds.length === 0}>
{loadingSession ? 'Iniciando...' : 'Iniciar Revisao'} {loadingSession ? 'Iniciando...' : 'Iniciar Revisão'}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{sessionCards.length > 0 && currentCard && ( {sessionCards.length > 0 && (
<div class="content-grid"> <FlashcardStudySession
<div class="review-panel"> cards={sessionCards}
<div class="session-header"> libraryMetaById={libraryMetaById}
<div class="session-title"> onAnswer={recordReviewAnswer}
<h3>Sessao de Revisao</h3> onEnd={endSession}
<p>{currentIndex + 1} de {sessionCards.length} cards</p> />
</div>
<div class="score-pill">
<b>{correctCount}</b>
<span>Corretos</span>
</div>
</div>
<div class="stage">
<div
ref={flashcardRef}
class={`flashcard${flipped ? ' is-flipped' : ''}${cardExiting ? ' is-reviewed' : ''}`}
onClick={() => { if (!showAnswer && !submittingAnswer) setShowAnswer(true); }}
tabIndex={0}
role="button"
aria-label={showAnswer ? 'Flashcard revelado' : 'Clique ou pressione Espaco para revelar'}
>
<div class="card-face">
{stampState && (
<div class={`stamp ${stampState}${stampState ? ' show' : ''}`}>
{stampState === 'correct' ? 'Correto!' : 'Errado'}
</div>
)}
<div class="card-meta">
<span class="tag">{libraryById.get(currentCard.libraryId)?.subject || 'Geral'}</span>
<span>{difficultyLabel(libraryById.get(currentCard.libraryId)?.difficulty || 'Easy')}</span>
</div>
<div class="card-question">
{currentCard.front}
</div>
<div class="card-footer">
<span>{libraryById.get(currentCard.libraryId)?.fileName || 'Arquivo'}</span>
<span class="spacebar">Espaco</span>
</div>
</div>
<div class="card-face card-back">
<div class="card-meta">
<span class="tag">Resposta</span>
<span>{difficultyLabel(libraryById.get(currentCard.libraryId)?.difficulty || 'Easy')}</span>
</div>
<div class="card-answer">
{currentCard.back}
</div>
<div class="card-footer">
<span>{libraryById.get(currentCard.libraryId)?.fileName || 'Arquivo'}</span>
<span class="spacebar">C / W</span>
</div>
</div>
</div>
</div>
<div class={`controls${showAnswer ? ' ready' : ''}`}>
<button
class="review-button correct"
onClick={() => registerReviewAnswer(true)}
disabled={!showAnswer || submittingAnswer}
>
Correto
</button>
<button
class="review-button wrong"
onClick={() => registerReviewAnswer(false)}
disabled={!showAnswer || submittingAnswer}
>
Incorreto
</button>
</div>
<div class="review-nav-row">
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0 || submittingAnswer}>
Anterior
</Button>
<Button variant="secondary" onClick={endSession}>
Encerrar Sessao
</Button>
</div>
</div>
<div class="side-panel">
<div class="panel-card">
<h3>Progresso</h3>
<div class="stat-grid">
<div class="stat">
<b>{currentIndex + 1}/{sessionCards.length}</b>
<span>Atual</span>
</div>
<div class="stat">
<b>{correctCount}</b>
<span>Corretos</span>
</div>
</div>
<div class="track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
<div class="panel-card">
<h3>Fila</h3>
<div class="queue">
{sessionCards.slice(currentIndex, currentIndex + 5).map((card, idx) => (
<div key={card.id} class="queue-item">
<span class="queue-number">{currentIndex + idx + 1}</span>
<strong>{card.front.substring(0, 40)}{card.front.length > 40 ? '...' : ''}</strong>
<span>{libraryById.get(card.libraryId)?.subject || ''}</span>
</div>
))}
{remainingCount > 5 && (
<div style="text-align:center;color:var(--muted);font-size:12px;padding:4px">
+{remainingCount - 5} restantes
</div>
)}
</div>
</div>
</div>
</div>
)}
{sessionCards.length > 0 && !currentCard && (
<div class="session-end">
<h3>Sessao concluida!</h3>
<p>Todos os cards foram revisados.</p>
<div style="margin-top:16px">
<Button variant="primary" onClick={endSession}>Voltar a selecao</Button>
</div>
</div>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,644 @@
.flashcard-study {
width: 100%;
}
.study-error {
color: var(--red);
font-size: 0.9rem;
background: rgba(183, 91, 77, 0.08);
border: 1px solid rgba(183, 91, 77, 0.2);
border-radius: var(--radius-md);
padding: 0.8rem 1rem;
margin-bottom: 1rem;
}
.study-content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 24px;
align-items: start;
}
.study-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;
}
.study-panel::before,
.study-panel::after {
content: "";
position: absolute;
border-radius: 999px;
pointer-events: none;
filter: blur(2px);
opacity: .5;
}
.study-panel::before {
width: 220px;
height: 220px;
right: -92px;
top: 100px;
background: rgba(63, 124, 172, .13);
}
.study-panel::after {
width: 180px;
height: 180px;
left: -70px;
bottom: 60px;
background: rgba(199, 149, 57, .18);
}
.study-session-header {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.study-session-title h3 {
margin: 0;
font-family: Georgia, serif;
font-size: clamp(24px, 3vw, 36px);
letter-spacing: -.04em;
color: var(--ink);
}
.study-session-title p {
max-width: 660px;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
margin: 4px 0 0;
}
.study-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);
}
.study-score-pill b {
display: block;
font-size: 19px;
color: var(--ink);
}
.study-score-pill span {
color: var(--muted);
font-size: 10px;
font-weight: 950;
letter-spacing: .12em;
text-transform: uppercase;
}
.study-stage {
position: relative;
z-index: 1;
display: grid;
place-items: center;
min-height: 390px;
perspective: 1400px;
padding: 22px 0;
}
.study-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;
}
.study-flashcard:hover .study-card-face {
border-color: rgba(63, 124, 172, .28);
}
.study-flashcard.is-flipped {
transform: rotateY(180deg);
}
.study-flashcard.is-reviewed {
animation: studyCardExit .58s var(--ease);
}
.study-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;
}
.study-card-face::before {
content: "";
position: absolute;
inset: 18px;
border: 1px dashed rgba(82, 54, 17, .18);
border-radius: 22px;
pointer-events: none;
}
.study-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;
}
.study-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);
}
.study-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;
}
.study-tag,
.study-status-badge {
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);
}
.study-status-badge {
font-size: 11px;
line-height: 1;
}
.study-status-icon {
width: 16px;
height: 16px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(82, 54, 17, .10);
font-size: 11px;
}
.study-status-badge.rag-red {
color: var(--red-deep);
background: rgba(183, 91, 77, 0.12);
border-color: rgba(183, 91, 77, 0.22);
}
.study-status-badge.rag-amber {
color: #74531c;
background: rgba(199, 149, 57, 0.14);
border-color: rgba(199, 149, 57, 0.24);
}
.study-status-badge.rag-green {
color: var(--green-deep);
background: rgba(79, 143, 90, 0.12);
border-color: rgba(79, 143, 90, 0.22);
}
.study-status-badge.rag-grey {
color: var(--muted);
background: rgba(123, 106, 80, 0.10);
border-color: rgba(123, 106, 80, 0.18);
}
.study-card-question,
.study-card-answer {
position: relative;
z-index: 1;
display: grid;
align-content: center;
gap: 16px;
min-height: 190px;
color: var(--ink);
font-family: "Segoe UI", Inter, Avenir, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
.study-card-question {
font-size: clamp(20px, 2.65vw, 24px);
line-height: 1.5;
}
.study-back-question,
.study-back-answer {
display: grid;
gap: 6px;
}
.study-back-question span,
.study-back-answer span {
color: var(--blue-deep);
font-size: 11px;
font-weight: 950;
letter-spacing: .12em;
text-transform: uppercase;
}
.study-back-question p,
.study-back-answer p {
margin: 0;
color: var(--ink);
}
.study-back-question p {
color: #66543d;
font-size: clamp(15px, 1.7vw, 18px);
line-height: 1.45;
}
.study-back-answer p {
font-size: clamp(20px, 2.65vw, 24px);
line-height: 1.5;
}
.study-card-details {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.study-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;
}
.study-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;
}
.study-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);
margin-top: 20px;
}
.study-controls.ready {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.study-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-family: inherit;
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);
}
.study-review-button.correct {
background: linear-gradient(135deg, var(--green), var(--green-deep));
}
.study-review-button.wrong {
background: linear-gradient(135deg, var(--red), var(--red-deep));
}
.study-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);
}
.study-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);
}
.study-review-button:hover::before {
transform: rotate(20deg) translateX(80%);
}
.study-review-button:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none !important;
}
.study-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;
}
.study-stamp.correct {
color: var(--green-deep);
}
.study-stamp.wrong {
color: var(--red-deep);
}
.study-stamp.show {
animation: studyStampIn .7s var(--ease);
}
.study-nav-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
justify-content: center;
}
.study-side-panel {
display: grid;
gap: 18px;
}
.study-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);
}
.study-panel-card h3 {
margin: 0 0 14px;
font-family: Georgia, serif;
font-size: 22px;
letter-spacing: -.03em;
color: var(--ink);
}
.study-stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.study-stat {
padding: 14px;
border-radius: 18px;
background: rgba(255,255,255,.52);
border: 1px solid rgba(82, 54, 17, .10);
}
.study-stat b {
display: block;
font-size: 24px;
letter-spacing: -.04em;
color: var(--ink);
}
.study-stat span {
color: var(--muted);
font-size: 11px;
font-weight: 950;
letter-spacing: .10em;
text-transform: uppercase;
}
.study-track {
height: 14px;
border-radius: 999px;
overflow: hidden;
background: rgba(80, 54, 18, .12);
margin-top: 8px;
}
.study-track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--red), var(--gold), var(--green));
}
.study-queue {
display: grid;
gap: 10px;
}
.study-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);
}
.study-queue-item strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ink);
font-size: 13px;
}
.study-queue-item span {
color: var(--muted);
font-size: 11px;
}
.study-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;
font-size: 14px;
}
.study-queue-more {
text-align: center;
color: var(--muted);
font-size: 12px;
padding: 4px;
}
.study-confetti-canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
}
.study-session-end {
text-align: center;
padding: 3rem 1rem;
color: var(--muted);
}
.study-session-end h3 {
font-family: Georgia, serif;
font-size: 28px;
color: var(--ink);
margin: 0 0 0.5rem;
}
.study-session-end-actions {
margin-top: 16px;
}
@keyframes studyCardExit {
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; }
}
@keyframes studyStampIn {
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); }
}
@media (max-width: 1120px) {
.study-content-grid {
grid-template-columns: 1fr;
}
.study-side-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.study-session-header {
flex-direction: column;
}
.study-panel {
min-height: auto;
}
.study-stage {
min-height: 420px;
}
.study-flashcard,
.study-card-face {
min-height: 380px;
}
.study-side-panel {
grid-template-columns: 1fr;
}
.study-review-button {
width: 100%;
}
.study-controls {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -0,0 +1,410 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import type { FlashcardCard } from '../services/MindforgeApiService';
import { Button } from './Button';
import './FlashcardStudySession.css';
export interface FlashcardStudySessionLibraryMeta {
fileName?: string;
subject?: string;
subSubject?: string;
difficultyLabel?: string;
statusLabel?: string;
statusIcon?: string;
statusClassName?: string;
footerDetails?: string[];
}
interface FlashcardStudySessionProps {
cards: FlashcardCard[];
libraryMetaById: Map<number, FlashcardStudySessionLibraryMeta>;
onAnswer: (card: FlashcardCard, correct: boolean) => Promise<void>;
onEnd: () => void;
}
interface ConfettiParticle {
x: number;
y: number;
vx: number;
vy: number;
color: string;
size: number;
life: number;
maxLife: number;
rotation: number;
rotationSpeed: number;
}
function fireConfetti(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const c = ctx;
const w = canvas.width = window.innerWidth;
const h = canvas.height = window.innerHeight;
const colors = ['#4f8f5a', '#3f7cac', '#c79539', '#7e65a8', '#f2dfb3', '#b75b4d'];
const particles: ConfettiParticle[] = [];
for (let i = 0; i < 120; i++) {
particles.push({
x: Math.random() * w,
y: -20 - Math.random() * h * 0.5,
vx: (Math.random() - 0.5) * 6,
vy: Math.random() * 5 + 2,
color: colors[Math.floor(Math.random() * colors.length)],
size: Math.random() * 8 + 4,
life: 0,
maxLife: 80 + Math.random() * 60,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.3,
});
}
let animating = true;
function animate() {
if (!animating) return;
c.clearRect(0, 0, w, h);
let alive = 0;
for (const particle of particles) {
particle.life++;
if (particle.life >= particle.maxLife) continue;
alive++;
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += 0.08;
particle.vx *= 0.995;
particle.rotation += particle.rotationSpeed;
const alpha = 1 - particle.life / particle.maxLife;
c.save();
c.globalAlpha = alpha;
c.translate(particle.x, particle.y);
c.rotate(particle.rotation);
c.fillStyle = particle.color;
c.fillRect(-particle.size / 2, -particle.size / 4, particle.size, particle.size / 2);
c.restore();
}
if (alive > 0) {
requestAnimationFrame(animate);
} else {
c.clearRect(0, 0, w, h);
animating = false;
}
}
requestAnimationFrame(animate);
}
function resetTimeout(timeoutRef: { current: number | null }) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}
function formatFooter(meta: FlashcardStudySessionLibraryMeta | undefined) {
const main = [meta?.fileName, meta?.subSubject].filter(Boolean).join(' - ');
return main || 'Arquivo';
}
export function FlashcardStudySession({ cards, libraryMetaById, onAnswer, onEnd }: FlashcardStudySessionProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [submittingAnswer, setSubmittingAnswer] = useState(false);
const [cardExiting, setCardExiting] = useState(false);
const [stampState, setStampState] = useState<'correct' | 'wrong' | null>(null);
const [flipped, setFlipped] = useState(false);
const [sessionAnswers, setSessionAnswers] = useState<Record<number, boolean>>({});
const [submissionError, setSubmissionError] = useState<string | null>(null);
const confettiRef = useRef<HTMLCanvasElement>(null);
const stampTimerRef = useRef<number | null>(null);
const advanceTimerRef = useRef<number | null>(null);
const currentCard = cards[currentIndex];
const currentMeta = currentCard ? libraryMetaById.get(currentCard.libraryId) : undefined;
const correctCount = Object.values(sessionAnswers).filter(Boolean).length;
const remainingCount = cards.length - currentIndex;
const progressPercent = cards.length > 0
? ((currentIndex + 1) / cards.length) * 100
: 0;
useEffect(() => {
setCurrentIndex(0);
setShowAnswer(false);
setSubmittingAnswer(false);
setCardExiting(false);
setStampState(null);
setFlipped(false);
setSessionAnswers({});
setSubmissionError(null);
}, [cards]);
useEffect(() => {
return () => {
resetTimeout(stampTimerRef);
resetTimeout(advanceTimerRef);
};
}, []);
useEffect(() => {
if (showAnswer && flipped) return;
if (!showAnswer && !flipped) return;
setFlipped(showAnswer);
}, [showAnswer, flipped]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (cards.length === 0 || !currentCard) return;
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) return;
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
if (!showAnswer) {
setShowAnswer(true);
}
} else if (e.code === 'KeyC' && showAnswer && !submittingAnswer) {
e.preventDefault();
void registerReviewAnswer(true);
} else if (e.code === 'KeyW' && showAnswer && !submittingAnswer) {
e.preventDefault();
void registerReviewAnswer(false);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [cards.length, showAnswer, submittingAnswer, currentCard]);
const goToPrevious = () => {
if (currentIndex === 0 || submittingAnswer) return;
setCurrentIndex(currentIndex - 1);
setShowAnswer(false);
setFlipped(false);
setStampState(null);
setCardExiting(false);
setSubmissionError(null);
};
const advanceCard = () => {
if (currentIndex >= cards.length - 1) {
onEnd();
return;
}
setCurrentIndex(currentIndex + 1);
setShowAnswer(false);
setFlipped(false);
setStampState(null);
setCardExiting(false);
setSubmittingAnswer(false);
setSubmissionError(null);
};
const registerReviewAnswer = async (correct: boolean) => {
if (!currentCard || !showAnswer || submittingAnswer) return;
resetTimeout(stampTimerRef);
resetTimeout(advanceTimerRef);
setSubmittingAnswer(true);
setSubmissionError(null);
try {
await onAnswer(currentCard, correct);
setStampState(correct ? 'correct' : 'wrong');
if (correct && confettiRef.current) {
fireConfetti(confettiRef.current);
}
setSessionAnswers((currentAnswers) => ({
...currentAnswers,
[currentCard.id]: correct,
}));
stampTimerRef.current = window.setTimeout(() => {
setCardExiting(true);
stampTimerRef.current = null;
}, 90);
advanceTimerRef.current = window.setTimeout(() => {
advanceCard();
advanceTimerRef.current = null;
}, 760);
} catch (err: any) {
resetTimeout(stampTimerRef);
resetTimeout(advanceTimerRef);
setSubmissionError(err?.message || 'Falha ao registrar resposta da revisão.');
setSubmittingAnswer(false);
setCardExiting(false);
setStampState(null);
}
};
if (!currentCard) {
return (
<div class="study-session-end">
<h3>Sessão concluída!</h3>
<p>Todos os cartões foram revisados.</p>
<div class="study-session-end-actions">
<Button variant="primary" onClick={onEnd}>Voltar à seleção</Button>
</div>
</div>
);
}
return (
<div class="flashcard-study">
<canvas ref={confettiRef} class="study-confetti-canvas" />
{submissionError && <div className="study-error">{submissionError}</div>}
<div class="study-content-grid">
<div class="study-panel">
<div class="study-session-header">
<div class="study-session-title">
<h3>Sessão de Revisão</h3>
<p>{currentIndex + 1} de {cards.length} cartões</p>
</div>
<div class="study-score-pill">
<b>{correctCount}</b>
<span>Corretos</span>
</div>
</div>
<div class="study-stage">
<div
class={`study-flashcard${flipped ? ' is-flipped' : ''}${cardExiting ? ' is-reviewed' : ''}`}
onClick={() => { if (!showAnswer && !submittingAnswer) setShowAnswer(true); }}
tabIndex={0}
role="button"
aria-label={showAnswer ? 'Flashcard revelado' : 'Clique ou pressione Espaço para revelar'}
>
<div class="study-card-face">
<div class="study-card-meta">
<span class="study-tag">{currentMeta?.subject || 'Geral'}</span>
<span>{currentMeta?.difficultyLabel || currentMeta?.statusLabel || 'Revisão'}</span>
</div>
<div class="study-card-question">
{currentCard.front}
</div>
<div class="study-card-footer">
<span>{formatFooter(currentMeta)}</span>
<span class="study-spacebar">Espaço</span>
</div>
</div>
<div class="study-card-face study-card-back">
{stampState && (
<div class={`study-stamp ${stampState} show`}>
{stampState === 'correct' ? 'Correto' : 'Incorreto'}
</div>
)}
<div class="study-card-meta">
<span class="study-tag">Resposta</span>
{currentMeta?.statusLabel ? (
<span class={`study-status-badge ${currentMeta.statusClassName || ''}`}>
{currentMeta.statusIcon && <span class="study-status-icon">{currentMeta.statusIcon}</span>}
{currentMeta.statusLabel}
</span>
) : (
<span>{currentMeta?.difficultyLabel || 'Revisão'}</span>
)}
</div>
<div class="study-card-answer">
<div class="study-back-question">
<span>Pergunta</span>
<p>{currentCard.front}</p>
</div>
<div class="study-back-answer">
<span>Resposta</span>
<p>{currentCard.back}</p>
</div>
{currentMeta?.footerDetails && currentMeta.footerDetails.length > 0 && (
<div class="study-card-details">
{currentMeta.footerDetails.map((detail) => (
<span key={detail}>{detail}</span>
))}
</div>
)}
</div>
<div class="study-card-footer">
<span>{formatFooter(currentMeta)}</span>
<span class="study-spacebar">C / W</span>
</div>
</div>
</div>
</div>
<div class={`study-controls${showAnswer ? ' ready' : ''}`}>
<button
class="study-review-button correct"
onClick={() => void registerReviewAnswer(true)}
disabled={!showAnswer || submittingAnswer}
>
Correto
</button>
<button
class="study-review-button wrong"
onClick={() => void registerReviewAnswer(false)}
disabled={!showAnswer || submittingAnswer}
>
Incorreto
</button>
</div>
<div class="study-nav-row">
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0 || submittingAnswer}>
Anterior
</Button>
<Button variant="secondary" onClick={onEnd}>
Encerrar Sessão
</Button>
</div>
</div>
<div class="study-side-panel">
<div class="study-panel-card">
<h3>Progresso</h3>
<div class="study-stat-grid">
<div class="study-stat">
<b>{currentIndex + 1}/{cards.length}</b>
<span>Atual</span>
</div>
<div class="study-stat">
<b>{correctCount}</b>
<span>Corretos</span>
</div>
</div>
<div class="study-track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
<div class="study-panel-card">
<h3>Fila</h3>
<div class="study-queue">
{cards.slice(currentIndex, currentIndex + 5).map((card, index) => {
const meta = libraryMetaById.get(card.libraryId);
return (
<div key={card.id} class="study-queue-item">
<span class="study-queue-number">{currentIndex + index + 1}</span>
<strong>{card.front.substring(0, 40)}{card.front.length > 40 ? '...' : ''}</strong>
<span>{meta?.subject || ''}</span>
</div>
);
})}
{remainingCount > 5 && (
<div class="study-queue-more">
+{remainingCount - 5} restantes
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -24,7 +24,7 @@ export function Header({ onGoHome }: HeaderProps) {
{repoName && ( {repoName && (
<div class="topbar-right"> <div class="topbar-right">
<span class="chip"> <span class="chip">
<span style="font-size:10px;font-weight:950;letter-spacing:.14em;text-transform:uppercase;color:var(--blue-deep)">Repo</span> <span style="font-size:10px;font-weight:950;letter-spacing:.14em;text-transform:uppercase;color:var(--blue-deep)">Repositório</span>
{repoName} {repoName}
</span> </span>
</div> </div>

View File

@@ -18,6 +18,24 @@
align-items: center; align-items: center;
gap: 14px; gap: 14px;
padding: 12px 12px 24px; padding: 12px 12px 24px;
width: 100%;
border: 0;
border-radius: 18px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background .25s var(--ease);
}
.brand:hover {
background: rgba(255,255,255,.34);
}
.brand:focus-visible {
outline: 2px solid rgba(63, 124, 172, .52);
outline-offset: 3px;
} }
.brand-mark { .brand-mark {
@@ -66,11 +84,13 @@
gap: 4px; gap: 4px;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
} }
.nav-item { .nav-item {
position: relative; position: relative;
width: 100%; width: calc(100% - 4px);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -165,9 +185,13 @@
.nav-list { .nav-list {
flex-direction: row; flex-direction: row;
flex: 0 0 auto;
overflow: visible;
padding-right: 0;
} }
.nav-item { .nav-item {
width: auto;
min-width: 56px; min-width: 56px;
justify-content: center; justify-content: center;
} }

View File

@@ -8,20 +8,20 @@ interface SidebarProps {
const NAV_ITEMS: { module: SidebarProps['activeModule']; icon: string; label: string }[] = [ const NAV_ITEMS: { module: SidebarProps['activeModule']; icon: string; label: string }[] = [
{ module: 'verificador', icon: '\u2713', label: 'Verificador' }, { module: 'verificador', icon: '\u2713', label: 'Verificador' },
{ module: 'flashcards', icon: '\u25A6', label: 'Flashcards' }, { module: 'flashcards', icon: '\u25A6', label: 'Flashcards' },
{ module: 'revisao-flashcards', icon: '\u25B3', label: 'Revisao Flashcards' }, { module: 'revisao-flashcards', icon: '\u25B3', label: 'Revisão de Flashcards' },
{ module: 'revisao-espacada', icon: '\u25CB', label: 'Revisao Espacada' }, { module: 'revisao-espacada', icon: '\u25CB', label: 'Revisão espaçada' },
]; ];
export function Sidebar({ onModuleChange, activeModule }: SidebarProps) { export function Sidebar({ onModuleChange, activeModule }: SidebarProps) {
return ( return (
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <button class="brand" type="button" onClick={() => onModuleChange('home')} aria-label="Ir para a página inicial">
<div class="brand-mark"> <div class="brand-mark">
<img src="/assets/mindforge.png" alt="M" /> <img src="/assets/mindforge.png" alt="M" />
</div> </div>
<h1>Mindforge</h1> <h1>Mindforge</h1>
</div> </button>
<div class="nav-section-title">Modulos</div> <div class="nav-section-title">Módulos</div>
<nav class="nav-list"> <nav class="nav-list">
{NAV_ITEMS.map(({ module, icon, label }) => ( {NAV_ITEMS.map(({ module, icon, label }) => (
<button <button

View File

@@ -318,193 +318,8 @@
color: var(--ink); color: var(--ink);
} }
/* Session Panel */
.spaced-review-session-panel {
background: rgba(255, 250, 239, .68);
border: 1px solid rgba(104, 69, 22, .13);
border-radius: var(--radius-xl);
padding: clamp(18px, 3vw, 34px);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
text-align: left;
}
.spaced-review-progress {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 1.2rem;
}
.spaced-review-progress span {
font-size: 0.9rem;
color: var(--ink);
min-width: 60px;
font-weight: 800;
}
.spaced-review-progress-bar {
width: 100%;
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(80, 54, 18, .12);
}
.spaced-review-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--blue), #79a9c8, var(--gold));
border-radius: 999px;
}
.spaced-review-card {
border: 1px solid rgba(82, 54, 17, .14);
border-radius: var(--radius-lg);
padding: 1.25rem;
background: rgba(255,255,255,.52);
min-height: 260px;
}
.spaced-review-card header {
margin-bottom: 0.8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
flex-wrap: wrap;
}
.spaced-review-card small {
color: var(--muted);
font-size: 0.85rem;
}
.spaced-review-card h3 {
margin: 0 0 0.4rem;
font-size: 0.85rem;
font-weight: 950;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--blue-deep);
}
.spaced-review-card p {
margin: 0 0 1rem;
font-size: 1.05rem;
line-height: 1.6;
color: var(--ink);
font-family: "Segoe UI", Inter, system-ui, sans-serif;
}
.spaced-review-card-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
color: var(--muted);
font-size: 0.84rem;
}
.spaced-review-session-actions {
margin-top: 1rem;
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
}
/* Review content grid for session mode */
.spaced-review-content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 24px;
align-items: start;
}
.spaced-review-side-panel {
display: grid;
gap: 18px;
}
.spaced-review-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);
}
.spaced-review-panel-card h3 {
margin: 0 0 14px;
font-family: Georgia, serif;
font-size: 22px;
letter-spacing: -.03em;
color: var(--ink);
}
.spaced-review-stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.spaced-review-stat {
padding: 14px;
border-radius: 18px;
background: rgba(255,255,255,.52);
border: 1px solid rgba(82, 54, 17, .10);
}
.spaced-review-stat b {
display: block;
font-size: 24px;
letter-spacing: -.04em;
color: var(--ink);
}
.spaced-review-stat span {
color: var(--muted);
font-size: 11px;
font-weight: 950;
letter-spacing: .10em;
text-transform: uppercase;
}
.spaced-review-track {
height: 14px;
border-radius: 999px;
overflow: hidden;
background: rgba(80, 54, 18, .12);
margin-top: 8px;
}
.spaced-review-track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--red), var(--gold), var(--green));
}
@media (max-width: 1120px) {
.spaced-review-content-grid {
grid-template-columns: 1fr;
}
.spaced-review-side-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.spaced-review-library-item { .spaced-review-library-item {
flex-wrap: wrap; flex-wrap: wrap;
} }
.spaced-review-session-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.spaced-review-side-panel {
grid-template-columns: 1fr;
}
} }

View File

@@ -7,6 +7,10 @@ import {
type FlashcardRagStatus, type FlashcardRagStatus,
} from '../services/MindforgeApiService'; } from '../services/MindforgeApiService';
import { Button } from './Button'; import { Button } from './Button';
import {
FlashcardStudySession,
type FlashcardStudySessionLibraryMeta,
} from './FlashcardStudySession';
import './SpacedReviewComponent.css'; import './SpacedReviewComponent.css';
interface RagStatusOption { interface RagStatusOption {
@@ -59,10 +63,10 @@ function formatPercentage(value: number) {
function summaryText(activeCount: number, greenPercentage: number, attentionPercentage: number) { function summaryText(activeCount: number, greenPercentage: number, attentionPercentage: number) {
if (activeCount === 0) { if (activeCount === 0) {
return 'Sem revisoes avaliaveis'; return 'Sem revisões avaliáveis';
} }
return `Verde ${formatPercentage(greenPercentage)} | Atencao ${formatPercentage(attentionPercentage)}`; return `Verde ${formatPercentage(greenPercentage)} | Atenção ${formatPercentage(attentionPercentage)}`;
} }
function formatPerformance(rate: number) { function formatPerformance(rate: number) {
@@ -76,7 +80,7 @@ function formatLastReviewed(value?: string | null) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return 'Data invalida'; return 'Data inválida';
} }
return date.toLocaleDateString('pt-BR'); return date.toLocaleDateString('pt-BR');
@@ -105,9 +109,6 @@ export function SpacedReviewComponent() {
const [startingSession, setStartingSession] = useState(false); const [startingSession, setStartingSession] = useState(false);
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]); const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
const [sessionLibraries, setSessionLibraries] = useState<FlashcardRagLibrary[]>([]); const [sessionLibraries, setSessionLibraries] = useState<FlashcardRagLibrary[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [showAnswer, setShowAnswer] = useState(false);
const [submittingAnswer, setSubmittingAnswer] = useState(false);
const loadDashboard = async (preserveSelection: boolean) => { const loadDashboard = async (preserveSelection: boolean) => {
setLoading(true); setLoading(true);
@@ -140,7 +141,7 @@ export function SpacedReviewComponent() {
return ['Red', 'Amber', 'Green', 'Grey']; return ['Red', 'Amber', 'Green', 'Grey'];
}); });
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Falha ao carregar status de revisao espacada.'); setError(err?.message || 'Falha ao carregar status de revisão espaçada.');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -165,12 +166,12 @@ export function SpacedReviewComponent() {
const startSession = async () => { const startSession = async () => {
if (selectedStatuses.length === 0) { if (selectedStatuses.length === 0) {
setError('Selecione ao menos um status para iniciar a revisao.'); setError('Selecione ao menos um status para iniciar a revisão.');
return; return;
} }
if (selectedLibraryIds.length === 0) { if (selectedLibraryIds.length === 0) {
setError('Selecione ao menos um arquivo para iniciar a revisao.'); setError('Selecione ao menos um arquivo para iniciar a revisão.');
return; return;
} }
@@ -181,6 +182,8 @@ export function SpacedReviewComponent() {
setStartingSession(true); setStartingSession(true);
setError(null); setError(null);
setSessionCards([]);
setSessionLibraries([]);
try { try {
const ragByLibraryId = new Map(selectedRagLibraries.map((library) => [library.libraryId, library])); const ragByLibraryId = new Map(selectedRagLibraries.map((library) => [library.libraryId, library]));
@@ -191,16 +194,14 @@ export function SpacedReviewComponent() {
const orderedCards = orderCardsForSession(filteredCards, ragByLibraryId); const orderedCards = orderCardsForSession(filteredCards, ragByLibraryId);
if (orderedCards.length === 0) { if (orderedCards.length === 0) {
setError('Os filtros selecionados nao retornaram cards para revisar.'); setError('Os filtros selecionados não retornaram cartões para revisar.');
return; return;
} }
setSessionLibraries(selectedRagLibraries); setSessionLibraries(selectedRagLibraries);
setSessionCards(orderedCards); setSessionCards(orderedCards);
setCurrentIndex(0);
setShowAnswer(false);
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'Falha ao iniciar revisao espacada.'); setError(err?.message || 'Falha ao iniciar revisão espaçada.');
} finally { } finally {
setStartingSession(false); setStartingSession(false);
} }
@@ -223,20 +224,29 @@ export function SpacedReviewComponent() {
libraryIdSet.has(library.libraryId) && statusSet.has(library.ragStatus)); libraryIdSet.has(library.libraryId) && statusSet.has(library.ragStatus));
}, [allRagLibraries, selectedLibraryIds, selectedStatuses]); }, [allRagLibraries, selectedLibraryIds, selectedStatuses]);
const sessionLibraryById = useMemo(() => { const sessionLibraryMetaById = useMemo(() => {
return new Map(sessionLibraries.map((library) => [library.libraryId, library])); return new Map<number, FlashcardStudySessionLibraryMeta>(
sessionLibraries.map((library) => {
const statusMeta = STATUS_META_BY_STATUS[library.ragStatus];
return [
library.libraryId,
{
fileName: library.fileName,
subject: library.subject || 'Geral',
subSubject: library.subSubject || 'Geral',
statusLabel: statusMeta.label,
statusIcon: statusMeta.icon,
statusClassName: statusMeta.className,
footerDetails: [
`Desempenho do arquivo: ${formatPerformance(library.performanceRate)}`,
`Última revisão: ${formatLastReviewed(library.lastReviewedAt)}`,
],
},
];
}),
);
}, [sessionLibraries]); }, [sessionLibraries]);
const currentCard = sessionCards[currentIndex];
const currentLibrary = currentCard ? sessionLibraryById.get(currentCard.libraryId) : undefined;
const currentStatusMeta = currentLibrary
? STATUS_META_BY_STATUS[currentLibrary.ragStatus]
: STATUS_META_BY_STATUS.Grey;
const progressPercent = sessionCards.length > 0
? ((currentIndex + 1) / sessionCards.length) * 100
: 0;
const toggleStatus = (status: FlashcardRagStatus) => { const toggleStatus = (status: FlashcardRagStatus) => {
if (selectedStatuses.includes(status)) { if (selectedStatuses.includes(status)) {
setSelectedStatuses(selectedStatuses.filter((value) => value !== status)); setSelectedStatuses(selectedStatuses.filter((value) => value !== status));
@@ -316,59 +326,27 @@ export function SpacedReviewComponent() {
const endSession = () => { const endSession = () => {
setSessionCards([]); setSessionCards([]);
setSessionLibraries([]); setSessionLibraries([]);
setCurrentIndex(0);
setShowAnswer(false);
setSubmittingAnswer(false);
void loadDashboard(true); void loadDashboard(true);
}; };
const goToPrevious = () => { const registerAnswer = async (card: FlashcardCard, correct: boolean) => {
if (currentIndex === 0) {
return;
}
setCurrentIndex(currentIndex - 1);
setShowAnswer(false);
};
const registerAnswer = async (correct: boolean) => {
if (!currentCard) {
return;
}
setSubmittingAnswer(true);
setError(null); setError(null);
try {
await MindforgeApiService.recordFlashcardReviewAnswer({ await MindforgeApiService.recordFlashcardReviewAnswer({
cardId: currentCard.id, cardId: card.id,
correct, correct,
}); });
if (currentIndex >= sessionCards.length - 1) {
endSession();
return;
}
setCurrentIndex(currentIndex + 1);
setShowAnswer(false);
} catch (err: any) {
setError(err?.message || 'Falha ao registrar resposta da revisao.');
} finally {
setSubmittingAnswer(false);
}
}; };
return ( return (
<div className="spaced-review-container"> <div className="spaced-review-container">
<h2 className="spaced-review-title">Revisao espacada</h2> <h2 className="spaced-review-title">Revisão espaçada</h2>
<p className="spaced-review-subtitle">Acompanhe o status RAG por arquivo de flashcards.</p> <p className="spaced-review-subtitle">Acompanhe o status RAG por arquivo de flashcards.</p>
{error && <div className="spaced-review-error">{error}</div>} {error && <div className="spaced-review-error">{error}</div>}
{sessionCards.length === 0 && ( {sessionCards.length === 0 && (
<div className="spaced-review-panel"> <div className="spaced-review-panel">
{loading && <p className="spaced-review-state">Carregando painel de revisao...</p>} {loading && <p className="spaced-review-state">Carregando painel de revisão...</p>}
{!loading && (!dashboard || dashboard.subjects.length === 0) && ( {!loading && (!dashboard || dashboard.subjects.length === 0) && (
<p className="spaced-review-state">Nenhum flashcard encontrado para revisar.</p> <p className="spaced-review-state">Nenhum flashcard encontrado para revisar.</p>
)} )}
@@ -455,9 +433,9 @@ export function SpacedReviewComponent() {
<div className="spaced-review-library-texts"> <div className="spaced-review-library-texts">
<strong>{library.fileName}</strong> <strong>{library.fileName}</strong>
<span> <span>
Cards: {library.cardCount} | Desempenho: {formatPerformance(library.performanceRate)} Cartões: {library.cardCount} | Desempenho: {formatPerformance(library.performanceRate)}
</span> </span>
<small>Ultima revisao: {formatLastReviewed(library.lastReviewedAt)}</small> <small>Última revisão: {formatLastReviewed(library.lastReviewedAt)}</small>
</div> </div>
<span className={`rag-badge ${statusMeta.className}`}> <span className={`rag-badge ${statusMeta.className}`}>
<span className="rag-icon">{statusMeta.icon}</span> <span className="rag-icon">{statusMeta.icon}</span>
@@ -485,105 +463,19 @@ export function SpacedReviewComponent() {
disabled={startingSession || loading} disabled={startingSession || loading}
onClick={startSession} onClick={startSession}
> >
{startingSession ? 'Iniciando...' : 'Iniciar Revisao Espacada'} {startingSession ? 'Iniciando...' : 'Iniciar Revisão Espaçada'}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{sessionCards.length > 0 && currentCard && ( {sessionCards.length > 0 && (
<div class="spaced-review-content-grid"> <FlashcardStudySession
<div className="spaced-review-session-panel"> cards={sessionCards}
<div className="spaced-review-progress"> libraryMetaById={sessionLibraryMetaById}
<span>{currentIndex + 1} / {sessionCards.length}</span> onAnswer={registerAnswer}
<div className="spaced-review-progress-bar"> onEnd={endSession}
<div className="spaced-review-progress-fill" style={{ width: `${progressPercent}%` }} /> />
</div>
</div>
<article className="spaced-review-card">
<header>
<small>
{currentLibrary?.fileName || 'Arquivo'} - {currentLibrary?.subject || 'Geral'} - {currentLibrary?.subSubject || 'Geral'}
</small>
<span className={`rag-badge ${currentStatusMeta.className}`}>
<span className="rag-icon">{currentStatusMeta.icon}</span>
{currentStatusMeta.label}
</span>
</header>
<h3>Frente</h3>
<p>{currentCard.front}</p>
{showAnswer && (
<>
<h3>Verso</h3>
<p>{currentCard.back}</p>
<div className="spaced-review-card-meta">
<span>Desempenho do arquivo: {currentLibrary ? formatPerformance(currentLibrary.performanceRate) : '-'}</span>
<span>Ultima revisao: {formatLastReviewed(currentLibrary?.lastReviewedAt)}</span>
</div>
</>
)}
</article>
<div className="spaced-review-session-actions">
<Button variant="secondary" onClick={goToPrevious} disabled={currentIndex === 0 || submittingAnswer}>
Anterior
</Button>
{!showAnswer && (
<Button variant="primary" onClick={() => setShowAnswer(true)}>
Revelar Resposta
</Button>
)}
{showAnswer && (
<>
<Button variant="primary" onClick={() => registerAnswer(true)} disabled={submittingAnswer}>
Acertei
</Button>
<Button variant="secondary" onClick={() => registerAnswer(false)} disabled={submittingAnswer}>
Errei
</Button>
</>
)}
<Button variant="secondary" onClick={endSession}>
Encerrar Sessao
</Button>
</div>
</div>
<div class="spaced-review-side-panel">
<div class="spaced-review-panel-card">
<h3>Progresso</h3>
<div class="spaced-review-stat-grid">
<div class="spaced-review-stat">
<b>{currentIndex + 1}/{sessionCards.length}</b>
<span>Atual</span>
</div>
<div class="spaced-review-stat">
<b class={`rag-badge ${currentStatusMeta.className}`} style="font-size:14px;padding:4px 8px">
<span class="rag-icon">{currentStatusMeta.icon}</span>
{currentStatusMeta.label}
</b>
<span>Status RAG</span>
</div>
</div>
<div class="spaced-review-track">
<span style={{ width: `${progressPercent}%` }} />
</div>
</div>
</div>
</div>
)}
{sessionCards.length > 0 && !currentCard && (
<div className="spaced-review-panel" style="text-align:center;padding:3rem 1rem">
<h3 style="font-family:Georgia,serif;font-size:28px;color:var(--ink);margin:0 0 0.5rem">Sessao concluida!</h3>
<p style="color:var(--muted)">Todos os cards foram revisados.</p>
</div>
)} )}
</div> </div>
); );

View File

@@ -32,7 +32,7 @@ export function VerificadorComponent() {
const handleSubmit = async () => { const handleSubmit = async () => {
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
setError('Selecione pelo menos um arquivo do repositorio.'); setError('Selecione pelo menos um arquivo do repositório.');
return; return;
} }
@@ -98,29 +98,29 @@ export function VerificadorComponent() {
return ( return (
<div className="verificador-container"> <div className="verificador-container">
<h2 className="verificador-title">Verificador de Arquivos</h2> <h2 className="verificador-title">Verificador de Arquivos</h2>
<p className="verificador-subtitle">Selecione os arquivos do repositorio para validação de linguagem ou conteudo.</p> <p className="verificador-subtitle">Selecione os arquivos do repositório para validação de linguagem ou conteúdo.</p>
<div className="verificador-form"> <div className="verificador-form">
<div className="input-group"> <div className="input-group">
<label>Arquivos do Repositorio</label> <label>Arquivos do Repositório</label>
<FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} /> <FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
{selectedPaths.length > 0 && ( {selectedPaths.length > 0 && (
<div class="select-status"> <div class="select-status">
{selectedPaths.length} arquivo{selectedPaths.length !== 1 ? 's' : ''} selecionado{selectedPaths.length !== 1 ? 's' : ''} {selectedPaths.length === 1 ? '1 arquivo selecionado' : `${selectedPaths.length} arquivos selecionados`}
</div> </div>
)} )}
</div> </div>
<div className="input-group"> <div className="input-group">
<label>Tipo de Verificacao</label> <label>Tipo de Verificação</label>
<select <select
className="select-input" className="select-input"
value={checkType} value={checkType}
onChange={(e) => setCheckType((e.target as HTMLSelectElement).value as CheckTypeEnum)} onChange={(e) => setCheckType((e.target as HTMLSelectElement).value as CheckTypeEnum)}
> >
<option value="language">Linguagem</option> <option value="language">Linguagem</option>
<option value="content">Conteudo</option> <option value="content">Conteúdo</option>
<option value="both">Linguagem e Conteudo</option> <option value="both">Linguagem e Conteúdo</option>
</select> </select>
</div> </div>
@@ -159,7 +159,7 @@ export function VerificadorComponent() {
{!fileResult.error && checkType === 'content' && fileResult.contentResult && ( {!fileResult.error && checkType === 'content' && fileResult.contentResult && (
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Conteudo</div> <div className="pane-title">Conteúdo</div>
<div <div
className="response-content markdown-body" className="response-content markdown-body"
dangerouslySetInnerHTML={{ __html: marked.parse(fileResult.contentResult) as string }} dangerouslySetInnerHTML={{ __html: marked.parse(fileResult.contentResult) as string }}
@@ -176,7 +176,7 @@ export function VerificadorComponent() {
</div> </div>
</div> </div>
<div className="side-pane"> <div className="side-pane">
<div className="pane-title">Conteudo</div> <div className="pane-title">Conteúdo</div>
<div <div
className="response-content markdown-body" className="response-content markdown-body"
style={{ minHeight: '200px' }} style={{ minHeight: '200px' }}

View File

@@ -188,7 +188,7 @@ export const MindforgeApiService = {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
await throwIfNotOk(response, `Erro ao iniciar revisao: ${response.statusText}`); await throwIfNotOk(response, `Erro ao iniciar revisão: ${response.statusText}`);
return response.json(); return response.json();
}, },
@@ -201,24 +201,24 @@ export const MindforgeApiService = {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
await throwIfNotOk(response, `Erro ao registrar resposta da revisao: ${response.statusText}`); await throwIfNotOk(response, `Erro ao registrar resposta da revisão: ${response.statusText}`);
}, },
async getFlashcardRagStatus(): Promise<FlashcardRagDashboardResponse> { async getFlashcardRagStatus(): Promise<FlashcardRagDashboardResponse> {
const response = await fetch(`${BASE_URL}/api/v1/flashcard/rag-status`); const response = await fetch(`${BASE_URL}/api/v1/flashcard/rag-status`);
await throwIfNotOk(response, `Erro ao buscar status RAG de revisao: ${response.statusText}`); await throwIfNotOk(response, `Erro ao buscar status RAG de revisão: ${response.statusText}`);
return response.json(); return response.json();
}, },
async getRepositoryInfo(): Promise<RepositoryInfo> { async getRepositoryInfo(): Promise<RepositoryInfo> {
const response = await fetch(`${BASE_URL}/api/v1/repository/info`); const response = await fetch(`${BASE_URL}/api/v1/repository/info`);
await throwIfNotOk(response, `Erro ao buscar info do repositorio: ${response.statusText}`); await throwIfNotOk(response, `Erro ao buscar info do repositório: ${response.statusText}`);
return response.json(); return response.json();
}, },
async getRepositoryTree(): Promise<FileTreeNode[]> { async getRepositoryTree(): Promise<FileTreeNode[]> {
const response = await fetch(`${BASE_URL}/api/v1/repository/tree`); const response = await fetch(`${BASE_URL}/api/v1/repository/tree`);
await throwIfNotOk(response, `Erro ao buscar arvore do repositorio: ${response.statusText}`); await throwIfNotOk(response, `Erro ao buscar árvore do repositório: ${response.statusText}`);
return response.json(); return response.json();
}, },

View File

@@ -138,14 +138,16 @@ Formas de requisição principais:
- **Tratamento de erros**: `ExceptionHandlingMiddleware` captura `UserException` (400) e exceções genéricas (500). - **Tratamento de erros**: `ExceptionHandlingMiddleware` captura `UserException` (400) e exceções genéricas (500).
### UI/UX ### UI/UX
- **Idioma**: Todo texto em **português brasileiro**. - **Idioma**: Todo texto em **português brasileiro**, com acentuação e grafia corretas nas telas e mensagens.
- **Navegação**: Os rótulos visíveis da revisão usam "Revisão de Flashcards" e "Revisão espaçada" como nomes canônicos. A marca Mindforge na sidebar navega para a página inicial.
- **Revisão de flashcards**: "Revisão de Flashcards" e "Revisão espaçada" mantêm seleção/filtros próprios, mas compartilham a mesma sessão visual (`FlashcardStudySession`) com título "Sessão de Revisão", flip 3D, atalhos, fila/progresso, carimbo e confete.
- **Tema**: Claro quente vintage ("mesa de estudo pessoal"). Fundos creme/pergaminho/dourado com textura de papel (grid via `body::before`). Vidro translúcido com `backdrop-filter` em tons quentes. - **Tema**: Claro quente vintage ("mesa de estudo pessoal"). Fundos creme/pergaminho/dourado com textura de papel (grid via `body::before`). Vidro translúcido com `backdrop-filter` em tons quentes.
- **Botões**: Estilo pill quente com sombras marrons. Variantes `primary` (gradiente azul, com brilho hover) e `secondary` (translúcido pergaminho). - **Botões**: Estilo pill quente com sombras marrons. Variantes `primary` (gradiente azul, com brilho hover) e `secondary` (translúcido pergaminho).
- **Tipografia**: Inter (UI global), Georgia/Times New Roman (marca e cabeçalhos), Segoe UI/Inter (conteúdo do flashcard). - **Tipografia**: Inter (UI global), Georgia/Times New Roman (marca e cabeçalhos), Segoe UI/Inter (conteúdo do flashcard).
- **Background**: Gradiente radial quente (dourado/azul) sobre base `#fff8e6` com overlay de grid de papel 24px. - **Background**: Gradiente radial quente (dourado/azul) sobre base `#fff8e6` com overlay de grid de papel 24px.
- **Layout**: CSS Grid (`grid-template-columns: 288px minmax(0, 1fr)`). Sidebar sticky com textura diagonal. Topbar integrada ao fluxo (não fixa). - **Layout**: CSS Grid (`grid-template-columns: 288px minmax(0, 1fr)`). Sidebar sticky com textura diagonal. Topbar integrada ao fluxo (não fixa).
- **Animações**: Apenas sob ação do usuário (flip 3D do flashcard, carimbo, saída do cartão, confete canvas). Sem animações infinitas ou spinners. - **Animações**: Apenas sob ação do usuário (flip 3D do flashcard, carimbo, saída do cartão, confete canvas). Sem animações infinitas ou spinners.
- **Flashcard**: Cartão 3D com efeito `rotateY(180deg)`, frente papel pautado com borda tracejada, verso azulado. Carimbo de feedback (correto/incorreto). Confete canvas ao acertar. - **Flashcard**: Cartão 3D com efeito `rotateY(180deg)`, frente papel pautado com borda tracejada, verso azulado que mostra pergunta menor + resposta principal. Carimbo de feedback ("Correto"/"Incorreto") é renderizado no verso visível antes da saída do cartão. Confete canvas ao acertar.
- **Responsivo**: Breakpoints em 1120px (sidebar colapsada) e 760px (layout single-column). - **Responsivo**: Breakpoints em 1120px (sidebar colapsada) e 760px (layout single-column).
### Variáveis CSS (definidas em `index.css`) ### Variáveis CSS (definidas em `index.css`)