Lexivo: vocabulary learning app with SM-2 spaced repetition
- Word import (multi-format: word, word-def, word:def, word|def) - Flashcard filter UI (swipe + keyboard arrows + Space to flip) - SM-2 spaced repetition review queue - Stimulus flashcard controller with 3D flip animation
This commit is contained in:
parent
5663e81697
commit
e9f5d8ece2
@ -121,3 +121,313 @@ h1 {
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Lexivo ── */
|
||||||
|
|
||||||
|
body { background: #f0f0f7; }
|
||||||
|
|
||||||
|
.topnav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 52px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #a5b4fc;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links { display: flex; gap: 4px; }
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.nav-link:hover { background: #2d2d4e; color: #fff; }
|
||||||
|
|
||||||
|
main .container { max-width: 700px; margin: 32px auto; padding: 0 20px; }
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
flex: 1 1 90px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 3px solid transparent;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||||
|
.stat-label { font-size: 0.75rem; color: #6b7280; margin-top: 4px; }
|
||||||
|
|
||||||
|
.stat-total { border-color: #6366f1; }
|
||||||
|
.stat-unseen { border-color: #94a3b8; }
|
||||||
|
.stat-learning { border-color: #f59e0b; }
|
||||||
|
.stat-known { border-color: #22c55e; }
|
||||||
|
.stat-due { border-color: #ef4444; }
|
||||||
|
|
||||||
|
.stat-total .stat-number { color: #6366f1; }
|
||||||
|
.stat-due .stat-number { color: #ef4444; }
|
||||||
|
.stat-learning .stat-number { color: #f59e0b; }
|
||||||
|
.stat-known .stat-number { color: #22c55e; }
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success { background: #22c55e; color: #fff; }
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #6366f1;
|
||||||
|
border: 1.5px solid #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import */
|
||||||
|
.section { margin-bottom: 36px; }
|
||||||
|
.section h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 8px; color: #111; }
|
||||||
|
.hint { font-size: 0.82rem; color: #6b7280; margin-bottom: 10px; }
|
||||||
|
.hint code { background: #e5e7eb; padding: 1px 4px; border-radius: 3px; font-family: monospace; }
|
||||||
|
|
||||||
|
.import-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.import-textarea:focus { border-color: #6366f1; }
|
||||||
|
|
||||||
|
/* Word table */
|
||||||
|
.word-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||||
|
.word-row { border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.word-row:hover { background: #f9fafb; }
|
||||||
|
.word-text { padding: 8px 6px; font-weight: 600; width: 28%; }
|
||||||
|
.word-def { padding: 8px 6px; color: #4b5563; width: 50%; }
|
||||||
|
.word-status { padding: 8px 6px; width: 14%; }
|
||||||
|
.word-actions { padding: 8px 4px; text-align: right; width: 8%; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-new { background: #e5e7eb; color: #374151; }
|
||||||
|
.badge-known { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-learning { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-skipped { background: #f1f5f9; color: #64748b; }
|
||||||
|
|
||||||
|
.due-info { font-size: 0.72rem; color: #ef4444; margin-left: 4px; }
|
||||||
|
|
||||||
|
.btn-del {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.btn-del:hover { color: #ef4444; }
|
||||||
|
|
||||||
|
/* Flash */
|
||||||
|
.flash {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.flash-notice { background: #dcfce7; color: #166534; }
|
||||||
|
.flash-alert { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
/* ── Flashcard ── */
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.link-muted { color: #9ca3af; text-decoration: none; }
|
||||||
|
.link-muted:hover { color: #6b7280; }
|
||||||
|
|
||||||
|
.flashcard-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 12px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3D card container */
|
||||||
|
.flashcard {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
height: 240px;
|
||||||
|
perspective: 1000px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.38s ease, opacity 0.22s ease, translate 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flashcard.is-flipped { transform: rotateY(180deg); }
|
||||||
|
|
||||||
|
.flashcard.slide-right {
|
||||||
|
translate: 120% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.flashcard.slide-left {
|
||||||
|
translate: -120% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.flashcard.slide-up {
|
||||||
|
translate: 0 -80px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px 24px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-back { transform: rotateY(180deg); }
|
||||||
|
|
||||||
|
.card-word {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-definition {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.card-definition.muted { color: #9ca3af; font-style: italic; }
|
||||||
|
|
||||||
|
.card-example {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-interval {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flip-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form { display: inline; }
|
||||||
|
|
||||||
|
.btn-learning { background: #6366f1; color: #fff; min-width: 110px; justify-content: center; }
|
||||||
|
.btn-known { background: #22c55e; color: #fff; min-width: 110px; justify-content: center; }
|
||||||
|
.btn-skip { background: #e5e7eb; color: #374151; min-width: 90px; justify-content: center; }
|
||||||
|
|
||||||
|
/* Keyboard hint row */
|
||||||
|
.key-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
.empty-icon {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #d1d5db;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.empty-state h2 { margin-bottom: 10px; }
|
||||||
|
.empty-state p { color: #6b7280; margin-bottom: 24px; }
|
||||||
|
|
||||||
|
/* Upcoming list */
|
||||||
|
.upcoming-list {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.upcoming-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.upcoming-word { font-weight: 600; }
|
||||||
|
.upcoming-when { color: #9ca3af; }
|
||||||
|
|
||||||
|
.progress-text { font-size: 0.85rem; color: #6b7280; }
|
||||||
|
|
||||||
|
|||||||
24
app/controllers/filter_controller.rb
Normal file
24
app/controllers/filter_controller.rb
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class FilterController < ApplicationController
|
||||||
|
def show
|
||||||
|
@word = Word.unseen.order(:id).first
|
||||||
|
@remaining = Word.unseen.count
|
||||||
|
@total = Word.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def known
|
||||||
|
Word.find(params[:id]).review.mark_known!
|
||||||
|
redirect_to filter_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def learning
|
||||||
|
Word.find(params[:id]).review.mark_learning!
|
||||||
|
redirect_to filter_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip
|
||||||
|
Word.find(params[:id]).review.mark_skipped!
|
||||||
|
redirect_to filter_path
|
||||||
|
end
|
||||||
|
end
|
||||||
23
app/controllers/reviews_controller.rb
Normal file
23
app/controllers/reviews_controller.rb
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ReviewsController < ApplicationController
|
||||||
|
def show
|
||||||
|
@review = Review.due.includes(:word).first
|
||||||
|
@due_count = Review.due.count
|
||||||
|
@upcoming = Review.upcoming.includes(:word).limit(5)
|
||||||
|
@upcoming_count = Review.upcoming.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate
|
||||||
|
review = Review.find(params[:id])
|
||||||
|
quality = case params[:rating]
|
||||||
|
when "easy" then 5
|
||||||
|
when "good" then 4
|
||||||
|
when "hard" then 3
|
||||||
|
when "forgot" then 1
|
||||||
|
else params[:quality].to_i
|
||||||
|
end
|
||||||
|
review.sm2_update!(quality)
|
||||||
|
redirect_to review_path
|
||||||
|
end
|
||||||
|
end
|
||||||
55
app/controllers/words_controller.rb
Normal file
55
app/controllers/words_controller.rb
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class WordsController < ApplicationController
|
||||||
|
def index
|
||||||
|
@stats = {
|
||||||
|
total: Word.count,
|
||||||
|
unseen: Review.where(status: "new").count,
|
||||||
|
known: Review.where(status: "known").count,
|
||||||
|
learning: Review.where(status: "learning").count,
|
||||||
|
skipped: Review.where(status: "skipped").count,
|
||||||
|
due: Review.due.count
|
||||||
|
}
|
||||||
|
@words = Word.includes(:review).by_text
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
raw = params[:word_list].to_s.strip
|
||||||
|
lines = raw.split("\n").map(&:strip).reject { |l| l.blank? || l.start_with?("#") }
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
lines.each do |line|
|
||||||
|
text, definition = parse_line(line)
|
||||||
|
next if text.blank?
|
||||||
|
|
||||||
|
w = Word.new(text: text.strip, definition: definition&.strip.presence)
|
||||||
|
if w.save
|
||||||
|
imported += 1
|
||||||
|
else
|
||||||
|
skipped += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
flash[:notice] = "Imported #{imported} word#{"s" unless imported == 1}."
|
||||||
|
flash[:alert] = "Skipped #{skipped} (duplicates or invalid)." if skipped > 0
|
||||||
|
redirect_to words_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
Word.find(params[:id]).destroy
|
||||||
|
redirect_to words_path, notice: "Removed."
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_line(line)
|
||||||
|
# "word - definition", "word: definition", "word | definition", "word\tdefinition"
|
||||||
|
if (m = line.match(/\A(.+?)\s*(?:\t|-{1,2}|:{1,2}|\|)\s*(.+)\z/))
|
||||||
|
[ m[1], m[2] ]
|
||||||
|
else
|
||||||
|
[ line, nil ]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
125
app/javascript/controllers/flashcard_controller.js
Normal file
125
app/javascript/controllers/flashcard_controller.js
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
// Handles flashcard flip + keyboard + swipe for both filter and review pages.
|
||||||
|
// Expected DOM:
|
||||||
|
// data-controller="flashcard"
|
||||||
|
// data-flashcard-target="card" (the 3D flippable element)
|
||||||
|
//
|
||||||
|
// Hidden forms in the same element:
|
||||||
|
// id="form-right" — known / easy
|
||||||
|
// id="form-left" — learning / forgot
|
||||||
|
// id="form-skip" — skip (filter only)
|
||||||
|
// id="form-q3" — hard (review only)
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["card"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.flipped = false
|
||||||
|
this.animating = false
|
||||||
|
this.touchStartX = 0
|
||||||
|
this.touchStartY = 0
|
||||||
|
|
||||||
|
this._keydown = this.handleKeydown.bind(this)
|
||||||
|
document.addEventListener("keydown", this._keydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
document.removeEventListener("keydown", this._keydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (this.animating) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case " ":
|
||||||
|
e.preventDefault()
|
||||||
|
this.flip()
|
||||||
|
break
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
this.flip()
|
||||||
|
break
|
||||||
|
case "ArrowRight":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-right", "form-right")
|
||||||
|
break
|
||||||
|
case "ArrowLeft":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-left", "form-left")
|
||||||
|
break
|
||||||
|
case "s":
|
||||||
|
case "S":
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-up", "form-skip")
|
||||||
|
break
|
||||||
|
case "1":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-left", "form-left")
|
||||||
|
break
|
||||||
|
case "2":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-up", "form-q3")
|
||||||
|
break
|
||||||
|
case "3":
|
||||||
|
e.preventDefault()
|
||||||
|
this.slideAndSubmit("slide-right", "form-right")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Touch / swipe ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
touchstart(e) {
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
this.touchStartX = t.clientX
|
||||||
|
this.touchStartY = t.clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
touchend(e) {
|
||||||
|
if (this.animating) return
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
const dx = t.clientX - this.touchStartX
|
||||||
|
const dy = t.clientY - this.touchStartY
|
||||||
|
|
||||||
|
// Small movement = tap, handled by click -> flip
|
||||||
|
if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return
|
||||||
|
|
||||||
|
e.preventDefault() // prevent the click event that follows
|
||||||
|
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy) && Math.abs(dx) > 40) {
|
||||||
|
dx > 0
|
||||||
|
? this.slideAndSubmit("slide-right", "form-right")
|
||||||
|
: this.slideAndSubmit("slide-left", "form-left")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card flip ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
flip(e) {
|
||||||
|
if (e) e.stopPropagation()
|
||||||
|
this.flipped = !this.flipped
|
||||||
|
this.cardTarget.classList.toggle("is-flipped", this.flipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form submission ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
slideAndSubmit(animClass, formId) {
|
||||||
|
this.animating = true
|
||||||
|
this.cardTarget.classList.add(animClass)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const form = this.element.querySelector(`#${formId}`)
|
||||||
|
if (form) {
|
||||||
|
form.requestSubmit()
|
||||||
|
} else {
|
||||||
|
// form not present on this page variant — ignore silently
|
||||||
|
this.animating = false
|
||||||
|
this.cardTarget.classList.remove(animClass)
|
||||||
|
}
|
||||||
|
}, 220)
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/models/review.rb
Normal file
78
app/models/review.rb
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Review < ApplicationRecord
|
||||||
|
belongs_to :word
|
||||||
|
|
||||||
|
EASE_MIN = 1.3
|
||||||
|
EASE_START = 2.5
|
||||||
|
|
||||||
|
# status: new | known | learning | skipped
|
||||||
|
|
||||||
|
scope :due, -> { where(status: "learning").where("due_at <= ?", Time.current).order(:due_at) }
|
||||||
|
scope :upcoming, -> { where(status: "learning").order(:due_at) }
|
||||||
|
|
||||||
|
def mark_known!
|
||||||
|
update!(status: "known", last_reviewed_at: Time.current, due_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_learning!
|
||||||
|
update!(
|
||||||
|
status: "learning",
|
||||||
|
interval: 1,
|
||||||
|
repetitions: 0,
|
||||||
|
ease_factor: EASE_START,
|
||||||
|
due_at: 1.day.from_now,
|
||||||
|
last_reviewed_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_skipped!
|
||||||
|
update!(status: "skipped", last_reviewed_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# SM-2 algorithm: quality 0-5
|
||||||
|
# 0-2 = forgot/hard 3-5 = remembered
|
||||||
|
def sm2_update!(quality)
|
||||||
|
quality = quality.to_i.clamp(0, 5)
|
||||||
|
|
||||||
|
new_ease = [EASE_MIN, ease_factor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)].max.round(3)
|
||||||
|
|
||||||
|
if quality < 3
|
||||||
|
update!(
|
||||||
|
status: "learning",
|
||||||
|
repetitions: 0,
|
||||||
|
interval: 1,
|
||||||
|
ease_factor: new_ease,
|
||||||
|
due_at: 1.day.from_now,
|
||||||
|
last_reviewed_at: Time.current
|
||||||
|
)
|
||||||
|
else
|
||||||
|
new_reps = repetitions + 1
|
||||||
|
new_interval = case repetitions
|
||||||
|
when 0 then 1
|
||||||
|
when 1 then 6
|
||||||
|
else [ (interval * new_ease).ceil, 1 ].max
|
||||||
|
end
|
||||||
|
update!(
|
||||||
|
status: "learning",
|
||||||
|
repetitions: new_reps,
|
||||||
|
interval: new_interval,
|
||||||
|
ease_factor: new_ease,
|
||||||
|
due_at: new_interval.days.from_now,
|
||||||
|
last_reviewed_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def due_label
|
||||||
|
return "Now" unless due_at
|
||||||
|
return "Now" if due_at <= Time.current
|
||||||
|
|
||||||
|
days = ((due_at - Time.current) / 1.day).ceil
|
||||||
|
case days
|
||||||
|
when 0 then "Today"
|
||||||
|
when 1 then "Tomorrow"
|
||||||
|
else "#{days} days"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
21
app/models/word.rb
Normal file
21
app/models/word.rb
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Word < ApplicationRecord
|
||||||
|
has_one :review, dependent: :destroy
|
||||||
|
|
||||||
|
validates :text, presence: true, uniqueness: { case_sensitive: false }
|
||||||
|
validates :text, length: { maximum: 255 }
|
||||||
|
|
||||||
|
after_create :create_initial_review
|
||||||
|
|
||||||
|
scope :unseen, -> { joins(:review).where(reviews: { status: "new" }) }
|
||||||
|
scope :known, -> { joins(:review).where(reviews: { status: "known" }) }
|
||||||
|
scope :learning, -> { joins(:review).where(reviews: { status: "learning" }) }
|
||||||
|
scope :by_text, -> { order(:text) }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_initial_review
|
||||||
|
create_review!(status: "new")
|
||||||
|
end
|
||||||
|
end
|
||||||
75
app/views/filter/show.html.erb
Normal file
75
app/views/filter/show.html.erb
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<% content_for :title, "Filter — Lexivo" %>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<% if @word.nil? %>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">--o</div>
|
||||||
|
<h2>All words filtered!</h2>
|
||||||
|
<p>
|
||||||
|
Nothing left to sort.
|
||||||
|
<% learning_count = Review.where(status: "learning").count %>
|
||||||
|
<% if learning_count > 0 %>
|
||||||
|
You have <%= learning_count %> word<%= "s" unless learning_count == 1 %> in the learning queue.
|
||||||
|
<%= link_to "Go to review", review_path %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<%= link_to "Back to words", words_path, class: "btn btn-outline" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="filter-header">
|
||||||
|
<span class="progress-text">
|
||||||
|
<%= @remaining %> remaining
|
||||||
|
<% if @total > 0 %>
|
||||||
|
·
|
||||||
|
<%= ((@total - @remaining).to_f / @total * 100).round %>% done
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<%= link_to "word list", words_path, class: "link-muted" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flashcard-wrap" data-controller="flashcard">
|
||||||
|
<div class="flashcard" data-flashcard-target="card"
|
||||||
|
data-action="touchstart->flashcard#touchstart touchend->flashcard#touchend click->flashcard#flip">
|
||||||
|
<div class="card-face card-front">
|
||||||
|
<div class="card-word"><%= @word.text %></div>
|
||||||
|
<div class="flip-hint">Space / tap to flip</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-face card-back">
|
||||||
|
<div class="card-word"><%= @word.text %></div>
|
||||||
|
<% if @word.definition.present? %>
|
||||||
|
<div class="card-definition"><%= @word.definition %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="card-definition muted">No definition</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @word.example.present? %>
|
||||||
|
<div class="card-example"><em><%= @word.example %></em></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<%= form_with url: filter_learning_path(@word), method: :post, id: "form-left", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.submit "Learning ←", class: "btn btn-learning" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: filter_skip_path(@word), method: :post, id: "form-skip", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.submit "Skip S", class: "btn btn-skip" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: filter_known_path(@word), method: :post, id: "form-right", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.submit "Known →", class: "btn btn-known" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-hint">
|
||||||
|
<kbd>←</kbd> Learning
|
||||||
|
<kbd>Space</kbd> Flip
|
||||||
|
<kbd>→</kbd> Known
|
||||||
|
<kbd>S</kbd> Skip
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title><%= content_for(:title) || "Starter" %></title>
|
<title><%= content_for(:title) || "Lexivo" %></title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="application-name" content="Starter">
|
<meta name="application-name" content="Starter">
|
||||||
@ -42,6 +42,16 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<%= yield %>
|
<nav class="topnav">
|
||||||
|
<%= link_to "Lexivo", root_path, class: "nav-brand" %>
|
||||||
|
<div class="nav-links">
|
||||||
|
<%= link_to "Words", words_path, class: "nav-link" %>
|
||||||
|
<%= link_to "Filter", filter_path, class: "nav-link" %>
|
||||||
|
<%= link_to "Review", review_path, class: "nav-link" %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main>
|
||||||
|
<%= yield %>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
77
app/views/reviews/show.html.erb
Normal file
77
app/views/reviews/show.html.erb
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<% content_for :title, "Review — Lexivo" %>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<% if @review.nil? %>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">zz</div>
|
||||||
|
<h2>Nothing due!</h2>
|
||||||
|
<% if @upcoming_count > 0 %>
|
||||||
|
<p>Next review: <%= @upcoming.first.due_label %> — <%= @upcoming_count %> upcoming.</p>
|
||||||
|
<div class="upcoming-list">
|
||||||
|
<% @upcoming.each do |r| %>
|
||||||
|
<div class="upcoming-item">
|
||||||
|
<span class="upcoming-word"><%= r.word.text %></span>
|
||||||
|
<span class="upcoming-when"><%= r.due_label %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p>No words in the learning queue yet. <%= link_to "Filter some words first", filter_path %>.</p>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "Back to words", words_path, class: "btn btn-outline" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="filter-header">
|
||||||
|
<span class="progress-text">
|
||||||
|
<%= @due_count %> due · <%= @upcoming_count %> upcoming
|
||||||
|
</span>
|
||||||
|
<%= link_to "word list", words_path, class: "link-muted" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flashcard-wrap" data-controller="flashcard">
|
||||||
|
<div class="flashcard" data-flashcard-target="card"
|
||||||
|
data-action="touchstart->flashcard#touchstart touchend->flashcard#touchend click->flashcard#flip">
|
||||||
|
<div class="card-face card-front">
|
||||||
|
<div class="card-word"><%= @review.word.text %></div>
|
||||||
|
<div class="card-interval">interval: <%= @review.interval %>d · rep #<%= @review.repetitions %></div>
|
||||||
|
<div class="flip-hint">Space / tap to flip</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-face card-back">
|
||||||
|
<div class="card-word"><%= @review.word.text %></div>
|
||||||
|
<% if @review.word.definition.present? %>
|
||||||
|
<div class="card-definition"><%= @review.word.definition %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if @review.word.example.present? %>
|
||||||
|
<div class="card-example"><em><%= @review.word.example %></em></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<%= form_with url: rate_review_path(@review), method: :post, id: "form-left", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.hidden_field :rating, value: "forgot" %>
|
||||||
|
<%= f.submit "Forgot 1", class: "btn btn-learning" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: rate_review_path(@review), method: :post, id: "form-q3", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.hidden_field :rating, value: "hard" %>
|
||||||
|
<%= f.submit "Hard 2", class: "btn btn-skip" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: rate_review_path(@review), method: :post, id: "form-right", class: "inline-form",
|
||||||
|
data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<%= f.hidden_field :rating, value: "easy" %>
|
||||||
|
<%= f.submit "Easy 3", class: "btn btn-known" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-hint">
|
||||||
|
<kbd>1</kbd> / <kbd>←</kbd> Forgot
|
||||||
|
<kbd>2</kbd> Hard
|
||||||
|
<kbd>3</kbd> / <kbd>→</kbd> Easy
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
85
app/views/words/index.html.erb
Normal file
85
app/views/words/index.html.erb
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<% content_for :title, "Lexivo" %>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<% if notice.present? %>
|
||||||
|
<div class="flash flash-notice"><%= notice %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if alert.present? %>
|
||||||
|
<div class="flash flash-alert"><%= alert %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card stat-total">
|
||||||
|
<div class="stat-number"><%= @stats[:total] %></div>
|
||||||
|
<div class="stat-label">Total</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-unseen">
|
||||||
|
<div class="stat-number"><%= @stats[:unseen] %></div>
|
||||||
|
<div class="stat-label">Unseen</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-learning">
|
||||||
|
<div class="stat-number"><%= @stats[:learning] %></div>
|
||||||
|
<div class="stat-label">Learning</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-known">
|
||||||
|
<div class="stat-number"><%= @stats[:known] %></div>
|
||||||
|
<div class="stat-label">Known</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-due">
|
||||||
|
<div class="stat-number"><%= @stats[:due] %></div>
|
||||||
|
<div class="stat-label">Due Now</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<% if @stats[:unseen] > 0 %>
|
||||||
|
<%= link_to "Filter words (#{@stats[:unseen]} unseen)", filter_path, class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
<% if @stats[:due] > 0 %>
|
||||||
|
<%= link_to "Review (#{@stats[:due]} due)", review_path, class: "btn btn-success" %>
|
||||||
|
<% end %>
|
||||||
|
<% if @stats[:learning] > 0 && @stats[:due] == 0 %>
|
||||||
|
<%= link_to "Review queue (#{@stats[:learning]} upcoming)", review_path, class: "btn btn-outline" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Import words</h2>
|
||||||
|
<p class="hint">
|
||||||
|
One entry per line. Formats: <code>word - definition</code> |
|
||||||
|
<code>word: definition</code> | <code>word | definition</code> |
|
||||||
|
just <code>word</code>
|
||||||
|
</p>
|
||||||
|
<%= form_with url: words_path, method: :post, local: false, data: { turbo: false } do |f| %>
|
||||||
|
<%= f.text_area :word_list, placeholder: "ephemeral - lasting for a very short time\npersistent\ntenacious: holding firmly to a purpose", rows: 8, class: "import-textarea" %>
|
||||||
|
<%= f.submit "Import", class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @words.any? %>
|
||||||
|
<div class="section">
|
||||||
|
<h2>All words (<%= @words.count %>)</h2>
|
||||||
|
<table class="word-table">
|
||||||
|
<tbody>
|
||||||
|
<% @words.each do |word| %>
|
||||||
|
<tr class="word-row status-<%= word.review&.status || "new" %>">
|
||||||
|
<td class="word-text"><%= word.text %></td>
|
||||||
|
<td class="word-def"><%= word.definition.presence || "-" %></td>
|
||||||
|
<td class="word-status">
|
||||||
|
<span class="badge badge-<%= word.review&.status %>">
|
||||||
|
<%= word.review&.status || "new" %>
|
||||||
|
</span>
|
||||||
|
<% if word.review&.status == "learning" && word.review.due_at %>
|
||||||
|
<span class="due-info"><%= word.review.due_label %></span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="word-actions">
|
||||||
|
<%= button_to "x", word_path(word), method: :delete, class: "btn-del", data: { turbo_confirm: "Remove #{word.text}?" } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@ -1,17 +1,19 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
get "up" => "rails/health#show", as: :rails_health_check
|
get "up" => "rails/health#show", as: :rails_health_check
|
||||||
|
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||||
|
get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
||||||
|
|
||||||
# Render dynamic PWA files from app/views/pwa/*
|
resources :words, only: [ :index, :create, :destroy ]
|
||||||
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
|
||||||
get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
|
||||||
|
|
||||||
resources :todos, only: [:index, :create, :edit, :update, :destroy] do
|
get "/filter", to: "filter#show", as: :filter
|
||||||
member do
|
post "/filter/:id/known", to: "filter#known", as: :filter_known
|
||||||
patch :toggle
|
post "/filter/:id/learning", to: "filter#learning", as: :filter_learning
|
||||||
end
|
post "/filter/:id/skip", to: "filter#skip", as: :filter_skip
|
||||||
end
|
|
||||||
|
|
||||||
root "todos#index"
|
get "/review", to: "reviews#show", as: :review
|
||||||
|
post "/review/:id/rate", to: "reviews#rate", as: :rate_review
|
||||||
|
|
||||||
|
root "words#index"
|
||||||
end
|
end
|
||||||
|
|||||||
11
db/migrate/20260422000001_create_words.rb
Normal file
11
db/migrate/20260422000001_create_words.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class CreateWords < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :words do |t|
|
||||||
|
t.string :text, null: false
|
||||||
|
t.text :definition
|
||||||
|
t.text :example
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :words, :text, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
16
db/migrate/20260422000002_create_reviews.rb
Normal file
16
db/migrate/20260422000002_create_reviews.rb
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
class CreateReviews < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :reviews do |t|
|
||||||
|
t.references :word, null: false, foreign_key: true
|
||||||
|
t.string :status, null: false, default: "new"
|
||||||
|
t.integer :interval, null: false, default: 0
|
||||||
|
t.integer :repetitions, null: false, default: 0
|
||||||
|
t.float :ease_factor, null: false, default: 2.5
|
||||||
|
t.datetime :due_at
|
||||||
|
t.datetime :last_reviewed_at
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
add_index :reviews, :status
|
||||||
|
add_index :reviews, [ :status, :due_at ]
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
x
Reference in New Issue
Block a user