summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/assets_controller.rb2
-rw-r--r--app/controllers/pages_controller.rb7
-rw-r--r--app/controllers/related_assets_controller.rb13
-rw-r--r--app/controllers/shared_previews_controller.rb2
-rw-r--r--app/helpers/content_helper.rb33
-rw-r--r--app/models/asset.rb12
-rw-r--r--app/models/asset_license.rb15
-rw-r--r--app/models/concerns/file_attachment.rb8
-rw-r--r--app/models/node_action.rb8
-rw-r--r--app/models/page.rb22
-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.erb27
-rw-r--r--app/views/content/_asset_credits.html.erb10
-rw-r--r--app/views/content/_headline_image.html.erb39
-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/node_actions/_change_details.html.erb16
-rw-r--r--app/views/nodes/edit.html.erb15
-rw-r--r--app/views/nodes/show.html.erb10
-rw-r--r--config/asset_licenses.yml25
-rw-r--r--config/locales/de.yml14
-rw-r--r--config/locales/en.yml14
-rw-r--r--config/routes.rb1
-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.js99
-rw-r--r--public/javascripts/public.js21
-rw-r--r--public/javascripts/related_assets.js36
-rw-r--r--public/stylesheets/admin.css92
-rw-r--r--public/stylesheets/ccc.css30
-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/page_test.rb8
-rw-r--r--test/models/related_asset_test.rb44
44 files changed, 721 insertions, 238 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/pages_controller.rb b/app/controllers/pages_controller.rb
index 2d08dea..0b7e98f 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -15,11 +15,4 @@ class PagesController < ApplicationController
15 ) 15 )
16 end 16 end
17 end 17 end
18
19 def sort_images
20 page = Page.find(params[:id])
21 page.update_assets(params[:images])
22
23 head :ok
24 end
25end 18end
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb
index 5af73fc..2fefaf7 100644
--- a/app/controllers/related_assets_controller.rb
+++ b/app/controllers/related_assets_controller.rb
@@ -26,6 +26,7 @@ class RelatedAssetsController < ApplicationController
26 id: related.id, 26 id: related.id,
27 asset_id: asset.id, 27 asset_id: asset.id,
28 name: asset.name, 28 name: asset.name,
29 has_credit: asset.has_credit?,
29 thumb_url: asset.upload.url(:thumb), 30 thumb_url: asset.upload.url(:thumb),
30 large_url: asset.upload.url(:large), 31 large_url: asset.upload.url(:large),
31 original_url: asset.upload.url, 32 original_url: asset.upload.url,
@@ -39,7 +40,17 @@ class RelatedAssetsController < ApplicationController
39 end 40 end
40 41
41 def update 42 def update
42 @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) 43 related = @node.editable_page.related_assets.find(params[:id])
44
45 if params.key?(:headline)
46 RelatedAsset.transaction do
47 @node.editable_page.related_assets.update_all(headline: false)
48 related.update!(headline: true) if params[:headline] == "true"
49 end
50 else
51 related.insert_at(params[:position].to_i)
52 end
53
43 head :ok 54 head :ok
44 end 55 end
45 56
diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb
index f6fb45a..65f744d 100644
--- a/app/controllers/shared_previews_controller.rb
+++ b/app/controllers/shared_previews_controller.rb
@@ -8,7 +8,7 @@ class SharedPreviewsController < ApplicationController
8 is_draft = node.draft_id == @page.id 8 is_draft = node.draft_id == @page.id
9 9
10 currently_public = is_head && @page.public? 10 currently_public = is_head && @page.public?
11 superseded = !is_head && !is_draft 11 superseded = !is_head && !is_draft
12 12
13 if superseded || currently_public 13 if superseded || currently_public
14 redirect_to @page.public_link 14 redirect_to @page.public_link
diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb
index 5810966..b3c6bad 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,30 @@ module ContentHelper
154 ) 151 )
155 end 152 end
156 153
154 def asset_credit(asset)
155 return nil unless asset
156 return nil unless asset.has_credit?
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
176
177 def glightbox_data(image, title)
178 "title: #{title.to_s.tr(';', ',')};"
179 end
157end 180end
diff --git a/app/models/asset.rb b/app/models/asset.rb
index aca0ee8..4a295d8 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -1,12 +1,22 @@
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
18
19 def has_credit?
20 creator.present? || source_url.present? || license_key.present?
21 end
12end 22end
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/node_action.rb b/app/models/node_action.rb
index 13bd5ba..63a99ae 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -166,4 +166,12 @@ class NodeAction < ApplicationRecord
166 def subject_name 166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node" 167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end 168 end
169
170 def diff_link_params
171 prev = NodeAction.where(node_id: node_id, action: "publish")
172 .where("id < ?", id)
173 .order(id: :desc).first
174 return nil unless prev&.page && page
175 { start_revision: prev.page.revision, end_revision: page.revision }
176 end
169end 177end
diff --git a/app/models/page.rb b/app/models/page.rb
index f33d88d..c1498fc 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -220,7 +220,12 @@ class Page < ApplicationRecord
220 end 220 end
221 221
222 # Clone asset references 222 # Clone asset references
223 self.assets = page.assets 223 self.related_assets.delete_all
224 page.related_assets.each do |related|
225 self.related_assets.create!(:asset_id => related.asset_id,
226 :position => related.position,
227 :headline => related.headline)
228 end
224 229
225 self.save 230 self.save
226 end 231 end
@@ -314,21 +319,6 @@ class Page < ApplicationRecord
314 end 319 end
315 end 320 end
316 321
317 def update_assets image_ids
318
319 transaction do
320 self.related_assets.delete_all
321
322 if image_ids
323 image_ids.each_with_index do |id, index|
324 asset = Asset.find(id)
325 self.related_assets.create!(:asset_id => asset.id, :position => index+1)
326 end
327 end
328 end
329
330 end
331
332 # Installs (or re-installs) the trigger that keeps page_translations' 322 # Installs (or re-installs) the trigger that keeps page_translations'
333 # search_vector in sync. Idempotent, safe to call on every boot. 323 # search_vector in sync. Idempotent, safe to call on every boot.
334 # search_vector is populated by a raw Postgres trigger, not anything 324 # search_vector is populated by a raw Postgres trigger, not anything
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..e551e35 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -16,8 +16,10 @@
16 </div> 16 </div>
17 </div> 17 </div>
18 18
19 <div class="node_description">Thumbnail</div> 19 <% if @asset.has_variant?(:medium) %>
20 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div> 20 <div class="node_description">Thumbnail</div>
21 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
22 <% end %>
21 23
22 <div class="node_description">Public Path</div> 24 <div class="node_description">Public Path</div>
23 <div class="node_content"> 25 <div class="node_content">
@@ -30,6 +32,27 @@
30 </button> 32 </button>
31 </div> 33 </div>
32 34
35 <div class="node_description">Creator</div>
36 <div class="node_content"><%= @asset.creator.presence || "—" %></div>
37
38 <div class="node_description">Source</div>
39 <div class="node_content">
40 <% if @asset.source_url.present? %>
41 <%= link_to @asset.source_url, @asset.source_url %>
42 <% else %>
43 —
44 <% end %>
45 </div>
46
47 <div class="node_description">License</div>
48 <div class="node_content">
49 <% if (license = AssetLicense.find(@asset.license_key)) %>
50 <%= t("asset_licenses.#{license.key}") %>
51 <% else %>
52 —
53 <% end %>
54 </div>
55
33 <div class="node_description">Content Type</div> 56 <div class="node_description">Content Type</div>
34 <div class="node_content"><%= @asset.upload.content_type %></div> 57 <div class="node_content"><%= @asset.upload.content_type %></div>
35 58
diff --git a/app/views/content/_asset_credits.html.erb b/app/views/content/_asset_credits.html.erb
new file mode 100644
index 0000000..5959abd
--- /dev/null
+++ b/app/views/content/_asset_credits.html.erb
@@ -0,0 +1,10 @@
1<% if @page.assets.images.any? %>
2 <div id="asset_credits">
3 <% @page.assets.images.each do |image| %>
4 <div id="credit_for_asset_<%= image.id %>"
5 class="asset_credit_block<%= " headline_credit" if image == @headline_asset %>">
6 <%= asset_credit(image) %>
7 </div>
8 <% end %>
9 </div>
10<% end %>
diff --git a/app/views/content/_headline_image.html.erb b/app/views/content/_headline_image.html.erb
index 243be40..e7b821b 100644
--- a/app/views/content/_headline_image.html.erb
+++ b/app/views/content/_headline_image.html.erb
@@ -1,16 +1,31 @@
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}",
9 :glightbox => glightbox_data(@headline_asset, @headline_asset.name),
10 :"credit-selector" => (@headline_asset.has_credit? ? "#credit_for_asset_#{@headline_asset.id}" : nil) }
11 ) %>
12 <% if gallery_images.size > 1 %>
13 <div class="right"><%= "#{gallery_images.size} #{t(:images)}" %></div>
14 <% end %>
15<% elsif gallery_images.any? %>
16 <%= link_to "#{gallery_images.size} #{t(:images)}, #{t(:open_gallery)}",
17 gallery_images.first.upload.url,
18 :class => "glightbox right",
19 :data => { :gallery => "page-#{@page.node.id}",
20 :glightbox => glightbox_data(gallery_images.first, gallery_images.first.name),
21 :"credit-selector" => (gallery_images.first.has_credit? ? "#credit_for_asset_#{gallery_images.first.id}" : nil) } %>
12<% end %> 22<% end %>
13 23
14<% @images[1..-1].each do |image| %> 24<% gallery_images.each do |image| %>
15 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none", :data => { :gallery => "page-#{@page.node.id}" } %> 25 <% next if image == @headline_asset %>
26 <% next if !@headline_asset && image == gallery_images.first %>
27 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none",
28 :data => { :gallery => "page-#{@page.node.id}",
29 :glightbox => glightbox_data(image, image.name),
30 :"credit-selector" => (image.has_credit? ? "#credit_for_asset_#{image.id}" : nil) } %>
16<% end %> 31<% 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/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
index 2583e8b..066d0f3 100644
--- a/app/views/node_actions/_change_details.html.erb
+++ b/app/views/node_actions/_change_details.html.erb
@@ -5,9 +5,13 @@
5 <tr> 5 <tr>
6 <th><%= I18n.default_locale.to_s.upcase %></th> 6 <th><%= I18n.default_locale.to_s.upcase %></th>
7 <td> 7 <td>
8 <%= safe_join(default_items, ", ") %> 8 <%= safe_join(default_items, tag.br) %>
9 <% if action_entry.page && action_entry.node %> 9 <% if action_entry.page && action_entry.node %>
10 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %> 10 <% if (diff_params = action_entry.diff_link_params) %>
11 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params) %>
12 <% else %>
13 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
14 <% end %>
11 <% end %> 15 <% end %>
12 </td> 16 </td>
13 </tr> 17 </tr>
@@ -16,9 +20,13 @@
16 <tr> 20 <tr>
17 <th><%= locale.upcase %></th> 21 <th><%= locale.upcase %></th>
18 <td> 22 <td>
19 <%= safe_join(translation_changes(diff), ", ") %> 23 <%= safe_join(translation_changes(diff), tag.br) %>
20 <% if action_entry.page && action_entry.node %> 24 <% if action_entry.page && action_entry.node %>
21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %> 25 <% if (diff_params = action_entry.diff_link_params) %>
26 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params.merge(:locale => locale)) %>
27 <% else %>
28 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
29 <% end %>
22 <% end %> 30 <% end %>
23 </td> 31 </td>
24 </tr> 32 </tr>
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index 25f15c2..07b42d4 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -82,17 +82,27 @@
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-has-credit="<%= related.asset.has_credit? %>"
90 data-headline="<%= related.headline? %>"
91 class="<%= "is_headline" if related.headline? %>">
88 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 92 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
89 <%= image_tag related.asset.upload.url(:thumb) %> 93 <%= image_tag related.asset.upload.url(:thumb) %>
94 <button type="button" class="related_asset_set_headline"
95 aria-pressed="<%= related.headline? %>" aria-label="Set as headline image"
96 title="Use this photo as the page's headline image">
97 <%= icon("star", library: "tabler", "aria-hidden": true) %>
98 </button>
90 <button type="button" class="related_asset_remove" aria-label="Remove image"> 99 <button type="button" class="related_asset_remove" aria-label="Remove image">
91 <%= icon("x", library: "tabler", "aria-hidden": true) %> 100 <%= icon("x", library: "tabler", "aria-hidden": true) %>
92 </button> 101 </button>
93 </li> 102 </li>
94 <% end %> 103 <% end %>
95 </ul> 104 </ul>
105 <p class="field_hint">The starred photo becomes this page's headline image on the public site. If none is starred, visitors get a link to browse all attached images instead.</p>
96 <%= text_field_tag nil, nil, id: "related_asset_search_term", placeholder: "Search images to attach…", autocomplete: "off" %> 106 <%= text_field_tag nil, nil, id: "related_asset_search_term", placeholder: "Search images to attach…", autocomplete: "off" %>
97 <div id="related_asset_search_results" class="search_results"></div> 107 <div id="related_asset_search_results" class="search_results"></div>
98 </div> 108 </div>
@@ -102,6 +112,9 @@
102 <li> 112 <li>
103 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 113 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
104 <img src=""> 114 <img src="">
115 <button type="button" class="related_asset_set_headline" aria-pressed="false" aria-label="Set as headline image">
116 <%= icon("star", library: "tabler", "aria-hidden": true) %>
117 </button>
105 <button type="button" class="related_asset_remove" aria-label="Remove image"> 118 <button type="button" class="related_asset_remove" aria-label="Remove image">
106 <%= icon("x", library: "tabler", "aria-hidden": true) %> 119 <%= icon("x", library: "tabler", "aria-hidden": true) %>
107 </button> 120 </button>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index af05778..a7be3c6 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -254,11 +254,19 @@
254 </div> 254 </div>
255 255
256 <% if @page.assets.images.any? %> 256 <% if @page.assets.images.any? %>
257 <% headline_asset_id = @page.related_assets.find_by(headline: true)&.asset_id %>
257 <div class="node_description">Images</div> 258 <div class="node_description">Images</div>
258 <div class="node_content node_info_group"> 259 <div class="node_content node_info_group">
259 <ul class="thumbnail_list"> 260 <ul class="thumbnail_list">
260 <% @page.assets.images.each do |asset| %> 261 <% @page.assets.images.each do |asset| %>
261 <li><%= link_to image_tag(asset.upload.url(:thumb)), asset_path(asset) %></li> 262 <li class="<%= "is_headline" if asset.id == headline_asset_id %>">
263 <%= link_to image_tag(asset.upload.url(:thumb)), asset_path(asset) %>
264 <% if asset.id == headline_asset_id %>
265 <span class="headline_indicator" title="This page's headline image">
266 <%= icon("star", library: "tabler", "aria-hidden": true) %>
267 </span>
268 <% end %>
269 </li>
262 <% end %> 270 <% end %>
263 </ul> 271 </ul>
264 </div> 272 </div>
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/config/routes.rb b/config/routes.rb
index 6ddf48c..20602f7 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -30,7 +30,6 @@ Cccms::Application.routes.draw do
30 end 30 end
31 31
32 get 'pages/:id/preview', to: 'pages#preview', as: :preview_page 32 get 'pages/:id/preview', to: 'pages#preview', as: :preview_page
33 put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page
34 33
35 get 'preview/:token', to: 'shared_previews#show', as: :shared_preview 34 get 'preview/:token', to: 'shared_previews#show', as: :shared_preview
36 35
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..f6d915f 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -45,10 +45,6 @@ $(document).ready(function () {
45 menu_item_sorter.initialize(); 45 menu_item_sorter.initialize();
46 } 46 }
47 47
48 if ($("#metadata").length != 0) {
49 meta_data.initialize();
50 }
51
52 if ($("#parent_search_term").length != 0) { 48 if ($("#parent_search_term").length != 0) {
53 parent_search.initialize_search(); 49 parent_search.initialize_search();
54 } 50 }
@@ -110,14 +106,6 @@ $(document).ready(function () {
110}); 106});
111 107
112 108
113meta_data = {
114 initialize : function() {
115 document.getElementById("metadata_details").addEventListener("toggle", function() {
116 if (this.open) image_interface.initialize();
117 });
118 }
119};
120
121cccms = { 109cccms = {
122 setup_autosave : function() { 110 setup_autosave : function() {
123 111
@@ -277,10 +265,13 @@ cccms = {
277 265
278 var items = $('#related_asset_list li').map(function() { 266 var items = $('#related_asset_list li').map(function() {
279 return { 267 return {
268 id: $(this).data('asset-id'),
280 thumb: $(this).find('img').attr('src'), 269 thumb: $(this).find('img').attr('src'),
281 large: $(this).data('large-url'), 270 large: $(this).data('large-url'),
282 original: $(this).data('original-url'), 271 original: $(this).data('original-url'),
283 name: $(this).data('name') 272 name: $(this).data('name'),
273 hasCredit: $(this).data('has-credit') === true,
274 headline: $(this).data('headline') === true
284 }; 275 };
285 }).get(); 276 }).get();
286 cccms.inline_images.items = items; 277 cccms.inline_images.items = items;
@@ -293,7 +284,7 @@ cccms = {
293 items.forEach(function(item, i) { 284 items.forEach(function(item, i) {
294 var wrapper = $('<div class="inline_image_picker_item"></div>'); 285 var wrapper = $('<div class="inline_image_picker_item"></div>');
295 wrapper.append($('<img>').attr('src', item.thumb).attr('data-index', i)); 286 wrapper.append($('<img>').attr('src', item.thumb).attr('data-index', i));
296 if (i === 0) { 287 if (item.headline) {
297 wrapper.append($('<span>', { "class": "inline_image_picker_headline_badge" }).text("Headline")); 288 wrapper.append($('<span>', { "class": "inline_image_picker_headline_badge" }).text("Headline"));
298 } 289 }
299 grid.append(wrapper); 290 grid.append(wrapper);
@@ -317,8 +308,13 @@ cccms = {
317 ? 'inline-image inline-image--full' 308 ? 'inline-image inline-image--full'
318 : 'inline-image inline-image--half inline-image--' + placement; 309 : 'inline-image inline-image--half inline-image--' + placement;
319 310
320 var html = '<a href="' + item.original + '" class="glightbox" data-gallery="page-' + cccms.inline_images.node_id + '">' + 311 var esc = cccms.inline_images.escape_attr;
321 '<img src="' + item.large + '" class="' + classes + '" alt="' + cccms.inline_images.escape_attr(item.name) + '"></a>'; 312 var titleForGlightbox = (item.name || '').replace(/;/g, ',');
313 var glightboxOpts = 'title: ' + esc(titleForGlightbox) +
314 (item.hasCredit ? '; description: #credit_for_asset_' + esc(item.id) : '') + ';';
315 var html = '<a href="' + esc(item.original) + '" class="glightbox" data-gallery="page-' + esc(cccms.inline_images.node_id) + '"' +
316 ' data-glightbox="' + glightboxOpts + '">' +
317 '<img src="' + esc(item.large) + '" class="' + classes + '" alt="' + esc(item.name) + '"></a>';
322 318
323 cccms.inline_images.editor.insertContent(html); 319 cccms.inline_images.editor.insertContent(html);
324 $('#inline_image_picker').hide(); 320 $('#inline_image_picker').hide();
@@ -356,77 +352,6 @@ menu_item_sorter = {
356 } 352 }
357} 353}
358 354
359image_interface = {
360
361 initialize : function() {
362
363 $("#image_browser").hide();
364 image_interface.initialize_sortable_image_box();
365 image_interface.connect_browser_and_box();
366 image_interface.set_droppable_behavior();
367 image_interface.bind_image_browser_toggle();
368 },
369
370
371 set_droppable_behavior : function() {
372 $("ul#image_box").droppable({
373 out : function(event, ui) {
374 $(ui.draggable).fadeTo("fast", 0.4);
375
376 $(ui.draggable).bind("mouseup", function() {
377 $(this).remove();
378 });
379 },
380 over : function(event, ui) {
381 $(ui.draggable).fadeTo("fast", 1.0);
382 $(ui.draggable).unbind("mouseup");
383 }
384 });
385 },
386
387 connect_browser_and_box : function() {
388 $("#image_browser ul li").draggable({
389 connectToSortable : 'ul#image_box',
390 helper : 'clone',
391 revert : 'invalid'
392 });
393 },
394
395 initialize_sortable_image_box : function() {
396
397 $("ul#image_box").sortable({
398 revert : true,
399 update : function(event, ui) {
400 images = $("ul#image_box").sortable("serialize", {attribute : "rel"});
401
402 $.ajax({
403 type : "POST",
404 url : "/pages/" + $("ul#image_box").attr("rel") + "/sort_images",
405 dataType : "json",
406 data : images + "&_method=put",
407 success : function() {
408 }
409 });
410 }
411 });
412 },
413
414 bind_image_browser_toggle : function() {
415 $("#image_browser_toggle").bind("click", function(){
416 if ($("#image_browser_toggle").attr("class") == "unselected") {
417 $("#image_browser_toggle").attr("class", "selected");
418 $("#image_browser").show();
419 }
420 else {
421 $("#image_browser_toggle").attr("class", "unselected");
422 $("#image_browser").hide();
423 }
424
425 return false;
426 });
427 }
428}
429
430rrule_builder = { 355rrule_builder = {
431 initialize : function() { 356 initialize : function() {
432 rrule_builder.try_populate_from_rrule($("#event_rrule").val()); 357 rrule_builder.try_populate_from_rrule($("#event_rrule").val());
diff --git a/public/javascripts/public.js b/public/javascripts/public.js
index a9250ca..7f38f66 100644
--- a/public/javascripts/public.js
+++ b/public/javascripts/public.js
@@ -1,6 +1,25 @@
1document.addEventListener('DOMContentLoaded', function(){ 1document.addEventListener('DOMContentLoaded', function(){
2 2
3 GLightbox({ selector: '.glightbox' }); 3 GLightbox({
4 selector: '.glightbox',
5 afterSlideLoad: function(slideData) {
6 var trigger = document.querySelectorAll('.glightbox')[slideData.index];
7 var selector = trigger && trigger.dataset.creditSelector;
8 if (!selector) return;
9
10 var source = document.querySelector(selector);
11 var container = slideData.slide.querySelector('.gdesc-inner');
12 if (!source || !container) return;
13
14 var target = container.querySelector('.gslide-desc');
15 if (!target) {
16 target = document.createElement('div');
17 target.className = 'gslide-desc';
18 container.appendChild(target);
19 }
20 target.innerHTML = source.innerHTML;
21 }
22 });
4 23
5 document.getElementById("light-mode").addEventListener("change", () => { 24 document.getElementById("light-mode").addEventListener("change", () => {
6 if (document.getElementById("light-mode").checked) 25 if (document.getElementById("light-mode").checked)
diff --git a/public/javascripts/related_assets.js b/public/javascripts/related_assets.js
index 3340fa7..c6c6a19 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,14 @@ 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);
90 item.attr("data-has-credit", related.has_credit);
84 item.attr("data-large-url", related.large_url); 91 item.attr("data-large-url", related.large_url);
85 item.attr("data-original-url", related.original_url); 92 item.attr("data-original-url", related.original_url);
86 item.attr("data-name", related.name); 93 item.attr("data-name", related.name);
87 item.find("img").attr("src", related.thumb_url); 94 item.find("img").attr("src", related.thumb_url);
88 list.append(item); 95 list.append(item);
89 list.sortable("refresh"); 96 list.sortable("refresh");
90 related_assets.update_headline_badge(list);
91 $("#related_asset_search_term").val(""); 97 $("#related_asset_search_term").val("");
92 $("#related_asset_search_results").slideUp().empty(); 98 $("#related_asset_search_results").slideUp().empty();
93 } 99 }
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 0e11747..ef6cb11 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1042,10 +1042,6 @@ div#page_editor {
1042 select { 1042 select {
1043 font-size: 1.5rem; 1043 font-size: 1.5rem;
1044 } 1044 }
1045
1046 #metadata ul#image_box {
1047 width: 100% !important;
1048 }
1049} 1045}
1050 1046
1051/* ============================================================ 1047/* ============================================================
@@ -1264,31 +1260,6 @@ div#draft_list table td.actions a {
1264 Image box / browser 1260 Image box / browser
1265 ============================================================ */ 1261 ============================================================ */
1266 1262
1267#metadata ul#image_box {
1268 box-sizing: border-box;
1269 margin: 0;
1270 padding: 10px 5px 10px 5px;
1271 height: 100px;
1272 width: 702px;
1273 border: 1px solid #989898;
1274}
1275
1276#metadata ul#image_box li {
1277 float: left;
1278 list-style-type: none;
1279 margin: 5px;
1280}
1281
1282div#image_browser {
1283 position: absolute;
1284 top: 40px;
1285 left: 800px;
1286}
1287
1288div#image_browser ul li {
1289 list-style-type: none;
1290}
1291
1292.thumbnail_list { 1263.thumbnail_list {
1293 display: flex; 1264 display: flex;
1294 flex-wrap: wrap; 1265 flex-wrap: wrap;
@@ -1316,6 +1287,30 @@ div#image_browser ul li {
1316 border-radius: 4px; 1287 border-radius: 4px;
1317} 1288}
1318 1289
1290.thumbnail_list li {
1291 position: relative;
1292}
1293
1294.thumbnail_list li .headline_indicator {
1295 position: absolute;
1296 top: 4px;
1297 right: 4px;
1298 width: 1.4rem;
1299 height: 1.4rem;
1300 display: flex;
1301 align-items: center;
1302 justify-content: center;
1303 border-radius: 50%;
1304 background: rgba(255, 255, 255, 0.85);
1305 color: #f5b400;
1306}
1307
1308.thumbnail_list li .headline_indicator svg {
1309 width: 0.9rem;
1310 height: 0.9rem;
1311 fill: currentColor;
1312}
1313
1319.related_asset_handle { 1314.related_asset_handle {
1320 display: flex; 1315 display: flex;
1321 color: #969696; 1316 color: #969696;
@@ -1327,15 +1322,6 @@ div#image_browser ul li {
1327 height: 1rem; 1322 height: 1rem;
1328} 1323}
1329 1324
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 { 1325.related_asset_remove {
1340 display: flex; 1326 display: flex;
1341 align-items: center; 1327 align-items: center;
@@ -1372,6 +1358,36 @@ div#image_browser ul li {
1372 border-radius: 4px; 1358 border-radius: 4px;
1373} 1359}
1374 1360
1361.related_asset_set_headline {
1362 display: flex;
1363 align-items: center;
1364 justify-content: center;
1365 background: none;
1366 border: none;
1367 padding: 2px;
1368 cursor: pointer;
1369 color: #969696;
1370}
1371
1372.related_asset_set_headline svg {
1373 width: 0.9rem;
1374 height: 0.9rem;
1375}
1376
1377.related_asset_set_headline:hover,
1378.related_asset_set_headline[aria-pressed="true"] {
1379 color: #f5b400;
1380}
1381
1382.related_asset_set_headline[aria-pressed="true"] svg {
1383 fill: currentColor;
1384}
1385
1386.thumbnail_list li.is_headline {
1387 border-color: #fff3cd;
1388 background-color: #fffdf5;
1389}
1390
1375/* ============================================================ 1391/* ============================================================
1376 Live edit preview 1392 Live edit preview
1377 ============================================================ */ 1393 ============================================================ */
diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css
index a07cc4d..9654594 100644
--- a/public/stylesheets/ccc.css
+++ b/public/stylesheets/ccc.css
@@ -727,7 +727,6 @@ div.teaser_ruler {
727 min-width: 0; 727 min-width: 0;
728} 728}
729 729
730
731.inline-image--full { 730.inline-image--full {
732 width: 100%; 731 width: 100%;
733 margin: 1rem 0; 732 margin: 1rem 0;
@@ -747,3 +746,32 @@ div.teaser_ruler {
747 float: right; 746 float: right;
748 margin-left: 1rem; 747 margin-left: 1rem;
749} 748}
749
750#asset_credits .asset_credit_block {
751 display: none;
752}
753
754#asset_credits .asset_credit_block.headline_credit {
755 display: block;
756}
757
758#asset_credits {
759 border-top: dotted 1px silver;
760}
761
762#asset_credits,
763.right {
764 font-style: italic;
765 font-family: Georgia;
766 font-size: 0.9rem;
767 color: color-mix(in srgb, CanvasText, #808080);
768}
769
770.glightbox-clean .gslide-description {
771 background: Canvas !important;
772}
773
774.glightbox-clean .gslide-title,
775.glightbox-clean .gslide-desc {
776 color: CanvasText !important;
777}
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/page_test.rb b/test/models/page_test.rb
index 1f924f9..98a00d2 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -309,11 +309,15 @@ class PageTest < ActiveSupport::TestCase
309 309
310 kept_asset = Asset.create!(:upload_file_name => "kept.png", :upload_content_type => "image/png", :upload_file_size => 1) 310 kept_asset = Asset.create!(:upload_file_name => "kept.png", :upload_content_type => "image/png", :upload_file_size => 1)
311 removed_asset = Asset.create!(:upload_file_name => "removed.pdf", :upload_content_type => "application/pdf", :upload_file_size => 1) 311 removed_asset = Asset.create!(:upload_file_name => "removed.pdf", :upload_content_type => "application/pdf", :upload_file_size => 1)
312 n.head.update_assets([kept_asset.id, removed_asset.id]) 312 n.head.related_assets.delete_all
313 n.head.related_assets.create!(:asset_id => kept_asset.id, :position => 1)
314 n.head.related_assets.create!(:asset_id => removed_asset.id, :position => 2)
313 315
314 d2 = find_or_create_draft(n, @user1) 316 d2 = find_or_create_draft(n, @user1)
315 added_asset = Asset.create!(:upload_file_name => "added.png", :upload_content_type => "image/png", :upload_file_size => 1) 317 added_asset = Asset.create!(:upload_file_name => "added.png", :upload_content_type => "image/png", :upload_file_size => 1)
316 d2.update_assets([kept_asset.id, added_asset.id]) 318 d2.related_assets.delete_all
319 d2.related_assets.create!(:asset_id => kept_asset.id, :position => 1)
320 d2.related_assets.create!(:asset_id => added_asset.id, :position => 2)
317 d2.save! 321 d2.save!
318 322
319 diff = d2.diff_against(n.head) 323 diff = d2.diff_against(n.head)
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