Волшебная Книга: Russian book storytelling app
Some checks failed
CI / Lint & Test (push) Has been cancelled
Deploy Status Page / Build & Deploy (push) Has been cancelled

- Book-styled UI with parchment aesthetic and Russian navigation
- Characters model with Konva.js 2D canvas drawing (draggable shapes)
- StoryPages model with character association and page navigation
- Stimulus controllers: canvas (editor) + canvas-preview (read-only)
- Full Russian interface: all labels, buttons, flash messages in Russian
This commit is contained in:
viktorvsk 2026-04-25 15:29:15 +00:00
parent 5663e81697
commit f3d33199f0
24 changed files with 1292 additions and 102 deletions

View File

@ -1,123 +1,511 @@
/*
* This is a manifest file that'll be compiled into application.css.
*
* With Propshaft, assets are served efficiently without preprocessing steps. You can still include
* application-wide styles in this file, but keep in mind that CSS precedence will follow the standard
* cascading order, meaning styles declared later in the document or manifest will override earlier ones,
* depending on specificity.
*
* Consider organizing styles into separate files for maintainability.
* Волшебная Книга стили книжного приложения
*/
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap');
*, *::before, *::after { box-sizing: border-box; }
:root {
--parchment: #f5ecd7;
--parchment-dark:#e8d9b5;
--ink: #2c1a0e;
--ink-light: #5a3e28;
--gold: #c9922a;
--gold-light: #e8b84b;
--leather: #7a3f1e;
--shadow: rgba(0,0,0,0.18);
--page-w: 700px;
}
html { scroll-behavior: smooth; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
color: #333;
font-family: 'Playfair Display', 'Georgia', serif;
background: #1a0d05;
background-image:
radial-gradient(ellipse at 20% 50%, #2a1208 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, #2a1208 0%, transparent 60%);
color: var(--ink);
margin: 0;
padding: 0;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 40px auto;
padding: 0 20px;
/* ── Navigation ─────────────────────────────────────────── */
.book-nav {
background: var(--leather);
background-image: linear-gradient(180deg, #8a4f2e 0%, #5a2e10 100%);
border-bottom: 3px solid var(--gold);
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
position: sticky;
top: 0;
z-index: 100;
}
h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
color: #111;
}
.todo-form { margin-bottom: 16px; }
.form-row {
.nav-inner {
max-width: var(--page-w);
margin: 0 auto;
padding: 12px 24px;
display: flex;
gap: 8px;
}
.todo-input {
flex: 1;
padding: 10px 14px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color 0.15s;
}
.todo-input:focus { border-color: #4f46e5; }
.btn {
padding: 10px 18px;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: opacity 0.15s;
justify-content: space-between;
gap: 20px;
}
.btn:hover { opacity: 0.85; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-secondary { background: #e5e7eb; color: #374151; }
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
.btn-danger { background: #ef4444; color: #fff; }
.nav-brand {
font-size: 1.3rem;
font-weight: 700;
color: var(--gold-light);
text-decoration: none;
letter-spacing: 0.03em;
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.todo-list {
.nav-links {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.nav-link {
color: #e8d4b0;
text-decoration: none;
font-size: 0.95rem;
padding: 4px 10px;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
}
.nav-link:hover { background: rgba(255,255,255,0.12); color: var(--gold-light); }
.nav-link--action {
background: var(--gold);
color: var(--ink);
font-weight: 700;
padding: 5px 14px;
}
.nav-link--action:hover { background: var(--gold-light); color: var(--ink); }
/* ── Flash messages ──────────────────────────────────────── */
.flash {
max-width: var(--page-w);
margin: 16px auto 0;
padding: 10px 20px;
border-radius: 6px;
font-size: 0.95rem;
font-style: italic;
}
.flash--notice { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.flash--alert { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
/* ── Main layout ─────────────────────────────────────────── */
.book-main {
max-width: var(--page-w);
margin: 32px auto;
padding: 0 16px 60px;
}
/* ── Book page card ──────────────────────────────────────── */
.page-card {
background: var(--parchment);
background-image:
linear-gradient(90deg, rgba(0,0,0,0.04) 0px, transparent 2px),
linear-gradient(rgba(0,0,0,0.025) 0px, transparent 1px);
background-size: 20px 20px, 100% 28px;
border-radius: 4px 12px 12px 4px;
box-shadow:
-6px 0 0 var(--parchment-dark),
-8px 0 0 #d4c5a0,
-10px 0 0 #c8b890,
4px 4px 20px var(--shadow),
inset 0 0 60px rgba(0,0,0,0.03);
padding: 48px 56px;
position: relative;
border-left: 3px solid #c8b37a;
}
.page-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(90deg, rgba(0,0,0,0.05) 0%, transparent 8%);
border-radius: inherit;
pointer-events: none;
}
/* ── Typography ──────────────────────────────────────────── */
.book-title {
font-size: 2.4rem;
color: var(--ink);
text-align: center;
margin: 0 0 8px;
line-height: 1.2;
letter-spacing: 0.01em;
}
.book-subtitle {
text-align: center;
color: var(--ink-light);
font-style: italic;
font-size: 1.1rem;
margin-bottom: 40px;
}
.chapter-title {
font-size: 1.7rem;
color: var(--ink);
margin: 0 0 24px;
padding-bottom: 12px;
border-bottom: 1px solid var(--parchment-dark);
font-style: italic;
}
.section-divider {
text-align: center;
color: var(--gold);
font-size: 1.4rem;
letter-spacing: 0.4em;
margin: 32px 0;
user-select: none;
}
.prose {
font-size: 1.05rem;
line-height: 1.85;
color: var(--ink);
text-align: justify;
hyphens: auto;
margin: 0 0 24px;
}
/* ── Cover page ──────────────────────────────────────────── */
.book-cover {
text-align: center;
padding: 40px 0;
}
.cover-ornament {
font-size: 3rem;
display: block;
margin-bottom: 16px;
filter: sepia(0.3);
}
.cover-stats {
display: flex;
justify-content: center;
gap: 32px;
margin: 32px 0;
flex-wrap: wrap;
}
.stat-block {
text-align: center;
}
.stat-number {
display: block;
font-size: 2.5rem;
font-weight: 700;
color: var(--gold);
line-height: 1;
}
.stat-label {
display: block;
font-size: 0.85rem;
color: var(--ink-light);
font-style: italic;
margin-top: 4px;
}
/* ── Page list ───────────────────────────────────────────── */
.page-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
align-items: center;
flex-direction: column;
gap: 12px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.todo-item.completed { opacity: 0.55; }
.todo-item.completed .todo-title { text-decoration: line-through; color: #9ca3af; }
.toggle-btn { background: none; border: none; padding: 0; cursor: pointer; }
.toggle-btn form { margin: 0; }
.checkbox {
width: 22px;
height: 22px;
border: 2px solid #4f46e5;
border-radius: 50%;
.page-list-item {
display: flex;
align-items: flex-start;
gap: 16px;
background: rgba(255,255,255,0.4);
border: 1px solid var(--parchment-dark);
border-radius: 6px;
padding: 14px 18px;
transition: background 0.2s;
text-decoration: none;
color: inherit;
}
.page-list-item:hover { background: rgba(255,255,255,0.65); }
.page-number {
font-size: 0.8rem;
color: var(--gold);
font-weight: 700;
min-width: 32px;
padding-top: 2px;
font-style: normal;
}
.page-excerpt {
font-size: 0.9rem;
color: var(--ink-light);
font-style: italic;
margin: 4px 0 0;
line-height: 1.4;
}
/* ── Character grid ──────────────────────────────────────── */
.character-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
margin-top: 24px;
}
.character-card {
background: rgba(255,255,255,0.45);
border: 1px solid var(--parchment-dark);
border-radius: 8px;
overflow: hidden;
text-align: center;
text-decoration: none;
color: inherit;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.character-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.character-canvas-preview {
width: 100%;
height: 150px;
display: block;
background: #f0e4c8;
}
.character-name {
padding: 10px 12px 12px;
font-weight: 700;
font-size: 0.95rem;
color: var(--ink);
}
/* ── Konva canvas container ──────────────────────────────── */
.canvas-wrap {
background: #f0e4c8;
border: 2px solid var(--parchment-dark);
border-radius: 8px;
overflow: hidden;
display: inline-block;
box-shadow: inset 0 2px 8px rgba(0,0,0,0.1);
}
.canvas-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
/* ── Forms ───────────────────────────────────────────────── */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 0.9rem;
font-weight: 700;
color: var(--ink-light);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-style: normal;
font-family: system-ui, sans-serif;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.7);
border: 1px solid #c8b37a;
border-radius: 6px;
font-family: inherit;
font-size: 1rem;
color: var(--ink);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(201, 146, 42, 0.15);
}
.form-textarea {
min-height: 200px;
resize: vertical;
line-height: 1.7;
}
/* ── Buttons ─────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #4f46e5;
font-weight: bold;
background: #fff;
gap: 6px;
padding: 9px 20px;
border: 1px solid transparent;
border-radius: 6px;
font-family: inherit;
font-size: 0.95rem;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
font-weight: 500;
}
.btn-primary {
background: var(--leather);
color: var(--gold-light);
border-color: var(--gold);
}
.btn-primary:hover {
background: #8a4020;
color: #fff;
}
.btn-secondary {
background: rgba(255,255,255,0.5);
color: var(--ink);
border-color: #c8b37a;
}
.btn-secondary:hover { background: rgba(255,255,255,0.8); }
.btn-danger {
background: #8b1a1a;
color: #fde8e8;
border-color: #a02020;
}
.btn-danger:hover { background: #a02020; }
.btn-sm { padding: 5px 12px; font-size: 0.82rem; }
.btn-tool {
background: rgba(255,255,255,0.6);
border: 1px solid #c8b37a;
color: var(--ink);
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.todo-item.completed .checkbox { background: #4f46e5; color: #fff; }
.todo-title { flex: 1; font-size: 1rem; }
.todo-actions { display: flex; gap: 6px; }
.btn-tool:hover { background: rgba(255,255,255,0.9); }
.btn-tool.active { background: var(--gold); color: var(--ink); font-weight: 700; }
.error-messages {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 12px;
color: #b91c1c;
font-size: 0.9rem;
/* ── Page reader (show view) ─────────────────────────────── */
.page-reader {
min-height: 400px;
}
.page-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--parchment-dark);
}
.page-content {
white-space: pre-wrap;
font-size: 1.08rem;
line-height: 1.9;
color: var(--ink);
font-family: 'Playfair Display', 'Georgia', serif;
}
/* ── Errors ──────────────────────────────────────────────── */
.error-messages {
background: #fef0f0;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
color: #8b1a1a;
font-size: 0.9rem;
font-style: italic;
}
.error-messages ul { margin: 6px 0 0 16px; padding: 0; }
/* ── Action row ──────────────────────────────────────────── */
.action-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-top: 24px;
}
/* ── Character avatar chip ───────────────────────────────── */
.character-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.82rem;
font-style: italic;
border: 1px solid currentColor;
opacity: 0.85;
}
/* ── Footer ──────────────────────────────────────────────── */
.book-footer {
text-align: center;
color: #8a6a3a;
font-size: 0.82rem;
font-style: italic;
padding: 20px;
border-top: 1px solid #3a1a08;
}
/* ── Empty state ─────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--ink-light);
font-style: italic;
}
.empty-state-icon { font-size: 3rem; display: block; margin-bottom: 16px; }
@media (max-width: 600px) {
.page-card { padding: 28px 24px; }
.book-title { font-size: 1.8rem; }
.nav-inner { flex-direction: column; gap: 10px; }
.nav-links { justify-content: center; }
}

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class BooksController < ApplicationController
def index
@pages = StoryPage.includes(:character).all
@characters = Character.all
@total_pages = @pages.count
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
class CharactersController < ApplicationController
before_action :set_character, only: [:show, :edit, :update, :destroy]
def index
@characters = Character.all.order(:created_at)
end
def show
end
def new
@character = Character.new
end
def create
@character = Character.new(character_params)
if @character.save
redirect_to @character, notice: "Персонаж создан."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @character.update(character_params)
redirect_to @character, notice: "Персонаж обновлён."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@character.destroy
redirect_to characters_path, notice: "Персонаж удалён."
end
private
def set_character
@character = Character.find(params[:id])
end
def character_params
params.require(:character).permit(:name, :description, :canvas_data, :color)
end
end

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
class StoryPagesController < ApplicationController
before_action :set_page, only: [:show, :edit, :update, :destroy]
def index
@pages = StoryPage.includes(:character).all
end
def show
@prev_page = StoryPage.where("position < ?", @page.position).last
@next_page = StoryPage.where("position > ?", @page.position).first
end
def new
@page = StoryPage.new
@characters = Character.all.order(:name)
end
def create
@page = StoryPage.new(page_params)
if @page.save
redirect_to @page, notice: "Страница добавлена."
else
@characters = Character.all.order(:name)
render :new, status: :unprocessable_entity
end
end
def edit
@characters = Character.all.order(:name)
end
def update
if @page.update(page_params)
redirect_to @page, notice: "Страница обновлена."
else
@characters = Character.all.order(:name)
render :edit, status: :unprocessable_entity
end
end
def destroy
@page.destroy
redirect_to story_pages_path, notice: "Страница удалена."
end
private
def set_page
@page = StoryPage.find(params[:id])
end
def page_params
params.require(:story_page).permit(:title, :content, :position, :character_id)
end
end

View File

@ -0,0 +1,182 @@
import { Controller } from "@hotwired/stimulus"
import Konva from "konva"
// Draws 2D book characters using Konva.js
// Shapes are draggable; right-click removes them.
export default class extends Controller {
static targets = ["container", "data"]
static values = { initial: String }
connect() {
this._initStage()
if (this.initialValue) this._load(this.initialValue)
this._bindAutoSave()
}
disconnect() {
if (this.stage) this.stage.destroy()
}
// ── Tools ─────────────────────────────────────────────────
addHead() {
this._add(new Konva.Circle({
x: 190, y: 90, radius: 55,
fill: "#f5d5a0", stroke: "#8b6914", strokeWidth: 2,
draggable: true, name: "shape"
}))
}
addBody() {
this._add(new Konva.Rect({
x: 145, y: 155, width: 90, height: 120,
fill: "#8b4513", stroke: "#5a2d0c", strokeWidth: 2,
cornerRadius: 6, draggable: true, name: "shape"
}))
}
addEye() {
const g = new Konva.Group({ x: 175, y: 80, draggable: true, name: "shape" })
g.add(new Konva.Circle({ x: 0, y: 0, radius: 8, fill: "#fff", stroke: "#333", strokeWidth: 1 }))
g.add(new Konva.Circle({ x: 0, y: 0, radius: 4, fill: "#1a0a00" }))
g.add(new Konva.Circle({ x: 2, y: -2, radius: 1.5, fill: "#fff" }))
this._add(g)
}
addMouth() {
this._add(new Konva.Arc({
x: 190, y: 120, innerRadius: 18, outerRadius: 22,
angle: 180, rotation: 0,
fill: "#c0392b", stroke: "#7b241c", strokeWidth: 1,
draggable: true, name: "shape"
}))
}
addHair() {
this._add(new Konva.Ellipse({
x: 190, y: 42, radiusX: 60, radiusY: 28,
fill: "#4a2c0a", stroke: "#2c1a05", strokeWidth: 1,
draggable: true, name: "shape"
}))
}
addArm() {
this._add(new Konva.Rect({
x: 110, y: 160, width: 28, height: 80,
fill: "#8b4513", stroke: "#5a2d0c", strokeWidth: 1,
cornerRadius: 8, draggable: true, name: "shape", rotation: -8
}))
}
addLeg() {
this._add(new Konva.Rect({
x: 165, y: 275, width: 32, height: 100,
fill: "#2c3e50", stroke: "#1a252f", strokeWidth: 1,
cornerRadius: 6, draggable: true, name: "shape"
}))
}
addAccessory() {
// A simple star / magic wand
this._add(new Konva.Star({
x: 240, y: 80, numPoints: 5,
innerRadius: 12, outerRadius: 24,
fill: "#f1c40f", stroke: "#c9a227", strokeWidth: 1.5,
draggable: true, name: "shape"
}))
}
clear() {
if (!confirm("Очистить холст?")) return
this.layer.destroyChildren()
this._save()
this.layer.draw()
}
// ── Private ────────────────────────────────────────────────
_initStage() {
const w = this.containerTarget.offsetWidth || 380
const h = this.containerTarget.offsetHeight || 480
this.stage = new Konva.Stage({ container: this.containerTarget, width: w, height: h })
this.layer = new Konva.Layer()
this.stage.add(this.layer)
// Subtle parchment grid
const bg = new Konva.Rect({ x: 0, y: 0, width: w, height: h, fill: "#f0e4c8", listening: false })
this.layer.add(bg)
this.layer.draw()
}
_add(shape) {
shape.on("contextmenu", (e) => {
e.evt.preventDefault()
shape.destroy()
this._save()
this.layer.draw()
})
this.layer.add(shape)
this.layer.draw()
this._save()
}
_bindAutoSave() {
this.layer.on("dragend", () => this._save())
}
_save() {
if (this.hasDataTarget) {
this.dataTarget.value = JSON.stringify(this.layer.toJSON())
}
}
_load(json) {
try {
const data = JSON.parse(json)
if (data && data.children) {
data.children.forEach(child => {
let shape
switch (child.className) {
case "Circle": shape = new Konva.Circle(child.attrs); break
case "Rect": shape = new Konva.Rect(child.attrs); break
case "Ellipse": shape = new Konva.Ellipse(child.attrs); break
case "Arc": shape = new Konva.Arc(child.attrs); break
case "Star": shape = new Konva.Star(child.attrs); break
case "Group": shape = this._buildGroup(child); break
default: return
}
if (shape) {
shape.draggable(true)
shape.on("contextmenu", (e) => {
e.evt.preventDefault()
shape.destroy()
this._save()
this.layer.draw()
})
this.layer.add(shape)
}
})
this.layer.draw()
}
} catch (e) {
console.warn("[canvas] failed to load saved data", e)
}
}
_buildGroup(data) {
const g = new Konva.Group(data.attrs)
if (data.children) {
data.children.forEach(child => {
let shape
switch (child.className) {
case "Circle": shape = new Konva.Circle(child.attrs); break
case "Rect": shape = new Konva.Rect(child.attrs); break
default: return
}
if (shape) g.add(shape)
})
}
return g
}
}

View File

@ -0,0 +1,50 @@
import { Controller } from "@hotwired/stimulus"
import Konva from "konva"
// Read-only Konva canvas for character previews
export default class extends Controller {
static values = { data: String }
connect() {
if (!this.dataValue) return
const w = this.element.offsetWidth || 300
const h = this.element.offsetHeight || 400
// Replace canvas element with a div for Konva to own
const div = document.createElement("div")
div.style.width = w + "px"
div.style.height = h + "px"
this.element.replaceWith(div)
const stage = new Konva.Stage({ container: div, width: w, height: h })
const layer = new Konva.Layer()
stage.add(layer)
// Scale to fit if saved canvas was larger
const saved = JSON.parse(this.dataValue)
const scaleX = w / (saved.attrs?.width || 380)
const scaleY = h / (saved.attrs?.height || 480)
const scale = Math.min(scaleX, scaleY)
layer.scale({ x: scale, y: scale })
layer.add(new Konva.Rect({ x: 0, y: 0, width: w / scale, height: h / scale, fill: "#f0e4c8", listening: false }))
if (saved.children) {
saved.children.forEach(child => {
let shape
switch (child.className) {
case "Circle": shape = new Konva.Circle(child.attrs); break
case "Rect": shape = new Konva.Rect(child.attrs); break
case "Ellipse": shape = new Konva.Ellipse(child.attrs); break
case "Arc": shape = new Konva.Arc(child.attrs); break
case "Star": shape = new Konva.Star(child.attrs); break
default: return
}
if (shape) { shape.listening(false); layer.add(shape) }
})
}
layer.draw()
}
}

8
app/models/character.rb Normal file
View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Character < ApplicationRecord
has_many :story_pages, dependent: :nullify
validates :name, presence: true, length: { maximum: 100 }
validates :color, format: { with: /\A#[0-9a-f]{6}\z/i }, allow_blank: true
end

18
app/models/story_page.rb Normal file
View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class StoryPage < ApplicationRecord
belongs_to :character, optional: true
validates :content, length: { maximum: 5000 }
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
default_scope { order(:position, :created_at) }
before_create :assign_position
private
def assign_position
self.position = (StoryPage.unscoped.maximum(:position) || -1) + 1 if position.zero?
end
end

View File

@ -0,0 +1,70 @@
<% content_for :title, "Волшебная Книга — Главная" %>
<div class="page-card">
<div class="book-cover">
<span class="cover-ornament">✦ ✦ ✦</span>
<h1 class="book-title">Волшебная Книга</h1>
<p class="book-subtitle">Место, где рождаются истории</p>
<div class="section-divider">❧ ❧ ❧</div>
<div class="cover-stats">
<div class="stat-block">
<span class="stat-number"><%= @total_pages %></span>
<span class="stat-label">страниц</span>
</div>
<div class="stat-block">
<span class="stat-number"><%= @characters.count %></span>
<span class="stat-label">персонажей</span>
</div>
</div>
<% if @pages.any? %>
<div class="section-divider">— ◆ —</div>
<h2 class="chapter-title" style="text-align:center">Содержание</h2>
<ul class="page-list">
<% @pages.each_with_index do |page, i| %>
<li>
<%= link_to story_page_path(page), class: "page-list-item" do %>
<span class="page-number">С. <%= i + 1 %></span>
<div>
<strong><%= page.title.presence || "Без названия" %></strong>
<p class="page-excerpt"><%= truncate(page.content, length: 120) %></p>
</div>
<% end %>
</li>
<% end %>
</ul>
<% else %>
<div class="empty-state">
<span class="empty-state-icon">📜</span>
<p>Книга пуста. Начните свою историю!</p>
<%= link_to "✎ Написать первую страницу", new_story_page_path, class: "btn btn-primary" %>
</div>
<% end %>
<% if @characters.any? %>
<div class="section-divider">— ◆ —</div>
<h2 class="chapter-title" style="text-align:center">Персонажи</h2>
<div class="character-grid">
<% @characters.each do |character| %>
<%= link_to character_path(character), class: "character-card" do %>
<% if character.canvas_data.present? %>
<canvas class="character-canvas-preview" data-canvas-data="<%= character.canvas_data %>"></canvas>
<% else %>
<div class="character-canvas-preview" style="display:flex;align-items:center;justify-content:center;font-size:3rem;">🧙</div>
<% end %>
<div class="character-name"><%= character.name %></div>
<% end %>
<% end %>
</div>
<% end %>
<div class="action-row" style="justify-content:center;margin-top:40px;">
<%= link_to "✎ Новая страница", new_story_page_path, class: "btn btn-primary" %>
<%= link_to " Новый персонаж", new_character_path, class: "btn btn-secondary" %>
</div>
</div>
</div>

View File

@ -0,0 +1,69 @@
<%= form_with model: character do |f| %>
<% if character.errors.any? %>
<div class="error-messages">
<strong>Исправьте ошибки:</strong>
<ul>
<% character.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :name, "Имя персонажа", class: "form-label" %>
<%= f.text_field :name, class: "form-input", placeholder: "Иван Царевич", autofocus: true %>
</div>
<div class="form-group">
<%= f.label :description, "Описание", class: "form-label" %>
<%= f.text_area :description, class: "form-textarea", rows: 4,
placeholder: "Храбрый юноша с добрым сердцем..." %>
</div>
<div class="form-group">
<%= f.label :color, "Цвет персонажа", class: "form-label" %>
<div style="display:flex;align-items:center;gap:12px;">
<%= f.color_field :color, class: "form-input", style: "width:60px;height:40px;padding:2px;cursor:pointer;" %>
<span style="font-size:0.85rem;color:var(--ink-light);font-style:italic;">Этот цвет будет использоваться для выделения персонажа в книге</span>
</div>
</div>
<div class="form-group">
<span class="form-label">Нарисуйте персонажа</span>
<p style="font-size:0.85rem;color:var(--ink-light);font-style:italic;margin:0 0 12px;">
Используйте инструменты ниже, чтобы создать портрет персонажа на холсте.
</p>
<div data-controller="canvas"
data-canvas-initial-value="<%= character.canvas_data.presence || '' %>">
<div class="canvas-toolbar">
<button type="button" class="btn-tool" data-action="canvas#addHead">👤 Голова</button>
<button type="button" class="btn-tool" data-action="canvas#addBody">🟫 Тело</button>
<button type="button" class="btn-tool" data-action="canvas#addEye">👁 Глаз</button>
<button type="button" class="btn-tool" data-action="canvas#addMouth">〰 Рот</button>
<button type="button" class="btn-tool" data-action="canvas#addHair">〰 Волосы</button>
<button type="button" class="btn-tool" data-action="canvas#addArm">💪 Рука</button>
<button type="button" class="btn-tool" data-action="canvas#addLeg">🦵 Нога</button>
<button type="button" class="btn-tool" data-action="canvas#addAccessory">⚔ Аксессуар</button>
<button type="button" class="btn-tool" data-action="canvas#clear" style="margin-left:auto;color:#8b1a1a;">✕ Очистить</button>
</div>
<div class="canvas-wrap">
<div data-canvas-target="container" style="width:380px;height:480px;"></div>
</div>
<p style="font-size:0.78rem;color:var(--ink-light);font-style:italic;margin-top:8px;">
Нажмите на фигуру, чтобы выделить. Перетащите для перемещения. Клик правой кнопкой — удалить.
</p>
<%= f.hidden_field :canvas_data, data: { canvas_target: "data" } %>
</div>
</div>
<div class="action-row">
<%= f.submit "💾 Сохранить персонажа", class: "btn btn-primary" %>
<%= link_to "Отмена", character.persisted? ? character_path(character) : characters_path, class: "btn btn-secondary" %>
</div>
<% end %>

View File

@ -0,0 +1,6 @@
<% content_for :title, "Редактировать персонажа" %>
<div class="page-card">
<h1 class="chapter-title">✎ Редактировать: <%= @character.name %></h1>
<%= render "form", character: @character %>
</div>

View File

@ -0,0 +1,36 @@
<% content_for :title, "Персонажи" %>
<div class="page-card">
<h1 class="book-title">Персонажи</h1>
<p class="book-subtitle">Герои вашей истории</p>
<div class="action-row">
<%= link_to " Новый персонаж", new_character_path, class: "btn btn-primary" %>
<%= link_to "← На обложку", root_path, class: "btn btn-secondary" %>
</div>
<div class="section-divider">❧ ❧ ❧</div>
<% if @characters.any? %>
<div class="character-grid">
<% @characters.each do |character| %>
<%= link_to character_path(character), class: "character-card" do %>
<div class="character-canvas-preview" style="display:flex;align-items:center;justify-content:center;position:relative;background:#f0e4c8;">
<% if character.canvas_data.present? %>
<canvas data-character-id="<%= character.id %>" data-canvas-data="<%= character.canvas_data %>" style="max-width:100%;max-height:100%;"></canvas>
<% else %>
<span style="font-size:2.5rem;">🧙</span>
<% end %>
<div style="position:absolute;bottom:4px;right:6px;width:12px;height:12px;border-radius:50%;background:<%= character.color %>;border:2px solid #fff;"></div>
</div>
<div class="character-name" style="color:<%= character.color %>"><%= character.name %></div>
<% end %>
<% end %>
</div>
<% else %>
<div class="empty-state">
<span class="empty-state-icon">🧙</span>
<p>Персонажей пока нет. Создайте первого героя!</p>
</div>
<% end %>
</div>

View File

@ -0,0 +1,6 @@
<% content_for :title, "Новый персонаж" %>
<div class="page-card">
<h1 class="chapter-title"> Новый персонаж</h1>
<%= render "form", character: @character %>
</div>

View File

@ -0,0 +1,52 @@
<% content_for :title, @character.name %>
<div class="page-card">
<div style="display:flex;align-items:flex-start;gap:32px;flex-wrap:wrap;">
<div>
<% if @character.canvas_data.present? %>
<div class="canvas-wrap">
<canvas id="preview-canvas" width="300" height="400"
data-controller="canvas-preview"
data-canvas-preview-data-value="<%= @character.canvas_data %>">
</canvas>
</div>
<% else %>
<div class="canvas-wrap" style="width:300px;height:400px;display:flex;align-items:center;justify-content:center;font-size:5rem;">🧙</div>
<% end %>
</div>
<div style="flex:1;min-width:200px;">
<h1 class="chapter-title" style="color:<%= @character.color %>"><%= @character.name %></h1>
<% if @character.description.present? %>
<p class="prose"><%= @character.description %></p>
<% end %>
<div style="margin:16px 0;">
<span class="form-label">Цвет персонажа</span>
<div style="display:flex;align-items:center;gap:8px;margin-top:4px;">
<div style="width:24px;height:24px;border-radius:50%;background:<%= @character.color %>;border:2px solid #c8b37a;"></div>
<code style="font-size:0.85rem;color:var(--ink-light);"><%= @character.color %></code>
</div>
</div>
<% pages_with_character = @character.story_pages %>
<% if pages_with_character.any? %>
<div style="margin-top:20px;">
<div class="form-label">Страницы с этим персонажем</div>
<ul style="margin:8px 0 0;padding-left:18px;">
<% pages_with_character.each_with_index do |pg, i| %>
<li><%= link_to pg.title.presence || "Страница #{i+1}", story_page_path(pg), style:"color:var(--leather);" %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="action-row" style="margin-top:32px;">
<%= link_to "✎ Редактировать", edit_character_path(@character), class: "btn btn-primary" %>
<%= link_to "✕ Удалить", character_path(@character), data: { turbo_method: :delete, turbo_confirm: "Удалить персонажа «#{@character.name}»?" }, class: "btn btn-danger" %>
<%= link_to "← Все персонажи", characters_path, class: "btn btn-secondary" %>
</div>
</div>
</div>
</div>

View File

@ -1,11 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Starter" %></title>
<title><%= content_for(:title) || "Волшебная Книга" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="Starter">
<meta name="application-name" content="Волшебная Книга">
<meta name="mobile-web-app-capable" content="yes">
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@ -42,6 +45,30 @@
</head>
<body>
<nav class="book-nav">
<div class="nav-inner">
<%= link_to "📖 Волшебная Книга", root_path, class: "nav-brand" %>
<div class="nav-links">
<%= link_to "Страницы", story_pages_path, class: "nav-link" %>
<%= link_to "Персонажи", characters_path, class: "nav-link" %>
<%= link_to "+ Новая страница", new_story_page_path, class: "nav-link nav-link--action" %>
</div>
</div>
</nav>
<% if notice.present? %>
<div class="flash flash--notice"><%= notice %></div>
<% end %>
<% if alert.present? %>
<div class="flash flash--alert"><%= alert %></div>
<% end %>
<main class="book-main">
<%= yield %>
</main>
<footer class="book-footer">
<p>«Волшебная Книга» &mdash; пишите свои истории</p>
</footer>
</body>
</html>

View File

@ -0,0 +1,35 @@
<%= form_with model: page, class: "story-form" do |f| %>
<% if page.errors.any? %>
<div class="error-messages">
<strong>Исправьте ошибки:</strong>
<ul>
<% page.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= f.label :title, "Название страницы (необязательно)", class: "form-label" %>
<%= f.text_field :title, class: "form-input", placeholder: "Глава первая: Начало..." %>
</div>
<div class="form-group">
<%= f.label :content, "Текст", class: "form-label" %>
<%= f.text_area :content, class: "form-textarea", placeholder: "Давным-давно в далёком царстве...", rows: 12 %>
</div>
<div class="form-group">
<%= f.label :character_id, "Персонаж на этой странице", class: "form-label" %>
<%= f.select :character_id,
[["— нет —", nil]] + @characters.map { |c| [c.name, c.id] },
{},
class: "form-select" %>
</div>
<div class="action-row">
<%= f.submit "💾 Сохранить", class: "btn btn-primary" %>
<%= link_to "Отмена", page.persisted? ? story_page_path(page) : story_pages_path, class: "btn btn-secondary" %>
</div>
<% end %>

View File

@ -0,0 +1,6 @@
<% content_for :title, "Редактировать страницу" %>
<div class="page-card">
<h1 class="chapter-title">✎ Редактировать страницу</h1>
<%= render "form", page: @page %>
</div>

View File

@ -0,0 +1,45 @@
<% content_for :title, "Страницы истории" %>
<div class="page-card">
<h1 class="book-title">Страницы</h1>
<p class="book-subtitle">Все главы вашей истории</p>
<div class="action-row">
<%= link_to "✎ Новая страница", new_story_page_path, class: "btn btn-primary" %>
<%= link_to "← На обложку", root_path, class: "btn btn-secondary" %>
</div>
<div class="section-divider">❧ ❧ ❧</div>
<% if @pages.any? %>
<ul class="page-list">
<% @pages.each_with_index do |page, i| %>
<li>
<div class="page-list-item" style="flex-direction:column;align-items:flex-start;gap:8px;">
<div style="display:flex;width:100%;align-items:center;gap:12px;">
<span class="page-number">С. <%= i + 1 %></span>
<div style="flex:1;">
<%= link_to page.title.presence || "Без названия", story_page_path(page), style:"color:var(--ink);font-weight:700;" %>
<% if page.character %>
<span class="character-badge" style="background:<%= page.character.color %>22;color:<%= page.character.color %>;margin-left:8px;">
<%= page.character.name %>
</span>
<% end %>
<p class="page-excerpt"><%= truncate(page.content, length: 100) %></p>
</div>
<div class="action-row" style="margin:0;gap:6px;">
<%= link_to "✎", edit_story_page_path(page), class: "btn btn-secondary btn-sm" %>
<%= link_to "✕", story_page_path(page), data: { turbo_method: :delete, turbo_confirm: "Удалить эту страницу?" }, class: "btn btn-danger btn-sm" %>
</div>
</div>
</div>
</li>
<% end %>
</ul>
<% else %>
<div class="empty-state">
<span class="empty-state-icon">📜</span>
<p>Страниц пока нет. Начните писать!</p>
</div>
<% end %>
</div>

View File

@ -0,0 +1,6 @@
<% content_for :title, "Новая страница" %>
<div class="page-card">
<h1 class="chapter-title">✎ Новая страница</h1>
<%= render "form", page: @page %>
</div>

View File

@ -0,0 +1,42 @@
<% content_for :title, @page.title.presence || "Страница" %>
<div class="page-card page-reader">
<% if @page.title.present? %>
<h1 class="chapter-title"><%= @page.title %></h1>
<% end %>
<% if @page.character %>
<div style="margin-bottom:24px;display:flex;align-items:center;gap:12px;">
<% if @page.character.canvas_data.present? %>
<canvas width="60" height="80" data-canvas-data="<%= @page.character.canvas_data %>" style="border-radius:4px;background:#f0e4c8;border:1px solid #c8b37a;"></canvas>
<% end %>
<div>
<div style="font-size:0.78rem;text-transform:uppercase;letter-spacing:0.08em;color:var(--ink-light);font-style:normal;font-family:system-ui,sans-serif;">Персонаж</div>
<div style="font-weight:700;color:<%= @page.character.color %>"><%= @page.character.name %></div>
<% if @page.character.description.present? %>
<div style="font-size:0.85rem;font-style:italic;color:var(--ink-light);margin-top:2px;"><%= @page.character.description %></div>
<% end %>
</div>
</div>
<div class="section-divider" style="margin:16px 0 24px;">— ◆ —</div>
<% end %>
<div class="page-content"><%= @page.content %></div>
<div class="page-navigation">
<div>
<% if @prev_page %>
<%= link_to "← #{@prev_page.title.presence || 'Предыдущая'}", story_page_path(@prev_page), class: "btn btn-secondary btn-sm" %>
<% end %>
</div>
<div class="action-row" style="margin:0;gap:6px;">
<%= link_to "✎ Редактировать", edit_story_page_path(@page), class: "btn btn-secondary btn-sm" %>
<%= link_to "✕ Удалить", story_page_path(@page), data: { turbo_method: :delete, turbo_confirm: "Удалить эту страницу?" }, class: "btn btn-danger btn-sm" %>
</div>
<div>
<% if @next_page %>
<%= link_to "#{@next_page.title.presence || 'Следующая'} →", story_page_path(@next_page), class: "btn btn-secondary btn-sm" %>
<% end %>
</div>
</div>
</div>

View File

@ -3,7 +3,7 @@
# Pin npm packages by running ./bin/importmap
pin "application"
pin "three", to: "https://ga.jspm.io/npm:three@0.183.2/build/three.module.js"
pin "konva", to: "https://esm.sh/konva@9.3.20"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"

View File

@ -7,11 +7,8 @@ Rails.application.routes.draw do
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
member do
patch :toggle
end
end
resources :story_pages, path: "stranitsy"
resources :characters, path: "personazhi"
root "todos#index"
root "books#index"
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class CreateCharacters < ActiveRecord::Migration[8.0]
def change
create_table :characters do |t|
t.string :name, null: false
t.text :description
t.text :canvas_data
t.string :color, default: "#c8956c"
t.timestamps
end
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateStoryPages < ActiveRecord::Migration[8.0]
def change
create_table :story_pages do |t|
t.string :title
t.text :content, null: false, default: ""
t.integer :position, null: false, default: 0
t.references :character, null: true, foreign_key: true
t.timestamps
end
add_index :story_pages, :position
end
end