summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-20 19:45:43 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-20 19:45:43 +0200
commit836308471f8d31ccdcdd3a5bd88bc76cc1c0831b (patch)
tree821a526d4d10870654a38d07a4eb53a2180ba313
parentd89e2de48a5e16ff01ec627e79c76b833e4618cf (diff)
Make headline images explicit, add asset credits
- related_assets gains a `headline` boolean (DB-enforced: at most one per page), replacing "first image by position" as the headline rule. A rake task backfills the current first image on every live head/draft, so nothing changes visually until an editor changes it. - The image picker sidebar gets a star toggle reflecting the flag; the TinyMCE inline-image picker's badge now reads it too, instead of assuming position 0. - No headline chosen (or none attached) now falls back to the gallery-count caption itself becoming the lightbox trigger, instead of the gallery being unreachable. - Assets gain creator, source_url, and license_key (against a new config/asset_licenses.yml dictionary). asset_credit renders a degrading attribution line, reused as a hidden per-image glightbox caption so credit is one click away for every image, not only the headline's always-visible one. - Fixed: asset thumbnails rendered unconditionally regardless of whether a real variant exists on disk. Asset#has_variant? checks file existence, not content type -- some legacy PDFs have real pre-rewrite thumbnails a content-type check would have hidden. - assets#new/edit rebuilt onto the same node_description/node_content layout as assets#show, picking up the three new fields in the process.
-rw-r--r--app/controllers/assets_controller.rb2
-rw-r--r--app/controllers/related_assets_controller.rb12
-rw-r--r--app/helpers/content_helper.rb29
-rw-r--r--app/models/asset.rb8
-rw-r--r--app/models/asset_license.rb15
-rw-r--r--app/models/concerns/file_attachment.rb8
-rw-r--r--app/models/related_asset.rb9
-rw-r--r--app/views/assets/edit.html.erb58
-rw-r--r--app/views/assets/index.html.erb2
-rw-r--r--app/views/assets/new.html.erb49
-rw-r--r--app/views/assets/show.html.erb26
-rw-r--r--app/views/content/_asset_credits.html.erb12
-rw-r--r--app/views/content/_headline_image.html.erb36
-rw-r--r--app/views/custom/page_templates/public/chapter_detail.html.erb1
-rw-r--r--app/views/custom/page_templates/public/no_date_and_author.html.erb1
-rw-r--r--app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb1
-rw-r--r--app/views/custom/page_templates/public/standard_template.html.erb1
-rw-r--r--app/views/custom/page_templates/public/title_only.html.erb1
-rw-r--r--app/views/custom/page_templates/public/update.html.erb1
-rw-r--r--app/views/nodes/edit.html.erb12
-rw-r--r--config/asset_licenses.yml25
-rw-r--r--config/locales/de.yml14
-rw-r--r--config/locales/en.yml14
-rw-r--r--db/migrate/20260720115941_add_credit_fields_to_assets.rb7
-rw-r--r--db/migrate/20260720120917_add_headline_to_related_assets.rb7
-rw-r--r--lib/tasks/assets.rake20
-rw-r--r--public/javascripts/admin_interface.js12
-rw-r--r--public/javascripts/related_assets.js35
-rw-r--r--public/stylesheets/admin.css35
-rw-r--r--public/stylesheets/ccc.css8
-rw-r--r--test/controllers/related_assets_controller_test.rb46
-rw-r--r--test/models/asset_license_test.rb31
-rw-r--r--test/models/asset_test.rb23
-rw-r--r--test/models/helpers/content_helper_test.rb78
-rw-r--r--test/models/related_asset_test.rb44
35 files changed, 592 insertions, 91 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index d150e06..becfe13 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -89,6 +89,6 @@ class AssetsController < ApplicationController
89 private 89 private
90 90
91 def asset_params 91 def asset_params
92 params.require(:asset).permit(:name, :upload) 92 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key)
93 end 93 end
94end 94end
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb
index 5af73fc..da82cde 100644
--- a/app/controllers/related_assets_controller.rb
+++ b/app/controllers/related_assets_controller.rb
@@ -39,7 +39,17 @@ class RelatedAssetsController < ApplicationController
39 end 39 end
40 40
41 def update 41 def update
42 @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) 42 related = @node.editable_page.related_assets.find(params[:id])
43
44 if params.key?(:headline)
45 RelatedAsset.transaction do
46 @node.editable_page.related_assets.update_all(headline: false)
47 related.update!(headline: true) if params[:headline] == "true"
48 end
49 else
50 related.insert_at(params[:position].to_i)
51 end
52
43 head :ok 53 head :ok
44 end 54 end
45 55
diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb
index 5810966..5b67259 100644
--- a/app/helpers/content_helper.rb
+++ b/app/helpers/content_helper.rb
@@ -53,11 +53,8 @@ module ContentHelper
53 end 53 end
54 54
55 def headline_image 55 def headline_image
56 @images = @page.assets.images 56 @headline_asset = @page.related_assets.find_by(headline: true)&.asset
57 57 render :partial => 'content/headline_image' if @headline_asset || @page.assets.images.any?
58 unless @images.empty?
59 render :partial => 'content/headline_image'
60 end
61 end 58 end
62 59
63 # Returns the published_at attribute of a page if it is not nil, otherwise 60 # Returns the published_at attribute of a page if it is not nil, otherwise
@@ -154,4 +151,26 @@ module ContentHelper
154 ) 151 )
155 end 152 end
156 153
154 def asset_credit(asset)
155 return nil unless asset
156 return nil if asset.creator.blank? && asset.source_url.blank? && asset.license_key.blank?
157
158 license = AssetLicense.find(asset.license_key)
159
160 photo_label = t("asset_credits.photo", name: asset.name)
161 photo = asset.source_url.present? ? link_to(photo_label, asset.source_url) : photo_label
162
163 attribution_parts = [photo]
164 attribution_parts << t("asset_credits.by", creator: asset.creator) if asset.creator.present?
165 attribution = safe_join(attribution_parts, " ")
166
167 license_text = if license
168 name = t("asset_licenses.#{license.key}", default: license.key)
169 phrase = license.style == "license" ? t("asset_credits.licensed_under", license: name) : name
170 license.url.present? ? link_to(phrase, license.url) : phrase
171 end
172
173 full = license_text ? safe_join([attribution, license_text], ", ") : attribution
174 safe_join([full, "."])
175 end
157end 176end
diff --git a/app/models/asset.rb b/app/models/asset.rb
index aca0ee8..8bea1b3 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -1,12 +1,18 @@
1class Asset < ApplicationRecord 1class Asset < ApplicationRecord
2 IMAGE_CONTENT_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]
2 3
3 include FileAttachment 4 include FileAttachment
4 5
5 has_many :related_assets, :dependent => :destroy 6 has_many :related_assets, :dependent => :destroy
6 has_many :pages, :through => :related_assets 7 has_many :pages, :through => :related_assets
7 8
8 scope :images, -> { where(:upload_content_type => ["image/gif", "image/jpeg", "image/png", "image/webp"]) } 9 scope :images, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES) }
9 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) } 10 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) }
10 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) } 11 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) }
11 12
13 validates :license_key, inclusion: { in: -> { AssetLicense.keys } }, allow_blank: true
14
15 def image?
16 IMAGE_CONTENT_TYPES.include?(upload_content_type)
17 end
12end 18end
diff --git a/app/models/asset_license.rb b/app/models/asset_license.rb
new file mode 100644
index 0000000..ca66d40
--- /dev/null
+++ b/app/models/asset_license.rb
@@ -0,0 +1,15 @@
1class AssetLicense
2 Entry = Struct.new(:key, :url, :requires_attribution, :style, keyword_init: true)
3
4 DICTIONARY = YAML.load_file(Rails.root.join("config", "asset_licenses.yml")).freeze
5
6 def self.keys
7 DICTIONARY.keys
8 end
9
10 def self.find(key)
11 entry = DICTIONARY[key.to_s] if key.present?
12 return nil unless entry
13 Entry.new(key: key.to_s, url: entry["url"], requires_attribution: entry["requires_attribution"], style: entry["style"])
14 end
15end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 3c09456..e7a33c5 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -52,6 +52,14 @@ module FileAttachment
52 build_upload_proxy 52 build_upload_proxy
53 end 53 end
54 54
55 def has_variant?(style)
56 upload_file_name.present? && File.exist?(file_path(style))
57 end
58
59 def previewable?
60 has_variant?(:medium)
61 end
62
55 private 63 private
56 64
57 def build_upload_proxy 65 def build_upload_proxy
diff --git a/app/models/related_asset.rb b/app/models/related_asset.rb
index 8f16460..62000cc 100644
--- a/app/models/related_asset.rb
+++ b/app/models/related_asset.rb
@@ -5,4 +5,13 @@ class RelatedAsset < ApplicationRecord
5 acts_as_list :scope => :page_id 5 acts_as_list :scope => :page_id
6 6
7 default_scope -> { order("position ASC") } 7 default_scope -> { order("position ASC") }
8
9 validate :headline_only_for_images
10
11 private
12
13 def headline_only_for_images
14 return unless asset
15 errors.add(:headline, "can only be set on image assets") if headline? && !asset.image?
16 end
8end 17end
diff --git a/app/views/assets/edit.html.erb b/app/views/assets/edit.html.erb
index f198600..3ef8fea 100644
--- a/app/views/assets/edit.html.erb
+++ b/app/views/assets/edit.html.erb
@@ -1,24 +1,44 @@
1<h1>Editing asset</h1> 1<div id="page_editor">
2 <h1>Editing asset</h1>
2 3
3<%= form_for(@asset, html: { multipart: true }) do |f| %> 4 <%= form_for(@asset, html: { multipart: true }) do |f| %>
4 <%= form_error_messages(f) %> 5 <%= form_error_messages(f) %>
5 6
6 <% if @asset.upload.present? %> 7 <div id="content">
7 <p> 8 <div class="node_description"><%= f.label :name %></div>
8 <strong>Current file:</strong> 9 <div class="node_content"><%= f.text_field :name %></div>
9 <%= @asset.upload.url %> 10
10 (<%= number_to_human_size(@asset.upload.size) %>) 11 <% if @asset.upload.present? %>
11 </p> 12 <div class="node_description">Current File</div>
12 <% end %> 13 <div class="node_content"><%= @asset.upload.url %> (<%= number_to_human_size(@asset.upload.size) %>)</div>
14 <% end %>
15
16 <div class="node_description"><%= f.label :upload, "Replace file" %></div>
17 <div class="node_content"><%= f.file_field :upload %></div>
13 18
14 <p> 19 <div class="node_description"><%= f.label :creator %></div>
15 <label>Replace file:</label><br> 20 <div class="node_content"><%= f.text_field :creator %></div>
16 <%= f.file_field :upload %>
17 </p>
18 21
19 <p> 22 <div class="node_description"><%= f.label :source_url, "Source URL" %></div>
20 <%= f.submit 'Update' %> 23 <div class="node_content"><%= f.text_field :source_url %></div>
21 </p>
22<% end %>
23 24
24<%= link_to 'Show', @asset %> | <%= link_to 'Back', assets_path %> 25 <div class="node_description"><%= f.label :license_key, "License" %></div>
26 <div class="node_content">
27 <%= f.select :license_key,
28 options_for_select(
29 [["— none —", ""]] + AssetLicense.keys.map { |key| [t("asset_licenses.#{key}"), key] },
30 @asset.license_key
31 ) %>
32 </div>
33
34 <div class="node_description">Actions</div>
35 <div class="node_content node_info_group">
36 <div class="node_info_group_items">
37 <div class="node_info_item"><%= f.submit 'Update' %></div>
38 <div class="node_info_item"><%= link_to 'Show', @asset %></div>
39 <div class="node_info_item"><%= link_to 'Back', assets_path %></div>
40 </div>
41 </div>
42 </div>
43 <% end %>
44</div>
diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb
index 8c35561..ff1300a 100644
--- a/app/views/assets/index.html.erb
+++ b/app/views/assets/index.html.erb
@@ -17,7 +17,7 @@
17 </tr> 17 </tr>
18<% @assets.each do |asset| %> 18<% @assets.each do |asset| %>
19 <tr> 19 <tr>
20 <td><%= image_tag asset.upload.url(:thumb), style: "max-width: 100px; max-height: 100px;" %></td> 20 <td><% if asset.has_variant?(:thumb) %><%= image_tag asset.upload.url(:thumb), style: "max-width: 100px; max-height: 100px;" %><% end %></td>
21 <td><%= link_to asset.name, asset.upload.url %></td> 21 <td><%= link_to asset.name, asset.upload.url %></td>
22 <td><%= asset.upload.content_type %></td> 22 <td><%= asset.upload.content_type %></td>
23 <td><%= link_to 'Show', asset %></td> 23 <td><%= link_to 'Show', asset %></td>
diff --git a/app/views/assets/new.html.erb b/app/views/assets/new.html.erb
index 6c1310a..2cf8865 100644
--- a/app/views/assets/new.html.erb
+++ b/app/views/assets/new.html.erb
@@ -1,17 +1,38 @@
1<h1>New asset</h1> 1<div id="page_editor">
2 <h1>New asset</h1>
2 3
3<%= form_for(@asset, :html => { :multipart => true }) do |f| %> 4 <%= form_for(@asset, :html => { :multipart => true }) do |f| %>
4 <%= form_error_messages(f) %> 5 <%= form_error_messages(f) %>
5
6 <p>
7 <%= f.label :name %><br />
8 <%= f.text_field :name %>
9 </p>
10 6
11 <p> 7 <div id="content">
12 <%= f.file_field :upload %> 8 <div class="node_description"><%= f.label :name %></div>
13 <%= f.submit 'Create' %> 9 <div class="node_content"><%= f.text_field :name %></div>
14 </p>
15<% end %>
16 10
17<%= link_to 'Back', assets_path %> 11 <div class="node_description"><%= f.label :upload, "File" %></div>
12 <div class="node_content"><%= f.file_field :upload %></div>
13
14 <div class="node_description"><%= f.label :creator %></div>
15 <div class="node_content"><%= f.text_field :creator %></div>
16
17 <div class="node_description"><%= f.label :source_url, "Source URL" %></div>
18 <div class="node_content"><%= f.text_field :source_url %></div>
19
20 <div class="node_description"><%= f.label :license_key, "License" %></div>
21 <div class="node_content">
22 <%= f.select :license_key,
23 options_for_select(
24 [["— none —", ""]] + AssetLicense.keys.map { |key| [t("asset_licenses.#{key}"), key] },
25 @asset.license_key
26 ) %>
27 </div>
28
29 <div class="node_description">Actions</div>
30 <div class="node_content node_info_group">
31 <div class="node_info_group_items">
32 <div class="node_info_item"><%= f.submit 'Create' %></div>
33 <div class="node_info_item"><%= link_to 'Back', assets_path %></div>
34 </div>
35 </div>
36 </div>
37 <% end %>
38</div>
diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb
index ff00883..a0e4e46 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -17,7 +17,10 @@
17 </div> 17 </div>
18 18
19 <div class="node_description">Thumbnail</div> 19 <div class="node_description">Thumbnail</div>
20 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div> 20 <% if @asset.has_variant?(:medium) %>
21 <div class="node_description">Thumbnail</div>
22 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
23 <% end %>
21 24
22 <div class="node_description">Public Path</div> 25 <div class="node_description">Public Path</div>
23 <div class="node_content"> 26 <div class="node_content">
@@ -30,6 +33,27 @@
30 </button> 33 </button>
31 </div> 34 </div>
32 35
36 <div class="node_description">Creator</div>
37 <div class="node_content"><%= @asset.creator.presence || "—" %></div>
38
39 <div class="node_description">Source</div>
40 <div class="node_content">
41 <% if @asset.source_url.present? %>
42 <%= link_to @asset.source_url, @asset.source_url %>
43 <% else %>
44 —
45 <% end %>
46 </div>
47
48 <div class="node_description">License</div>
49 <div class="node_content">
50 <% if (license = AssetLicense.find(@asset.license_key)) %>
51 <%= t("asset_licenses.#{license.key}") %>
52 <% else %>
53 —
54 <% end %>
55 </div>
56
33 <div class="node_description">Content Type</div> 57 <div class="node_description">Content Type</div>
34 <div class="node_content"><%= @asset.upload.content_type %></div> 58 <div class="node_content"><%= @asset.upload.content_type %></div>
35 59
diff --git a/app/views/content/_asset_credits.html.erb b/app/views/content/_asset_credits.html.erb
new file mode 100644
index 0000000..85739d0
--- /dev/null
+++ b/app/views/content/_asset_credits.html.erb
@@ -0,0 +1,12 @@
1<% if @page.assets.images.any? %>
2 <div id="asset_credits">
3 <% @page.assets.images.each do |image| %>
4 <% credit = asset_credit(image) %>
5 <% next unless credit %>
6 <div id="credit_for_asset_<%= image.id %>"
7 class="glightbox-desc<%= " headline_credit" if image == @headline_asset %>">
8 <%= credit %>
9 </div>
10 <% end %>
11 </div>
12<% end %>
diff --git a/app/views/content/_headline_image.html.erb b/app/views/content/_headline_image.html.erb
index 243be40..c764d22 100644
--- a/app/views/content/_headline_image.html.erb
+++ b/app/views/content/_headline_image.html.erb
@@ -1,16 +1,28 @@
1<%= link_to( 1<% gallery_images = @page.assets.images %>
2 image_tag(@images[0].upload.url(:headline)),
3 @images[0].upload.url,
4 :class => "glightbox",
5 :data => { :gallery => "page-#{@page.node.id}" }
6) %>
7 2
8<% if 1 < @images.length %> 3<% if @headline_asset %>
9 <div class="right"> 4 <%= link_to(
10 <%= "#{@images.length} #{t(:images)}" %> 5 image_tag(@headline_asset.upload.url(:headline)),
11 </div> 6 @headline_asset.upload.url,
7 :class => "glightbox",
8 :data => { :gallery => "page-#{@page.node.id}", :title => @headline_asset.name,
9 :description => "#credit_for_asset_#{@headline_asset.id}" }
10 ) %>
11 <% if gallery_images.size > 1 %>
12 <div class="right"><%= "#{gallery_images.size} #{t(:images)}" %></div>
13 <% end %>
14<% elsif gallery_images.any? %>
15 <%= link_to "#{gallery_images.size} #{t(:images)}, #{t(:open_gallery)}",
16 gallery_images.first.upload.url,
17 :class => "glightbox right",
18 :data => { :gallery => "page-#{@page.node.id}", :title => gallery_images.first.name,
19 :description => "#credit_for_asset_#{gallery_images.first.id}" } %>
12<% end %> 20<% end %>
13 21
14<% @images[1..-1].each do |image| %> 22<% gallery_images.each do |image| %>
15 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none", :data => { :gallery => "page-#{@page.node.id}" } %> 23 <% next if image == @headline_asset %>
24 <% next if !@headline_asset && image == gallery_images.first %>
25 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none",
26 :data => { :gallery => "page-#{@page.node.id}", :title => image.name,
27 :description => "#credit_for_asset_#{image.id}" } %>
16<% end %> 28<% end %>
diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb
index c4c2598..18449b8 100644
--- a/app/views/custom/page_templates/public/chapter_detail.html.erb
+++ b/app/views/custom/page_templates/public/chapter_detail.html.erb
@@ -31,4 +31,5 @@
31 </ul> 31 </ul>
32 </div> 32 </div>
33 <% end %> 33 <% end %>
34 <%= render partial: 'content/asset_credits' %>
34</div> 35</div>
diff --git a/app/views/custom/page_templates/public/no_date_and_author.html.erb b/app/views/custom/page_templates/public/no_date_and_author.html.erb
index fa39e54..ec7e850 100644
--- a/app/views/custom/page_templates/public/no_date_and_author.html.erb
+++ b/app/views/custom/page_templates/public/no_date_and_author.html.erb
@@ -3,6 +3,7 @@
3 <p><%= sanitize( @page.abstract ) %></p> 3 <p><%= sanitize( @page.abstract ) %></p>
4 <div id="headline_image"><%= headline_image %></div> 4 <div id="headline_image"><%= headline_image %></div>
5 <%= aggregate?(@page.body) %> 5 <%= aggregate?(@page.body) %>
6 <%= render partial: 'content/asset_credits' %>
6</div> 7</div>
7 8
8<%= will_paginate(@content_collection) if @content_collection %> 9<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb b/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
index 64e2a7c..c486684 100644
--- a/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
+++ b/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
@@ -1,6 +1,7 @@
1<div class="article" lang="<%= @page.effective_lang %>"> 1<div class="article" lang="<%= @page.effective_lang %>">
2 <div id="headline_image"><%= headline_image %></div> 2 <div id="headline_image"><%= headline_image %></div>
3 <%= aggregate?(@page.body) %> 3 <%= aggregate?(@page.body) %>
4 <%= render partial: 'content/asset_credits' %>
4</div> 5</div>
5 6
6<%= will_paginate(@content_collection) if @content_collection %> 7<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/standard_template.html.erb b/app/views/custom/page_templates/public/standard_template.html.erb
index bbc8cd0..e1e2eb0 100644
--- a/app/views/custom/page_templates/public/standard_template.html.erb
+++ b/app/views/custom/page_templates/public/standard_template.html.erb
@@ -3,6 +3,7 @@
3 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div> 3 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div>
4 <div id="headline_image"><%= headline_image %></div> 4 <div id="headline_image"><%= headline_image %></div>
5 <div class="body"><%= aggregate?(@page.body) %></div> 5 <div class="body"><%= aggregate?(@page.body) %></div>
6 <%= render partial: 'content/asset_credits' %>
6</div> 7</div>
7 8
8<%= will_paginate(@content_collection) if @content_collection %> 9<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/title_only.html.erb b/app/views/custom/page_templates/public/title_only.html.erb
index 42fe9a6..d706e9d 100644
--- a/app/views/custom/page_templates/public/title_only.html.erb
+++ b/app/views/custom/page_templates/public/title_only.html.erb
@@ -2,6 +2,7 @@
2 <h2 class="headline"><%= @page.title %></h2> 2 <h2 class="headline"><%= @page.title %></h2>
3 <div id="headline_image"><%= headline_image %></div> 3 <div id="headline_image"><%= headline_image %></div>
4 <%= aggregate?(@page.body) %> 4 <%= aggregate?(@page.body) %>
5 <%= render partial: 'content/asset_credits' %>
5</div> 6</div>
6 7
7<%= will_paginate(@content_collection) if @content_collection %> 8<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/update.html.erb b/app/views/custom/page_templates/public/update.html.erb
index d5995ac..0ff1bb2 100644
--- a/app/views/custom/page_templates/public/update.html.erb
+++ b/app/views/custom/page_templates/public/update.html.erb
@@ -4,6 +4,7 @@
4 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div> 4 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div>
5 <div id="headline_image"><%= headline_image %></div> 5 <div id="headline_image"><%= headline_image %></div>
6 <div class="body"><%= aggregate?(@page.body) %></div> 6 <div class="body"><%= aggregate?(@page.body) %></div>
7 <%= render partial: 'content/asset_credits' %>
7</div> 8</div>
8 9
9<%= will_paginate(@content_collection) if @content_collection %> 10<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index 25f15c2..2c1e975 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -82,11 +82,18 @@
82 <ul id="related_asset_list" class="thumbnail_list"> 82 <ul id="related_asset_list" class="thumbnail_list">
83 <% @page.related_assets.includes(:asset).each do |related| %> 83 <% @page.related_assets.includes(:asset).each do |related| %>
84 <li data-url="<%= node_related_asset_path(@node, related) %>" 84 <li data-url="<%= node_related_asset_path(@node, related) %>"
85 data-asset-id="<%= related.asset.id %>"
85 data-large-url="<%= related.asset.upload.url(:large) %>" 86 data-large-url="<%= related.asset.upload.url(:large) %>"
86 data-original-url="<%= related.asset.upload.url %>" 87 data-original-url="<%= related.asset.upload.url %>"
87 data-name="<%= related.asset.name %>"> 88 data-name="<%= related.asset.name %>"
89 data-headline="<%= related.headline? %>"
90 class="<%= "is_headline" if related.headline? %>">
88 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 91 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
89 <%= image_tag related.asset.upload.url(:thumb) %> 92 <%= image_tag related.asset.upload.url(:thumb) %>
93 <button type="button" class="related_asset_set_headline"
94 aria-pressed="<%= related.headline? %>" aria-label="Set as headline image">
95 <%= icon("star", library: "tabler", "aria-hidden": true) %>
96 </button>
90 <button type="button" class="related_asset_remove" aria-label="Remove image"> 97 <button type="button" class="related_asset_remove" aria-label="Remove image">
91 <%= icon("x", library: "tabler", "aria-hidden": true) %> 98 <%= icon("x", library: "tabler", "aria-hidden": true) %>
92 </button> 99 </button>
@@ -102,6 +109,9 @@
102 <li> 109 <li>
103 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 110 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
104 <img src=""> 111 <img src="">
112 <button type="button" class="related_asset_set_headline" aria-pressed="false" aria-label="Set as headline image">
113 <%= icon("star", library: "tabler", "aria-hidden": true) %>
114 </button>
105 <button type="button" class="related_asset_remove" aria-label="Remove image"> 115 <button type="button" class="related_asset_remove" aria-label="Remove image">
106 <%= icon("x", library: "tabler", "aria-hidden": true) %> 116 <%= icon("x", library: "tabler", "aria-hidden": true) %>
107 </button> 117 </button>
diff --git a/config/asset_licenses.yml b/config/asset_licenses.yml
new file mode 100644
index 0000000..5cf377f
--- /dev/null
+++ b/config/asset_licenses.yml
@@ -0,0 +1,25 @@
1# config/asset_licenses.yml
2cc0:
3 url: "https://creativecommons.org/publicdomain/zero/1.0/"
4 requires_attribution: false
5 style: license
6cc_by_4:
7 url: "https://creativecommons.org/licenses/by/4.0/"
8 requires_attribution: true
9 style: license
10cc_by_sa_4:
11 url: "https://creativecommons.org/licenses/by-sa/4.0/"
12 requires_attribution: true
13 style: license
14public_domain:
15 url:
16 requires_attribution: false
17 style: note
18used_with_permission:
19 url:
20 requires_attribution: false
21 style: note
22own_work:
23 url:
24 requires_attribution: false
25 style: note
diff --git a/config/locales/de.yml b/config/locales/de.yml
index f64f6cd..21c8ebf 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -116,3 +116,17 @@ de:
116 revision_created: "angelegt am %{date} von %{actor}" 116 revision_created: "angelegt am %{date} von %{actor}"
117 revision_published: "veröffentlicht am %{date} von %{actor}" 117 revision_published: "veröffentlicht am %{date} von %{actor}"
118 revision_restored: "wiederhergestellt am %{date} von %{actor}" 118 revision_restored: "wiederhergestellt am %{date} von %{actor}"
119
120 open_gallery: "Gallerie anzeigen"
121 asset_licenses:
122 cc0: "CC0"
123 cc_by_4: "CC BY 4.0"
124 cc_by_sa_4: "CC BY-SA 4.0"
125 public_domain: "Gemeinfrei"
126 used_with_permission: "Mit Genehmigung verwendet"
127 own_work: "Eigenes Werk"
128
129 asset_credits:
130 photo: "Foto %{name}"
131 by: "von %{creator}"
132 licensed_under: "Lizenziert unter %{license}"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8a8acc1..1afcf21 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -68,3 +68,17 @@ en:
68 revision_created: "created %{date} by %{actor}" 68 revision_created: "created %{date} by %{actor}"
69 revision_published: "published %{date} by %{actor}" 69 revision_published: "published %{date} by %{actor}"
70 revision_restored: "restored %{date} by %{actor}" 70 revision_restored: "restored %{date} by %{actor}"
71
72 open_gallery: "Open gallery"
73 asset_licenses:
74 cc0: "CC0"
75 cc_by_4: "CC BY 4.0"
76 cc_by_sa_4: "CC BY-SA 4.0"
77 public_domain: "Public domain"
78 used_with_permission: "Used with permission"
79 own_work: "Own work"
80
81 asset_credits:
82 photo: "Photo %{name}"
83 by: "by %{creator}"
84 licensed_under: "Licensed under %{license}"
diff --git a/db/migrate/20260720115941_add_credit_fields_to_assets.rb b/db/migrate/20260720115941_add_credit_fields_to_assets.rb
new file mode 100644
index 0000000..638d894
--- /dev/null
+++ b/db/migrate/20260720115941_add_credit_fields_to_assets.rb
@@ -0,0 +1,7 @@
1class AddCreditFieldsToAssets < ActiveRecord::Migration[8.1]
2 def change
3 add_column :assets, :license_key, :string
4 add_column :assets, :creator, :string
5 add_column :assets, :source_url, :string
6 end
7end
diff --git a/db/migrate/20260720120917_add_headline_to_related_assets.rb b/db/migrate/20260720120917_add_headline_to_related_assets.rb
new file mode 100644
index 0000000..c77e1ef
--- /dev/null
+++ b/db/migrate/20260720120917_add_headline_to_related_assets.rb
@@ -0,0 +1,7 @@
1class AddHeadlineToRelatedAssets < ActiveRecord::Migration[8.1]
2 def change
3 add_column :related_assets, :headline, :boolean, null: false, default: false
4 add_index :related_assets, :page_id, unique: true, where: "headline = true",
5 name: "index_related_assets_on_page_id_headline_uniqueness"
6 end
7end
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index 563dfad..9d4b048 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -16,4 +16,24 @@ namespace :assets do
16 puts "Regenerated variants for Asset##{asset.id} (#{asset.name})" 16 puts "Regenerated variants for Asset##{asset.id} (#{asset.name})"
17 end 17 end
18 end 18 end
19
20desc "One-shot: flag each live page's current first-by-position image as its headline"
21 task backfill_headline: :environment do
22 count = 0
23 page_ids = (Node.where.not(head_id: nil).pluck(:head_id) +
24 Node.where.not(draft_id: nil).pluck(:draft_id)).uniq
25
26 Page.where(id: page_ids).find_each do |page|
27 next if page.related_assets.where(headline: true).exists?
28
29 first_image = page.related_assets.joins(:asset).merge(Asset.images).order(:position).first
30 next unless first_image
31
32 first_image.update!(headline: true)
33 count += 1
34 end
35
36 puts "Flagged #{count} pages' current first image as headline."
37 end
38
19end 39end
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index e9f96c2..7fb1dc2 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -277,10 +277,12 @@ cccms = {
277 277
278 var items = $('#related_asset_list li').map(function() { 278 var items = $('#related_asset_list li').map(function() {
279 return { 279 return {
280 id: $(this).data('asset-id'),
280 thumb: $(this).find('img').attr('src'), 281 thumb: $(this).find('img').attr('src'),
281 large: $(this).data('large-url'), 282 large: $(this).data('large-url'),
282 original: $(this).data('original-url'), 283 original: $(this).data('original-url'),
283 name: $(this).data('name') 284 name: $(this).data('name'),
285 headline: $(this).data('headline') === true
284 }; 286 };
285 }).get(); 287 }).get();
286 cccms.inline_images.items = items; 288 cccms.inline_images.items = items;
@@ -293,7 +295,7 @@ cccms = {
293 items.forEach(function(item, i) { 295 items.forEach(function(item, i) {
294 var wrapper = $('<div class="inline_image_picker_item"></div>'); 296 var wrapper = $('<div class="inline_image_picker_item"></div>');
295 wrapper.append($('<img>').attr('src', item.thumb).attr('data-index', i)); 297 wrapper.append($('<img>').attr('src', item.thumb).attr('data-index', i));
296 if (i === 0) { 298 if (item.headline) {
297 wrapper.append($('<span>', { "class": "inline_image_picker_headline_badge" }).text("Headline")); 299 wrapper.append($('<span>', { "class": "inline_image_picker_headline_badge" }).text("Headline"));
298 } 300 }
299 grid.append(wrapper); 301 grid.append(wrapper);
@@ -317,8 +319,10 @@ cccms = {
317 ? 'inline-image inline-image--full' 319 ? 'inline-image inline-image--full'
318 : 'inline-image inline-image--half inline-image--' + placement; 320 : 'inline-image inline-image--half inline-image--' + placement;
319 321
320 var html = '<a href="' + item.original + '" class="glightbox" data-gallery="page-' + cccms.inline_images.node_id + '">' + 322 var esc = cccms.inline_images.escape_attr;
321 '<img src="' + item.large + '" class="' + classes + '" alt="' + cccms.inline_images.escape_attr(item.name) + '"></a>'; 323 var html = '<a href="' + esc(item.original) + '" class="glightbox" data-gallery="page-' + esc(cccms.inline_images.node_id) + '"' +
324 ' data-title="' + esc(item.name) + '" data-description="#credit_for_asset_' + esc(item.id) + '">' +
325 '<img src="' + esc(item.large) + '" class="' + classes + '" alt="' + esc(item.name) + '"></a>';
322 326
323 cccms.inline_images.editor.insertContent(html); 327 cccms.inline_images.editor.insertContent(html);
324 $('#inline_image_picker').hide(); 328 $('#inline_image_picker').hide();
diff --git a/public/javascripts/related_assets.js b/public/javascripts/related_assets.js
index 3340fa7..cc07d86 100644
--- a/public/javascripts/related_assets.js
+++ b/public/javascripts/related_assets.js
@@ -5,8 +5,6 @@ related_assets = {
5 var createUrl = container.data("create-url"); 5 var createUrl = container.data("create-url");
6 var list = $("#related_asset_list"); 6 var list = $("#related_asset_list");
7 7
8 related_assets.update_headline_badge(list);
9
10 initSearchPicker({ 8 initSearchPicker({
11 inputSelector: "#related_asset_search_term", 9 inputSelector: "#related_asset_search_term",
12 resultsSelector: "#related_asset_search_results", 10 resultsSelector: "#related_asset_search_results",
@@ -44,7 +42,6 @@ related_assets = {
44 url: ui.item.data("url"), 42 url: ui.item.data("url"),
45 data: "position=" + newPosition 43 data: "position=" + newPosition
46 }); 44 });
47 related_assets.update_headline_badge(list);
48 } 45 }
49 }); 46 });
50 47
@@ -55,21 +52,29 @@ related_assets = {
55 url: item.data("url"), 52 url: item.data("url"),
56 success: function() { 53 success: function() {
57 item.remove(); 54 item.remove();
58 related_assets.update_headline_badge(list);
59 } 55 }
60 }); 56 });
61 }); 57 });
62 },
63 58
64 update_headline_badge: function(list) { 59 list.on("click", ".related_asset_set_headline", function() {
65 list.find(".related_asset_headline_badge").remove(); 60 var button = $(this);
66 var first = list.children("li").first(); 61 var item = button.closest("li");
67 if (first.length) { 62 var makeHeadline = button.attr("aria-pressed") !== "true";
68 $("<span>", { 63
69 "class": "related_asset_headline_badge", 64 $.ajax({
70 "title": "Shown automatically as this page's headline image" 65 type: "PATCH",
71 }).text("Headline").insertBefore(first.find(".related_asset_remove")); 66 url: item.data("url"),
72 } 67 data: "headline=" + makeHeadline,
68 success: function() {
69 list.children("li").removeClass("is_headline")
70 .find(".related_asset_set_headline").attr("aria-pressed", "false");
71 if (makeHeadline) {
72 item.addClass("is_headline");
73 button.attr("aria-pressed", "true");
74 }
75 }
76 });
77 });
73 }, 78 },
74 79
75 attach: function(assetId, createUrl, list) { 80 attach: function(assetId, createUrl, list) {
@@ -81,13 +86,13 @@ related_assets = {
81 success: function(related) { 86 success: function(related) {
82 var item = $($("#related_asset_template").html().trim()); 87 var item = $($("#related_asset_template").html().trim());
83 item.attr("data-url", related.url); 88 item.attr("data-url", related.url);
89 item.attr("data-asset-id", related.asset_id);
84 item.attr("data-large-url", related.large_url); 90 item.attr("data-large-url", related.large_url);
85 item.attr("data-original-url", related.original_url); 91 item.attr("data-original-url", related.original_url);
86 item.attr("data-name", related.name); 92 item.attr("data-name", related.name);
87 item.find("img").attr("src", related.thumb_url); 93 item.find("img").attr("src", related.thumb_url);
88 list.append(item); 94 list.append(item);
89 list.sortable("refresh"); 95 list.sortable("refresh");
90 related_assets.update_headline_badge(list);
91 $("#related_asset_search_term").val(""); 96 $("#related_asset_search_term").val("");
92 $("#related_asset_search_results").slideUp().empty(); 97 $("#related_asset_search_results").slideUp().empty();
93 } 98 }
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 0e11747..b171800 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1327,15 +1327,6 @@ div#image_browser ul li {
1327 height: 1rem; 1327 height: 1rem;
1328} 1328}
1329 1329
1330.related_asset_headline_badge {
1331 background: #fff3cd;
1332 color: #6b5900;
1333 border-radius: 999px;
1334 padding: 0.1rem 0.6rem;
1335 font-size: 0.75em;
1336 white-space: nowrap;
1337}
1338
1339.related_asset_remove { 1330.related_asset_remove {
1340 display: flex; 1331 display: flex;
1341 align-items: center; 1332 align-items: center;
@@ -1372,6 +1363,32 @@ div#image_browser ul li {
1372 border-radius: 4px; 1363 border-radius: 4px;
1373} 1364}
1374 1365
1366.related_asset_set_headline {
1367 display: flex;
1368 align-items: center;
1369 justify-content: center;
1370 background: none;
1371 border: none;
1372 padding: 2px;
1373 cursor: pointer;
1374 color: #969696;
1375}
1376
1377.related_asset_set_headline svg {
1378 width: 0.9rem;
1379 height: 0.9rem;
1380}
1381
1382.related_asset_set_headline:hover,
1383.related_asset_set_headline[aria-pressed="true"] {
1384 color: #6b5900;
1385}
1386
1387.thumbnail_list li.is_headline {
1388 border-color: #fff3cd;
1389 background-color: #fffdf5;
1390}
1391
1375/* ============================================================ 1392/* ============================================================
1376 Live edit preview 1393 Live edit preview
1377 ============================================================ */ 1394 ============================================================ */
diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css
index a07cc4d..8358d06 100644
--- a/public/stylesheets/ccc.css
+++ b/public/stylesheets/ccc.css
@@ -747,3 +747,11 @@ div.teaser_ruler {
747 float: right; 747 float: right;
748 margin-left: 1rem; 748 margin-left: 1rem;
749} 749}
750
751#asset_credits .glightbox-desc {
752 display: none;
753}
754
755#asset_credits .headline_credit {
756 display: block;
757}
diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb
index 9d20cbb..2384adc 100644
--- a/test/controllers/related_assets_controller_test.rb
+++ b/test/controllers/related_assets_controller_test.rb
@@ -88,4 +88,50 @@ class RelatedAssetsControllerTest < ActionController::TestCase
88 ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id) 88 ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id)
89 assert_equal [second.id, first.id], ordered_asset_ids 89 assert_equal [second.id, first.id], ordered_asset_ids
90 end 90 end
91
92 test "update sets the headline flag" do
93 login_as :quentin
94 node = Node.root.children.create!(:slug => "related_assets_headline_test")
95 asset = Asset.create!(:name => "headline-photo", :upload_content_type => "image/png")
96 node.draft.assets << asset
97 related = node.draft.related_assets.find_by(:asset_id => asset.id)
98
99 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "true" }
100
101 assert_response :success
102 assert related.reload.headline?
103 end
104
105 test "update with headline=true clears any previous headline on the same page" do
106 login_as :quentin
107 node = Node.root.children.create!(:slug => "related_assets_headline_swap_test")
108 first = Asset.create!(:name => "first-headline", :upload_content_type => "image/png")
109 second = Asset.create!(:name => "second-headline", :upload_content_type => "image/png")
110 node.draft.assets << first
111 node.draft.assets << second
112
113 first_related = node.draft.related_assets.find_by(:asset_id => first.id)
114 second_related = node.draft.related_assets.find_by(:asset_id => second.id)
115 first_related.update!(:headline => true)
116
117 patch :update, params: { :node_id => node.id, :id => second_related.id, :headline => "true" }
118
119 assert_response :success
120 assert_not first_related.reload.headline?
121 assert second_related.reload.headline?
122 end
123
124 test "update with headline=false clears the headline" do
125 login_as :quentin
126 node = Node.root.children.create!(:slug => "related_assets_headline_unset_test")
127 asset = Asset.create!(:name => "unset-headline", :upload_content_type => "image/png")
128 node.draft.assets << asset
129 related = node.draft.related_assets.find_by(:asset_id => asset.id)
130 related.update!(:headline => true)
131
132 patch :update, params: { :node_id => node.id, :id => related.id, :headline => "false" }
133
134 assert_response :success
135 assert_not related.reload.headline?
136 end
91end 137end
diff --git a/test/models/asset_license_test.rb b/test/models/asset_license_test.rb
new file mode 100644
index 0000000..9f2d04f
--- /dev/null
+++ b/test/models/asset_license_test.rb
@@ -0,0 +1,31 @@
1require 'test_helper'
2
3class AssetLicenseTest < ActiveSupport::TestCase
4 test "find returns a populated entry for a known key" do
5 entry = AssetLicense.find("cc_by_4")
6
7 assert_equal "cc_by_4", entry.key
8 assert_equal "https://creativecommons.org/licenses/by/4.0/", entry.url
9 assert entry.requires_attribution
10 assert_equal "license", entry.style
11 end
12
13 test "find returns nil for an unknown or blank key" do
14 assert_nil AssetLicense.find("not_a_real_license")
15 assert_nil AssetLicense.find(nil)
16 assert_nil AssetLicense.find("")
17 end
18
19 test "keys includes every dictionary entry" do
20 assert_includes AssetLicense.keys, "cc0"
21 assert_includes AssetLicense.keys, "own_work"
22 end
23
24 test "a note-style entry has no attribution requirement and no url" do
25 entry = AssetLicense.find("own_work")
26
27 assert_not entry.requires_attribution
28 assert_nil entry.url
29 assert_equal "note", entry.style
30 end
31end
diff --git a/test/models/asset_test.rb b/test/models/asset_test.rb
index a1041e4..d246abe 100644
--- a/test/models/asset_test.rb
+++ b/test/models/asset_test.rb
@@ -19,5 +19,26 @@ class AssetTest < ActiveSupport::TestCase
19 assert_equal 0, draft.assets.length 19 assert_equal 0, draft.assets.length
20 assert_equal 0, RelatedAsset.count 20 assert_equal 0, RelatedAsset.count
21 end 21 end
22 22
23 test "image? is true for supported image content types" do
24 assert Asset.new(:upload_content_type => "image/png").image?
25 assert Asset.new(:upload_content_type => "image/jpeg").image?
26 end
27
28 test "image? is false for non-image content types" do
29 assert_not Asset.new(:upload_content_type => "application/pdf").image?
30 assert_not Asset.new(:upload_content_type => nil).image?
31 end
32
33 test "license_key must be a known dictionary key" do
34 asset = Asset.new(:license_key => "not_a_real_license")
35 I18n.with_locale(:en) do
36 assert_not asset.valid?
37 assert_includes asset.errors[:license_key], "is not included in the list"
38 end
39 end
40
41 test "license_key may be blank" do
42 assert Asset.new(:license_key => nil).valid?
43 end
23end 44end
diff --git a/test/models/helpers/content_helper_test.rb b/test/models/helpers/content_helper_test.rb
index a7ed478..d6c7b43 100644
--- a/test/models/helpers/content_helper_test.rb
+++ b/test/models/helpers/content_helper_test.rb
@@ -2,7 +2,81 @@ require 'test_helper'
2 2
3class ContentHelperTest < ActionView::TestCase 3class ContentHelperTest < ActionView::TestCase
4 test "weekday_abbr delegates through the current I18n locale" do 4 test "weekday_abbr delegates through the current I18n locale" do
5 I18n.locale = :de 5 I18n.with_locale(:de) do
6 assert_equal "Mo", weekday_abbr(Time.parse("2026-07-06")) 6 assert_equal "Mo", weekday_abbr(Time.parse("2026-07-06"))
7 end
8 end
9
10 test "asset_credit returns nil for a nil asset" do
11 assert_nil asset_credit(nil)
12 end
13
14 test "asset_credit returns nil when creator, source, and license are all blank" do
15 assert_nil asset_credit(Asset.new(:name => "blank"))
16 end
17
18 test "asset_credit renders creator, source link, and license link when all are present" do
19 asset = Asset.new(:name => "demo", :creator => "Jane Doe",
20 :source_url => "https://example.org/photo", :license_key => "cc_by_4")
21
22 result = I18n.with_locale(:en) { asset_credit(asset) }
23
24 assert_match %r{<a href="https://example.org/photo">Photo demo</a>}, result
25 assert_match "by Jane Doe", result
26 assert_match %r{<a href="https://creativecommons.org/licenses/by/4.0/">Licensed under CC BY 4.0</a>}, result
27 end
28
29 test "asset_credit falls back to plain text when source_url is blank" do
30 asset = Asset.new(:name => "demo", :creator => "Jane Doe", :license_key => "cc_by_4")
31 assert_no_match %r{<a href="[^"]*">Photo demo</a>}, I18n.with_locale(:en) { asset_credit(asset) }
32 end
33
34 test "asset_credit omits the 'by' clause when creator is blank" do
35 asset = Asset.new(:name => "demo", :source_url => "https://example.org/photo", :license_key => "cc_by_4")
36 assert_no_match "by ", I18n.with_locale(:en) { asset_credit(asset) }
37 end
38
39 test "a note-style license renders with no 'Licensed under' prefix and no link" do
40 asset = Asset.new(:name => "demo", :creator => "CCC", :license_key => "own_work")
41 result = I18n.with_locale(:en) { asset_credit(asset) }
42
43 assert_match "Own work", result
44 assert_no_match "Licensed under", result
45 assert_no_match "<a", result
46 end
47
48 test "asset_credit degrades gracefully when the license_key is no longer in the dictionary" do
49 asset = Asset.new(:name => "demo", :creator => "Jane Doe")
50 asset.license_key = "a_retired_key"
51
52 result = I18n.with_locale(:en) { asset_credit(asset) }
53
54 assert_match "Photo demo by Jane Doe", result
55 assert_no_match "Licensed under", result
56 end
57
58 test "headline_image renders nothing when no images are attached" do
59 node = Node.root.children.create!(:slug => "headline_image_empty_test")
60 @page = node.draft
61 assert_nil headline_image
62 end
63
64 test "headline_image renders the flagged headline asset" do
65 node = Node.root.children.create!(:slug => "headline_image_flagged_test")
66 asset = Asset.create!(:name => "flagged", :upload_content_type => "image/png")
67 node.draft.assets << asset
68 node.draft.related_assets.find_by(:asset_id => asset.id).update!(:headline => true)
69 @page = node.draft
70
71 assert_match "glightbox", headline_image
72 end
73
74 test "headline_image falls back to a gallery-open caption when no headline is flagged" do
75 node = Node.root.children.create!(:slug => "headline_image_no_flag_test")
76 asset = Asset.create!(:name => "unflagged", :upload_content_type => "image/png")
77 node.draft.assets << asset
78 @page = node.draft
79
80 I18n.with_locale(:en) { assert_match t(:open_gallery), headline_image }
7 end 81 end
8end 82end
diff --git a/test/models/related_asset_test.rb b/test/models/related_asset_test.rb
index a739e6b..710b4cc 100644
--- a/test/models/related_asset_test.rb
+++ b/test/models/related_asset_test.rb
@@ -1,8 +1,44 @@
1require 'test_helper' 1require 'test_helper'
2 2
3class RelatedImageTest < ActiveSupport::TestCase 3class RelatedAssetTest < ActiveSupport::TestCase
4 # Replace this with your real tests. 4 test "headline can be set on an image asset" do
5 test "the truth" do 5 node = Node.root.children.create!(:slug => "related_asset_headline_image_test")
6 assert true 6 asset = Asset.create!(:name => "photo", :upload_content_type => "image/png")
7 node.draft.assets << asset
8 related = node.draft.related_assets.find_by(:asset_id => asset.id)
9
10 related.headline = true
11 assert related.valid?
12 end
13
14 test "headline cannot be set on a non-image asset" do
15 node = Node.root.children.create!(:slug => "related_asset_headline_pdf_test")
16 asset = Asset.create!(:name => "programme", :upload_content_type => "application/pdf")
17 node.draft.assets << asset
18 related = node.draft.related_assets.find_by(:asset_id => asset.id)
19
20 related.headline = true
21 assert_not related.valid?
22 assert_includes related.errors[:headline], "can only be set on image assets"
23 end
24
25 test "the headline validation does not raise when asset is missing" do
26 related = RelatedAsset.new(:headline => true)
27 assert_not related.valid?
28 end
29
30 test "at most one headline per page is enforced at the database level" do
31 node = Node.root.children.create!(:slug => "related_asset_headline_uniqueness_test")
32 first = Asset.create!(:name => "first", :upload_content_type => "image/png")
33 second = Asset.create!(:name => "second", :upload_content_type => "image/png")
34 node.draft.assets << first
35 node.draft.assets << second
36
37 node.draft.related_assets.find_by(:asset_id => first.id).update!(:headline => true)
38 second_related = node.draft.related_assets.find_by(:asset_id => second.id)
39
40 assert_raises(ActiveRecord::RecordNotUnique) do
41 RelatedAsset.where(:id => second_related.id).update_all(:headline => true)
42 end
7 end 43 end
8end 44end