Волшебная Книга: Russian book storytelling app
- 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:
parent
5663e81697
commit
f3d33199f0
@ -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; }
|
*, *::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 {
|
body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: 'Playfair Display', 'Georgia', serif;
|
||||||
background: #f5f5f5;
|
background: #1a0d05;
|
||||||
color: #333;
|
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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
/* ── Navigation ─────────────────────────────────────────── */
|
||||||
max-width: 640px;
|
.book-nav {
|
||||||
margin: 40px auto;
|
background: var(--leather);
|
||||||
padding: 0 20px;
|
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 {
|
.nav-inner {
|
||||||
font-size: 2rem;
|
max-width: var(--page-w);
|
||||||
font-weight: 700;
|
margin: 0 auto;
|
||||||
margin-bottom: 24px;
|
padding: 12px 24px;
|
||||||
color: #111;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-form { margin-bottom: 16px; }
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
transition: opacity 0.15s;
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover { opacity: 0.85; }
|
.nav-brand {
|
||||||
.btn-primary { background: #4f46e5; color: #fff; }
|
font-size: 1.3rem;
|
||||||
.btn-secondary { background: #e5e7eb; color: #374151; }
|
font-weight: 700;
|
||||||
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
color: var(--gold-light);
|
||||||
.btn-danger { background: #ef4444; color: #fff; }
|
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;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.todo-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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; }
|
.page-list-item {
|
||||||
.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%;
|
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 6px;
|
||||||
font-size: 0.75rem;
|
padding: 9px 20px;
|
||||||
color: #4f46e5;
|
border: 1px solid transparent;
|
||||||
font-weight: bold;
|
border-radius: 6px;
|
||||||
background: #fff;
|
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;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.todo-item.completed .checkbox { background: #4f46e5; color: #fff; }
|
.btn-tool:hover { background: rgba(255,255,255,0.9); }
|
||||||
.todo-title { flex: 1; font-size: 1rem; }
|
.btn-tool.active { background: var(--gold); color: var(--ink); font-weight: 700; }
|
||||||
.todo-actions { display: flex; gap: 6px; }
|
|
||||||
|
|
||||||
.error-messages {
|
/* ── Page reader (show view) ─────────────────────────────── */
|
||||||
background: #fef2f2;
|
.page-reader {
|
||||||
border: 1px solid #fecaca;
|
min-height: 400px;
|
||||||
border-radius: 6px;
|
}
|
||||||
padding: 10px 14px;
|
|
||||||
margin-bottom: 12px;
|
.page-navigation {
|
||||||
color: #b91c1c;
|
display: flex;
|
||||||
font-size: 0.9rem;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
9
app/controllers/books_controller.rb
Normal file
9
app/controllers/books_controller.rb
Normal 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
|
||||||
51
app/controllers/characters_controller.rb
Normal file
51
app/controllers/characters_controller.rb
Normal 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
|
||||||
57
app/controllers/story_pages_controller.rb
Normal file
57
app/controllers/story_pages_controller.rb
Normal 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
|
||||||
182
app/javascript/controllers/canvas_controller.js
Normal file
182
app/javascript/controllers/canvas_controller.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/javascript/controllers/canvas_preview_controller.js
Normal file
50
app/javascript/controllers/canvas_preview_controller.js
Normal 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
8
app/models/character.rb
Normal 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
18
app/models/story_page.rb
Normal 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
|
||||||
70
app/views/books/index.html.erb
Normal file
70
app/views/books/index.html.erb
Normal 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>
|
||||||
69
app/views/characters/_form.html.erb
Normal file
69
app/views/characters/_form.html.erb
Normal 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 %>
|
||||||
6
app/views/characters/edit.html.erb
Normal file
6
app/views/characters/edit.html.erb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<% content_for :title, "Редактировать персонажа" %>
|
||||||
|
|
||||||
|
<div class="page-card">
|
||||||
|
<h1 class="chapter-title">✎ Редактировать: <%= @character.name %></h1>
|
||||||
|
<%= render "form", character: @character %>
|
||||||
|
</div>
|
||||||
36
app/views/characters/index.html.erb
Normal file
36
app/views/characters/index.html.erb
Normal 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>
|
||||||
6
app/views/characters/new.html.erb
Normal file
6
app/views/characters/new.html.erb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<% content_for :title, "Новый персонаж" %>
|
||||||
|
|
||||||
|
<div class="page-card">
|
||||||
|
<h1 class="chapter-title">+ Новый персонаж</h1>
|
||||||
|
<%= render "form", character: @character %>
|
||||||
|
</div>
|
||||||
52
app/views/characters/show.html.erb
Normal file
52
app/views/characters/show.html.erb
Normal 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>
|
||||||
@ -1,11 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title><%= content_for(:title) || "Starter" %></title>
|
<title><%= content_for(:title) || "Волшебная Книга" %></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="Волшебная Книга">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<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 %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|
||||||
@ -42,6 +45,30 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 %>
|
<%= yield %>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="book-footer">
|
||||||
|
<p>«Волшебная Книга» — пишите свои истории</p>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
35
app/views/story_pages/_form.html.erb
Normal file
35
app/views/story_pages/_form.html.erb
Normal 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 %>
|
||||||
6
app/views/story_pages/edit.html.erb
Normal file
6
app/views/story_pages/edit.html.erb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<% content_for :title, "Редактировать страницу" %>
|
||||||
|
|
||||||
|
<div class="page-card">
|
||||||
|
<h1 class="chapter-title">✎ Редактировать страницу</h1>
|
||||||
|
<%= render "form", page: @page %>
|
||||||
|
</div>
|
||||||
45
app/views/story_pages/index.html.erb
Normal file
45
app/views/story_pages/index.html.erb
Normal 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>
|
||||||
6
app/views/story_pages/new.html.erb
Normal file
6
app/views/story_pages/new.html.erb
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<% content_for :title, "Новая страница" %>
|
||||||
|
|
||||||
|
<div class="page-card">
|
||||||
|
<h1 class="chapter-title">✎ Новая страница</h1>
|
||||||
|
<%= render "form", page: @page %>
|
||||||
|
</div>
|
||||||
42
app/views/story_pages/show.html.erb
Normal file
42
app/views/story_pages/show.html.erb
Normal 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>
|
||||||
@ -3,7 +3,7 @@
|
|||||||
# Pin npm packages by running ./bin/importmap
|
# Pin npm packages by running ./bin/importmap
|
||||||
|
|
||||||
pin "application"
|
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", to: "stimulus.min.js"
|
||||||
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||||
|
|||||||
@ -7,11 +7,8 @@ Rails.application.routes.draw do
|
|||||||
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 :story_pages, path: "stranitsy"
|
||||||
member do
|
resources :characters, path: "personazhi"
|
||||||
patch :toggle
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
root "todos#index"
|
root "books#index"
|
||||||
end
|
end
|
||||||
|
|||||||
14
db/migrate/20260425153000_create_characters.rb
Normal file
14
db/migrate/20260425153000_create_characters.rb
Normal 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
|
||||||
16
db/migrate/20260425153001_create_story_pages.rb
Normal file
16
db/migrate/20260425153001_create_story_pages.rb
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user