diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7e37425..f5b53cd 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -121,3 +121,313 @@ h1 { color: #b91c1c; 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; } + diff --git a/app/controllers/filter_controller.rb b/app/controllers/filter_controller.rb new file mode 100644 index 0000000..624ed4e --- /dev/null +++ b/app/controllers/filter_controller.rb @@ -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 diff --git a/app/controllers/reviews_controller.rb b/app/controllers/reviews_controller.rb new file mode 100644 index 0000000..e153b2f --- /dev/null +++ b/app/controllers/reviews_controller.rb @@ -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 diff --git a/app/controllers/words_controller.rb b/app/controllers/words_controller.rb new file mode 100644 index 0000000..bb6959a --- /dev/null +++ b/app/controllers/words_controller.rb @@ -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 diff --git a/app/javascript/controllers/flashcard_controller.js b/app/javascript/controllers/flashcard_controller.js new file mode 100644 index 0000000..7ef1945 --- /dev/null +++ b/app/javascript/controllers/flashcard_controller.js @@ -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) + } +} diff --git a/app/models/review.rb b/app/models/review.rb new file mode 100644 index 0000000..53e8361 --- /dev/null +++ b/app/models/review.rb @@ -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 diff --git a/app/models/word.rb b/app/models/word.rb new file mode 100644 index 0000000..1799baa --- /dev/null +++ b/app/models/word.rb @@ -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 diff --git a/app/views/filter/show.html.erb b/app/views/filter/show.html.erb new file mode 100644 index 0000000..073fb1e --- /dev/null +++ b/app/views/filter/show.html.erb @@ -0,0 +1,75 @@ +<% content_for :title, "Filter — Lexivo" %> + +
+ 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 %> +
+ <%= link_to "Back to words", words_path, class: "btn btn-outline" %> +Next review: <%= @upcoming.first.due_label %> — <%= @upcoming_count %> upcoming.
+No words in the learning queue yet. <%= link_to "Filter some words first", filter_path %>.
+ <% end %> + <%= link_to "Back to words", words_path, class: "btn btn-outline" %> +
+ One entry per line. Formats: word - definition |
+ word: definition | word | definition |
+ just word
+
| <%= word.text %> | +<%= word.definition.presence || "-" %> | ++ + <%= word.review&.status || "new" %> + + <% if word.review&.status == "learning" && word.review.due_at %> + <%= word.review.due_label %> + <% end %> + | ++ <%= button_to "x", word_path(word), method: :delete, class: "btn-del", data: { turbo_confirm: "Remove #{word.text}?" } %> + | +