Add ai_answer extraction + broader ai_decide sampling + flashcard title edit
ai_decide now samples 4 points across the section (start, 1/3, 2/3, end)
instead of just the first 30 + last 20 pages. This gives accurate strategy
detection on large documents where the answer format might be deeper in.
New ai_answer extraction mode:
- Extracts questions from Q&A-format PDFs that have no answer key
- AI picks the correct option from each question's choices
- Generates explanation using document context + medical knowledge
- Useful for PDFs like practice tests where answers were never included
- Available manually and as an ai_decide strategy
Flashcard decks can now be renamed:
- PATCH /flashcards/{deck_id} updates title
- Inline edit on FlashcardsPage with responsive layout (input full-width,
buttons wrap under it so Cancel never overflows the card)
- Title truncates with ellipsis when not editing
Note: generate mode (textbook -> MCQs) is unchanged per user request.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5398342e3d
commit
2a0dd56f95
5 changed files with 232 additions and 20 deletions
|
|
@ -23,6 +23,10 @@ class FlashcardDeckCreate(BaseModel):
|
||||||
model_id: str | None = None
|
model_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FlashcardDeckUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class FlashcardDeckResponse(BaseModel):
|
class FlashcardDeckResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -256,6 +260,25 @@ def restore_flashcard_deck(
|
||||||
|
|
||||||
# ── Sharing & rating ────────────────────────────────────────────────
|
# ── Sharing & rating ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch("/{deck_id}")
|
||||||
|
def update_deck(
|
||||||
|
deck_id: int,
|
||||||
|
data: FlashcardDeckUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update deck metadata (title). Owner or admin only."""
|
||||||
|
deck = _own_deck_or_404(deck_id, current_user, db)
|
||||||
|
if data.title is not None:
|
||||||
|
title = data.title.strip()
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=400, detail="Title cannot be empty")
|
||||||
|
deck.title = title[:300]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(deck)
|
||||||
|
return {"id": deck.id, "title": deck.title}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{deck_id}/share")
|
@router.put("/{deck_id}/share")
|
||||||
def toggle_share_deck(
|
def toggle_share_deck(
|
||||||
deck_id: int,
|
deck_id: int,
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,98 @@ def extract_questions_only(content: str, page_info: str, page_ref: int | None,
|
||||||
raise RuntimeError("questions_only extraction failed after 3 attempts")
|
raise RuntimeError("questions_only extraction failed after 3 attempts")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI-ANSWERED (no inline/back answers — AI deduces correct answer) ───────
|
||||||
|
|
||||||
|
AI_ANSWER_PROMPT = """Pick the correct answer for each question below using medical knowledge and any supporting context from the provided document excerpt.
|
||||||
|
|
||||||
|
Return ONLY JSON:
|
||||||
|
{{"answers": [
|
||||||
|
{{
|
||||||
|
"item_number": "<matches input>",
|
||||||
|
"correct_answer": "<exact text of one of the given options>",
|
||||||
|
"explanation": "<1-3 sentence explanation; cite the document if relevant, otherwise general medical reasoning>"
|
||||||
|
}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- correct_answer must be the EXACT text of one of the options (not "A", "B", etc.)
|
||||||
|
- Prefer wording and reasoning supported by the Document context — fall back to general medical knowledge only when the document doesn't address it
|
||||||
|
- Keep explanations focused on why the answer is correct and briefly why others are wrong
|
||||||
|
|
||||||
|
Document context (pages {page_info}):
|
||||||
|
{content}
|
||||||
|
|
||||||
|
Questions (JSON list):
|
||||||
|
{questions_json}"""
|
||||||
|
|
||||||
|
|
||||||
|
def ai_answer_questions(questions: list[dict], content: str, page_info: str,
|
||||||
|
model_id: str | None, api_key: str | None) -> list[dict]:
|
||||||
|
"""For questions extracted without answers, use AI to determine correct answer
|
||||||
|
and explanation, using the document content as supporting context.
|
||||||
|
Mutates and returns the questions list."""
|
||||||
|
if not questions:
|
||||||
|
return questions
|
||||||
|
# Strip-down representation to keep the prompt small
|
||||||
|
qs_for_prompt = [
|
||||||
|
{
|
||||||
|
"item_number": str(q.get("item_number") or "").strip() or None,
|
||||||
|
"question_text": q.get("question_text", ""),
|
||||||
|
"options": q.get("options") or [],
|
||||||
|
}
|
||||||
|
for q in questions
|
||||||
|
]
|
||||||
|
content = ai_service._truncate_content(content)
|
||||||
|
prompt = AI_ANSWER_PROMPT.format(
|
||||||
|
page_info=page_info,
|
||||||
|
content=content,
|
||||||
|
questions_json=json.dumps(qs_for_prompt, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
text = ai_service._call_model(prompt, model_id, api_key).strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
||||||
|
if text.endswith("```"):
|
||||||
|
text = text[:-3]
|
||||||
|
text = text.strip()
|
||||||
|
data = json.loads(text)
|
||||||
|
answers = data.get("answers", data) if isinstance(data, dict) else data
|
||||||
|
# Match by item_number or by question_text fallback
|
||||||
|
by_item = {}
|
||||||
|
for a in answers:
|
||||||
|
item = str(a.get("item_number") or "").strip()
|
||||||
|
if item:
|
||||||
|
by_item[item] = a
|
||||||
|
for q in questions:
|
||||||
|
key = str(q.get("item_number") or "").strip()
|
||||||
|
ans = by_item.get(key)
|
||||||
|
if not ans:
|
||||||
|
# fallback: positional match if all numbers missing
|
||||||
|
continue
|
||||||
|
correct_text = (ans.get("correct_answer") or "").strip()
|
||||||
|
if correct_text:
|
||||||
|
# Find best-matching option text (case-insensitive contains)
|
||||||
|
opts = q.get("options") or []
|
||||||
|
matched = None
|
||||||
|
for opt in opts:
|
||||||
|
if opt.strip().lower() == correct_text.lower():
|
||||||
|
matched = opt
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
for opt in opts:
|
||||||
|
if correct_text.lower() in opt.lower() or opt.lower() in correct_text.lower():
|
||||||
|
matched = opt
|
||||||
|
break
|
||||||
|
q["correct_answer"] = matched or correct_text
|
||||||
|
q["explanation"] = (ans.get("explanation") or "").strip()
|
||||||
|
return questions
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"ai_answer attempt {attempt + 1}: {e}")
|
||||||
|
# If all attempts fail, return questions as-is (PENDING)
|
||||||
|
return questions
|
||||||
|
|
||||||
|
|
||||||
# ─── TWO-STEP (SEPARATE ANSWER KEY) ─────────────────────────────────────────
|
# ─── TWO-STEP (SEPARATE ANSWER KEY) ─────────────────────────────────────────
|
||||||
|
|
||||||
def extract_two_step(
|
def extract_two_step(
|
||||||
|
|
@ -330,23 +422,32 @@ def extract_with_regex(
|
||||||
|
|
||||||
# ─── AI DECIDES ──────────────────────────────────────────────────────────────
|
# ─── AI DECIDES ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
AI_DECIDE_PROMPT = """You are analysing a PREP medical exam PDF to determine the best extraction strategy.
|
AI_DECIDE_PROMPT = """You are analysing a medical study PDF to determine the best extraction strategy.
|
||||||
|
|
||||||
Sample from first 30 pages:
|
Section spans pages {start_page}-{end_page} ({total_pages} pages total).
|
||||||
|
|
||||||
|
Samples from several points across the section so you can see the overall structure:
|
||||||
|
|
||||||
|
[BEGIN — pages {start_page}-{s1_end}]
|
||||||
{sample_start}
|
{sample_start}
|
||||||
|
|
||||||
---
|
[MIDDLE — pages {s2_start}-{s2_end}]
|
||||||
Sample from last 20 pages:
|
{sample_middle_1}
|
||||||
|
|
||||||
|
[MIDDLE — pages {s3_start}-{s3_end}]
|
||||||
|
{sample_middle_2}
|
||||||
|
|
||||||
|
[END — pages {s4_start}-{end_page}]
|
||||||
{sample_end}
|
{sample_end}
|
||||||
|
|
||||||
Based on these samples, which extraction strategy should be used?
|
Based on these samples taken from across the document, which extraction strategy should be used?
|
||||||
|
|
||||||
1. standard — "Correct Answer: X" or "Preferred Response: X" appears right after each question
|
1. standard — "Correct Answer: X" or "Preferred Response: X" appears right after each question
|
||||||
2. two_step — questions come first (no answers), then a separate answer key section at the back
|
2. two_step — questions come first (no answers inline), then a separate answer key section at the back
|
||||||
3. questions_only — no answer indicators at all (answers unknown)
|
3. ai_answer — document has Q&A format but NO answers anywhere; AI will pick the correct option and write an explanation
|
||||||
|
|
||||||
Return ONLY JSON:
|
Return ONLY JSON:
|
||||||
{{"strategy": "standard" | "two_step" | "questions_only",
|
{{"strategy": "standard" | "two_step" | "ai_answer",
|
||||||
"reasoning": "<one sentence>"}}"""
|
"reasoning": "<one sentence>"}}"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -443,16 +544,40 @@ def ai_decide_strategy(
|
||||||
model_id: str | None,
|
model_id: str | None,
|
||||||
api_key: str | None,
|
api_key: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""AI reads samples from start and end of document and decides extraction strategy."""
|
"""AI samples across the section (start, two middle points, end) to decide extraction strategy.
|
||||||
sample_start = vector_service.get_pages_text(document_id=document_id,
|
For large documents this gives a much better view than just start+end."""
|
||||||
start_page=section_start,
|
total = max(1, section_end - section_start + 1)
|
||||||
end_page=min(section_start + 29, section_end))
|
sample_size = min(15, max(6, total // 15)) # 6-15 pages per sample
|
||||||
sample_end = vector_service.get_pages_text(document_id=document_id,
|
|
||||||
start_page=max(section_start, section_end - 19),
|
# Evenly spaced sample windows across the section
|
||||||
end_page=section_end)
|
s1_start = section_start
|
||||||
|
s1_end = min(section_start + sample_size - 1, section_end)
|
||||||
|
|
||||||
|
s2_start = min(section_start + total // 3, section_end - sample_size + 1)
|
||||||
|
s2_end = min(s2_start + sample_size - 1, section_end)
|
||||||
|
|
||||||
|
s3_start = min(section_start + (2 * total) // 3, section_end - sample_size + 1)
|
||||||
|
s3_end = min(s3_start + sample_size - 1, section_end)
|
||||||
|
|
||||||
|
s4_start = max(section_start, section_end - sample_size + 1)
|
||||||
|
s4_end = section_end
|
||||||
|
|
||||||
|
def _page_text(a, b):
|
||||||
|
return _normalize(vector_service.get_pages_text(document_id=document_id, start_page=a, end_page=b) or "")
|
||||||
|
|
||||||
|
sample_start = _page_text(s1_start, s1_end)[:12000]
|
||||||
|
sample_middle_1 = _page_text(s2_start, s2_end)[:12000]
|
||||||
|
sample_middle_2 = _page_text(s3_start, s3_end)[:12000]
|
||||||
|
sample_end = _page_text(s4_start, s4_end)[:12000]
|
||||||
|
|
||||||
prompt = AI_DECIDE_PROMPT.format(
|
prompt = AI_DECIDE_PROMPT.format(
|
||||||
sample_start=_normalize(sample_start or "")[:30000],
|
start_page=section_start, end_page=section_end, total_pages=total,
|
||||||
sample_end=_normalize(sample_end or "")[:20000],
|
s1_end=s1_end, s2_start=s2_start, s2_end=s2_end,
|
||||||
|
s3_start=s3_start, s3_end=s3_end, s4_start=s4_start,
|
||||||
|
sample_start=sample_start,
|
||||||
|
sample_middle_1=sample_middle_1,
|
||||||
|
sample_middle_2=sample_middle_2,
|
||||||
|
sample_end=sample_end,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
text = ai_service._call_model(prompt, model_id, api_key).strip()
|
text = ai_service._call_model(prompt, model_id, api_key).strip()
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ def extract_quiz(
|
||||||
from app.services.extraction_modes import (
|
from app.services.extraction_modes import (
|
||||||
extract_questions_only, extract_two_step,
|
extract_questions_only, extract_two_step,
|
||||||
extract_with_regex, ai_decide_strategy, generate_from_text,
|
extract_with_regex, ai_decide_strategy, generate_from_text,
|
||||||
|
ai_answer_questions,
|
||||||
)
|
)
|
||||||
|
|
||||||
resolved_mode = extraction_mode
|
resolved_mode = extraction_mode
|
||||||
|
|
@ -138,6 +139,29 @@ def extract_quiz(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}")
|
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}")
|
||||||
|
|
||||||
|
elif resolved_mode == "ai_answer":
|
||||||
|
_push_step(r, job_id, "ai", "Mode: AI Answer — extracting questions and using AI to determine correct answers.")
|
||||||
|
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
|
||||||
|
if r.get(f"extraction:status:{job_id}") == "cancelled":
|
||||||
|
_push_step(r, job_id, "cancelled", "Job cancelled.")
|
||||||
|
return
|
||||||
|
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} — extracting questions…")
|
||||||
|
chunk_content = vector_service.get_pages_text(
|
||||||
|
document_id=section.document_id, start_page=start_p, end_page=end_p)
|
||||||
|
if not chunk_content:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
normalized = _normalize_ocr(chunk_content)
|
||||||
|
qs = extract_questions_only(normalized, f"{start_p}-{end_p}", start_p, model_id, api_key)
|
||||||
|
if qs:
|
||||||
|
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p}: {len(qs)} questions found, AI determining answers…")
|
||||||
|
qs = ai_answer_questions(qs, normalized, f"{start_p}-{end_p}", model_id, api_key)
|
||||||
|
answered = sum(1 for q in qs if q.get("correct_answer") and q["correct_answer"] != "PENDING")
|
||||||
|
all_valid_questions.extend(qs)
|
||||||
|
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p}: {answered}/{len(qs)} answered. Total: {len(all_valid_questions)}.")
|
||||||
|
except Exception as e:
|
||||||
|
_push_step(r, job_id, "ai", f" Pages {start_p}–{end_p} failed: {e}")
|
||||||
|
|
||||||
elif resolved_mode == "two_step":
|
elif resolved_mode == "two_step":
|
||||||
_push_step(r, job_id, "ai", "Mode: Two-Step (separate answer key section).")
|
_push_step(r, job_id, "ai", "Mode: Two-Step (separate answer key section).")
|
||||||
all_valid_questions, all_skipped = extract_two_step(
|
all_valid_questions, all_skipped = extract_two_step(
|
||||||
|
|
|
||||||
|
|
@ -405,6 +405,7 @@ export default function DocumentDetailPage() {
|
||||||
<select value={extractionMode} onChange={e => setExtractionMode(e.target.value)}>
|
<select value={extractionMode} onChange={e => setExtractionMode(e.target.value)}>
|
||||||
<option value="standard">Standard — inline answers (Correct Answer / Preferred Response)</option>
|
<option value="standard">Standard — inline answers (Correct Answer / Preferred Response)</option>
|
||||||
<option value="questions_only">Questions Only — no answers (fill in manually later)</option>
|
<option value="questions_only">Questions Only — no answers (fill in manually later)</option>
|
||||||
|
<option value="ai_answer">AI Answer — extract questions, AI determines correct answers</option>
|
||||||
<option value="two_step">Two-Step — separate answer key section (PREP 2013 style)</option>
|
<option value="two_step">Two-Step — separate answer key section (PREP 2013 style)</option>
|
||||||
<option value="regex">AI + Regex — AI analyses format then applies regex for answers</option>
|
<option value="regex">AI + Regex — AI analyses format then applies regex for answers</option>
|
||||||
<option value="ai_decide">AI Decides — AI reads the document and picks best strategy</option>
|
<option value="ai_decide">AI Decides — AI reads the document and picks best strategy</option>
|
||||||
|
|
@ -413,9 +414,10 @@ export default function DocumentDetailPage() {
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
|
||||||
{extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'}
|
{extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'}
|
||||||
{extractionMode === 'questions_only' && 'Extracts questions + options only. Answer each question manually in Edit mode.'}
|
{extractionMode === 'questions_only' && 'Extracts questions + options only. Answer each question manually in Edit mode.'}
|
||||||
|
{extractionMode === 'ai_answer' && 'For Q&A PDFs with no answer key. AI extracts questions then determines the correct answer and explanation from document context + medical knowledge.'}
|
||||||
{extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'}
|
{extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'}
|
||||||
{extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'}
|
{extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'}
|
||||||
{extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy.'}
|
{extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy (standard, two_step, or ai_answer).'}
|
||||||
{extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'}
|
{extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ export default function FlashcardsPage() {
|
||||||
const [filterDeckId, setFilterDeckId] = useState('')
|
const [filterDeckId, setFilterDeckId] = useState('')
|
||||||
const [studyCard, setStudyCard] = useState(null)
|
const [studyCard, setStudyCard] = useState(null)
|
||||||
const [flipped, setFlipped] = useState(false)
|
const [flipped, setFlipped] = useState(false)
|
||||||
|
const [editingDeckId, setEditingDeckId] = useState(null)
|
||||||
|
const [editingTitle, setEditingTitle] = useState('')
|
||||||
const [deletingCard, setDeletingCard] = useState(null)
|
const [deletingCard, setDeletingCard] = useState(null)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -111,6 +113,15 @@ export default function FlashcardsPage() {
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveTitle = async (deckId, newTitle) => {
|
||||||
|
const t = (newTitle || '').trim()
|
||||||
|
if (!t) return
|
||||||
|
try {
|
||||||
|
await api.patch(`/flashcards/${deckId}`, { title: t })
|
||||||
|
setDecks(prev => prev.map(d => d.id === deckId ? { ...d, title: t } : d))
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
const toggleShare = async (deckId) => {
|
const toggleShare = async (deckId) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.put(`/flashcards/${deckId}/share`)
|
const res = await api.put(`/flashcards/${deckId}/share`)
|
||||||
|
|
@ -189,7 +200,34 @@ export default function FlashcardsPage() {
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||||
{decks.map(deck => (
|
{decks.map(deck => (
|
||||||
<div key={deck.id} className="card" style={{ padding: 20 }}>
|
<div key={deck.id} className="card" style={{ padding: 20 }}>
|
||||||
<h3 style={{ fontSize: '1rem', marginBottom: 6 }}>{deck.title}</h3>
|
{editingDeckId === deck.id ? (
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingTitle}
|
||||||
|
onChange={e => setEditingTitle(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') { saveTitle(deck.id, editingTitle); setEditingDeckId(null) }
|
||||||
|
if (e.key === 'Escape') setEditingDeckId(null)
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
style={{ width: '100%', boxSizing: 'border-box', padding: '6px 10px', fontSize: '1rem', fontWeight: 600, border: '1px solid var(--border)', borderRadius: 6, background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => { saveTitle(deck.id, editingTitle); setEditingDeckId(null) }}>Save</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setEditingDeckId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 style={{ fontSize: '1rem', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||||
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{deck.title}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingDeckId(deck.id); setEditingTitle(deck.title) }}
|
||||||
|
title="Rename deck"
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.85rem', padding: '0 4px', flexShrink: 0 }}
|
||||||
|
>✎</button>
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 4 }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: 4 }}>
|
||||||
{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}
|
{deck.card_count} cards · {new Date(deck.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue