From 95955abaa339098755a214cfcadf87c90211fe64 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 1 Jul 2026 00:24:10 +0200 Subject: Add RRULE humanizer and wire events into nodes#show - app/models/concerns/rrule_humanizer.rb: new concern included into Event, renders recurring schedule as natural-language German or English from RRULE string; handles WEEKLY/MONTHLY, biweekly (INTERVAL=2), ordinal weekday positions (1TU, -1TH, -2WE), BYMONTH single-month exclusions (December pause convention); gracefully returns nil for COUNT/UNTIL/unrecognized shapes - test/models/concerns/rrule_humanizer_test.rb: 15 tests covering all distinct RRULE shapes found in production data - app/helpers/nodes_helper.rb: add event_schedule_text helper combining humanize_rrule with start_time formatting - app/views/nodes/show.html.erb: add events row, conditionally rendered when node has associated events - config/locales/de.yml, en.yml: add event_schedule_time, event_schedule_unrecognized, event_schedule_none keys --- app/views/nodes/show.html.erb | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index de4d8a26..72232194 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,6 +42,18 @@ Tagged with: <%= @page.tag_list %> + <% if @node.events.any? %> + + Events + + + + + <% end %> Title <%= sanitize( @page.title ) %> @@ -59,4 +71,4 @@ - \ No newline at end of file + -- cgit v1.3 From b6416e86c9c58b8e886c14de55b01aeb863b8676 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:12:02 +0200 Subject: Clean up events views: return_to, subnav, remove custom_rrule - events_controller: wire return_to through show and new actions; create respects return_to with fallback to node/event path; new pre-populates node_id and tag_list from params - events/edit: remove custom_rrule checkbox; node link removed from subnav (use show for node navigation); destroy button added - events/new: remove custom_rrule checkbox; add return_to hidden field and back link; tag_list field added - events/show: fix back link via safe_return_to; add node link to subnav; add destroy button; remove custom_rrule display; show humanized rrule below raw string - events/index: destructive class on destroy button; node_id column replaced with node link; show/edit links carry return_to - nodes/show: add show/edit links to event entries with return_to --- app/controllers/events_controller.rb | 10 +++++++--- app/views/events/edit.html.erb | 9 +++------ app/views/events/index.html.erb | 10 ++++------ app/views/events/new.html.erb | 7 ++++--- app/views/events/show.html.erb | 16 +++++++++++----- app/views/nodes/show.html.erb | 6 +++++- 6 files changed, 34 insertions(+), 24 deletions(-) (limited to 'app/views/nodes') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 3a60cf93..9bed5ddd 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -21,6 +21,7 @@ class EventsController < ApplicationController # GET /events/1.xml def show @event = Event.find(params[:id]) + @return_to = params[:return_to] || events_path respond_to do |format| format.html # show.html.erb @@ -31,7 +32,10 @@ class EventsController < ApplicationController # GET /events/new # GET /events/new.xml def new - @event = Event.new(:node_id => params[:node_id]) + @event = Event.new( + node_id: params[:node_id], + tag_list: params[:tag_list] + ) respond_to do |format| format.html # new.html.erb @@ -53,7 +57,7 @@ class EventsController < ApplicationController respond_to do |format| if @event.save flash[:notice] = 'Event was successfully created.' - format.html { redirect_to(@event.node ? edit_node_path(@event.node) : edit_event_path(@event)) } + format.html { redirect_to(safe_return_to(params[:return_to] || (@event.node ? edit_node_path(@event.node) : edit_event_path(@event)))) } format.xml { render :xml => @event, :status => :created, :location => @event } else format.html { render :action => "new" } @@ -94,6 +98,6 @@ class EventsController < ApplicationController private def event_params - params.require(:event).permit(:title, :description, :is_primary, :start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) + params.require(:event).permit(:title, :description, :start_time, :end_time, :rrule, :allday, :url, :latitude, :longitude, :node_id, :location, :tag_list) end end diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 17457df4..5aee5019 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -1,9 +1,6 @@ <% content_for :subnavigation do %> <%= link_to 'back', safe_return_to(params[:return_to] || events_path) %> - <% if @event.node %> - <%= link_to 'node', edit_node_path(@event.node) %> - <% end %> - <%= link_to 'show', @event %> + <%= button_to 'destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> <% end %>

Editing event

@@ -25,8 +22,8 @@ <%= f.text_field :rrule %>

- <%= f.label :custom_rrule %>
- <%= f.check_box :custom_rrule %> + <%= f.label :tag_list, "Tags" %>
+ <%= f.text_field :tag_list %>

<%= f.label :allday %>
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 064fa860..d0458d71 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -3,7 +3,6 @@ - @@ -16,17 +15,16 @@ <% @events.each do |event| %> - - - - - + + + + <% end %>
TitleIs primary Start time End time Rrule
<%=h event.display_title %><%=h event.is_primary %> <%=h event.start_time %> <%=h event.end_time %> <%=h event.rrule %> <%=h event.custom_rrule %> <%=h event.allday %> <%=h event.url %><%=h event.node_id %><%= link_to 'Show', event %><%= link_to 'Edit', edit_event_path(event) %><%= button_to 'Destroy', event, method: :delete, form: { data: { confirm: 'Are you sure?' } } %><%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %><%= link_to 'show', event %><%= link_to 'edit', edit_event_path(event) %><%= button_to 'destroy', event, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } %>
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index cd892c5d..4e1ef53e 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -16,8 +16,8 @@ <%= f.text_field :rrule %>

- <%= f.label :custom_rrule %>
- <%= f.check_box :custom_rrule %> + <%= f.label :tag_list, "Tags" %>
+ <%= f.text_field :tag_list %>

<%= f.label :allday %>
@@ -37,10 +37,11 @@

<%= f.hidden_field :node_id %> + <%= hidden_field_tag :return_to, params[:return_to] %>

<%= f.submit 'Create' %>

<% end %> -<%= link_to 'Back', events_path %> +<%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index ba14a7d3..e206bc4c 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -1,6 +1,9 @@ <% content_for :subnavigation do %> - <%= link_to 'back', edit_node_path(@event.node) %> - <%= link_to 'edit', edit_event_path(@event) %> + <% if @event.node %> + <%= link_to 'node', node_path(@event.node) %> + <% end %> + <%= link_to 'edit', edit_event_path(@event, return_to: request.path) %> + <%= button_to 'destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> <% end %>

Event for node <%= @event.node.unique_name %>

@@ -18,11 +21,14 @@

Rrule: <%=h @event.rrule %> + <% if (human = @event.humanize_rrule(I18n.locale)) %> +
( <%= human %> ) + <% end %>

- Custom rrule: - <%=h @event.custom_rrule %> + Tags: + <%=h @event.tag_list %>

@@ -43,4 +49,4 @@

Longitude: <%=h @event.longitude %> -

\ No newline at end of file +

diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 72232194..2ce3853e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -48,7 +48,11 @@ -- cgit v1.3 From 2c54303b1ac08ec6b7382ece218f37d59db7052c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:53:40 +0200 Subject: Add direct edit link to nodes#index First application of subnav visual conventions to nodes: actions column now shows show | edit | revisions as plain informational links. edit intentionally uses edit_node_path (locking is expected behaviour for nodes, unlike event views where node_path is used to avoid inadvertent locking). --- app/views/nodes/index.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index e5a55d41..2399105f 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -21,7 +21,8 @@ <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> <%= node.lock_owner.login if node.lock_owner %> -- cgit v1.3 From 206dc5c50a73c5402b90d7fdc8945d3ba9356758 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:24:55 +0200 Subject: Add self-service event creation from nodes#show nodes#show's events table now renders unconditionally (previously hidden entirely for a zero-event node) and gains an "add event" link. The suggested tag_list is derived from the page's own category tags via NodesHelper::DEFAULT_EVENT_TAG_BY_PAGE_TAG (erfa-detail/ chaostreff-detail -> open-day) rather than hardcoded, and degrades to a blank field for any node whose tags don't match - deliberately universal, not chapter-specific, since Updates have historically carried event dates the same way. events#new surfaces *why* the tag was pre-filled via flash.now (not flash - this is a same-request render, not a redirect), using an explicit auto_tag_source param passed alongside tag_list rather than having the controller re-derive the reason from node_id. No destroy link added to nodes#show's events list - deliberate, per existing subnav-semantics convention (destructive actions live on the resource's own views). Covered directly by test. Adds real coverage for EventsController, previously 100% commented-out scaffold, plus unit tests for the new tag-mapping helper and the weekday-abbreviation helpers from the prior commit. --- app/controllers/events_controller.rb | 4 + app/helpers/nodes_helper.rb | 14 +++ app/views/nodes/show.html.erb | 4 +- test/controllers/events_controller_test.rb | 167 ++++++++++++++++++++------- test/controllers/nodes_controller_test.rb | 33 ++++++ test/models/concerns/rrule_humanizer_test.rb | 15 +++ test/models/helpers/content_helper_test.rb | 4 + test/models/helpers/nodes_helper_test.rb | 21 ++++ 8 files changed, 219 insertions(+), 43 deletions(-) (limited to 'app/views/nodes') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 9bed5ddd..b98a38e8 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -37,6 +37,10 @@ class EventsController < ApplicationController tag_list: params[:tag_list] ) + if params[:tag_list].present? && params[:auto_tag_source].present? + flash.now[:notice] = "Tag '#{params[:tag_list]}' was pre-filled because this page is tagged '#{params[:auto_tag_source]}'. You can remove it below." + end + respond_to do |format| format.html # new.html.erb format.xml { render :xml => @event } diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 329bcc53..093bfc6a 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -44,6 +44,20 @@ module NodesHelper ]) end + DEFAULT_EVENT_TAG_BY_PAGE_TAG = { + 'erfa-detail' => 'open-day', + 'chaostreff-detail' => 'open-day' + }.freeze + + def default_event_tag_mapping(page) + page_tags = page.tag_list + DEFAULT_EVENT_TAG_BY_PAGE_TAG.find { |page_tag, _| page_tags.include?(page_tag) } + end + + def default_event_tag_list(page) + default_event_tag_mapping(page)&.last + end + def event_schedule_text(event) if event.rrule.present? recurrence = event.humanize_rrule(I18n.locale) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2ce3853e..c533a55e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,7 +42,6 @@ Tagged with: <%= @page.tag_list %> - <% if @node.events.any? %> Events @@ -55,9 +54,10 @@ <% 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) %> - <% end %> Title <%= sanitize( @page.title ) %> diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 14e534ea..9371ca78 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1,45 +1,130 @@ require 'test_helper' class EventsControllerTest < ActionController::TestCase - # test "should get index" do - # get :index - # assert_response :success - # assert_not_nil assigns(:events) - # end - # - # test "should get new" do - # get :new - # assert_response :success - # end - # - # test "should create event" do - # assert_difference('Event.count') do - # post :create, params: { :event => { } } - # end - # - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should show event" do - # get :show, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should get edit" do - # get :edit, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should update event" do - # put :update, params: { :id => events(:one).to_param, :event => { } } - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should destroy event" do - # assert_difference('Event.count', -1) do - # delete :destroy, params: { :id => events(:one).to_param } - # end - # - # assert_redirected_to events_path - # end + + test "should get index" do + login_as :quentin + get :index + assert_response :success + assert_not_nil assigns(:events) + end + + test "should get new" do + login_as :quentin + get :new + assert_response :success + end + + test "new pre-fills tag_list and explains it via flash when auto_tag_source is given" do + login_as :quentin + node = create_node_with_published_page + + get :new, params: { node_id: node.id, tag_list: "open-day", auto_tag_source: "erfa-detail" } + + assert_response :success + assert_equal "open-day", assigns(:event).tag_list.to_s + assert_match "open-day", flash[:notice] + assert_match "erfa-detail", flash[:notice] + end + + test "new does not flash without an auto_tag_source" do + login_as :quentin + + get :new, params: { tag_list: "open-day" } + + assert_response :success + assert_nil flash[:notice] + end + + test "new with no params renders a blank, unflashed form" do + login_as :quentin + + get :new + + assert_response :success + assert_nil assigns(:event).tag_list.presence + assert_nil flash[:notice] + end + + test "should show event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: event.id } + assert_response :success + end + + test "should get edit" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :edit, params: { id: event.id } + assert_response :success + end + + test "should create event attached to a node" do + login_as :quentin + node = create_node_with_published_page + + assert_difference('Event.count') do + post :create, params: { + event: { + node_id: node.id, + start_time: Time.now, + end_time: Time.now + 1.hour, + tag_list: "open-day" + } + } + end + + assert_redirected_to edit_node_path(node) + assert_equal 'Event was successfully created.', flash[:notice] + end + + test "should not create an event without a title or a node_id" do + login_as :quentin + + assert_no_difference('Event.count') do + post :create, params: { event: { start_time: Time.now, end_time: Time.now + 1.hour } } + end + + assert_response :success # re-renders :new, not a redirect + end + + test "should honour return_to on create" do + login_as :quentin + node = create_node_with_published_page + + post :create, params: { + event: { node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour }, + return_to: node_path(node) + } + + assert_redirected_to node_path(node) + end + + test "should update event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + put :update, params: { id: event.id, event: { title: "Updated title" } } + + assert_redirected_to events_path + assert_equal "Updated title", event.reload.title + end + + test "should destroy event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + assert_difference('Event.count', -1) do + delete :destroy, params: { id: event.id } + end + + assert_redirected_to events_url + end end diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 53799f13..f14e27ce 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -379,4 +379,37 @@ class NodesControllerTest < ActionController::TestCase get :index end + test "show renders events row and add-link for zero-event chapter node" do + login_as :quentin + node = create_node_with_published_page + node.head.tag_list = "erfa-detail" + node.head.save! + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=open-day']" + assert_select "a[href*='auto_tag_source=erfa-detail']" + end + + test "show renders events row without a tag default for untagged node" do + login_as :quentin + node = create_node_with_published_page + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=']", count: 0 + end + + test "show never renders a destroy link for events" do + login_as :quentin + node = create_node_with_published_page + Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: node.id } + assert_response :success + assert_select "form.button_to.destructive", count: 0 + end + end diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 500dbc7d..279ff73f 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -81,4 +81,19 @@ class RruleHumanizerTest < ActiveSupport::TestCase test "falls back to english for unknown locale" do assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) end + + test "wday_abbr returns the correct German abbreviation for each day" do + monday = Time.parse("2026-07-06") # confirmed Monday + assert_equal "Mo", RruleHumanizer.wday_abbr(monday, :de) + assert_equal "Di", RruleHumanizer.wday_abbr(monday + 1.day, :de) + assert_equal "Mi", RruleHumanizer.wday_abbr(monday + 2.days, :de) + assert_equal "Do", RruleHumanizer.wday_abbr(monday + 3.days, :de) + assert_equal "Fr", RruleHumanizer.wday_abbr(monday + 4.days, :de) + assert_equal "Sa", RruleHumanizer.wday_abbr(monday + 5.days, :de) + assert_equal "So", RruleHumanizer.wday_abbr(monday + 6.days, :de) + end + + test "wday_abbr falls back to :de for an unrecognized locale" do + assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) + end end diff --git a/test/models/helpers/content_helper_test.rb b/test/models/helpers/content_helper_test.rb index 2da82d7e..a7ed478d 100644 --- a/test/models/helpers/content_helper_test.rb +++ b/test/models/helpers/content_helper_test.rb @@ -1,4 +1,8 @@ require 'test_helper' class ContentHelperTest < ActionView::TestCase + test "weekday_abbr delegates through the current I18n locale" do + I18n.locale = :de + assert_equal "Mo", weekday_abbr(Time.parse("2026-07-06")) + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 13011dea..5d91a885 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class NodesHelperTest < ActionView::TestCase + FakePage = Struct.new(:tag_list) + + test "default_event_tag_mapping matches erfa-detail" do + page = FakePage.new(["erfa-detail"]) + assert_equal ["erfa-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping matches chaostreff-detail" do + page = FakePage.new(["chaostreff-detail"]) + assert_equal ["chaostreff-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping returns nil for unrelated tags" do + page = FakePage.new(["update"]) + assert_nil default_event_tag_mapping(page) + end + + test "default_event_tag_list is nil without a matching tag" do + page = FakePage.new([]) + assert_nil default_event_tag_list(page) + end end -- cgit v1.3 From 9c630d186003a47563be2e3547485060dac7dd98 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:34:42 +0200 Subject: Retire event_information, superseded by nodes#show's event manager The metadata-panel "Event" field on nodes#edit called event_information, a second, tag-less implementation of "list events + add one" - now redundant now that nodes#show has the tag-aware version with the auto-tag flash explanation. Confirmed via grep this was the only call site before removing both it and the method definition. --- app/helpers/nodes_helper.rb | 15 --------------- app/views/nodes/edit.html.erb | 3 --- 2 files changed, 18 deletions(-) (limited to 'app/views/nodes') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 093bfc6a..2baf813c 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -29,21 +29,6 @@ module NodesHelper User.all.map {|u| [u.login, u.id]} end - def event_information - events = @node.events.order(:start_time) - items = events.map do |event| - safe_join([ - "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", - link_to('edit', edit_event_path(event)), - ]) - end - safe_join([ - safe_join(items, ' | '), - ' > ', - link_to('add event', new_event_path(:node_id => @node.id)) - ]) - end - DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 'erfa-detail' => 'open-day', 'chaostreff-detail' => 'open-day' diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 498fa1fa..31f585c6 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -15,9 +15,6 @@ <% end %>
-
Event
-
<%= event_information %>
-
Slug
<%= f.text_field( -- cgit v1.3 From 9a7ce465b06462443ee82e8418f3c070de080ab2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:52:11 +0200 Subject: Surface node-kind conventions in the admin UI nodes#new: each kind's radio button now shows a label plus a smaller, muted .field_hint explaining what happens automatically (parent, tags, template) - previously a single undifferentiated string mixing a short label with a long parenthetical, cluttering the smaller kinds (erfa/chaostreff) worst. Title field gets the same hint treatment, telling editors up front that the slug is auto-generated and where to adjust it later - the previous silence here meant editors only discovered slug generation after already committing to a title, with no visible way to fix it. New "Resulting path" row live-previews the full URL (origin + kind's path prefix + a debounced call to the new parameterize_preview route, reusing the exact same slug_for helper "create" now calls, so the preview can never diverge from what actually gets created) as the editor types, plus a copy-to-clipboard button. For "generic", the prefix comes from whichever parent gets chosen via the search widget, not the registry - parent_search.link_closure now stashes the chosen node's unique_name as a data attribute for exactly this. New read-only admin/conventions view dumps the NODE_KINDS registry as a reference table for whoever's confused later about how a given kind behaves - deliberately just renders the same label/hint text used on nodes#new rather than re-describing parent/tags/template separately, since decomposing the parent Proc would mean either invoking it (Update's has a real side effect - creates the year folder) or re-stating its logic a second time in a different shape, either of which risks drifting from the actual behavior. nodes#edit's Template field gets a one-line hint noting the value may already be pre-filled based on how the node was created - the one place the previous commit's new inheritance mechanism actually surfaces on this view. --- app/helpers/application_helper.rb | 4 ++++ app/views/nodes/edit.html.erb | 5 ++++- app/views/nodes/new.html.erb | 34 +++++++++++++++++++--------------- config/routes.rb | 1 + public/stylesheets/admin.css | 12 ++++++++++++ 5 files changed, 40 insertions(+), 16 deletions(-) (limited to 'app/views/nodes') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0be66e90..87aa82c4 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,4 +11,8 @@ module ApplicationHelper end end end + + def resolve_kind_text(value) + value.respond_to?(:call) ? value.call : value + end end diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 31f585c6..2d0edc16 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -44,7 +44,10 @@
<%= d.datetime_select :published_at, :value => @draft.published_at %>
Template
-
<%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
+
+ <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> + Set automatically based on how this node was created - change it if needed. +
Author
<%= d.select :user_id, user_list %>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 028d7270..f5574547 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,27 +13,23 @@ Type -
- <%= radio_button_tag :kind, "top_level" %> - Top Level -
-

- <%= radio_button_tag :kind, "generic", :selected => true %> - Generic ( can be created anywhere ) -

-

- <%= radio_button_tag :kind, "update" %> - Update ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tag "update" ) -

+ <% CccConventions::NODE_KINDS.each do |kind, config| %>

- <%= radio_button_tag :kind, "press_release" %> - Pressemitteilung ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tags "update, pressemitteilung" ) + <%= radio_button_tag :kind, kind, kind == "generic", + data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> + <%= resolve_kind_text(config[:label]) %> + <% if config[:hint] %> + <%= resolve_kind_text(config[:hint]) %> + <% end %>

+ <% end %> Title - <%= text_field_tag :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 @@ -45,6 +41,14 @@
+ + 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" %> diff --git a/config/routes.rb b/config/routes.rb index cf4733c0..6c074143 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 a02bd61d..f240bb23 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 9e63a6bec1b4ccc45dd684f7b6a941b75f9b9cf0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 06:09:21 +0200 Subject: Add public preview links for drafts Lets editors share a draft with people outside the admin system, via a random per-page token rather than an enumerable id - /preview/. shared_previews#show is intentionally unauthenticated. Redirects to the real public URL once the page is published. Surfaced on nodes#show (Admin Preview + Public Preview, next to Public Link) as generate/revoke buttons. --- app/controllers/nodes_controller.rb | 18 ++++++++++++++++++ app/controllers/shared_previews_controller.rb | 13 +++++++++++++ app/views/nodes/show.html.erb | 17 +++++++++++++++++ config/routes.rb | 4 ++++ 4 files changed, 52 insertions(+) create mode 100644 app/controllers/shared_previews_controller.rb (limited to 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 380a6597..69aa2683 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -104,6 +104,24 @@ class NodesController < ApplicationController redirect_to node_path(@node) end + def generate_shared_preview + @node = Node.find(params[:id]) + if @node.draft + @node.draft.ensure_preview_token! + flash[:notice] = "Shareable preview link created - see below." + else + flash[:notice] = "Create or edit a draft first - shared preview links are only available for pages with an active draft." + end + redirect_to node_path(@node) + end + + def revoke_shared_preview + @node = Node.find(params[:id]) + @node.draft.revoke_preview_token! if @node.draft + flash[:notice] = "Shareable preview link revoked." + redirect_to node_path(@node) + end + def parameterize_preview render plain: slug_for(params[:title]) end diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb new file mode 100644 index 00000000..a8a540f1 --- /dev/null +++ b/app/controllers/shared_previews_controller.rb @@ -0,0 +1,13 @@ +class SharedPreviewsController < ApplicationController + def show + @page = Page.find_by!(preview_token: params[:token]) + + if @page.node && @page.node.head_id == @page.id + redirect_to node_path(@page.node) + return + end + + response.headers['X-Robots-Tag'] = 'noindex' + render template: @page.valid_template, layout: "application" + end +end diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index c533a55e..d63df86d 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -12,6 +12,23 @@ Public Link <%= 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 %> Author <%= @page.user.try(:login) %> diff --git a/config/routes.rb b/config/routes.rb index 6c074143..26c3d4d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,8 @@ Cccms::Application.routes.draw do member do put :unlock put :publish + put :generate_shared_preview + put :revoke_shared_preview end resources :revisions do @@ -50,6 +52,8 @@ Cccms::Application.routes.draw do end end + get 'preview/:token', to: 'shared_previews#show', as: :shared_preview + scope '/admin' do resources :assets end -- 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 'app/views/nodes') diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index f5574547..0a053250 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 d63df86d..189adb89 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 f240bb23..1196d830 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 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 69aa2683..a72be68e 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 2baf813c..a89f8799 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 0a053250..71f2fbf1 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 189adb89..963bc37c 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 c0f9943f..b4204528 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 25659298..6ef9087a 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 1196d830..3033798c 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 42f81016be8c55322e0dbb2c20b08812d5d2228b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:43:21 +0200 Subject: Adopt button pills in views --- app/helpers/link_helper.rb | 2 +- app/views/admin/_drafts.html.erb | 2 +- app/views/nodes/edit.html.erb | 2 +- app/views/nodes/show.html.erb | 4 ++-- app/views/revisions/index.html.erb | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) (limited to 'app/views/nodes') diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index 878e8e4a..9b0b4e10 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -44,7 +44,7 @@ module LinkHelper "Last modified #{@page.updated_at.to_fs(:db)}" button_to 'Unlock', unlock_node_path(@node), method: :put, - form: { data: { confirm: message } } + form: { data: { confirm: message }, class: 'button_to state_changing' } end def content_path(page_path = nil, options = {}) diff --git a/app/views/admin/_drafts.html.erb b/app/views/admin/_drafts.html.erb index a35b0ab8..4ef568f8 100644 --- a/app/views/admin/_drafts.html.erb +++ b/app/views/admin/_drafts.html.erb @@ -17,7 +17,7 @@ <%= link_to 'Show', node_path(draft.node) %> <%= link_to "Revisions", revision_path(draft.node.id) %> - <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" } } %> + <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" }, class: 'button_to state_changing' } %> <% end %> diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 2d0edc16..4cdb3ae0 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -2,7 +2,7 @@ <%= link_to 'metadata', '#', :id => 'button', :class => "unselected" %> <%= 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?" } } %> + <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> <%= link_to 'Revisions', node_revisions_path(@node) %> <% end %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 963bc37c..71dbaa3e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -59,9 +59,9 @@ 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." } } %> + <%= 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." }, class: 'button_to state_changing' } %> <% else %> - <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> + <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put, form: { class: 'button_to state_changing' } %> <% end %>
<% end %> diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index b875a4fa..26a9ff45 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -27,7 +27,7 @@ <%= button_to 'restore', restore_node_revision_path(@node, page), method: :put, - form: { data: { confirm: "Restore this revision?" } } %> + form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> <% end %> -- cgit v1.3 From b034da9fbabb882fbe9f5f3f355442dfa20e5ecf Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:56:11 +0200 Subject: Tuck away metadata section in nodes#edit behind a summary/detail cascade --- app/views/nodes/edit.html.erb | 105 ++++++++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 51 deletions(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4cdb3ae0..4693569f 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -1,9 +1,7 @@ <% content_for :subnavigation do %> - <%= link_to 'metadata', '#', :id => 'button', :class => "unselected" %> <%= 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' } %> - <%= link_to 'Revisions', node_revisions_path(@node) %> <% end %>
@@ -13,67 +11,72 @@
    <% @node.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
<% end %> - -
-
Slug
-
- <%= f.text_field( - :staged_slug, :value => @node.staged_slug || @node.slug - ) - %> -
-
parent
-
- <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> -
+

<%= title_for_node(@node) %>

-
- <%= f.hidden_field( - :staged_parent_id, - :value => @node.staged_parent_id || @node.parent_id +
+ Metadata (slug, parent, tags, template, author, images) +
+
Slug
+
+ <%= f.text_field( + :staged_slug, :value => @node.staged_slug || @node.slug ) - %> -
- - <%= fields_for @draft do |d| %> -
Tags - comma seperated
-
<%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
+ %> +
-
Publish at
-
<%= d.datetime_select :published_at, :value => @draft.published_at %>
+
parent
+
+ <%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> +
-
Template
-
- <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> - Set automatically based on how this node was created - change it if needed. -
+
+ <%= f.hidden_field( + :staged_parent_id, + :value => @node.staged_parent_id || @node.parent_id + ) + %> +
-
Author
-
<%= d.select :user_id, user_list %>
+ <%= fields_for @draft do |d| %> +
Tags - comma seperated
+
<%= text_field_tag :tag_list, @draft.tag_list.join(', ') %>
-
Images
-
-
    - <% @draft.assets.images.each do |image| %> -
  • - <%= image_tag(image.upload.url(:thumb)) %> -
  • - <% end %> -
-
- image browser +
Publish at
+
<%= d.datetime_select :published_at, :value => @draft.published_at %>
+ +
Template
+
+ <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> + Set automatically based on how this node was created - change it if needed.
-
-
    - <% Asset.images.each do |image| %> -
  • <%= image_tag(image.upload.url(:thumb)) %>
  • + +
    Author
    +
    <%= d.select :user_id, user_list %>
    + +
    Images
    +
    +
      + <% @draft.assets.images.each do |image| %> +
    • + <%= image_tag(image.upload.url(:thumb)) %> +
    • <% end %>
    + +
    +
      + <% Asset.images.each do |image| %> +
    • <%= image_tag(image.upload.url(:thumb)) %>
    • + <% end %> +
    +
-
- +
+
Title
<%= d.text_field :title %>
-- cgit v1.3 From b555a3e05c44f21067855b6bbafa70a5e32c9a40 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:56:39 +0200 Subject: Promote page title to headline in nodes#show --- app/views/nodes/show.html.erb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 71dbaa3e..2ab7986e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -4,8 +4,8 @@ <% end %>
+

<%= title_for_node(@node) %>

-
People
@@ -128,9 +128,6 @@
<% end %> -
Title
-
<%= sanitize(@page.title) %>
-
Abstract
<%= sanitize(@page.abstract) %>
-- 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 'app/views/nodes') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4693569f..1c6cc3a1 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 41699a17..4107ce8e 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 0c0779fb..7ae374cf 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 'app/views/nodes') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c6cc3a1..feba92ab 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 7ae374cf..ceacf174 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 0bc112baec8b3d19aba3c1757cb4cf81fda098c5 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:49:49 +0200 Subject: Fix blank-title validation and its lost form state on re-render A blank title failed presence and length validation simultaneously, showing two redundant messages for one problem; length now skips whenever slug is already blank, matching presence's own skip condition. Separately, create's failure path never computed @selected_kind/@parent_id/@parent_name the way new does, so re-rendering after a validation error silently lost which kind and parent had been chosen -- for the auto-tagging/auto-templating kinds, that meant a corrected resubmission could silently produce a plain generic node instead. required on the title field closes the common case without a round trip; the server-side fix remains the actual guarantee, since required is trivially bypassed. --- app/controllers/nodes_controller.rb | 5 +++++ app/models/node.rb | 2 +- app/views/nodes/new.html.erb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) (limited to 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fd000b8..b56d7791 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -49,6 +49,11 @@ class NodesController < ApplicationController redirect_to(edit_node_path(@node)) else + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" + if params[:parent_id].present? + @parent_id = params[:parent_id] + @parent_name = Node.find(@parent_id).title + end render :new end end diff --git a/app/models/node.rb b/app/models/node.rb index f15c908a..7675ab64 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -17,7 +17,7 @@ class Node < ApplicationRecord after_save :update_unique_names_of_children # Validations - validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } + validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } validates_presence_of :slug, :unless => -> { parent_id.nil? } validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } validates_presence_of :parent_id, :unless => -> { Node.root.nil? } diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 71f2fbf1..afc632f9 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -25,7 +25,7 @@
Title
- <%= text_field_tag :title %> + <%= text_field_tag :title, nil, required: true %> A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.
-- 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 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index d8586e29..72d4a3eb 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 feba92ab..cdc9b36e 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 2ab7986e..036caf29 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 ceacf174..a6e8bf6e 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 8310751d4db2854597d6379b24e346c65622972d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 14:19:36 +0200 Subject: Major resource view cleanup Remove dead pages#index and occurrences resource; fix menu_items#new menu_items#new called menu_item_params, which requires a submitted menu_item key that can never be present on a fresh GET -- new only ever needs a blank record to render against. pages#index and the entire occurrences resource were unmodified Rails scaffold generator output, unlinked from any nav and unreachable except by direct URL. pages#index had no controller action at all (@pages was never assigned); occurrences#index queried every row with no scoping or pagination and its layout referenced a scaffold.css asset that no longer exists in the pipeline. Neither is a real editorial surface -- occurrences are auto-generated from an event's RRULE and were never meant to be browsed as a flat list. Removed rather than repaired. pages#preview and pages#sort_images remain, now as explicit routes rather than under a full resources :pages block. --- app/controllers/menu_items_controller.rb | 2 +- app/controllers/users_controller.rb | 2 +- app/views/assets/index.html.erb | 6 ++-- app/views/assets/show.html.erb | 50 ++++++++++++++++------------- app/views/events/index.html.erb | 4 ++- app/views/menu_items/edit.html.erb | 51 +++++++++++++----------------- app/views/menu_items/index.html.erb | 9 ++---- app/views/nodes/index.html.erb | 5 +++ app/views/occurrences/index.html.erb | 2 +- app/views/pages/index.html.erb | 2 +- app/views/users/edit.html.erb | 54 ++++++++++++++------------------ app/views/users/index.html.erb | 17 +++++----- app/views/users/new.html.erb | 52 ++++++++++++++---------------- app/views/users/show.html.erb | 39 ++++++++++++----------- config/routes.rb | 9 ++---- public/stylesheets/admin.css | 32 +++++++++++++++++++ 16 files changed, 177 insertions(+), 159 deletions(-) (limited to 'app/views/nodes') diff --git a/app/controllers/menu_items_controller.rb b/app/controllers/menu_items_controller.rb index 1b1eb598..314d1ead 100644 --- a/app/controllers/menu_items_controller.rb +++ b/app/controllers/menu_items_controller.rb @@ -14,7 +14,7 @@ class MenuItemsController < ApplicationController end def new - @menu_item = MenuItem.new menu_item_params + @menu_item = MenuItem.new end def create diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f01691f7..6572b7a2 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -15,7 +15,7 @@ class UsersController < ApplicationController end def new - @user = User.new + @user = User.new(admin: params[:admin].present?) end def create diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 4301fee6..6591091e 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -1,6 +1,6 @@ -<% content_for :subnavigation do %> - <%= link_to 'New asset', new_asset_path %> -<% end %> +

Assets

+ +<%= link_to 'New asset', new_asset_path, class: 'action_button' %> <%= will_paginate @assets %> diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb index 694be5a5..02866780 100644 --- a/app/views/assets/show.html.erb +++ b/app/views/assets/show.html.erb @@ -1,23 +1,29 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_asset_path(@asset) %> - <%= link_to 'Back', assets_path %> -<% end %> +
+

<%= @asset.name %>

- - - - - - - - - - - - - - - - - -
Thumbnail<%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %>
Public Path<%= @asset.upload.url.sub(/\?\d+$/, "") %>
Content Type<%= @asset.upload.content_type %>
Size<%= "#{@asset.upload.size/1024} KB" %>
+
+
Actions
+
+
+
+ <%= link_to 'Edit', edit_asset_path(@asset), class: 'action_button' %> +
+
+ <%= link_to 'Back', assets_path %> +
+
+
+ +
Thumbnail
+
<%= image_tag @asset.upload.url(:medium), style: "max-width: 300px; max-height: 300px;" %>
+ +
Public Path
+
<%= @asset.upload.url.sub(/\?\d+$/, "") %>
+ +
Content Type
+
<%= @asset.upload.content_type %>
+ +
Size
+
<%= "#{@asset.upload.size/1024} KB" %>
+
+
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index c3e0e5ae..b6a5b1f0 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,4 +1,6 @@ -

Listing events

+

Events

+ +<%= link_to 'New event', new_event_path, class: 'action_button' %> diff --git a/app/views/menu_items/edit.html.erb b/app/views/menu_items/edit.html.erb index 98917082..dc5e8f97 100644 --- a/app/views/menu_items/edit.html.erb +++ b/app/views/menu_items/edit.html.erb @@ -1,32 +1,25 @@

Edit Menu Item

-<%= form_for @menu_item do |f| %> -
- - - - - - - - - - - - - - - - - - - - - -
Search - <%= text_field_tag :menu_search_term %> -
+
+ <%= 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 'Update' %>
-<% end %> +
Node Id
+
<%= f.text_field :node_id %>
+ +
Path
+
<%= f.text_field :path %>
+ +
Title
+
<%= f.text_field :title %>
+ +
+
<%= f.submit 'Update' %>
+ <% end %> +
+
diff --git a/app/views/menu_items/index.html.erb b/app/views/menu_items/index.html.erb index c52c1506..e84199b3 100644 --- a/app/views/menu_items/index.html.erb +++ b/app/views/menu_items/index.html.erb @@ -1,10 +1,7 @@ -<% content_for :subnavigation do %> - <%= link_to "new", new_menu_item_path %> -<% end %> - -

Menu Items

+<%= link_to 'New menu item', new_menu_item_path, class: 'action_button' %> + <% @menu_items.each do |menu_item| %> @@ -16,7 +13,7 @@ <% end %> diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index 2399105f..903e80bf 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -3,6 +3,11 @@ <% end %>

Nodes

+
+ <%= link_to 'New node', new_node_path, class: 'action_button' %> + Creates a node with no parent or kind set — for a specific page, use the wizard's create flow or "add child" from an existing node instead. +
+ <%= will_paginate @nodes %> diff --git a/app/views/occurrences/index.html.erb b/app/views/occurrences/index.html.erb index 0e998572..82818d09 100644 --- a/app/views/occurrences/index.html.erb +++ b/app/views/occurrences/index.html.erb @@ -1,4 +1,4 @@ -

Listing occurrences

+

Occurrences

diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb index 91e53598..05c73117 100644 --- a/app/views/pages/index.html.erb +++ b/app/views/pages/index.html.erb @@ -1,4 +1,4 @@ -

Listing pages

+

Pages

diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index df1005bc..77b33c6a 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -2,38 +2,32 @@ <% if @user.errors.any? %>
-
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg.gsub("Slug", "Title") %>
  • <% end %>
+
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
<% end %> +
+ <%= form_for @user do |f| %> +
+
Login
+
<%= f.text_field :login %>
-<%= form_for @user do |f| %> -
- - - - - - - - - - - - - - - - - <% if current_user.admin? %> - - - - +
E-Mail
+
<%= f.text_field :email %>
+ +
Password
+
<%= f.password_field :password %>
+ +
Confirm
+
<%= f.password_field :password_confirmation %>
+ + <% if current_user.admin? %> +
Admin?
+
<%= f.check_box :admin %>
+ <% end %> + +
+
<%= f.submit "Update" %>
<% end %> - - - - -
Login<%= f.text_field :login %>
E-Mail<%= f.text_field :email %>
Password<%= f.password_field :password %>
Confirm<%= f.password_field :password_confirmation %>
Admin?<%= f.check_box :admin %>
<%= f.submit "Update" %>
-<% end %> +
+
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 88868f56..46e958bf 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,7 +1,6 @@ +

Admins

+<%= link_to 'New admin user', new_user_path(admin: true), class: 'action_button' %> - - - @@ -9,9 +8,11 @@ <%= render :partial => "user", :locals => {:users => @users[:admin] ||= []} %> - - - +

Admins

Login

Users

+ +

Users

+<%= link_to 'New user', new_user_path, class: 'action_button' %> + @@ -20,7 +21,3 @@ <%= render :partial => "user", :locals => {:users => @users[:user] ||= []} %>
Login
- -<% content_for :subnavigation do %> - <%= link_to "create", new_user_path %> -<% end %> diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index 6beda4f1..ce38fdf9 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -2,36 +2,30 @@ <% if @user.errors.any? %>
-
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg.gsub("Slug", "Title") %>
  • <% end %>
+
    <% @user.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
<% end %> +
+ <%= form_for @user do |f| %> +
+
Login
+
<%= f.text_field :login %>
-<%= form_for @user do |f| %> - - - - - - - - - - - - - - - - - - - - - - - - - -
Login<%= f.text_field :login %>
E-Mail<%= f.text_field :email %>
Password<%= f.password_field :password %>
Confirm<%= f.password_field :password_confirmation %>
Admin?<%= f.check_box :admin %>
<%= f.submit "Create" %>
-<% end %> +
E-Mail
+
<%= f.text_field :email %>
+ +
Password
+
<%= f.password_field :password %>
+ +
Confirm
+
<%= f.password_field :password_confirmation %>
+ +
Admin?
+
<%= f.check_box :admin %>
+ +
+
<%= f.submit "Create" %>
+ <% end %> +
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 80483d3b..e744efeb 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -1,20 +1,23 @@ -<% content_for :subnavigation do %> - <%= link_to 'Edit', edit_user_path(@user) %> -<% end %> +
+

<%= @user.login %>

-

User: <%= @user.login %>

+
+
Actions
+
+
+
+ <%= link_to 'Edit', edit_user_path(@user), class: 'action_button' %> +
+
+
- - - - - - - - - - - - - -
Login<%= @user.login %>
E-Mail<%= @user.email %>
Admin?<%= @user.admin ? "yes" : "no" %>
\ No newline at end of file +
Login
+
<%= @user.login %>
+ +
E-Mail
+
<%= @user.email %>
+ +
Admin?
+
<%= @user.admin ? "yes" : "no" %>
+
+
diff --git a/config/routes.rb b/config/routes.rb index da46e5c4..5d688285 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,15 +20,10 @@ Cccms::Application.routes.draw do scope '(:locale)', locale: /de|en/ do resources :tags - resources :occurrences resources :events - resources :pages do - member do - get :preview - put :sort_images - end - end + get 'pages/:id/preview', to: 'pages#preview', as: :preview_page + put 'pages/:id/sort_images', to: 'pages#sort_images', as: :sort_images_page resources :nodes do collection do diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 66f13b8e..f00a6581 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -640,6 +640,13 @@ form.button_to button[type="submit"] { color: #969696; padding-bottom: 4px; } + +.action_button + .field_hint { + display: inline; + margin-left: 0.5rem; + 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. */ @@ -692,6 +699,7 @@ a.action_button { font-weight: bold; text-decoration: none; color: #000000; + margin-bottom: 1rem; } #page_editor a.action_button:hover, @@ -700,6 +708,11 @@ a.action_button:hover { background-color: #000000; } +#page_editor form input[type=text], +#page_editor form input[type=password] { + padding: 5px; +} + @media(min-width:1016px) { input#tag_list, input#node_staged_slug, @@ -731,6 +744,11 @@ a.action_button:hover { #page_editor #metadata, #page_editor #content, #admin_overview { margin-left: -125px; } + + #page_editor form input[type=text], + #page_editor form input[type=password] { + width: 690px; + } } @media(max-width:1015px) { @@ -773,6 +791,12 @@ a.action_button:hover { font-weight: bold; } + #page_editor form input[type=text], + #page_editor form input[type=password] { + box-sizing: border-box; + width: 100%; + } + input[type=text], textarea { font-size: 1.5rem; } @@ -920,6 +944,14 @@ 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 0bc7bdc7cd2fc111e4e161065b217bd2ddecfbec Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 15:41:36 +0200 Subject: Remove subnav from nodes#index, New node has its own action button now --- app/views/nodes/index.html.erb | 3 --- 1 file changed, 3 deletions(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index 903e80bf..2688ea12 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -1,6 +1,3 @@ -<% content_for :subnavigation do %> - <%= link_to 'Create', new_node_path %> -<% end %>

Nodes

-- cgit v1.3 From 205e6216fc7850fe717122c189e5003d1f9e8afe Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 02:03:19 +0200 Subject: Add head/draft/autosave layer comparison at three UI entry points Node#resolve_page_reference and #available_layer_pairs let Page#diff_against compare named layers (head/draft/autosave), not just numbered revisions -- autosave was never part of Node#pages, so this was the missing piece. Wired into nodes#show's Status section, nodes#edit right after an autosave gets resurrected ("What changed?"), and the admin wizard's current-drafts table, which now also lists autosave-only nodes it previously never showed. revisions#diff hides the numbered-revision picker when comparing named layers (it can't represent them), shows a plain label instead, and offers buttons to switch between whichever other pairs make sense for the node's current state. Destroying the topmost layer is available directly from the diff view, reusing the existing revert! path. "Discard changes" is renamed "Discard Autosave" everywhere it appears, to match "Destroy Draft". --- app/controllers/admin_controller.rb | 4 +-- app/controllers/nodes_controller.rb | 2 +- app/controllers/revisions_controller.rb | 16 ++++++++--- app/helpers/revisions_helper.rb | 5 ++++ app/models/node.rb | 21 ++++++++++++++ app/views/admin/index.html.erb | 13 +++++++-- app/views/nodes/edit.html.erb | 9 +++++- app/views/nodes/show.html.erb | 12 +++++++- app/views/revisions/diff.html.erb | 40 +++++++++++++++++++++++---- config/routes.rb | 1 + test/controllers/admin_controller_test.rb | 15 ++++++++-- test/controllers/revisions_controller_test.rb | 39 ++++++++++++++++++++++++++ test/models/node_test.rb | 22 +++++++++++++++ 13 files changed, 179 insertions(+), 20 deletions(-) (limited to 'app/views/nodes') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3fa05194..6ab21358 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -5,10 +5,10 @@ class AdminController < ApplicationController before_action :login_required def index - @drafts = Node.where("draft_id IS NOT NULL") + @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") .limit(50).order("updated_at desc") - @drafts_count = Node.where("draft_id IS NOT NULL").count + @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count @recent_changes = Node.where( "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL", diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 38d42d90..d1538e11 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -72,7 +72,7 @@ class NodesController < ApplicationController if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ - "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." + "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version." elsif freshly_locked flash.now[:notice] = "Node locked and ready to edit" end diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb index 9acb26f4..4b0c5491 100644 --- a/app/controllers/revisions_controller.rb +++ b/app/controllers/revisions_controller.rb @@ -21,10 +21,18 @@ class RevisionsController < ApplicationController params[:start_revision], params[:end_revision] = 1, 1 end - @start = @node.pages.find_by_revision( params[:start_revision] ) - @end = @node.pages.find_by_revision( params[:end_revision] ) - @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline - @diff = @end.diff_against( @start, view: @diff_view ) + @start = @node.resolve_page_reference(params[:start_revision]) + @end = @node.resolve_page_reference(params[:end_revision]) + + if @start.nil? || @end.nil? + flash[:error] = "That comparison is no longer available." + redirect_to(node_path(@node)) and return + end + + @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline + @diff = @end.diff_against(@start, view: @diff_view) + @available_layer_pairs = @node.available_layer_pairs + @locked_by_other = @node.locked? && @node.lock_owner != current_user end def show diff --git a/app/helpers/revisions_helper.rb b/app/helpers/revisions_helper.rb index fdb51f8b..a6290133 100644 --- a/app/helpers/revisions_helper.rb +++ b/app/helpers/revisions_helper.rb @@ -1,2 +1,7 @@ module RevisionsHelper + # Human-readable label for a diff endpoint -- "head"/"draft"/"autosave" + # get their name; anything else is a revision number. + def describe_page_reference(ref) + %w[head draft autosave].include?(ref.to_s) ? ref.to_s.capitalize : "revision #{ref}" + end end diff --git a/app/models/node.rb b/app/models/node.rb index 7675ab64..82d99540 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -123,6 +123,27 @@ class Node < ApplicationRecord self.draft.reload end + def resolve_page_reference ref + case ref.to_s + when "head" then head + when "draft" then draft + when "autosave" then autosave + else pages.find_by_revision(ref) + end + end + + # Which layer-pairs are meaningful to compare right now, given this + # node's actual state. Head vs autosave only shows up when no draft + # sits between them -- with a draft present, autosave is compared + # against the draft, never past it straight to head. + def available_layer_pairs + pairs = [] + pairs << [:head, :draft] if head && draft + pairs << [:draft, :autosave] if draft && autosave + pairs << [:head, :autosave] if head && autosave && !draft + pairs + end + def find_or_create_draft current_user self.wipe_draft! if draft && self.lock_owner == current_user diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 77b45f4c..c67ccb35 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -82,9 +82,9 @@
- -

Current Drafts (<%= @drafts_count %>)

- + +

Current Drafts & Autosaves (<%= @drafts_count %>)

+ @@ -92,6 +92,7 @@ + <% @drafts.each do |node| %> "> @@ -103,6 +104,9 @@ + <% end %>
IDActions Locked by Rev.Autosave
<%= link_to 'show', node_path(node) %> <%= link_to 'Revisions', node_revisions_path(node) %> + <% if pair = node.available_layer_pairs.last %> + <%= link_to 'diff', diff_node_revisions_path(node, start_revision: pair.first, end_revision: pair.last) %> + <% end %> <%= node.lock_owner.login if node.lock_owner %> @@ -110,6 +114,9 @@ <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> + <%= node.autosave ? "unsaved changes" : "" %> +
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index cdc9b36e..1c194101 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -6,9 +6,16 @@ disabled: @node.autosave.present? %> <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% if pair = @node.available_layer_pairs.find { |p| p.include?(:autosave) } %> + <%= button_to 'What changed?', + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> + <% end %> <% end %> <%= submit_tag "Save Draft", form: dom_id(@node, :edit) %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 036caf29..24693108 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -32,6 +32,16 @@ <% end %>
+ <% @node.available_layer_pairs.each do |pair| %> +
+ <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> +
+ <% end %> + <% unless locked_by_other %> <% if @node.draft && !@node.autosave %>
@@ -41,7 +51,7 @@ <% end %> <% if @node.draft || @node.autosave %>
- <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %>
diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d7bb528d..3157dcaa 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -4,11 +4,41 @@ <%= link_to 'Revisions', node_revisions_path(@node) %>

-<%= 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) %> - <%= submit_tag 'Diff' %> +

+ Comparing <%= describe_page_reference(params[:start_revision]) %> + against <%= describe_page_reference(params[:end_revision]) %> +

+ +<% 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) %> + <%= submit_tag 'Diff' %> + <% end %> +<% else %> +

<%= link_to 'Compare two numbered revisions instead', node_revisions_path(@node) %>

+<% end %> + +<% if @available_layer_pairs.present? %> +

+ <% @available_layer_pairs.each do |pair| %> + <% next if [params[:start_revision].to_s, params[:end_revision].to_s].sort == pair.map(&:to_s).sort %> + <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last, view: @diff_view }, + form: { class: 'button_to computation' } %> + <% end %> + + <% if !@locked_by_other && (@node.autosave || @node.draft) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> +

<% end %>
diff --git a/config/routes.rb b/config/routes.rb index c0aef2ff..da6b6266 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,6 +47,7 @@ Cccms::Application.routes.draw do resources :revisions do collection do post :diff + get :diff end member do put :restore diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9bbf29bc..d6005ba6 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -1,8 +1,17 @@ require 'test_helper' class AdminControllerTest < ActionController::TestCase - # Replace this with your real tests. - test "the truth" do - assert true + test "current drafts includes nodes with only an autosave" do + node = Node.root.children.create!(:slug => "admin_autosave_only") + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "in progress"}, User.find_by_login("aaron")) + node.save_draft!(User.find_by_login("aaron")) + node.publish_draft! + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "editing again"}, User.find_by_login("aaron")) + + login_as :quentin + get :index + assert_includes assigns(:drafts), node end end diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index caca6bfc..162e6f1c 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -100,4 +100,43 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select ".diff_column", 2 end + + test "diffing head against draft by name" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.update(:body => "draft body") + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + end + + test "diffing a layer pair that no longer exists redirects with a flash" do + login_as :quentin + post(:diff, params: { :node_id => @node.id, :start_revision => "draft", :end_revision => "autosave" }) + assert_redirected_to node_path(@node) + assert flash[:error].present? + end + + test "diffing by name shows a clear comparison label instead of a misleading revision picker" 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 "strong", "Head" + assert_select "strong", "Draft" + assert_select "select[name=?]", "start_revision", :count => 0 + end + + test "pair-switcher buttons carry their params as real hidden fields, not a query string" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.lock_for_editing!(@user) + @node.autosave!({ :body => "unsaved" }, @user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "form.computation input[type=hidden][name=start_revision]" + assert_select "form.computation input[type=hidden][name=end_revision]" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2138c19d..9e71dec5 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -473,4 +473,26 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! end end + + test "available_layer_pairs matches the six-state table" do + node = Node.root.children.create!(:slug => "layer_pairs_test") + user = @user1 || User.find_by_login("aaron") + + assert_equal [[:draft, :autosave]], (node.lock_for_editing!(user); node.autosave!({title: "v1"}, user); node.available_layer_pairs) # state F + + node.save_draft!(user) + node.publish_draft! + assert_equal [], node.available_layer_pairs # state A + + node.lock_for_editing!(user) + node.autosave!({title: "v2"}, user) + assert_equal [[:head, :autosave]], node.available_layer_pairs # state B + + node.save_draft!(user) + assert_equal [[:head, :draft]], node.available_layer_pairs # state C + + node.lock_for_editing!(user) + node.autosave!({title: "v3"}, user) + assert_equal [[:head, :draft], [:draft, :autosave]], node.available_layer_pairs # state D + end end -- cgit v1.3 From c2b2648d327e1c1749c37fe2e58cd051ed871547 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 02:13:02 +0200 Subject: Destroying Draft or Discarding Autosave drops you where you left --- app/controllers/nodes_controller.rb | 5 +++- app/views/nodes/show.html.erb | 1 + app/views/revisions/diff.html.erb | 1 + test/controllers/nodes_controller_test.rb | 39 +++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) (limited to 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index d1538e11..6fcd930c 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -128,7 +128,10 @@ class NodesController < ApplicationController def revert @node.lock_for_editing!(current_user) @node.revert!(current_user) - if @node.draft + + if params[:return_to].present? + redirect_to safe_return_to(params[:return_to]) + elsif @node.draft redirect_to edit_node_path(@node) else redirect_to node_path(@node) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 24693108..8b9e98b0 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -53,6 +53,7 @@
<%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, + params: { return_to: request.path }, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %>
<% end %> diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index 3157dcaa..490cf172 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -36,6 +36,7 @@ <% if !@locked_by_other && (@node.autosave || @node.draft) %> <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, + params: { return_to: request.fullpath }, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> <% end %>

diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index f3e04c27..ce3419cf 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -423,4 +423,43 @@ class NodesControllerTest < ActionController::TestCase assert_select "form.button_to.destructive", count: 0 end + test "reverting from nodes#show returns to the show page, not the editor, even if a draft remains" do + user = User.find_by_login("aaron") + node = Node.root.children.create!(:slug => "revert_return_to_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.lock_for_editing!(user) + node.autosave!({:title => "v3"}, user) + # state D: head, draft, and autosave all present, locked by aaron + + login_as :aaron + put :revert, params: { :id => node.id, :return_to => node_path(node) } + assert_redirected_to node_path(node) + node.reload + assert node.draft.present? + assert node.autosave.blank? + end + + test "reverting from nodes#edit without return_to still lands back in the editor when a draft remains" do + user = User.find_by_login("aaron") + node = Node.root.children.create!(:slug => "revert_default_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.lock_for_editing!(user) + node.autosave!({:title => "v3"}, user) + + login_as :aaron + put :revert, params: { :id => node.id } + assert_redirected_to edit_node_path(node) + 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 'app/views/nodes') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 8b9e98b0..5756649e 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 1bb6cf4c..e4bdac41 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 15aff3eff51809d1ce21caab406a0ef8b13624b6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 01:33:07 +0200 Subject: Do not offer to destroy the only draft of a never-published node --- app/views/nodes/show.html.erb | 2 +- test/controllers/nodes_controller_test.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) (limited to 'app/views/nodes') diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 5756649e..07cb5d13 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -49,7 +49,7 @@ form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %>
<% end %> - <% if @node.draft || @node.autosave %> + <% if @node.autosave || (@node.draft && @node.head) %>
<%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 37091d5a..b563d4da 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -463,4 +463,12 @@ class NodesControllerTest < ActionController::TestCase put :revert, params: { :id => node.id } assert_redirected_to edit_node_path(node) end + + test "nodes#show does not offer to destroy the only draft of a never-published node" do + node = Node.root.children.create!(:slug => "draft_only_test") + login_as :quentin + get :show, params: { :id => node.id } + assert_response :success + assert_select "form.destructive", :count => 0 + end end -- 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 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fcd930c..ede91ad8 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 9cb48407..24f3cd09 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 00000000..03f38b4c --- /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 00000000..939f19cd --- /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 00000000..6d0830c9 --- /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 00000000..fc5680a9 --- /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 00000000..36027750 --- /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 00000000..0ebc6ce6 --- /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 aebce90f..2c165d26 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 28b8494c..aa8b2886 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 b563d4da..05cb1953 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 56263848..15e908b2 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 'app/views/nodes') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index ede91ad8..772bf4b8 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 a89f8799..1268b63b 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 00000000..a799c179 --- /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 2c165d26..bb34bd80 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 b4204528..352dd3c9 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 aa8b2886..5c1e489f 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 05cb1953..b43d2def 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 5d91a885..5ab924fd 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 'app/views/nodes') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 435df57b..3c45c496 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 6cb04bd7..45b084f0 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 b20fe488..7a1ee7a3 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 e714c281..079346b2 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 dc5e8f97..44e5a791 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 68081d0f..223cb8ec 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 1c194101..13b78fca 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 afc632f9..bb7e078a 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 0e708458..f553334d 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 5c1e489f..da315352 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 848ce18303f927bfadaf0965a769cae2c62492a0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 23:43:02 +0200 Subject: Normalize action-button wording and add icons app-wide "New X" becomes "Create X" throughout (users, events, assets, menu items, nodes), matching the verb-first pattern the dashboard's own signposts already established, with a shared plus icon rather than a document-flavored one that only made sense next to "post". The "Destroy"/"destroy"/"Delete" family is normalized to "Destroy" everywhere, with a shared trash icon; occurrences#index and pages#index also pick up the destructive button class they'd been silently missing. Filter and Search convert from submit_tag to button_tag, the only way either can hold an icon alongside its label. Edit and the node editor's three dynamic labels (Continue Editing / Edit Draft / Lock + Edit) share one icon without touching their wording -- unlike Destroy's family, the state nuance in the text is real information, not just inconsistent phrasing. --- app/views/assets/index.html.erb | 8 ++++++-- app/views/assets/show.html.erb | 4 +++- app/views/events/edit.html.erb | 6 ++++-- app/views/events/index.html.erb | 8 ++++++-- app/views/events/show.html.erb | 8 ++++++-- app/views/events/without_node.html.erb | 4 +++- app/views/menu_items/index.html.erb | 11 +++++++---- app/views/nodes/_node_list.html.erb | 4 +++- app/views/nodes/chapters.html.erb | 6 ++++-- app/views/nodes/edit.html.erb | 8 +++++--- app/views/nodes/index.html.erb | 4 +++- app/views/nodes/show.html.erb | 15 ++++++++++----- app/views/occurrences/index.html.erb | 4 +++- app/views/pages/index.html.erb | 4 +++- app/views/revisions/diff.html.erb | 10 ++++++---- app/views/users/_user.html.erb | 7 ++++--- app/views/users/index.html.erb | 8 ++++++-- app/views/users/show.html.erb | 4 +++- test/controllers/users_controller_test.rb | 2 +- 19 files changed, 86 insertions(+), 39 deletions(-) (limited to 'app/views/nodes') diff --git a/app/views/assets/index.html.erb b/app/views/assets/index.html.erb index 6591091e..8c355617 100644 --- a/app/views/assets/index.html.erb +++ b/app/views/assets/index.html.erb @@ -1,6 +1,8 @@

Assets

-<%= link_to 'New asset', new_asset_path, class: 'action_button' %> +<%= link_to new_asset_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create asset +<% end %> <%= will_paginate @assets %> @@ -20,7 +22,9 @@ <%= asset.upload.content_type %> <%= link_to 'Show', asset %> <%= link_to 'Edit', edit_asset_path(asset) %> - <%= button_to 'Destroy', asset, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } %> + <%= button_to asset, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/assets/show.html.erb b/app/views/assets/show.html.erb index 02866780..5717dd99 100644 --- a/app/views/assets/show.html.erb +++ b/app/views/assets/show.html.erb @@ -6,7 +6,9 @@
- <%= link_to 'Edit', edit_asset_path(@asset), class: 'action_button' %> + <%= link_to edit_asset_path(@asset), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
<%= link_to 'Back', assets_path %> diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 45b084f0..b6564a4e 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -2,8 +2,10 @@
<%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> - <%= button_to 'Destroy', event_path(@event), method: :delete, - form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> + <%= button_to event_path(@event), method: :delete, + form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %>
<%= form_for(@event) do |f| %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 3d953d5f..dde897ee 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,7 +1,11 @@

Events

-<%= link_to 'New event', new_event_path, class: 'action_button' %> -<%= link_to 'View events without a page →', without_node_events_path %> +
+ <%= link_to new_event_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create event + <% end %> + <%= link_to 'View events without a page →', without_node_events_path %> +
<%= will_paginate @events %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 58122280..ac084c92 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -11,10 +11,14 @@
<% end %>
- <%= link_to 'Edit', edit_event_path(@event, return_to: request.path), class: 'action_button' %> + <%= link_to edit_event_path(@event, return_to: request.path), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
- <%= button_to 'Destroy', event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } %> + <%= button_to event_path(@event), method: :delete, form: { data: { confirm: 'Delete this event?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %>
diff --git a/app/views/events/without_node.html.erb b/app/views/events/without_node.html.erb index 52514ab9..bb27173b 100644 --- a/app/views/events/without_node.html.erb +++ b/app/views/events/without_node.html.erb @@ -1,7 +1,9 @@

Events without a node

<%= link_to '← All events', events_path %> -<%= link_to 'New event', new_event_path, class: 'action_button' %> +<%= link_to new_event_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create event +<% end %> <%= will_paginate @events %> diff --git a/app/views/menu_items/index.html.erb b/app/views/menu_items/index.html.erb index e84199b3..591eb68d 100644 --- a/app/views/menu_items/index.html.erb +++ b/app/views/menu_items/index.html.erb @@ -1,6 +1,8 @@

Menu Items

-<%= link_to 'New menu item', new_menu_item_path, class: 'action_button' %> +<%= link_to new_menu_item_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create menu item +<% end %> <% @menu_items.each do |menu_item| %> @@ -11,9 +13,10 @@ <% end %> diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb index 03f38b4c..f7210f12 100644 --- a/app/views/nodes/_node_list.html.erb +++ b/app/views/nodes/_node_list.html.erb @@ -4,7 +4,9 @@ <% 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" %> + <%= button_tag type: "submit", class: "action_button" do %> + <%= icon("search", library: "tabler", "aria-hidden": true) %> Search + <% end %> <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> <% end %> diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb index 939f19cd..543bc53c 100644 --- a/app/views/nodes/chapters.html.erb +++ b/app/views/nodes/chapters.html.erb @@ -1,9 +1,11 @@

Chapters

-<%= form_tag chapters_nodes_path, method: :get do %> +<%= form_tag chapters_nodes_path, method: :get, class: "node_search_form" do %> - <%= submit_tag "Filter", class: "action_button" %> + <%= button_tag type: "submit", class: "action_button" do %> + <%= icon("filter", library: "tabler", "aria-hidden": true) %> Filter + <% end %> <% end %> <%= render 'node_list' %> diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 13b78fca..1ac05e08 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -6,9 +6,11 @@ disabled: @node.autosave.present? %> <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), - revert_node_path(@node), method: :put, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <%= button_to revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %> <% if pair = @node.available_layer_pairs.find { |p| p.include?(:autosave) } %> <%= button_to 'What changed?', diff_node_revisions_path(@node), diff --git a/app/views/nodes/index.html.erb b/app/views/nodes/index.html.erb index 2688ea12..3bb251c5 100644 --- a/app/views/nodes/index.html.erb +++ b/app/views/nodes/index.html.erb @@ -1,7 +1,9 @@

Nodes

- <%= link_to 'New node', new_node_path, class: 'action_button' %> + <%= link_to new_node_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create node + <% end %> Creates a node with no parent or kind set — for a specific page, use the wizard's create flow or "add child" from an existing node instead.
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 07cb5d13..6e202582 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -25,7 +25,10 @@ <% 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" %> + <%= link_to edit_node_path(@node), class: "action_button" do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> + <%= @node.autosave ? "Continue Editing" : (@node.draft ? "Edit Draft" : "Lock + Edit") %> + <% end %> <% if !@node.draft && !@node.autosave %> Nothing pending — this will start a fresh draft. <% end %> @@ -51,10 +54,12 @@ <% end %> <% if @node.autosave || (@node.draft && @node.head) %>
- <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), - revert_node_path(@node), method: :put, - params: { return_to: request.path }, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <%= button_to revert_node_path(@node), method: :put, + params: { return_to: request.path }, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %>
<% end %> <% end %> diff --git a/app/views/occurrences/index.html.erb b/app/views/occurrences/index.html.erb index 82818d09..aa084ed9 100644 --- a/app/views/occurrences/index.html.erb +++ b/app/views/occurrences/index.html.erb @@ -18,7 +18,9 @@ - + <% end %> diff --git a/app/views/pages/index.html.erb b/app/views/pages/index.html.erb index 05c73117..da67871c 100644 --- a/app/views/pages/index.html.erb +++ b/app/views/pages/index.html.erb @@ -12,7 +12,9 @@ <%=h page.title %> <%= link_to 'Show', content_path(:page_path => page.node.unique_path) %> <%= link_to 'Edit', edit_page_path(page) %> - <%= button_to 'Destroy', page, method: :delete, form: { data: { confirm: 'Are you sure?' } } %> + <%= button_to page, method: :delete, form: { data: { confirm: 'Are you sure?' }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index b9ce6bdd..5638b7ab 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -49,10 +49,12 @@ <% end %> <% if !@locked_by_other && (@node.autosave || @node.draft) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), - revert_node_path(@node), method: :put, - params: { return_to: request.fullpath }, - form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <%= button_to revert_node_path(@node), method: :put, + params: { return_to: request.fullpath }, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= @node.draft && !@node.autosave ? "Destroy Draft" : "Discard Autosave" %> + <% end %> <% end %>

<% end %> diff --git a/app/views/users/_user.html.erb b/app/views/users/_user.html.erb index 798b82b3..3a88f5e2 100644 --- a/app/views/users/_user.html.erb +++ b/app/views/users/_user.html.erb @@ -7,9 +7,10 @@ <%= link_to "edit", edit_user_path(user) %> - <%= button_to "destroy", user_path(user), - method: :delete, - form: { data: { confirm: "Do you really want to delete user #{user.login}?" }, class: 'button_to destructive' } %> + <%= button_to user_path(user), method: :delete, + form: { data: { confirm: "Do you really want to destroy user #{user.login}?" }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> Destroy + <% end %> <% end %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 46e958bf..4f5266d8 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -1,5 +1,7 @@

Admins

-<%= link_to 'New admin user', new_user_path(admin: true), class: 'action_button' %> +<%= link_to new_user_path(admin: true), class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create admin user +<% end %> @@ -11,7 +13,9 @@
Login

Users

-<%= link_to 'New user', new_user_path, class: 'action_button' %> +<%= link_to new_user_path, class: 'action_button' do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create user +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index e744efeb..70881dbd 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -6,7 +6,9 @@
- <%= link_to 'Edit', edit_user_path(@user), class: 'action_button' %> + <%= link_to edit_user_path(@user), class: 'action_button' do %> + <%= icon("edit", library: "tabler", "aria-hidden": true) %> Edit + <% end %>
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 5cd5ad4d..58f8a86b 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -13,7 +13,7 @@ class UsersControllerTest < ActionController::TestCase login_as :aaron get :index assert_response :success - assert_select "button[type=submit]", "destroy" + assert_select "button[type=submit]", "Destroy" assert_select "a", "show" end -- 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 'app/views/nodes') diff --git a/app/views/admin/search_results.html.erb b/app/views/admin/search_results.html.erb index f8aece0b..a7571977 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 00d70750..61bb5c34 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 223cb8ec..89572939 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| %> -
Login
- - - - - - - - - - - - - - - - - - - - - -
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 bb7e078a..b63a6064 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 3c10303f..23952b73 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 c1cc456a..2044ab0c 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 65c7c40f74c315c1a39fb15da8ce341fb8b9b05e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 01:57:43 +0200 Subject: Wavy-underline: allowlist to default; destroy-button and search polish Flips the plain-link underline from an opt-in list (growing every time a new context needed it) to the default a rule, with opt-outs living as more specific overrides where something shouldn't look like plain content -- .action_button, the icon nav, the search dropdown's bold results. The allowlist's own history made the case for this: three of its entries were already hand-copied duplicates of the same five properties, and two of those had already silently drifted. form.button_to's icon+text buttons (Destroy, currently) get the same inline-flex/gap treatment .action_button already had -- they were never included in that fix, so they still baseline-aligned an SVG against text instead of centering it, plus an explicit icon size they'd never had either. Dashboard search results: tags render as a wrapped row of pills instead of one-per-line, for visual distinction from page results. The public-preview link and its Revoke button get their own row rather than loose inline flow, which wrapped unpredictably next to a long token URL -- aligned by box-top rather than center or baseline, since a