helios-press 0.1.0 → 0.3

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +83 -18
  3. data/app/controllers/helios/press/admin/base_controller.rb +1 -2
  4. data/app/controllers/helios/press/admin/blocks_controller.rb +1 -1
  5. data/app/controllers/helios/press/admin/posts_controller.rb +5 -5
  6. data/app/controllers/helios/press/api/posts_controller.rb +19 -13
  7. data/app/controllers/helios/press/posts_controller.rb +5 -1
  8. data/app/models/helios/press/block_image.rb +23 -1
  9. data/app/services/helios/press/image_ingestor.rb +167 -0
  10. data/app/views/helios/press/admin/blocks/_image_block.html.erb +2 -2
  11. data/app/views/helios/press/admin/blocks/_image_item.html.erb +14 -2
  12. data/app/views/helios/press/admin/blocks/_text_block.html.erb +2 -2
  13. data/app/views/helios/press/admin/blocks/_video_block.html.erb +11 -3
  14. data/app/views/helios/press/admin/posts/_form.html.erb +2 -2
  15. data/app/views/helios/press/admin/posts/edit.html.erb +1 -1
  16. data/app/views/helios/press/admin/posts/index.html.erb +32 -21
  17. data/app/views/helios/press/posts/blocks/_image_container.html.erb +20 -3
  18. data/app/views/helios/press/posts/index.html.erb +21 -0
  19. data/config/routes/admin.rb +17 -0
  20. data/config/routes/api.rb +5 -0
  21. data/config/routes/public.rb +6 -0
  22. data/config/routes.rb +0 -19
  23. data/db/migrate/20250526000001_add_image_ingestion_to_helios_press.rb +6 -0
  24. data/lib/helios/press/configuration.rb +2 -0
  25. data/lib/helios/press/engine.rb +42 -0
  26. data/lib/helios/press/version.rb +1 -1
  27. data/lib/helios/press.rb +1 -0
  28. data/lib/helios-press.rb +1 -0
  29. metadata +27 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3b7ae24fbb56776cc12ecba0e5c5f8c4fe86add758aceeddf4a2556233f65ad
4
- data.tar.gz: 27ec84d4133ac49444df9b70f145214e1c57757f8098e19af4d3261271a08eef
3
+ metadata.gz: 1d3b3438e1adcc7a8776aab2c1504eca5768bdb82529e1a8013b5c52786b1322
4
+ data.tar.gz: 0caad891b10dc08f98f43ded048b20abfeec168829642cdcc26505f0360dc862
5
5
  SHA512:
6
- metadata.gz: d0224ef4872b8727d78d1dc9e1aba083b254bf6380a0e1cf5ea5ffbd4b30a8903b1847253963620f7a2f84a4da93a621d31fba0a81b0297c7e70b9f68236fddb
7
- data.tar.gz: 26d02bcc128f38ead744f8dacc50005caa749bb2f8da86249af09053aed2461766f32594e439242a4bf91765816dfcb6c5e3492d91281bf1d5a2a8a590c75b38
6
+ metadata.gz: 6f42ac281d93c06b6bbdd98b869f9c6a63f68309f4036a9cefd85f32e87770ae18f62a2fbce701b8940fcc6ba3d3d8569aee9175f72585ebb5ff80cb8c05001a
7
+ data.tar.gz: c999f3186d81ee01f529de8170b3b32677c93939ca3a6af02979a670554b19dbce6e2a10d0f87d8222a70481147d4b3a56214bee24202acdaa8beb94b48949c6
data/README.md CHANGED
@@ -32,6 +32,9 @@ Helios::Press.configure do |config|
32
32
  # Parent controller for admin views (must provide authentication)
33
33
  config.admin_parent_controller = "Admin::BaseController"
34
34
 
35
+ # Parent controller for public views (blog index/show)
36
+ config.public_parent_controller = "ApplicationController"
37
+
35
38
  # Optional slug prefix for posts
36
39
  config.post_slug_prefix = nil # e.g., "blog/" for /blog/my-post
37
40
 
@@ -49,16 +52,40 @@ end
49
52
 
50
53
  ## Routes
51
54
 
52
- Mount the engine:
55
+ Helios::Press provides three independent engines that you can mount wherever you want:
56
+
57
+ ```ruby
58
+ # Admin block editor — mount behind your auth
59
+ mount Helios::Press::Admin::Engine, at: "/admin/press"
60
+
61
+ # Public blog index/show — mount at your preferred public path
62
+ mount Helios::Press::Public::Engine, at: "/blog"
63
+
64
+ # API for external post ingestion
65
+ mount Helios::Press::Api::Engine, at: "/api/press"
66
+ ```
67
+
68
+ Mount only the engines you need. For example, if you only want the admin editor and will build your own public views:
53
69
 
54
70
  ```ruby
55
- mount Helios::Press::Engine, at: "/helios_press"
71
+ mount Helios::Press::Admin::Engine, at: "/admin/press"
56
72
  ```
57
73
 
58
- This provides:
59
- - **Admin UI**: `/helios_press/admin/posts` - Full block editor
60
- - **API**: `POST /helios_press/api/posts` - Ingest posts from external sources
61
- - **Public**: Define your own catch-all route pointing to `Helios::Press::PostsController#show`
74
+ ### Routes provided
75
+
76
+ **Admin Engine:**
77
+ - `GET /` Posts list
78
+ - `GET /posts/new` — New post form
79
+ - `GET /posts/:id/edit` — Block editor
80
+ - `POST/PATCH/DELETE /posts/:id` — CRUD
81
+ - Block and image sub-resources
82
+
83
+ **Public Engine:**
84
+ - `GET /` — Published posts index
85
+ - `GET /:slug` — Single post view
86
+
87
+ **API Engine:**
88
+ - `POST /posts` — Upsert a post by `external_id`
62
89
 
63
90
  ## JavaScript Setup
64
91
 
@@ -69,15 +96,11 @@ import {
69
96
  HeliosPressBlocksController,
70
97
  HeliosPressTextBlockController,
71
98
  HeliosPressImageBlockController
72
- } from "helios-press"
99
+ } from "helios/press"
73
100
 
74
101
  application.register("helios-press-blocks", HeliosPressBlocksController)
75
102
  application.register("helios-press-text-block", HeliosPressTextBlockController)
76
103
  application.register("helios-press-image-block", HeliosPressImageBlockController)
77
-
78
- // If using helios-videos:
79
- import { HeliosVideoBlockController } from "helios-videos"
80
- application.register("helios-video-block", HeliosVideoBlockController)
81
104
  ```
82
105
 
83
106
  **npm dependencies** (add to your host app's package.json):
@@ -86,13 +109,35 @@ application.register("helios-video-block", HeliosVideoBlockController)
86
109
  - `@rails/activestorage`
87
110
  - `trix` and `@rails/actiontext`
88
111
 
112
+ ### Vite
113
+
114
+ If your host app uses Vite, add an alias so Vite can resolve the gem's JavaScript:
115
+
116
+ ```typescript
117
+ // vite.config.mts
118
+ resolve: {
119
+ alias: {
120
+ 'helios/press': resolve(__dirname, '/path/to/helios-press/app/javascript/helios/press'),
121
+ },
122
+ },
123
+ ```
124
+
125
+ When using a local path gem, point to the local checkout. When using the published gem, point to the installed gem path (e.g., via `bundle show helios-press`).
126
+
89
127
  ## CSS
90
128
 
91
- Include the block editor styles in your asset pipeline:
129
+ Include the block editor styles in your stylesheet:
92
130
 
93
- ```css
94
- /* In your application CSS */
95
- @import "helios/press/blocks";
131
+ ```scss
132
+ @import 'helios_press_blocks';
133
+ ```
134
+
135
+ You can either symlink or copy the file from the gem:
136
+
137
+ ```bash
138
+ # Symlink (local development)
139
+ ln -s /path/to/helios-press/app/assets/stylesheets/helios/press/blocks.css \
140
+ app/assets/stylesheets/_helios_press_blocks.scss
96
141
  ```
97
142
 
98
143
  ## Block Types
@@ -103,7 +148,7 @@ Include the block editor styles in your asset pipeline:
103
148
 
104
149
  ## API Ingestion
105
150
 
106
- POST to `/helios_press/api/posts` with `X-API-Key` header:
151
+ POST to your mounted API path with `X-API-Key` header:
107
152
 
108
153
  ```json
109
154
  {
@@ -112,11 +157,31 @@ POST to `/helios_press/api/posts` with `X-API-Key` header:
112
157
  "slug": "my-post-title",
113
158
  "description": "Meta description",
114
159
  "keywords": "keyword1, keyword2",
115
- "body_html": "<p>Post content...</p>",
116
- "published": true
160
+ "body_html": "<p>Post content with <img src=\"https://example.com/photo.jpg\"> tags...</p>",
161
+ "published": true,
162
+ "images": [
163
+ {
164
+ "reference_key": "hero1",
165
+ "url": "https://example.com/hero.jpg",
166
+ "alt": "Hero image",
167
+ "caption": "Photo credit: Example"
168
+ }
169
+ ]
117
170
  }
118
171
  ```
119
172
 
173
+ ### Image Ingestion
174
+
175
+ When a post is ingested via the API, all images in `body_html` are automatically downloaded, stored via ActiveStorage (e.g., to S3), and the `<img>` src attributes are rewritten to local proxy URLs. This ensures no external image hotlinking in published posts.
176
+
177
+ Two modes are supported:
178
+
179
+ 1. **Explicit references**: Use `src="helios://image/<reference_key>"` in your `body_html` and provide the actual download URL in the `images` array. The ingestor matches them by `reference_key`.
180
+
181
+ 2. **Auto-import**: Any `src="https://..."` URL in `body_html` is automatically downloaded. Images are deduplicated by a SHA256 hash of the URL path (ignoring query strings/signatures), so re-ingesting the same image with different signed URLs won't create duplicates.
182
+
183
+ Images are stored as `BlockImage` records with ActiveStorage attachments and served via `rails_storage_proxy_path`. Failed downloads are logged and skipped (soft failure — the original src is preserved).
184
+
120
185
  ## License
121
186
 
122
187
  Proprietary. All rights reserved.
@@ -1,8 +1,7 @@
1
1
  module Helios
2
2
  module Press
3
3
  module Admin
4
- class BaseController < ::ApplicationController
5
- layout "helios/press/admin"
4
+ class BaseController < Helios::Press.configuration.admin_parent_controller.constantize
6
5
  end
7
6
  end
8
7
  end
@@ -30,7 +30,7 @@ module Helios
30
30
 
31
31
  # Handle video upload for video_container blocks (when helios-videos is present)
32
32
  if @block.video_container? && params[:video_signed_id].present? && Helios::Press.videos_enabled?
33
- video = Helios::Videos::Video.new(name: params[:video_name], block: @block)
33
+ video = Helios::Videos.video_class.new(name: params[:video_name], block: @block)
34
34
  video.video_file.attach(params[:video_signed_id])
35
35
  video.save!
36
36
  end
@@ -23,7 +23,7 @@ module Helios
23
23
  @post = Post.new(post_params)
24
24
 
25
25
  if @post.save
26
- redirect_to helios_press.edit_admin_post_path(@post), notice: "Post was successfully created."
26
+ redirect_to helios_press_admin.edit_post_path(@post), notice: "Post was successfully created."
27
27
  else
28
28
  render :new, status: :unprocessable_entity
29
29
  end
@@ -42,7 +42,7 @@ module Helios
42
42
  end
43
43
 
44
44
  if @post.update(update_params)
45
- redirect_to helios_press.edit_admin_post_path(@post), notice: success_message
45
+ redirect_to helios_press_admin.edit_post_path(@post), notice: success_message
46
46
  else
47
47
  render :edit, status: :unprocessable_entity
48
48
  end
@@ -50,7 +50,7 @@ module Helios
50
50
 
51
51
  def destroy
52
52
  @post.destroy
53
- redirect_to helios_press.admin_posts_path, notice: "Post was successfully deleted."
53
+ redirect_to helios_press_admin.posts_path, notice: "Post was successfully deleted."
54
54
  end
55
55
 
56
56
  private
@@ -63,8 +63,8 @@ module Helios
63
63
  params.require(:post).permit(:name, :published, :slug, :keywords, :description)
64
64
  end
65
65
 
66
- def helios_press
67
- Helios::Press::Engine.routes.url_helpers
66
+ def helios_press_admin
67
+ Helios::Press::Admin::Engine.routes.url_helpers
68
68
  end
69
69
  end
70
70
  end
@@ -2,7 +2,7 @@ module Helios
2
2
  module Press
3
3
  module Api
4
4
  class PostsController < Helios::Press::Api::BaseController
5
- # POST /api/v1/posts
5
+ # POST /api/press/posts
6
6
  # Upsert a post by external_id. Payload shape:
7
7
  # {
8
8
  # "external_id": "helios-blog-post-draft-4821",
@@ -10,9 +10,18 @@ module Helios
10
10
  # "slug": "...",
11
11
  # "keywords": "...",
12
12
  # "description": "...",
13
- # "body_html": "...",
14
- # "published": true
13
+ # "body_html": "<p>Content with <img src=\"https://...\"> tags...</p>",
14
+ # "published": true,
15
+ # "images": [
16
+ # { "reference_key": "hero1", "url": "https://...", "alt": "...", "caption": "..." }
17
+ # ]
15
18
  # }
19
+ #
20
+ # Images in body_html are automatically downloaded, stored via ActiveStorage,
21
+ # and their src attributes rewritten to local proxy URLs. Two modes:
22
+ # 1. Explicit: use src="helios://image/<reference_key>" in body_html and
23
+ # provide the download URL in the images array.
24
+ # 2. Auto: any src="https://..." in body_html is downloaded automatically.
16
25
  def create
17
26
  external_id = params[:external_id].to_s
18
27
  return render json: { error: "external_id is required" }, status: :bad_request if external_id.blank?
@@ -27,19 +36,16 @@ module Helios
27
36
  published: ActiveModel::Type::Boolean.new.cast(params[:published])
28
37
  )
29
38
 
30
- # If body_html is provided, create/update a single text block with the content
39
+ post.save!
40
+
31
41
  if params[:body_html].present?
32
- if post.persisted?
33
- # Update or create the first text block
34
- text_block = post.blocks.find_by(block_type: "text") || post.blocks.build(block_type: "text", position: 0)
35
- text_block.content = params[:body_html]
36
- else
37
- post.blocks.build(block_type: "text", position: 0, content: params[:body_html])
38
- end
42
+ ingestor = ImageIngestor.new(post)
43
+ ingestor.call(
44
+ body_html: params[:body_html],
45
+ images_payload: params[:images] || []
46
+ )
39
47
  end
40
48
 
41
- post.save!
42
-
43
49
  render json: {
44
50
  ok: true,
45
51
  id: post.id,
@@ -1,6 +1,10 @@
1
1
  module Helios
2
2
  module Press
3
- class PostsController < ::ApplicationController
3
+ class PostsController < Helios::Press.configuration.public_parent_controller.constantize
4
+ def index
5
+ @posts = Post.published.reverse_sorted
6
+ end
7
+
4
8
  def show
5
9
  @post = Post.published.find_by!(slug: params[:slug])
6
10
  end
@@ -8,9 +8,31 @@ module Helios
8
8
  has_one_attached :file
9
9
 
10
10
  validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
11
- validates :file, presence: true
11
+ validates :file, presence: true, unless: :reference_key?
12
+
13
+ def reference_key?
14
+ reference_key.present?
15
+ end
12
16
 
13
17
  acts_as_list scope: :block
18
+
19
+ DOCUMENT_CONTENT_TYPES = %w[
20
+ application/pdf
21
+ application/msword
22
+ application/vnd.openxmlformats-officedocument.wordprocessingml.document
23
+ ].freeze
24
+
25
+ def svg?
26
+ file.attached? && file.content_type == "image/svg+xml"
27
+ end
28
+
29
+ def document?
30
+ file.attached? && DOCUMENT_CONTENT_TYPES.include?(file.content_type)
31
+ end
32
+
33
+ def variable?
34
+ file.attached? && file.variable?
35
+ end
14
36
  end
15
37
  end
16
38
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "nokogiri"
5
+ require "uri"
6
+ require "digest"
7
+
8
+ module Helios
9
+ module Press
10
+ # Downloads remote images referenced in ingested post HTML, stores them via
11
+ # ActiveStorage, and rewrites the body HTML so <img> src attributes point at
12
+ # locally-hosted ActiveStorage proxy URLs.
13
+ #
14
+ # Supports two modes:
15
+ # 1. Explicit references: src="helios://image/<reference_key>" paired with
16
+ # an images_payload array containing download URLs.
17
+ # 2. Auto-import: any remaining src="https://..." URLs are downloaded,
18
+ # deduplicated by a SHA256 of the URL path.
19
+ class ImageIngestor
20
+ class FetchError < StandardError; end
21
+
22
+ MAX_BYTES = 15.megabytes
23
+
24
+ def initialize(post)
25
+ @post = post
26
+ end
27
+
28
+ # images_payload: Array of Hashes
29
+ # [{ "reference_key" => "abc123", "url" => "https://...", "alt" => "...", "caption" => "..." }]
30
+ def call(body_html:, images_payload: [])
31
+ ensure_text_block!
32
+ upsert_inline_images(images_payload)
33
+ rewritten = rewrite_body_html(body_html)
34
+ text_block.content = rewritten
35
+ text_block.save!
36
+ rewritten
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :post
42
+
43
+ def text_block
44
+ @text_block ||= post.blocks.find_by(block_type: "text") ||
45
+ post.blocks.build(block_type: "text", position: 0)
46
+ end
47
+
48
+ def ensure_text_block!
49
+ text_block # trigger lazy init
50
+ end
51
+
52
+ def image_block
53
+ @image_block ||= post.blocks.find_by(block_type: "image_container") ||
54
+ post.blocks.create!(block_type: "image_container", position: 1)
55
+ end
56
+
57
+ def upsert_inline_images(payload)
58
+ return if payload.blank?
59
+
60
+ payload.each do |entry|
61
+ key = entry["reference_key"].presence
62
+ next unless key
63
+
64
+ bi = image_block.block_images.find_or_initialize_by(reference_key: key)
65
+ bi.caption = entry["caption"].to_s if entry["caption"].present?
66
+ bi.position ||= (image_block.block_images.maximum(:position) || 0) + 1
67
+
68
+ if bi.file.attached?
69
+ bi.save! if bi.changed?
70
+ next
71
+ end
72
+
73
+ blob = fetch_as_blob(entry["url"])
74
+ unless blob
75
+ Rails.logger.warn("Helios::Press::ImageIngestor: skipping #{key} — blob fetch failed")
76
+ next
77
+ end
78
+
79
+ bi.file.attach(blob)
80
+ bi.save!
81
+ end
82
+ end
83
+
84
+ def rewrite_body_html(html)
85
+ return html if html.blank?
86
+
87
+ doc = Nokogiri::HTML.fragment(html)
88
+ doc.css("img").each do |img|
89
+ src = img["src"].to_s
90
+
91
+ if src.start_with?("helios://image/")
92
+ key = src.delete_prefix("helios://image/")
93
+ bi = image_block.block_images.find_by(reference_key: key)
94
+ next unless bi&.file&.attached?
95
+
96
+ img["src"] = proxy_path(bi.file)
97
+ img["alt"] = bi.caption if bi.caption.present? && img["alt"].blank?
98
+ elsif src.match?(%r{\Ahttps?://}i)
99
+ bi = import_remote_image(src, alt: img["alt"].to_s)
100
+ next unless bi&.file&.attached?
101
+
102
+ img["src"] = proxy_path(bi.file)
103
+ end
104
+ end
105
+
106
+ doc.to_html
107
+ end
108
+
109
+ def import_remote_image(url, alt: "")
110
+ uri = URI.parse(url)
111
+ key = "auto_#{Digest::SHA256.hexdigest(uri.path.to_s)[0, 16]}"
112
+
113
+ bi = image_block.block_images.find_or_initialize_by(reference_key: key)
114
+ bi.caption = alt if alt.present? && bi.caption.blank?
115
+ bi.position ||= (image_block.block_images.maximum(:position) || 0) + 1
116
+
117
+ if bi.file.attached?
118
+ bi.save! if bi.changed?
119
+ return bi
120
+ end
121
+
122
+ blob = fetch_as_blob(url)
123
+ unless blob
124
+ Rails.logger.warn("Helios::Press::ImageIngestor: skipping remote image #{url} — blob fetch failed")
125
+ return nil
126
+ end
127
+
128
+ bi.file.attach(blob)
129
+ bi.save!
130
+ bi
131
+ rescue StandardError => e
132
+ Rails.logger.error("Helios::Press::ImageIngestor: failed to import #{url} — #{e.class}: #{e.message}")
133
+ nil
134
+ end
135
+
136
+ def proxy_path(attachment)
137
+ Rails.application.routes.url_helpers.rails_storage_proxy_path(attachment, only_path: true)
138
+ end
139
+
140
+ def fetch_as_blob(url)
141
+ uri = URI.parse(url)
142
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
143
+
144
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
145
+ http.open_timeout = 10
146
+ http.read_timeout = 30
147
+ http.request(Net::HTTP::Get.new(uri))
148
+ end
149
+
150
+ raise FetchError, "HTTP #{response.code} fetching #{url}" unless response.is_a?(Net::HTTPSuccess)
151
+ raise FetchError, "Image too large: #{response.body.bytesize}" if response.body.bytesize > MAX_BYTES
152
+
153
+ filename = File.basename(uri.path.presence || "image")
154
+ filename = "image.jpg" if filename.blank? || filename == "/"
155
+
156
+ ActiveStorage::Blob.create_and_upload!(
157
+ io: StringIO.new(response.body),
158
+ filename: filename,
159
+ content_type: response["content-type"] || "application/octet-stream"
160
+ )
161
+ rescue StandardError => e
162
+ Rails.logger.error("Helios::Press::ImageIngestor: failed to fetch #{url} — #{e.class}: #{e.message}")
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,8 +1,8 @@
1
1
  <div data-controller="helios-press-image-block"
2
2
  data-helios-press-image-block-id-value="<%= block.id %>"
3
3
  data-helios-press-image-block-post-id-value="<%= post.id %>"
4
- data-helios-press-image-block-base-url-value="<%= helios_press.admin_post_block_images_path(post, block) %>"
5
- data-helios-press-image-block-block-url-value="<%= helios_press.admin_post_block_path(post, block) %>"
4
+ data-helios-press-image-block-base-url-value="<%= helios_press_admin.post_block_images_path(post, block) %>"
5
+ data-helios-press-image-block-block-url-value="<%= helios_press_admin.post_block_path(post, block) %>"
6
6
  class="image-block-container">
7
7
 
8
8
  <!-- Block Controls -->
@@ -2,10 +2,22 @@
2
2
  data-helios-press-image-block-target="image"
3
3
  data-image-id="<%= block_image.id %>">
4
4
 
5
- <!-- Image -->
5
+ <!-- Image / Document -->
6
6
  <div class="image-wrapper">
7
7
  <% if block_image.file.attached? %>
8
- <%= image_tag block_image.file.variant(resize_to_limit: [800, 800]), class: "block-image" %>
8
+ <% if block_image.svg? %>
9
+ <%= image_tag url_for(block_image.file), class: "block-image" %>
10
+ <% elsif block_image.document? %>
11
+ <div class="document-preview d-flex flex-column align-items-center justify-content-center h-100">
12
+ <i class="bi <%= block_image.file.content_type.include?('pdf') ? 'bi-file-earmark-pdf' : 'bi-file-earmark-word' %>" style="font-size: 3rem; color: #6c757d;"></i>
13
+ <small class="text-muted mt-1"><%= block_image.file.filename %></small>
14
+ <%= link_to "Download", url_for(block_image.file), class: "btn btn-sm btn-outline-secondary mt-1", download: true %>
15
+ </div>
16
+ <% elsif block_image.variable? %>
17
+ <%= image_tag block_image.file.variant(resize_to_limit: [800, 800]), class: "block-image" %>
18
+ <% else %>
19
+ <%= image_tag url_for(block_image.file), class: "block-image" %>
20
+ <% end %>
9
21
  <% end %>
10
22
 
11
23
  <!-- Delete button -->
@@ -1,7 +1,7 @@
1
1
  <div data-controller="helios-press-text-block"
2
2
  data-helios-press-text-block-id-value="<%= block.id %>"
3
3
  data-helios-press-text-block-post-id-value="<%= post.id %>"
4
- data-helios-press-text-block-base-url-value="<%= helios_press.admin_post_block_path(post, block) %>"
4
+ data-helios-press-text-block-base-url-value="<%= helios_press_admin.post_block_path(post, block) %>"
5
5
  data-helios-press-text-block-mode-value="view">
6
6
 
7
7
  <!-- View Mode -->
@@ -18,7 +18,7 @@
18
18
  <!-- Edit Mode -->
19
19
  <div data-helios-press-text-block-target="editMode" class="block-edit-mode d-none">
20
20
  <%= form_with(model: block,
21
- url: helios_press.admin_post_block_path(post, block),
21
+ url: helios_press_admin.post_block_path(post, block),
22
22
  data: { helios_press_text_block_target: "form" }) do |f| %>
23
23
  <%= f.rich_text_area :content,
24
24
  data: { helios_press_text_block_target: "editor" },
@@ -1,7 +1,10 @@
1
1
  <% if block.respond_to?(:video) && block.video.present? %>
2
2
  <% video = block.video %>
3
+ <% video_ready = video.key.present? && video.playback_urls.present? %>
3
4
  <div data-controller="helios-video-block"
4
5
  data-helios-video-block-video-id-value="<%= video.id %>"
6
+ data-helios-video-block-ready-value="<%= video_ready %>"
7
+ data-helios-video-block-status-url-value="<%= helios_videos.admin_video_path(video) %>"
5
8
  class="video-block-container">
6
9
  <!-- Block Controls -->
7
10
  <div class="d-flex justify-content-end mb-2">
@@ -32,12 +35,17 @@
32
35
  </div>
33
36
 
34
37
  <!-- Video Player -->
35
- <div class="video-player-wrapper">
38
+ <div class="video-player-wrapper" data-helios-video-block-target="player">
36
39
  <%= video.player_component.html_safe %>
37
40
  </div>
38
41
  </div>
39
42
  <% else %>
40
- <div class="alert alert-warning mb-0">
41
- No video found for this block.
43
+ <div class="alert alert-warning mb-0 d-flex justify-content-between align-items-center">
44
+ <span>No video found for this block.</span>
45
+ <button type="button"
46
+ class="btn btn-sm btn-danger"
47
+ data-action="click->helios-press-blocks#deleteBlock">
48
+ Delete Block
49
+ </button>
42
50
  </div>
43
51
  <% end %>
@@ -1,4 +1,4 @@
1
- <%= form_with(model: post, url: post.persisted? ? helios_press.admin_post_path(post) : helios_press.admin_posts_path, local: true) do |form| %>
1
+ <%= form_with(model: post, url: post.persisted? ? helios_press_admin.post_path(post) : helios_press_admin.posts_path, local: true) do |form| %>
2
2
  <% if post.errors.any? %>
3
3
  <div class="alert alert-danger">
4
4
  <h4><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h4>
@@ -43,6 +43,6 @@
43
43
  <% if post.persisted? && !post.published? %>
44
44
  <%= form.submit "Publish Now", name: "publish", class: "btn btn-success", data: { turbo_confirm: "Publish this post now?" } %>
45
45
  <% end %>
46
- <%= link_to "Cancel", helios_press.admin_posts_path, class: "btn btn-secondary" %>
46
+ <%= link_to "Cancel", helios_press_admin.posts_path, class: "btn btn-secondary" %>
47
47
  </div>
48
48
  <% end %>
@@ -16,7 +16,7 @@
16
16
  <h5 class="mb-0">Content Blocks</h5>
17
17
  </div>
18
18
  <div class="card-body">
19
- <div data-controller="helios-press-blocks" data-helios-press-blocks-post-id-value="<%= @post.id %>" data-helios-press-blocks-base-url-value="<%= helios_press.admin_post_blocks_path(@post) %>">
19
+ <div data-controller="helios-press-blocks" data-helios-press-blocks-post-id-value="<%= @post.id %>" data-helios-press-blocks-base-url-value="<%= helios_press_admin.post_blocks_path(@post) %>">
20
20
  <%= render "blocks_container", post: @post %>
21
21
  </div>
22
22
  </div>
@@ -1,30 +1,41 @@
1
1
  <div class="container py-5">
2
2
  <div class="d-flex justify-content-between align-items-center mb-4">
3
3
  <h1>Posts</h1>
4
- <%= link_to "New Post", helios_press.new_admin_post_path, class: "btn btn-primary" %>
4
+ <%= link_to "New Post", helios_press_admin.new_post_path, class: "btn btn-primary" %>
5
5
  </div>
6
6
 
7
7
  <% if @posts.any? %>
8
- <div class="list-group">
9
- <% @posts.each do |post| %>
10
- <div class="list-group-item d-flex justify-content-between align-items-center">
11
- <div>
12
- <h5 class="mb-1">
13
- <%= link_to post.name, helios_press.edit_admin_post_path(post) %>
14
- </h5>
15
- <small class="text-muted">/<%= post.slug %></small>
16
- <% if post.published? %>
17
- <span class="badge bg-success ms-2">Published</span>
18
- <% else %>
19
- <span class="badge bg-secondary ms-2">Draft</span>
20
- <% end %>
21
- </div>
22
- <div>
23
- <small class="text-muted"><%= post.updated_at.strftime("%m/%d/%y") %></small>
24
- </div>
25
- </div>
26
- <% end %>
27
- </div>
8
+ <table class="table">
9
+ <thead>
10
+ <tr>
11
+ <th>Title</th>
12
+ <th>Slug</th>
13
+ <th>Status</th>
14
+ <th>Last Updated</th>
15
+ <th>Actions</th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @posts.each do |post| %>
20
+ <tr>
21
+ <td><%= link_to post.name, helios_press_admin.edit_post_path(post) %></td>
22
+ <td><small class="text-muted">/<%= post.slug %></small></td>
23
+ <td>
24
+ <% if post.published? %>
25
+ <span class="badge bg-success">Published</span>
26
+ <% else %>
27
+ <span class="badge bg-secondary">Draft</span>
28
+ <% end %>
29
+ </td>
30
+ <td><%= post.updated_at.strftime("%m/%d/%y") %></td>
31
+ <td>
32
+ <%= link_to "Edit", helios_press_admin.edit_post_path(post), class: "btn btn-sm btn-outline-primary" %>
33
+ <%= link_to "Delete", helios_press_admin.post_path(post), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, class: "btn btn-sm btn-outline-danger" %>
34
+ </td>
35
+ </tr>
36
+ <% end %>
37
+ </tbody>
38
+ </table>
28
39
  <% else %>
29
40
  <p class="text-muted">No posts yet. Create your first post!</p>
30
41
  <% end %>
@@ -3,9 +3,26 @@
3
3
  <% block.block_images.each do |block_image| %>
4
4
  <div class="image-item">
5
5
  <% if block_image.file.attached? %>
6
- <%= image_tag block_image.file.variant(resize_to_limit: [1200, 1200]),
7
- class: "img-fluid rounded",
8
- alt: block_image.caption.presence || "" %>
6
+ <% if block_image.svg? %>
7
+ <%= image_tag url_for(block_image.file),
8
+ class: "img-fluid rounded",
9
+ alt: block_image.caption.presence || "" %>
10
+ <% elsif block_image.document? %>
11
+ <div class="document-download text-center p-3">
12
+ <i class="bi <%= block_image.file.content_type.include?('pdf') ? 'bi-file-earmark-pdf' : 'bi-file-earmark-word' %>" style="font-size: 3rem;"></i>
13
+ <p class="mt-2 mb-0">
14
+ <%= link_to block_image.file.filename, url_for(block_image.file), download: true %>
15
+ </p>
16
+ </div>
17
+ <% elsif block_image.variable? %>
18
+ <%= image_tag block_image.file.variant(resize_to_limit: [1200, 1200]),
19
+ class: "img-fluid rounded",
20
+ alt: block_image.caption.presence || "" %>
21
+ <% else %>
22
+ <%= image_tag url_for(block_image.file),
23
+ class: "img-fluid rounded",
24
+ alt: block_image.caption.presence || "" %>
25
+ <% end %>
9
26
  <% end %>
10
27
  <% if block_image.caption.present? %>
11
28
  <p class="image-caption text-muted mt-2 small">
@@ -0,0 +1,21 @@
1
+ <div class="container my-5">
2
+ <h1>Blog</h1>
3
+
4
+ <% if @posts.any? %>
5
+ <% @posts.each do |post| %>
6
+ <article class="mb-4">
7
+ <h2><%= link_to post.name, helios_press_public.post_path(post.slug) %></h2>
8
+ <p class="text-muted">
9
+ <time datetime="<%= post.updated_at.iso8601 %>">
10
+ <%= post.updated_at.strftime("%m/%d/%y") %>
11
+ </time>
12
+ </p>
13
+ <% if post.description.present? %>
14
+ <p><%= post.description %></p>
15
+ <% end %>
16
+ </article>
17
+ <% end %>
18
+ <% else %>
19
+ <p>No posts yet.</p>
20
+ <% end %>
21
+ </div>
@@ -0,0 +1,17 @@
1
+ Helios::Press::Admin::Engine.routes.draw do
2
+ scope module: "helios/press/admin" do
3
+ root to: "posts#index"
4
+ resources :posts do
5
+ resources :blocks, only: [:create, :update, :destroy] do
6
+ collection do
7
+ patch :reorder
8
+ end
9
+ resources :images, controller: "block_images", only: [:create, :update, :destroy] do
10
+ collection do
11
+ patch :reorder
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,5 @@
1
+ Helios::Press::Api::Engine.routes.draw do
2
+ scope module: "helios/press/api" do
3
+ resources :posts, only: [:create]
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ Helios::Press::Public::Engine.routes.draw do
2
+ scope module: "helios/press" do
3
+ root to: "posts#index"
4
+ get ":slug", to: "posts#show", as: :post
5
+ end
6
+ end
data/config/routes.rb CHANGED
@@ -1,21 +1,2 @@
1
1
  Helios::Press::Engine.routes.draw do
2
- namespace :admin do
3
- root to: "posts#index"
4
- resources :posts do
5
- resources :blocks, only: [:create, :update, :destroy] do
6
- collection do
7
- patch :reorder
8
- end
9
- resources :images, controller: "block_images", only: [:create, :update, :destroy] do
10
- collection do
11
- patch :reorder
12
- end
13
- end
14
- end
15
- end
16
- end
17
-
18
- namespace :api do
19
- resources :posts, only: [:create]
20
- end
21
2
  end
@@ -0,0 +1,6 @@
1
+ class AddImageIngestionToHeliosPress < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :helios_press_block_images, :reference_key, :string
4
+ add_index :helios_press_block_images, [:block_id, :reference_key], unique: true
5
+ end
6
+ end
@@ -2,12 +2,14 @@ module Helios
2
2
  module Press
3
3
  class Configuration
4
4
  attr_accessor :admin_parent_controller,
5
+ :public_parent_controller,
5
6
  :api_parent_controller,
6
7
  :api_authentication,
7
8
  :post_slug_prefix
8
9
 
9
10
  def initialize
10
11
  @admin_parent_controller = "ApplicationController"
12
+ @public_parent_controller = "ApplicationController"
11
13
  @api_parent_controller = "ActionController::API"
12
14
  @api_authentication = nil
13
15
  @post_slug_prefix = nil
@@ -6,6 +6,48 @@ module Helios
6
6
  initializer "helios_press.assets" do |app|
7
7
  app.config.assets.precompile += %w[helios/press/blocks.css] if app.config.respond_to?(:assets)
8
8
  end
9
+
10
+ initializer "helios_press.helpers" do
11
+ ActiveSupport.on_load(:action_controller) do
12
+ helper Rails.application.routes.url_helpers
13
+ end
14
+ end
15
+ end
16
+
17
+ module Admin
18
+ class Engine < ::Rails::Engine
19
+ engine_name "helios_press_admin"
20
+
21
+ self.paths["config/routes.rb"] = "config/routes/admin.rb"
22
+
23
+ initializer "helios_press_admin.helpers" do
24
+ ActiveSupport.on_load(:action_controller) do
25
+ helper Rails.application.routes.url_helpers
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ module Public
32
+ class Engine < ::Rails::Engine
33
+ engine_name "helios_press_public"
34
+
35
+ self.paths["config/routes.rb"] = "config/routes/public.rb"
36
+
37
+ initializer "helios_press_public.helpers" do
38
+ ActiveSupport.on_load(:action_controller) do
39
+ helper Rails.application.routes.url_helpers
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ module Api
46
+ class Engine < ::Rails::Engine
47
+ engine_name "helios_press_api"
48
+
49
+ self.paths["config/routes.rb"] = "config/routes/api.rb"
50
+ end
9
51
  end
10
52
  end
11
53
  end
@@ -1,5 +1,5 @@
1
1
  module Helios
2
2
  module Press
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3"
4
4
  end
5
5
  end
data/lib/helios/press.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require "helios/press/version"
2
2
  require "helios/press/engine"
3
3
  require "helios/press/configuration"
4
+ require "acts_as_list"
4
5
  require "helios-videos"
5
6
  require "helios-sitemap"
6
7
 
@@ -0,0 +1 @@
1
+ require "helios/press"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: helios-press
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: '0.3'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Fleetwood-Boldt
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -37,6 +38,20 @@ dependencies:
37
38
  - - ">="
38
39
  - !ruby/object:Gem::Version
39
40
  version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: nokogiri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '1.13'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '1.13'
40
55
  - !ruby/object:Gem::Dependency
41
56
  name: helios-videos
42
57
  requirement: !ruby/object:Gem::Requirement
@@ -97,6 +112,7 @@ files:
97
112
  - app/models/helios/press/block.rb
98
113
  - app/models/helios/press/block_image.rb
99
114
  - app/models/helios/press/post.rb
115
+ - app/services/helios/press/image_ingestor.rb
100
116
  - app/views/helios/press/admin/blocks/_block.html.erb
101
117
  - app/views/helios/press/admin/blocks/_image_block.html.erb
102
118
  - app/views/helios/press/admin/blocks/_image_item.html.erb
@@ -110,13 +126,19 @@ files:
110
126
  - app/views/helios/press/posts/blocks/_image_container.html.erb
111
127
  - app/views/helios/press/posts/blocks/_text.html.erb
112
128
  - app/views/helios/press/posts/blocks/_video_container.html.erb
129
+ - app/views/helios/press/posts/index.html.erb
113
130
  - app/views/helios/press/posts/show.html.erb
114
131
  - app/views/layouts/helios/press/admin.html.erb
115
132
  - app/views/layouts/helios/press/application.html.erb
116
133
  - config/routes.rb
134
+ - config/routes/admin.rb
135
+ - config/routes/api.rb
136
+ - config/routes/public.rb
117
137
  - db/migrate/20250510000001_create_helios_press_posts.rb
118
138
  - db/migrate/20250510000002_create_helios_press_blocks.rb
119
139
  - db/migrate/20250510000003_create_helios_press_block_images.rb
140
+ - db/migrate/20250526000001_add_image_ingestion_to_helios_press.rb
141
+ - lib/helios-press.rb
120
142
  - lib/helios/press.rb
121
143
  - lib/helios/press/configuration.rb
122
144
  - lib/helios/press/engine.rb
@@ -128,6 +150,7 @@ licenses:
128
150
  metadata:
129
151
  homepage_uri: https://github.com/heliosdev-shop/helios-press
130
152
  source_code_uri: https://github.com/heliosdev-shop/helios-press
153
+ post_install_message:
131
154
  rdoc_options: []
132
155
  require_paths:
133
156
  - lib
@@ -142,7 +165,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
165
  - !ruby/object:Gem::Version
143
166
  version: '0'
144
167
  requirements: []
145
- rubygems_version: 3.6.9
168
+ rubygems_version: 3.4.19
169
+ signing_key:
146
170
  specification_version: 4
147
171
  summary: Multi-block blog post editor for Rails with drag-to-reorder
148
172
  test_files: []