Lexivo: vocabulary learning app with SM-2 spaced repetition
Some checks failed
CI / Lint & Test (push) Has been cancelled
Deploy Status Page / Build & Deploy (push) Has been cancelled

- 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:
Fibe Agent 2026-04-22 15:02:45 +00:00
parent 5663e81697
commit e9f5d8ece2
14 changed files with 924 additions and 12 deletions

View File

@ -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; }

View 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

View 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

View 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

View 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
View 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
View 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

View 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 %>
&nbsp;&middot;&nbsp;
<%= ((@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 &nbsp;&nbsp;
<kbd>Space</kbd> Flip &nbsp;&nbsp;
<kbd>→</kbd> Known &nbsp;&nbsp;
<kbd>S</kbd> Skip
</div>
</div>
<% end %>
</div>

View File

@ -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>
<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 %> <%= yield %>
</main>
</body> </body>
</html> </html>

View 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 %> &mdash; <%= @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 &nbsp;&middot;&nbsp; <%= @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 &nbsp;&middot;&nbsp; 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 &nbsp;&nbsp;
<kbd>2</kbd> Hard &nbsp;&nbsp;
<kbd>3</kbd> / <kbd>→</kbd> Easy
</div>
</div>
<% end %>
</div>

View 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> &nbsp;|&nbsp;
<code>word: definition</code> &nbsp;|&nbsp; <code>word | definition</code> &nbsp;|&nbsp;
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>

View File

@ -2,16 +2,18 @@
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
# Render dynamic PWA files from app/views/pwa/*
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
resources :todos, only: [:index, :create, :edit, :update, :destroy] do resources :words, only: [ :index, :create, :destroy ]
member do
patch :toggle
end
end
root "todos#index" get "/filter", to: "filter#show", as: :filter
post "/filter/:id/known", to: "filter#known", as: :filter_known
post "/filter/:id/learning", to: "filter#learning", as: :filter_learning
post "/filter/:id/skip", to: "filter#skip", as: :filter_skip
get "/review", to: "reviews#show", as: :review
post "/review/:id/rate", to: "reviews#rate", as: :rate_review
root "words#index"
end end

View 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

View 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