railspress-engine 0.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +20 -0
  3. data/README.md +195 -25
  4. data/app/assets/javascripts/railspress/admin.js +39 -0
  5. data/app/assets/javascripts/railspress/markdown_mode.js +343 -0
  6. data/app/assets/stylesheets/application.css +0 -0
  7. data/app/assets/stylesheets/railspress/admin/badges.css +70 -0
  8. data/app/assets/stylesheets/railspress/admin/base.css +25 -0
  9. data/app/assets/stylesheets/railspress/admin/buttons.css +140 -0
  10. data/app/assets/stylesheets/railspress/admin/cards.css +52 -0
  11. data/app/assets/stylesheets/railspress/admin/components/exports.css +55 -0
  12. data/app/assets/stylesheets/railspress/admin/components/focal_point.css +801 -0
  13. data/app/assets/stylesheets/railspress/admin/components/imports.css +144 -0
  14. data/app/assets/stylesheets/railspress/admin/components/lexxy.css +156 -0
  15. data/app/assets/stylesheets/railspress/admin/filters.css +73 -0
  16. data/app/assets/stylesheets/railspress/admin/flash.css +26 -0
  17. data/app/assets/stylesheets/railspress/admin/forms.css +459 -0
  18. data/app/assets/stylesheets/railspress/admin/layout.css +256 -0
  19. data/app/assets/stylesheets/railspress/admin/lists.css +24 -0
  20. data/app/assets/stylesheets/railspress/admin/page.css +111 -0
  21. data/app/assets/stylesheets/railspress/admin/responsive.css +174 -0
  22. data/app/assets/stylesheets/railspress/admin/stats.css +43 -0
  23. data/app/assets/stylesheets/railspress/admin/tables.css +163 -0
  24. data/app/assets/stylesheets/railspress/admin/utilities.css +202 -0
  25. data/app/assets/stylesheets/railspress/admin/variables.css +58 -0
  26. data/app/assets/stylesheets/railspress/application.css +44 -13
  27. data/app/controllers/railspress/admin/base_controller.rb +6 -3
  28. data/app/controllers/railspress/admin/categories_controller.rb +1 -1
  29. data/app/controllers/railspress/admin/cms_transfers_controller.rb +49 -0
  30. data/app/controllers/railspress/admin/content_element_versions_controller.rb +12 -0
  31. data/app/controllers/railspress/admin/content_elements_controller.rb +143 -0
  32. data/app/controllers/railspress/admin/content_groups_controller.rb +69 -0
  33. data/app/controllers/railspress/admin/dashboard_controller.rb +6 -0
  34. data/app/controllers/railspress/admin/entities_controller.rb +157 -0
  35. data/app/controllers/railspress/admin/exports_controller.rb +55 -0
  36. data/app/controllers/railspress/admin/focal_points_controller.rb +100 -0
  37. data/app/controllers/railspress/admin/imports_controller.rb +63 -0
  38. data/app/controllers/railspress/admin/posts_controller.rb +58 -4
  39. data/app/controllers/railspress/admin/prototypes_controller.rb +30 -0
  40. data/app/controllers/railspress/admin/tags_controller.rb +1 -1
  41. data/app/controllers/railspress/application_controller.rb +1 -0
  42. data/app/helpers/railspress/admin_helper.rb +733 -0
  43. data/app/helpers/railspress/application_helper.rb +23 -0
  44. data/app/helpers/railspress/cms_helper.rb +319 -0
  45. data/app/javascript/railspress/controllers/cms_inline_editor_controller.js +147 -0
  46. data/app/javascript/railspress/controllers/content_element_form_controller.js +15 -0
  47. data/app/javascript/railspress/controllers/crop_controller.js +224 -0
  48. data/app/javascript/railspress/controllers/dropzone_controller.js +261 -0
  49. data/app/javascript/railspress/controllers/focal_point_controller.js +124 -0
  50. data/app/javascript/railspress/controllers/image_section_controller.js +94 -0
  51. data/app/javascript/railspress/controllers/index.js +37 -0
  52. data/app/javascript/railspress/index.js +62 -0
  53. data/app/jobs/railspress/export_posts_job.rb +16 -0
  54. data/app/jobs/railspress/import_posts_job.rb +44 -0
  55. data/app/models/concerns/railspress/has_focal_point.rb +242 -0
  56. data/app/models/concerns/railspress/soft_deletable.rb +23 -0
  57. data/app/models/concerns/railspress/taggable.rb +23 -0
  58. data/app/models/railspress/content_element.rb +103 -0
  59. data/app/models/railspress/content_element_version.rb +32 -0
  60. data/app/models/railspress/content_group.rb +39 -0
  61. data/app/models/railspress/export.rb +67 -0
  62. data/app/models/railspress/focal_point.rb +70 -0
  63. data/app/models/railspress/import.rb +65 -0
  64. data/app/models/railspress/post.rb +102 -15
  65. data/app/models/railspress/post_export_processor.rb +162 -0
  66. data/app/models/railspress/post_import_processor.rb +382 -0
  67. data/app/models/railspress/tag.rb +10 -3
  68. data/app/models/railspress/tagging.rb +11 -0
  69. data/app/services/railspress/content_export_service.rb +122 -0
  70. data/app/services/railspress/content_import_service.rb +228 -0
  71. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  72. data/app/views/active_storage/blobs/_blob.html.erb +1 -1
  73. data/app/views/layouts/railspress/admin.html.erb +3 -1
  74. data/app/views/railspress/admin/categories/index.html.erb +11 -15
  75. data/app/views/railspress/admin/cms_transfers/show.html.erb +167 -0
  76. data/app/views/railspress/admin/content_element_versions/show.html.erb +42 -0
  77. data/app/views/railspress/admin/content_elements/_form.html.erb +71 -0
  78. data/app/views/railspress/admin/content_elements/_inline_form.html.erb +32 -0
  79. data/app/views/railspress/admin/content_elements/_inline_form_frame.html.erb +6 -0
  80. data/app/views/railspress/admin/content_elements/edit.html.erb +6 -0
  81. data/app/views/railspress/admin/content_elements/index.html.erb +74 -0
  82. data/app/views/railspress/admin/content_elements/new.html.erb +6 -0
  83. data/app/views/railspress/admin/content_elements/show.html.erb +124 -0
  84. data/app/views/railspress/admin/content_groups/_form.html.erb +9 -0
  85. data/app/views/railspress/admin/content_groups/edit.html.erb +6 -0
  86. data/app/views/railspress/admin/content_groups/index.html.erb +42 -0
  87. data/app/views/railspress/admin/content_groups/new.html.erb +6 -0
  88. data/app/views/railspress/admin/content_groups/show.html.erb +92 -0
  89. data/app/views/railspress/admin/dashboard/index.html.erb +36 -1
  90. data/app/views/railspress/admin/entities/_form.html.erb +53 -0
  91. data/app/views/railspress/admin/entities/edit.html.erb +4 -0
  92. data/app/views/railspress/admin/entities/index.html.erb +74 -0
  93. data/app/views/railspress/admin/entities/new.html.erb +4 -0
  94. data/app/views/railspress/admin/entities/show.html.erb +117 -0
  95. data/app/views/railspress/admin/exports/show.html.erb +62 -0
  96. data/app/views/railspress/admin/imports/_instructions.html.erb +56 -0
  97. data/app/views/railspress/admin/imports/show.html.erb +137 -0
  98. data/app/views/railspress/admin/posts/_form.html.erb +102 -28
  99. data/app/views/railspress/admin/posts/_post_row.html.erb +40 -0
  100. data/app/views/railspress/admin/posts/index.html.erb +47 -36
  101. data/app/views/railspress/admin/posts/show.html.erb +55 -19
  102. data/app/views/railspress/admin/prototypes/image_section.html.erb +42 -0
  103. data/app/views/railspress/admin/shared/_dropzone.html.erb +84 -0
  104. data/app/views/railspress/admin/shared/_focal_point_editor.html.erb +102 -0
  105. data/app/views/railspress/admin/shared/_image_section.html.erb +159 -0
  106. data/app/views/railspress/admin/shared/_image_section_compact.html.erb +90 -0
  107. data/app/views/railspress/admin/shared/_image_section_editor.html.erb +171 -0
  108. data/app/views/railspress/admin/shared/_image_section_v2.html.erb +205 -0
  109. data/app/views/railspress/admin/shared/_sidebar.html.erb +73 -5
  110. data/app/views/railspress/admin/tags/index.html.erb +12 -16
  111. data/config/brakeman.ignore +18 -0
  112. data/config/importmap.rb +23 -0
  113. data/config/routes.rb +62 -1
  114. data/db/migrate/20241218000004_create_railspress_post_tags.rb +1 -1
  115. data/db/migrate/20241218000005_create_railspress_imports.rb +21 -0
  116. data/db/migrate/20241218000006_create_railspress_exports.rb +20 -0
  117. data/db/migrate/20241218000007_create_railspress_taggings.rb +20 -0
  118. data/db/migrate/20241218000008_drop_railspress_post_tags.rb +14 -0
  119. data/db/migrate/20241218000010_add_reading_time_to_railspress_posts.rb +5 -0
  120. data/db/migrate/20250105000002_create_railspress_focal_points.rb +20 -0
  121. data/db/migrate/20260206000001_create_railspress_content_groups.rb +18 -0
  122. data/db/migrate/20260206000002_create_railspress_content_elements.rb +21 -0
  123. data/db/migrate/20260206000003_create_railspress_content_element_versions.rb +20 -0
  124. data/db/migrate/20260207000001_add_unique_index_to_content_elements.rb +11 -0
  125. data/db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb +7 -0
  126. data/db/migrate/20260211154040_add_required_to_railspress_content_elements.rb +5 -0
  127. data/lib/generators/railspress/entity/entity_generator.rb +89 -0
  128. data/lib/generators/railspress/entity/templates/migration.rb.tt +13 -0
  129. data/lib/generators/railspress/entity/templates/model.rb.tt +21 -0
  130. data/lib/generators/railspress/install/install_generator.rb +51 -40
  131. data/lib/generators/railspress/install/templates/initializer.rb +29 -0
  132. data/lib/railspress/engine.rb +38 -0
  133. data/lib/railspress/entity.rb +239 -0
  134. data/lib/railspress/version.rb +1 -1
  135. data/lib/railspress.rb +198 -8
  136. data/lib/tasks/railspress_tasks.rake +49 -4
  137. metadata +215 -21
  138. data/MIT-LICENSE +0 -20
  139. data/app/assets/stylesheets/railspress/admin.css +0 -1207
  140. data/app/models/railspress/post_tag.rb +0 -8
@@ -1,34 +1,70 @@
1
1
  <div class="rp-page-header">
2
2
  <div>
3
3
  <h1 class="rp-page-title"><%= @post.title %></h1>
4
- <div class="rp-post-meta">
5
- <span class="rp-badge rp-badge--<%= @post.status %>"><%= @post.status.titleize %></span>
6
- <% if @post.category %>
7
- <span><%= @post.category.name %></span>
8
- <% end %>
9
- <% if authors_enabled? && @post.author %>
10
- <span>by <%= @post.author.public_send(Railspress.author_display_method) %></span>
11
- <% end %>
12
- <span><%= @post.created_at.strftime("%b %d, %Y") %></span>
13
- </div>
4
+ <% if authors_enabled? && @post.author %>
5
+ <p class="rp-byline">by <%= @post.author.public_send(Railspress.author_display_method) %></p>
6
+ <% end %>
14
7
  </div>
15
8
  <div class="rp-page-actions">
9
+ <% if @post.live? %>
10
+ <%= link_to "View", "#{Railspress.blog_path}/#{@post.slug}", class: "rp-btn rp-btn--secondary", target: "_blank" %>
11
+ <% end %>
16
12
  <%= link_to "Edit", edit_admin_post_path(@post), class: "rp-btn rp-btn--primary" %>
17
13
  <%= link_to "Back", admin_posts_path, class: "rp-btn rp-btn--secondary" %>
18
14
  </div>
19
15
  </div>
20
16
 
21
- <% if @post.tags.any? %>
22
- <div class="rp-tag-list rp-post-tags">
23
- <% @post.tags.each do |tag| %>
24
- <span class="rp-tag"><%= tag.name %></span>
25
- <% end %>
17
+ <div class="rp-post-meta-card">
18
+ <div class="rp-meta-item">
19
+ <span class="rp-meta-label">Status</span>
20
+ <span class="rp-badge rp-badge--<%= @post.display_status %>"><%= @post.display_status.titleize %></span>
26
21
  </div>
27
- <% end %>
22
+ <% if @post.published_at %>
23
+ <div class="rp-meta-item">
24
+ <span class="rp-meta-label">Published</span>
25
+ <span class="rp-meta-value"><%= @post.published_at.strftime("%b %d, %Y at %l:%M %p") %></span>
26
+ </div>
27
+ <% end %>
28
+ <div class="rp-meta-item">
29
+ <span class="rp-meta-label">Created</span>
30
+ <span class="rp-meta-value"><%= @post.created_at.strftime("%b %d, %Y") %></span>
31
+ </div>
32
+ <div class="rp-meta-item">
33
+ <span class="rp-meta-label">Updated</span>
34
+ <span class="rp-meta-value"><%= @post.updated_at.strftime("%b %d, %Y") %></span>
35
+ </div>
36
+ <div class="rp-meta-item">
37
+ <span class="rp-meta-label">Reading Time</span>
38
+ <span class="rp-meta-value"><%= @post.reading_time_display %> min</span>
39
+ </div>
40
+ <% if @post.category || @post.tags.any? %>
41
+ <div class="rp-meta-row">
42
+ <% if @post.category %>
43
+ <div class="rp-meta-item">
44
+ <span class="rp-meta-label">Category</span>
45
+ <span class="rp-meta-value"><%= @post.category.name %></span>
46
+ </div>
47
+ <% end %>
48
+ <% if @post.tags.any? %>
49
+ <div class="rp-meta-item rp-meta-item--tags">
50
+ <span class="rp-meta-label">Tags</span>
51
+ <div class="rp-tag-list">
52
+ <% @post.tags.each do |tag| %>
53
+ <span class="rp-tag"><%= tag.name %></span>
54
+ <% end %>
55
+ </div>
56
+ </div>
57
+ <% end %>
58
+ </div>
59
+ <% end %>
60
+ </div>
28
61
 
29
- <% if header_images_enabled? && @post.header_image.attached? %>
30
- <div class="rp-header-image">
31
- <%= image_tag main_app.url_for(@post.header_image.variant(resize_to_limit: [1200, 630])), class: "rp-header-image-full" %>
62
+ <% if post_images_enabled? && @post.header_image.attached? %>
63
+ <div class="rp-image-section-card">
64
+ <%= render "railspress/admin/shared/image_section_compact",
65
+ record: @post,
66
+ attachment_name: :header_image,
67
+ label: "Main Image" %>
32
68
  </div>
33
69
  <% end %>
34
70
 
@@ -0,0 +1,42 @@
1
+ <h1 class="rp-page-title rp-page-title--standalone">Image Section Prototype</h1>
2
+
3
+ <p class="rp-hint" style="margin-bottom: var(--rp-space-xl); max-width: 700px;">
4
+ <strong>Purpose:</strong> Iterate on the main image section UI/UX.
5
+ This uses real CSS and Stimulus controllers with mock data.
6
+ </p>
7
+
8
+ <div class="rp-card rp-card--padded" style="max-width: 800px;">
9
+ <h2 style="font-size: 1rem; margin-bottom: var(--rp-space-lg); color: var(--rp-text-muted);">
10
+ State 1: Image Exists (Compact View)
11
+ </h2>
12
+
13
+ <%= render "railspress/admin/shared/image_section_v2",
14
+ has_image: true,
15
+ image_url: @mock_image_url,
16
+ filename: @mock_filename,
17
+ filesize: @mock_filesize,
18
+ focal_point: @mock_focal_point,
19
+ contexts: @contexts,
20
+ label: "Main Image" %>
21
+ </div>
22
+
23
+ <div class="rp-card rp-card--padded" style="max-width: 800px; margin-top: var(--rp-space-xl);">
24
+ <h2 style="font-size: 1rem; margin-bottom: var(--rp-space-lg); color: var(--rp-text-muted);">
25
+ State 2: No Image (Upload Prompt)
26
+ </h2>
27
+
28
+ <%= render "railspress/admin/shared/image_section_v2",
29
+ has_image: false,
30
+ label: "Main Image" %>
31
+ </div>
32
+
33
+ <div style="margin-top: var(--rp-space-2xl); padding: var(--rp-space-lg); background: var(--rp-bg); border: 1px solid var(--rp-border); border-radius: var(--rp-radius);">
34
+ <h3 style="font-size: 0.875rem; margin-bottom: var(--rp-space-md);">Implementation Notes</h3>
35
+ <ul style="font-size: 0.875rem; color: var(--rp-text-muted); padding-left: var(--rp-space-lg); margin: 0;">
36
+ <li>Stimulus controller handles expand/collapse (no server round-trip)</li>
37
+ <li>Clicking compact view or Edit button expands the editor</li>
38
+ <li>Done button collapses back to compact view</li>
39
+ <li>Focal point picker reuses existing <code>railspress--focal-point</code> controller</li>
40
+ <li>CSS animations for smooth transitions</li>
41
+ </ul>
42
+ </div>
@@ -0,0 +1,84 @@
1
+ <%#
2
+ Dropzone Partial
3
+ ================
4
+ A drag-and-drop file upload component with progress indicator.
5
+
6
+ Required locals:
7
+ form - The Rails form builder
8
+ field_name - Symbol for the attachment field (e.g., :header_image)
9
+
10
+ Optional locals:
11
+ accept - MIME types to accept (default: "image/*")
12
+ max_size - Max file size in bytes (default: 10MB)
13
+ prompt - Text prompt to display (default: "Drag image here or click to browse")
14
+ hint - Hint text below prompt (default: "PNG, JPG, WebP up to 10MB")
15
+
16
+ Example:
17
+ render "railspress/admin/shared/dropzone",
18
+ form: form, field_name: :header_image, prompt: "Drop featured image here"
19
+ %>
20
+
21
+ <% accept ||= "image/*" %>
22
+ <% max_size ||= 10 * 1024 * 1024 %>
23
+ <% prompt ||= "Drag image here or click to browse" %>
24
+ <% hint ||= "PNG, JPG, WebP up to 10MB" %>
25
+
26
+ <div data-controller="railspress--dropzone"
27
+ data-railspress--dropzone-url-value="<%= main_app.rails_direct_uploads_path %>"
28
+ data-railspress--dropzone-accept-value="<%= accept %>"
29
+ data-railspress--dropzone-max-size-value="<%= max_size %>"
30
+ data-action="drop->railspress--dropzone#drop
31
+ dragover->railspress--dropzone#dragover
32
+ dragleave->railspress--dropzone#dragleave"
33
+ class="rp-dropzone">
34
+
35
+ <%# Hidden file input %>
36
+ <%= form.file_field field_name,
37
+ accept: accept,
38
+ class: "rp-sr-only",
39
+ data: {
40
+ railspress__dropzone_target: "input",
41
+ action: "change->railspress--dropzone#change"
42
+ } %>
43
+
44
+ <%# Hidden field for signed_id (populated after upload) %>
45
+ <input type="hidden"
46
+ name="<%= form.object_name %>[<%= field_name %>]"
47
+ data-railspress--dropzone-target="signedId">
48
+
49
+ <%# Drop area %>
50
+ <div data-railspress--dropzone-target="dropArea" class="rp-dropzone__area">
51
+ <svg class="rp-dropzone__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
52
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
53
+ <circle cx="8.5" cy="8.5" r="1.5"/>
54
+ <polyline points="21 15 16 10 5 21"/>
55
+ </svg>
56
+ <p class="rp-dropzone__prompt" data-railspress--dropzone-target="prompt">
57
+ <strong><%= prompt.split.first %></strong> <%= prompt.split[1..].join(" ") %>
58
+ </p>
59
+ <p class="rp-dropzone__hint"><%= hint %></p>
60
+ </div>
61
+
62
+ <%# Preview (shown after file selected) %>
63
+ <div data-railspress--dropzone-target="preview" class="rp-dropzone__preview" hidden>
64
+ <img data-railspress--dropzone-target="previewImage" class="rp-dropzone__preview-image" alt="">
65
+ <button type="button"
66
+ class="rp-dropzone__remove rp-btn rp-btn--ghost rp-btn--sm"
67
+ data-action="railspress--dropzone#remove">
68
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
69
+ <line x1="18" y1="6" x2="6" y2="18"></line>
70
+ <line x1="6" y1="6" x2="18" y2="18"></line>
71
+ </svg>
72
+ Remove
73
+ </button>
74
+ </div>
75
+
76
+ <%# Progress indicator %>
77
+ <div data-railspress--dropzone-target="progress" class="rp-dropzone__progress" hidden>
78
+ <div class="rp-dropzone__progress-bar" data-railspress--dropzone-target="progressBar"></div>
79
+ <span class="rp-dropzone__progress-text" data-railspress--dropzone-target="progressText">0%</span>
80
+ </div>
81
+
82
+ <%# Error message %>
83
+ <div data-railspress--dropzone-target="error" class="rp-dropzone__error" hidden></div>
84
+ </div>
@@ -0,0 +1,102 @@
1
+ <%#
2
+ Focal Point Editor Partial
3
+ ==========================
4
+ An interactive focal point picker with live previews.
5
+
6
+ Required locals:
7
+ form - The Rails form builder
8
+ record - The record with HasFocalPoint concern
9
+ attachment_name - Symbol for the attachment (e.g., :header_image)
10
+
11
+ Optional locals:
12
+ contexts - Array of context configs for previews (default: Railspress.image_contexts)
13
+
14
+ Example:
15
+ render "railspress/admin/shared/focal_point_editor",
16
+ form: form, record: @post, attachment_name: :header_image
17
+ %>
18
+
19
+ <% contexts ||= Railspress.image_contexts %>
20
+ <% focal_point = record.focal_point(attachment_name) %>
21
+ <% attachment = record.send(attachment_name) %>
22
+
23
+ <div data-controller="railspress--focal-point"
24
+ data-railspress--focal-point-x-value="<%= focal_point[:x] %>"
25
+ data-railspress--focal-point-y-value="<%= focal_point[:y] %>"
26
+ class="rp-focal-editor">
27
+
28
+ <div class="rp-focal-editor__layout">
29
+ <%# Source image with crosshair %>
30
+ <div class="rp-focal-editor__source">
31
+ <div class="rp-focal-editor__image-wrap"
32
+ data-action="click->railspress--focal-point#pick
33
+ touchstart->railspress--focal-point#touch"
34
+ tabindex="0"
35
+ data-action="keydown->railspress--focal-point#keydown">
36
+ <% if attachment.attached? %>
37
+ <%= image_tag main_app.url_for(attachment.variant(resize_to_limit: [1200, 800])),
38
+ data: { railspress__focal_point_target: "image" },
39
+ class: "rp-focal-editor__image",
40
+ alt: "Source image for focal point selection" %>
41
+ <% end %>
42
+ <div data-railspress--focal-point-target="crosshair"
43
+ class="rp-focal-editor__crosshair"
44
+ style="left: <%= focal_point[:x] * 100 %>%; top: <%= focal_point[:y] * 100 %>%;"></div>
45
+ </div>
46
+
47
+ <div class="rp-focal-editor__hint">
48
+ <span>Click to set focal point</span>
49
+ <span class="rp-focal-editor__coords" data-railspress--focal-point-target="coordsDisplay">
50
+ <%= "#{(focal_point[:x] * 100).round}%, #{(focal_point[:y] * 100).round}%" %>
51
+ </span>
52
+ </div>
53
+
54
+ <div class="rp-focal-editor__actions">
55
+ <button type="button"
56
+ class="rp-btn rp-btn--outline rp-btn--sm"
57
+ data-action="railspress--focal-point#reset">
58
+ Reset to Center
59
+ </button>
60
+ </div>
61
+ </div>
62
+
63
+ <%# Live previews %>
64
+ <% if contexts.any? %>
65
+ <div class="rp-focal-editor__previews">
66
+ <span class="rp-focal-editor__previews-title">Live Preview</span>
67
+
68
+ <% contexts.each do |name, config| %>
69
+ <% aspect = config[:aspect] %>
70
+ <% aspect_ratio = aspect.is_a?(Array) ? "#{aspect[0]} / #{aspect[1]}" : aspect %>
71
+ <% ratio_label = aspect.is_a?(Array) ? "#{aspect[0]}:#{aspect[1]}" : aspect.to_s %>
72
+
73
+ <div class="rp-focal-editor__preview">
74
+ <div class="rp-focal-editor__preview-image" style="aspect-ratio: <%= aspect_ratio %>;">
75
+ <% if attachment.attached? %>
76
+ <%= image_tag main_app.url_for(attachment.variant(resize_to_limit: [400, 300])),
77
+ data: { railspress__focal_point_target: "preview" },
78
+ style: record.image_css_for(name, attachment_name),
79
+ alt: "#{name.to_s.titleize} preview" %>
80
+ <% end %>
81
+ </div>
82
+ <div class="rp-focal-editor__preview-label">
83
+ <span class="rp-focal-editor__preview-name"><%= name.to_s.titleize %></span>
84
+ <span class="rp-focal-editor__preview-ratio"><%= ratio_label %></span>
85
+ </div>
86
+ </div>
87
+ <% end %>
88
+ </div>
89
+ <% end %>
90
+ </div>
91
+
92
+ <%# Hidden inputs for form submission via nested attributes %>
93
+ <% association_name = :"#{attachment_name}_focal_point" %>
94
+ <%= form.fields_for association_name, record.send(association_name) do |fp| %>
95
+ <%= fp.hidden_field :focal_x,
96
+ value: focal_point[:x],
97
+ data: { railspress__focal_point_target: "xInput" } %>
98
+ <%= fp.hidden_field :focal_y,
99
+ value: focal_point[:y],
100
+ data: { railspress__focal_point_target: "yInput" } %>
101
+ <% end %>
102
+ </div>
@@ -0,0 +1,159 @@
1
+ <%#
2
+ Image Section Partial
3
+ =====================
4
+ Complete image upload and focal point editing section.
5
+ Shows compact view with Edit button; expands to full editor.
6
+
7
+ Required locals:
8
+ form - The Rails form builder
9
+ record - The record with HasFocalPoint concern
10
+ attachment_name - Symbol for the attachment (e.g., :header_image)
11
+
12
+ Optional locals:
13
+ label - Section label (default: "Main Image")
14
+ contexts - Array of context configs for previews
15
+ show_advanced - Show per-context overrides (default: false)
16
+
17
+ Example:
18
+ render "railspress/admin/shared/image_section",
19
+ form: form, record: @post, attachment_name: :header_image
20
+ %>
21
+
22
+ <% label ||= "Main Image" %>
23
+ <% contexts ||= Railspress.image_contexts %>
24
+ <% show_advanced ||= false %>
25
+ <% attachment = record.send(attachment_name) %>
26
+ <% has_image = attachment.attached? && attachment.blob&.persisted? %>
27
+
28
+ <div class="rp-image-section">
29
+ <div class="rp-image-section__label">
30
+ <label class="rp-label"><%= label %></label>
31
+ </div>
32
+
33
+ <% if has_image %>
34
+ <%# Compact view (default, collapsed state) %>
35
+ <details class="rp-image-section__details">
36
+ <summary class="rp-image-section__compact">
37
+ <div class="rp-image-section__thumb">
38
+ <%= image_tag main_app.url_for(attachment.variant(resize_to_limit: [120, 80])),
39
+ style: record.focal_point_css(attachment_name),
40
+ alt: "" %>
41
+ </div>
42
+ <div class="rp-image-section__info">
43
+ <span class="rp-image-section__filename"><%= attachment.filename %></span>
44
+ <span class="rp-image-section__meta">
45
+ <%= number_to_human_size(attachment.byte_size) %>
46
+ </span>
47
+ <% if record.has_focal_point?(attachment_name) %>
48
+ <span class="rp-image-section__focal-indicator">
49
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
50
+ <circle cx="12" cy="12" r="3"/>
51
+ <path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/>
52
+ </svg>
53
+ Focal point set
54
+ </span>
55
+ <% end %>
56
+ </div>
57
+ <div class="rp-image-section__toggle">
58
+ <span class="rp-btn rp-btn--outline rp-btn--sm">Edit</span>
59
+ </div>
60
+ </summary>
61
+
62
+ <%# Expanded editor %>
63
+ <div class="rp-image-section__editor">
64
+ <div class="rp-image-section__editor-header">
65
+ <span class="rp-image-section__editor-title"><%= label %> Settings</span>
66
+ <div class="rp-image-section__editor-actions">
67
+ <label class="rp-btn rp-btn--outline rp-btn--sm">
68
+ Change
69
+ <%= form.file_field attachment_name,
70
+ accept: "image/*",
71
+ class: "rp-sr-only",
72
+ direct_upload: true %>
73
+ </label>
74
+ <label class="rp-checkbox-inline rp-btn rp-btn--outline rp-btn--sm">
75
+ <%= form.check_box :"remove_#{attachment_name}", {}, "1", "0" %>
76
+ Remove
77
+ </label>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="rp-image-section__editor-body">
82
+ <%= render "railspress/admin/shared/focal_point_editor",
83
+ form: form,
84
+ record: record,
85
+ attachment_name: attachment_name,
86
+ contexts: contexts %>
87
+
88
+ <% if show_advanced && contexts.any? %>
89
+ <details class="rp-advanced-section">
90
+ <summary class="rp-advanced-toggle">
91
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
92
+ <polyline points="6 9 12 15 18 9"/>
93
+ </svg>
94
+ Advanced: Per-context image overrides
95
+ </summary>
96
+
97
+ <div class="rp-advanced-content">
98
+ <p class="rp-hint">Override specific contexts with custom crops or separate images.</p>
99
+
100
+ <div class="rp-context-overrides">
101
+ <% contexts.each do |name, config| %>
102
+ <% override = record.image_override(name, attachment_name) %>
103
+ <% has_override = record.has_image_override?(name, attachment_name) %>
104
+
105
+ <div class="rp-context-override">
106
+ <div class="rp-context-override__header">
107
+ <span class="rp-context-override__name"><%= name.to_s.titleize %></span>
108
+ <span class="rp-context-override__badge <%= 'rp-context-override__badge--custom' if has_override %>">
109
+ <%= has_override ? override[:type].titleize : "Using focal" %>
110
+ </span>
111
+ </div>
112
+
113
+ <div class="rp-context-override__preview" style="aspect-ratio: <%= config[:aspect].is_a?(Array) ? "#{config[:aspect][0]} / #{config[:aspect][1]}" : config[:aspect] %>;">
114
+ <% context_image = record.image_for(name, attachment_name) %>
115
+ <% if context_image.respond_to?(:variant) %>
116
+ <%= image_tag main_app.url_for(context_image.variant(resize_to_limit: [400, 300])),
117
+ style: record.image_css_for(name, attachment_name),
118
+ alt: "" %>
119
+ <% elsif context_image.respond_to?(:url) %>
120
+ <%= image_tag main_app.url_for(context_image),
121
+ style: record.image_css_for(name, attachment_name),
122
+ alt: "" %>
123
+ <% end %>
124
+ </div>
125
+
126
+ <div class="rp-context-override__actions">
127
+ <% if has_override %>
128
+ <button type="button" class="rp-btn rp-btn--outline rp-btn--sm" style="flex:1;">
129
+ Change
130
+ </button>
131
+ <button type="button" class="rp-btn rp-btn--outline rp-btn--sm" style="flex:1;">
132
+ Use Focal
133
+ </button>
134
+ <% else %>
135
+ <button type="button" class="rp-btn rp-btn--outline rp-btn--sm" style="flex:1;">
136
+ Custom Crop
137
+ </button>
138
+ <button type="button" class="rp-btn rp-btn--outline rp-btn--sm" style="flex:1;">
139
+ Upload
140
+ </button>
141
+ <% end %>
142
+ </div>
143
+ </div>
144
+ <% end %>
145
+ </div>
146
+ </div>
147
+ </details>
148
+ <% end %>
149
+ </div>
150
+ </div>
151
+ </details>
152
+ <% else %>
153
+ <%# No image - show dropzone %>
154
+ <%= render "railspress/admin/shared/dropzone",
155
+ form: form,
156
+ field_name: attachment_name,
157
+ prompt: "Click to upload #{label.downcase}" %>
158
+ <% end %>
159
+ </div>
@@ -0,0 +1,90 @@
1
+ <%#
2
+ Image Section Compact View
3
+ ==========================
4
+ Shows thumbnail, filename, and Edit button.
5
+ Clicking Edit loads the editor via Turbo Frame.
6
+
7
+ Required locals:
8
+ record - The record with HasFocalPoint concern
9
+ attachment_name - Symbol for the attachment (e.g., :header_image)
10
+
11
+ Optional locals:
12
+ label - Section label (default: "Main Image")
13
+ flash_message - Success message to display
14
+ contexts - Hash of context configs (for editor URL)
15
+ %>
16
+
17
+ <% label ||= "Main Image" %>
18
+ <% flash_message ||= nil %>
19
+ <% contexts ||= Railspress.image_contexts %>
20
+ <% attachment = record.send(attachment_name) %>
21
+ <% has_image = attachment.attached? && attachment.blob&.persisted? %>
22
+ <% frame_id = dom_id(record, "image_section_#{attachment_name}") %>
23
+
24
+ <%# Build editor URL based on record type (can be overridden via editor_url local) %>
25
+ <%
26
+ editor_url ||= nil
27
+ unless editor_url
28
+ if record.is_a?(Railspress::Post)
29
+ editor_url = image_editor_admin_post_path(record, attachment: attachment_name)
30
+ elsif record.is_a?(Railspress::ContentElement)
31
+ editor_url = image_editor_admin_content_element_path(record)
32
+ else
33
+ entity_type = record.class.railspress_config.route_key
34
+ editor_url = admin_entity_image_editor_path(entity_type: entity_type, id: record.id, attachment: attachment_name)
35
+ end
36
+ end
37
+ %>
38
+
39
+ <%= content_tag "turbo-frame", id: frame_id do %>
40
+ <div class="rp-image-section">
41
+ <div class="rp-image-section__label">
42
+ <label class="rp-label"><%= label %></label>
43
+ </div>
44
+
45
+ <% if flash_message %>
46
+ <div class="rp-flash rp-flash--success" style="margin-bottom: var(--rp-space-sm);">
47
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
48
+ <path d="M20 6L9 17l-5-5"/>
49
+ </svg>
50
+ <%= flash_message %>
51
+ </div>
52
+ <% end %>
53
+
54
+ <% if has_image %>
55
+ <div class="rp-image-section__details">
56
+ <div class="rp-image-section__compact">
57
+ <div class="rp-image-section__preview">
58
+ <%= image_tag main_app.url_for(attachment.variant(resize_to_limit: [800, 450])),
59
+ style: record.focal_point_css(attachment_name),
60
+ alt: "" %>
61
+ <div class="rp-image-section__overlay">
62
+ <%= link_to editor_url, class: "rp-btn", data: { turbo_frame: frame_id } do %>
63
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right: 6px;">
64
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
65
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
66
+ </svg>
67
+ Edit
68
+ <% end %>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <% if record.has_focal_point?(attachment_name) %>
73
+ <div class="rp-image-section__footer">
74
+ <span class="rp-image-section__focal-indicator">
75
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
76
+ <circle cx="12" cy="12" r="3"/>
77
+ <path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/>
78
+ </svg>
79
+ Focal point set
80
+ </span>
81
+ </div>
82
+ <% end %>
83
+ </div>
84
+ <% else %>
85
+ <div class="rp-image-section__empty">
86
+ <p class="rp-hint">No image attached.</p>
87
+ </div>
88
+ <% end %>
89
+ </div>
90
+ <% end %>