summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/assets_controller.rb24
-rw-r--r--app/controllers/nodes_controller.rb16
-rw-r--r--app/controllers/pages_controller.rb20
-rw-r--r--app/controllers/related_assets_controller.rb17
-rw-r--r--app/controllers/shared_previews_controller.rb2
-rw-r--r--app/helpers/content_helper.rb33
-rw-r--r--app/models/asset.rb33
-rw-r--r--app/models/asset_license.rb15
-rw-r--r--app/models/concerns/file_attachment.rb52
-rw-r--r--app/models/node.rb66
-rw-r--r--app/models/node_action.rb8
-rw-r--r--app/models/page.rb26
-rw-r--r--app/models/related_asset.rb9
-rw-r--r--app/views/assets/edit.html.erb75
-rw-r--r--app/views/assets/index.html.erb2
-rw-r--r--app/views/assets/new.html.erb66
-rw-r--r--app/views/assets/show.html.erb52
-rw-r--r--app/views/content/_asset_credits.html.erb10
-rw-r--r--app/views/content/_headline_image.html.erb67
-rw-r--r--app/views/custom/page_templates/public/chapter_detail.html.erb1
-rw-r--r--app/views/custom/page_templates/public/no_date_and_author.html.erb1
-rw-r--r--app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb1
-rw-r--r--app/views/custom/page_templates/public/standard_template.html.erb1
-rw-r--r--app/views/custom/page_templates/public/title_only.html.erb1
-rw-r--r--app/views/custom/page_templates/public/update.html.erb1
-rw-r--r--app/views/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/node_actions/_change_details.html.erb16
-rw-r--r--app/views/nodes/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/edit.html.erb144
-rw-r--r--app/views/nodes/new.html.erb18
-rw-r--r--app/views/nodes/recent.html.erb7
-rw-r--r--app/views/nodes/show.html.erb39
34 files changed, 661 insertions, 216 deletions
diff --git a/app/controllers/assets_controller.rb b/app/controllers/assets_controller.rb
index d150e06..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
@@ -89,6 +92,25 @@ class AssetsController < ApplicationController
89 private 92 private
90 93
91 def asset_params 94 def asset_params
92 params.require(:asset).permit(:name, :upload) 95 params.require(:asset).permit(:name, :upload, :creator, :source_url, :license_key)
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)
93 end 115 end
94end 116end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 6caa827..ce8f053 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -30,6 +30,7 @@ class NodesController < ApplicationController
30 @node = Node.new node_params 30 @node = Node.new node_params
31 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 31 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
32 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) 32 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
33 @attach_asset = Asset.find(params[:asset_id]) if params.has_key?(:asset_id)
33 end 34 end
34 35
35 def create 36 def create
@@ -51,10 +52,19 @@ class NodesController < ApplicationController
51 :action => "create", 52 :action => "create",
52 :title => params[:title], :path => @node.unique_name) 53 :title => params[:title], :path => @node.unique_name)
53 54
55 if params[:asset_id].present? && (asset = Asset.find(params[:asset_id]))
56 result = @node.attach_asset!(asset, :user => current_user,
57 :headline => params[:asset_headline].present?)
58 flash[:notice] = "Page created with “#{asset.name}” attached."
59 flash[:notice] += " It is the page's headline." if result[:headline] == :set
60 flash[:error] = "This asset type cannot be a headline." if result[:headline] == :not_eligible
61 end
62
54 redirect_to(edit_node_path(@node)) 63 redirect_to(edit_node_path(@node))
55 else 64 else
56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 65 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
57 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) 66 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
67 @attach_asset = Asset.find(params[:asset_id]) if params.has_key?(:asset_id)
58 render :new 68 render :new
59 end 69 end
60 end 70 end
@@ -77,7 +87,7 @@ class NodesController < ApplicationController
77 "This page has unsaved changes from a previous session, shown below. " \ 87 "This page has unsaved changes from a previous session, shown below. " \
78 "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version." 88 "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version."
79 elsif freshly_locked 89 elsif freshly_locked
80 flash.now[:notice] = "Node locked and ready to edit" 90 flash.now[:notice] ||= "Node locked and ready to edit"
81 end 91 end
82 rescue LockedByAnotherUser => e 92 rescue LockedByAnotherUser => e
83 flash[:error] = e.message 93 flash[:error] = e.message
@@ -223,10 +233,6 @@ class NodesController < ApplicationController
223 @nodes = index_matching(Node.drafts_and_autosaves) 233 @nodes = index_matching(Node.drafts_and_autosaves)
224 end 234 end
225 235
226 def recent
227 @nodes = index_matching(Node.recently_changed)
228 end
229
230 def mine 236 def mine
231 base = Node.joins(:pages) 237 base = Node.joins(:pages)
232 .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) 238 .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 5af73fc..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,
@@ -39,7 +40,17 @@ class RelatedAssetsController < ApplicationController
39 end 40 end
40 41
41 def update 42 def update
42 @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) 43 related = @node.editable_page.related_assets.find(params[:id])
44
45 if params.key?(:headline)
46 RelatedAsset.transaction do
47 @node.editable_page.related_assets.update_all(headline: false)
48 related.update!(headline: true) if params[:headline] == "true"
49 end
50 else
51 related.insert_at(params[:position].to_i)
52 end
53
43 head :ok 54 head :ok
44 end 55 end
45 56
diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb
index f6fb45a..65f744d 100644
--- a/app/controllers/shared_previews_controller.rb
+++ b/app/controllers/shared_previews_controller.rb
@@ -8,7 +8,7 @@ class SharedPreviewsController < ApplicationController
8 is_draft = node.draft_id == @page.id 8 is_draft = node.draft_id == @page.id
9 9
10 currently_public = is_head && @page.public? 10 currently_public = is_head && @page.public?
11 superseded = !is_head && !is_draft 11 superseded = !is_head && !is_draft
12 12
13 if superseded || currently_public 13 if superseded || currently_public
14 redirect_to @page.public_link 14 redirect_to @page.public_link
diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb
index 5810966..7a52d9c 100644
--- a/app/helpers/content_helper.rb
+++ b/app/helpers/content_helper.rb
@@ -53,11 +53,8 @@ module ContentHelper
53 end 53 end
54 54
55 def headline_image 55 def headline_image
56 @images = @page.assets.images 56 @headline_asset = @page.headline_asset
57 57 render :partial => 'content/headline_image' if @headline_asset || @page.assets.images.any? || @page.assets.pdfs.any?
58 unless @images.empty?
59 render :partial => 'content/headline_image'
60 end
61 end 58 end
62 59
63 # Returns the published_at attribute of a page if it is not nil, otherwise 60 # Returns the published_at attribute of a page if it is not nil, otherwise
@@ -154,4 +151,30 @@ module ContentHelper
154 ) 151 )
155 end 152 end
156 153
154 def asset_credit(asset)
155 return nil unless asset
156 return nil unless asset.has_credit?
157
158 license = AssetLicense.find(asset.license_key)
159
160 photo_label = t("asset_credits.photo", name: asset.name)
161 photo = asset.source_url.present? ? link_to(photo_label, asset.source_url) : photo_label
162
163 attribution_parts = [photo]
164 attribution_parts << t("asset_credits.by", creator: asset.creator) if asset.creator.present?
165 attribution = safe_join(attribution_parts, " ")
166
167 license_text = if license
168 name = t("asset_licenses.#{license.key}", default: license.key)
169 phrase = license.style == "license" ? t("asset_credits.licensed_under", license: name) : name
170 license.url.present? ? link_to(phrase, license.url) : phrase
171 end
172
173 full = license_text ? safe_join([attribution, license_text], ", ") : attribution
174 safe_join([full, "."])
175 end
176
177 def glightbox_data(image, title)
178 "title: #{title.to_s.tr(';', ',')};"
179 end
157end 180end
diff --git a/app/models/asset.rb b/app/models/asset.rb
index aca0ee8..73970e2 100644
--- a/app/models/asset.rb
+++ b/app/models/asset.rb
@@ -1,12 +1,43 @@
1class Asset < ApplicationRecord 1class Asset < ApplicationRecord
2 IMAGE_CONTENT_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]
3 PDF_CONTENT_TYPE = "application/pdf"
2 4
3 include FileAttachment 5 include FileAttachment
4 6
5 has_many :related_assets, :dependent => :destroy 7 has_many :related_assets, :dependent => :destroy
6 has_many :pages, :through => :related_assets 8 has_many :pages, :through => :related_assets
7 9
8 scope :images, -> { where(:upload_content_type => ["image/gif", "image/jpeg", "image/png", "image/webp"]) } 10 scope :images, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES) }
9 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"]) }
10 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) }
11 14
15 scope :headline_eligible, -> { where(:upload_content_type => IMAGE_CONTENT_TYPES + [PDF_CONTENT_TYPE]) }
16
17 validates :license_key, inclusion: { in: -> { AssetLicense.keys } }, allow_blank: true
18
19 def image?
20 IMAGE_CONTENT_TYPES.include?(upload_content_type)
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
34
35 # Nodes whose current lifecycle rows (head, draft or autosave) carry
36 # this asset. Historical revisions also hold RelatedAsset rows; they
37 # are excluded by construction here, since nothing points at them.
38 def attached_nodes
39 page_ids = related_assets.select(:page_id)
40 Node.where("head_id IN (:ids) OR draft_id IN (:ids) OR autosave_id IN (:ids)",
41 :ids => page_ids).distinct
42 end
12end 43end
diff --git a/app/models/asset_license.rb b/app/models/asset_license.rb
new file mode 100644
index 0000000..ca66d40
--- /dev/null
+++ b/app/models/asset_license.rb
@@ -0,0 +1,15 @@
1class AssetLicense
2 Entry = Struct.new(:key, :url, :requires_attribution, :style, keyword_init: true)
3
4 DICTIONARY = YAML.load_file(Rails.root.join("config", "asset_licenses.yml")).freeze
5
6 def self.keys
7 DICTIONARY.keys
8 end
9
10 def self.find(key)
11 entry = DICTIONARY[key.to_s] if key.present?
12 return nil unless entry
13 Entry.new(key: key.to_s, url: entry["url"], requires_attribution: entry["requires_attribution"], style: entry["style"])
14 end
15end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 3c09456..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
@@ -52,29 +53,49 @@ module FileAttachment
52 build_upload_proxy 53 build_upload_proxy
53 end 54 end
54 55
56 def has_variant?(style)
57 upload_file_name.present? && File.exist?(file_path(style))
58 end
59
60 def previewable?
61 has_variant?(:medium)
62 end
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
55 private 69 private
56 70
57 def build_upload_proxy 71 def build_upload_proxy
58 @upload = UploadProxy.new(self) 72 @upload = UploadProxy.new(self)
59 end 73 end
60 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
61 def process_upload 85 def process_upload
62 return unless @pending_upload 86 return unless @pending_upload
63 uploaded_file = @pending_upload 87 uploaded_file = @pending_upload
64 @pending_upload = nil 88 @pending_upload = nil
65 89
66 old_dir = Rails.root.join("public", "system", "uploads", id.to_s) 90 old_dir = upload_root.join(id.to_s)
91
67 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir) 92 FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir)
68 93
69 original_path = file_path(:original) 94 original_path = file_path(:original)
70 FileUtils.mkdir_p(File.dirname(original_path)) 95 FileUtils.mkdir_p(File.dirname(original_path))
71 FileUtils.cp(uploaded_file.tempfile.path, original_path) 96 FileUtils.cp(uploaded_file.tempfile.path, original_path)
72 97
73 if IMAGE_CONTENT_TYPES.include?(upload_content_type) 98 generate_all_variants(original_path)
74 generate_variants(original_path)
75 elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
76 generate_svg_variants(original_path)
77 end
78 end 99 end
79 100
80 def generate_variants(original_path) 101 def generate_variants(original_path)
@@ -93,16 +114,23 @@ module FileAttachment
93 end 114 end
94 end 115 end
95 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
96 def delete_upload_files 127 def delete_upload_files
97 dir = Rails.root.join("public", "system", "uploads", id.to_s) 128 dir = upload_root.join(id.to_s)
98 FileUtils.rm_rf(dir) if Dir.exist?(dir) 129 FileUtils.rm_rf(dir) if Dir.exist?(dir)
99 end 130 end
100 131
101 def file_path(style) 132 def file_path(style)
102 Rails.root.join( 133 upload_root.join(id.to_s, style.to_s, variant_filename(style)).to_s
103 "public", "system", "uploads",
104 id.to_s, style.to_s, upload_file_name
105 ).to_s
106 end 134 end
107 135
108 def sanitize_filename(filename) 136 def sanitize_filename(filename)
@@ -119,7 +147,7 @@ module FileAttachment
119 147
120 def url(style = :original) 148 def url(style = :original)
121 return "" if @record.upload_file_name.blank? 149 return "" if @record.upload_file_name.blank?
122 "/system/uploads/#{@record.id}/#{style}/#{@record.upload_file_name}" 150 "/system/uploads/#{@record.id}/#{style}/#{@record.variant_filename(style)}"
123 end 151 end
124 152
125 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/node_action.rb b/app/models/node_action.rb
index 13bd5ba..63a99ae 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -166,4 +166,12 @@ class NodeAction < ApplicationRecord
166 def subject_name 166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node" 167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end 168 end
169
170 def diff_link_params
171 prev = NodeAction.where(node_id: node_id, action: "publish")
172 .where("id < ?", id)
173 .order(id: :desc).first
174 return nil unless prev&.page && page
175 { start_revision: prev.page.revision, end_revision: page.revision }
176 end
169end 177end
diff --git a/app/models/page.rb b/app/models/page.rb
index f33d88d..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 8f16460..8f8d49c 100644
--- a/app/models/related_asset.rb
+++ b/app/models/related_asset.rb
@@ -5,4 +5,13 @@ class RelatedAsset < ApplicationRecord
5 acts_as_list :scope => :page_id 5 acts_as_list :scope => :page_id
6 6
7 default_scope -> { order("position ASC") } 7 default_scope -> { order("position ASC") }
8
9 validate :headline_only_for_images
10
11 private
12
13 def headline_only_for_images
14 return unless asset
15 errors.add(:headline, "can only be set on image or PDF assets") if headline? && !(asset.image? || asset.pdf?)
16 end
8end 17end
diff --git a/app/views/assets/edit.html.erb b/app/views/assets/edit.html.erb
index f198600..0c1dd8f 100644
--- a/app/views/assets/edit.html.erb
+++ b/app/views/assets/edit.html.erb
@@ -1,24 +1,61 @@
1<h1>Editing asset</h1> 1<div id="page_editor">
2 <h1>Editing asset</h1>
2 3
3<%= form_for(@asset, html: { multipart: true }) do |f| %> 4 <%= form_for(@asset, html: { multipart: true }) do |f| %>
4 <%= form_error_messages(f) %> 5 <%= form_error_messages(f) %>
5 6
6 <% if @asset.upload.present? %> 7 <div id="content">
7 <p> 8 <div class="node_description"><%= f.label :name %></div>
8 <strong>Current file:</strong> 9 <div class="node_content"><%= f.text_field :name %></div>
9 <%= @asset.upload.url %> 10
10 (<%= number_to_human_size(@asset.upload.size) %>) 11 <% if @asset.upload.present? %>
11 </p> 12 <div class="node_description">Current File</div>
12 <% end %> 13 <div class="node_content"><%= @asset.upload.url %> (<%= number_to_human_size(@asset.upload.size) %>)</div>
14 <% end %>
15
16 <div class="node_description"><%= f.label :upload, "Replace file" %></div>
17 <div class="node_content"><%= f.file_field :upload %></div>
18
19 <div class="node_description"><%= f.label :creator %></div>
20 <div class="node_content"><%= f.text_field :creator %></div>
13 21
14 <p> 22 <div class="node_description"><%= f.label :source_url, "Source URL" %></div>
15 <label>Replace file:</label><br> 23 <div class="node_content"><%= f.text_field :source_url %></div>
16 <%= f.file_field :upload %>
17 </p>
18 24
19 <p> 25 <div class="node_description"><%= f.label :license_key, "License" %></div>
20 <%= f.submit 'Update' %> 26 <div class="node_content">
21 </p> 27 <%= f.select :license_key,
22<% end %> 28 options_for_select(
29 [["— none —", ""]] + AssetLicense.keys.map { |key| [t("asset_licenses.#{key}"), key] },
30 @asset.license_key
31 ) %>
32 </div>
23 33
24<%= link_to 'Show', @asset %> | <%= link_to 'Back', assets_path %> 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
51 <div class="node_description">Actions</div>
52 <div class="node_content node_info_group">
53 <div class="node_info_group_items">
54 <div class="node_info_item"><%= f.submit 'Update' %></div>
55 <div class="node_info_item"><%= link_to 'Show', @asset %></div>
56 <div class="node_info_item"><%= link_to 'Back', assets_path %></div>
57 </div>
58 </div>
59 </div>
60 <% end %>
61</div>
diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb
index 8c35561..ff1300a 100644
--- a/app/views/assets/index.html.erb
+++ b/app/views/assets/index.html.erb
@@ -17,7 +17,7 @@
17 </tr> 17 </tr>
18<% @assets.each do |asset| %> 18<% @assets.each do |asset| %>
19 <tr> 19 <tr>
20 <td><%= image_tag asset.upload.url(:thumb), style: "max-width: 100px; max-height: 100px;" %></td> 20 <td><% if asset.has_variant?(:thumb) %><%= image_tag asset.upload.url(:thumb), style: "max-width: 100px; max-height: 100px;" %><% end %></td>
21 <td><%= link_to asset.name, asset.upload.url %></td> 21 <td><%= link_to asset.name, asset.upload.url %></td>
22 <td><%= asset.upload.content_type %></td> 22 <td><%= asset.upload.content_type %></td>
23 <td><%= link_to 'Show', asset %></td> 23 <td><%= link_to 'Show', asset %></td>
diff --git a/app/views/assets/new.html.erb b/app/views/assets/new.html.erb
index 6c1310a..7bded94 100644
--- a/app/views/assets/new.html.erb
+++ b/app/views/assets/new.html.erb
@@ -1,17 +1,55 @@
1<h1>New asset</h1> 1<div id="page_editor">
2 <h1>New asset</h1>
2 3
3<%= form_for(@asset, :html => { :multipart => true }) do |f| %> 4 <%= form_for(@asset, :html => { :multipart => true }) do |f| %>
4 <%= form_error_messages(f) %> 5 <%= form_error_messages(f) %>
5
6 <p>
7 <%= f.label :name %><br />
8 <%= f.text_field :name %>
9 </p>
10 6
11 <p> 7 <div id="content">
12 <%= f.file_field :upload %> 8 <div class="node_description"><%= f.label :name %></div>
13 <%= f.submit 'Create' %> 9 <div class="node_content"><%= f.text_field :name %></div>
14 </p>
15<% end %>
16 10
17<%= link_to 'Back', assets_path %> 11 <div class="node_description"><%= f.label :upload, "File" %></div>
12 <div class="node_content"><%= f.file_field :upload %></div>
13
14 <div class="node_description"><%= f.label :creator %></div>
15 <div class="node_content"><%= f.text_field :creator %></div>
16
17 <div class="node_description"><%= f.label :source_url, "Source URL" %></div>
18 <div class="node_content"><%= f.text_field :source_url %></div>
19
20 <div class="node_description"><%= f.label :license_key, "License" %></div>
21 <div class="node_content">
22 <%= f.select :license_key,
23 options_for_select(
24 [["— none —", ""]] + AssetLicense.keys.map { |key| [t("asset_licenses.#{key}"), key] },
25 @asset.license_key
26 ) %>
27 </div>
28
29 <div class="node_description">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
46 <div class="node_description">Actions</div>
47 <div class="node_content node_info_group">
48 <div class="node_info_group_items">
49 <div class="node_info_item"><%= f.submit 'Create' %></div>
50 <div class="node_info_item"><%= link_to 'Back', assets_path %></div>
51 </div>
52 </div>
53 </div>
54 <% end %>
55</div>
diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb
index ff00883..2723821 100644
--- a/app/views/assets/show.html.erb
+++ b/app/views/assets/show.html.erb
@@ -16,8 +16,35 @@
16 </div> 16 </div>
17 </div> 17 </div>
18 18
19 <div class="node_description">Thumbnail</div> 19 <% if @asset.has_variant?(:medium) %>
20 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div> 20 <div class="node_description">Thumbnail</div>
21 <div class="node_content"><%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %></div>
22 <% end %>
23
24 <div class="node_description">Attached to</div>
25 <div class="node_content">
26 <% nodes = @asset.attached_nodes %>
27 <% if nodes.any? %>
28 <ul class="search_results_list">
29 <% nodes.each do |node| %>
30 <li>
31 <%= link_to node.title, node_path(node) %>
32 <% if node.editable_page&.headline_asset == @asset %>
33 <%= icon("star", library: "tabler", "aria-hidden": true) %>
34 <% end %>
35 <% unless node.head && node.head.related_assets.exists?(:asset_id => @asset.id) %>
36 <span class="field_hint">pending versions only</span>
37 <% end %>
38 </li>
39 <% end %>
40 </ul>
41 <% else %>
42 — not attached to any page —
43 <% end %>
44 <%= link_to new_node_path(:asset_id => @asset.id), :class => "action_button" do %>
45 <%= icon("file-plus", library: "tabler", "aria-hidden": true) %> New page with this attachment
46 <% end %>
47 </div>
21 48
22 <div class="node_description">Public Path</div> 49 <div class="node_description">Public Path</div>
23 <div class="node_content"> 50 <div class="node_content">
@@ -30,6 +57,27 @@
30 </button> 57 </button>
31 </div> 58 </div>
32 59
60 <div class="node_description">Creator</div>
61 <div class="node_content"><%= @asset.creator.presence || "—" %></div>
62
63 <div class="node_description">Source</div>
64 <div class="node_content">
65 <% if @asset.source_url.present? %>
66 <%= link_to @asset.source_url, @asset.source_url %>
67 <% else %>
68
69 <% end %>
70 </div>
71
72 <div class="node_description">License</div>
73 <div class="node_content">
74 <% if (license = AssetLicense.find(@asset.license_key)) %>
75 <%= t("asset_licenses.#{license.key}") %>
76 <% else %>
77
78 <% end %>
79 </div>
80
33 <div class="node_description">Content Type</div> 81 <div class="node_description">Content Type</div>
34 <div class="node_content"><%= @asset.upload.content_type %></div> 82 <div class="node_content"><%= @asset.upload.content_type %></div>
35 83
diff --git a/app/views/content/_asset_credits.html.erb b/app/views/content/_asset_credits.html.erb
new file mode 100644
index 0000000..5959abd
--- /dev/null
+++ b/app/views/content/_asset_credits.html.erb
@@ -0,0 +1,10 @@
1<% if @page.assets.images.any? %>
2 <div id="asset_credits">
3 <% @page.assets.images.each do |image| %>
4 <div id="credit_for_asset_<%= image.id %>"
5 class="asset_credit_block<%= " headline_credit" if image == @headline_asset %>">
6 <%= asset_credit(image) %>
7 </div>
8 <% end %>
9 </div>
10<% end %>
diff --git a/app/views/content/_headline_image.html.erb b/app/views/content/_headline_image.html.erb
index 243be40..4a3dfdc 100644
--- a/app/views/content/_headline_image.html.erb
+++ b/app/views/content/_headline_image.html.erb
@@ -1,16 +1,59 @@
1<%= link_to( 1<% gallery_images = @page.assets.images %>
2 image_tag(@images[0].upload.url(:headline)), 2<% image_headline = @headline_asset if @headline_asset&.image? %>
3 @images[0].upload.url, 3<% other_pdfs = @page.assets.pdfs.where.not(id: @headline_asset&.id) %>
4 :class => "glightbox",
5 :data => { :gallery => "page-#{@page.node.id}" }
6) %>
7 4
8<% if 1 < @images.length %> 5<% if @headline_asset&.pdf? %>
9 <div class="right"> 6 <%= link_to @headline_asset.upload.url, :class => "headline_document_card", :target => "_blank", :rel => "noopener" do %>
10 <%= "#{@images.length} #{t(:images)}" %> 7 <%= image_tag @headline_asset.upload.url(:medium), :alt => "", :class => "headline_document_card_thumb" %>
11 </div> 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 %>
16 <%= link_to(
17 image_tag(image_headline.upload.url(:headline)),
18 image_headline.upload.url,
19 :class => "glightbox",
20 :data => { :gallery => "page-#{@page.node.id}",
21 :glightbox => glightbox_data(image_headline, image_headline.name),
22 :"credit-selector" => (image_headline.show_credit? ? "#credit_for_asset_#{image_headline.id}" : nil) }
23 ) %>
24 <% if gallery_images.size > 1 %>
25 <div class="right"><%= "#{gallery_images.size} #{t(:images)}" %></div>
26 <% end %>
27<% elsif gallery_images.any? %>
28 <%= link_to "#{gallery_images.size} #{t(:images)}, #{t(:open_gallery)}",
29 gallery_images.first.upload.url,
30 :class => "glightbox right",
31 :data => { :gallery => "page-#{@page.node.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) } %>
12<% end %> 34<% end %>
13 35
14<% @images[1..-1].each do |image| %> 36<% gallery_images.each do |image| %>
15 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none", :data => { :gallery => "page-#{@page.node.id}" } %> 37 <% next if image == image_headline %>
38 <% next if !image_headline && image == gallery_images.first %>
39 <%= link_to "", image.upload.url, :class => "glightbox", :style => "display: none",
40 :data => { :gallery => "page-#{@page.node.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>
16<% end %> 59<% end %>
diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb
index c4c2598..18449b8 100644
--- a/app/views/custom/page_templates/public/chapter_detail.html.erb
+++ b/app/views/custom/page_templates/public/chapter_detail.html.erb
@@ -31,4 +31,5 @@
31 </ul> 31 </ul>
32 </div> 32 </div>
33 <% end %> 33 <% end %>
34 <%= render partial: 'content/asset_credits' %>
34</div> 35</div>
diff --git a/app/views/custom/page_templates/public/no_date_and_author.html.erb b/app/views/custom/page_templates/public/no_date_and_author.html.erb
index fa39e54..ec7e850 100644
--- a/app/views/custom/page_templates/public/no_date_and_author.html.erb
+++ b/app/views/custom/page_templates/public/no_date_and_author.html.erb
@@ -3,6 +3,7 @@
3 <p><%= sanitize( @page.abstract ) %></p> 3 <p><%= sanitize( @page.abstract ) %></p>
4 <div id="headline_image"><%= headline_image %></div> 4 <div id="headline_image"><%= headline_image %></div>
5 <%= aggregate?(@page.body) %> 5 <%= aggregate?(@page.body) %>
6 <%= render partial: 'content/asset_credits' %>
6</div> 7</div>
7 8
8<%= will_paginate(@content_collection) if @content_collection %> 9<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb b/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
index 64e2a7c..c486684 100644
--- a/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
+++ b/app/views/custom/page_templates/public/no_title_abstract_date_and_author.html.erb
@@ -1,6 +1,7 @@
1<div class="article" lang="<%= @page.effective_lang %>"> 1<div class="article" lang="<%= @page.effective_lang %>">
2 <div id="headline_image"><%= headline_image %></div> 2 <div id="headline_image"><%= headline_image %></div>
3 <%= aggregate?(@page.body) %> 3 <%= aggregate?(@page.body) %>
4 <%= render partial: 'content/asset_credits' %>
4</div> 5</div>
5 6
6<%= will_paginate(@content_collection) if @content_collection %> 7<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/standard_template.html.erb b/app/views/custom/page_templates/public/standard_template.html.erb
index bbc8cd0..e1e2eb0 100644
--- a/app/views/custom/page_templates/public/standard_template.html.erb
+++ b/app/views/custom/page_templates/public/standard_template.html.erb
@@ -3,6 +3,7 @@
3 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div> 3 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div>
4 <div id="headline_image"><%= headline_image %></div> 4 <div id="headline_image"><%= headline_image %></div>
5 <div class="body"><%= aggregate?(@page.body) %></div> 5 <div class="body"><%= aggregate?(@page.body) %></div>
6 <%= render partial: 'content/asset_credits' %>
6</div> 7</div>
7 8
8<%= will_paginate(@content_collection) if @content_collection %> 9<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/title_only.html.erb b/app/views/custom/page_templates/public/title_only.html.erb
index 42fe9a6..d706e9d 100644
--- a/app/views/custom/page_templates/public/title_only.html.erb
+++ b/app/views/custom/page_templates/public/title_only.html.erb
@@ -2,6 +2,7 @@
2 <h2 class="headline"><%= @page.title %></h2> 2 <h2 class="headline"><%= @page.title %></h2>
3 <div id="headline_image"><%= headline_image %></div> 3 <div id="headline_image"><%= headline_image %></div>
4 <%= aggregate?(@page.body) %> 4 <%= aggregate?(@page.body) %>
5 <%= render partial: 'content/asset_credits' %>
5</div> 6</div>
6 7
7<%= will_paginate(@content_collection) if @content_collection %> 8<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/custom/page_templates/public/update.html.erb b/app/views/custom/page_templates/public/update.html.erb
index d5995ac..0ff1bb2 100644
--- a/app/views/custom/page_templates/public/update.html.erb
+++ b/app/views/custom/page_templates/public/update.html.erb
@@ -4,6 +4,7 @@
4 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div> 4 <div class="abstract"><p><%= sanitize( @page.abstract )%></p></div>
5 <div id="headline_image"><%= headline_image %></div> 5 <div id="headline_image"><%= headline_image %></div>
6 <div class="body"><%= aggregate?(@page.body) %></div> 6 <div class="body"><%= aggregate?(@page.body) %></div>
7 <%= render partial: 'content/asset_credits' %>
7</div> 8</div>
8 9
9<%= will_paginate(@content_collection) if @content_collection %> 10<%= will_paginate(@content_collection) if @content_collection %>
diff --git a/app/views/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/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb
index 2583e8b..066d0f3 100644
--- a/app/views/node_actions/_change_details.html.erb
+++ b/app/views/node_actions/_change_details.html.erb
@@ -5,9 +5,13 @@
5 <tr> 5 <tr>
6 <th><%= I18n.default_locale.to_s.upcase %></th> 6 <th><%= I18n.default_locale.to_s.upcase %></th>
7 <td> 7 <td>
8 <%= safe_join(default_items, ", ") %> 8 <%= safe_join(default_items, tag.br) %>
9 <% if action_entry.page && action_entry.node %> 9 <% if action_entry.page && action_entry.node %>
10 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %> 10 <% if (diff_params = action_entry.diff_link_params) %>
11 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params) %>
12 <% else %>
13 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %>
14 <% end %>
11 <% end %> 15 <% end %>
12 </td> 16 </td>
13 </tr> 17 </tr>
@@ -16,9 +20,13 @@
16 <tr> 20 <tr>
17 <th><%= locale.upcase %></th> 21 <th><%= locale.upcase %></th>
18 <td> 22 <td>
19 <%= safe_join(translation_changes(diff), ", ") %> 23 <%= safe_join(translation_changes(diff), tag.br) %>
20 <% if action_entry.page && action_entry.node %> 24 <% if action_entry.page && action_entry.node %>
21 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %> 25 <% if (diff_params = action_entry.diff_link_params) %>
26 <%= link_to t("node_actions.view_diff"), diff_node_revisions_path(action_entry.node, diff_params.merge(:locale => locale)) %>
27 <% else %>
28 <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %>
29 <% end %>
22 <% end %> 30 <% end %>
23 </td> 31 </td>
24 </tr> 32 </tr>
diff --git a/app/views/nodes/_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 25f15c2..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
47 <div class="node_description">parent</div>
48 <div class="node_content">
49 <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %>
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
58 <%= fields_for @page do |d| %>
59 <div class="node_description">Tags - comma seperated</div>
60 <div class="node_content"><%= text_field_tag :tag_list, @page.tag_list.join(', ') %></div>
61
62 <div class="node_description">Publish at</div>
63 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
64 38
65 <div class="node_description">Template</div> 39 <div id="main_fields">
66 <div class="node_content"> 40 <div class="node_description">Title</div>
67 <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> 41 <div class="node_content"><%= d.text_field :title %></div>
68 <span class="field_hint">Set automatically based on how this node was created - change it if needed.</span>
69 </div>
70 42
71 <div class="node_description">Author</div> 43 <div class="node_description">Abstract</div>
72 <div class="node_content"> 44 <div class="node_content"><%= d.text_area :abstract %></div>
73 <%= d.select :user_id, user_list,
74 :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %>
75 </div>
76 45
77 <div class="node_description">Images</div> 46 <div class="node_description">Attachments</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) %>"
@@ -82,17 +51,27 @@
82 <ul id="related_asset_list" class="thumbnail_list"> 51 <ul id="related_asset_list" class="thumbnail_list">
83 <% @page.related_assets.includes(:asset).each do |related| %> 52 <% @page.related_assets.includes(:asset).each do |related| %>
84 <li data-url="<%= node_related_asset_path(@node, related) %>" 53 <li data-url="<%= node_related_asset_path(@node, related) %>"
54 data-asset-id="<%= related.asset.id %>"
85 data-large-url="<%= related.asset.upload.url(:large) %>" 55 data-large-url="<%= related.asset.upload.url(:large) %>"
86 data-original-url="<%= related.asset.upload.url %>" 56 data-original-url="<%= related.asset.upload.url %>"
87 data-name="<%= related.asset.name %>"> 57 data-name="<%= related.asset.name %>"
58 data-has-credit="<%= related.asset.show_credit? %>"
59 data-headline="<%= related.headline? %>"
60 class="<%= "is_headline" if related.headline? %>">
88 <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>
89 <%= image_tag related.asset.upload.url(:thumb) %> 62 <%= image_tag related.asset.upload.url(:thumb) %>
63 <button type="button" class="related_asset_set_headline"
64 aria-pressed="<%= related.headline? %>" aria-label="Set as headline image"
65 title="Use this photo as the page's headline image">
66 <%= icon("star", library: "tabler", "aria-hidden": true) %>
67 </button>
90 <button type="button" class="related_asset_remove" aria-label="Remove image"> 68 <button type="button" class="related_asset_remove" aria-label="Remove image">
91 <%= icon("x", library: "tabler", "aria-hidden": true) %> 69 <%= icon("x", library: "tabler", "aria-hidden": true) %>
92 </button> 70 </button>
93 </li> 71 </li>
94 <% end %> 72 <% end %>
95 </ul> 73 </ul>
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>
96 <%= 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" %>
97 <div id="related_asset_search_results" class="search_results"></div> 76 <div id="related_asset_search_results" class="search_results"></div>
98 </div> 77 </div>
@@ -102,38 +81,81 @@
102 <li> 81 <li>
103 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 82 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
104 <img src=""> 83 <img src="">
84 <button type="button" class="related_asset_set_headline" aria-pressed="false" aria-label="Set as headline image">
85 <%= icon("star", library: "tabler", "aria-hidden": true) %>
86 </button>
105 <button type="button" class="related_asset_remove" aria-label="Remove image"> 87 <button type="button" class="related_asset_remove" aria-label="Remove image">
106 <%= icon("x", library: "tabler", "aria-hidden": true) %> 88 <%= icon("x", library: "tabler", "aria-hidden": true) %>
107 </button> 89 </button>
108 </li> 90 </li>
109 </template> 91 </template>
110
111 </div> 92 </div>
112 </details>
113 93
114 <div id="content"> 94 <details id="metadata_details">
115 <div class="node_description">Title</div> 95 <summary>Metadata (slug, parent, tags, template, author, images)</summary>
116 <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>
117 104
118 <div class="node_description">Abstract</div> 105 <div class="node_description">parent</div>
119 <div class="node_content"><%= d.text_area :abstract %></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>
120 116
121 <div class="node_description">Body</div> 117 <div class="node_description">Tags</div>
122 <div class="body_toolbar_row"> 118 <div class="node_content">
123 <button type="button" id="preview_toggle" class="view_toggle" aria-pressed="false" aria-label="Toggle live preview" title="Toggle live preview"> 119 <%= text_field_tag :tag_list, @page.tag_list.join(', ') %>
124 <%= icon("layout-sidebar-right", library: "tabler", "aria-hidden": true) %> <span>Live preview</span> 120 <span class="field_hint">Comma separated.</span>
125 </button> 121 </div>
126 <button type="button" id="preview_force_render" class="view_toggle" style="display:none" aria-label="Force preview render" title="Force preview render"> 122
127 <%= icon("refresh", library: "tabler", "aria-hidden": true) %> <span>Force refresh</span> 123 <div class="node_description">Publish at</div>
128 </button> 124 <div class="node_content"><%= d.datetime_select :published_at, :value => @page.published_at %></div>
129 </div> 125
126 <div class="node_description">Template</div>
127 <div class="node_content">
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>
130 131
131 <div id="editor_and_preview"> 132 <div class="node_description">Author</div>
132 <div class="preview_content"> 133 <div class="node_content">
133 <div class="node_content"><%= d.text_area :body, :class => 'with_editor' %></div> 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>
134 </div> 150 </div>
135 <div id="preview_panel" style="display:none"> 151
136 <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>
137 </div> 159 </div>
138 </div> 160 </div>
139 </div> 161 </div>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 805fbc9..1303e9e 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -48,6 +48,24 @@
48 <span class="field_hint">This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it).</span> 48 <span class="field_hint">This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it).</span>
49 </div> 49 </div>
50 50
51 <% if @attach_asset %>
52 <div class="node_description">attachment</div>
53 <div class="node_content">
54 <ul class="thumbnail_list">
55 <li>
56 <% if @attach_asset.has_variant?(:thumb) %>
57 <%= image_tag @attach_asset.upload.url(:thumb) %>
58 <% end %>
59 <%= link_to @attach_asset.name, asset_path(@attach_asset),
60 :target => "_blank", :rel => "noopener" %>
61 </li>
62 </ul>
63 <span class="field_hint">This asset will automatically be attached to the new page.</span>
64 <label><%= check_box_tag :asset_headline, "1" %> as the page's headline</label>
65 <%= hidden_field_tag :asset_id, @attach_asset.id %>
66 </div>
67 <% end %>
68
51 <div class="node_description"></div> 69 <div class="node_description"></div>
52 <div class="node_content"><%= submit_tag "Create" %></div> 70 <div class="node_content"><%= submit_tag "Create" %></div>
53 71
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 %>