diff --git a/app/controllers/featured_stories_controller.rb b/app/controllers/featured_stories_controller.rb
new file mode 100644
index 000000000..2fe7d376a
--- /dev/null
+++ b/app/controllers/featured_stories_controller.rb
@@ -0,0 +1,15 @@
+class FeaturedStoriesController < ApplicationController
+ def index
+ authorize! Story, to: :reorder?
+ # Operate on the full featured set (published or not) so the drag-and-drop
+ # index lines up exactly with the positioning scope (positioned on: :featured).
+ @stories = Story.where(featured: true).order(:position).decorate
+ end
+
+ def update
+ story = Story.find(params[:id])
+ authorize! story, to: :reorder?
+ story.update!(position: params[:position])
+ head :no_content
+ end
+end
diff --git a/app/controllers/home/stories_controller.rb b/app/controllers/home/stories_controller.rb
index c9f44d33c..827e1b77a 100644
--- a/app/controllers/home/stories_controller.rb
+++ b/app/controllers/home/stories_controller.rb
@@ -5,7 +5,7 @@ class StoriesController < ApplicationController
def index
authorize! :home
@stories = authorized_scope(Story.published
- .order(:title), with: HomePolicy)
+ .order(:position), with: HomePolicy)
.decorate
render "home/stories/index"
diff --git a/app/models/story.rb b/app/models/story.rb
index 7c479dc68..f0fd3cbda 100644
--- a/app/models/story.rb
+++ b/app/models/story.rb
@@ -2,6 +2,12 @@ class Story < ApplicationRecord
include AuthorCreditable
include Featureable, Publishable, TagFilterable, Trendable, WindowsTypeFilterable, RichTextSearchable
+ # Featured stories are curated into a manual order (drag-and-drop) so the
+ # home page can control which stories surface first. Scoping on :featured
+ # keeps a contiguous sequence within the featured set, independent of the
+ # much larger pool of non-featured stories.
+ positioned on: :featured
+
has_rich_text :rhino_body
belongs_to :created_by, class_name: "User"
diff --git a/app/policies/story_policy.rb b/app/policies/story_policy.rb
index 960f6d4e2..4d53ffa12 100644
--- a/app/policies/story_policy.rb
+++ b/app/policies/story_policy.rb
@@ -9,6 +9,10 @@ def show?
admin? || record.publicly_visible? || (authenticated? && record.published?)
end
+ def reorder?
+ admin?
+ end
+
# Scoping
# See https://actionpolicy.evilmartians.io/#/scoping
#
diff --git a/app/views/featured_stories/_featured_story.html.erb b/app/views/featured_stories/_featured_story.html.erb
new file mode 100644
index 000000000..4d9f0e74c
--- /dev/null
+++ b/app/views/featured_stories/_featured_story.html.erb
@@ -0,0 +1,25 @@
+
+
+
+
+ <%= render "assets/display_image",
+ resource: story,
+ width: 16, height: 12,
+ link: false,
+ link_to_object: false,
+ file: story.display_image,
+ variant: :gallery %>
+
+
+
+
<%= story.title %>
+ <% unless story.published? %>
+
Not published — hidden from the home page
+ <% end %>
+
+
+ <%= link_to "Edit", edit_story_path(story), class: "btn btn-secondary-outline shrink-0" %>
+
diff --git a/app/views/featured_stories/index.html.erb b/app/views/featured_stories/index.html.erb
new file mode 100644
index 000000000..19dcc1fd9
--- /dev/null
+++ b/app/views/featured_stories/index.html.erb
@@ -0,0 +1,29 @@
+<% content_for(:page_bg_class, "public") %>
+<%= render "shared/public_welcome_banner" %>
+
+
+
+
+
Reorder featured stories
+
Drag the stories into the order they should appear in the highlighted stories section of the home page.
+
+
+
+ <%= link_to "Back to stories", stories_path, class: "btn btn-primary-outline" %>
+
+
+
+
+ <% if @stories.present? %>
+
"
+ data-testid="featured-story-list">
+ <%= render partial: "featured_story", collection: @stories, as: :story %>
+
+ <% else %>
+
No featured stories yet. Mark a story as featured to curate the home page order.
+ <% end %>
+
+
diff --git a/app/views/stories/index.html.erb b/app/views/stories/index.html.erb
index 6c17afc97..4cf2bce50 100644
--- a/app/views/stories/index.html.erb
+++ b/app/views/stories/index.html.erb
@@ -9,7 +9,12 @@
Check out stories from Windows Facilitators around the world and the healing they make possible through art
-
+
+ <% if allowed_to?(:reorder?, Story) %>
+ <%= link_to "Reorder featured",
+ featured_stories_path,
+ class: "admin-only bg-blue-100 btn btn-secondary-outline" %>
+ <% end %>
<% if allowed_to?(:new?, Story) %>
<%= link_to "New Story",
new_story_path,
diff --git a/config/routes.rb b/config/routes.rb
index 7eb89603c..2d86c3df3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -162,6 +162,7 @@
get "search/:model", to: "search#index"
resources :story_ideas
resources :stories
+ resources :featured_stories, only: [ :index, :update ]
resources :story_shares, only: [ :index, :show ]
resources :video_recordings
resources :user_forms
diff --git a/db/migrate/20260604143000_add_position_to_stories.rb b/db/migrate/20260604143000_add_position_to_stories.rb
new file mode 100644
index 000000000..04164ed58
--- /dev/null
+++ b/db/migrate/20260604143000_add_position_to_stories.rb
@@ -0,0 +1,24 @@
+class AddPositionToStories < ActiveRecord::Migration[8.1]
+ def up
+ add_column :stories, :position, :integer
+ add_index :stories, [ :featured, :position ]
+
+ # Backfill a contiguous 1..n sequence within each featured group so the
+ # positioning gem (scoped on :featured) has a valid starting state.
+ execute <<~SQL.squish
+ UPDATE stories s
+ JOIN (
+ SELECT id, ROW_NUMBER() OVER (PARTITION BY featured ORDER BY created_at, id) AS rn
+ FROM stories
+ ) ranked ON ranked.id = s.id
+ SET s.position = ranked.rn
+ SQL
+
+ change_column_null :stories, :position, false
+ end
+
+ def down
+ remove_index :stories, [ :featured, :position ], if_exists: true
+ remove_column :stories, :position, if_exists: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e0f387ba6..55738accf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_05_29_155200) do
+ActiveRecord::Schema[8.1].define(version: 2026_06_04_143000) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -906,6 +906,7 @@
t.boolean "featured", default: false, null: false
t.integer "organization_id"
t.boolean "permission_given"
+ t.integer "position", null: false
t.boolean "publicly_featured", default: false, null: false
t.boolean "publicly_visible", default: false, null: false
t.boolean "published", default: false, null: false
@@ -919,6 +920,7 @@
t.integer "workshop_id"
t.string "youtube_url"
t.index ["created_by_id"], name: "index_stories_on_created_by_id"
+ t.index ["featured", "position"], name: "index_stories_on_featured_and_position"
t.index ["organization_id"], name: "index_stories_on_organization_id"
t.index ["published"], name: "index_stories_on_published"
t.index ["spotlighted_facilitator_id"], name: "index_stories_on_spotlighted_facilitator_id"
diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb
index 36e94234e..3828c928f 100644
--- a/spec/models/story_spec.rb
+++ b/spec/models/story_spec.rb
@@ -139,4 +139,41 @@
expect(results).not_to include(published_story, draft_story, old_story)
end
end
+
+ describe "positioning of featured stories" do
+ it "assigns sequential positions within the featured set as stories are created" do
+ first = create(:story, :featured)
+ second = create(:story, :featured)
+
+ expect([ first.position, second.position ]).to eq([ 1, 2 ])
+ end
+
+ it "scopes positions to featured, so non-featured stories keep their own sequence" do
+ featured = create(:story, :featured)
+ non_featured = create(:story)
+
+ expect(featured.position).to eq(1)
+ expect(non_featured.position).to eq(1)
+ end
+
+ it "reorders the featured set when a position is assigned" do
+ first = create(:story, :featured)
+ second = create(:story, :featured)
+ third = create(:story, :featured)
+
+ third.update!(position: 1)
+
+ expect(first.reload.position).to eq(2)
+ expect(second.reload.position).to eq(3)
+ expect(third.reload.position).to eq(1)
+ end
+
+ it "orders the featured scope by position" do
+ first = create(:story, :featured, :published)
+ second = create(:story, :featured, :published)
+ second.update!(position: 1)
+
+ expect(Story.featured.order(:position)).to eq([ second, first ])
+ end
+ end
end
diff --git a/spec/requests/featured_stories_spec.rb b/spec/requests/featured_stories_spec.rb
new file mode 100644
index 000000000..ff2c83889
--- /dev/null
+++ b/spec/requests/featured_stories_spec.rb
@@ -0,0 +1,64 @@
+require "rails_helper"
+
+RSpec.describe "/featured_stories", type: :request do
+ let(:admin) { create(:user, :admin) }
+ let(:regular_user) { create(:user) }
+
+ let!(:first_story) { create(:story, :featured, :published, title: "First featured") }
+ let!(:second_story) { create(:story, :featured, :published, title: "Second featured") }
+
+ describe "GET /index" do
+ context "as an admin" do
+ before { sign_in admin }
+
+ it "renders the reorder page with featured stories" do
+ get featured_stories_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("First featured")
+ expect(response.body).to include("Second featured")
+ end
+ end
+
+ context "as a non-admin user" do
+ before { sign_in regular_user }
+
+ it "redirects away from the reorder page" do
+ get featured_stories_path
+ expect(response).to redirect_to(root_path)
+ end
+ end
+
+ context "as a guest" do
+ it "redirects to sign in" do
+ get featured_stories_path
+ expect(response).to redirect_to(new_user_session_path)
+ end
+ end
+ end
+
+ describe "PUT /update" do
+ context "as an admin" do
+ before { sign_in admin }
+
+ it "moves the story to the requested position" do
+ put featured_story_path(second_story), params: { position: 1 }, as: :json
+
+ expect(response).to have_http_status(:no_content)
+ expect(second_story.reload.position).to eq(1)
+ expect(first_story.reload.position).to eq(2)
+ end
+ end
+
+ context "as a non-admin user" do
+ before { sign_in regular_user }
+
+ it "is not authorized and leaves the order unchanged" do
+ put featured_story_path(second_story), params: { position: 1 }, as: :json
+
+ expect(response).to redirect_to(root_path)
+ expect(second_story.reload.position).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/requests/home/stories_spec.rb b/spec/requests/home/stories_spec.rb
new file mode 100644
index 000000000..100f70f4f
--- /dev/null
+++ b/spec/requests/home/stories_spec.rb
@@ -0,0 +1,21 @@
+require "rails_helper"
+
+RSpec.describe "/home/stories", type: :request do
+ let(:user) { create(:user) }
+
+ before { sign_in user }
+
+ describe "GET /home/stories" do
+ it "lists featured stories in their curated position order" do
+ first = create(:story, :featured, :published, title: "Alpha story")
+ second = create(:story, :featured, :published, title: "Bravo story")
+ # Curate Bravo ahead of Alpha despite alphabetical order.
+ second.update!(position: 1)
+
+ get home_stories_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body.index(second.title)).to be < response.body.index(first.title)
+ end
+ end
+end