diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 7e37425..040d79f 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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; } } diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb new file mode 100644 index 0000000..7d5dc95 --- /dev/null +++ b/app/controllers/books_controller.rb @@ -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 diff --git a/app/controllers/characters_controller.rb b/app/controllers/characters_controller.rb new file mode 100644 index 0000000..4eb12fc --- /dev/null +++ b/app/controllers/characters_controller.rb @@ -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 diff --git a/app/controllers/story_pages_controller.rb b/app/controllers/story_pages_controller.rb new file mode 100644 index 0000000..794228f --- /dev/null +++ b/app/controllers/story_pages_controller.rb @@ -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 diff --git a/app/javascript/controllers/canvas_controller.js b/app/javascript/controllers/canvas_controller.js new file mode 100644 index 0000000..f01abd9 --- /dev/null +++ b/app/javascript/controllers/canvas_controller.js @@ -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 + } +} diff --git a/app/javascript/controllers/canvas_preview_controller.js b/app/javascript/controllers/canvas_preview_controller.js new file mode 100644 index 0000000..5967742 --- /dev/null +++ b/app/javascript/controllers/canvas_preview_controller.js @@ -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() + } +} diff --git a/app/models/character.rb b/app/models/character.rb new file mode 100644 index 0000000..879b857 --- /dev/null +++ b/app/models/character.rb @@ -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 diff --git a/app/models/story_page.rb b/app/models/story_page.rb new file mode 100644 index 0000000..ebcf0c7 --- /dev/null +++ b/app/models/story_page.rb @@ -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 diff --git a/app/views/books/index.html.erb b/app/views/books/index.html.erb new file mode 100644 index 0000000..ea9a7eb --- /dev/null +++ b/app/views/books/index.html.erb @@ -0,0 +1,70 @@ +<% content_for :title, "Волшебная Книга — Главная" %> + +
Место, где рождаются истории
+ +<%= truncate(page.content, length: 120) %>
+Книга пуста. Начните свою историю!
+ <%= link_to "✎ Написать первую страницу", new_story_page_path, class: "btn btn-primary" %> ++ Используйте инструменты ниже, чтобы создать портрет персонажа на холсте. +
+ ++ Нажмите на фигуру, чтобы выделить. Перетащите для перемещения. Клик правой кнопкой — удалить. +
+ + <%= f.hidden_field :canvas_data, data: { canvas_target: "data" } %> +Герои вашей истории
+ +Персонажей пока нет. Создайте первого героя!
+<%= @character.description %>
+ <% end %> + +<%= @character.color %>
+ Все главы вашей истории
+ +<%= truncate(page.content, length: 100) %>
+Страниц пока нет. Начните писать!
+