summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/assets_controller.rb22
-rw-r--r--app/controllers/nodes_controller.rb4
-rw-r--r--app/controllers/pages_controller.rb20
-rw-r--r--app/controllers/related_assets_controller.rb5
-rw-r--r--app/controllers/shared_previews_controller.rb2
-rw-r--r--app/helpers/content_helper.rb10
-rw-r--r--app/models/asset.rb16
-rw-r--r--app/models/concerns/file_attachment.rb44
-rw-r--r--app/models/node.rb66
-rw-r--r--app/models/page.rb26
-rw-r--r--app/models/related_asset.rb2
-rw-r--r--app/views/assets/edit.html.erb17
-rw-r--r--app/views/assets/new.html.erb17
-rw-r--r--app/views/assets/show.html.erb1
-rw-r--r--app/views/content/_asset_credits.html.erb6
-rw-r--r--app/views/content/_headline_image.html.erb53
-rw-r--r--app/views/custom/partials/_chapter.html.erb4
-rw-r--r--app/views/layouts/_flash.html.erb24
-rw-r--r--app/views/layouts/admin.html.erb17
-rw-r--r--app/views/nodes/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/edit.html.erb132
-rw-r--r--app/views/nodes/recent.html.erb7
-rw-r--r--app/views/nodes/show.html.erb39
-rw-r--r--config/locales/de.yml2
-rw-r--r--config/locales/en.yml2
-rw-r--r--config/routes.rb2
-rw-r--r--doc/DESIGN_HISTORY.md36
-rw-r--r--lib/tasks/assets.rake6
-rw-r--r--public/javascripts/admin_interface.js101
-rw-r--r--public/javascripts/admin_search.js17
-rw-r--r--public/javascripts/public.js21
-rw-r--r--public/javascripts/related_assets.js1
-rw-r--r--public/stylesheets/admin.css133
-rw-r--r--public/stylesheets/ccc.css83
-rw-r--r--test/controllers/assets_controller_test.rb32
-rw-r--r--test/controllers/nodes_controller_test.rb6
-rw-r--r--test/controllers/pages_controller_test.rb69
-rw-r--r--test/controllers/related_assets_controller_test.rb25
-rw-r--r--test/models/asset_test.rb12
-rw-r--r--test/models/helpers/content_helper_test.rb46
-rw-r--r--test/models/node_attach_asset_test.rb107
-rw-r--r--test/models/node_test.rb35
-rw-r--r--test/models/page_test.rb8
-rw-r--r--test/models/related_asset_test.rb16
44 files changed, 910 insertions, 393 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index becfe13..fbede0a 100644
--- a/app/controllers/assets_controller.rb
+++ b/app/controllers/assets_controller.rb
@@ -28,6 +28,7 @@ class AssetsController < ApplicationController
28 # GET /assets/new.xml 28 # GET /assets/new.xml
29 def new 29 def new
30 @asset = Asset.new 30 @asset = Asset.new
31 @attach_node = Node.not_in_trash.find_by(:id => params[:node_id]) if params[:node_id].present?
31 32
32 respond_to do |format| 33 respond_to do |format|
33 format.html # new.html.erb 34 format.html # new.html.erb
@@ -44,10 +45,12 @@ class AssetsController < ApplicationController
44 # POST /assets.xml 45 # POST /assets.xml
45 def create 46 def create
46 @asset = Asset.new(asset_params) 47 @asset = Asset.new(asset_params)
48 attach_node = Node.not_in_trash.find_by(:id => params[:node_id]) if params[:node_id].present?
47 49
48 respond_to do |format| 50 respond_to do |format|
49 if @asset.save 51 if @asset.save
50 flash[:notice] = 'Asset was successfully created.' 52 flash[:notice] = 'Asset was successfully created.'
53 attach_to(attach_node) if attach_node
51 format.html { redirect_to(@asset) } 54 format.html { redirect_to(@asset) }
52 format.xml { render :xml => @asset, :status => :created, :location => @asset } 55 format.xml { render :xml => @asset, :status => :created, :location => @asset }
53 else 56 else
@@ -91,4 +94,23 @@ class AssetsController < ApplicationController
91 def asset_params 94 def asset_params
92 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key) 95 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key)
93 end 96 end
97
98 def attach_to node
99 result = node.attach_asset!(@asset, :user => current_user,
100 :headline => params[:headline].present?)
101 flash[:notice] =
102 if result[:attached].zero?
103 "Asset saved — it was already attached to “#{node.title}”."
104 else
105 "Asset was successfully created and attached to “#{node.title}”."
106 end
107 case result[:headline]
108 when :set then flash[:notice] += " It is now the page's headline."
109 when :kept_existing then flash[:headline_kept_path] = node_path(node)
110 when :not_eligible then flash[:error] = "This asset type cannot be a headline."
111 end
112 rescue LockedByAnotherUser
113 flash[:locked_by] = node.lock_owner&.login
114 flash[:locked_node_path] = node_path(node)
115 end
94end 116end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 6caa827..9c84420 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -223,10 +223,6 @@ class NodesController < ApplicationController
223 @nodes = index_matching(Node.drafts_and_autosaves) 223 @nodes = index_matching(Node.drafts_and_autosaves)
224 end 224 end
225 225
226 def recent
227 @nodes = index_matching(Node.recently_changed)
228 end
229
230 def mine 226 def mine
231 base = Node.joins(:pages) 227 base = Node.joins(:pages)
232 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) 228 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user)
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 2d08dea..326fbd4 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -6,6 +6,19 @@ class PagesController < ApplicationController
6 6
7 def preview 7 def preview
8 @page = Page.find(params[:id]) 8 @page = Page.find(params[:id])
9 unless @page.node
10 node = Node.find_by(autosave_id: @page.id) ||
11 Node.find_by(draft_id: @page.id) ||
12 Node.find_by(head_id: @page.id)
13 @page.node = node if node
14 end
15
16 node ||= @page.node
17 if node && node.draft_id == @page.id && node.autosave
18 @page = node.autosave
19 @page.node = node
20 end
21
9 22
10 if @page 23 if @page
11 template = @page.valid_template 24 template = @page.valid_template
@@ -15,11 +28,4 @@ class PagesController < ApplicationController
15 ) 28 )
16 end 29 end
17 end 30 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 31end
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb
index da82cde..ca894f2 100644
--- a/app/controllers/related_assets_controller.rb
+++ b/app/controllers/related_assets_controller.rb
@@ -5,10 +5,10 @@ class RelatedAssetsController < ApplicationController
5 def search 5 def search
6 term = params[:search_term].to_s.strip 6 term = params[:search_term].to_s.strip
7 attached_ids = @node.editable_page.related_assets.pluck(:asset_id) 7 attached_ids = @node.editable_page.related_assets.pluck(:asset_id)
8 scope = Asset.images.where.not(id: attached_ids) 8 scope = Asset.headline_eligible.where.not(id: attached_ids)
9 9
10 results = if term.present? 10 results = if term.present?
11 scope.where("name ILIKE ?", "%#{term}%").limit(10) 11 scope.where("name ILIKE :term OR upload_file_name ILIKE :term", term: "%#{term}%").limit(10)
12 else 12 else
13 scope.order(created_at: :desc).limit(5) 13 scope.order(created_at: :desc).limit(5)
14 end 14 end
@@ -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.show_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,
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 5b67259..7a52d9c 100644
--- a/app/helpers/content_helper.rb
+++ b/app/helpers/content_helper.rb
@@ -53,8 +53,8 @@ module ContentHelper
53 end 53 end
54 54
55 def headline_image 55 def headline_image
56 @headline_asset = @page.related_assets.find_by(headline: true)&.asset 56 @headline_asset = @page.headline_asset
57 render :partial => 'content/headline_image' if @headline_asset || @page.assets.images.any? 57 render :partial => 'content/headline_image' if @headline_asset || @page.assets.images.any? || @page.assets.pdfs.any?
58 end 58 end
59 59
60 # 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
@@ -153,7 +153,7 @@ module ContentHelper
153 153
154 def asset_credit(asset) 154 def asset_credit(asset)
155 return nil unless asset 155 return nil unless asset
156 return nil if asset.creator.blank? && asset.source_url.blank? && asset.license_key.blank? 156 return nil unless asset.has_credit?
157 157
158 license = AssetLicense.find(asset.license_key) 158 license = AssetLicense.find(asset.license_key)
159 159
@@ -173,4 +173,8 @@ module ContentHelper
173 full = license_text ? safe_join([attribution, license_text], ", ") : attribution 173 full = license_text ? safe_join([attribution, license_text], ", ") : attribution
174 safe_join([full, "."]) 174 safe_join([full, "."])
175 end 175 end
176
177 def glightbox_data(image, title)
178 "title: #{title.to_s.tr(';', ',')};"
179 end
176end 180end
diff --git a/app/models/asset.rb b/app/models/asset.rb
index 8bea1b3..ba9c2f0 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -1,5 +1,6 @@
1class Asset < ApplicationRecord 1class Asset < ApplicationRecord
2 IMAGE_CONTENT_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"] 2 IMAGE_CONTENT_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]
3 PDF_CONTENT_TYPE = "application/pdf"
3 4
4 include FileAttachment 5 include FileAttachment
5 6
@@ -9,10 +10,25 @@ class Asset < ApplicationRecord
9 scope :images, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES) } 10 scope :images, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES) }
10 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) } 11 scope :documents, -> { where(:upload_content_type => ["application/pdf", "text/plain", "text/rtf"]) }
11 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) } 12 scope :audio, -> { where(:upload_content_type => ["audio/mpeg", "audio/x-m4a", "audio/wav", "audio/x-wav"]) }
13 scope :pdfs, -> { where(:upload_content_type => PDF_CONTENT_TYPE) }
14
15 scope :headline_eligible, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES + [PDF_CONTENT_TYPE]) }
12 16
13 validates :license_key, inclusion: { in: -> { AssetLicense.keys } }, allow_blank: true 17 validates :license_key, inclusion: { in: -> { AssetLicense.keys } }, allow_blank: true
14 18
15 def image? 19 def image?
16 IMAGE_CONTENT_TYPES.include?(upload_content_type) 20 IMAGE_CONTENT_TYPES.include?(upload_content_type)
17 end 21 end
22
23 def pdf?
24 upload_content_type == PDF_CONTENT_TYPE
25 end
26
27 def has_credit?
28 creator.present? || source_url.present? || license_key.present?
29 end
30
31 def show_credit?
32 image? && has_credit?
33 end
18end 34end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index e7a33c5..6221312 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -31,6 +31,7 @@ module FileAttachment
31 31
32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze 32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze
33 VECTOR_CONTENT_TYPES = %w[image/svg+xml].freeze 33 VECTOR_CONTENT_TYPES = %w[image/svg+xml].freeze
34 RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze
34 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES 35 DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES
35 36
36 included do 37 included do
@@ -60,29 +61,41 @@ module FileAttachment
60 has_variant?(:medium) 61 has_variant?(:medium)
61 end 62 end
62 63
64 def variant_filename(style)
65 return upload_file_name if style == :original || !RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
66 File.basename(upload_file_name, ".*") + ".png"
67 end
68
63 private 69 private
64 70
65 def build_upload_proxy 71 def build_upload_proxy
66 @upload = UploadProxy.new(self) 72 @upload = UploadProxy.new(self)
67 end 73 end
68 74
75 class_methods do
76 def upload_root
77 Rails.env.test? ? Rails.root.join("tmp", "test_uploads") : Rails.root.join("public", "system", "uploads")
78 end
79 end
80
81 def upload_root
82 self.class.upload_root
83 end
84
69 def process_upload 85 def process_upload
70 return unless @pending_upload 86 return unless @pending_upload
71 uploaded_file = @pending_upload 87 uploaded_file = @pending_upload
72 @pending_upload = nil 88 @pending_upload = nil
73 89
74 old_dir = Rails.root.join("public", "system", "uploads", id.to_s) 90 old_dir = upload_root.join(id.to_s)
91
75 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir) 92 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir)
76 93
77 original_path = file_path(:original) 94 original_path = file_path(:original)
78 FileUtils.mkdir_p(File.dirname(original_path)) 95 FileUtils.mkdir_p(File.dirname(original_path))
79 FileUtils.cp(uploaded_file.tempfile.path, original_path) 96 FileUtils.cp(uploaded_file.tempfile.path, original_path)
80 97
81 if IMAGE_CONTENT_TYPES.include?(upload_content_type) 98 generate_all_variants(original_path)
82 generate_variants(original_path)
83 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
84 generate_svg_variants(original_path)
85 end
86 end 99 end
87 100
88 def generate_variants(original_path) 101 def generate_variants(original_path)
@@ -101,16 +114,23 @@ module FileAttachment
101 end 114 end
102 end 115 end
103 116
117 def generate_all_variants(original_path)
118 if IMAGE_CONTENT_TYPES.include?(upload_content_type)
119 generate_variants(original_path)
120 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
121 generate_svg_variants(original_path)
122 elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
123 generate_variants("#{original_path}[0]")
124 end
125 end
126
104 def delete_upload_files 127 def delete_upload_files
105 dir = Rails.root.join("public", "system", "uploads", id.to_s) 128 dir = upload_root.join(id.to_s)
106 FileUtils.rm_rf(dir) if Dir.exist?(dir) 129 FileUtils.rm_rf(dir) if Dir.exist?(dir)
107 end 130 end
108 131
109 def file_path(style) 132 def file_path(style)
110 Rails.root.join( 133 upload_root.join(id.to_s, style.to_s, variant_filename(style)).to_s
111 "public", "system", "uploads",
112 id.to_s, style.to_s, upload_file_name
113 ).to_s
114 end 134 end
115 135
116 def sanitize_filename(filename) 136 def sanitize_filename(filename)
@@ -127,7 +147,7 @@ module FileAttachment
127 147
128 def url(style = :original) 148 def url(style = :original)
129 return "" if @record.upload_file_name.blank? 149 return "" if @record.upload_file_name.blank?
130 "/system/uploads/#{@record.id}/#{style}/#{@record.upload_file_name}" 150 "/system/uploads/#{@record.id}/#{style}/#{@record.variant_filename(style)}"
131 end 151 end
132 152
133 def content_type 153 def content_type
diff --git a/app/models/node.rb b/app/models/node.rb
index a440c2f..a5a40d3 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -442,8 +442,65 @@ class Node < ApplicationRecord
442 end 442 end
443 end 443 end
444 444
445 # Attaches an asset to every current lifecycle row -- head, draft and
446 # autosave -- that does not already carry it. Attachments are page-
447 # scoped content (RelatedAsset belongs_to :page; drafts and autosaves
448 # are wholesale clones), so attaching to a single layer is how an
449 # attachment gets silently lost when another layer replaces it at
450 # publish or save. This is the out-of-band counterpart to the
451 # in-editor attach UI; it refuses when someone else holds the editing
452 # lock. Attaching to a head row changes the public page immediately,
453 # by design -- same reasoning as formalizing an already-existing
454 # editorial link.
455 #
456 # headline is a node-level decision: the flag is set on the newly
457 # created joins only when no current row has a headline yet and the
458 # asset is eligible; otherwise the asset is attached plain and the
459 # result says why, so the caller can point the editor at the star in
460 # the editor instead.
461 #
462 # Returns { :attached => n, :already => n,
463 # :headline => nil | :set | :kept_existing | :not_eligible }
464 def attach_asset! asset, user:, headline: false
465 if in_trash? || trash_node?
466 raise ActiveRecord::RecordInvalid.new(self), "Cannot attach assets to a node in the Trash"
467 end
468
469 if lock_owner && lock_owner != user
470 raise(
471 LockedByAnotherUser,
472 "Page is locked by another user who is working on it! " \
473 "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
474 )
475 end
476
477 rows = [head, draft, autosave].compact
478 to_attach = rows.reject { |row| row.related_assets.exists?(:asset_id => asset.id) }
479
480 headline_state =
481 if headline && to_attach.any?
482 if !(asset.image? || asset.pdf?)
483 :not_eligible
484 elsif rows.any? { |row| row.headline_asset.present? }
485 :kept_existing
486 else
487 :set
488 end
489 end
490
491 ActiveRecord::Base.transaction do
492 to_attach.each do |row|
493 row.related_assets.create!(:asset => asset, :headline => headline_state == :set)
494 end
495 end
496
497 { :attached => to_attach.size,
498 :already => rows.size - to_attach.size,
499 :headline => headline_state }
500 end
501
445 def title 502 def title
446 head ? head.title : draft.title 503 editable_page&.title
447 end 504 end
448 505
449 def update_unique_names? 506 def update_unique_names?
@@ -533,13 +590,6 @@ class Node < ApplicationRecord
533 throw :abort 590 throw :abort
534 end 591 end
535 592
536 def self.recently_changed
537 includes(:head).where(
538 "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL",
539 Time.now, Time.now - 14.days
540 ).order("pages.updated_at desc").references(:head)
541 end
542
543 protected 593 protected
544 def lock_for! current_user 594 def lock_for! current_user
545 self.lock_owner = current_user 595 self.lock_owner = current_user
diff --git a/app/models/page.rb b/app/models/page.rb
index f33d88d..817e3bd 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
@@ -287,6 +292,10 @@ class Page < ApplicationRecord
287 end 292 end
288 end 293 end
289 294
295 def headline_asset
296 related_assets.find_by(headline: true)&.asset
297 end
298
290 # Returns true if a page has translations where one of them is significantly 299 # Returns true if a page has translations where one of them is significantly
291 # older than the other. 300 # older than the other.
292 # Takes the I18n.default locale and a second :locale to test if the 301 # Takes the I18n.default locale and a second :locale to test if the
@@ -314,21 +323,6 @@ class Page < ApplicationRecord
314 end 323 end
315 end 324 end
316 325
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' 326 # Installs (or re-installs) the trigger that keeps page_translations'
333 # search_vector in sync. Idempotent, safe to call on every boot. 327 # search_vector in sync. Idempotent, safe to call on every boot.
334 # search_vector is populated by a raw Postgres trigger, not anything 328 # 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 62000cc..8f8d49c 100644
--- a/app/models/related_asset.rb
+++ b/app/models/related_asset.rb
@@ -12,6 +12,6 @@ class RelatedAsset < ApplicationRecord
12 12
13 def headline_only_for_images 13 def headline_only_for_images
14 return unless asset 14 return unless asset
15 errors.add(:headline, "can only be set on image assets") if headline? && !asset.image? 15 errors.add(:headline, "can only be set on image or PDF assets") if headline? && !(asset.image? || asset.pdf?)
16 end 16 end
17end 17end
diff --git a/app/views/assets/edit.html.erb b/app/views/assets/edit.html.erb
index 3ef8fea..0c1dd8f 100644
--- a/app/views/assets/edit.html.erb
+++ b/app/views/assets/edit.html.erb
@@ -31,6 +31,23 @@
31 ) %> 31 ) %>
32 </div> 32 </div>
33 33
34 <div class="node_description">attach to page</div>
35 <div class="node_content">
36 <div class="restore_picker">
37 <%= text_field_tag :asset_node_search_term, @attach_node&.title,
38 :placeholder => "Search for a page…", :autocomplete => "off" %>
39 <div id="asset_node_search_results" class="search_results" style="display: none"></div>
40 </div>
41 <%= hidden_field_tag :node_id, @attach_node&.id %>
42 <span class="field_hint">Optional — attaches this asset to that page, all pending versions included.</span>
43 </div>
44
45 <div class="node_description"></div>
46 <div class="node_content">
47 <label><%= check_box_tag :headline, "1" %> attach as the page's headline</label>
48 <span class="field_hint">Applies only if the page has no headline yet.</span>
49 </div>
50
34 <div class="node_description">Actions</div> 51 <div class="node_description">Actions</div>
35 <div class="node_content node_info_group"> 52 <div class="node_content node_info_group">
36 <div class="node_info_group_items"> 53 <div class="node_info_group_items">
diff --git a/app/views/assets/new.html.erb b/app/views/assets/new.html.erb
index 2cf8865..7bded94 100644
--- a/app/views/assets/new.html.erb
+++ b/app/views/assets/new.html.erb
@@ -26,6 +26,23 @@
26 ) %> 26 ) %>
27 </div> 27 </div>
28 28
29 <div class="node_description">attach to page</div>
30 <div class="node_content">
31 <div class="restore_picker">
32 <%= text_field_tag :asset_node_search_term, @attach_node&.title,
33 :placeholder => "Search for a page…", :autocomplete => "off" %>
34 <div id="asset_node_search_results" class="search_results" style="display: none"></div>
35 </div>
36 <%= hidden_field_tag :node_id, @attach_node&.id %>
37 <span class="field_hint">Optional — attaches this asset to that page, all pending versions included.</span>
38 </div>
39
40 <div class="node_description"></div>
41 <div class="node_content">
42 <label><%= check_box_tag :headline, "1" %> attach as the page's headline</label>
43 <span class="field_hint">Applies only if the page has no headline yet.</span>
44 </div>
45
29 <div class="node_description">Actions</div> 46 <div class="node_description">Actions</div>
30 <div class="node_content node_info_group"> 47 <div class="node_content node_info_group">
31 <div class="node_info_group_items"> 48 <div class="node_info_group_items">
diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb
index a0e4e46..e551e35 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -16,7 +16,6 @@
16 </div> 16 </div>
17 </div> 17 </div>
18 18
19 <div class="node_description">Thumbnail</div>
20 <% if @asset.has_variant?(:medium) %> 19 <% if @asset.has_variant?(:medium) %>
21 <div class="node_description">Thumbnail</div> 20 <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> 21 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
diff --git a/app/views/content/_asset_credits.html.erb b/app/views/content/_asset_credits.html.erb
index 85739d0..5959abd 100644
--- a/app/views/content/_asset_credits.html.erb
+++ b/app/views/content/_asset_credits.html.erb
@@ -1,11 +1,9 @@
1<% if @page.assets.images.any? %> 1<% if @page.assets.images.any? %>
2 <div id="asset_credits"> 2 <div id="asset_credits">
3 <% @page.assets.images.each do |image| %> 3 <% @page.assets.images.each do |image| %>
4 <% credit = asset_credit(image) %>
5 <% next unless credit %>
6 <div id="credit_for_asset_<%= image.id %>" 4 <div id="credit_for_asset_<%= image.id %>"
7 class="glightbox-desc<%= " headline_credit" if image == @headline_asset %>"> 5 class="asset_credit_block<%= " headline_credit" if image == @headline_asset %>">
8 <%= credit %> 6 <%= asset_credit(image) %>
9 </div> 7 </div>
10 <% end %> 8 <% end %>
11 </div> 9 </div>
diff --git a/app/views/content/_headline_image.html.erb b/app/views/content/_headline_image.html.erb
index c764d22..4a3dfdc 100644
--- a/app/views/content/_headline_image.html.erb
+++ b/app/views/content/_headline_image.html.erb
@@ -1,12 +1,25 @@
1<% gallery_images = @page.assets.images %> 1<% gallery_images = @page.assets.images %>
2<% image_headline = @headline_asset if @headline_asset&.image? %>
3<% other_pdfs = @page.assets.pdfs.where.not(id: @headline_asset&.id) %>
2 4
3<% if @headline_asset %> 5<% if @headline_asset&.pdf? %>
6 <%= link_to @headline_asset.upload.url, :class => "headline_document_card", :target => "_blank", :rel => "noopener" do %>
7 <%= image_tag @headline_asset.upload.url(:medium), :alt => "", :class => "headline_document_card_thumb" %>
8 <div class="headline_document_card_info">
9 <%= icon("file-text", library: "tabler", "aria-hidden": true) %>
10 <span class="headline_document_card_title"><%= @headline_asset.name %></span>
11 </div>
12 <% end %>
13<% end %>
14
15<% if image_headline %>
4 <%= link_to( 16 <%= link_to(
5 image_tag(@headline_asset.upload.url(:headline)), 17 image_tag(image_headline.upload.url(:headline)),
6 @headline_asset.upload.url, 18 image_headline.upload.url,
7 :class => "glightbox", 19 :class => "glightbox",
8 :data => { :gallery => "page-#{@page.node.id}", :title => @headline_asset.name, 20 :data => { :gallery => "page-#{@page.node.id}",
9 :description => "#credit_for_asset_#{@headline_asset.id}" } 21 :glightbox => glightbox_data(image_headline, image_headline.name),
22 :"credit-selector" => (image_headline.show_credit? ? "#credit_for_asset_#{image_headline.id}" : nil) }
10 ) %> 23 ) %>
11 <% if gallery_images.size > 1 %> 24 <% if gallery_images.size > 1 %>
12 <div class="right"><%= "#{gallery_images.size} #{t(:images)}" %></div> 25 <div class="right"><%= "#{gallery_images.size} #{t(:images)}" %></div>
@@ -15,14 +28,32 @@
15 <%= link_to "#{gallery_images.size} #{t(:images)}, #{t(:open_gallery)}", 28 <%= link_to "#{gallery_images.size} #{t(:images)}, #{t(:open_gallery)}",
16 gallery_images.first.upload.url, 29 gallery_images.first.upload.url,
17 :class => "glightbox right", 30 :class => "glightbox right",
18 :data => { :gallery => "page-#{@page.node.id}", :title => gallery_images.first.name, 31 :data => { :gallery => "page-#{@page.node.id}",
19 :description => "#credit_for_asset_#{gallery_images.first.id}" } %> 32 :glightbox => glightbox_data(gallery_images.first, gallery_images.first.name),
33 :"credit-selector" => (gallery_images.first.show_credit? ? "#credit_for_asset_#{gallery_images.first.id}" : nil) } %>
20<% end %> 34<% end %>
21 35
22<% gallery_images.each do |image| %> 36<% gallery_images.each do |image| %>
23 <% next if image == @headline_asset %> 37 <% next if image == image_headline %>
24 <% next if !@headline_asset && image == gallery_images.first %> 38 <% next if !image_headline && image == gallery_images.first %>
25 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none", 39 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none",
26 :data => { :gallery => "page-#{@page.node.id}", :title => image.name, 40 :data => { :gallery => "page-#{@page.node.id}",
27 :description => "#credit_for_asset_#{image.id}" } %> 41 :glightbox => glightbox_data(image, image.name),
42 :"credit-selector" => (image.show_credit? ? "#credit_for_asset_#{image.id}" : nil) } %>
43<% end %>
44
45<% if other_pdfs.any? %>
46 <div class="related_documents">
47 <div class="related_documents_label"><%= t(:related_documents) %></div>
48 <ul class="related_documents_list">
49 <% other_pdfs.each do |pdf| %>
50 <li>
51 <%= link_to pdf.upload.url, :class => "related_document_link", :target => "_blank", :rel => "noopener" do %>
52 <%= icon("file-text", library: "tabler", "aria-hidden": true) %>
53 <span><%= pdf.name %></span>
54 <% end %>
55 </li>
56 <% end %>
57 </ul>
58 </div>
28<% end %> 59<% end %>
diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb
index 47f3950..5d47679 100644
--- a/app/views/custom/partials/_chapter.html.erb
+++ b/app/views/custom/partials/_chapter.html.erb
@@ -1,7 +1,7 @@
1<div class="article_partial chapter_partial" lang="<%= page.effective_lang %>"> 1<div class="article_partial chapter_partial" lang="<%= page.effective_lang %>">
2 <div class="chapter_partial_layout"> 2 <div class="chapter_partial_layout">
3 <% if page.assets.images.any? %> 3 <% if page.headline_asset %>
4 <%= link_to_path image_tag(page.assets.images.first.upload.url(:thumb), :alt => ""), page.node.unique_name, class: "chapter_thumbnail" %> 4 <%= link_to_path image_tag(page.headline_asset.upload.url(:thumb), :alt => ""), page.node.unique_name, class: "chapter_thumbnail" %>
5 <% end %> 5 <% end %>
6 <div class="chapter_partial_content"> 6 <div class="chapter_partial_content">
7 <h2 class="headline"><%= link_to_path page.title, page.node.unique_name %></h2> 7 <h2 class="headline"><%= link_to_path page.title, page.node.unique_name %></h2>
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb
new file mode 100644
index 0000000..a67fc86
--- /dev/null
+++ b/app/views/layouts/_flash.html.erb
@@ -0,0 +1,24 @@
1<% if flash.any? %>
2<div id="flash">
3 <%= flash[:notice] %>
4 <% if flash[:status_path] %>
5 <%= link_to 'Go to Status', flash[:status_path] %>
6 <% end %>
7 <% if flash[:stale_locale_path] %>
8 The <%= flash[:stale_locale] %> translation may be out of date —
9 <%= link_to 'review it', flash[:stale_locale_path] %> too.
10 <% end %>
11 <% if flash[:locked_node_path] %>
12 <span class="warning">The page is locked by <%= flash[:locked_by] %> —
13 <%= link_to 'unlock it there', flash[:locked_node_path] %> first,
14 then attach from this asset's page.</span>
15 <% end %>
16 <% if flash[:headline_kept_path] %>
17 <span class="warning">The page's existing headline was kept —
18 <%= link_to 'change it there', flash[:headline_kept_path] %> if needed.</span>
19 <% end %>
20 <% if flash[:error] %>
21 <span id="flash_error"><%= flash[:error] %></span>
22 <% end %>
23</div>
24<% end %>
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index e220beb..89c9b55 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -37,25 +37,10 @@
37 </div> 37 </div>
38 </div> 38 </div>
39 <div class="admin_content_spacer"></div> 39 <div class="admin_content_spacer"></div>
40 <% if flash[:notice].present? || flash[:error].present? %> 40 <%= render "layouts/flash" %>
41 <div id="flash">
42 <%= flash[:notice] %>
43 <% if flash[:status_path] %>
44 <%= link_to 'Go to Status', flash[:status_path] %>
45 <% end %>
46 <% if flash[:stale_locale_path] %>
47 The <%= flash[:stale_locale] %> translation may be out of date —
48 <%= link_to 'review it', flash[:stale_locale_path] %> too.
49 <% end %>
50 <% if flash[:error] %>
51 <span id="flash_error"><%= flash[:error] %></span>
52 <% end %>
53 </div>
54 <% end %>
55 <div id="content"> 41 <div id="content">
56 <%= yield %> 42 <%= yield %>
57 </div> 43 </div>
58
59 <div id="results"></div> 44 <div id="results"></div>
60 </div> 45 </div>
61 </body> 46 </body>
diff --git a/app/views/nodes/_recent_change_item.html.erb b/app/views/nodes/_recent_change_item.html.erb
deleted file mode 100644
index 754a775..0000000
--- a/app/views/nodes/_recent_change_item.html.erb
+++ /dev/null
@@ -1,9 +0,0 @@
1<li>
2 <div>
3 <%= link_to title_for_node(node), node_path(node) %>
4 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
5 </div>
6 <span class="dashboard_widget_meta">
7 <%= (editor = node_head_editor(node)) ? t("last_edited_by", editor: editor) : t("last_edited") %>, <%= relative_time_phrase(node.head.updated_at) %>
8 </span>
9</li>
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index da682f0..fc1d18f 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -33,48 +33,17 @@
33 </div> 33 </div>
34 <% end %> 34 <% end %>
35 35
36 <details id="metadata_details"> 36 <%= fields_for @page do |d| %>
37 <summary>Metadata (slug, parent, tags, template, author, images)</summary> 37 <div id="edit_grid">
38 <div id="metadata">
39 <div class="node_description">Slug</div>
40 <div class="node_content">
41 <%= f.text_field(
42 :staged_slug, :value => @node.staged_slug || @node.slug
43 )
44 %>
45 </div>
46 38
47 <div class="node_description">parent</div> 39 <div id="main_fields">
48 <div class="node_content"> 40 <div class="node_description">Title</div>
49 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> 41 <div class="node_content"><%= d.text_field :title %></div>
50 <div id="move_to_search_results" class="search_results"></div>
51 <%= f.hidden_field(
52 :staged_parent_id,
53 :value => @node.staged_parent_id || @node.parent_id
54 )
55 %>
56 </div>
57 42
58 <%= fields_for @page do |d| %> 43 <div class="node_description">Abstract</div>
59 <div class="node_description">Tags - comma seperated</div> 44 <div class="node_content"><%= d.text_area :abstract %></div>
60 <div class="node_content"><%= text_field_tag :tag_list, @page.tag_list.join(', ') %></div>
61 45
62 <div class="node_description">Publish at</div> 46 <div class="node_description">Attachments</div>
63 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
64
65 <div class="node_description">Template</div>
66 <div class="node_content">
67 <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
68 <span class="field_hint">Set automatically based on how this node was created - change it if needed.</span>
69 </div>
70
71 <div class="node_description">Author</div>
72 <div class="node_content">
73 <%= d.select :user_id, user_list,
74 :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %>
75 </div>
76
77 <div class="node_description">Images</div>
78 <div class="node_content"> 47 <div class="node_content">
79 <div id="related_assets" 48 <div id="related_assets"
80 data-search-url="<%= search_node_related_assets_path(@node) %>" 49 data-search-url="<%= search_node_related_assets_path(@node) %>"
@@ -86,6 +55,7 @@
86 data-large-url="<%= related.asset.upload.url(:large) %>" 55 data-large-url="<%= related.asset.upload.url(:large) %>"
87 data-original-url="<%= related.asset.upload.url %>" 56 data-original-url="<%= related.asset.upload.url %>"
88 data-name="<%= related.asset.name %>" 57 data-name="<%= related.asset.name %>"
58 data-has-credit="<%= related.asset.show_credit? %>"
89 data-headline="<%= related.headline? %>" 59 data-headline="<%= related.headline? %>"
90 class="<%= "is_headline" if related.headline? %>"> 60 class="<%= "is_headline" if related.headline? %>">
91 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 61 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
@@ -101,7 +71,7 @@
101 </li> 71 </li>
102 <% end %> 72 <% end %>
103 </ul> 73 </ul>
104 <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> 74 <p class="field_hint">The starred image or PDF becomes this page's headline on the public site. If none is starred, visitors get a link to browse all attached images instead.</p>
105 <%= text_field_tag nil, nil, id: "related_asset_search_term", placeholder: "Search images to attach…", autocomplete: "off" %> 75 <%= text_field_tag nil, nil, id: "related_asset_search_term", placeholder: "Search images to attach…", autocomplete: "off" %>
106 <div id="related_asset_search_results" class="search_results"></div> 76 <div id="related_asset_search_results" class="search_results"></div>
107 </div> 77 </div>
@@ -119,33 +89,73 @@
119 </button> 89 </button>
120 </li> 90 </li>
121 </template> 91 </template>
122
123 </div> 92 </div>
124 </details>
125 93
126 <div id="content"> 94 <details id="metadata_details">
127 <div class="node_description">Title</div> 95 <summary>Metadata (slug, parent, tags, template, author, images)</summary>
128 <div class="node_content"><%= d.text_field :title %></div> 96 <div id="metadata">
97 <div class="node_description">Slug</div>
98 <div class="node_content">
99 <%= f.text_field(
100 :staged_slug, :value => @node.staged_slug || @node.slug
101 )
102 %>
103 </div>
104
105 <div class="node_description">parent</div>
106 <div class="node_content">
107 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %>
108 <p class="field_hint">Start typing to find a new parent for this node.</p>
109 <div id="move_to_search_results" class="search_results"></div>
110 <%= f.hidden_field(
111 :staged_parent_id,
112 :value => @node.staged_parent_id || @node.parent_id
113 )
114 %>
115 </div>
129 116
130 <div class="node_description">Abstract</div> 117 <div class="node_description">Tags</div>
131 <div class="node_content"><%= d.text_area :abstract %></div> 118 <div class="node_content">
119 <%= text_field_tag :tag_list, @page.tag_list.join(', ') %>
120 <span class="field_hint">Comma separated.</span>
121 </div>
132 122
133 <div class="node_description">Body</div> 123 <div class="node_description">Publish at</div>
134 <div class="body_toolbar_row"> 124 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
135 <button type="button" id="preview_toggle" class="view_toggle" aria-pressed="false" aria-label="Toggle live preview" title="Toggle live preview">
136 <%= icon("layout-sidebar-right", library: "tabler", "aria-hidden": true) %> <span>Live preview</span>
137 </button>
138 <button type="button" id="preview_force_render" class="view_toggle" style="display:none" aria-label="Force preview render" title="Force preview render">
139 <%= icon("refresh", library: "tabler", "aria-hidden": true) %> <span>Force refresh</span>
140 </button>
141 </div>
142 125
143 <div id="editor_and_preview"> 126 <div class="node_description">Template</div>
144 <div class="preview_content"> 127 <div class="node_content">
145 <div class="node_content"><%= d.text_area :body, :class => 'with_editor' %></div> 128 <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
129 <span class="field_hint">Set automatically based on how this node was created - change it if needed.</span>
130 </div>
131
132 <div class="node_description">Author</div>
133 <div class="node_content">
134 <%= d.select :user_id, user_list,
135 :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %>
136 </div>
137
138 </div>
139 </details>
140
141 <div id="content">
142 <div class="node_description">Body</div>
143 <div class="body_toolbar_row">
144 <button type="button" id="preview_toggle" class="view_toggle" aria-pressed="false" aria-label="Toggle live preview" title="Toggle live preview">
145 <%= icon("layout-sidebar-right", library: "tabler", "aria-hidden": true) %> <span>Live preview</span>
146 </button>
147 <button type="button" id="preview_force_render" class="view_toggle" style="display:none" aria-label="Force preview render" title="Force preview render">
148 <%= icon("refresh", library: "tabler", "aria-hidden": true) %> <span>Force refresh</span>
149 </button>
146 </div> 150 </div>
147 <div id="preview_panel" style="display:none"> 151
148 <iframe id="live_preview_iframe" title="Live preview" data-src="<%= preview_page_path(@page, :locale => I18n.default_locale) %>"></iframe> 152 <div id="editor_and_preview">
153 <div class="preview_content">
154 <div class="node_content"><%= d.text_area :body, :class => 'with_editor' %></div>
155 </div>
156 <div id="preview_panel" style="display:none">
157 <iframe id="live_preview_iframe" title="Live preview" data-src="<%= preview_page_path(@page, :locale => I18n.default_locale) %>"></iframe>
158 </div>
149 </div> 159 </div>
150 </div> 160 </div>
151 </div> 161 </div>
diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb
deleted file mode 100644
index d256253..0000000
--- a/app/views/nodes/recent.html.erb
+++ /dev/null
@@ -1,7 +0,0 @@
1<h1>Recently changed</h1>
2
3<%= will_paginate @nodes %>
4<ul id="recent_changes_full_list">
5 <%= render partial: "nodes/recent_change_item", collection: @nodes, as: :node %>
6</ul>
7<%= will_paginate @nodes %>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index af05778..b3987c6 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -253,16 +253,30 @@
253 <% end %> 253 <% end %>
254 </div> 254 </div>
255 255
256 <% if @page.assets.images.any? %> 256 <div class="node_description">Attachments</div>
257 <div class="node_description">Images</div> 257 <div class="node_content node_info_group">
258 <div class="node_content node_info_group"> 258 <% if @page.assets.any? %>
259 <% headline_asset_id = @page.headline_asset&.id %>
259 <ul class="thumbnail_list"> 260 <ul class="thumbnail_list">
260 <% @page.assets.images.each do |asset| %> 261 <% @page.assets.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 <% end %>
265 <% end %> 273 <p class="add_attachments">
274 <%= link_to new_asset_path(:node_id => @node.id), class: 'action_button' do %>
275 <%= icon("paperclip", library: "tabler", "aria-hidden": true) %> Upload new attachment
276 <% end %>
277 <span class="field_hint">To add or remove existing attachments, edit this node instead.</span>
278 </p>
279 </div>
266 280
267 <div class="node_description">Events</div> 281 <div class="node_description">Events</div>
268 <div class="node_content node_info_group"> 282 <div class="node_content node_info_group">
@@ -276,7 +290,11 @@
276 <% end %> 290 <% end %>
277 </ul> 291 </ul>
278 <% mapping = default_event_tag_mapping(@page) %> 292 <% mapping = default_event_tag_mapping(@page) %>
279 <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> 293 <p class="add_events">
294 <%= link_to new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path), class: 'action_button' do %>
295 <%= icon("calendar-plus", library: "tabler", "aria-hidden": true) %> Add event
296 <% end %>
297 </p>
280 </div> 298 </div>
281 299
282 <% matches = matching_node_kinds(@node) %> 300 <% matches = matching_node_kinds(@node) %>
@@ -296,10 +314,11 @@
296 <% if matches.any? %> 314 <% if matches.any? %>
297 <p class="add_child_links"> 315 <p class="add_child_links">
298 <% matches.each_with_index do |(kind, config), index| %> 316 <% matches.each_with_index do |(kind, config), index| %>
299 <%= " &middot; ".html_safe if index > 0 %>
300 <% link_params = { kind: kind } %> 317 <% link_params = { kind: kind } %>
301 <% link_params[:parent_id] = @node.id if kind == "generic" %> 318 <% link_params[:parent_id] = @node.id if kind == "generic" %>
302 <%= link_to "add '#{resolve_kind_text(config[:label])}' child", new_node_path(link_params) %> 319 <%= link_to new_node_path(link_params), class: 'action_button' do %>
320 <%= icon("text-plus", library: "tabler", "aria-hidden": true) %> Add child type <%= resolve_kind_text(config[:label]) %>
321 <% end %>
303 <% end %> 322 <% end %>
304 </p> 323 </p>
305 <% end %> 324 <% end %>
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 21c8ebf..dd22cef 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -130,3 +130,5 @@ de:
130 photo: "Foto %{name}" 130 photo: "Foto %{name}"
131 by: "von %{creator}" 131 by: "von %{creator}"
132 licensed_under: "Lizenziert unter %{license}" 132 licensed_under: "Lizenziert unter %{license}"
133
134 related_documents: "Weitere Dokumente"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 1afcf21..9be0152 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -82,3 +82,5 @@ en:
82 photo: "Photo %{name}" 82 photo: "Photo %{name}"
83 by: "by %{creator}" 83 by: "by %{creator}"
84 licensed_under: "Licensed under %{license}" 84 licensed_under: "Licensed under %{license}"
85
86 related_documents: "Related documents"
diff --git a/config/routes.rb b/config/routes.rb
index 6ddf48c..6223a40 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
@@ -42,7 +41,6 @@ Cccms::Application.routes.draw do
42 get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } 41 get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ }
43 get :parameterize_preview 42 get :parameterize_preview
44 get :drafts 43 get :drafts
45 get :recent
46 get :mine 44 get :mine
47 get :chapters 45 get :chapters
48 get :sitemap 46 get :sitemap
diff --git a/doc/DESIGN_HISTORY.md b/doc/DESIGN_HISTORY.md
index 5eb664b..7f411a3 100644
--- a/doc/DESIGN_HISTORY.md
+++ b/doc/DESIGN_HISTORY.md
@@ -198,3 +198,39 @@ so the log can always answer, for any entry, how much to trust it.
198`app/models/node.rb`. That's the authoritative, current version; this 198`app/models/node.rb`. That's the authoritative, current version; this
199entry explains why it's shaped the way it is, not what it says field 199entry explains why it's shaped the way it is, not what it says field
200by field. 200by field.
201
202### Retired code
203
204The timestamp-driven surfaces this replaced — the dashboard widget
205and the nodes#recent page — are gone; admin/log is the sole
206recent-activity view.
207
208## Attachments: images and PDFs
209
210Originally attachments were images only; PDFs are now first-class,
211and the subsystems below treat both through one mechanism.
212
213Every attachment is an Asset joined to pages via RelatedAsset. The
214starred attachment ("headline") becomes the page's face on the
215public site; if none is starred, visitors get a link to browse all
216attached images instead.
217
218PDFs are headline-eligible because the standard variant set (thumb,
219medium, large, headline) is generated for them as PNG rasters of the
220document's first page (ImageMagick with Ghostscript); the original
221remains the PDF.
222
223Presentation differs by type at the last step only: an image
224headline opens in the lightbox, a PDF headline renders as a document
225card whose click delivers the document itself — a lightbox showing a
226raster of page one would misrepresent what the asset is. Pages list
227attached documents separately from the image gallery.
228
229Credit display (photographer, license, attribution) applies to
230images only; the vocabulary describes photographs and is deliberately
231not rendered for PDFs. Admin asset search matches filenames as well
232as names, since document filenames carry meaning in a way image
233filenames rarely do.
234
235The test suite runs against isolated storage and never touches the
236real upload tree; keep it that way when adding asset code.
diff --git a/lib/tasks/assets.rake b/lib/tasks/assets.rake
index 9d4b048..e778021 100644
--- a/lib/tasks/assets.rake
+++ b/lib/tasks/assets.rake
@@ -1,7 +1,9 @@
1namespace :assets do 1namespace :assets do
2 desc "Regenerate ImageMagick variants for every image asset from its stored original -- rerun whenever a new style is added to FileAttachment::STYLES after assets already exist. Scope to one asset first with ASSET_ID=123." 2 desc "Regenerate ImageMagick variants for every image asset from its stored original -- rerun whenever a new style is added to FileAttachment::STYLES after assets already exist. Scope to one asset first with ASSET_ID=123."
3 task :regenerate_variants => :environment do 3 task :regenerate_variants => :environment do
4 scope = Asset.images 4 scope = Asset.where(upload_content_type: FileAttachment::IMAGE_CONTENT_TYPES +
5 FileAttachment::VECTOR_CONTENT_TYPES +
6 FileAttachment::RASTERIZED_CONTENT_TYPES)
5 scope = scope.where(id: ENV["ASSET_ID"]) if ENV["ASSET_ID"].present? 7 scope = scope.where(id: ENV["ASSET_ID"]) if ENV["ASSET_ID"].present?
6 8
7 scope.find_each do |asset| 9 scope.find_each do |asset|
@@ -12,7 +14,7 @@ namespace :assets do
12 next 14 next
13 end 15 end
14 16
15 asset.send(:generate_variants, original_path) 17 asset.send(:generate_all_variants, original_path)
16 puts "Regenerated variants for Asset##{asset.id} (#{asset.name})" 18 puts "Regenerated variants for Asset##{asset.id} (#{asset.name})"
17 end 19 end
18 end 20 end
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 7fb1dc2..5514f3b 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 }
@@ -69,6 +65,10 @@ $(document).ready(function () {
69 related_assets.initialize(); 65 related_assets.initialize();
70 } 66 }
71 67
68 if ($("#asset_node_search_term").length != 0) {
69 asset_node_search.initialize_search();
70 }
71
72 if ($("#rrule_builder").length != 0) { 72 if ($("#rrule_builder").length != 0) {
73 rrule_builder.initialize(); 73 rrule_builder.initialize();
74 } 74 }
@@ -77,6 +77,14 @@ $(document).ready(function () {
77 cccms.preview.initialize(); 77 cccms.preview.initialize();
78 } 78 }
79 79
80 var metadata_details = document.getElementById('metadata_details');
81 if (metadata_details) {
82 var desktop_mq = window.matchMedia('(min-width: 1016px)');
83 var sync_metadata = function() { metadata_details.open = desktop_mq.matches; };
84 sync_metadata();
85 desktop_mq.addEventListener('change', sync_metadata);
86 }
87
80 jQuery.ajaxSetup({ 88 jQuery.ajaxSetup({
81 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");} 89 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");}
82 }); 90 });
@@ -110,14 +118,6 @@ $(document).ready(function () {
110}); 118});
111 119
112 120
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 = { 121cccms = {
122 setup_autosave : function() { 122 setup_autosave : function() {
123 123
@@ -282,6 +282,7 @@ cccms = {
282 large: $(this).data('large-url'), 282 large: $(this).data('large-url'),
283 original: $(this).data('original-url'), 283 original: $(this).data('original-url'),
284 name: $(this).data('name'), 284 name: $(this).data('name'),
285 hasCredit: $(this).data('has-credit') === true,
285 headline: $(this).data('headline') === true 286 headline: $(this).data('headline') === true
286 }; 287 };
287 }).get(); 288 }).get();
@@ -320,8 +321,11 @@ cccms = {
320 : 'inline-image inline-image--half inline-image--' + placement; 321 : 'inline-image inline-image--half inline-image--' + placement;
321 322
322 var esc = cccms.inline_images.escape_attr; 323 var esc = cccms.inline_images.escape_attr;
324 var titleForGlightbox = (item.name || '').replace(/;/g, ',');
325 var glightboxOpts = 'title: ' + esc(titleForGlightbox) +
326 (item.hasCredit ? '; description: #credit_for_asset_' + esc(item.id) : '') + ';';
323 var html = '<a href="' + esc(item.original) + '" class="glightbox" data-gallery="page-' + esc(cccms.inline_images.node_id) + '"' + 327 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) + '">' + 328 ' data-glightbox="' + glightboxOpts + '">' +
325 '<img src="' + esc(item.large) + '" class="' + classes + '" alt="' + esc(item.name) + '"></a>'; 329 '<img src="' + esc(item.large) + '" class="' + classes + '" alt="' + esc(item.name) + '"></a>';
326 330
327 cccms.inline_images.editor.insertContent(html); 331 cccms.inline_images.editor.insertContent(html);
@@ -360,77 +364,6 @@ menu_item_sorter = {
360 } 364 }
361} 365}
362 366
363image_interface = {
364
365 initialize : function() {
366
367 $("#image_browser").hide();
368 image_interface.initialize_sortable_image_box();
369 image_interface.connect_browser_and_box();
370 image_interface.set_droppable_behavior();
371 image_interface.bind_image_browser_toggle();
372 },
373
374
375 set_droppable_behavior : function() {
376 $("ul#image_box").droppable({
377 out : function(event, ui) {
378 $(ui.draggable).fadeTo("fast", 0.4);
379
380 $(ui.draggable).bind("mouseup", function() {
381 $(this).remove();
382 });
383 },
384 over : function(event, ui) {
385 $(ui.draggable).fadeTo("fast", 1.0);
386 $(ui.draggable).unbind("mouseup");
387 }
388 });
389 },
390
391 connect_browser_and_box : function() {
392 $("#image_browser ul li").draggable({
393 connectToSortable : 'ul#image_box',
394 helper : 'clone',
395 revert : 'invalid'
396 });
397 },
398
399 initialize_sortable_image_box : function() {
400
401 $("ul#image_box").sortable({
402 revert : true,
403 update : function(event, ui) {
404 images = $("ul#image_box").sortable("serialize", {attribute : "rel"});
405
406 $.ajax({
407 type : "POST",
408 url : "/pages/" + $("ul#image_box").attr("rel") + "/sort_images",
409 dataType : "json",
410 data : images + "&_method=put",
411 success : function() {
412 }
413 });
414 }
415 });
416 },
417
418 bind_image_browser_toggle : function() {
419 $("#image_browser_toggle").bind("click", function(){
420 if ($("#image_browser_toggle").attr("class") == "unselected") {
421 $("#image_browser_toggle").attr("class", "selected");
422 $("#image_browser").show();
423 }
424 else {
425 $("#image_browser_toggle").attr("class", "unselected");
426 $("#image_browser").hide();
427 }
428
429 return false;
430 });
431 }
432}
433
434rrule_builder = { 367rrule_builder = {
435 initialize : function() { 368 initialize : function() {
436 rrule_builder.try_populate_from_rrule($("#event_rrule").val()); 369 rrule_builder.try_populate_from_rrule($("#event_rrule").val());
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index ad368cb..792849f 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -286,3 +286,20 @@ event_search = {
286 }); 286 });
287 } 287 }
288}; 288};
289
290asset_node_search = {
291 initialize_search : function() {
292 initSearchPicker({
293 inputSelector: "#asset_node_search_term",
294 resultsSelector: "#asset_node_search_results",
295 onSelect: function(node) {
296 $("#asset_node_search_term").val(node.title);
297 $("#node_id").val(node.node_id);
298 }
299 });
300
301 $("#asset_node_search_term").bind("input", function() {
302 if ($(this).val() === "") { $("#node_id").val(""); }
303 });
304 }
305};
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 cc07d86..c6c6a19 100644
--- a/public/javascripts/related_assets.js
+++ b/public/javascripts/related_assets.js
@@ -87,6 +87,7 @@ related_assets = {
87 var item = $($("#related_asset_template").html().trim()); 87 var item = $($("#related_asset_template").html().trim());
88 item.attr("data-url", related.url); 88 item.attr("data-url", related.url);
89 item.attr("data-asset-id", related.asset_id); 89 item.attr("data-asset-id", related.asset_id);
90 item.attr("data-has-credit", related.has_credit);
90 item.attr("data-large-url", related.large_url); 91 item.attr("data-large-url", related.large_url);
91 item.attr("data-original-url", related.original_url); 92 item.attr("data-original-url", related.original_url);
92 item.attr("data-name", related.name); 93 item.attr("data-name", related.name);
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 086e73d..9f73775 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -97,7 +97,8 @@ input[type=radio] {
97} 97}
98 98
99#metadata, 99#metadata,
100#content { 100#content,
101#main_fields {
101 margin-left: 5px; 102 margin-left: 5px;
102} 103}
103 104
@@ -830,12 +831,11 @@ form.button_to button[type="submit"] {
830 831
831/* Layout only -- the at-rest visibility (wavy underline) for these 832/* Layout only -- the at-rest visibility (wavy underline) for these
832 links comes from the scoped rule in Base elements above. */ 833 links comes from the scoped rule in Base elements above. */
834.add_events,
835.add_attachments,
833.add_child_links { 836.add_child_links {
834 margin-top: 0.5rem; 837 margin-top: 0.5rem;
835} 838 margin-bottom: 0;
836
837.add_child_links a {
838 white-space: nowrap;
839} 839}
840 840
841.sitemap_node { 841.sitemap_node {
@@ -892,6 +892,26 @@ form.button_to button[type="submit"] {
892 Page editor / metadata forms 892 Page editor / metadata forms
893 ============================================================ */ 893 ============================================================ */
894 894
895@media(min-width:1016px) {
896 #edit_grid {
897 display: grid;
898 grid-template-columns: 1fr 1fr;
899 grid-template-areas:
900 "main meta"
901 "body body";
902 column-gap: 2rem;
903 align-items: start;
904 margin-left: -125px;
905 }
906 #main_fields { grid-area: main; }
907 #metadata_details { grid-area: meta; }
908 #page_editor #content { grid-area: body; }
909
910 /* Details is force-opened by JS on desktop; hide the summary only
911 once open, so a JS failure still leaves a working toggle. */
912 #metadata_details[open] > summary { display: none; }
913}
914
895#new_node { 915#new_node {
896 margin-left: -118px; 916 margin-left: -118px;
897} 917}
@@ -947,18 +967,16 @@ div#page_editor {
947} 967}
948 968
949@media(min-width:1016px) { 969@media(min-width:1016px) {
950 input#tag_list,
951 input#menu_search_term, 970 input#menu_search_term,
952 input#menu_item_node_id, 971 input#menu_item_node_id,
953 input#menu_item_path, 972 input#menu_item_path,
954 input#menu_item_title, 973 input#menu_item_title {
955 input#node_staged_slug,
956 input#move_to_search_term {
957 width: 690px; 974 width: 690px;
958 } 975 }
959 976
960 input[type=text]#page_title { 977 input[type=text]#page_title {
961 width: 690px; 978 width: 100%;
979 box-sizing: border-box;
962 font-size: 1rem; 980 font-size: 1rem;
963 padding-top: 6px; 981 padding-top: 6px;
964 padding-bottom: 4px; 982 padding-bottom: 4px;
@@ -968,24 +986,27 @@ div#page_editor {
968 } 986 }
969 987
970 textarea#page_abstract { 988 textarea#page_abstract {
971 width: 690px; 989 width: 100%;
972 height: 150px; 990 box-sizing: border-box;
991 height: 250px;
973 padding: 5px; 992 padding: 5px;
974 } 993 }
975 994
976 #page_editor textarea#page_body { 995 #page_editor textarea#page_body {
977 width: 700px; 996 width: 100%;
997 box-sizing: border-box;
978 height: 600px; 998 height: 600px;
979 } 999 }
980 1000
981 #page_editor #metadata, #page_editor #content, #admin_overview { 1001 #admin_overview {
982 margin-left: -125px; 1002 margin-left: -125px;
983 } 1003 }
984 1004
985 #page_editor input[type=text], 1005 #page_editor input[type=text],
986 #page_editor input[type=password], 1006 #page_editor input[type=password],
987 #page_editor textarea { 1007 #page_editor textarea {
988 width: 690px; 1008 width: 100%;
1009 box-sizing: border-box;
989 } 1010 }
990} 1011}
991 1012
@@ -998,18 +1019,14 @@ div#page_editor {
998 } 1019 }
999 1020
1000 #page_editor #content, 1021 #page_editor #content,
1001 #page_editor #metadata { 1022 #page_editor #metadata,
1023 #page_editor #main_fields {
1002 padding-right: 5px; 1024 padding-right: 5px;
1003 } 1025 }
1004 1026
1005 /* Fixed 800px width will overflow narrow viewports despite
1006 border-box -- every sibling rule in this block uses 100% instead.
1007 Worth checking on an actual phone; looks unfinished rather than
1008 intentional. Not changed here since it wasn't part of what you
1009 asked to fix. */
1010 #page_editor textarea#page_body { 1027 #page_editor textarea#page_body {
1011 box-sizing: border-box; 1028 box-sizing: border-box;
1012 width: 800px; 1029 width: 100%;
1013 height: 50rem; 1030 height: 50rem;
1014 } 1031 }
1015 1032
@@ -1042,10 +1059,6 @@ div#page_editor {
1042 select { 1059 select {
1043 font-size: 1.5rem; 1060 font-size: 1.5rem;
1044 } 1061 }
1045
1046 #metadata ul#image_box {
1047 width: 100% !important;
1048 }
1049} 1062}
1050 1063
1051/* ============================================================ 1064/* ============================================================
@@ -1128,25 +1141,6 @@ div#draft_list table td.actions a {
1128 font-size: 0.9em; 1141 font-size: 0.9em;
1129} 1142}
1130 1143
1131#recent_changes_full_list {
1132 list-style: none;
1133 padding: 0;
1134 margin: 0;
1135}
1136
1137#recent_changes_full_list li {
1138 padding: 0.75rem 0;
1139 border-bottom: 1px solid #eee;
1140}
1141
1142#recent_changes_full_list li:last-child {
1143 border-bottom: none;
1144}
1145
1146#recent_changes_full_list li > div {
1147 margin-bottom: 0.25rem;
1148}
1149
1150/* ============================================================ 1144/* ============================================================
1151 Search widgets 1145 Search widgets
1152 ============================================================ */ 1146 ============================================================ */
@@ -1264,31 +1258,6 @@ div#draft_list table td.actions a {
1264 Image box / browser 1258 Image box / browser
1265 ============================================================ */ 1259 ============================================================ */
1266 1260
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 { 1261.thumbnail_list {
1293 display: flex; 1262 display: flex;
1294 flex-wrap: wrap; 1263 flex-wrap: wrap;
@@ -1316,6 +1285,30 @@ div#image_browser ul li {
1316 border-radius: 4px; 1285 border-radius: 4px;
1317} 1286}
1318 1287
1288.thumbnail_list li {
1289 position: relative;
1290}
1291
1292.thumbnail_list li .headline_indicator {
1293 position: absolute;
1294 top: 4px;
1295 right: 4px;
1296 width: 1.4rem;
1297 height: 1.4rem;
1298 display: flex;
1299 align-items: center;
1300 justify-content: center;
1301 border-radius: 50%;
1302 background: rgba(255, 255, 255, 0.85);
1303 color: #f5b400;
1304}
1305
1306.thumbnail_list li .headline_indicator svg {
1307 width: 0.9rem;
1308 height: 0.9rem;
1309 fill: currentColor;
1310}
1311
1319.related_asset_handle { 1312.related_asset_handle {
1320 display: flex; 1313 display: flex;
1321 color: #969696; 1314 color: #969696;
diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css
index 8358d06..91f7a06 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;
@@ -748,10 +747,88 @@ div.teaser_ruler {
748 margin-left: 1rem; 747 margin-left: 1rem;
749} 748}
750 749
751#asset_credits .glightbox-desc { 750#asset_credits .asset_credit_block {
752 display: none; 751 display: none;
753} 752}
754 753
755#asset_credits .headline_credit { 754#asset_credits .asset_credit_block.headline_credit {
756 display: block; 755 display: block;
757} 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}
778
779.headline_document_card {
780 display: flex;
781 gap: 1rem;
782 align-items: flex-start;
783 border: 1px solid color-mix(in srgb, CanvasText, #808080 25%);
784 border-radius: 6px;
785 padding: 1rem;
786 text-decoration: none;
787 color: inherit;
788}
789
790.headline_document_card:hover {
791 border-color: CanvasText;
792}
793
794.headline_document_card_thumb {
795 max-width: 120px;
796 max-height: 160px;
797 border: 1px solid color-mix(in srgb, CanvasText, #808080 25%);
798 flex-shrink: 0;
799}
800
801.headline_document_card_info {
802 display: flex;
803 align-items: center;
804 gap: 0.5rem;
805}
806
807.headline_document_card_title {
808 font-weight: bold;
809}
810
811.related_documents {
812 margin-top: 1rem;
813}
814
815.related_documents_label {
816 font-style: italic;
817 font-family: Georgia;
818 font-size: 0.9rem;
819 color: color-mix(in srgb, CanvasText, #808080);
820 margin-bottom: 0.3rem;
821}
822
823.related_documents_list {
824 list-style: none;
825 padding: 0;
826 margin: 0;
827}
828
829.related_document_link {
830 display: inline-flex;
831 align-items: center;
832 gap: 0.4rem;
833 text-decoration: none;
834}
diff --git a/test/controllers/assets_controller_test.rb b/test/controllers/assets_controller_test.rb
index 5f5f6e5..f834541 100644
--- a/test/controllers/assets_controller_test.rb
+++ b/test/controllers/assets_controller_test.rb
@@ -4,17 +4,16 @@ class AssetsControllerTest < ActionController::TestCase
4 4
5 def setup 5 def setup
6 login_as :quentin 6 login_as :quentin
7 @existing_asset_ids = Asset.pluck(:id)
7 end 8 end
8 9
9 def teardown 10 def teardown
10 # Clean up any files written to disk during tests 11 (Asset.pluck(:id) - @existing_asset_ids).each do |id|
11 Dir.glob(Rails.root.join('public', 'system', 'uploads', 'test_*')).each do |dir| 12 dir = Asset.upload_root.join(id.to_s)
13 raise "Refusing to delete #{dir} -- outside tmp/, Rails.env.test? may be false" unless
14 dir.to_s.start_with?(Rails.root.join("tmp").to_s)
12 FileUtils.rm_rf(dir) 15 FileUtils.rm_rf(dir)
13 end 16 end
14 # Remove uploads created for assets created during tests
15 Asset.where("upload_file_name IS NOT NULL").where("id > 1000000").each do |a|
16 FileUtils.rm_rf(Rails.root.join('public', 'system', 'uploads', a.id.to_s))
17 end
18 end 17 end
19 18
20 # --- index --- 19 # --- index ---
@@ -64,15 +63,14 @@ class AssetsControllerTest < ActionController::TestCase
64 63
65 # original and all four variants should exist on disk 64 # original and all four variants should exist on disk
66 %w[original medium thumb headline large].each do |style| 65 %w[original medium thumb headline large].each do |style|
67 path = Rails.root.join('public', 'system', 'uploads', 66 path = asset.send(:file_path, style)
68 asset.id.to_s, style, 'test_image.png')
69 assert File.exist?(path), "Expected #{style} variant at #{path}" 67 assert File.exist?(path), "Expected #{style} variant at #{path}"
70 end 68 end
71 end 69 end
72 70
73 # --- create with PDF --- 71 # --- create with PDF ---
74 72
75 test "create asset with PDF upload generates only original" do 73 test "create asset with PDF upload generates rasterized variants" do
76 uploaded = Rack::Test::UploadedFile.new( 74 uploaded = Rack::Test::UploadedFile.new(
77 Rails.root.join('test', 'fixtures', 'files', 'test_document.pdf'), 75 Rails.root.join('test', 'fixtures', 'files', 'test_document.pdf'),
78 'application/pdf' 76 'application/pdf'
@@ -83,18 +81,14 @@ class AssetsControllerTest < ActionController::TestCase
83 assert_response :redirect 81 assert_response :redirect
84 82
85 asset = Asset.last 83 asset = Asset.last
86 assert_equal 'test_document.pdf', asset.upload_file_name 84 original_path = asset.send(:file_path, :original)
87 assert_equal 'application/pdf', asset.upload_content_type
88
89 # only original should exist, no image variants
90 original_path = Rails.root.join('public', 'system', 'uploads',
91 asset.id.to_s, 'original', 'test_document.pdf')
92 assert File.exist?(original_path), "Expected original at #{original_path}" 85 assert File.exist?(original_path), "Expected original at #{original_path}"
86 assert_equal 'test_document.pdf', File.basename(original_path)
93 87
94 %w[medium thumb headline large].each do |style| 88 %w[medium thumb headline large].each do |style|
95 path = Rails.root.join('public', 'system', 'uploads', 89 path = asset.send(:file_path, style)
96 asset.id.to_s, style, 'test_document.pdf') 90 assert File.exist?(path), "Expected a #{style} variant at #{path}"
97 assert !File.exist?(path), "Expected no #{style} variant for PDF" 91 assert_equal '.png', File.extname(path), "Expected #{style} variant to be a PNG, not a PDF"
98 end 92 end
99 end 93 end
100 94
@@ -137,7 +131,7 @@ class AssetsControllerTest < ActionController::TestCase
137 ) 131 )
138 post :create, params: { asset: { name: 'To be deleted', upload: uploaded } } 132 post :create, params: { asset: { name: 'To be deleted', upload: uploaded } }
139 asset = Asset.last 133 asset = Asset.last
140 upload_dir = Rails.root.join('public', 'system', 'uploads', asset.id.to_s) 134 upload_dir = asset.send(:upload_root).join(asset.id.to_s)
141 assert Dir.exist?(upload_dir), "Upload directory should exist before destroy" 135 assert Dir.exist?(upload_dir), "Upload directory should exist before destroy"
142 136
143 assert_difference 'Asset.count', -1 do 137 assert_difference 'Asset.count', -1 do
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index ddc4565..b8f9278 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -514,12 +514,6 @@ class NodesControllerTest < ActionController::TestCase
514 assert_includes assigns(:nodes), chaostreff_node 514 assert_includes assigns(:nodes), chaostreff_node
515 end 515 end
516 516
517 test "recent combined with a search term does not raise an ambiguous column error" do
518 login_as :quentin
519 get :recent, params: { :q => "Zombies" }
520 assert_response :success
521 end
522
523 test "drafts combined with a search term does not raise an ambiguous column error" do 517 test "drafts combined with a search term does not raise an ambiguous column error" do
524 login_as :quentin 518 login_as :quentin
525 get :drafts, params: { :q => "Zombies" } 519 get :drafts, params: { :q => "Zombies" }
diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb
index 3879014..732869b 100644
--- a/test/controllers/pages_controller_test.rb
+++ b/test/controllers/pages_controller_test.rb
@@ -1,5 +1,72 @@
1require 'test_helper' 1require 'test_helper'
2 2
3class PagesControllerTest < ActionController::TestCase 3class PagesControllerTest < ActionController::TestCase
4 # will be removed anyway 4 test "preview shows the autosave when a draft and an autosave both exist" do
5 login_as :quentin
6
7 node = Node.root.children.create!(:slug => "preview_retest")
8 node.draft.update!(:title => "draft title")
9 node.publish_draft!
10
11 node.draft
12 node.lock_for_editing!(users(:quentin))
13 node.autosave!({ :title => "draft title" }, users(:quentin))
14 node.save_draft!(users(:quentin))
15 node.autosave!({ :title => "autosave title" }, users(:quentin))
16
17 get :preview, params: { :id => node.draft_id }
18
19 assert_response :success
20 assert_match "autosave title", response.body
21 end
22
23 test "preview renders a headlined image on the autosave without crashing" do
24 login_as :quentin
25
26 node = Node.root.children.create!(:slug => "preview_retest_with_image")
27 node.lock_for_editing!(users(:quentin))
28 node.autosave!({ :title => "draft title" }, users(:quentin))
29 node.save_draft!(users(:quentin))
30 node.autosave!({ :title => "autosave title" }, users(:quentin))
31
32 asset = Asset.create!(:name => "test", :upload_content_type => "image/png")
33 node.autosave.related_assets.create!(:asset_id => asset.id, :position => 1, :headline => true)
34
35 get :preview, params: { :id => node.draft_id }
36
37 assert_response :success
38 assert_match "autosave title", response.body
39 end
40
41 test "preview shows the autosave when no draft exists at all" do
42 login_as :quentin
43
44 node = Node.root.children.create!(:slug => "preview_retest_no_draft")
45 node.draft.destroy
46 node.update_column(:draft_id, nil)
47
48 node.lock_for_editing!(users(:quentin))
49 node.autosave!({ :title => "autosave only title" }, users(:quentin))
50
51 asset = Asset.create!(:name => "test", :upload_content_type => "image/png")
52 node.autosave.related_assets.create!(:asset_id => asset.id, :position => 1)
53
54 get :preview, params: { :id => node.autosave_id }
55
56 assert_response :success
57 assert_match "autosave only title", response.body
58 end
59
60 test "preview shows head normally when there is no draft or autosave" do
61 login_as :quentin
62
63 node = Node.root.children.create!(:slug => "preview_retest_head_only")
64 node.draft.update!(:title => "head title")
65 node.publish_draft!
66
67 get :preview, params: { :id => node.head_id }
68
69 assert_response :success
70 assert_match "head title", response.body
71 end
5end 72end
diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb
index 2384adc..ced4b74 100644
--- a/test/controllers/related_assets_controller_test.rb
+++ b/test/controllers/related_assets_controller_test.rb
@@ -134,4 +134,29 @@ class RelatedAssetsControllerTest < ActionController::TestCase
134 assert_response :success 134 assert_response :success
135 assert_not related.reload.headline? 135 assert_not related.reload.headline?
136 end 136 end
137
138 test "search includes PDF assets as headline-eligible candidates" do
139 login_as :quentin
140 node = Node.root.children.create!(:slug => "related_assets_search_pdf_test")
141 asset = Asset.create!(:name => "expert-opinion-searchable", :upload_content_type => "application/pdf")
142
143 get :search, params: { :node_id => node.id, :search_term => "expert-opinion-searchable" }
144
145 assert_response :success
146 ids = JSON.parse(response.body).map { |r| r["id"] }
147 assert_includes ids, asset.id
148 end
149
150 test "search matches by filename as well as name" do
151 login_as :quentin
152 node = Node.root.children.create!(:slug => "related_assets_search_filename_test")
153 asset = Asset.create!(:name => "Untitled", :upload_content_type => "application/pdf",
154 :upload_file_name => "Stellungnahme_Patientendaten_Schutz.pdf")
155
156 get :search, params: { :node_id => node.id, :search_term => "Patientendaten" }
157
158 assert_response :success
159 ids = JSON.parse(response.body).map { |r| r["id"] }
160 assert_includes ids, asset.id
161 end
137end 162end
diff --git a/test/models/asset_test.rb b/test/models/asset_test.rb
index d246abe..ab1cc5d 100644
--- a/test/models/asset_test.rb
+++ b/test/models/asset_test.rb
@@ -41,4 +41,16 @@ class AssetTest < ActiveSupport::TestCase
41 test "license_key may be blank" do 41 test "license_key may be blank" do
42 assert Asset.new(:license_key => nil).valid? 42 assert Asset.new(:license_key => nil).valid?
43 end 43 end
44
45 test "pdf? is true only for application/pdf" do
46 assert Asset.new(:upload_content_type => "application/pdf").pdf?
47 assert_not Asset.new(:upload_content_type => "image/png").pdf?
48 end
49
50 test "show_credit? is false for a PDF even with every credit field present" do
51 asset = Asset.new(:name => "demo", :upload_content_type => "application/pdf",
52 :creator => "Jane Doe", :source_url => "https://example.org", :license_key => "cc_by_4")
53 assert asset.has_credit?
54 assert_not asset.show_credit?
55 end
44end 56end
diff --git a/test/models/helpers/content_helper_test.rb b/test/models/helpers/content_helper_test.rb
index d6c7b43..c69ef4e 100644
--- a/test/models/helpers/content_helper_test.rb
+++ b/test/models/helpers/content_helper_test.rb
@@ -79,4 +79,50 @@ class ContentHelperTest < ActionView::TestCase
79 79
80 I18n.with_locale(:en) { assert_match t(:open_gallery), headline_image } 80 I18n.with_locale(:en) { assert_match t(:open_gallery), headline_image }
81 end 81 end
82
83 test "headline_image renders a document card for a PDF headline, not a lightbox image" do
84 node = Node.root.children.create!(:slug => "headline_image_pdf_test")
85 asset = Asset.create!(:name => "Expert Opinion", :upload_content_type => "application/pdf")
86 node.draft.assets << asset
87 node.draft.related_assets.find_by(:asset_id => asset.id).update!(:headline => true)
88 @page = node.draft
89
90 result = headline_image
91
92 assert_match "headline_document_card", result
93 assert_match "Expert Opinion", result
94 assert_no_match "data-gallery", result
95 end
96
97 test "headline_image lists other attached PDFs below the headline" do
98 node = Node.root.children.create!(:slug => "headline_image_multi_pdf_test")
99 headline_pdf = Asset.create!(:name => "Main Filing", :upload_content_type => "application/pdf")
100 other_pdf = Asset.create!(:name => "Supplementary Exhibit", :upload_content_type => "application/pdf")
101 node.draft.assets << headline_pdf
102 node.draft.assets << other_pdf
103 node.draft.related_assets.find_by(:asset_id => headline_pdf.id).update!(:headline => true)
104 @page = node.draft
105
106 result = headline_image
107
108 assert_match "Main Filing", result
109 assert_match "Supplementary Exhibit", result
110 assert_match "headline_document_card", result
111 assert_match "related_documents_list", result
112 end
113
114 test "headline_image lists attached PDFs even with no headline chosen" do
115 node = Node.root.children.create!(:slug => "headline_image_pdf_no_headline_test")
116 pdf_a = Asset.create!(:name => "Document A", :upload_content_type => "application/pdf")
117 pdf_b = Asset.create!(:name => "Document B", :upload_content_type => "application/pdf")
118 node.draft.assets << pdf_a
119 node.draft.assets << pdf_b
120 @page = node.draft
121
122 result = headline_image
123
124 assert_no_match "headline_document_card", result
125 assert_match "Document A", result
126 assert_match "Document B", result
127 end
82end 128end
diff --git a/test/models/node_attach_asset_test.rb b/test/models/node_attach_asset_test.rb
new file mode 100644
index 0000000..cb5b60f
--- /dev/null
+++ b/test/models/node_attach_asset_test.rb
@@ -0,0 +1,107 @@
1require "test_helper"
2
3class NodeAttachAssetTest < ActiveSupport::TestCase
4
5 def setup
6 @user = users(:quentin)
7 @other = users(:aaron)
8 @node = Node.root.children.create!(:slug => "attach_asset_test")
9 @image = create_image_asset
10 end
11
12 test "attaches to a draft-only node" do
13 result = @node.attach_asset!(@image, :user => @user)
14 assert_equal 1, result[:attached]
15 assert_includes @node.draft.assets, @image
16 end
17
18 test "attaches to head when no draft is pending" do
19 @node.publish_draft!(@user)
20 result = @node.attach_asset!(@image, :user => @user)
21 assert_equal 1, result[:attached]
22 assert_includes @node.head.assets, @image
23 end
24
25 test "attaches to head and pending draft alike" do
26 @node.publish_draft!(@user)
27 @node.lock_for_editing!(@user)
28 @node.create_new_draft(@user)
29 result = @node.attach_asset!(@image, :user => @user)
30 assert_equal 2, result[:attached]
31 assert_includes @node.head.assets, @image
32 assert_includes @node.draft.assets, @image
33 end
34
35 test "attaches to all three lifecycle rows" do
36 @node.publish_draft!(@user)
37 @node.lock_for_editing!(@user)
38 @node.create_new_draft(@user)
39 @node.autosave!({ :title => "wip" }, @user)
40 result = @node.attach_asset!(@image, :user => @user)
41 assert_equal 3, result[:attached]
42 [@node.head, @node.draft, @node.autosave].each do |row|
43 assert_includes row.assets, @image
44 end
45 end
46
47 test "skips rows that already carry the asset" do
48 @node.draft.related_assets.create!(:asset => @image)
49 @node.publish_draft!(@user)
50 @node.lock_for_editing!(@user)
51 @node.create_new_draft(@user)
52 result = @node.attach_asset!(@image, :user => @user)
53 assert_equal 0, result[:attached]
54 assert_equal 2, result[:already]
55 assert_equal 1, @node.draft.related_assets.where(:asset_id => @image.id).count
56 end
57
58 test "refuses when another user holds the lock and writes nothing" do
59 @node.lock_for_editing!(@other)
60 assert_raises(LockedByAnotherUser) { @node.attach_asset!(@image, :user => @user) }
61 assert_empty @node.draft.assets.reload
62 end
63
64 test "proceeds when the attaching user holds the lock" do
65 @node.lock_for_editing!(@user)
66 result = @node.attach_asset!(@image, :user => @user)
67 assert_equal 1, result[:attached]
68 end
69
70 test "sets the headline when none exists" do
71 result = @node.attach_asset!(@image, :user => @user, :headline => true)
72 assert_equal :set, result[:headline]
73 assert_equal @image, @node.draft.headline_asset
74 end
75
76 test "keeps an existing headline and reports it" do
77 incumbent = create_image_asset
78 @node.draft.related_assets.create!(:asset => incumbent, :headline => true)
79 result = @node.attach_asset!(@image, :user => @user, :headline => true)
80 assert_equal :kept_existing, result[:headline]
81 assert_equal incumbent, @node.draft.reload.headline_asset
82 assert_includes @node.draft.assets, @image
83 end
84
85 test "declines the headline flag for ineligible asset types" do
86 plain = create_plain_asset
87 result = @node.attach_asset!(plain, :user => @user, :headline => true)
88 assert_equal :not_eligible, result[:headline]
89 assert_includes @node.draft.assets.reload, plain
90 assert_nil @node.draft.headline_asset
91 end
92
93 test "refuses nodes in the Trash" do
94 @node.trash!(@user)
95 assert_raises(ActiveRecord::RecordInvalid) { @node.attach_asset!(@image, :user => @user) }
96 end
97
98 private
99
100 def create_image_asset
101 Asset.create!(:name => "attach test image", :upload_content_type => "image/png")
102 end
103
104 def create_plain_asset
105 Asset.create!(:name => "attach test note", :upload_content_type => "text/plain")
106 end
107end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index ab66f81..ba38340 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -268,7 +268,6 @@ class NodeTest < ActiveSupport::TestCase
268 assert_equal "quentin", node.head.user.login 268 assert_equal "quentin", node.head.user.login
269 end 269 end
270 270
271
272 test "update?" do 271 test "update?" do
273 Node.root.descendants.delete_all 272 Node.root.descendants.delete_all
274 updates = Node.root.children.create!( :slug => "updates" ) 273 updates = Node.root.children.create!( :slug => "updates" )
@@ -359,6 +358,16 @@ class NodeTest < ActiveSupport::TestCase
359 assert_equal @user1, node.reload.lock_owner 358 assert_equal @user1, node.reload.lock_owner
360 end 359 end
361 360
361 test "title reads from autosave when neither draft nor head exists yet" do
362 node = Node.root.children.create!(:slug => "title_autosave_only_test")
363 node.draft.destroy
364 node.update_column(:draft_id, nil)
365 node.lock_for_editing!(users(:quentin))
366 node.autosave!({ :title => "autosave-only title" }, users(:quentin))
367
368 assert_equal "autosave-only title", node.reload.title
369 end
370
362 test "revert! is a safe no-op on a fresh node with only a draft" do 371 test "revert! is a safe no-op on a fresh node with only a draft" do
363 node = create_node_with_draft 372 node = create_node_with_draft
364 node.lock_for_editing!(@user1) 373 node.lock_for_editing!(@user1)
@@ -537,30 +546,6 @@ class NodeTest < ActiveSupport::TestCase
537 assert result.index(mine) < result.index(someone_elses_newer) 546 assert result.index(mine) < result.index(someone_elses_newer)
538 end 547 end
539 548
540 test "recently_changed includes a node whose head was recently published" do
541 node = Node.root.children.create!(:slug => "recent_changed_published")
542 find_or_create_draft(node, @user1)
543 node.autosave!({:title => "v1"}, @user1)
544 node.save_draft!(@user1)
545 node.publish_draft!
546
547 assert_includes Node.recently_changed, node
548 end
549
550 test "recently_changed excludes a node only touched by locking or unlocking after an old publish" do
551 node = Node.root.children.create!(:slug => "recent_changed_lock_only")
552 find_or_create_draft(node, @user1)
553 node.autosave!({:title => "v1"}, @user1)
554 node.save_draft!(@user1)
555 node.publish_draft!
556 node.head.update_column(:updated_at, 20.days.ago)
557
558 node.lock_for_editing!(@user1)
559 node.unlock!
560
561 assert_not_includes Node.recently_changed, node
562 end
563
564 test "autosave! carries over the current related assets to the newly created autosave row" do 549 test "autosave! carries over the current related assets to the newly created autosave row" do
565 node = Node.root.children.create!(:slug => "autosave_asset_carryover_test") 550 node = Node.root.children.create!(:slug => "autosave_asset_carryover_test")
566 user = User.find_by_login("quentin") 551 user = User.find_by_login("quentin")
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 710b4cc..bb86ddb 100644
--- a/test/models/related_asset_test.rb
+++ b/test/models/related_asset_test.rb
@@ -11,15 +11,25 @@ class RelatedAssetTest < ActiveSupport::TestCase
11 assert related.valid? 11 assert related.valid?
12 end 12 end
13 13
14 test "headline cannot be set on a non-image asset" do 14 test "headline can be set on a PDF asset" do
15 node = Node.root.children.create!(:slug => "related_asset_headline_pdf_test") 15 node = Node.root.children.create!(:slug => "related_asset_headline_pdf_test")
16 asset = Asset.create!(:name => "programme", :upload_content_type => "application/pdf") 16 asset = Asset.create!(:name => "expert opinion", :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 related.valid?
22 end
23
24 test "headline cannot be set on a non-image, non-PDF asset" do
25 node = Node.root.children.create!(:slug => "related_asset_headline_text_test")
26 asset = Asset.create!(:name => "programme", :upload_content_type => "text/plain")
17 node.draft.assets << asset 27 node.draft.assets << asset
18 related = node.draft.related_assets.find_by(:asset_id => asset.id) 28 related = node.draft.related_assets.find_by(:asset_id => asset.id)
19 29
20 related.headline = true 30 related.headline = true
21 assert_not related.valid? 31 assert_not related.valid?
22 assert_includes related.errors[:headline], "can only be set on image assets" 32 assert_includes related.errors[:headline], "can only be set on image or PDF assets"
23 end 33 end
24 34
25 test "the headline validation does not raise when asset is missing" do 35 test "the headline validation does not raise when asset is missing" do