improving ui quality
This commit is contained in:
@@ -12,7 +12,7 @@ const minAmount = 10;
|
||||
const maxAmount = 50;
|
||||
|
||||
function difficultyLabel(difficulty: string) {
|
||||
return difficulty === 'Medium' ? 'Medio' : 'Facil';
|
||||
return difficulty === 'Medium' ? 'Médio' : 'Fácil';
|
||||
}
|
||||
|
||||
export function FlashcardComponent() {
|
||||
@@ -25,7 +25,7 @@ export function FlashcardComponent() {
|
||||
|
||||
const handleGenerate = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -50,22 +50,21 @@ export function FlashcardComponent() {
|
||||
return (
|
||||
<div className="flashcard-container">
|
||||
<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="input-group">
|
||||
<label>Arquivos do Repositorio</label>
|
||||
<label>Arquivos do Repositório</label>
|
||||
<FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
|
||||
{selectedPaths.length > 0 && (
|
||||
<div className="selection-meta">
|
||||
{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 className="input-group">
|
||||
<label>Quantidade por Arquivo ({minAmount} - {maxAmount})</label>
|
||||
<label>Quantidade por arquivo ({minAmount} - {maxAmount})</label>
|
||||
<div className="slider-wrapper">
|
||||
<input
|
||||
type="range"
|
||||
@@ -92,7 +91,7 @@ export function FlashcardComponent() {
|
||||
onChange={() => setDifficulty('Easy')}
|
||||
/>
|
||||
<label htmlFor="difficulty-easy" className="radio-label" title="Perguntas mais diretas e objetivas">
|
||||
Facil
|
||||
Fácil
|
||||
</label>
|
||||
</div>
|
||||
<div className="radio-item">
|
||||
@@ -104,8 +103,8 @@ export function FlashcardComponent() {
|
||||
checked={difficulty === 'Medium'}
|
||||
onChange={() => setDifficulty('Medium')}
|
||||
/>
|
||||
<label htmlFor="difficulty-medium" className="radio-label" title="Perguntas de nivel intermediario">
|
||||
Medio
|
||||
<label htmlFor="difficulty-medium" className="radio-label" title="Perguntas de nível intermediário">
|
||||
Médio
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,10 +132,10 @@ export function FlashcardComponent() {
|
||||
<article key={library.id} className="flashcard-result-item">
|
||||
<div className="flashcard-result-header">
|
||||
<strong>{library.fileName}</strong>
|
||||
<span>{library.cardCount} cards</span>
|
||||
<span>{library.cardCount} cartões</span>
|
||||
</div>
|
||||
<div className="flashcard-result-meta">
|
||||
<span>Materia: {library.subject}</span>
|
||||
<span>Matéria: {library.subject}</span>
|
||||
<span>Dificuldade: {difficultyLabel(library.difficulty)}</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Library Selection */
|
||||
.review-select-panel {
|
||||
background: rgba(255, 250, 239, .68);
|
||||
border: 1px solid rgba(104, 69, 22, .13);
|
||||
@@ -109,548 +108,8 @@
|
||||
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) {
|
||||
.session-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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;
|
||||
.review-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import {
|
||||
MindforgeApiService,
|
||||
type FlashcardCard,
|
||||
type FlashcardLibrarySummary,
|
||||
} from '../services/MindforgeApiService';
|
||||
import { Button } from './Button';
|
||||
import {
|
||||
FlashcardStudySession,
|
||||
type FlashcardStudySessionLibraryMeta,
|
||||
} from './FlashcardStudySession';
|
||||
import './FlashcardReviewComponent.css';
|
||||
|
||||
function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) {
|
||||
@@ -21,7 +25,7 @@ function groupLibrariesBySubject(libraries: FlashcardLibrarySummary[]) {
|
||||
}
|
||||
|
||||
function difficultyLabel(difficulty: string) {
|
||||
return difficulty === 'Medium' ? 'Medio' : 'Facil';
|
||||
return difficulty === 'Medium' ? 'Médio' : 'Fácil';
|
||||
}
|
||||
|
||||
function shuffleCards(cards: FlashcardCard[]) {
|
||||
@@ -33,86 +37,6 @@ function shuffleCards(cards: FlashcardCard[]) {
|
||||
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() {
|
||||
const [libraries, setLibraries] = useState<FlashcardLibrarySummary[]>([]);
|
||||
const [selectedLibraryIds, setSelectedLibraryIds] = useState<number[]>([]);
|
||||
@@ -120,16 +44,6 @@ export function FlashcardReviewComponent() {
|
||||
const [loadingSession, setLoadingSession] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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(() => {
|
||||
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 libraryById = useMemo(() => {
|
||||
return new Map(libraries.map((library) => [library.id, library]));
|
||||
const libraryMetaById = useMemo(() => {
|
||||
return new Map<number, FlashcardStudySessionLibraryMeta>(
|
||||
libraries.map((library) => [
|
||||
library.id,
|
||||
{
|
||||
fileName: library.fileName,
|
||||
subject: library.subject || 'Geral',
|
||||
difficultyLabel: difficultyLabel(library.difficulty),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}, [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) => {
|
||||
if (selectedLibraryIds.includes(libraryId)) {
|
||||
setSelectedLibraryIds(selectedLibraryIds.filter((id) => id !== libraryId));
|
||||
@@ -210,27 +99,28 @@ export function FlashcardReviewComponent() {
|
||||
|
||||
const startReviewSession = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
setLoadingSession(true);
|
||||
setError(null);
|
||||
setSessionCards([]);
|
||||
|
||||
try {
|
||||
const response = await MindforgeApiService.createFlashcardReviewSession({
|
||||
libraryIds: selectedLibraryIds,
|
||||
});
|
||||
const shuffledCards = shuffleCards(response.cards);
|
||||
|
||||
setSessionCards(shuffleCards(response.cards));
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
setFlipped(false);
|
||||
setStampState(null);
|
||||
setCardExiting(false);
|
||||
setSessionAnswers({});
|
||||
if (shuffledCards.length === 0) {
|
||||
setError('As bibliotecas selecionadas não possuem cartões para revisar.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionCards(shuffledCards);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Falha ao iniciar sessao de revisao.');
|
||||
setError(err?.message || 'Falha ao iniciar sessão de revisão.');
|
||||
} finally {
|
||||
setLoadingSession(false);
|
||||
}
|
||||
@@ -238,87 +128,20 @@ export function FlashcardReviewComponent() {
|
||||
|
||||
const endSession = () => {
|
||||
setSessionCards([]);
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
setSubmittingAnswer(false);
|
||||
setFlipped(false);
|
||||
setStampState(null);
|
||||
setCardExiting(false);
|
||||
setSessionAnswers({});
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
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);
|
||||
const recordReviewAnswer = async (card: FlashcardCard, correct: boolean) => {
|
||||
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({
|
||||
cardId: currentCard.id,
|
||||
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);
|
||||
}
|
||||
await MindforgeApiService.recordFlashcardReviewAnswer({
|
||||
cardId: card.id,
|
||||
correct,
|
||||
});
|
||||
};
|
||||
|
||||
const correctCount = Object.values(sessionAnswers).filter(Boolean).length;
|
||||
const remainingCount = sessionCards.length - currentIndex;
|
||||
|
||||
return (
|
||||
<div className="review-container">
|
||||
<canvas ref={confettiRef} class="confetti-canvas" />
|
||||
<h2 className="review-title">Revisao Flashcards</h2>
|
||||
<p className="review-subtitle">Escolha as bibliotecas para estudar e inicie uma sessao de revisao.</p>
|
||||
<h2 className="review-title">Revisão de Flashcards</h2>
|
||||
<p className="review-subtitle">Escolha as bibliotecas para estudar e inicie uma sessão de revisão.</p>
|
||||
|
||||
{error && <div className="review-error">{error}</div>}
|
||||
|
||||
@@ -326,7 +149,7 @@ export function FlashcardReviewComponent() {
|
||||
<div className="review-select-panel">
|
||||
{loadingLibraries && <p className="review-state">Carregando bibliotecas...</p>}
|
||||
{!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 && (
|
||||
@@ -345,7 +168,7 @@ export function FlashcardReviewComponent() {
|
||||
<div className="review-library-texts">
|
||||
<strong>{library.fileName}</strong>
|
||||
<span>
|
||||
{library.cardCount} cards - {difficultyLabel(library.difficulty)}
|
||||
{library.cardCount} cartões - {difficultyLabel(library.difficulty)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
@@ -358,144 +181,19 @@ export function FlashcardReviewComponent() {
|
||||
|
||||
<div className="review-actions">
|
||||
<Button variant="primary" onClick={startReviewSession} disabled={loadingSession || selectedLibraryIds.length === 0}>
|
||||
{loadingSession ? 'Iniciando...' : 'Iniciar Revisao'}
|
||||
{loadingSession ? 'Iniciando...' : 'Iniciar Revisão'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionCards.length > 0 && currentCard && (
|
||||
<div class="content-grid">
|
||||
<div class="review-panel">
|
||||
<div class="session-header">
|
||||
<div class="session-title">
|
||||
<h3>Sessao de Revisao</h3>
|
||||
<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>
|
||||
{sessionCards.length > 0 && (
|
||||
<FlashcardStudySession
|
||||
cards={sessionCards}
|
||||
libraryMetaById={libraryMetaById}
|
||||
onAnswer={recordReviewAnswer}
|
||||
onEnd={endSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
644
Mindforge.Web/src/components/FlashcardStudySession.css
Normal file
644
Mindforge.Web/src/components/FlashcardStudySession.css
Normal 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;
|
||||
}
|
||||
}
|
||||
410
Mindforge.Web/src/components/FlashcardStudySession.tsx
Normal file
410
Mindforge.Web/src/components/FlashcardStudySession.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export function Header({ onGoHome }: HeaderProps) {
|
||||
{repoName && (
|
||||
<div class="topbar-right">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,24 @@
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
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 {
|
||||
@@ -66,11 +84,13 @@
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
width: calc(100% - 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -165,9 +185,13 @@
|
||||
|
||||
.nav-list {
|
||||
flex-direction: row;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: auto;
|
||||
min-width: 56px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -8,20 +8,20 @@ interface SidebarProps {
|
||||
const NAV_ITEMS: { module: SidebarProps['activeModule']; icon: string; label: string }[] = [
|
||||
{ module: 'verificador', icon: '\u2713', label: 'Verificador' },
|
||||
{ module: 'flashcards', icon: '\u25A6', label: 'Flashcards' },
|
||||
{ module: 'revisao-flashcards', icon: '\u25B3', label: 'Revisao Flashcards' },
|
||||
{ module: 'revisao-espacada', icon: '\u25CB', label: 'Revisao Espacada' },
|
||||
{ module: 'revisao-flashcards', icon: '\u25B3', label: 'Revisão de Flashcards' },
|
||||
{ module: 'revisao-espacada', icon: '\u25CB', label: 'Revisão espaçada' },
|
||||
];
|
||||
|
||||
export function Sidebar({ onModuleChange, activeModule }: SidebarProps) {
|
||||
return (
|
||||
<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">
|
||||
<img src="/assets/mindforge.png" alt="M" />
|
||||
</div>
|
||||
<h1>Mindforge</h1>
|
||||
</div>
|
||||
<div class="nav-section-title">Modulos</div>
|
||||
</button>
|
||||
<div class="nav-section-title">Módulos</div>
|
||||
<nav class="nav-list">
|
||||
{NAV_ITEMS.map(({ module, icon, label }) => (
|
||||
<button
|
||||
|
||||
@@ -318,193 +318,8 @@
|
||||
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) {
|
||||
.spaced-review-library-item {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
type FlashcardRagStatus,
|
||||
} from '../services/MindforgeApiService';
|
||||
import { Button } from './Button';
|
||||
import {
|
||||
FlashcardStudySession,
|
||||
type FlashcardStudySessionLibraryMeta,
|
||||
} from './FlashcardStudySession';
|
||||
import './SpacedReviewComponent.css';
|
||||
|
||||
interface RagStatusOption {
|
||||
@@ -59,10 +63,10 @@ function formatPercentage(value: number) {
|
||||
|
||||
function summaryText(activeCount: number, greenPercentage: number, attentionPercentage: number) {
|
||||
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) {
|
||||
@@ -76,7 +80,7 @@ function formatLastReviewed(value?: string | null) {
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Data invalida';
|
||||
return 'Data inválida';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('pt-BR');
|
||||
@@ -105,9 +109,6 @@ export function SpacedReviewComponent() {
|
||||
const [startingSession, setStartingSession] = useState(false);
|
||||
const [sessionCards, setSessionCards] = useState<FlashcardCard[]>([]);
|
||||
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) => {
|
||||
setLoading(true);
|
||||
@@ -140,7 +141,7 @@ export function SpacedReviewComponent() {
|
||||
return ['Red', 'Amber', 'Green', 'Grey'];
|
||||
});
|
||||
} 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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -165,12 +166,12 @@ export function SpacedReviewComponent() {
|
||||
|
||||
const startSession = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -181,6 +182,8 @@ export function SpacedReviewComponent() {
|
||||
|
||||
setStartingSession(true);
|
||||
setError(null);
|
||||
setSessionCards([]);
|
||||
setSessionLibraries([]);
|
||||
|
||||
try {
|
||||
const ragByLibraryId = new Map(selectedRagLibraries.map((library) => [library.libraryId, library]));
|
||||
@@ -191,16 +194,14 @@ export function SpacedReviewComponent() {
|
||||
const orderedCards = orderCardsForSession(filteredCards, ragByLibraryId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setSessionLibraries(selectedRagLibraries);
|
||||
setSessionCards(orderedCards);
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Falha ao iniciar revisao espacada.');
|
||||
setError(err?.message || 'Falha ao iniciar revisão espaçada.');
|
||||
} finally {
|
||||
setStartingSession(false);
|
||||
}
|
||||
@@ -223,20 +224,29 @@ export function SpacedReviewComponent() {
|
||||
libraryIdSet.has(library.libraryId) && statusSet.has(library.ragStatus));
|
||||
}, [allRagLibraries, selectedLibraryIds, selectedStatuses]);
|
||||
|
||||
const sessionLibraryById = useMemo(() => {
|
||||
return new Map(sessionLibraries.map((library) => [library.libraryId, library]));
|
||||
const sessionLibraryMetaById = useMemo(() => {
|
||||
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]);
|
||||
|
||||
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) => {
|
||||
if (selectedStatuses.includes(status)) {
|
||||
setSelectedStatuses(selectedStatuses.filter((value) => value !== status));
|
||||
@@ -316,59 +326,27 @@ export function SpacedReviewComponent() {
|
||||
const endSession = () => {
|
||||
setSessionCards([]);
|
||||
setSessionLibraries([]);
|
||||
setCurrentIndex(0);
|
||||
setShowAnswer(false);
|
||||
setSubmittingAnswer(false);
|
||||
void loadDashboard(true);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (currentIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentIndex(currentIndex - 1);
|
||||
setShowAnswer(false);
|
||||
};
|
||||
|
||||
const registerAnswer = async (correct: boolean) => {
|
||||
if (!currentCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmittingAnswer(true);
|
||||
const registerAnswer = async (card: FlashcardCard, correct: boolean) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await MindforgeApiService.recordFlashcardReviewAnswer({
|
||||
cardId: currentCard.id,
|
||||
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);
|
||||
}
|
||||
await MindforgeApiService.recordFlashcardReviewAnswer({
|
||||
cardId: card.id,
|
||||
correct,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{error && <div className="spaced-review-error">{error}</div>}
|
||||
|
||||
{sessionCards.length === 0 && (
|
||||
<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) && (
|
||||
<p className="spaced-review-state">Nenhum flashcard encontrado para revisar.</p>
|
||||
)}
|
||||
@@ -455,9 +433,9 @@ export function SpacedReviewComponent() {
|
||||
<div className="spaced-review-library-texts">
|
||||
<strong>{library.fileName}</strong>
|
||||
<span>
|
||||
Cards: {library.cardCount} | Desempenho: {formatPerformance(library.performanceRate)}
|
||||
Cartões: {library.cardCount} | Desempenho: {formatPerformance(library.performanceRate)}
|
||||
</span>
|
||||
<small>Ultima revisao: {formatLastReviewed(library.lastReviewedAt)}</small>
|
||||
<small>Última revisão: {formatLastReviewed(library.lastReviewedAt)}</small>
|
||||
</div>
|
||||
<span className={`rag-badge ${statusMeta.className}`}>
|
||||
<span className="rag-icon">{statusMeta.icon}</span>
|
||||
@@ -485,105 +463,19 @@ export function SpacedReviewComponent() {
|
||||
disabled={startingSession || loading}
|
||||
onClick={startSession}
|
||||
>
|
||||
{startingSession ? 'Iniciando...' : 'Iniciar Revisao Espacada'}
|
||||
{startingSession ? 'Iniciando...' : 'Iniciar Revisão Espaçada'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionCards.length > 0 && currentCard && (
|
||||
<div class="spaced-review-content-grid">
|
||||
<div className="spaced-review-session-panel">
|
||||
<div className="spaced-review-progress">
|
||||
<span>{currentIndex + 1} / {sessionCards.length}</span>
|
||||
<div className="spaced-review-progress-bar">
|
||||
<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>
|
||||
{sessionCards.length > 0 && (
|
||||
<FlashcardStudySession
|
||||
cards={sessionCards}
|
||||
libraryMetaById={sessionLibraryMetaById}
|
||||
onAnswer={registerAnswer}
|
||||
onEnd={endSession}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function VerificadorComponent() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedPaths.length === 0) {
|
||||
setError('Selecione pelo menos um arquivo do repositorio.');
|
||||
setError('Selecione pelo menos um arquivo do repositório.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,29 +98,29 @@ export function VerificadorComponent() {
|
||||
return (
|
||||
<div className="verificador-container">
|
||||
<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="input-group">
|
||||
<label>Arquivos do Repositorio</label>
|
||||
<label>Arquivos do Repositório</label>
|
||||
<FileTreeComponent selectedPaths={selectedPaths} onSelectionChange={setSelectedPaths} />
|
||||
{selectedPaths.length > 0 && (
|
||||
<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 className="input-group">
|
||||
<label>Tipo de Verificacao</label>
|
||||
<label>Tipo de Verificação</label>
|
||||
<select
|
||||
className="select-input"
|
||||
value={checkType}
|
||||
onChange={(e) => setCheckType((e.target as HTMLSelectElement).value as CheckTypeEnum)}
|
||||
>
|
||||
<option value="language">Linguagem</option>
|
||||
<option value="content">Conteudo</option>
|
||||
<option value="both">Linguagem e Conteudo</option>
|
||||
<option value="content">Conteúdo</option>
|
||||
<option value="both">Linguagem e Conteúdo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function VerificadorComponent() {
|
||||
|
||||
{!fileResult.error && checkType === 'content' && fileResult.contentResult && (
|
||||
<div className="side-pane">
|
||||
<div className="pane-title">Conteudo</div>
|
||||
<div className="pane-title">Conteúdo</div>
|
||||
<div
|
||||
className="response-content markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(fileResult.contentResult) as string }}
|
||||
@@ -176,7 +176,7 @@ export function VerificadorComponent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="side-pane">
|
||||
<div className="pane-title">Conteudo</div>
|
||||
<div className="pane-title">Conteúdo</div>
|
||||
<div
|
||||
className="response-content markdown-body"
|
||||
style={{ minHeight: '200px' }}
|
||||
|
||||
@@ -188,7 +188,7 @@ export const MindforgeApiService = {
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -201,24 +201,24 @@ export const MindforgeApiService = {
|
||||
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> {
|
||||
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();
|
||||
},
|
||||
|
||||
async getRepositoryInfo(): Promise<RepositoryInfo> {
|
||||
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();
|
||||
},
|
||||
|
||||
async getRepositoryTree(): Promise<FileTreeNode[]> {
|
||||
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();
|
||||
},
|
||||
|
||||
|
||||
@@ -138,14 +138,16 @@ Formas de requisição principais:
|
||||
- **Tratamento de erros**: `ExceptionHandlingMiddleware` captura `UserException` (400) e exceções genéricas (500).
|
||||
|
||||
### 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.
|
||||
- **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).
|
||||
- **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).
|
||||
- **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).
|
||||
|
||||
### Variáveis CSS (definidas em `index.css`)
|
||||
|
||||
Reference in New Issue
Block a user