From bf68c8fed6776cd40d859e6e968a251b4b934c8b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:11:23 +0200 Subject: Fix button_to styling for Rails 8.1, add destructive variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend all selectors to cover button[type="submit"] alongside input[type="submit"] — Rails 8.1 generates + 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). + + <%= submit_tag "Create" %> diff --git a/config/routes.rb b/config/routes.rb index cf4733c..6c07414 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -59,6 +59,7 @@ Cccms::Application.routes.draw do match 'admin' => 'admin#index', :as => :admin, :via => :get match 'admin/search' => 'admin#search', :as => :admin_search, :via => :get match 'admin/menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'admin/conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get match 'search' => 'search#index', :as => :search, :via => :get resources :users diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index a02bd61..f240bb2 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -508,6 +508,14 @@ table tr.header { text-align: left; } +.field_hint { + display: block; + margin-top: 2px; + font-size: 0.75rem; + color: #969696; + padding-bottom: 4px; +} + #search_widget { position: absolute; top: 20px; @@ -527,18 +535,21 @@ table tr.header { font-size: 18px; } +#menu_search_results p, #search_results p { margin: 0; padding: 4px 4px 0 4px; border-bottom: 1px solid #e8e8e8; } +#menu_search_results p a, #search_results p a { display: block; font-weight: bold; font-size: 0.95rem; } +#menu_search_results p span.result_path, #search_results p span.result_path { display: block; margin-top: 2px; @@ -548,6 +559,7 @@ table tr.header { padding-bottom: 4px; } +#menu_search_results p.search_more, #search_results p.search_more { margin: 0; padding: 6px 4px; -- cgit v1.3 From 36b5324900968afc7498950c14ad13be5c061a7c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 16:49:45 +0200 Subject: Restructure nodes#show and nodes#new around node_description/node_content nodes#show's flat of label/value pairs grew unreadable once Admin Preview and Public Preview were added alongside Public Link - switched to the div.node_description/div.node_content pattern already used by nodes#edit, grouped into People/Dates/Links/Revisions/Tags/ Events/Children sections rather than one undifferentiated list. People/Dates/Links use a flex-based item layout, not a second
- multiple independent tables sharing one outer width but different column counts produced misaligned columns with no shared grid. Flex items size to their own content with a shared minimum instead. Revisions and Children are both collapsed via
, no JS needed - previously nodes#show didn't list a node's children at all, blocking the ability to find/navigate erfa and chaostreff pages without already knowing they're now standalone nodes. Revision list items link to node_revision_path(@node, page) - the actual per-revision diff/restore view, not the plain index. nodes#new converted to the same pattern for visual consistency, plus a few things surfaced along the way: submit buttons were unstyled site-wide (a bare input[type=submit] with no border, fill, or hover state - Create on this exact page was easy to miss entirely), fixed with a bordered/bold treatment reusing the existing form.button_to visual language rather than introducing a fourth button style. Title regained the bold weight it had before the table rewrite dropped it silently. Tag list grouping and full link/button semantic taxonomy (show vs edit/add vs publish/revoke vs destroy, applied consistently across every link on the page, not just buttons) are known follow-ups, not attempted here. --- app/views/nodes/new.html.erb | 57 ++++++------ app/views/nodes/show.html.erb | 204 ++++++++++++++++++++++++------------------ public/stylesheets/admin.css | 64 +++++++++---- 3 files changed, 192 insertions(+), 133 deletions(-) (limited to 'public') diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index f557454..0a05325 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -6,13 +6,11 @@ <% end %> -

What kind of node do you want to create?

- <%= form_tag nodes_path do %> -
- - - - - - - - - - - - - - - - - - - - -
Type +
+ +
Type
+
<% CccConventions::NODE_KINDS.each do |kind, config| %>

<%= radio_button_tag :kind, kind, kind == "generic", @@ -23,35 +21,32 @@ <% end %>

<% end %> -
Title<%= text_field_tag :title %> + + +
Title
+
+ <%= text_field_tag :title %> A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field. -
Parent + + +
+
Parent
+
<%= text_field_tag :parent_search_term, @parent_name %> <%= hidden_field_tag :parent_id, @parent_id %> -
- -
-
Resulting path +
+ + + +
Resulting path
+
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). -
<%= submit_tag "Create" %>
+ + +
+
<%= submit_tag "Create" %>
+ + <% end %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index d63df86..189adb8 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,95 +1,129 @@ <% content_for :subnavigation do %> <%= link_to 'Edit', edit_node_path(@node), :class => "unselected" %> <%= link_to 'Preview', preview_page_path(@page) %> - <%= link_to 'Revisions', node_revisions_path(@node) %> - <%= unlock_link if @node.locked? %> <% end %> -
- - - - - - <% if @node.draft %> - - - - - - - - - <% end %> - - - - - - - - - <% if @page.node.locked? %> - - - - - <% end %> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Public Link<%= link_to @page.public_link, content_url(@node.unique_path) %>
Admin Preview<%= link_to preview_page_path(@node.draft), preview_page_path(@node.draft) %>
Public Preview - <% if @node.draft.preview_token.present? %> - <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %> - <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." } } %> - <% else %> - <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> +
+ +
People
+
+
+
+ Author + <%= @page.user.try(:login) %> +
+
+ Editor + <%= @page.editor.try(:login) %> +
+ <% if @node.locked? %> +
+ Locked by + <%= @node.lock_owner.login %> + <%= unlock_link %> +
<% end %> -
Author<%= @page.user.try(:login) %>
Editor<%= @page.editor.try(:login) %>
Locked by<%= @page.node.lock_owner.login %>
Last updated<%= @page.updated_at %>
Published at<%= @page.published_at %>
Revision<%= @page.revision %>
Tagged with:<%= @page.tag_list %>
Events -
    - <% @node.events.order(:start_time).each do |event| %> -
  • - <%= event_schedule_text(event) %> - [<%= link_to 'show', event_path(event, return_to: request.path) %> - | <%= link_to 'edit', edit_event_path(event, return_to: request.path) %>] -
  • + + + +
    Dates
    +
    +
    +
    + Last updated + <%= @page.updated_at %> +
    + <% if @page.published_at.present? %> +
    + <%= @page.public? ? 'Published at' : 'Will publish at' %> + <%= @page.published_at %> +
    + <% end %> +
    +
    + +
    Links
    +
    +
    +
    + Public + <%= link_to @page.public_link, content_url(@node.unique_path) %> +
    + <% if @node.draft %> +
    + Admin Preview + <%= link_to preview_page_path(@node.draft), preview_page_path(@node.draft) %> +
    +
    + Public Preview + <% if @node.draft.preview_token.present? %> + <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %> + <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." } } %> + <% else %> + <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> + <% end %> +
    <% end %> +
    +
    + +
    Revisions
    +
    +
    + + <%= pluralize(@node.pages.count, 'revision', 'revisions') %> + · <%= link_to 'full history (diff / restore)', node_revisions_path(@node) %> + +
      + <% @node.pages.order(:revision).each do |page| %> +
    • <%= link_to "##{page.revision} — #{page.title} (#{page.user.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %>
    • + <% end %>
    - <% mapping = default_event_tag_mapping(@page) %> - <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> -
Title<%= sanitize( @page.title ) %>
Abstract<%= sanitize( @page.abstract ) %>
Body<%= sanitize( @page.body ) %>
+ +
+ +
Tags
+
<%= @page.tag_list %>
+ +
Events
+
+
    + <% @node.events.order(:start_time).each do |event| %> +
  • + <%= event_schedule_text(event) %> + [<%= link_to 'show', event_path(event, return_to: request.path) %> + | <%= link_to 'edit', edit_event_path(event, return_to: request.path) %>] +
  • + <% end %> +
+ <% mapping = default_event_tag_mapping(@page) %> + <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> +
+ + <% if @node.children.any? %> +
Children
+
+
+ <%= pluralize(@node.children.count, 'child', 'children') %> +
    + <% @node.children.order(:slug).each do |child| %> +
  • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
  • + <% end %> +
+
+
+ <% end %> + +
Title
+
<%= sanitize(@page.title) %>
+ +
Abstract
+
<%= sanitize(@page.abstract) %>
+ +
Body
+
<%= sanitize(@page.body) %>
+ <%# Assets not yet addressed - no confirmed model/association seen for this page's attached assets %> + + diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index f240bb2..1196d83 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -106,7 +106,6 @@ div.pagination span.current, div.pagination a:hover { #main_navigation a, #sub_navigation a, #metadata a, -input[type=submit], #overview_toggle a { letter-spacing: 1px; padding-left: 5px; @@ -316,17 +315,23 @@ input[type=password] { border: 1px solid #989898; } -input[type=submit] { - border: none; - padding-top: 0px; - padding-bottom: 1px; - background-color: #ffffff; +input[type="submit"] { + -webkit-appearance: none; + appearance: none; + background: none; + border: 1px solid #000000; + border-radius: 2px; + padding: 4px 12px; + font: inherit; + font-weight: bold; + color: inherit; + cursor: pointer; + text-decoration: none; } -input[type=submit]:hover { - border: none; +input[type="submit"]:hover { color: #ffffff; - background-color: #ff9600; + background-color: #000000; } input[type=radio] { @@ -516,6 +521,39 @@ table tr.header { padding-bottom: 4px; } +.node_content.node_info_group { + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.node_info_group_items { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2rem; +} + +.node_info_item { + min-width: 140px; +} + +.node_info_label { + display: block; + font-weight: normal; + color: #969696; + font-size: 0.85rem; + text-transform: lowercase; +} + +.node_info_group ul { + margin: 0; + padding-left: 1.25rem; +} + +.node_info_group li { + margin-bottom: 0.25rem; +} + #search_widget { position: absolute; top: 20px; @@ -569,14 +607,6 @@ table tr.header { border-bottom: none; } -table#content th.description { - width: 100px; -} - -table#content th.content { - width: 690px; -} - #menu_item_list { border-collapse: collapse; padding: 5px 5px 5px 5px; -- cgit v1.3 From 1bf719d6ac58187cf406d92a40b665d3fa6e658b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 03:48:09 +0200 Subject: Add context-aware child-creation shortcuts to nodes#show parent_match Procs on CccConventions::NODE_KINDS, matched against unique_path, decide which "add child" kinds show on a given node. Fixes nodes#new not honoring a pre-selected kind (radio group and parent-field visibility both defaulted to "generic" unconditionally). --- app/controllers/nodes_controller.rb | 1 + app/helpers/nodes_helper.rb | 5 +++ app/views/nodes/new.html.erb | 4 +-- app/views/nodes/show.html.erb | 29 +++++++++++++----- lib/ccc_conventions.rb | 61 ++++++++++++++++++++----------------- public/javascripts/admin_search.js | 18 +++++++---- public/stylesheets/admin.css | 8 +++++ 7 files changed, 82 insertions(+), 44 deletions(-) (limited to 'public') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 69aa268..a72be68 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -22,6 +22,7 @@ class NodesController < ApplicationController def new @node = Node.new node_params + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" if params.has_key?(:parent_id) @parent_id = params[:parent_id] @parent_name = Node.find(@parent_id).title diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 2baf813..a89f879 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -58,4 +58,9 @@ module NodesHelper t(:event_schedule_none) end end + + def matching_node_kinds(node) + path = node.unique_path + CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } + end end diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 0a05325..71f2fbf 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,7 +13,7 @@
<% CccConventions::NODE_KINDS.each do |kind, config| %>

- <%= radio_button_tag :kind, kind, kind == "generic", + <%= radio_button_tag :kind, kind, kind == @selected_kind, data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> <%= resolve_kind_text(config[:label]) %> <% if config[:hint] %> @@ -29,7 +29,7 @@ A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.

-
+
">
Parent
<%= text_field_tag :parent_search_term, @parent_name %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 189adb8..963bc37 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -101,17 +101,30 @@ <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %>
- <% if @node.children.any? %> + <% matches = matching_node_kinds(@node) %> + <% if @node.children.any? || matches.any? %>
Children
-
- <%= pluralize(@node.children.count, 'child', 'children') %> -
    - <% @node.children.order(:slug).each do |child| %> -
  • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
  • + <% if @node.children.any? %> +
    + <%= pluralize(@node.children.count, 'child', 'children') %> +
      + <% @node.children.order(:slug).each do |child| %> +
    • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
    • + <% end %> +
    +
    + <% end %> + <% if matches.any? %> +
-
+

+ <% end %>
<% end %> diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index c0f9943..b420452 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -4,44 +4,49 @@ module CccConventions NODE_KINDS = { "top_level" => { - parent: -> { Node.root }, - path_prefix: "", - label: "Top Level" + parent: -> { Node.root }, + parent_match: ->(path) { path == [] }, + path_prefix: "", + label: "Top Level" }, "generic" => { - label: "Generic", - hint: "Can be created anywhere - choose the parent below." - # no path_prefix - depends on whatever parent gets chosen; see below + parent_match: ->(path) { true }, + label: "Generic", + hint: "Can be created anywhere - choose the parent below." }, "update" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Update", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Update", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } }, "press_release" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update", "pressemitteilung"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Pressemitteilung", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update", "pressemitteilung"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Pressemitteilung", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } }, "erfa" => { - parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, - tags: ["erfa-detail"], - template: "chapter_detail", - path_prefix: ERFA_PARENT_NAME, - label: "Erfa", - hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "erfas"] }, + tags: ["erfa-detail"], + template: "chapter_detail", + path_prefix: ERFA_PARENT_NAME, + label: "Erfa", + hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." }, "chaostreff" => { - parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, - tags: ["chaostreff-detail"], - template: "chapter_detail", - path_prefix: CHAOSTREFF_PARENT_NAME, - label: "Chaostreff", - hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "chaostreffs"] }, + tags: ["chaostreff-detail"], + template: "chapter_detail", + path_prefix: CHAOSTREFF_PARENT_NAME, + label: "Chaostreff", + hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." } }.freeze end diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 2565929..6ef9087 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -208,14 +208,20 @@ parent_search = { }, initialize_radio_buttons : function() { - $("input[name='kind']").bind("change", function(){ - if ($(this).val() === "generic") { - $("#parent_search_field").show(); - } else { - $("#parent_search_field").hide(); - } + parent_search.sync_parent_field(); + $("input[name='kind']").bind("change", function() { + parent_search.sync_parent_field(); parent_search.update_resulting_path(); }); + }, + + sync_parent_field : function() { + var kind = $("input[name='kind']:checked").val(); + if (kind === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1196d83..3033798 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -338,6 +338,14 @@ input[type=radio] { border: 1px solid #989898; } +.add_child_links { + margin-top: 0.5rem; +} + +.add_child_links a { + white-space: nowrap; +} + div#login_form input[type=text], div#login_form input[type=password] { width: 150px; } -- cgit v1.3 From 4be318cf508f164e3cac1ee3854a0497c8223275 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 13:14:46 +0200 Subject: Add link and lists to tinymce, wash pasted blocks clean of classes --- public/javascripts/admin_interface.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'public') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index f68fee8..450438f 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -18,11 +18,12 @@ $(document).ready(function () { license_key: 'gpl', promotion: false, menubar: false, - plugins: 'code', + plugins: 'code link lists visualblocks', toolbar: 'bold italic underline | bullist numlist | link unlink | blocks | code', - extended_valid_elements: 'aggregate[tags|limit|order_by|order_direction|partial|conditions]', + extended_valid_elements: 'aggregate[children|tags|limit|order_by|order_direction|partial|conditions]', relative_urls: false, entity_encoding: 'raw', + valid_classes: { '*': '' }, setup: function(editor) { editor.on('init', function() { cccms.setup_autosave(); -- cgit v1.3 From ae29567bea75b28bd3f5655b6bf3e6444d51eb38 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:41:36 +0200 Subject: Reorganize admin.css and overhaul button/link/flash visual hierarchy Sorts rules by hierarchy (base -> layout -> navigation -> buttons -> tables -> forms -> components), consolidates three duplicated media query breakpoints into one each, and adds section comments. Bundled with this reorganization, since the file was rewritten wholesale: - form.button_to.state_changing: new tinted-pill variant (blue) for Publish/Restore/Unlock/Revoke, matching .destructive's pattern - form.button_to.destructive: now a tinted pill at rest instead of plain colored text, consistent padding at rest and on hover - Scoped wavy-underline link-visibility fix for #page_editor, table.node_table, table.assets_table, table.events_table, table.user_table, .add_child_links, and the dashboard draft list - #flash decoupled from the page's structural nav-to-content spacing (.admin_content_spacer), which the flash div was silently providing as a side effect whenever it rendered --- app/views/layouts/admin.html.erb | 4 +- public/stylesheets/admin.css | 709 ++++++++++++++++++++++----------------- 2 files changed, 413 insertions(+), 300 deletions(-) (limited to 'public') diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index a7ce68a..340eaf2 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -29,13 +29,15 @@ <%= yield :subnavigation %>
-
+
+ <% if flash[:notice].present? || flash[:error].present? %>
<%= flash[:notice] %> <% if flash[:error] %> <%= flash[:error] %> <% end %>
+ <% end %>
<%= yield %>
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 3033798..94248a6 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -1,4 +1,6 @@ -/* Layout */ +/* ============================================================ + Base elements + ============================================================ */ body { font-family: Helvetica, Arial, sans-serif; @@ -15,33 +17,160 @@ a:hover { color: #ff9600; } +/* Scoped link visibility: a light, desaturated squiggly underline + signals "clickable" at rest without the weight of a solid line. + Hover still relies on the global a:hover color change above -- this + only adds an at-rest affordance for plain content links that would + otherwise be indistinguishable from body text. Covers: page editor + body content, node listing tables, the child-creation shortcuts on + nodes#show, and the dashboard draft list's Show/Revisions links. */ +#page_editor a, +table.node_table a, +table.assets_table a, +table.events_table a, +table.user_table a, +.add_child_links a, +div#draft_list table td.actions a { + text-decoration: underline; + -webkit-text-decoration-style: wavy; + text-decoration-style: wavy; + text-decoration-color: #b0b0b0; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + th { text-transform: lowercase; } +td { + padding-top: 10px; + padding-bottom: 5px; + padding-right: 10px; +} +/* table#revisions td only overrides padding-right, so its rows still + depend on this rule for vertical padding. table.node_table td resets + padding-top/bottom to 0 explicitly and does not depend on it. */ + +input[type=text], textarea { + font-size: 1rem; + font-family: Helvetica; + border: 1px solid #989898; +} + +select { + font-size: 1rem; + border: 1px solid #989898; +} + +input[type=password] { + border: 1px solid #989898; +} + +input[type=radio] { + border: 1px solid #989898; +} + +/* ============================================================ + Layout + ============================================================ */ + +@media(min-width:1016px) { + #wrapper { + margin: 0 125px; + } +} +@media(max-width:1015px) { + #wrapper { + margin: 0; + width: 100%; + box-sizing: border-box; + } +} + #metadata, #content { margin-left: 5px; } -@media(min-width:1016px) { -#wrapper { - margin: 0 125px; +.right { text-align: right; } +.clear_left { clear: left; } + +/* ============================================================ + Navigation + ============================================================ */ + +#navigation { + position: relative; + margin-top: 10px; } + +#navigation div { float: left; } + +#navigation a:hover { + color: #ffffff; + background-color: #000000; } -@media(max-width:1015px) { -#wrapper { - margin: 0; - width: 100%; - box-sizing: border-box; + +#navigation a.selected, +#metadata a.selected, +#overview_toggle a.selected { + color: #ffffff; + background-color: #000000; } + +#main_navigation a, +#sub_navigation a, +#metadata a, +#overview_toggle a { + letter-spacing: 1px; + padding-left: 5px; + padding-right: 5px; + padding-top: 1px; + padding-bottom: 1px; + text-transform: lowercase; +} + +#metadata a:hover { + color: #ffffff; + background-color: #ff9600; +} + +/* #sub_navigation covers 13 views' Show/Preview/Revisions/metadata + links (see `grep -rn "content_for :subnavigation" app/views/`), + including nodes#edit -- so #page_editor's own subnav links are + already covered here and are not part of the new scoped-underline + rule above. Proposed for full removal per the redesign notes; the + 13-file list is the confirmation needed before scoping that work. */ +#sub_navigation a { + color: #969696; +} + +#sub_navigation a:hover { + color: #ffffff; + background-color: #ff9600; +} + +#sub_navigation a.selected { + color: #ffffff; + background-color: #ff9600; } +.admin_content_spacer { + clear: both; + margin-bottom: 40px; +} + +/* ============================================================ + Flash / notices + ============================================================ */ #flash { - height: 12px; + height: auto; line-height: 20px; - margin-top: 40px; + padding: 6px 10px; + border-left: 3px solid #969696; + background-color: #f1f1f1; margin-bottom: 20px; margin-left: 5px; } @@ -52,7 +181,6 @@ th { text-transform: lowercase; } - #flash img { height: 12px; width: 12px; @@ -68,7 +196,9 @@ span#flash_error, span.warning { padding-bottom: 1px; } -/* Pagination */ +/* ============================================================ + Pagination + ============================================================ */ div.pagination { padding-top: 5px; @@ -79,43 +209,12 @@ div.pagination span.current, div.pagination a:hover { color: #ff9600; } -/* Styles */ - -.right { - text-align: right; -} - -.clear_left { - clear: left; -} - -#navigation { - position: relative; - margin-top: 10px; -} - -#navigation div { - float: left; -} - -#navigation a:hover { - color: #ffffff; - background-color: #000000; -} - -#main_navigation a, -#sub_navigation a, -#metadata a, -#overview_toggle a { - letter-spacing: 1px; - padding-left: 5px; - padding-right: 5px; - padding-top: 1px; - padding-bottom: 1px; - text-transform: lowercase; -} +/* ============================================================ + Buttons + ============================================================ */ -/* button_to forms: render submit inputs as plain links */ +/* button_to forms: render submit inputs/buttons as plain links, + orange on hover; .destructive variant is red. */ form.button_to { display: inline; margin: 0; @@ -136,31 +235,64 @@ form.button_to button[type="submit"] { text-decoration: none; } -form.button_to input[type="submit"]:hover, -form.button_to button[type="submit"]:hover { - color: #ff9600; +form.button_to.state_changing input[type="submit"], +form.button_to.state_changing button[type="submit"] { + color: #0d47a1; + background-color: #e3f2fd; + border-radius: 2px; + padding: 2px 8px; +} + +form.button_to.state_changing input[type="submit"]:hover, +form.button_to.state_changing button[type="submit"]:hover { + color: #ffffff; + background-color: #1565c0; } form.button_to.destructive input[type="submit"], form.button_to.destructive button[type="submit"] { color: #cc0000; + background-color: #fdecea; + border-radius: 2px; + padding: 2px 8px; } form.button_to.destructive input[type="submit"]:hover, form.button_to.destructive button[type="submit"]:hover { color: #ffffff; background-color: #cc0000; +} + +/* Native (non-button_to) submit buttons -- Create/Save/Publish forms. + Lower specificity than form.button_to's own rule, so button_to forms + are correctly unaffected. If a button_to output ever loses its class + wrapper, it will silently fall through to this bordered style instead + of rendering as a plain link -- worth knowing, not necessarily fixing. */ +input[type="submit"] { + -webkit-appearance: none; + appearance: none; + background: none; + border: 1px solid #000000; border-radius: 2px; - padding: 0 3px; + padding: 4px 12px; + font: inherit; + font-weight: bold; + color: inherit; + cursor: pointer; + text-decoration: none; } -#admin_wizard { - margin-bottom: 2rem; +input[type="submit"]:hover { + color: #ffffff; + background-color: #000000; } -#page_submit a, -.admin_wizard_button a { - color: white !important; +/* ============================================================ + Wizard buttons + ============================================================ */ + +#admin_wizard { + margin-bottom: 2rem; } #page_submit, @@ -173,32 +305,30 @@ form.button_to.destructive button[type="submit"]:hover { background-color: green; } +#page_submit a, +.admin_wizard_button a { + color: white !important; +} + #page_submit:hover, .admin_wizard_button:hover { background-color: lime !important; } -#sub_navigation a { - color: #969696; -} +/* ============================================================ + Tables + ============================================================ */ -#sub_navigation a:hover, #metadata a:hover { - color: #ffffff; - background-color: #ff9600; -} - -#sub_navigation a.selected { - color: #ffffff; - background-color: #ff9600; -} - -#navigation a.selected, #metadata a.selected, #overview_toggle a.selected { - color: #ffffff; - background-color: #000000; +/* Generic fallback header row. Shadowed by table.node_table's own + tr.header (2rem) and duplicated (same values) by table#revisions's + own tr.header below. Applies as-is to #menu_item_list, + div#draft_list table, and table.user_table, none of which define + their own tr.header. */ +table tr.header { + height: 20px; + text-align: left; } -/* Nodes */ - table.node_table { border-collapse: collapse; } @@ -214,6 +344,9 @@ table.node_table th.title { min-width: 4rem; } +/* th.title's min-width is set to 4rem above and 12rem here -- the + later, equally-specific rule wins, so th.title ends up at 12rem and + the 4rem entry for it above is dead. Likely a copy/paste leftover. */ table.node_table th.editor, table.node_table th.title { min-width: 12rem; @@ -237,6 +370,10 @@ table.node_table .node_id { padding-right: 15px; } +table.node_table .actions { + text-transform: lowercase; +} + table.node_table tr.header:hover { background-color: #ffffff; } @@ -245,20 +382,25 @@ table.node_table tr:hover { background-color: #f1f1f1; } -table.node_table .actions { - text-transform: lowercase; -} - #admin_sitemap_table .node_id:before { content: "• "; } -/* Revisions */ - table#revisions { border-collapse: collapse; } +table#revisions tr { + height: 45px; +} + +/* Redundant with the generic table tr.header rule above (identical + values) -- harmless, kept presumably for explicitness. */ +table#revisions tr.header { + height: 20px; + text-align: left; +} + table#revisions td { padding-right: 25px; } @@ -283,61 +425,91 @@ table#revisions tr:hover { background-color: #f1f1f1; } -table#revisions tr.header { - height: 20px; - text-align: left; +table.user_table td.user_login { + padding-right: 30px; } -table#revisions tr { - height: 45px +/* ============================================================ + Node display (nodes#show / nodes#new) + ============================================================ */ + +@media(min-width:1016px) { + div.node_description { + float: left; + width: 110px; + min-height: 2rem; + text-align: right; + text-transform: lowercase; + vertical-align: top; + } + + div.node_content { + margin-left: 120px; + min-height: 2rem; + display: block; + margin-bottom: 1rem; + } } +@media(max-width:1015px) { + div.node_description { + min-height: 2rem; + width: 30em; + text-transform: lowercase; + vertical-align: top; + } -/* Page Editor */ + div.node_content { + min-height: 2rem; + margin-bottom: 1rem; + } +} -input[type=text], textarea { - font-size: 1rem; - font-family: Helvetica; - border: 1px solid #989898; +.node_content.node_info_group { + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 0.5rem 0.75rem; } -select { - font-size: 1rem; - border: 1px solid #989898; +.node_info_group_items { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2rem; } -input[type=text]#tag_list, -input[type=text]#node_staged_slug, -input#move_to_search_term { - padding: 5px; +.node_info_item { + min-width: 140px; } -input[type=password] { - border: 1px solid #989898; +.node_info_label { + display: block; + font-weight: normal; + color: #969696; + font-size: 0.85rem; + text-transform: lowercase; } -input[type="submit"] { - -webkit-appearance: none; - appearance: none; - background: none; - border: 1px solid #000000; - border-radius: 2px; - padding: 4px 12px; - font: inherit; - font-weight: bold; - color: inherit; - cursor: pointer; - text-decoration: none; +.node_info_group ul { + margin: 0; + padding-left: 1.25rem; } -input[type="submit"]:hover { - color: #ffffff; - background-color: #000000; +.node_info_group li { + margin-bottom: 0.25rem; } -input[type=radio] { - border: 1px solid #989898; +.field_hint { + display: block; + margin-top: 2px; + font-size: 0.75rem; + color: #969696; + padding-bottom: 4px; } +/* Identical declaration block to #search_results p span.result_path / + #menu_search_results p span.result_path below -- three independent + copies of the same "small gray helper text" style. */ +/* Layout only -- the at-rest visibility (wavy underline) for these + links comes from the scoped rule in Base elements above. */ .add_child_links { margin-top: 0.5rem; } @@ -346,92 +518,9 @@ input[type=radio] { white-space: nowrap; } -div#login_form input[type=text], div#login_form input[type=password] { - width: 150px; -} - - -@media(min-width:1016px) { -input#tag_list, -input#node_staged_slug, -input#move_to_search_term { - width: 690px; -} - -input[type=text]#page_title { - width: 690px; - font-size: 1rem; - padding-top: 6px; - padding-bottom: 4px; - padding-left: 5px; - padding-right: 5px; - font-weight: bold; -} - -textarea#page_abstract { - width: 690px; - height: 150px; - padding: 5px; -} - -#page_editor textarea#page_body { - width: 700px; - height: 600px; -} - -#page_editor #metadata, #page_editor #content, #admin_overview { - margin-left: -125px; -} - -} - -/* Mobile */ -@media(max-width:1015px) { -input#tag_list, -input#node_staged_slug, -input#move_to_search_term { - box-sizing: border-box; - width: 100%; -} - -#page_editor #content, -#page_editor #metadata { - padding-right: 5px; -} - -#page_editor textarea#page_body { - box-sizing: border-box; - width: 800px; - height: 50rem; -} - -#page_editor textarea#page_abstract { - box-sizing: border-box; - height: 8rem; - width: 100%; -} - -input[type=text]#page_title { - box-sizing: border-box; - width: 100%; - font-size: 2rem; - padding-top: 6px; - padding-bottom: 4px; - padding-left: 5px; - font-weight: bold; -} - -input[type=text], textarea { - font-size: 1.5rem; -} -select { - font-size: 1.5rem; -} - -#metadata ul#image_box { - width: 100% !important; -} -} +/* ============================================================ + Page editor / metadata forms + ============================================================ */ #new_node { margin-left: -118px; @@ -441,44 +530,14 @@ select { width: 350px; } -tr {} - -td { - padding-top: 10px; - padding-bottom: 5px; - padding-right: 10px; -} - -@media(min-width:1016px) { -div.node_description { - float: left; - width: 110px; - min-height: 2rem; - text-align: right; - text-transform: lowercase; - vertical-align: top; -} - -div.node_content { - margin-left: 120px; - min-height: 2rem; - display: block; - margin-bottom: 1rem; -} -} -@media(max-width:1015px) { -div.node_description { - min-height: 2rem; - width: 30em; - text-transform: lowercase; - vertical-align: top; -} - -div.node_content { - min-height: 2rem; - margin-bottom: 1rem; +div#login_form input[type=text], div#login_form input[type=password] { + width: 150px; } +input[type=text]#tag_list, +input[type=text]#node_staged_slug, +input#move_to_search_term { + padding: 5px; } div#page_editor { @@ -486,6 +545,95 @@ div#page_editor { margin-left: 10px; } +@media(min-width:1016px) { + input#tag_list, + input#node_staged_slug, + input#move_to_search_term { + width: 690px; + } + + input[type=text]#page_title { + width: 690px; + font-size: 1rem; + padding-top: 6px; + padding-bottom: 4px; + padding-left: 5px; + padding-right: 5px; + font-weight: bold; + } + + textarea#page_abstract { + width: 690px; + height: 150px; + padding: 5px; + } + + #page_editor textarea#page_body { + width: 700px; + height: 600px; + } + + #page_editor #metadata, #page_editor #content, #admin_overview { + margin-left: -125px; + } +} + +@media(max-width:1015px) { + input#tag_list, + input#node_staged_slug, + input#move_to_search_term { + box-sizing: border-box; + width: 100%; + } + + #page_editor #content, + #page_editor #metadata { + padding-right: 5px; + } + + /* Fixed 800px width will overflow narrow viewports despite + border-box -- every sibling rule in this block uses 100% instead. + Worth checking on an actual phone; looks unfinished rather than + intentional. Not changed here since it wasn't part of what you + asked to fix. */ + #page_editor textarea#page_body { + box-sizing: border-box; + width: 800px; + height: 50rem; + } + + #page_editor textarea#page_abstract { + box-sizing: border-box; + height: 8rem; + width: 100%; + } + + input[type=text]#page_title { + box-sizing: border-box; + width: 100%; + font-size: 2rem; + padding-top: 6px; + padding-bottom: 4px; + padding-left: 5px; + font-weight: bold; + } + + input[type=text], textarea { + font-size: 1.5rem; + } + select { + font-size: 1.5rem; + } + + #metadata ul#image_box { + width: 100% !important; + } +} + +/* ============================================================ + Draft list (dashboard widget) + ============================================================ */ + div#draft_list { border: 1px solid #000000; padding: 5px; @@ -501,66 +649,26 @@ div#draft_list table tr.header:hover { background-color: #ffffff; } +div#draft_list table tr:hover { + background-color: #f1f1f1; +} + div#draft_list table td.actions { text-transform: lowercase; white-space: nowrap; } +/* Layout only -- the at-rest visibility (wavy underline) for these + links comes from the scoped rule in Base elements above. */ div#draft_list table td.actions a { letter-spacing: 1px; padding-left: 5px; padding-right: 5px; } -div#draft_list table tr:hover { - background-color: #f1f1f1; -} - -table tr.header { - height: 20px; - text-align: left; -} - -.field_hint { - display: block; - margin-top: 2px; - font-size: 0.75rem; - color: #969696; - padding-bottom: 4px; -} - -.node_content.node_info_group { - border: 1px solid #e8e8e8; - border-radius: 6px; - padding: 0.5rem 0.75rem; -} - -.node_info_group_items { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 2rem; -} - -.node_info_item { - min-width: 140px; -} - -.node_info_label { - display: block; - font-weight: normal; - color: #969696; - font-size: 0.85rem; - text-transform: lowercase; -} - -.node_info_group ul { - margin: 0; - padding-left: 1.25rem; -} - -.node_info_group li { - margin-bottom: 0.25rem; -} +/* ============================================================ + Search widgets + ============================================================ */ #search_widget { position: absolute; @@ -615,6 +723,10 @@ table tr.header { border-bottom: none; } +/* ============================================================ + Menu items (navigation editor) + ============================================================ */ + #menu_item_list { border-collapse: collapse; padding: 5px 5px 5px 5px; @@ -631,6 +743,10 @@ table tr.header { height: 20px; } +#menu_item_list td.menu_item_title { + width: 200px; +} + #menu_item_list td.menu_sort_handle div { background-color: #989898; height: 26px; @@ -649,14 +765,14 @@ table tr.header { cursor: grab; } -#menu_item_list td.menu_item_title { - width: 200px; -} - -.ui-state-highlight td{ +.ui-state-highlight td { height: 20px; } +/* ============================================================ + Image box / browser + ============================================================ */ + #metadata ul#image_box { box-sizing: border-box; margin: 0; @@ -681,8 +797,3 @@ div#image_browser { div#image_browser ul li { list-style-type: none; } - -table.user_list td.user_login { - padding-right: 30px; -} - -- cgit v1.3 From 3d7ddaf18419c983dd179b50eabe253c3eee8194 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:55:25 +0200 Subject: WIP: dynamic rrule builder --- app/views/events/edit.html.erb | 30 ++++++++++++++++++++++++++++-- public/javascripts/admin_interface.js | 6 +++++- 2 files changed, 33 insertions(+), 3 deletions(-) (limited to 'public') diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 5aee501..d37c299 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -18,8 +18,34 @@ <%= f.datetime_select :end_time %>

- <%= f.label :rrule %>
- <%= f.text_field :rrule %> + <%= f.label :rrule, "Recurrence" %> +

+

+ + +

+
+

+

+ <% %w[MO TU WE TH FR SA SU].each do |code| %> + + <% end %> +

+
+ +

+ + <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %> +

+ Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them. +
+ <%= f.text_field :rrule, id: "event_rrule" %>

<%= f.label :tag_list, "Tags" %>
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 450438f..cb32d5a 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -50,7 +50,11 @@ $(document).ready(function () { if ($("#move_to_search_term").length != 0) { move_to_search.initialize_search(); } - + + if ($("#rrule_builder").length != 0) { + rrule_builder.initialize(); + } + if ($('#recent_changes_toggle').length != 0) { hide_all(); $('#recent_changes_toggle').attr("class", "selected"); -- cgit v1.3 From a6140897f2fbb0f59a639a931e23751fb93719a2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:57:32 +0200 Subject: Whitespace fixes --- public/javascripts/admin_interface.js | 72 +++++++++++++++++------------------ 1 file changed, 36 insertions(+), 36 deletions(-) (limited to 'public') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index cb32d5a..67bfc02 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -12,7 +12,7 @@ function hide_all() { $(document).ready(function () { admin_search.initialize(); - + tinymce.init({ selector: 'textarea.with_editor', license_key: 'gpl', @@ -29,24 +29,24 @@ $(document).ready(function () { cccms.setup_autosave(); }); } - }); + }); if ($("#menu_search_term").length != 0) { menu_items.initialize_search(); } - + if ($("#menu_item_list").length != 0) { menu_item_sorter.initialize(); } - + if ($("#metadata").length != 0) { meta_data.initialize(); } - + if ($("#parent_search_term").length != 0) { parent_search.initialize_search(); } - + if ($("#move_to_search_term").length != 0) { move_to_search.initialize_search(); } @@ -59,14 +59,14 @@ $(document).ready(function () { hide_all(); $('#recent_changes_toggle').attr("class", "selected"); $('#recent_changes_table').show(); - + $('#recent_changes_toggle').bind("click", function(){ hide_all(); $('#recent_changes_toggle').attr("class", "selected"); $('#recent_changes_table').show(); return false; }); - + $('#my_work_toggle').bind("click", function(){ hide_all(); $('#my_work_toggle').attr("class", "selected"); @@ -80,7 +80,7 @@ $(document).ready(function () { $('#my_work_table').show(); return false; }); - + $('#current_drafts_toggle').bind("click", function(){ hide_all(); $('#current_drafts_toggle').attr("class", "selected"); @@ -102,18 +102,18 @@ $(document).ready(function () { return false; }); } - - jQuery.ajaxSetup({ + + jQuery.ajaxSetup({ 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");} }); - + $(document).ajaxSend(function(event, request, settings) { if (typeof(AUTH_TOKEN) == "undefined") return; // settings.data is a serialized string like "foo=bar&baz=boink" (or null) settings.data = settings.data || ""; settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN); }); - + }); @@ -143,50 +143,50 @@ meta_data = { cccms = { setup_autosave : function() { - + var elements = { title : $('#page_title'), abstract : $('#page_abstract'), body : $('#page_body_ifr').contents().find('#tinymce'), } - - var page = { + + var page = { cached_title : elements.title.val(), cached_abstract : elements.abstract.val(), cached_body : elements.body.html(), - + title_has_changed : function() { return (elements.title.val() != this.cached_title) }, - + abstract_has_changed : function() { return (elements.abstract.val() != this.cached_abstract) }, - + body_has_changed : function() { return elements.body.html() != this.cached_body } } - + jQuery.fn.submitWithAjax = function(options) { if (page.title_has_changed() || page.abstract_has_changed() || page.body_has_changed()) { - + page.cached_title = elements.title.val(); page.cached_abstract = elements.abstract.val(); page.cached_body = elements.body.html(); - + $("#flash").append(""); $.post(this.attr("action"), $(this).serialize(), null, "script"); - + } }; - + setInterval('$("#page_editor > form").submitWithAjax()', 7000); } } menu_item_sorter = { - + initialize : function() { $("#menu_item_list").sortable({ axis: 'y', @@ -209,24 +209,24 @@ menu_item_sorter = { } }); }, - + placeholder_helper : function(e,ui) { $(".ui-state-highlight").html(""); } } image_interface = { - + initialize : function() { - + $("#image_browser").hide(); image_interface.initialize_sortable_image_box(); image_interface.connect_browser_and_box(); image_interface.set_droppable_behavior(); - image_interface.bind_image_browser_toggle(); + image_interface.bind_image_browser_toggle(); }, - - + + set_droppable_behavior : function() { $("ul#image_box").droppable({ out : function(event, ui) { @@ -242,7 +242,7 @@ image_interface = { } }); }, - + connect_browser_and_box : function() { $("#image_browser ul li").draggable({ connectToSortable : 'ul#image_box', @@ -250,9 +250,9 @@ image_interface = { revert : 'invalid' }); }, - + initialize_sortable_image_box : function() { - + $("ul#image_box").sortable({ revert : true, update : function(event, ui) { @@ -267,9 +267,9 @@ image_interface = { } }); } - }); + }); }, - + bind_image_browser_toggle : function() { $("#image_browser_toggle").bind("click", function(){ if ($("#image_browser_toggle").attr("class") == "unselected") { -- cgit v1.3 From 971cbc3f0087f288fec9ae0320bb2b22d600df5e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:06 +0200 Subject: Link new metadata behaviour and layout in css and javascript --- public/javascripts/admin_interface.js | 20 ++------------------ public/stylesheets/admin.css | 4 ++++ 2 files changed, 6 insertions(+), 18 deletions(-) (limited to 'public') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 67bfc02..4b1d4a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -119,24 +119,8 @@ $(document).ready(function () { meta_data = { initialize : function() { - $("#metadata").hide(); - - $("#button").click(function () { - - $("#metadata").slideToggle(1200); - image_interface.initialize(); - - if ($("#button").attr("class") == "unselected") { - $("#button").attr("class", "selected"); - - } - else { - $("#button").attr("class", "unselected"); - $("#image_browser").hide(); - $("#image_browser_toggle").attr("class", "unselected"); - } - - return false; + document.getElementById("metadata_details").addEventListener("toggle", function() { + if (this.open) image_interface.initialize(); }); } }; diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 94248a6..0c0779f 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -93,6 +93,10 @@ input[type=radio] { margin-left: 5px; } +#metadata_details summary { + margin-bottom: 1em; +} + .right { text-align: right; } .clear_left { clear: left; } -- cgit v1.3 From 618aca78a5574de3d5f5f275c73d33a5cdcb9df8 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:36 +0200 Subject: WIP: dynamic rrule builder, javascript side --- public/javascripts/admin_interface.js | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) (limited to 'public') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 4b1d4a9..3c480aa 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -271,3 +271,104 @@ image_interface = { } +rrule_builder = { + initialize : function() { + rrule_builder.try_populate_from_rrule($("#event_rrule").val()); + + $("input[name='rrule_freq']").bind("change", function() { + if ($(this).val() === "weekly") { rrule_builder.show_weekly_options(); } + else { rrule_builder.show_monthly_options(); } + rrule_builder.sync(); + }); + $("#rrule_monthly_ordinal").bind("change", function() { + $("#rrule_ordinal_fields").toggle($(this).is(":checked")); + rrule_builder.sync(); + }); + $("#rrule_exclude_month").bind("change", function() { + $("#rrule_excluded_month").toggle($(this).is(":checked")); + rrule_builder.sync(); + }); + $("#rrule_builder input, #rrule_builder select").bind("change", rrule_builder.sync); + }, + + show_weekly_options : function() { + $("#rrule_weekly_options").show(); + $("#rrule_monthly_options").hide(); + }, + show_monthly_options : function() { + $("#rrule_weekly_options").hide(); + $("#rrule_monthly_options").show(); + }, + + sync : function() { + var freq = $("input[name='rrule_freq']:checked").val(); + var parts = []; + + if (freq === "weekly") { + parts.push("FREQ=WEEKLY"); + if ($("#rrule_biweekly").is(":checked")) parts.push("INTERVAL=2"); + var days = []; + $("input[id^='rrule_byday_']:checked").each(function() { + days.push($(this).attr("id").replace("rrule_byday_", "")); + }); + if (days.length > 0) parts.push("BYDAY=" + days.join(",")); + } else { + parts.push("FREQ=MONTHLY"); + if ($("#rrule_monthly_ordinal").is(":checked")) { + parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); + } + } + + if ($("#rrule_exclude_month").is(":checked")) { + var excluded = parseInt($("#rrule_excluded_month").val(), 10); + var months = []; + for (var m = 1; m <= 12; m++) { if (m !== excluded) months.push(m); } + parts.push("BYMONTH=" + months.join(",")); + } + + $("#event_rrule").val(parts.join(";")); + }, + + try_populate_from_rrule : function(rrule) { + if (!rrule) return; + var parts = {}; + rrule.split(";").forEach(function(p) { + var kv = p.split("="); + parts[kv[0]] = kv[1]; + }); + if (parts.COUNT || parts.UNTIL) return; + + if (parts.FREQ === "WEEKLY") { + $("input[name='rrule_freq'][value='weekly']").prop("checked", true); + rrule_builder.show_weekly_options(); + if (parts.INTERVAL === "2") $("#rrule_biweekly").prop("checked", true); + if (parts.BYDAY) { + parts.BYDAY.split(",").forEach(function(code) { + $("#rrule_byday_" + code).prop("checked", true); + }); + } + } else if (parts.FREQ === "MONTHLY") { + $("input[name='rrule_freq'][value='monthly']").prop("checked", true); + rrule_builder.show_monthly_options(); + var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); + if (match) { + $("#rrule_monthly_ordinal").prop("checked", true); + $("#rrule_ordinal_fields").show(); + $("#rrule_ordinal").val(match[1]); + $("#rrule_ordinal_day").val(match[2]); + } + } else { + return; + } + + if (parts.BYMONTH) { + var included = parts.BYMONTH.split(",").map(function(s) { return parseInt(s, 10); }); + var missing = []; + for (var m = 1; m <= 12; m++) { if (included.indexOf(m) === -1) missing.push(m); } + if (missing.length === 1) { + $("#rrule_exclude_month").prop("checked", true); + $("#rrule_excluded_month").val(missing[0]).show(); + } + } + } +}; -- cgit v1.3 From 08b4cdb711ffe39153264a5c4024fac95cd8d91a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:46 +0200 Subject: Whitespace fixes --- public/javascripts/admin_interface.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'public') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 3c480aa..41699a1 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -264,12 +264,11 @@ image_interface = { $("#image_browser_toggle").attr("class", "unselected"); $("#image_browser").hide(); } - + return false; }); } } - rrule_builder = { initialize : function() { -- cgit v1.3 From 43e7743ebff1b1f41bbb5ae39bcc5bed7d8982e3 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:24:05 +0200 Subject: Autosave posts to its own endpoint surface lock loss immediately Previously piggybacked on the main form's action and relied on a .js.erb response to update #flash; now posts to the dedicated autosave route with an explicit _method=put override, and reacts to a 423 by stopping the timer and showing a persistent banner rather than letting the next Save discover the conflict on its own. --- app/views/nodes/edit.html.erb | 2 +- public/javascripts/admin_interface.js | 38 ++++++++++++++++++++++++++++++++--- public/stylesheets/admin.css | 5 +++++ 3 files changed, 41 insertions(+), 4 deletions(-) (limited to 'public') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4693569..1c6cc3a 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -5,7 +5,7 @@ <% end %>

-<%= form_for(@node) do |f| %> + <%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %>
    <% @node.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 41699a1..4107ce8 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -159,13 +159,45 @@ cccms = { page.cached_abstract = elements.abstract.val(); page.cached_body = elements.body.html(); - $("#flash").append(""); - $.post(this.attr("action"), $(this).serialize(), null, "script"); + var data = this.serializeArray().filter(function(field) { + return field.name !== "_method"; + }); + data.push({ name: "_method", value: "put" }); + + $.ajax({ + type: "POST", + url: this.attr("data-autosave-url"), + data: data, + error: function(xhr) { + if (xhr.status === 423) { + clearInterval(cccms.autosave_timer); + cccms.report_lock_lost($("#page_editor > form").attr("data-show-url")); + } + // any other failure: quietly retried on the next tick + } + }); } }; - setInterval('$("#page_editor > form").submitWithAjax()', 7000); + cccms.autosave_timer = setInterval(function() { + $("#page_editor > form").submitWithAjax(); + }, 7000); + }, + + report_lock_lost : function(show_url) { + var $flash = $("#flash"); + if ($flash.length === 0) { + $flash = $("
", { id: "flash" }).insertAfter(".admin_content_spacer"); + } + + var $banner = $("", { "class": "warning" }).text( + "This page is now locked by someone else — your changes here can no longer be saved. Copy anything you need, then " + ); + $banner.append($("", { href: show_url }).text("return to the published page")); + $banner.append("."); + + $flash.html($banner); } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 0c0779f..7ae374c 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -200,6 +200,11 @@ span#flash_error, span.warning { padding-bottom: 1px; } +span.warning a { + color: inherit; + text-decoration: underline; +} + /* ============================================================ Pagination ============================================================ */ -- cgit v1.3 From 6398042d3e009d54b15ad01faf993b3d97a203c6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:33:26 +0200 Subject: Add a action bar to the node edit view. This is the first application of a proposed new layout --- app/views/nodes/edit.html.erb | 45 +++++++++++++++++++++++++++++-------------- public/stylesheets/admin.css | 8 ++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) (limited to 'public') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c6cc3a..feba92a 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,9 +1,3 @@ -<% content_for :subnavigation do %> - <%= link_to 'Show', @node %> - <%= link_to 'Preview', preview_page_path(@draft) %> - <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> -<% end %> -
<%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %> @@ -14,6 +8,23 @@

<%= title_for_node(@node) %>

+
+ <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, + form: { class: 'button_to state_changing' }, + disabled: @node.autosave.present? %> + + <% if @node.autosave || (@node.draft && @node.head) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> + + <%= link_to 'Preview', preview_page_path(@page) %> + + <%= f.submit 'Save Draft' %> + <%= f.submit 'Save + Unlock + Exit' %> +
+
Metadata (slug, parent, tags, template, author, images)
@@ -38,12 +49,12 @@ %>
- <%= fields_for @draft do |d| %> + <%= fields_for @page do |d| %>
Tags - comma seperated
-
<%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
+
<%= text_field_tag :tag_list, @page.tag_list.join(', ') %>
Publish at
-
<%= d.datetime_select :published_at, :value => @draft.published_at %>
+
<%= d.datetime_select :published_at, :value => @page.published_at %>
Template
@@ -52,12 +63,15 @@
Author
-
<%= d.select :user_id, user_list %>
+
+ <%= d.select :user_id, user_list, + :selected => @page.user_id || @node.draft&.user_id || @node.head&.user_id %> +
Images
-
    - <% @draft.assets.images.each do |image| %> +
      + <% @page.assets.images.each do |image| %>
    • <%= image_tag(image.upload.url(:thumb)) %>
    • @@ -86,8 +100,11 @@
      Body
      <%= d.text_area :body, :class => 'with_editor' %>
      + <% end %> -
      <%= d.submit 'save' %>
      +
      + <%= f.submit 'Save Draft' %> + <%= f.submit 'Save + Unlock + Exit' %> +
      <% end %> -<% end %>
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 7ae374c..ceacf17 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -165,6 +165,14 @@ input[type=radio] { margin-bottom: 40px; } +#node_action_bar { + margin-bottom: 1rem; +} + +#node_action_bar > * { + margin-right: 0.5rem; +} + /* ============================================================ Flash / notices ============================================================ */ -- cgit v1.3 From 168ff3f8b6037bc3c2b5bce74adac37e48366dac Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:52:00 +0200 Subject: Add the Status section to nodes#show and rebuild nodes#edit's action bar Retires content_for :subnavigation on nodes#show entirely -- Preview was fully redundant with Links' own preview URLs in every state, and Edit is now a permanent, always-rendered action inside the new Status section rather than a link floating at the top, consistent with the "action lives next to the info" pattern People already established for Unlock. Status surfaces head/draft/autosave plainly and gates Edit/Publish/Destroy/Discard on lock ownership specifically, not mere lock presence -- @node.locked? alone would have blocked the lock owner's own session, caught by the click-test and fixed here rather than shipped. nodes#edit's action bar is rebuilt to sit outside form_for (both button_to calls render their own nested form, invalid HTML the browser was silently stripping) with Save wired back in via form="..." rather than needing to live inside the form tag at all. Also brings the locked-and-ready-to-edit flash message, and the visual polish from this session's click-test: consistent button heights across the bordered and pill-shaped variants sharing a row, spacing between Status's data and its actions, and error_messages styling reusing the destructive-red vocabulary already established elsewhere. --- app/controllers/nodes_controller.rb | 2 + app/views/nodes/edit.html.erb | 45 ++++++++++--------- app/views/nodes/show.html.erb | 58 +++++++++++++++++++++--- public/stylesheets/admin.css | 88 ++++++++++++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 34 deletions(-) (limited to 'public') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index d8586e2..72d4a3e 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -68,6 +68,8 @@ class NodesController < ApplicationController @node.lock_for_editing!( current_user ) @page = @node.autosave || @node.draft || @node.head + flash.now[:notice] = "Node locked and ready to edit" unless @node.autosave + if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index feba92a..cdc9b36 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,3 +1,21 @@ +

<%= title_for_node(@node) %>

+ +
+ <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, + form: { class: 'button_to state_changing' }, + disabled: @node.autosave.present? %> + + <% if @node.autosave || (@node.draft && @node.head) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> + + <%= submit_tag "Save Draft", form: dom_id(@node, :edit) %> + <%= submit_tag "Save + Unlock + Exit", form: dom_id(@node, :edit) %> + <%= link_to "Preview ↗", preview_page_path(@page), target: "_blank", rel: "noopener", class: "preview_link" %> +
+
<%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %> @@ -6,25 +24,6 @@
<% end %> -

<%= title_for_node(@node) %>

- -
- <%= button_to 'Unlock + Back', unlock_node_path(@node), method: :put, - form: { class: 'button_to state_changing' }, - disabled: @node.autosave.present? %> - - <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), - revert_node_path(@node), method: :put, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> - <% end %> - - <%= link_to 'Preview', preview_page_path(@page) %> - - <%= f.submit 'Save Draft' %> - <%= f.submit 'Save + Unlock + Exit' %> -
-
Metadata (slug, parent, tags, template, author, images)
@@ -102,9 +101,9 @@
<%= d.text_area :body, :class => 'with_editor' %>
<% end %> -
- <%= f.submit 'Save Draft' %> - <%= f.submit 'Save + Unlock + Exit' %> -
+
+ <%= f.submit 'Save Draft' %> + <%= f.submit 'Save + Unlock + Exit' %> +
<% end %>
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2ab7986..036caf2 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,11 +1,59 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_node_path(@node), :class => "unselected" %> - <%= link_to 'Preview', preview_page_path(@page) %> -<% end %> - +<% locked_by_other = @node.locked? && @node.lock_owner != current_user %>

<%= title_for_node(@node) %>

+
Status
+
+
+
+ Head + <%= @node.head ? "#{@node.head.title} (rev #{@node.head.revision}, #{@node.head.updated_at})" : "none — never published" %> +
+
+ Draft + <%= @node.draft ? "#{@node.draft.title} (rev #{@node.draft.revision}, saved #{@node.draft.updated_at})" : "none" %> +
+
+ Autosave + <%= @node.autosave ? "#{@node.autosave.title} (unsaved, #{@node.autosave.updated_at})" : "none" %> +
+
+ + <% edit_label = @node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Edit") %> +
+
+ <% if locked_by_other %> + <%= edit_label %> + <% else %> + <%= link_to (@node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Lock + Edit")), edit_node_path(@node), class: "action_button" %> + <% if !@node.draft && !@node.autosave %> + Nothing pending — this will start a fresh draft. + <% end %> + <% end %> +
+ + <% unless locked_by_other %> + <% if @node.draft && !@node.autosave %> +
+ <%= button_to 'Publish', publish_node_path(@node), method: :put, + form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> +
+ <% end %> + <% if @node.draft || @node.autosave %> +
+ <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> +
+ <% end %> + <% end %> +
+ + <% if locked_by_other %> + Locked — see People below to unlock before editing, publishing, or discarding. + <% end %> +
+
People
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index ceacf17..a6e8bf6 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -79,6 +79,9 @@ input[type=radio] { #wrapper { margin: 0 125px; } + .node_action_bar.node_action_bar_save { + margin-left: 120px; + } } @media(max-width:1015px) { #wrapper { @@ -165,12 +168,14 @@ input[type=radio] { margin-bottom: 40px; } -#node_action_bar { - margin-bottom: 1rem; -} - -#node_action_bar > * { - margin-right: 0.5rem; +.node_action_bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin: 1rem 0 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e8e8e8; } /* ============================================================ @@ -213,6 +218,18 @@ span.warning a { text-decoration: underline; } +.error_messages { + border-left: 3px solid #cc0000; + background-color: #fdecea; + padding: 6px 10px; + margin-bottom: 1rem; +} + +.error_messages ul { + margin: 0; + padding-left: 1.25rem; +} + /* ============================================================ Pagination ============================================================ */ @@ -487,6 +504,16 @@ table.user_table td.user_login { padding: 0.5rem 0.75rem; } +.node_info_group .disabled_action { + display: inline-block; + border: 1px solid #c0c0c0; + border-radius: 2px; + padding: 4px 12px; + font-weight: bold; + color: #969696; + cursor: not-allowed; +} + .node_info_group_items { display: flex; flex-wrap: wrap; @@ -514,6 +541,38 @@ table.user_table td.user_login { margin-bottom: 0.25rem; } +.node_content.node_status { + border: 2px solid #000000; + background-color: #fafafa; +} + +.node_status .node_info_group_items:first-child { + margin-bottom: 0.75rem; +} + +.node_status .node_info_group_items + .node_info_group_items { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.node_status .field_hint { + margin-top: 0.5rem; + display: block; +} + +#page_editor a.action_button, +.node_status form.button_to input[type="submit"], +.node_status form.button_to button[type="submit"] { + padding: 4px 12px; + line-height: 1.2; + box-sizing: border-box; +} + +.node_status form.button_to input[type="submit"], +.node_status form.button_to button[type="submit"] { + border: 1px solid transparent; +} + .field_hint { display: block; margin-top: 2px; @@ -562,6 +621,23 @@ div#page_editor { margin-left: 10px; } +#page_editor a.action_button { + display: inline-block; + -webkit-appearance: none; + appearance: none; + border: 1px solid #000000; + border-radius: 2px; + padding: 4px 12px; + font-weight: bold; + text-decoration: none; + color: #000000; +} + +#page_editor a.action_button:hover { + color: #ffffff; + background-color: #000000; +} + @media(min-width:1016px) { input#tag_list, input#node_staged_slug, -- cgit v1.3 From 332ffbeed90cf07342925b82ed3fda1762346be2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 02:25:56 +0200 Subject: Restyle revisions#index: sticky diff bar, computation button style, pre-selected defaults Adds a fourth button category, .computation -- teal, matching Preview's existing color -- for actions that compute a view without changing state, the first case in the app that didn't fit reversible/state-changing/ destructive. Diff revisions now starts disabled and only enables once two distinct revisions are selected, matching the disabled-state pattern already used for Unlock + Back. The header row is wrapped in a real (previously flat inside the table alongside data rows) so a sticky diff bar can sit above the fold on long revision lists -- selecting a comparison pair no longer requires scrolling back up to find the button, or back down to confirm what's selected, both surfaced live via one shared JS function. The two most recent revisions are pre-selected by default, covering the most common comparison (current against previous) with no extra clicks. Deliberately scoped to numbered revisions only -- comparing draft/head or autosave/draft was raised and set aside, since neither exists as a row in this table by design and extending the comparison to them is a separate feature, not a default on this one. Also drops the dead "Edit" subnav link, redundant with nodes#show's Status section covering the same action with state-aware labeling this static link could never have. --- app/views/revisions/index.html.erb | 100 +++++++++++++++++++++++-------------- public/stylesheets/admin.css | 66 ++++++++++++++++++------ 2 files changed, 114 insertions(+), 52 deletions(-) (limited to 'public') diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index 26a9ff4..a6a981a 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -1,46 +1,70 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_node_path(@node) %> -<% end %> -

Revisions for Node: <%= @node.unique_name %>

- - - - - - - - - - - -<% (@pages || @node.pages.all).reverse.each do |page| %> - - - - - - - - - - -<% end %> - - - +
FirstLastRev.TitleEditorDate
<%= radio_button_tag :start_revision, page.revision %><%= radio_button_tag :end_revision, page.revision %><%= page.revision %><%= page.title %><%= page.editor.try(:login) %><%= page.updated_at %><%= link_to 'show', node_revision_path(@node, page) %> - <%= button_to 'restore', restore_node_revision_path(@node, page), - method: :put, - form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> -
- <%= button_to 'Diff revisions', diff_node_revisions_path(@node), - method: :post, - form: { id: 'diff_form' } %> -
+ + + + + + + + + + + + + + + + + <% pages = (@pages || @node.pages.all).reverse %> + <% pages.each_with_index do |page, index| %> + + + + + + + + + + + <% end %> +
FirstLastRev.TitleEditorDate
+ <%= button_to 'Diff revisions', diff_node_revisions_path(@node), + method: :post, + form: { id: 'diff_form', class: 'button_to computation' }, + disabled: true %> + +
<%= radio_button_tag :start_revision, page.revision, index == 1 %><%= radio_button_tag :end_revision, page.revision, index == 0 %><%= page.revision %><%= page.title %><%= page.editor.try(:login) %><%= page.updated_at %><%= link_to 'show', node_revision_path(@node, page) %> + <%= button_to 'restore', restore_node_revision_path(@node, page), + method: :put, + form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> +
- -
-

Title

-

- -

Abstract

-

- -

Body

-

+ <% if @diff_view == :side_by_side %> +
+
+

Title

+

<%= raw @diff[:title][0] %>

+

Abstract

+

<%= raw @diff[:abstract][0] %>

+

Body

+ <%= raw @diff[:body][0] %> +
+
+

Title

+

<%= raw @diff[:title][1] %>

+

Abstract

+

<%= raw @diff[:abstract][1] %>

+

Body

+ <%= raw @diff[:body][1] %> +
+
+ <% else %> +

Title

+

<%= raw @diff[:title] %>

+

Abstract

+

<%= raw @diff[:abstract] %>

+

Body

+ <%= raw @diff[:body] %> + <% end %>
diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index a6a981a..58c08b7 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -19,6 +19,8 @@ form: { id: 'diff_form', class: 'button_to computation' }, disabled: true %> + + @@ -68,6 +70,7 @@ document.getElementById('diff_form').addEventListener('submit', function(e) { var start = document.querySelector('input[name="start_revision"]:checked'); var end = document.querySelector('input[name="end_revision"]:checked'); + var view = document.querySelector('input[name="view"]:checked'); if (start) { var s = document.createElement('input'); s.type = 'hidden'; s.name = 'start_revision'; s.value = start.value; @@ -78,5 +81,10 @@ en.type = 'hidden'; en.name = 'end_revision'; en.value = end.value; this.appendChild(en); } + if (view) { + var v = document.createElement('input'); + v.type = 'hidden'; v.name = 'view'; v.value = view.value; + this.appendChild(v); + } }); diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb new file mode 100644 index 0000000..1f28cf5 --- /dev/null +++ b/lib/html_word_diff.rb @@ -0,0 +1,52 @@ +require 'diff/lcs' + +module HtmlWordDiff + TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ + + def self.inline(old_html, new_html) + html = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + html << new_token + when '-' + html << "#{old_token}" + when '+' + html << "#{new_token}" + when '!' + html << "#{old_token}#{new_token}" + end + end + html + end + + def self.side_by_side(old_html, new_html) + old_out = +'' + new_out = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + old_out << old_token + new_out << new_token + when '-' + old_out << "#{old_token}" + when '+' + new_out << "#{new_token}" + when '!' + old_out << "#{old_token}" + new_out << "#{new_token}" + end + end + [old_out, new_out] + end + + def self.each_change(old_html, new_html) + Diff::LCS.sdiff(tokenize(old_html), tokenize(new_html)).each do |change| + yield change.action, change.old_element, change.new_element + end + end + + def self.tokenize(html) + html.to_s.scan(TOKEN_REGEXP) + end +end diff --git a/public/javascripts/cacycle_diff.js b/public/javascripts/cacycle_diff.js deleted file mode 100644 index 24f9d0b..0000000 --- a/public/javascripts/cacycle_diff.js +++ /dev/null @@ -1,1112 +0,0 @@ -//

- 
-/*
- 
-Name:    diff.js
-Version: 0.9.5a (April 6, 2008)
-Info:    http://en.wikipedia.org/wiki/User:Cacycle/diff
-Code:    http://en.wikipedia.org/wiki/User:Cacycle/diff.js
- 
-JavaScript diff algorithm by [[en:User:Cacycle]] (http://en.wikipedia.org/wiki/User_talk:Cacycle).
-Outputs html/css-formatted new text with highlighted deletions, inserts, and block moves.
- 
-The program uses cross-browser code and should work with all modern browsers. It has been tested with:
-* Mozilla Firefox 1.5.0.1
-* Mozilla SeaMonkey 1.0
-* Opera 8.53
-* Internet Explorer 6.0.2900.2180
-* Internet Explorer 7.0.5730.11
-This program is also compatibel with Greasemonkey
- 
-An implementation of the word-based algorithm from:
- 
-Communications of the ACM 21(4):264 (1978)
-http://doi.acm.org/10.1145/359460.359467
- 
-With the following additional feature:
- 
-* Word types have been optimized for MediaWiki source texts
-* Additional post-pass 5 code for resolving islands caused by adding
-  two common words at the end of sequences of common words
-* Additional detection of block borders and color coding of moved blocks and their original position
-* Optional "intelligent" omission of unchanged parts from the output
- 
-This code is used by the MediaWiki in-browser text editors [[en:User:Cacycle/editor]] and [[en:User:Cacycle/wikEd]]
-and the enhanced diff view tool wikEdDiff [[en:User:Cacycle/wikEd]].
- 
-Usage: var htmlText = WDiffString(oldText, newText);
- 
-This code has been released into the public domain.
- 
-Datastructures:
- 
-text: an object that holds all text related datastructures
-  .newWords: consecutive words of the new text (N)
-  .oldWords: consecutive words of the old text (O)
-  .newToOld: array of corresponding word number in old text (NA)
-  .oldToNew: array of corresponding word number in new text (OA)
-  .message:  output message for testing purposes
- 
-symbol['word']: symbol table for passes 1 - 3, holds words as a hash
-  .newCtr:  new word occurences counter (NC)
-  .oldCtr:  old word occurences counter (OC)
-  .toNew:   table last old word number
-  .toOld:   last new word number (OLNA)
- 
-block: an object that holds block move information
-  blocks indexed after new text:
-  .newStart:  new text word number of start of this block
-  .newLength: element number of this block including non-words
-  .newWords:  true word number of this block
-  .newNumber: corresponding block index in old text
-  .newBlock:  moved-block-number of a block that has been moved here
-  .newLeft:   moved-block-number of a block that has been moved from this border leftwards
-  .newRight:  moved-block-number of a block that has been moved from this border rightwards
-  .newLeftIndex:  index number of a block that has been moved from this border leftwards
-  .newRightIndex: index number of a block that has been moved from this border rightwards
-  blocks indexed after old text:
-  .oldStart:  word number of start of this block
-  .oldToNew:  corresponding new text word number of start
-  .oldLength: element number of this block including non-words
-  .oldWords:  true word number of this block
- 
-*/
- 
- 
-// css for change indicators
-if (typeof(wDiffStyleDelete) == 'undefined') { window.wDiffStyleDelete = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #990033;'; }
-if (typeof(wDiffStyleInsert) == 'undefined') { window.wDiffStyleInsert = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #009933;'; }
-if (typeof(wDiffStyleMoved)  == 'undefined') { window.wDiffStyleMoved  = 'font-weight: bold;  color: #000; vertical-align: text-bottom; font-size: xx-small; padding: 0; border: solid 1px;'; }
-if (typeof(wDiffStyleBlock)  == 'undefined') { window.wDiffStyleBlock  = [
-  'color: #000; background-color: #ffff80;',
-  'color: #000; background-color: #c0ffff;',
-  'color: #000; background-color: #ffd0f0;',
-  'color: #000; background-color: #ffe080;',
-  'color: #000; background-color: #aaddff;',
-  'color: #000; background-color: #ddaaff;',
-  'color: #000; background-color: #ffbbbb;',
-  'color: #000; background-color: #d8ffa0;',
-  'color: #000; background-color: #d0d0d0;'
-]; }
- 
-// html for change indicators, {number} is replaced by the block number
-// {block} is replaced by the block style, class and html comments are important for shortening the output
-if (typeof(wDiffHtmlMovedRight)  == 'undefined') { window.wDiffHtmlMovedRight  = ''; }
-if (typeof(wDiffHtmlMovedLeft)   == 'undefined') { window.wDiffHtmlMovedLeft   = ''; }
- 
-if (typeof(wDiffHtmlBlockStart)  == 'undefined') { window.wDiffHtmlBlockStart  = ''; }
-if (typeof(wDiffHtmlBlockEnd)    == 'undefined') { window.wDiffHtmlBlockEnd    = ''; }
- 
-if (typeof(wDiffHtmlDeleteStart) == 'undefined') { window.wDiffHtmlDeleteStart = ''; }
-if (typeof(wDiffHtmlDeleteEnd)   == 'undefined') { window.wDiffHtmlDeleteEnd   = ''; }
- 
-if (typeof(wDiffHtmlInsertStart) == 'undefined') { window.wDiffHtmlInsertStart = ''; }
-if (typeof(wDiffHtmlInsertEnd)   == 'undefined') { window.wDiffHtmlInsertEnd   = ''; }
- 
-// minimal number of real words for a moved block (0 for always displaying block move indicators)
-if (typeof(wDiffBlockMinLength) == 'undefined') { window.wDiffBlockMinLength = 3; }
- 
-// exclude identical sequence starts and endings from change marking
-if (typeof(wDiffWordDiff) == 'undefined') { window.wDiffWordDiff = true; }
- 
-// enable recursive diff to resolve problematic sequences
-if (typeof(wDiffRecursiveDiff) == 'undefined') { window.wDiffRecursiveDiff = true; }
- 
-// enable block move display
-if (typeof(wDiffShowBlockMoves) == 'undefined') { window.wDiffShowBlockMoves = true; }
- 
-// remove unchanged parts from final output
- 
-// characters before diff tag to search for previous heading, paragraph, line break, cut characters
-if (typeof(wDiffHeadingBefore)   == 'undefined') { window.wDiffHeadingBefore   = 1500; }
-if (typeof(wDiffParagraphBefore) == 'undefined') { window.wDiffParagraphBefore = 1500; }
-if (typeof(wDiffLineBeforeMax)   == 'undefined') { window.wDiffLineBeforeMax   = 1000; }
-if (typeof(wDiffLineBeforeMin)   == 'undefined') { window.wDiffLineBeforeMin   =  500; }
-if (typeof(wDiffBlankBeforeMax)  == 'undefined') { window.wDiffBlankBeforeMax  = 1000; }
-if (typeof(wDiffBlankBeforeMin)  == 'undefined') { window.wDiffBlankBeforeMin  =  500; }
-if (typeof(wDiffCharsBefore)     == 'undefined') { window.wDiffCharsBefore     =  500; }
- 
-// characters after diff tag to search for next heading, paragraph, line break, or characters
-if (typeof(wDiffHeadingAfter)   == 'undefined') { window.wDiffHeadingAfter   = 1500; }
-if (typeof(wDiffParagraphAfter) == 'undefined') { window.wDiffParagraphAfter = 1500; }
-if (typeof(wDiffLineAfterMax)   == 'undefined') { window.wDiffLineAfterMax   = 1000; }
-if (typeof(wDiffLineAfterMin)   == 'undefined') { window.wDiffLineAfterMin   =  500; }
-if (typeof(wDiffBlankAfterMax)  == 'undefined') { window.wDiffBlankAfterMax  = 1000; }
-if (typeof(wDiffBlankAfterMin)  == 'undefined') { window.wDiffBlankAfterMin  =  500; }
-if (typeof(wDiffCharsAfter)     == 'undefined') { window.wDiffCharsAfter     =  500; }
- 
-// maximal fragment distance to join close fragments
-if (typeof(wDiffFragmentJoin)  == 'undefined') { window.wDiffFragmentJoin = 1000; }
-if (typeof(wDiffOmittedChars)  == 'undefined') { window.wDiffOmittedChars = '…'; }
-if (typeof(wDiffOmittedLines)  == 'undefined') { window.wDiffOmittedLines = '
'; } -if (typeof(wDiffNoChange) == 'undefined') { window.wDiffNoChange = '
'; } - -// compatibility fix for old name of main function -window.StringDiff = window.WDiffString; - - -// WDiffString: main program -// input: oldText, newText, strings containing the texts -// returns: html diff - -window.WDiffString = function(oldText, newText) { - -// IE / Mac fix - oldText = oldText.replace(/(\r\n)/g, '\n'); - newText = newText.replace(/(\r\n)/g, '\n'); - - var text = {}; - text.newWords = []; - text.oldWords = []; - text.newToOld = []; - text.oldToNew = []; - text.message = ''; - var block = {}; - var outText = ''; - -// trap trivial changes: no change - if (oldText == newText) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - return(outText); - } - -// trap trivial changes: old text deleted - if ( (oldText == null) || (oldText.length == 0) ) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlInsertStart + outText + wDiffHtmlInsertEnd; - return(outText); - } - -// trap trivial changes: new text deleted - if ( (newText == null) || (newText.length == 0) ) { - outText = oldText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlDeleteStart + outText + wDiffHtmlDeleteEnd; - return(outText); - } - -// split new and old text into words - WDiffSplitText(oldText, newText, text); - -// calculate diff information - WDiffText(text); - -//detect block borders and moved blocks - WDiffDetectBlocks(text, block); - -// process diff data into formatted html text - outText = WDiffToHtml(text, block); - -// IE fix - outText = outText.replace(/> ( *) $1<'); - - return(outText); -} - - -// WDiffSplitText: split new and old text into words -// input: oldText, newText, strings containing the texts -// changes: text.newWords and text.oldWords, arrays containing the texts in arrays of words - -window.WDiffSplitText = function(oldText, newText, text) { - -// convert strange spaces - oldText = oldText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - newText = newText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - -// split old text into words - -// / | | | | | | | | | | | | | | / - var pattern = /[\w]+|\[\[|\]\]|\{\{|\}\}|\n+| +|&\w+;|'''|''|=+|\{\||\|\}|\|\-|./g; - var result; - do { - result = pattern.exec(oldText); - if (result != null) { - text.oldWords.push(result[0]); - } - } while (result != null); - -// split new text into words - do { - result = pattern.exec(newText); - if (result != null) { - text.newWords.push(result[0]); - } - } while (result != null); - - return; -} - - -// WDiffText: calculate diff information -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// optionally for recursive calls: newStart, newEnd, oldStart, oldEnd, recursionLevel -// changes: text.newToOld and text.oldToNew, containing the line numbers in the other version - -window.WDiffText = function(text, newStart, newEnd, oldStart, oldEnd, recursionLevel) { - - symbol = new Object(); - symbol.newCtr = []; - symbol.oldCtr = []; - symbol.toNew = []; - symbol.toOld = []; - -// set defaults - newStart = newStart || 0; - newEnd = newEnd || text.newWords.length; - oldStart = oldStart || 0; - oldEnd = oldEnd || text.oldWords.length; - recursionLevel = recursionLevel || 0; - -// limit recursion depth - if (recursionLevel > 10) { - return; - } - -// pass 1: parse new text into symbol table s - - var word; - for (var i = newStart; i < newEnd; i ++) { - word = text.newWords[i]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for new text - symbol[word].newCtr ++; - -// add last word number in new text - symbol[word].toNew = i; - } - -// pass 2: parse old text into symbol table - - for (var j = oldStart; j < oldEnd; j ++) { - word = text.oldWords[j]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for old text - symbol[word].oldCtr ++; - -// add last word number in old text - symbol[word].toOld = j; - } - -// pass 3: connect unique words - - for (var i in symbol) { - -// find words in the symbol table that occur only once in both versions - if ( (symbol[i].newCtr == 1) && (symbol[i].oldCtr == 1) ) { - var toNew = symbol[i].toNew; - var toOld = symbol[i].toOld; - -// do not use spaces as unique markers - if ( ! /\s/.test( text.newWords[toNew] ) ) { - -// connect from new to old and from old to new - text.newToOld[toNew] = toOld; - text.oldToNew[toOld] = toNew; - } - } - } - -// pass 4: connect adjacent identical words downwards - - for (var i = newStart; i < newEnd - 1; i ++) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the following words are not yet connected - if ( (text.newToOld[i + 1] == null) && (text.oldToNew[j + 1] == null) ) { - -// if the following words are the same connect them - if ( text.newWords[i + 1] == text.oldWords[j + 1] ) { - text.newToOld[i + 1] = j + 1; - text.oldToNew[j + 1] = i + 1; - } - } - } - } - -// pass 5: connect adjacent identical words upwards - - for (var i = newEnd - 1; i > newStart; i --) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the preceeding words are not yet connected - if ( (text.newToOld[i - 1] == null) && (text.oldToNew[j - 1] == null) ) { - -// if the preceeding words are the same connect them - if ( text.newWords[i - 1] == text.oldWords[j - 1] ) { - text.newToOld[i - 1] = j - 1; - text.oldToNew[j - 1] = i - 1; - } - } - } - } - -// recursively diff still unresolved regions downwards - - if (wDiffRecursiveDiff) { - i = newStart; - j = oldStart; - while (i < newEnd) { - if (text.newToOld[i - 1] != null) { - j = text.newToOld[i - 1] + 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i; - while ( (text.newToOld[iEnd] == null) && (iEnd < newEnd) ) { - iEnd ++; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j; - while ( (text.oldToNew[jEnd] == null) && (jEnd < oldEnd) ) { - jEnd ++; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iEnd; - } - else { - i ++; - } - } - } - -// recursively diff still unresolved regions upwards - - if (wDiffRecursiveDiff) { - i = newEnd - 1; - j = oldEnd - 1; - while (i >= newStart) { - if (text.newToOld[i + 1] != null) { - j = text.newToOld[i + 1] - 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i + 1; - while ( (text.newToOld[iStart - 1] == null) && (iStart >= newStart) ) { - iStart --; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j + 1; - while ( (text.oldToNew[jStart - 1] == null) && (jStart >= oldStart) ) { - jStart --; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iStart - 1; - } - else { - i --; - } - } - } - return; -} - - -// WDiffToHtml: process diff data into formatted html text -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// text.newToOld and text.oldToNew, containing the line numbers in the other version -// block data structure -// returns: outText, a html string - -window.WDiffToHtml = function(text, block) { - - var outText = text.message; - - var blockNumber = 0; - var i = 0; - var j = 0; - var movedAsInsertion; - -// cycle through the new text - do { - var movedIndex = []; - var movedBlock = []; - var movedLeft = []; - var blockText = ''; - var identText = ''; - var delText = ''; - var insText = ''; - var identStart = ''; - -// check if a block ends here and finish previous block - if (movedAsInsertion != null) { - if (movedAsInsertion == false) { - identStart += wDiffHtmlBlockEnd; - } - else { - identStart += wDiffHtmlInsertEnd; - } - movedAsInsertion = null; - } - -// detect block boundary - if ( (text.newToOld[i] != j) || (blockNumber == 0 ) ) { - if ( ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) && ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) ) { - -// block moved right - var moved = block.newRight[blockNumber]; - if (moved > 0) { - var index = block.newRightIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(false); - } - -// block moved left - moved = block.newLeft[blockNumber]; - if (moved > 0) { - var index = block.newLeftIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(true); - } - -// check if a block starts here - moved = block.newBlock[blockNumber]; - if (moved > 0) { - -// mark block as inserted text - if (block.newWords[blockNumber] < wDiffBlockMinLength) { - identStart += wDiffHtmlInsertStart; - movedAsInsertion = true; - } - -// mark block by color - else { - if (moved > wDiffStyleBlock.length) { - moved = wDiffStyleBlock.length; - } - identStart += WDiffHtmlCustomize(wDiffHtmlBlockStart, moved - 1); - movedAsInsertion = false; - } - } - - if (i >= text.newWords.length) { - i ++; - } - else { - j = text.newToOld[i]; - blockNumber ++; - } - } - } - -// get the correct order if moved to the left as well as to the right from here - if (movedIndex.length == 2) { - if (movedIndex[0] > movedIndex[1]) { - movedIndex.reverse(); - movedBlock.reverse(); - movedLeft.reverse(); - } - } - -// handle left and right block moves from this position - for (var m = 0; m < movedIndex.length; m ++) { - -// insert the block as deleted text - if (block.newWords[ movedIndex[m] ] < wDiffBlockMinLength) { - var movedStart = block.newStart[ movedIndex[m] ]; - var movedLength = block.newLength[ movedIndex[m] ]; - var str = ''; - for (var n = movedStart; n < movedStart + movedLength; n ++) { - str += text.newWords[n]; - } - str = WDiffEscape(str); - str = str.replace(/\n/g, '¶
'); - blockText += wDiffHtmlDeleteStart + str + wDiffHtmlDeleteEnd; - } - -// add a placeholder / move direction indicator - else { - if (movedBlock[m] > wDiffStyleBlock.length) { - movedBlock[m] = wDiffStyleBlock.length; - } - if (movedLeft[m]) { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedLeft, movedBlock[m] - 1); - } - else { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedRight, movedBlock[m] - 1); - } - } - } - -// collect consecutive identical text - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.newToOld[i] != j) { - break; - } - identText += text.newWords[i]; - i ++; - j ++; - } - -// collect consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - delText += text.oldWords[j]; - j ++; - } - -// collect consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - insText += text.newWords[i]; - i ++; - } - -// remove leading and trailing similarities betweein delText and ins from highlighting - var preText = ''; - var postText = ''; - if (wDiffWordDiff) { - if ( (delText != '') && (insText != '') ) { - -// remove leading similarities - while ( delText.charAt(0) == insText.charAt(0) && (delText != '') && (insText != '') ) { - preText = preText + delText.charAt(0); - delText = delText.substr(1); - insText = insText.substr(1); - } - -// remove trailing similarities - while ( delText.charAt(delText.length - 1) == insText.charAt(insText.length - 1) && (delText != '') && (insText != '') ) { - postText = delText.charAt(delText.length - 1) + postText; - delText = delText.substr(0, delText.length - 1); - insText = insText.substr(0, insText.length - 1); - } - } - } - -// output the identical text, deletions and inserts - -// moved from here indicator - if (blockText != '') { - outText += blockText; - } - -// identical text - if (identText != '') { - outText += identStart + WDiffEscape(identText); - } - outText += preText; - -// deleted text - if (delText != '') { - delText = wDiffHtmlDeleteStart + WDiffEscape(delText) + wDiffHtmlDeleteEnd; - delText = delText.replace(/\n/g, '¶
'); - outText += delText; - } - -// inserted text - if (insText != '') { - insText = wDiffHtmlInsertStart + WDiffEscape(insText) + wDiffHtmlInsertEnd; - insText = insText.replace(/\n/g, '¶
'); - outText += insText; - } - outText += postText; - } while (i <= text.newWords.length); - - outText += '\n'; - outText = WDiffHtmlFormat(outText); - - return(outText); -} - - -// WDiffEscape: replaces html-sensitive characters in output text with character entities - -window.WDiffEscape = function(text) { - - text = text.replace(/&/g, '&'); - text = text.replace(//g, '>'); - text = text.replace(/\"/g, '"'); - - return(text); -} - - -// HtmlCustomize: customize indicator html: replace {number} with the block number, {block} with the block style - -window.WDiffHtmlCustomize = function(text, block) { - - text = text.replace(/\{number\}/, block); - text = text.replace(/\{block\}/, wDiffStyleBlock[block]); - - return(text); -} - - -// HtmlFormat: replaces newlines and multiple spaces in text with html code - -window.WDiffHtmlFormat = function(text) { - - text = text.replace(/ /g, '  '); - text = text.replace(/\n/g, '
'); - - return(text); -} - - -// WDiffDetectBlocks: detect block borders and moved blocks -// input: text object, block object - -window.WDiffDetectBlocks = function(text, block) { - - block.oldStart = []; - block.oldToNew = []; - block.oldLength = []; - block.oldWords = []; - block.newStart = []; - block.newLength = []; - block.newWords = []; - block.newNumber = []; - block.newBlock = []; - block.newLeft = []; - block.newRight = []; - block.newLeftIndex = []; - block.newRightIndex = []; - - var blockNumber = 0; - var wordCounter = 0; - var realWordCounter = 0; - -// get old text block order - if (wDiffShowBlockMoves) { - var j = 0; - var i = 0; - do { - -// detect block boundaries on old text - if ( (text.oldToNew[j] != i) || (blockNumber == 0 ) ) { - if ( ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) && ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) ) { - if (blockNumber > 0) { - block.oldLength[blockNumber - 1] = wordCounter; - block.oldWords[blockNumber - 1] = realWordCounter; - wordCounter = 0; - realWordCounter = 0; - } - - if (j >= text.oldWords.length) { - j ++; - } - else { - i = text.oldToNew[j]; - block.oldStart[blockNumber] = j; - block.oldToNew[blockNumber] = text.oldToNew[j]; - blockNumber ++; - } - } - } - -// jump over identical pairs - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.oldToNew[j] != i) { - break; - } - i ++; - j ++; - wordCounter ++; - if ( /\w/.test( text.newWords[i] ) ) { - realWordCounter ++; - } - } - -// jump over consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - j ++; - } - -// jump over consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - i ++; - } - } while (j <= text.oldWords.length); - -// get the block order in the new text - var lastMin; - var currMinIndex; - lastMin = null; - -// sort the data by increasing start numbers into new text block info - for (var i = 0; i < blockNumber; i ++) { - currMin = null; - for (var j = 0; j < blockNumber; j ++) { - curr = block.oldToNew[j]; - if ( (curr > lastMin) || (lastMin == null) ) { - if ( (curr < currMin) || (currMin == null) ) { - currMin = curr; - currMinIndex = j; - } - } - } - block.newStart[i] = block.oldToNew[currMinIndex]; - block.newLength[i] = block.oldLength[currMinIndex]; - block.newWords[i] = block.oldWords[currMinIndex]; - block.newNumber[i] = currMinIndex; - lastMin = currMin; - } - -// detect not moved blocks - for (var i = 0; i < blockNumber; i ++) { - if (block.newBlock[i] == null) { - if (block.newNumber[i] == i) { - block.newBlock[i] = 0; - } - } - } - -// detect switches of neighbouring blocks - for (var i = 0; i < blockNumber - 1; i ++) { - if ( (block.newBlock[i] == null) && (block.newBlock[i + 1] == null) ) { - if (block.newNumber[i] - block.newNumber[i + 1] == 1) { - if ( (block.newNumber[i + 1] - block.newNumber[i + 2] != 1) || (i + 2 >= blockNumber) ) { - -// the shorter one is declared the moved one - if (block.newLength[i] < block.newLength[i + 1]) { - block.newBlock[i] = 1; - block.newBlock[i + 1] = 0; - } - else { - block.newBlock[i] = 0; - block.newBlock[i + 1] = 1; - } - } - } - } - } - -// mark all others as moved and number the moved blocks - j = 1; - for (var i = 0; i < blockNumber; i ++) { - if ( (block.newBlock[i] == null) || (block.newBlock[i] == 1) ) { - block.newBlock[i] = j++; - } - } - -// check if a block has been moved from this block border - for (var i = 0; i < blockNumber; i ++) { - for (var j = 0; j < blockNumber; j ++) { - - if (block.newNumber[j] == i) { - if (block.newBlock[j] > 0) { - -// block moved right - if (block.newNumber[j] < j) { - block.newRight[i] = block.newBlock[j]; - block.newRightIndex[i] = j; - } - -// block moved left - else { - block.newLeft[i + 1] = block.newBlock[j]; - block.newLeftIndex[i + 1] = j; - } - } - } - } - } - } - return; -} - - -// WDiffShortenOutput: remove unchanged parts from final output -// input: the output of WDiffString -// returns: the text with removed unchanged passages indicated by (...) - -window.WDiffShortenOutput = function(diffText) { - -// html
to newlines - diffText = diffText.replace(/]*>/g, '\n'); - -// scan for diff html tags - var regExpDiff = new RegExp('<\\w+ class=\\"(\\w+)\\"[^>]*>(.|\\n)*?', 'g'); - var tagStart = []; - var tagEnd = []; - var i = 0; - var found; - while ( (found = regExpDiff.exec(diffText)) != null ) { - -// combine consecutive diff tags - if ( (i > 0) && (tagEnd[i - 1] == found.index) ) { - tagEnd[i - 1] = found.index + found[0].length; - } - else { - tagStart[i] = found.index; - tagEnd[i] = found.index + found[0].length; - i ++; - } - } - -// no diff tags detected - if (tagStart.length == 0) { - return(wDiffNoChange); - } - -// define regexps - var regExpHeading = new RegExp('\\n=+.+?=+ *\\n|\\n\\{\\||\\n\\|\\}', 'g'); - var regExpParagraph = new RegExp('\\n\\n+', 'g'); - var regExpLine = new RegExp('\\n+', 'g'); - var regExpBlank = new RegExp('(<[^>]+>)*\\s+', 'g'); - -// determine fragment border positions around diff tags - var rangeStart = []; - var rangeEnd = []; - var rangeStartType = []; - var rangeEndType = []; - for (var i = 0; i < tagStart.length; i ++) { - var found; - -// find last heading before diff tag - var lastPos = tagStart[i] - wDiffHeadingBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpHeading.lastIndex = lastPos; - while ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'heading'; - } - -// find last paragraph before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffParagraphBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpParagraph.lastIndex = lastPos; - while ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'paragraph'; - } - } - -// find line break before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffLineBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpLine.lastIndex = lastPos; - while ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffLineBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'line'; - } - } - -// find blank before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffBlankBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpBlank.lastIndex = lastPos; - while ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffBlankBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'blank'; - } - } - -// fixed number of chars before diff tag - if (rangeStart[i] == null) { - rangeStart[i] = tagStart[i] - wDiffCharsBefore; - rangeStartType[i] = 'chars'; - if (rangeStart[i] < 0) { - rangeStart[i] = 0; - } - } - -// find first heading after diff tag - regExpHeading.lastIndex = tagEnd[i]; - if ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffHeadingAfter) { - rangeEnd[i] = found.index + found[0].length; - rangeEndType[i] = 'heading'; - } - } - -// find first paragraph after diff tag - if (rangeEnd[i] == null) { - regExpParagraph.lastIndex = tagEnd[i]; - if ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffParagraphAfter) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'paragraph'; - } - } - } - -// find first line break after diff tag - if (rangeEnd[i] == null) { - regExpLine.lastIndex = tagEnd[i] + wDiffLineAfterMin; - if ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffLineAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'break'; - } - } - } - -// find blank after diff tag - if (rangeEnd[i] == null) { - regExpBlank.lastIndex = tagEnd[i] + wDiffBlankAfterMin; - if ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffBlankAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'blank'; - } - } - } - -// fixed number of chars after diff tag - if (rangeEnd[i] == null) { - rangeEnd[i] = tagEnd[i] + wDiffCharsAfter; - if (rangeEnd[i] > diffText.length) { - rangeEnd[i] = diffText.length; - rangeEndType[i] = 'chars'; - } - } - } - -// remove overlaps, join close fragments - var fragmentStart = []; - var fragmentEnd = []; - var fragmentStartType = []; - var fragmentEndType = []; - fragmentStart[0] = rangeStart[0]; - fragmentEnd[0] = rangeEnd[0]; - fragmentStartType[0] = rangeStartType[0]; - fragmentEndType[0] = rangeEndType[0]; - var j = 1; - for (var i = 1; i < rangeStart.length; i ++) { - if (rangeStart[i] > fragmentEnd[j - 1] + wDiffFragmentJoin) { - fragmentStart[j] = rangeStart[i]; - fragmentEnd[j] = rangeEnd[i]; - fragmentStartType[j] = rangeStartType[i]; - fragmentEndType[j] = rangeEndType[i]; - j ++; - } - else { - fragmentEnd[j - 1] = rangeEnd[i]; - fragmentEndType[j - 1] = rangeEndType[i]; - } - } - -// assemble the fragments - var outText = ''; - for (var i = 0; i < fragmentStart.length; i ++) { - -// get text fragment - var fragment = diffText.substring(fragmentStart[i], fragmentEnd[i]); - var fragment = fragment.replace(/^\n+|\n+$/g, ''); - -// add inline marks for omitted chars and words - if (fragmentStart[i] > 0) { - if (fragmentStartType[i] == 'chars') { - fragment = wDiffOmittedChars + fragment; - } - else if (fragmentStartType[i] == 'blank') { - fragment = wDiffOmittedChars + ' ' + fragment; - } - } - if (fragmentEnd[i] < diffText.length) { - if (fragmentStartType[i] == 'chars') { - fragment = fragment + wDiffOmittedChars; - } - else if (fragmentStartType[i] == 'blank') { - fragment = fragment + ' ' + wDiffOmittedChars; - } - } - -// add omitted line separator - if (fragmentStart[i] > 0) { - outText += wDiffOmittedLines; - } - -// encapsulate span errors - outText += '
' + fragment + '
'; - } - -// add trailing omitted line separator - if (fragmentEnd[i - 1] < diffText.length) { - outText = outText + wDiffOmittedLines; - } - -// remove leading and trailing empty lines - outText = outText.replace(/^(
)\n+|\n+(<\/div>)$/g, '$1$2'); - -// convert to html linebreaks - outText = outText.replace(/\n/g, '
'); - - return(outText); -} - - -//

\ No newline at end of file
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 38c9e5a..1bb6cf4 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -511,6 +511,31 @@ table.revisions_table tr:hover {
   background-color: #f1f1f1;
 }
 
+#diffview del {
+  background: #ffd7d5;
+  color: #82071e;
+  text-decoration: line-through;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview ins {
+  background: #ccffd8;
+  color: #055d20;
+  text-decoration: none;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview .diff_side_by_side {
+  display: flex;
+  gap: 1rem;
+}
+
+#diffview .diff_column {
+  flex: 1;
+}
+
 table.user_table td.user_login {
   padding-right: 30px;
 }
diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb
index b4dcd8f..e2fc976 100644
--- a/test/controllers/revisions_controller_test.rb
+++ b/test/controllers/revisions_controller_test.rb
@@ -59,4 +59,33 @@ class RevisionsControllerTest < ActionController::TestCase
     assert_equal @node.head, @node.pages.first
     assert_equal "first", @node.head.reload.body
   end
+
+  test "diffing two revisions renders real markup with only the changed words marked" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision
+      }
+    )
+    assert_response :success
+    assert_select "del", "first"
+    assert_select "ins", "second"
+    assert_no_match /</, response.body
+  end
+
+  test "diffing two revisions in side by side view renders two columns" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision,
+        :view => "side_by_side"
+      }
+    )
+    assert_response :success
+    assert_select ".diff_column", 2
+  end
 end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index ad2742f..ac5691a 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -176,4 +176,38 @@ class PageTest < ActiveSupport::TestCase
     assert_not_equal first_page.id, first_page.node.head_id
     assert first_page.published_at.present?
   end
+
+  def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word
+    n = Node.root.children.create! :slug => "diff_against_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    diff = d2.diff_against(n.head)
+
+    assert_match "Old", diff[:title]
+    assert_match "New", diff[:title]
+  end
+
+  def test_diff_against_side_by_side_returns_two_annotated_strings
+    n = Node.root.children.create! :slug => "diff_against_sbs_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    old_html, new_html = d2.diff_against(n.head, view: :side_by_side)[:title]
+
+    assert_match "Old", old_html
+    assert_match "New", new_html
+  end
 end
-- 
cgit v1.3


From b928656088ef14e7d004de726df0d1cd70d1cdba Mon Sep 17 00:00:00 2001
From: erdgeist 
Date: Fri, 10 Jul 2026 03:19:47 +0200
Subject: Move link to full revision list out of summary

---
 app/views/nodes/show.html.erb |  2 +-
 public/stylesheets/admin.css  | 14 +++++++++++---
 2 files changed, 12 insertions(+), 4 deletions(-)

(limited to 'public')

diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 8b9e98b..5756649 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -132,7 +132,6 @@
       
<%= pluralize(@node.pages.count, 'revision', 'revisions') %> - · <%= link_to 'full history (diff / restore)', node_revisions_path(@node) %>
    <% @node.pages.order(:revision).each do |page| %> @@ -140,6 +139,7 @@ <% end %>
+
Tags
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1bb6cf4..e4bdac4 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -65,6 +65,14 @@ select { border: 1px solid #989898; } +summary { + cursor: pointer; +} + +summary:hover { + background-color: #f7f7f7; +} + input[type=password] { border: 1px solid #989898; } @@ -665,9 +673,9 @@ form.button_to button[type="submit"] { margin-top: 0; } -/* Identical declaration block to #search_results p span.result_path / - #menu_search_results p span.result_path below -- three independent - copies of the same "small gray helper text" style. */ +.revisions_full_history_link { + margin-bottom: 0; +} /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ -- cgit v1.3 From 80e23e2629a37d8ccaa6798a8b5e29b49a3a7268 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:22:32 +0200 Subject: Remove dead CSS rule for table.node_table th.title --- public/stylesheets/admin.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'public') diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index e4bdac4..56470bc 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -398,8 +398,7 @@ table.events_table tr { } table.node_table th.node_id, -table.node_table th.revision, -table.node_table th.title { +table.node_table th.revision { min-width: 4rem; } -- cgit v1.3 From cf730b751b590ca80695492f5d62c4fb43efb4d3 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:40:48 +0200 Subject: Ensure that comparison view toggle is there for all diff modes --- app/views/revisions/diff.html.erb | 17 ++++++++++++++++- public/stylesheets/admin.css | 1 + test/controllers/revisions_controller_test.rb | 9 +++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) (limited to 'public') diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index 490cf17..d70503c 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -9,13 +9,28 @@ against <%= describe_page_reference(params[:end_revision]) %>

+

+ View: + <% if @diff_view == :inline %> + Inline + <% else %> + <%= link_to 'Inline', diff_node_revisions_path(@node, start_revision: params[:start_revision], end_revision: params[:end_revision], view: 'inline') %> + <% end %> + · + <% if @diff_view == :side_by_side %> + Side by side + <% else %> + <%= link_to 'Side by side', diff_node_revisions_path(@node, start_revision: params[:start_revision], end_revision: params[:end_revision], view: 'side_by_side') %> + <% end %> +

+ <% numeric_comparison = params[:start_revision].to_s =~ /\A\d+\z/ && params[:end_revision].to_s =~ /\A\d+\z/ %> <% if numeric_comparison %> <%= form_tag diff_node_revisions_path do %> <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> - <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> + <%= hidden_field_tag :view, @diff_view %> <%= submit_tag 'Diff' %> <% end %> <% else %> diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 56470bc..510e3cc 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -32,6 +32,7 @@ table.user_table a, table.revisions_table a, .add_child_links a, .standalone_action_bar a, +.diff_view_toggle a, div#draft_list table td.actions a { text-decoration: underline; -webkit-text-decoration-style: wavy; diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index 162e6f1..bf92c5b 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -139,4 +139,13 @@ class RevisionsControllerTest < ActionController::TestCase assert_select "form.computation input[type=hidden][name=start_revision]" assert_select "form.computation input[type=hidden][name=end_revision]" end + + test "the view toggle is available even when comparing named layers" do + login_as :quentin + @node.find_or_create_draft(@user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "a", "Side by side" + end end -- cgit v1.3 From d18e0fa70de9b5029ebed93a1760aea46e8abf19 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 03:59:02 +0200 Subject: Restore the inline/side-by-side toggle for named-layer diffs Hiding the numeric revision picker for head/draft/autosave comparisons accidentally took the view toggle down with it -- it was nested inside the numeric-only branch instead of being its own control. Now renders unconditionally as plain links, with the current mode shown as non-interactive text. Switching which revisions to compare via the numeric form no longer silently resets the view mode back to inline. --- public/stylesheets/admin.css | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'public') diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 510e3cc..e04499d 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -544,6 +544,11 @@ table.revisions_table tr:hover { flex: 1; } +#diffview del.diff_structural, +#diffview ins.diff_structural { + cursor: help; +} + table.user_table td.user_login { padding-right: 30px; } -- cgit v1.3 From 30ed9fd9a8bffb44e6ab91dfedb8c0e33837769a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 04:41:54 +0200 Subject: Include assets, tags and template in diff between revisions --- app/models/page.rb | 33 ++++++++++++-------- app/views/revisions/diff.html.erb | 27 +++++++++++++++++ public/stylesheets/admin.css | 11 +++++++ test/controllers/revisions_controller_test.rb | 13 ++++++++ test/models/page_test.rb | 43 +++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) (limited to 'public') diff --git a/app/models/page.rb b/app/models/page.rb index ea04cd6..740d42e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -176,19 +176,26 @@ class Page < ApplicationRecord end def diff_against other, view: :inline - if view == :side_by_side - { - title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), - abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), - body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) - } - else - { - title: HtmlWordDiff.inline(other.title.to_s, title.to_s), - abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), - body: HtmlWordDiff.inline(other.body.to_s, body.to_s) - } - end + text_diffs = + if view == :side_by_side + { + title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) + } + else + { + title: HtmlWordDiff.inline(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.inline(other.body.to_s, body.to_s) + } + end + + text_diffs.merge( + tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a }, + template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name }, + assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a } + ) end def public? diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d70503c..b9ce6bd 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -85,4 +85,31 @@

Body

<%= raw @diff[:body] %> <% end %> + +

Tags

+ <% if @diff[:tags][:added].empty? && @diff[:tags][:removed].empty? %> +

No change.

+ <% else %> +
    + <% @diff[:tags][:added].each do |tag| %>
  • <%= tag %>
  • <% end %> + <% @diff[:tags][:removed].each do |tag| %>
  • <%= tag %>
  • <% end %> +
+ <% end %> + +

Template

+ <% if @diff[:template_name][:changed] %> +

<%= @diff[:template_name][:from] || '(none)' %> <%= @diff[:template_name][:to] || '(none)' %>

+ <% else %> +

No change.

+ <% end %> + +

Assets

+ <% if @diff[:assets][:added].empty? && @diff[:assets][:removed].empty? %> +

No change.

+ <% else %> +
    + <% @diff[:assets][:added].each do |asset| %>
  • <%= asset.upload_file_name %>
  • <% end %> + <% @diff[:assets][:removed].each do |asset| %>
  • <%= asset.upload_file_name %>
  • <% end %> +
+ <% end %>
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index e04499d..7b39c72 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -549,6 +549,17 @@ table.revisions_table tr:hover { cursor: help; } +.diff_unchanged { + color: #969696; + font-style: italic; +} + +.diff_set_list { + list-style: none; + padding-left: 0; + margin: 0; +} + table.user_table td.user_login { padding-right: 30px; } diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index bf92c5b..53927b4 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -148,4 +148,17 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select "a", "Side by side" end + + test "diffing two revisions also shows tag, template, and asset changes" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.tag_list = "update" + @node.draft.save! + + post(:diff, params: { :node_id => @node.id, :start_revision => @node.pages.first.revision, :end_revision => @node.pages.last.revision }) + assert_response :success + assert_select "h3", "Tags" + assert_select "h3", "Template" + assert_select "h3", "Assets" + end end diff --git a/test/models/page_test.rb b/test/models/page_test.rb index 3868a53..bcddc89 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb @@ -228,4 +228,47 @@ class PageTest < ActiveSupport::TestCase assert_equal 2, fragment.css('ins.diff_structural').length assert_match "der Zugang erfolgt über den Hinterhof.", fragment.text end + + test "diff_against reports tag and template changes" do + n = Node.root.children.create! :slug => "field_diff_test" + d = n.find_or_create_draft @user1 + d.tag_list = "update" + d.template_name = "standard_template" + d.save! + n.publish_draft! + + d2 = n.find_or_create_draft @user1 + d2.tag_list = "update, pressemitteilung" + d2.template_name = "title_only" + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal ["pressemitteilung"], diff[:tags][:added] + assert_equal [], diff[:tags][:removed] + assert diff[:template_name][:changed] + assert_equal "standard_template", diff[:template_name][:from] + assert_equal "title_only", diff[:template_name][:to] + end + + test "diff_against reports added and removed assets by filename" do + n = Node.root.children.create! :slug => "asset_diff_test" + d = n.find_or_create_draft @user1 + d.save! + n.publish_draft! + + kept_asset = Asset.create!(:upload_file_name => "kept.png", :upload_content_type => "image/png", :upload_file_size => 1) + removed_asset = Asset.create!(:upload_file_name => "removed.pdf", :upload_content_type => "application/pdf", :upload_file_size => 1) + n.head.update_assets([kept_asset.id, removed_asset.id]) + + d2 = n.find_or_create_draft @user1 + added_asset = Asset.create!(:upload_file_name => "added.png", :upload_content_type => "image/png", :upload_file_size => 1) + d2.update_assets([kept_asset.id, added_asset.id]) + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal [added_asset], diff[:assets][:added] + assert_equal [removed_asset], diff[:assets][:removed] + end end -- cgit v1.3 From ed9e846d1d85fa326389982e16bbc8799f2001c0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 14:43:45 +0200 Subject: Fix page-editor text inputs falling through to unstyled defaults "#page_editor form input[...]" -- but form_for wraps around inside, so the selector never matched anything. Every field without its own dedicated per-id rule (i.e. everything outside nodes#edit) fell through to the bare, unsized base rule instead. Fixed by dropping the erroneous "form" and adding textarea, which the rule never covered either even for its intended targets. nodes#edit's own per-id rules (#page_title, #page_abstract, etc.) are unaffected -- same specificity, later in the file, so they still win where they already applied. --- public/stylesheets/admin.css | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'public') diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 7b39c72..1a81728 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -750,8 +750,9 @@ a.action_button:hover { background-color: #000000; } -#page_editor form input[type=text], -#page_editor form input[type=password] { +#page_editor input[type=text], +#page_editor input[type=password], +#page_editor textarea { padding: 5px; } @@ -787,8 +788,9 @@ a.action_button:hover { margin-left: -125px; } - #page_editor form input[type=text], - #page_editor form input[type=password] { + #page_editor input[type=text], + #page_editor input[type=password], + #page_editor textarea { width: 690px; } } @@ -833,8 +835,9 @@ a.action_button:hover { font-weight: bold; } - #page_editor form input[type=text], - #page_editor form input[type=password] { + #page_editor input[type=text], + #page_editor input[type=password], + #page_editor textarea { box-sizing: border-box; width: 100%; } -- cgit v1.3 From c9401e45433ea45b46f9a8faf1e7e537e4683244 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 18:07:15 +0200 Subject: Fix rrule/url column overflow on events#index Long RRULEs previously overflowed their column with no wrap point; now escaped and rendered with a after each semicolon, so a long rule wraps at a clause boundary instead of running off the table. Deliberately not truncated -- a cut-off RRULE's trailing clause (BYDAY, BYMONTH, etc.) is usually the most specific part. The url column is now a real link, truncated with an ellipsis at a fixed width -- deliberately no tooltip, since hovering a real link already shows the full address in the browser's own status bar. rrule_with_break_opportunities splits on the raw string's own semicolons before escaping each piece, not after -- escaping first and searching the result for semicolons also matches the ones inside </> entities, corrupting anything containing a literal < or >. --- app/helpers/events_helper.rb | 11 +++++++++++ app/views/events/index.html.erb | 8 ++++++-- public/stylesheets/admin.css | 15 +++++++++++++++ test/models/helpers/events_helper_test.rb | 21 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) (limited to 'public') diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 8a9a878..5e84f53 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,2 +1,13 @@ +require 'cgi' + module EventsHelper + # Insert a zero-width break opportunity after each semicolon, so a long + # RRULE can wrap at a clause boundary instead of overflowing its column. + # Deliberately , not ellipsis -- unlike a URL, an RRULE's trailing + # characters (BYDAY, BYMONTH, etc.) are usually the most specific part, + # and truncating them would hide exactly the wrong end of the string. + def rrule_with_break_opportunities(rrule) + return "" if rrule.blank? + raw(rrule.split(';', -1).map { |part| CGI.escapeHTML(part) }.join(';')) + end end diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 8127e3a..3d953d5 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -22,9 +22,13 @@ <%= link_to event.display_title, event %> <%=h event.start_time %> <%=h event.end_time %> - <%=h event.rrule %> + <%= rrule_with_break_opportunities(event.rrule) %> <%=h event.allday %> - <%=h event.url %> + + <% if event.url.present? %> + <%= link_to event.url, event.url %> + <% end %> + <%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %> <%= link_to 'edit', edit_event_path(event) %> diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1a81728..4723f50 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -519,6 +519,21 @@ table.revisions_table tr:hover { background-color: #f1f1f1; } +.events_table .rrule_text { + display: inline-block; + max-width: 300px; + overflow-wrap: break-word; +} + +.events_table .truncate { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + #diffview del { background: #ffd7d5; color: #82071e; diff --git a/test/models/helpers/events_helper_test.rb b/test/models/helpers/events_helper_test.rb index 2e7567e..0486b3e 100644 --- a/test/models/helpers/events_helper_test.rb +++ b/test/models/helpers/events_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class EventsHelperTest < ActionView::TestCase + test "rrule_with_break_opportunities inserts a break opportunity after each semicolon" do + result = rrule_with_break_opportunities("FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH") + assert_equal "FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH", result + assert result.html_safe? + end + + test "rrule_with_break_opportunities escapes HTML-significant characters" do + result = rrule_with_break_opportunities("FREQ=WEEKLY;BYDAY= diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 5be5d57..0e70845 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -201,7 +201,7 @@ parent_search = { clearTimeout(parent_search.path_timeout); parent_search.path_timeout = setTimeout(function() { - $.get("/nodes/parameterize_preview", { title: title }, function(slug) { + $.get(PARAMETERIZE_PREVIEW_URL, { title: title }, function(slug) { $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug); }); }, 300); -- cgit v1.3 From 92c394b43a0603743b914c6298aab986805ca990 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 23:43:02 +0200 Subject: Add drafts/recent/mine/chapters admin node views Four NodesController actions -- drafts, recent, mine, chapters -- each building its own base scope, sharing one private method (index_matching) for search narrowing and pagination. Wizard rewrite to link into these instead of rendering its own tables is a separate, later step. Node.editor_search backs the shared "q" narrowing: an ILIKE substring match against title/abstract on whichever of head or draft is present, splitting the term on whitespace and requiring every word to match somewhere independently, not as one phrase, since real words can end up separated by markup in the underlying HTML. Deliberately separate from Node.search, the public content search, which stays tsvector-based and head-only. chapters generalizes into /admin/nodes/tags/:tags for an arbitrary tag list (OR'd, not AND'd), sharing the controller action but rendering its own template rather than branching inside one view. --- app/controllers/nodes_controller.rb | 45 +++++++++++++ app/models/node.rb | 22 +++++++ app/views/nodes/_node_list.html.erb | 39 +++++++++++ app/views/nodes/chapters.html.erb | 9 +++ app/views/nodes/drafts.html.erb | 3 + app/views/nodes/mine.html.erb | 3 + app/views/nodes/recent.html.erb | 3 + app/views/nodes/tags.html.erb | 3 + config/routes.rb | 5 ++ public/stylesheets/admin.css | 15 +++++ test/controllers/nodes_controller_test.rb | 104 ++++++++++++++++++++++++++++++ test/models/node_test.rb | 26 ++++++++ 12 files changed, 277 insertions(+) create mode 100644 app/views/nodes/_node_list.html.erb create mode 100644 app/views/nodes/chapters.html.erb create mode 100644 app/views/nodes/drafts.html.erb create mode 100644 app/views/nodes/mine.html.erb create mode 100644 app/views/nodes/recent.html.erb create mode 100644 app/views/nodes/tags.html.erb (limited to 'public') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fcd930..ede91ad 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -183,6 +183,39 @@ class NodesController < ApplicationController render plain: slug_for(params[:title]) end + # Filter functions for admin views + def drafts + base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + @nodes = index_matching(base) + end + + def recent + base = Node.where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + @nodes = index_matching(base) + end + + def mine + base = Node.joins(:pages) + .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) + .distinct + @nodes = index_matching(base) + end + + def chapters + @kind_keys = Array(params[:kinds]) & %w[erfa chaostreff] + @kind_keys = %w[erfa chaostreff] if @kind_keys.empty? + tags = @kind_keys.flat_map { |key| CccConventions::NODE_KINDS[key][:tags] } + @nodes = nodes_matching_tags(tags) + end + + def tags + tags = params[:tags].to_s.split(',').map(&:strip).reject(&:blank?) + @nodes = nodes_matching_tags(tags) + end + private def slug_for(title) @@ -214,4 +247,16 @@ class NodesController < ApplicationController config && config[:parent] ? config[:parent].call.id : nil end end + + def nodes_matching_tags(tags) + matching_pages = Page.tagged_with(tags, any: true).reselect(:id) + base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) + index_matching(base) + end + + def index_matching(base_scope) + scope = base_scope.includes(:head, :draft) + scope = scope.merge(Node.editor_search(params[:q])) if params[:q].present? + scope.order("nodes.updated_at desc").paginate(page: params[:page], per_page: 25) + end end diff --git a/app/models/node.rb b/app/models/node.rb index 9cb4840..24f3cd0 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -345,6 +345,28 @@ class Node < ApplicationRecord .distinct end + # This one is for admin-only views, where finding a draft is the point. + # Substring match on whichever of head/draft is present. + def self.editor_search(term) + words = term.to_s.split(/\s+/).reject(&:blank?) + return none if words.empty? + + conditions = [] + binds = {} + + words.each_with_index do |word, i| + key = "term#{i}" + binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" + conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ + "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" + end + + joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") + .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") + .where(conditions.join(" AND "), binds) + .distinct + end + protected def lock_for! current_user self.lock_owner = current_user diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb new file mode 100644 index 0000000..03f38b4 --- /dev/null +++ b/app/views/nodes/_node_list.html.erb @@ -0,0 +1,39 @@ +<%= form_tag url_for(controller: params[:controller], action: params[:action]), method: :get, class: "node_search_form" do %> + <% Array(params[:kinds]).each do |kind| %> + <%= hidden_field_tag "kinds[]", kind %> + <% end %> + <%= hidden_field_tag :tags, params[:tags] if params[:tags].present? %> + <%= text_field_tag :q, params[:q], placeholder: "Search title, abstract, body…" %> + <%= submit_tag "Search", class: "action_button" %> + <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> + <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> + <% end %> +<% end %> + +<%= will_paginate @nodes %> + + + + + + + + + <% @nodes.each do |node| %> + "> + + + + + + + <% end %> +
IDTitleActionsLocked byRev.
<%= node.id %> +

<%= link_to title_for_node(node), node_path(node) %>

+

<%= link_to_path(node.unique_name, node.unique_name) %>

+
+ <%= link_to 'show', node_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> + <%= node.lock_owner.login if node.lock_owner %><%= node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY") %>
+<%= will_paginate @nodes %> diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb new file mode 100644 index 0000000..939f19c --- /dev/null +++ b/app/views/nodes/chapters.html.erb @@ -0,0 +1,9 @@ +

Chapters

+ +<%= form_tag chapters_nodes_path, method: :get do %> + + + <%= submit_tag "Filter", class: "action_button" %> +<% end %> + +<%= render 'node_list' %> diff --git a/app/views/nodes/drafts.html.erb b/app/views/nodes/drafts.html.erb new file mode 100644 index 0000000..6d0830c --- /dev/null +++ b/app/views/nodes/drafts.html.erb @@ -0,0 +1,3 @@ +

Nodes with drafts or autosaves

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/mine.html.erb b/app/views/nodes/mine.html.erb new file mode 100644 index 0000000..fc5680a --- /dev/null +++ b/app/views/nodes/mine.html.erb @@ -0,0 +1,3 @@ +

My work

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb new file mode 100644 index 0000000..3602775 --- /dev/null +++ b/app/views/nodes/recent.html.erb @@ -0,0 +1,3 @@ +

Recently changed

+ +<%= render 'node_list' %> diff --git a/app/views/nodes/tags.html.erb b/app/views/nodes/tags.html.erb new file mode 100644 index 0000000..0ebc6ce --- /dev/null +++ b/app/views/nodes/tags.html.erb @@ -0,0 +1,3 @@ +

Nodes tagged: <%= params[:tags] %>

+ +<%= render 'node_list' %> diff --git a/config/routes.rb b/config/routes.rb index aebce90..2c165d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,7 +37,12 @@ Cccms::Application.routes.draw do resources :nodes do collection do + get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } get :parameterize_preview + get :drafts + get :recent + get :mine + get :chapters end member do diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 28b8494..aa8b288 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -748,6 +748,21 @@ form.button_to button[type="submit"] { margin-bottom: 0; } +.node_search_form { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.node_search_form input[type=text] { + padding: 4px 12px; + box-sizing: border-box; + height: 2.25rem; + width: 22rem; + border-radius: 2px; +} + /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ .add_child_links { diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index b563d4d..05cb195 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -471,4 +471,108 @@ class NodesControllerTest < ActionController::TestCase assert_response :success assert_select "form.destructive", :count => 0 end + + test "drafts includes a never-published node with only a draft" do + node = Node.root.children.create!(:slug => "drafts_action_test") + login_as :quentin + get :drafts + assert_includes assigns(:nodes), node + end + + test "chapters filters by kind, matching head or draft, and shows both by default" do + erfa_node = Node.root.children.create!(:slug => "chapters_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "chapters_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + + get :chapters, params: { :kinds => "erfa" } + assert_includes assigns(:nodes), erfa_node + assert_not_includes assigns(:nodes), chaostreff_node + + get :chapters + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "recent combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :recent, params: { :q => "Zombies" } + assert_response :success + end + + test "drafts combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :drafts, params: { :q => "Zombies" } + assert_response :success + end + + test "mine combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :mine, params: { :q => "Zombies" } + assert_response :success + end + + test "chapters combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :chapters, params: { :q => "Zombies" } + assert_response :success + end + + test "tags path filters by an arbitrary raw tag, generalizing chapters" do + presse_node = Node.root.children.create!(:slug => "tags_path_presse_test") + presse_node.find_or_create_draft(@user1) + presse_node.draft.tag_list = "pressemitteilung" + presse_node.draft.save! + presse_node.publish_draft! + + erfa_node = Node.root.children.create!(:slug => "tags_path_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + login_as :quentin + get :tags, params: { :tags => "pressemitteilung" } + + assert_includes assigns(:nodes), presse_node + assert_not_includes assigns(:nodes), erfa_node + + assert_select "h1", "Nodes tagged: pressemitteilung" + assert_select "h1", :text => "Chapters", :count => 0 + end + + test "tags path with multiple tags matches any of them, not all" do + erfa_node = Node.root.children.create!(:slug => "tags_path_multi_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "tags_path_multi_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + get :tags, params: {:tags => "erfa-detail,chaostreff-detail" } + + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "chapters renders the curated heading" do + login_as :quentin + get :chapters + assert_select "h1", "Chapters" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 5626384..15e908b 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -511,4 +511,30 @@ class NodeTest < ActiveSupport::TestCase a.reload assert_equal Node.root.id, a.parent_id end + + test "editor_search matches a partial substring, not just a whole word" do + node = Node.root.children.create!(:slug => "editor_search_substring_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Biometrics Conference") + node.publish_draft! + + assert_includes Node.editor_search("bio"), node + assert_includes Node.editor_search("Conf"), node + end + + test "editor_search returns an empty relation for a blank term, not every node" do + assert_equal 0, Node.editor_search("").count + assert_equal 0, Node.editor_search(nil).count + assert_equal 0, Node.editor_search(" ").count + end + + test "editor_search requires every word to match, but each word can match a different field" do + node = Node.root.children.create!(:slug => "editor_search_multiword_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Backspace e.V. Bamberg", :abstract => "Spiegelgraben 41, 96052 Bamberg") + node.publish_draft! + + assert_includes Node.editor_search("Backspace Spiegelgraben"), node + assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count + end end -- cgit v1.3 From 45bf65d04d046c0ea4a1150096b2a9b846d6c0b8 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 02:15:44 +0200 Subject: Give the sitemap its own view, with collapse and descendant counts Extracted from admin#index's inline table into NodesController#sitemap. Nested
/ per branch, one linear pass over the existing flat [node, level] list (no added queries) -- each node's own descendant count computed the same way, via a small stack rather than re-walking the tree per node. Branches under updates/, club/erfas, club/chaostreffs, and disclosure start collapsed by default (CccConventions::SITEMAP_COLLAPSED_PATHS); any branch currently collapsed, whether by that default or because someone just closed it, is highlighted via a plain :not([open]) selector -- no state tracked outside the DOM itself. Dropped the update?-post exclusion this view used to rely on -- no longer needed now that updates/ collapses instead of being filtered out, so its real children (previously silently absent) now show up correctly. admin#index's own, separate @sitemap query is unchanged; that view has no collapse mechanism to compensate and wasn't part of this. --- app/controllers/nodes_controller.rb | 23 ++++++++++++++ app/helpers/nodes_helper.rb | 5 ++- app/views/nodes/sitemap.html.erb | 32 +++++++++++++++++++ config/routes.rb | 1 + lib/ccc_conventions.rb | 1 + public/stylesheets/admin.css | 40 ++++++++++++++++++++++++ test/controllers/nodes_controller_test.rb | 52 +++++++++++++++++++++++++++++++ test/models/helpers/nodes_helper_test.rb | 11 +++++++ 8 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 app/views/nodes/sitemap.html.erb (limited to 'public') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index ede91ad..772bf4b 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -216,6 +216,11 @@ class NodesController < ApplicationController @nodes = nodes_matching_tags(tags) end + def sitemap + @sitemap = Node.root.self_and_descendants_ordered_with_level + @sitemap_descendant_counts = descendant_counts_for(@sitemap) + end + private def slug_for(title) @@ -248,6 +253,24 @@ class NodesController < ApplicationController end end + def descendant_counts_for(ordered_with_level) + counts = Hash.new(0) + stack = [] # [node, level, index] + ordered_with_level.each_with_index do |(node, level), index| + while stack.any? && stack.last[1] >= level + ancestor_node, _ancestor_level, ancestor_index = stack.pop + counts[ancestor_node.id] = index - ancestor_index - 1 + end + stack << [node, level, index] + end + total = ordered_with_level.length + while stack.any? + ancestor_node, _ancestor_level, ancestor_index = stack.pop + counts[ancestor_node.id] = total - ancestor_index - 1 + end + counts + end + def nodes_matching_tags(tags) matching_pages = Page.tagged_with(tags, any: true).reselect(:id) base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index a89f879..1268b63 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -12,7 +12,6 @@ module NodesHelper end end - def truncated_title_for_node node if (title = title_for_node node) && title.size > 20 "#{truncate(title, 40)}" @@ -63,4 +62,8 @@ module NodesHelper path = node.unique_path CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } end + + def sitemap_node_open?(node) + !CccConventions::SITEMAP_COLLAPSED_PATHS.include?(node.unique_name) + end end diff --git a/app/views/nodes/sitemap.html.erb b/app/views/nodes/sitemap.html.erb new file mode 100644 index 0000000..a799c17 --- /dev/null +++ b/app/views/nodes/sitemap.html.erb @@ -0,0 +1,32 @@ +

Sitemap

+ +
+<% + open_details = [] # levels with a currently-open
+%> +<% @sitemap.each_with_index do |(node, level), index| %> + <% while open_details.any? && open_details.last >= level %> +
+ <% open_details.pop %> + <% end %> + +
+

<%= link_to title_for_node(node), node_path(node) %>

+ <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +

+ <%= link_to 'Show', node_path(node) %> + <%= link_to 'Create Child', new_node_path(:parent_id => node.id) %> +

+
+ + <% next_level = @sitemap[index + 1]&.last %> + <% if next_level && next_level > level %> + > + + <%= pluralize(@sitemap_descendant_counts[node.id], 'descendant', 'descendants') %> + + <% open_details.push(level) %> + <% end %> +<% end %> +<% open_details.length.times { %>
<% } %> +
diff --git a/config/routes.rb b/config/routes.rb index 2c165d2..bb34bd8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -43,6 +43,7 @@ Cccms::Application.routes.draw do get :recent get :mine get :chapters + get :sitemap end member do diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index b420452..352dd3c 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -1,6 +1,7 @@ module CccConventions ERFA_PARENT_NAME = "club/erfas" CHAOSTREFF_PARENT_NAME = "club/chaostreffs" + SITEMAP_COLLAPSED_PATHS = %w[updates club/erfas club/chaostreffs disclosure].freeze NODE_KINDS = { "top_level" => { diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index aa8b288..5c1e489 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -25,6 +25,7 @@ a:hover { body content, node listing tables, the child-creation shortcuts on nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, +#sitemap a, table.node_table a, table.assets_table a, table.events_table a, @@ -104,6 +105,11 @@ input[type=radio] { width: 100%; box-sizing: border-box; } + + #sitemap details details { + margin-left: 0.75rem; + padding-left: 0.5rem; + } } #metadata, @@ -773,6 +779,40 @@ form.button_to button[type="submit"] { white-space: nowrap; } +.sitemap_node { + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px solid #ececec; +} + +.sitemap_node h4 { + margin: 0; +} + +.sitemap_node .field_hint { + display: block; + margin: 2px 0 0; +} + +.sitemap_node p { + margin: 2px 0 0; +} + +#sitemap details details { + margin-left: 1.5rem; + border-left: 1px solid #e8e8e8; + padding-left: 0.75rem; +} + +#sitemap summary { + padding: 4px 0; +} + +#sitemap details:not([open]) > summary { + color: #ff9600; + font-weight: bold; +} + /* ============================================================ Page editor / metadata forms ============================================================ */ diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 05cb195..b43d2de 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -575,4 +575,56 @@ class NodesControllerTest < ActionController::TestCase get :chapters assert_select "h1", "Chapters" end + + test "sitemap collapses configured paths but leaves others open" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + other = Node.root.children.create!(:slug => "sitemap_controller_open_test") + other.children.create!(:slug => "sitemap_controller_open_child") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + other_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(other.unique_name) } + + erfas_details = erfas_node_div.next_element + other_details = other_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_equal 'details', other_details.name + assert_not erfas_details.key?('open') + assert other_details.key?('open') + end + + test "sitemap shows how many descendants a collapsed branch is hiding" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + erfas.children.create!(:slug => "one_chapter") + erfas.children.create!(:slug => "another_chapter") + + login_as :quentin + get :sitemap + assert_response :success + + doc = Nokogiri::HTML::DocumentFragment.parse(response.body) + erfas_node_div = doc.css('.sitemap_node').find { |div| div.at_css('.field_hint')&.text&.include?(erfas.unique_name) } + erfas_details = erfas_node_div.next_element + + assert_equal 'details', erfas_details.name + assert_match "2 descendants", erfas_details.at_css('summary').text + end + + test "sitemap shows Show and Create Child, not Revisions" do + node = Node.root.children.create!(:slug => "sitemap_actions_test") + login_as :quentin + get :sitemap + assert_select ".sitemap_node_actions", :text => /Show/ + assert_select ".sitemap_node_actions", :text => /Create Child/ + assert_select ".sitemap_node_actions", :text => /Revisions/, :count => 0 + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 5d91a88..5ab924f 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -22,4 +22,15 @@ class NodesHelperTest < ActionView::TestCase page = FakePage.new([]) assert_nil default_event_tag_list(page) end + + test "sitemap_node_open? is false for a configured collapsed path" do + club = Node.root.children.create!(:slug => "club") + erfas = club.children.create!(:slug => "erfas") + assert_equal false, sitemap_node_open?(erfas) + end + + test "sitemap_node_open? is true for anything not configured as collapsed" do + node = Node.root.children.create!(:slug => "sitemap_open_test") + assert_equal true, sitemap_node_open?(node) + end end -- cgit v1.3 From 19e0ee821d5b2b6d3397a81411f4f3a4cbd2fa83 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 13:58:36 +0200 Subject: Unify the four field-level pickers, fix search visibility menu_items/parent_search/move_to_search/event_search each duplicated their own debounce-free AJAX call and result rendering. Replaced with one shared initSearchPicker, taking each picker's distinctive behavior (parent_search's live path preview, event_search's title hint) as a callback rather than a whole reimplementation. Adds a real debounce with an out-of-order-response guard -- none of the four had either before. admin_search (the Alt+F top-bar search) now shares the same function via its own url/isActive/header options, gaining the same guard and fixing an inconsistency of its own (it previously always slid its panel open, even on zero results). Each picker's results container gets its own id instead of sharing was ever supposed to be on screen at once, an assumption with no actual enforcement behind it. Styling moved from an id-pair (#menu_search_results, #search_results) to a shared .search_results class so a future picker never needs this file touched again. menu_search and the admin top-bar search now call Node.editor_search instead of the public, head-only Node.search -- both are admin-only, authenticated views, and had no reason to inherit the public search's "can't find a draft" limitation. The always-ignored :per_page => 1000 on the latter is gone too; Node.search's second argument was never read by the method at all. Also removed a stale #metadata a { text-transform: lowercase } rule, found while verifying the above -- written for the pre-subnav-removal expand-toggle, which no longer exists; it had been silently lowercasing nodes#edit's own, unrelated #metadata div (including move_to_search's results) by id coincidence ever since. #main_navigation and #overview_toggle intentionally left capitalized rather than special-cased -- both belong to the nav bar already slated to shrink to three icons, not worth polishing on the way out. --- app/controllers/admin_controller.rb | 4 +- app/views/events/edit.html.erb | 4 +- app/views/events/new.html.erb | 2 +- app/views/layouts/admin.html.erb | 4 +- app/views/menu_items/edit.html.erb | 2 +- app/views/menu_items/new.html.erb | 4 +- app/views/nodes/edit.html.erb | 14 +- app/views/nodes/new.html.erb | 2 +- public/javascripts/admin_search.js | 355 ++++++++++++------------------------ public/stylesheets/admin.css | 13 +- 10 files changed, 137 insertions(+), 267 deletions(-) (limited to 'public') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 435df57..3c45c49 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -31,7 +31,7 @@ class AdminController < ApplicationController end def search - @results = Node.search params[:search_term], :per_page => 1000 + @results = Node.editor_search(params[:search_term]) respond_to do |format| format.html do @@ -53,7 +53,7 @@ class AdminController < ApplicationController if params[:search_term] == "Root" @results = [Node.root] else - @results = Node.search params[:search_term] + @results = Node.editor_search(params[:search_term]) end respond_to do |format| diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 6cb04bd..45b084f 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -19,12 +19,12 @@
Change node <%= text_field_tag :event_node_search_term %> -
+
This will re-link the event to a different node — rarely needed.
<% else %> <%= text_field_tag :event_node_search_term %> -
+
Optional — search and pick a node to associate this event with a page. <% end %> <%= f.hidden_field :node_id %> diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index b20fe48..7a1ee7a 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -10,7 +10,7 @@
Node
<%= text_field_tag :event_node_search_term %> -
+
<%= f.hidden_field :node_id %> Optional — search and pick a node to associate this event with a page.
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index e714c28..079346b 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -56,9 +56,7 @@ Search: <%= text_field_tag :search_term, nil, autocomplete: "off" %> <% end %>
- +
diff --git a/app/views/menu_items/edit.html.erb b/app/views/menu_items/edit.html.erb index dc5e8f9..44e5a79 100644 --- a/app/views/menu_items/edit.html.erb +++ b/app/views/menu_items/edit.html.erb @@ -6,7 +6,7 @@
Search
<%= text_field_tag :menu_search_term %> -
+
Node Id
diff --git a/app/views/menu_items/new.html.erb b/app/views/menu_items/new.html.erb index 68081d0..223cb8e 100644 --- a/app/views/menu_items/new.html.erb +++ b/app/views/menu_items/new.html.erb @@ -7,9 +7,7 @@ Search <%= text_field_tag :menu_search_term %> -
- -
+ diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c19410..13b78fc 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -45,14 +45,12 @@
parent
<%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> -
- -
- <%= f.hidden_field( - :staged_parent_id, - :value => @node.staged_parent_id || @node.parent_id - ) - %> +
+ <%= f.hidden_field( + :staged_parent_id, + :value => @node.staged_parent_id || @node.parent_id + ) + %>
<%= fields_for @page do |d| %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index afc632f..bb7e078 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -34,7 +34,7 @@
<%= text_field_tag :parent_search_term, @parent_name %> <%= hidden_field_tag :parent_id, @parent_id %> -
+
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 0e70845..f553334 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -1,5 +1,4 @@ admin_search = { - initialize : function() { $(document).bind("keydown", 'Alt+f', function(){ admin_search.display_toggle(); @@ -20,25 +19,12 @@ admin_search = { } }); - $("#search_term").bind("input", function() { - if (!$('#search_widget').is(':visible')) return; - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success: function(results) { - admin_search.show_results(results); - }, - error: function(xhr, status, error) { - console.log("Ajax error:", status, error, xhr.status, xhr.responseText); - } - }); - } else { - $('#menu_search_results').slideUp(); - $('#menu_search_results').empty(); - } + initSearchPicker({ + inputSelector: "#search_term", + resultsSelector: "#menu_search_results", + url: ADMIN_SEARCH_URL, + isActive: function() { return $('#search_widget').is(':visible'); }, + resultsHeaderHtml: "

Press Enter to see all results ⏎

" }); }, @@ -49,75 +35,97 @@ admin_search = { $('#search_widget').fadeIn(); $('#search_term').focus(); } - }, - - show_results : function(results) { - $('#menu_search_results').empty(); - if (results.length) { - $('#menu_search_results').append( - "

Press Enter to see all results ⏎

" - ); - } - for (result in results) { - $('#menu_search_results').append( - "

" + - results[result].title + - "" + results[result].unique_name + "" + - "

" - ); - } - $('#menu_search_results').slideDown(); } }; -menu_items = { +function initSearchPicker(options) { + var inputSelector = options.inputSelector; + var resultsSelector = options.resultsSelector; + var url = options.url || ADMIN_MENU_SEARCH_URL; + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search + var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var requestId = 0; + var timeout; + + $(inputSelector).bind("input", function() { + if (isActive && !isActive()) return; + + var term = $(this).val(); + var results = $(resultsSelector); + clearTimeout(timeout); + + if (!term) { + results.slideUp(); + results.empty(); + return; + } - initialize_search : function() { - $("#menu_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - menu_items.show_results(results); + timeout = setTimeout(function() { + var thisRequest = ++requestId; + $.ajax({ + type: "GET", + url: url, + data: "search_term=" + term, + dataType: "json", + success: function(nodes) { + if (thisRequest !== requestId) return; + results.empty(); + if (nodes.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + var found = false; + for (var i = 0; i < nodes.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + } + results.append(link); + found = true; + })(nodes[i]); + } + if (found) { + results.slideDown(); + } else { + results.slideUp(); + } + }, + error: function(xhr, status, error) { + console.log("Ajax error:", status, error, xhr.status, xhr.responseText); + } + }); + }, 250); + }); +} + +menu_items = { + initialize_search : function() { + initSearchPicker({ + inputSelector: "#menu_search_term", + resultsSelector: "#menu_item_search_results", + onSelect: function(node) { + $("#menu_item_node_id").val(node.node_id); + $("#menu_item_path").val("/" + node.unique_name); + $("#menu_item_title").val(node.title); } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", menu_items.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - - } - }, - - link_closure : function(node) { - var barf = function(){ - $("#menu_item_node_id").val(node.node_id); - $("#menu_item_path").val("/" + node.unique_name); - $("#menu_item_title").val(node.title); - return false; - } - - return barf; } }; @@ -125,21 +133,13 @@ parent_search = { initialize_search : function() { parent_search.initialize_radio_buttons(); - $("#parent_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - parent_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#parent_search_term", + resultsSelector: "#parent_search_results", + onSelect: function(node) { + $("#parent_search_term").val(node.title); + $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); + parent_search.update_resulting_path(); } }); @@ -154,38 +154,6 @@ parent_search = { }); }, - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", parent_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#parent_search_term").val(node.title); - $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); - $('#search_results').slideUp(); - $('#search_results').empty(); - parent_search.update_resulting_path(); - return false; - } - - return barf; - }, - update_resulting_path : function() { var kind = $("input[name='kind']:checked"); var title = $("#title").val(); @@ -223,122 +191,35 @@ parent_search = { $("#parent_search_field").hide(); } } -} +}; move_to_search = { initialize_search : function() { - $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))}); - }, - - do_search : function(_this) { - if (_this.val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + _this.val(), - dataType: "json", - success : function(results) { - move_to_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); - } - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", move_to_search.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - - }, - - link_closure : function(node) { - var barf = function(){ - $("#move_to_search_term").val(node.title); - $("#node_staged_parent_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - return false; - } - - return barf; + initSearchPicker({ + inputSelector: "#move_to_search_term", + resultsSelector: "#move_to_search_results", + onSelect: function(node) { + $("#move_to_search_term").val(node.title); + $("#node_staged_parent_id").val(node.node_id); + } + }); } -} +}; event_search = { initialize_search : function() { - $("#event_node_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - event_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#event_node_search_term", + resultsSelector: "#event_search_results", + onSelect: function(node) { + $("#event_node_search_term").val(node.title); + $("#event_node_id").val(node.node_id); + + var title_field = $("#event_title"); + if (title_field.val() === "") { + $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); + } } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", event_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#event_node_search_term").val(node.title); - $("#event_node_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - - var title_field = $("#event_title"); - if (title_field.val() === "") { - $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); - } - - return false; - } - return barf; } }; diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 5c1e489..da31535 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -155,7 +155,6 @@ input[type=radio] { padding-right: 5px; padding-top: 1px; padding-bottom: 1px; - text-transform: lowercase; } #metadata a:hover { @@ -1023,22 +1022,19 @@ div#draft_list table td.actions a { font-size: 18px; } -#menu_search_results p, -#search_results p { +.search_results p { margin: 0; padding: 4px 4px 0 4px; border-bottom: 1px solid #e8e8e8; } -#menu_search_results p a, -#search_results p a { +.search_results p a { display: block; font-weight: bold; font-size: 0.95rem; } -#menu_search_results p span.result_path, -#search_results p span.result_path { +.search_results p span.result_path { display: block; margin-top: 2px; font-size: 0.75rem; @@ -1047,8 +1043,7 @@ div#draft_list table td.actions a { padding-bottom: 4px; } -#menu_search_results p.search_more, -#search_results p.search_more { +.search_results p.search_more { margin: 0; padding: 6px 4px; font-size: 0.8rem; -- cgit v1.3 From 4ec70e5ecf792a56e868538738d6c7f63bb6cf24 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 16:33:12 +0200 Subject: Add a grouped tag+node search endpoint for the upcoming dashboard AdminController#dashboard_search returns tags and nodes as separate, labeled groups (via ActsAsTaggableOn::Tag.named_like and Node.editor_search) rather than the flat per-node list every existing picker returns. initSearchPicker gains a renderResults callback option that, when given, replaces the default per-node rendering entirely -- lets the dashboard render its two labeled groups without a second, parallel picker implementation. resultsHeaderHtml (the "Press Enter to see all results" hint) is now threaded through as a third argument to renderResults, so a picker with custom rendering can still show it -- previously only the default per-node branch ever did. Tags link straight to the existing /admin/nodes/tags/:tag view rather than becoming an in-place filter chip. --- app/controllers/admin_controller.rb | 18 +++++ app/views/layouts/admin.html.erb | 1 + config/routes.rb | 9 +-- public/javascripts/admin_interface.js | 4 ++ public/javascripts/admin_search.js | 109 +++++++++++++++++++++--------- public/stylesheets/admin.css | 10 +++ test/controllers/admin_controller_test.rb | 24 +++++++ 7 files changed, 139 insertions(+), 36 deletions(-) (limited to 'public') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3c45c49..8445997 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -49,6 +49,24 @@ class AdminController < ApplicationController end end + def dashboard_search + term = params[:search_term] + + if term.blank? + render json: { tags: [], nodes: [] } + return + end + + render json: { + tags: ActsAsTaggableOn::Tag.named_like(term).limit(5).map { |tag| + { name: tag.name, tag_path: tags_nodes_path(tags: tag.name) } + }, + nodes: Node.editor_search(term).limit(10).map { |node| + { node_id: node.id, title: node.title, unique_name: node.unique_name, node_path: node_path(node) } + } + } + end + def menu_search if params[:search_term] == "Root" @results = [Node.root] diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 079346b..00d7075 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -17,6 +17,7 @@ var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; + var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; diff --git a/config/routes.rb b/config/routes.rb index bb34bd8..92301e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,10 +66,11 @@ Cccms::Application.routes.draw do end end - match '' => 'admin#index', :as => :admin, :via => :get - match 'search' => 'admin#search', :as => :admin_search, :via => :get - match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get - match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match '' => 'admin#index', :as => :admin, :via => :get + match 'search' => 'admin#search', :as => :admin_search, :via => :get + match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get end match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 8ca5b5e..3f6a0a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -31,6 +31,10 @@ $(document).ready(function () { } }); + if ($("#dashboard_search_term").length != 0) { + dashboard_search.initialize(); + } + if ($("#menu_search_term").length != 0) { menu_items.initialize_search(); } diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index f553334..53bcb5e 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -42,9 +42,10 @@ function initSearchPicker(options) { var inputSelector = options.inputSelector; var resultsSelector = options.resultsSelector; var url = options.url || ADMIN_MENU_SEARCH_URL; - var onSelect = options.onSelect; // omit for a real-navigation search like admin_search - var isActive = options.isActive; // optional guard, checked before every search + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely var requestId = 0; var timeout; @@ -68,39 +69,44 @@ function initSearchPicker(options) { url: url, data: "search_term=" + term, dataType: "json", - success: function(nodes) { + success: function(data) { if (thisRequest !== requestId) return; results.empty(); - if (nodes.length && resultsHeaderHtml) { - results.append(resultsHeaderHtml); - } - var found = false; - for (var i = 0; i < nodes.length; i++) { - (function(node) { - var link; - if (onSelect) { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - link.find("a").bind("click", function() { - onSelect(node); - results.slideUp(); - results.empty(); - return false; - }); - } else { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - } - results.append(link); - found = true; - })(nodes[i]); + + var found; + if (renderResults) { + found = renderResults(data, results, resultsHeaderHtml); + } else { + if (data.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + found = false; + for (var i = 0; i < data.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + } + results.append(link); + found = true; + })(data[i]); + } } + if (found) { results.slideDown(); } else { @@ -115,6 +121,45 @@ function initSearchPicker(options) { }); } +dashboard_search = { + initialize : function() { + initSearchPicker({ + inputSelector: "#dashboard_search_term", + resultsSelector: "#dashboard_search_results", + url: DASHBOARD_SEARCH_URL, + resultsHeaderHtml: "

Press Enter to see all results ⏎

", + renderResults: function(data, results, resultsHeaderHtml) { + var found = false; + + if ((data.tags.length || data.nodes.length) && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + + if (data.tags.length) { + results.append("

Tags

"); + data.tags.forEach(function(tag) { + results.append("

" + tag.name + "

"); + found = true; + }); + } + + if (data.nodes.length) { + results.append("

Pages

"); + data.nodes.forEach(function(node) { + results.append( + "

" + node.title + + "" + node.unique_name + "

" + ); + found = true; + }); + } + + return found; + } + }); + } +}; + menu_items = { initialize_search : function() { initSearchPicker({ diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index da31535..ade3a62 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -1052,6 +1052,16 @@ div#draft_list table td.actions a { border-bottom: none; } +.search_results p.search_group_label { + margin: 8px 0 2px; + padding: 0; + border-bottom: none; + font-size: 0.75rem; + color: #969696; + text-transform: lowercase; + font-weight: normal; +} + /* ============================================================ Menu items (navigation editor) ============================================================ */ diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 13cc1bb..9beaf58 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -34,4 +34,28 @@ class AdminControllerTest < ActionController::TestCase matches = assigns(:mynodes).select { |n| n.id == node.id } assert_equal 1, matches.length end + + test "dashboard_search returns matching tags and nodes grouped separately" do + node = Node.root.children.create!(:slug => "dashboard_search_test") + node.find_or_create_draft(User.find_by_login("aaron")) + node.draft.update(:title => "Biometrics Workshop") + node.draft.tag_list = "biometrics-workshop" + node.draft.save! + + login_as :quentin + get :dashboard_search, params: { :search_term => "biometr" }, :format => :json + + json = JSON.parse(response.body) + assert json["tags"].any? { |t| t["name"] == "biometrics-workshop" } + assert json["nodes"].any? { |n| n["title"] == "Biometrics Workshop" } + end + + test "dashboard_search returns empty results for a blank term" do + login_as :quentin + get :dashboard_search, params: { :search_term => "" }, :format => :json + + json = JSON.parse(response.body) + assert_equal [], json["tags"] + assert_equal [], json["nodes"] + end end -- cgit v1.3 From bcfc2b5901caeaabdf3efa745441a339568b671a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 23:43:25 +0200 Subject: Style the new dashboard; consolidate duplicated button/link CSS New rules for the icon nav, search bar, signpost/housekeeping button rows, and the two dashboard widgets. Generalizes .action_button from an anchor-only selector to cover any element, needed once Filter and Search became real buttons rather than submit_tag inputs coincidentally sharing similar values with the generic input[type=submit] fallback. Also folds three hand-copied duplicates of the wavy-underline "plain link" treatment (#flash a, div.pagination a, #menu_item_list td a) into the one shared rule they were always copies of -- two of the three had already silently drifted by missing the -webkit-prefixed property, the actual cost of copying instead of sharing rather than a hypothetical one. --- public/stylesheets/admin.css | 156 ++++++++++++++++++++++++++++++------------- 1 file changed, 111 insertions(+), 45 deletions(-) (limited to 'public') diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index ade3a62..c1cc456 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -25,7 +25,11 @@ a:hover { body content, node listing tables, the child-creation shortcuts on nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, +.dashboard_widget a, #sitemap a, +#flash a, +div.pagination a, +#menu_item_list td a, table.node_table a, table.assets_table a, table.events_table a, @@ -191,15 +195,6 @@ input[type=radio] { margin-left: 5px; } -#flash a { - text-decoration: underline; - -webkit-text-decoration-style: wavy; - text-decoration-style: wavy; - text-decoration-color: #b0b0b0; - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - #flash span { letter-spacing: 1px; margin-right: 10px; @@ -263,14 +258,6 @@ div.pagination span.gap { font-style: normal; } -div.pagination a { - text-decoration: underline; - text-decoration-style: wavy; - text-decoration-color: #b0b0b0; - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - div.pagination a:hover { color: #ff9600; } @@ -385,31 +372,108 @@ input[type="submit"]:hover { } /* ============================================================ - Wizard buttons + Admin dashboard ============================================================ */ -#admin_wizard { +#main_navigation { + display: flex; + align-items: center; + gap: 0.5rem; +} + +#main_navigation a, +#main_navigation form.button_to button { + display: inline-flex; + align-items: center; + background-color: #f7f7f7; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 2px; +} + +#main_navigation a:hover, +#main_navigation form.button_to button:hover { + background-color: #000000; + color: #ffffff; +} + +#main_navigation svg, +.action_button svg { + width: 1.25rem; + height: 1.25rem; +} + +#dashboard_search { + margin-bottom: 1.5rem; +} + +#dashboard_search input[type=text] { + width: 100%; + box-sizing: border-box; + padding: 10px 14px; + font-size: 1.1rem; + border-radius: 2px; +} + +.button_row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.button_row .action_button, +.node_search_form .action_button { + margin-bottom: 0; +} + +#dashboard_signposts { margin-bottom: 2rem; } -#page_submit, -.admin_wizard_button { - display: inline-block; - font-size: 1rem; - font-weight: bold; - padding: 0.5rem; - margin-bottom: 0.5rem; - background-color: green; +#dashboard_housekeeping h3 { + margin-bottom: 0.75rem; +} + +#dashboard_widgets { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.dashboard_widget { + flex: 1 1 300px; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 0.5rem 0.75rem; +} + +.dashboard_widget h3 { + margin-top: 0; +} + +.dashboard_widget ul { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; } -#page_submit a, -.admin_wizard_button a { - color: white !important; +.dashboard_widget li { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 0.35rem 0; + border-bottom: 1px solid #f1f1f1; } -#page_submit:hover, -.admin_wizard_button:hover { - background-color: lime !important; +.dashboard_widget_meta { + white-space: nowrap; + color: #969696; + font-size: 0.85rem; } /* ============================================================ @@ -721,7 +785,7 @@ table.user_table td.user_login { display: block; } -a.action_button, +.action_button, form.button_to input[type="submit"], form.button_to button[type="submit"] { padding: 4px 12px; @@ -768,6 +832,11 @@ form.button_to button[type="submit"] { border-radius: 2px; } +.node_search_form .action_button { + height: 2.25rem; + box-sizing: border-box; +} + /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ .add_child_links { @@ -840,8 +909,10 @@ div#page_editor { } #page_editor a.action_button, -a.action_button { - display: inline-block; +.action_button { + display: inline-flex; + align-items: center; + gap: 0.5rem; -webkit-appearance: none; appearance: none; border: 1px solid #000000; @@ -851,10 +922,13 @@ a.action_button { text-decoration: none; color: #000000; margin-bottom: 1rem; + background: none; + cursor: pointer; + font: inherit; } #page_editor a.action_button:hover, -a.action_button:hover { +.action_button:hover { color: #ffffff; background-color: #000000; } @@ -1104,14 +1178,6 @@ div#draft_list table td.actions a { cursor: grab; } -#menu_item_list td a { - text-decoration: underline; - text-decoration-style: wavy; - text-decoration-color: #b0b0b0; - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - .ui-state-highlight td { height: 20px; } -- cgit v1.3 From 4faf0da3a36bfdcf354b095a2cf5b297ca09af65 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 00:41:36 +0200 Subject: UI cleanup pass: search overlay position, remaining table forms The search overlay was positioned against the whole viewport (left: 50%, transform: translateX(-50%)) because it sat outside wider text nav it used to center under. Moved inside #navigation, which already has its own positioning context, so it now anchors to the icon nav itself instead of the page center. admin/search_results.html.erb was a bare, unstyled
    concatenating title and unique_name into one string by hand. Restyled to match the byline convention already used everywhere else (title_for_node, a separate link_to_path byline with the trailing arrow), and results now link to the node's admin show page rather than straight to edit, matching every other node list in the app instead of being the one exception. menu_items#new and sessions#new (the login page) were the last two views still using the pre-node_content/node_description table-based label/field layout -- found via a grep for the old td.description marker, not guessed at. Converting menu_items#new surfaced a real, separate gap: its input fields have never had a width rule, since the existing 690px rule only applies to a few specifically-named field IDs or anything inside #page_editor, neither of which applies here. Same likely gap probably exists on other simple CRUD forms (users/events/assets/occurrences/pages) -- not fixed here, flagged for its own pass rather than guessed at blind. nodes#new's Create button was pushed to the far right of the page by one stray class (node_content right) no sibling create form carries -- not a family-wide inconsistency, one extra class in one file. --- app/views/admin/search_results.html.erb | 11 +++++---- app/views/layouts/admin.html.erb | 17 +++++++------ app/views/menu_items/new.html.erb | 42 +++++++++++++-------------------- app/views/nodes/new.html.erb | 2 +- app/views/sessions/new.html.erb | 31 +++++++++--------------- public/stylesheets/admin.css | 21 ++++++++++++++--- 6 files changed, 61 insertions(+), 63 deletions(-) (limited to 'public') diff --git a/app/views/admin/search_results.html.erb b/app/views/admin/search_results.html.erb index f8aece0..a757197 100644 --- a/app/views/admin/search_results.html.erb +++ b/app/views/admin/search_results.html.erb @@ -1,7 +1,10 @@

    Search Results

    -
      -<%- @results.each do |result| %> -
    • <%= link_to "#{result.title} (#{result.unique_name})", edit_node_path(result) %>
    • -<% end %> +
        + <% @results.each do |result| %> +
      • + <%= link_to title_for_node(result), node_path(result) %> + <%= link_to_path("#{result.unique_name} ↗", result.unique_name) %> +
      • + <% end %>
      diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 00d7075..61bb5c3 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -27,6 +27,14 @@ +
<% if flash[:notice].present? || flash[:error].present? %> @@ -50,14 +58,5 @@
- - diff --git a/app/views/menu_items/new.html.erb b/app/views/menu_items/new.html.erb index 223cb8e..8957293 100644 --- a/app/views/menu_items/new.html.erb +++ b/app/views/menu_items/new.html.erb @@ -1,30 +1,20 @@

Add Menu Item

<%= form_for @menu_item do |f| %> - - - - - - - - - - - - - - - - - - - - - - -
Search - <%= text_field_tag :menu_search_term %> - -
Node Id<%= f.text_field :node_id %>
Path<%= f.text_field :path %>
Title<%= f.text_field :title %>
<%= f.submit 'Create' %>
+
Search
+
+ <%= text_field_tag :menu_search_term %> + +
+ +
Node Id
+
<%= f.text_field :node_id %>
+ +
Path
+
<%= f.text_field :path %>
+ +
Title
+
<%= f.text_field :title %>
+ +
<%= f.submit 'Create' %>
<% end %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index bb7e078..b63a606 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -46,7 +46,7 @@
-
<%= submit_tag "Create" %>
+
<%= submit_tag "Create" %>
<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 3c10303..23952b7 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,24 +1,15 @@
<%= form_tag session_path do -%> - - - - - - - - - - - - - - - - - -
- <%= flash[:error] %>  -
Login<%= text_field_tag 'login', @login %>
Password<%= password_field_tag 'password', nil %>
<%= submit_tag 'log in' %>
+ <% if flash[:error] %> +
<%= flash[:error] %>
+ <% end %> + +
Login
+
<%= text_field_tag 'login', @login %>
+ +
Password
+
<%= password_field_tag 'password', nil %>
+ +
<%= submit_tag 'log in' %>
<% end -%>
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index c1cc456..2044ab0 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -26,6 +26,7 @@ a:hover { nodes#show, and the dashboard draft list's Show/Revisions links. */ #page_editor a, .dashboard_widget a, +.search_results_list a, #sitemap a, #flash a, div.pagination a, @@ -941,6 +942,10 @@ div#page_editor { @media(min-width:1016px) { input#tag_list, + input#menu_search_term, + input#menu_item_node_id, + input#menu_item_path, + input#menu_item_title, input#node_staged_slug, input#move_to_search_term { width: 690px; @@ -1073,15 +1078,25 @@ div#draft_list table td.actions a { padding-right: 5px; } +.search_results_list { + list-style: none; + margin: 0; + padding: 0; +} + +.search_results_list li { + padding: 0.5rem 0; + border-bottom: 1px solid #f1f1f1; +} + /* ============================================================ Search widgets ============================================================ */ #search_widget { position: absolute; - top: 20px; - left: 50%; - transform: translateX(-50%); + top: 2.75rem; + left: 0; width: min(520px, 90vw); border: 1px solid #000000; -webkit-box-shadow: 3px 3px 5px #b1b1b1; -- cgit v1.3 From 6730af4b33b207a7a1880cef2706e67313a7e5cd Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 01:01:31 +0200 Subject: Fix search widget input width; remove dead navigation CSS The input's width was calc(100% - 70px), a number tuned to leave room for a "Search: " label that no longer needs to exist -- replaced by a placeholder instead, which also removes the fragile arithmetic entirely rather than re-tuning it. Also picked up a proper border-radius and padding, matching the dashboard's own search input. The width bug's actual proximate cause was a stray #navigation div { float: left } left over from the old text nav, interfering with the icon nav's own flex layout. Going through that whole block with the same scrutiny turned up four more rules worth removing, not fixing: a:hover (identical specificity, later in the file, silently winning the cascade since the icon nav landed), and #metadata/#overview_toggle in the remaining selectors were dead for two different, confirmed reasons -- #overview_toggle was the old wizard's toggle bar, gone along with it, and #metadata here was the pre-details-view subnav toggle, retired before this session and unrelated to the current, still-live #metadata id on the metadata-details section in nodes#edit (a confusing reuse that's caused real problems before). --- app/views/layouts/admin.html.erb | 2 +- public/stylesheets/admin.css | 35 +++++------------------------------ 2 files changed, 6 insertions(+), 31 deletions(-) (limited to 'public') diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 61bb5c3..5ae363f 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -30,7 +30,7 @@